Skip to content

Commit a0f6a99

Browse files
improve: tune frame wait and add startup threshold checks
Co-authored-by: Richie McIlroy <richiemcilroy@users.noreply.github.com>
1 parent e698d6a commit a0f6a99

6 files changed

Lines changed: 75 additions & 19 deletions

File tree

‎crates/editor/PLAYBACK-BENCHMARKS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This document tracks performance benchmarks for Cap's playback and decoding syst
1010
|--------|--------|-----------|
1111
| Decoder Init | <200ms | - |
1212
| Decode Latency (p95) | <50ms | - |
13+
| Startup to First Frame | <250ms | configurable |
1314
| Effective FPS | ≥30 fps | ±2 fps |
1415
| Decode Jitter | <10ms | - |
1516
| Scrub Seek Latency (p95) | <40ms | - |
@@ -42,6 +43,7 @@ This document tracks performance benchmarks for Cap's playback and decoding syst
4243
```bash
4344
# Run full playback validation on recordings from real-device-test-runner
4445
cargo run -p cap-recording --example playback-test-runner -- full
46+
cargo run -p cap-recording --example playback-test-runner -- full --startup-threshold-ms 250
4547

4648
# Run specific test categories
4749
cargo run -p cap-recording --example playback-test-runner -- decoder
@@ -118,9 +120,9 @@ cargo run -p cap-recording --example playback-test-runner -- scrub --fps 60 --be
118120
Automated helper for machine runs:
119121

120122
```bash
121-
node scripts/run-playback-benchmark-matrix.js --platform macos-13 --gpu apple-silicon --output-dir /tmp/playback-matrix --fps 60 --input-dir /tmp/cap-real-device-tests
122-
node scripts/run-playback-benchmark-matrix.js --platform windows-11 --gpu nvidia-discrete --output-dir /tmp/playback-matrix-windows-nvidia --fps 60 --require-formats mp4,fragmented --input-dir /tmp/cap-real-device-tests
123-
node scripts/run-playback-benchmark-matrix.js --platform windows-11 --gpu integrated --output-dir /tmp/playback-matrix-windows-integrated --fps 60 --scenarios scrub --input-dir /tmp/cap-real-device-tests
123+
node scripts/run-playback-benchmark-matrix.js --platform macos-13 --gpu apple-silicon --output-dir /tmp/playback-matrix --fps 60 --startup-threshold-ms 250 --input-dir /tmp/cap-real-device-tests
124+
node scripts/run-playback-benchmark-matrix.js --platform windows-11 --gpu nvidia-discrete --output-dir /tmp/playback-matrix-windows-nvidia --fps 60 --startup-threshold-ms 250 --require-formats mp4,fragmented --input-dir /tmp/cap-real-device-tests
125+
node scripts/run-playback-benchmark-matrix.js --platform windows-11 --gpu integrated --output-dir /tmp/playback-matrix-windows-integrated --fps 60 --startup-threshold-ms 250 --scenarios scrub --input-dir /tmp/cap-real-device-tests
124126
```
125127

126128
| Platform | GPU Class | MP4 Full | Fragmented Full | MP4 Scrub | Fragmented Scrub | Notes |

‎crates/editor/PLAYBACK-FINDINGS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,15 @@ cargo run -p cap-recording --example playback-test-runner -- full
171171
- Only the latest pending seek frame is sent while an async seek is in-flight.
172172
- Duplicate same-frame seeks are dropped in both frontend dispatch and playback seek signaling.
173173

174+
8. **Playback frame wait timeout now scales with target FPS (2026-02-13)**
175+
- Replaced fixed 200ms frame fetch waits with FPS-derived bounded timeout.
176+
- Reduces long stall windows on 60fps playback and improves real-time catch-up behavior.
177+
174178
8. **Playback benchmark runner now supports JSON evidence export (2026-02-13)**
175179
- `playback-test-runner` supports `--json-output` for structured report emission.
176180
- JSON output includes command metadata, system info, summary, and per-recording test detail.
177181
- Command metadata now includes input scope and output flags for reproducibility.
182+
- Startup-to-first-frame threshold is configurable with `--startup-threshold-ms` and tracked as pass/fail signal.
178183

179184
9. **Added JSON aggregate utility for cross-platform benchmark collation (2026-02-13)**
180185
- `scripts/aggregate-playback-benchmarks.js` builds a markdown table from multiple JSON outputs.
@@ -185,6 +190,7 @@ cargo run -p cap-recording --example playback-test-runner -- full
185190
- Automatically generates aggregate markdown for each machine run directory.
186191
- Performs per-machine post-run validation for required scenarios and optional format requirements.
187192
- Supports scenario subset reruns via `--scenarios` for faster targeted validation.
193+
- Supports startup threshold tuning via `--startup-threshold-ms`.
188194

189195
11. **Added matrix completeness validator (2026-02-13)**
190196
- `scripts/validate-playback-matrix.js` validates required platform/gpu/scenario cells.

‎crates/editor/PLAYBACK-MATRIX-RUNBOOK.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ node scripts/run-playback-benchmark-matrix.js \
4343
--gpu "<gpu-label>" \
4444
--output-dir "$OUT_DIR" \
4545
--fps 60 \
46+
--startup-threshold-ms 250 \
4647
--require-formats mp4,fragmented \
4748
--scenarios full,scrub \
4849
--input-dir "$INPUT_DIR"
@@ -51,7 +52,7 @@ node scripts/run-playback-benchmark-matrix.js \
5152
Equivalent shortcut:
5253

5354
```bash
54-
pnpm bench:playback:matrix -- --platform "<platform-label>" --gpu "<gpu-label>" --output-dir "$OUT_DIR" --fps 60 --require-formats mp4,fragmented --scenarios full,scrub --input-dir "$INPUT_DIR"
55+
pnpm bench:playback:matrix -- --platform "<platform-label>" --gpu "<gpu-label>" --output-dir "$OUT_DIR" --fps 60 --startup-threshold-ms 250 --require-formats mp4,fragmented --scenarios full,scrub --input-dir "$INPUT_DIR"
5556
```
5657

5758
Rerun only scrub scenario for a machine:

‎crates/editor/src/playback.rs‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,10 @@ impl Playback {
384384
.spawn();
385385

386386
let frame_duration = Duration::from_secs_f64(1.0 / fps_f64);
387+
let frame_fetch_timeout = frame_duration
388+
.mul_f64(4.0)
389+
.max(Duration::from_millis(20))
390+
.min(Duration::from_millis(80));
387391
let mut frame_number = self.start_frame_number;
388392
let mut prefetch_buffer: VecDeque<PrefetchedFrame> =
389393
VecDeque::with_capacity(PREFETCH_BUFFER_SIZE);
@@ -553,7 +557,7 @@ impl Playback {
553557

554558
if is_in_flight {
555559
let wait_start = Instant::now();
556-
let max_wait = Duration::from_millis(200);
560+
let max_wait = frame_fetch_timeout;
557561
let mut found_frame = None;
558562

559563
while wait_start.elapsed() < max_wait {
@@ -603,11 +607,8 @@ impl Playback {
603607
} else if prefetch_buffer.is_empty() && total_frames_rendered < 15 {
604608
let _ = frame_request_tx.send(frame_number);
605609

606-
let wait_result = tokio::time::timeout(
607-
Duration::from_millis(200),
608-
prefetch_rx.recv(),
609-
)
610-
.await;
610+
let wait_result =
611+
tokio::time::timeout(frame_fetch_timeout, prefetch_rx.recv()).await;
611612

612613
if let Ok(Some(prefetched)) = wait_result {
613614
if prefetched.frame_number == frame_number {
@@ -651,7 +652,7 @@ impl Playback {
651652
guard.insert(frame_number);
652653
}
653654

654-
let max_wait = Duration::from_millis(200);
655+
let max_wait = frame_fetch_timeout;
655656
let data = tokio::select! {
656657
_ = stop_rx.changed() => {
657658
if let Ok(mut guard) = main_in_flight.write() {

‎crates/recording/examples/playback-test-runner.rs‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ struct Cli {
3939
#[arg(long, global = true, default_value = "30")]
4040
fps: u32,
4141

42+
#[arg(long, global = true, default_value_t = STARTUP_TO_FIRST_FRAME_WARNING_MS)]
43+
startup_threshold_ms: f64,
44+
4245
#[arg(long, global = true)]
4346
verbose: bool,
4447

@@ -66,6 +69,7 @@ enum Commands {
6669
const FPS_TOLERANCE: f64 = 2.0;
6770
const DECODE_LATENCY_WARNING_MS: f64 = 50.0;
6871
const SCRUB_SEEK_WARNING_MS: f64 = 40.0;
72+
const STARTUP_TO_FIRST_FRAME_WARNING_MS: f64 = 250.0;
6973
const AUDIO_VIDEO_SYNC_TOLERANCE_MS: f64 = 100.0;
7074
const CAMERA_SYNC_TOLERANCE_MS: f64 = 100.0;
7175

@@ -101,6 +105,8 @@ struct PlaybackTestResult {
101105
fps_ok: bool,
102106
jitter_ms: f64,
103107
decode_latency_ok: bool,
108+
startup_latency_ok: bool,
109+
startup_threshold_ms: f64,
104110
errors: Vec<String>,
105111
}
106112

@@ -243,6 +249,12 @@ impl RecordingTestReport {
243249
if !result.decode_latency_ok {
244250
println!(" WARN: Decode latency exceeds {DECODE_LATENCY_WARNING_MS}ms!");
245251
}
252+
if !result.startup_latency_ok {
253+
println!(
254+
" WARN: Startup-to-first-frame exceeds {:.1}ms!",
255+
result.startup_threshold_ms
256+
);
257+
}
246258
for err in &result.errors {
247259
println!(" ERROR: {err}");
248260
}
@@ -390,12 +402,14 @@ async fn test_playback(
390402
meta: &StudioRecordingMeta,
391403
segment_index: usize,
392404
fps: u32,
405+
startup_threshold_ms: f64,
393406
verbose: bool,
394407
) -> PlaybackTestResult {
395408
let playback_start = Instant::now();
396409
let mut result = PlaybackTestResult {
397410
segment_index,
398411
expected_fps: fps as f64,
412+
startup_threshold_ms,
399413
..Default::default()
400414
};
401415

@@ -496,9 +510,11 @@ async fn test_playback(
496510
result.fps_ok = (result.effective_fps - result.expected_fps).abs() <= FPS_TOLERANCE
497511
|| result.effective_fps >= result.expected_fps;
498512
result.decode_latency_ok = result.p95_decode_time_ms <= DECODE_LATENCY_WARNING_MS;
513+
result.startup_latency_ok = result.startup_to_first_frame_ms <= startup_threshold_ms;
499514

500515
result.passed = result.fps_ok
501516
&& result.decode_latency_ok
517+
&& result.startup_latency_ok
502518
&& result.failed_frames == 0
503519
&& result.decoded_frames > 0;
504520

@@ -880,6 +896,7 @@ fn discover_recordings(input_dir: &Path) -> Vec<PathBuf> {
880896
async fn run_tests_on_recording(
881897
recording_path: &Path,
882898
fps: u32,
899+
startup_threshold_ms: f64,
883900
run_decoder: bool,
884901
run_playback: bool,
885902
run_scrub: bool,
@@ -969,8 +986,15 @@ async fn run_tests_on_recording(
969986
if verbose {
970987
println!(" Testing playback for segment {segment_idx}...");
971988
}
972-
let playback_result =
973-
test_playback(&meta, studio_meta.as_ref(), segment_idx, fps, verbose).await;
989+
let playback_result = test_playback(
990+
&meta,
991+
studio_meta.as_ref(),
992+
segment_idx,
993+
fps,
994+
startup_threshold_ms,
995+
verbose,
996+
)
997+
.await;
974998
report.playback_results.push(playback_result);
975999
}
9761000

@@ -1082,6 +1106,13 @@ fn get_failure_tags(report: &RecordingTestReport) -> Vec<String> {
10821106
if report.playback_results.iter().any(|r| !r.decode_latency_ok) {
10831107
tags.push("LATENCY".to_string());
10841108
}
1109+
if report
1110+
.playback_results
1111+
.iter()
1112+
.any(|r| !r.startup_latency_ok)
1113+
{
1114+
tags.push("STARTUP".to_string());
1115+
}
10851116
if report.scrub_results.iter().any(|r| !r.seek_latency_ok) {
10861117
tags.push("SCRUB_LATENCY".to_string());
10871118
}
@@ -1191,7 +1222,7 @@ fn report_to_markdown(report: &RecordingTestReport) -> String {
11911222
));
11921223
md.push_str(&format!(
11931224
"| ↳ Startup | {} | first_decode={:.1}ms startup_to_first={:.1}ms |\n",
1194-
if result.startup_to_first_frame_ms > 0.0 {
1225+
if result.startup_latency_ok {
11951226
"✅"
11961227
} else {
11971228
"❌"
@@ -1505,9 +1536,10 @@ fn shell_quote(value: &str) -> String {
15051536

15061537
fn build_command_string(cli: &Cli) -> String {
15071538
let mut command = format!(
1508-
"cargo run -p cap-recording --example playback-test-runner -- {} --fps {}",
1539+
"cargo run -p cap-recording --example playback-test-runner -- {} --fps {} --startup-threshold-ms {:.1}",
15091540
command_name(cli.command.as_ref()),
1510-
cli.fps
1541+
cli.fps,
1542+
cli.startup_threshold_ms
15111543
);
15121544

15131545
if let Some(path) = &cli.recording_path {
@@ -1598,9 +1630,10 @@ async fn main() -> anyhow::Result<()> {
15981630
println!("\nCap Playback Test Runner");
15991631
println!("{}", "=".repeat(40));
16001632
println!(
1601-
"Testing {} recording(s) at {} FPS",
1633+
"Testing {} recording(s) at {} FPS (startup threshold: {:.1}ms)",
16021634
recordings.len(),
1603-
cli.fps
1635+
cli.fps,
1636+
cli.startup_threshold_ms
16041637
);
16051638
println!();
16061639

@@ -1612,6 +1645,7 @@ async fn main() -> anyhow::Result<()> {
16121645
match run_tests_on_recording(
16131646
recording_path,
16141647
cli.fps,
1648+
cli.startup_threshold_ms,
16151649
run_decoder,
16161650
run_playback,
16171651
run_scrub,

‎scripts/run-playback-benchmark-matrix.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ function parseArgs(argv) {
1010
gpu: null,
1111
outputDir: null,
1212
fps: 60,
13+
startupThresholdMs: 250,
1314
recordingPath: null,
1415
inputDir: null,
1516
validate: true,
@@ -46,6 +47,14 @@ function parseArgs(argv) {
4647
options.fps = value;
4748
continue;
4849
}
50+
if (arg === "--startup-threshold-ms") {
51+
const value = Number.parseFloat(argv[++i] ?? "");
52+
if (!Number.isFinite(value) || value <= 0) {
53+
throw new Error("Invalid --startup-threshold-ms value");
54+
}
55+
options.startupThresholdMs = value;
56+
continue;
57+
}
4958
if (arg === "--recording-path") {
5059
options.recordingPath = argv[++i] ?? null;
5160
continue;
@@ -85,7 +94,7 @@ function parseArgs(argv) {
8594
}
8695

8796
function usage() {
88-
console.log(`Usage: node scripts/run-playback-benchmark-matrix.js --platform <name> --gpu <name> --output-dir <dir> [--fps 60] [--recording-path <path>] [--input-dir <path>] [--require-formats mp4,fragmented] [--scenarios full,scrub]
97+
console.log(`Usage: node scripts/run-playback-benchmark-matrix.js --platform <name> --gpu <name> --output-dir <dir> [--fps 60] [--startup-threshold-ms 250] [--recording-path <path>] [--input-dir <path>] [--require-formats mp4,fragmented] [--scenarios full,scrub]
8998
9099
Runs playback benchmark matrix scenarios and writes JSON outputs.
91100
@@ -96,6 +105,7 @@ Required:
96105
97106
Optional:
98107
--fps FPS for benchmark runs (default: 60)
108+
--startup-threshold-ms Startup-to-first-frame threshold in ms (default: 250)
99109
--recording-path Specific recording path
100110
--input-dir Recording discovery directory
101111
--require-formats Required formats for local validation (comma-separated)
@@ -134,6 +144,8 @@ function scenarioArgs(options, scenario) {
134144
scenario,
135145
"--fps",
136146
String(options.fps),
147+
"--startup-threshold-ms",
148+
String(options.startupThresholdMs),
137149
"--json-output",
138150
jsonOutput,
139151
"--notes",

0 commit comments

Comments
 (0)