Skip to content

Commit

Permalink
Add control on race dashboard to set length of instant replay clip.
Browse files Browse the repository at this point in the history
Express replay length in (integer) milliseconds rather than floating-point seconds.
Express replay playback rate as an (integer) percentage rather than floating-point multiplier.

Update doc on replay protocol.
  • Loading branch information
jeffpiazza committed Nov 15, 2024
1 parent ece3d37 commit 6501e8e
Show file tree
Hide file tree
Showing 7 changed files with 431 additions and 302 deletions.
571 changes: 329 additions & 242 deletions docs/Developers- Replay Messages.fodt

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions testing/test-basic-racing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ run_heat 1 1 101:3.3294 121:3.4179 141:3.8182 111:2.2401

curl_postj action.php "action=replay.message" | \
jq -e '.replay == ["CANCEL", "START Lions & Tigers_Round1_Heat01",
"RACE_STARTS 4 2 0.5",
"REPLAY 4 2 0.5",
"RACE_STARTS 4000 2 50",
"REPLAY 4000 2 50",
"CANCEL",
"START Lions & Tigers_Round1_Heat02"]' \
>/dev/null || test_fails
Expand Down
60 changes: 35 additions & 25 deletions website/coordinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require_permission(SET_UP_PERMISSION); // TODO: What's the correct permission?
$warn_no_timer = warn_no_timer();

?><!DOCTYPE html>
<html>
<head>
Expand All @@ -28,7 +29,19 @@
<script type="text/javascript" src="js/coordinator-poll.js"></script>
<script type="text/javascript" src="js/timer-alive.js"></script>
<script type="text/javascript">
var g_use_subgroups = <?php echo use_subgroups() ? "true" : "false"; ?>;
var g_use_subgroups = <?php echo use_subgroups() ? "true" : "false"; ?>;

$(function() {
$("#replay-skipback option[value='<?php
echo read_raceinfo('replay-skipback', '3000'); ?>']").attr('selected', 'selected');
mobile_select_refresh('#replay-skipback');
$("#replay-num-showings option[value='<?php
echo read_raceinfo('replay-num-showings', '2'); ?>']").attr('selected', 'selected');
mobile_select_refresh('#replay-num-showings');
$("#replay-rate option[value='<?php
echo read_raceinfo('replay-rate', '50'); ?>']").attr('selected', 'selected');
mobile_select_refresh('#replay-rate');
});
</script>
<style>

Expand Down Expand Up @@ -195,40 +208,37 @@
<div id='replay_settings_modal' class="modal_dialog hidden block_buttons">
<form>
<input type="hidden" name="action" value="settings.write"/>
<?php /*
<label for="replay-skipback">Duration of replay, in seconds:</label>
<!-- Could be any decimal value... -->
<!-- TODO MacReplay only accepts integral skipback values presently -->
<!-- TODO When displaying this modal, should read the current settings and populate controls accordingly. -->
<!-- Value in milliseconds, must match as a string -->
<select id="replay-skipback" name="replay-skipback">
<option value="2">2.0</option>
<!-- <option>2.5</option> -->
<option value="3">3.0</option>
<!-- <option>3.5</option> -->
<option selected="selected" value="4">4.0</option>
<!-- <option>4.5</option> -->
<option value="5">5.0</option>
<!-- <option>5.5</option> -->
<option value="6">6.0</option>
<!-- <option>6.5</option> -->
<!-- <option value="2000">2.0</option> -->
<option value="2500">2.5</option>
<option value="3000">3.0</option>
<option value="3500">3.5</option>
<option value="4000">4.0</option>
<option value="4500">4.5</option>
<option value="5000">5.0</option>
<!-- <option value="5500">5.5</option> -->
<option value="6000">6.0</option>
<!-- <option value="6500">6.5</option> -->
</select>
*/ ?>

<label for="replay-num-showings">Number of times to show replay:</label>
<!-- Could be any positive integer -->
<select id="replay-num-showings" name="replay-num-showings">
<option>1</option>
<option selected="selected">2</option>
<option>3</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>

<label for="replay_rate">Replay playback speed:</label>
<!-- Could be any decimal value -->
<!-- Expressed as a percentage -->
<select id="replay-rate" name="replay-rate">
<option value="0.10">0.1x</option>
<option value="0.25">0.25x</option>
<option value="0.50">0.5x</option>
<option selected="selected" value="0.75">0.75x</option>
<option value="1.00">1x</option>
<option value="10">0.1x</option>
<option value="25">0.25x</option>
<option value="50">0.5x</option>
<option value="75">0.75x</option>
<option value="100">1x</option>
</select>
<input type="submit" value="Submit"/>
<input type="button" value="Cancel"
Expand Down
12 changes: 6 additions & 6 deletions website/inc/replay.inc
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ function drain_queue() {

function send_replay_TEST() {
// test <skipback_seconds> <repeat> <rate>
$skipback = read_raceinfo('replay-skipback', '4');
$skipback = read_raceinfo('replay-skipback', '4000');
$num_showings = read_raceinfo('replay-num-showings', '2');
$rate = read_raceinfo('replay-rate', '0.5');
$rate = read_raceinfo('replay-rate', '50');
send_replay_message("TEST ".$skipback." ".$num_showings." ".$rate);
}

Expand All @@ -162,9 +162,9 @@ function send_replay_START(&$current) {

// "REPLAY skipback showings rate -- stop recording if recording; playback\r\n"
function send_replay_REPLAY() {
$skipback = read_raceinfo('replay-skipback', '4');
$skipback = read_raceinfo('replay-skipback', '4000');
$num_showings = read_raceinfo('replay-num-showings', '2');
$rate = read_raceinfo('replay-rate', '0.5');
$rate = read_raceinfo('replay-rate', '50');
send_replay_message("REPLAY ".$skipback." ".$num_showings." ".$rate);
}

Expand All @@ -175,9 +175,9 @@ function send_replay_CANCEL() {

// RACE_STARTS skipback showings rate -- induce a replay in replay-skipback less epsilon
function send_replay_RACE_STARTS() {
$skipback = read_raceinfo('replay-skipback', '4');
$skipback = read_raceinfo('replay-skipback', '4000');
$num_showings = read_raceinfo('replay-num-showings', '2');
$rate = read_raceinfo('replay-rate', '0.5');
$rate = read_raceinfo('replay-rate', '50');
send_replay_message("RACE_STARTS $skipback $num_showings $rate");
}

Expand Down
65 changes: 48 additions & 17 deletions website/js/circular-frame-buffer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

function CircularFrameBuffer(stream, no_seconds) {
function CircularFrameBuffer(stream, length_ms) {
console.log('CFB: length_ms = ', length_ms);
// We expect a video refresh rate of 60 frames per second
const k_refresh_fps = 60;
let debugging = false;
Expand All @@ -10,13 +11,20 @@ function CircularFrameBuffer(stream, no_seconds) {

let resizing_callback = false;
this.on_resize = function(cb) { resizing_callback = cb; }

// Takes effect for the next recording, not the current one
this.set_recording_length = function(number_of_milliseconds) {
length_ms = number_of_milliseconds; }

let offscreen_video = document.createElement('video');
// For a remote stream, the width and height may initially not be set, and
// will require fixing up in the catch clause in recording_playback.
let stream_settings = stream.getVideoTracks()[0].getSettings();
console.log('CircularFrameBuffer: stream reports w,h', stream_settings.width, stream_settings.height);
offscreen_video.width = stream_settings.width;
offscreen_video.height = stream_settings.height;
// Without this, iOS produces NotAllowedError at the call to .play()
offscreen_video.playsInline = true;

let offscreen_canvas = document.createElement('canvas');
offscreen_canvas.width = offscreen_video.width;
Expand All @@ -34,7 +42,10 @@ function CircularFrameBuffer(stream, no_seconds) {
let recording = false;

offscreen_video.srcObject = stream;
offscreen_video.play();

offscreen_video.play().then(
function () { console.log('Video play() started'); },
function(reason) { console.log('Video element play() rejected:', reason); });

let fps_div = $("#fps");
let last_ts = 0;
Expand All @@ -49,6 +60,8 @@ function CircularFrameBuffer(stream, no_seconds) {
}
}

// This is the callback function passed to window.requestAnimationFrame on a
// repaint.
function recording_callback(ts) {
// ts is a double giving time in milliseconds
//
Expand All @@ -60,8 +73,10 @@ function CircularFrameBuffer(stream, no_seconds) {
return;
}

// Only capture at 30fps
// Only capture at 30fps (33.3ms per frame)
if (last_recorded_frame_index < 0 || ts - frame_times[last_recorded_frame_index] >= 33) {
// Using the offscreen <video> element as an HTMLVideoSource, capture an
// image of the current frame into the offscreen <canvas> element.
offscreen_context.drawImage(offscreen_video, 0, 0);

if (debugging) {
Expand Down Expand Up @@ -92,19 +107,30 @@ function CircularFrameBuffer(stream, no_seconds) {
}
}

frames[frame_index] =
offscreen_context.getImageData(0, 0,
offscreen_canvas.width,
offscreen_canvas.height);
frame_times[frame_index] = ts;
last_recorded_frame_index = frame_index;
if (offscreen_canvas.width <= 0 || offscreen_canvas.height <= 0) {
console.log('Skipping frame ' + frame_index + ' because offscreen canvas is ' +
offscreen_canvas.width + 'x' + offscreen_canvas.height);
} else {
// With this statement removed completely, iOS Safari stops complaining.
// With just the getImageData call, also no complaint but a red "low
// fps" marker.
//
// Capture an ImageData object from the <canvas>
frames[frame_index] =
offscreen_context.getImageData(0, 0,
offscreen_canvas.width,
offscreen_canvas.height);
frame_times[frame_index] = ts;
last_recorded_frame_index = frame_index;
}
} catch(error) {
console.log("CFB " + now_msg + " caught error " + error.message +
" at frame_index " + frame_index);
// For a remote stream, the width and height may not have been known
// initially, and require fixing up here.
}

// frame_index may wrap around to 0
frame_index = (frame_index + 1) % frames.length;
}

Expand All @@ -116,13 +142,14 @@ function CircularFrameBuffer(stream, no_seconds) {
this_cfb.stop_recording();
offscreen_context = null;
offscreen_canvas.remove();
// TODO offscreen_video.remove() ?
}
}
}

this.start_recording = function() {
frames = Array(no_seconds * k_refresh_fps);
frame_times = Array(no_seconds * 60);
frames = Array(Math.ceil(length_ms / 1000 * k_refresh_fps));
frame_times = Array(Math.ceil(length_ms / 1000 * 60));
frame_index = 0;
last_recorded_frame_index = -1;
recording = true;
Expand All @@ -139,9 +166,11 @@ function CircularFrameBuffer(stream, no_seconds) {

// canvas -- a DOM <canvas> element
// repeat -- number of times to play back the video
// playback_rate -- multiplier for playback (0.5 = half speed slow-motion)
// on_precanvas -- callback invoked on the offscreen <canvas> element rendering each frame
// on_playback_finished -- callback invoked when a playback finishes (may be called repeat times)
// playback_rate -- percentage multiplier for playback (50 = half speed slow-motion)
// on_precanvas -- callback invoked (once) on the offscreen <canvas> element that
// will be used to render each frame
// on_playback_finished -- callback invoked when a playback finishes
// (may be called repeat times)
// on_done -- callback to be invoked when playback completes.
this.playback = function(canvas, repeat, playback_rate,
on_precanvas, on_playback_finished, on_done) {
Expand Down Expand Up @@ -179,9 +208,9 @@ function CircularFrameBuffer(stream, no_seconds) {
}

// frame_times[last_recorded_frame_index] is the time of the last captured frame
// frame_times[last_recorded_frame_index] - no_seconds * 1000 is the time of the
// frame_times[last_recorded_frame_index] - length_ms is the time of the
// first frame for playback
let start_goal = frame_times[last_recorded_frame_index] - no_seconds * 1000;
let start_goal = frame_times[last_recorded_frame_index] - Math.round(length_ms);
console.log("Playback from " + now_msg + ": repeat=" + repeat +
", playback_rate=" + playback_rate);
console.log("last_recorded_frame_index = " + last_recorded_frame_index);
Expand Down Expand Up @@ -215,10 +244,12 @@ function CircularFrameBuffer(stream, no_seconds) {
requestAnimationFrame(playback_callback);
}

// Callback to window.requestAnimationFrame that will draw the next frame
// being played back.
function playback_callback(ts) {
report_fps(ts, "play ");

let goal_frame_time = start_goal + (ts - playback_start_time) * playback_rate;
let goal_frame_time = start_goal + (ts - playback_start_time) * playback_rate / 100;
let found = false;
for (let tries = 0; tries < frames.length; ++tries) {
if (frame_times[pindex] >= goal_frame_time) {
Expand Down
2 changes: 1 addition & 1 deletion website/js/dashboard-ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ $(document).ajaxSuccess(function(event, xhr, options, data) {
return;
}
}
var fail = data.documentElement.getElementsByTagName("failure");
var fail = data.documentElement && data.documentElement.getElementsByTagName("failure");
if (fail && fail.length > 0) {
console.log(data);
alert("Action failed: " + fail[0].textContent);
Expand Down
19 changes: 10 additions & 9 deletions website/replay.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
<script type="text/javascript">

g_websocket_url = <?php echo json_encode(read_raceinfo('_websocket_url', '')); ?>;
g_ws_trigger_port = <?php echo json_encode(read_raceinfo('_ws_trigger_port', '')); ?>;
// If using websockets to listen for replay messages, this variable will hold
// the websocket object.
var g_trigger_websocket;
Expand Down Expand Up @@ -96,11 +95,11 @@ function poll_once_for_replay() {
}

function listen_for_replay_messages() {
if (g_ws_trigger_port != "") {
if (g_websocket_url != "") {
g_trigger_websocket = new MessagePoller(make_id_string('replay-'),
function(msg) { handle_replay_message(msg.cmd); });
// Even though we're using the websocket for triggering, poll every 5s (as
// opposed to several times per second). so we get credit for being
// opposed to several times per second), so we get credit for being
// connected.
setInterval(poll_once_for_replay, 5000);
} else {
Expand All @@ -123,14 +122,17 @@ function(msg) { handle_replay_message(msg.cmd); });

var g_replay_options = {
count: 2,
rate: 0.5,
length: 4
rate: 50, // expressed as a percentage
length: 4000 // ms
};

function parse_replay_options(cmdline) {
g_replay_options.length = parseInt(cmdline.split(" ")[1]);
if (g_recorder) {
g_recorder.set_recording_length(g_replay_options.length);
}
g_replay_options.count = parseInt(cmdline.split(" ")[2]);
g_replay_options.rate = parseFloat(cmdline.split(" ")[3]);
// TODO .length
g_replay_options.rate = parseInt(cmdline.split(" ")[3]);
}

// If non-zero, holds the timeout ID of a pending timeout that will trigger a
Expand All @@ -153,7 +155,6 @@ function handle_replay_message(cmdline) {
g_preempted = false;
} else if (cmdline.startsWith("REPLAY")) {
// REPLAY skipback showings rate
// skipback and rate are ignored, but showings we can honor
// (Must be exactly one space between fields:)
parse_replay_options(cmdline);
if (!g_preempted) {
Expand All @@ -173,7 +174,7 @@ function() {
console.log('Triggering replay from timeout after RACE_STARTS', root);
on_replay(root);
},
g_replay_options.length * 1000 - g_replay_timeout_epsilon);
g_replay_options.length - g_replay_timeout_epsilon);
} else {
console.log("Unrecognized replay message: " + cmdline);
}
Expand Down

0 comments on commit 6501e8e

Please sign in to comment.