diff --git a/spikes/mpv-frame-copy/README.md b/spikes/mpv-frame-copy/README.md index 8f320ff34..c0c325e1a 100644 --- a/spikes/mpv-frame-copy/README.md +++ b/spikes/mpv-frame-copy/README.md @@ -73,12 +73,23 @@ doc's worst-case budget (33 MB memcpy ≈ 1.2 ms, not 6–8 ms; end-to-end added latency ≈ 10 ms, not 40–60 ms). The remaining open gates are weaker hardware and long-run pacing, not raw throughput on modern Macs. -## Still to measure (go/no-go gates from the analysis doc) +## Gate status (details in RESULTS.md) -- Intel Mac and mid-range Windows laptop (iGPU) — the actual risk hardware. -- Long-run frame-time variance (judder), not average fps; 50 Hz content on a - 60 Hz display. -- 4K HDR (tonemapping to SDR before readback). +Measured on M1 Pro (2026-07-10): + +- ✅ Throughput: 4K60 HEVC hwdec sustained end to end with ~10 ms added + latency and single-digit-ms copies. +- ✅ Frame pacing: viewer STATS now report per-interval stddev/p99/late + counts (`present iv` / `src iv`, `LONGRUN` cumulative line every 30 s). + 50 fps and 25 fps cadences are clean; residual jitter is 120 Hz rAF grid + quantization (≤ one display tick), not lost frames. +- ✅ HDR: 4K25 HDR10 (PQ/BT.2020) tonemapped to SDR by mpv before readback + at full rate with unchanged copy costs. + +Still open: + +- Intel Mac and mid-range Windows laptop (iGPU) — the actual risk hardware + (pending device; Windows also needs the helper port). - End-to-end latency flash test (photodiode/screen-capture method) and audio/video sync offset to calibrate `--audio-delay`. - Battery drain delta vs the native-surface approach. diff --git a/spikes/mpv-frame-copy/RESULTS.md b/spikes/mpv-frame-copy/RESULTS.md index 8ce98ccaf..0c2250911 100644 --- a/spikes/mpv-frame-copy/RESULTS.md +++ b/spikes/mpv-frame-copy/RESULTS.md @@ -38,6 +38,35 @@ Source: commit `7e39d2e5`, libmpv 2.3.0 (Homebrew mpv 0.39.0), Electron 41.7.2. Helper-side PBO map+copy at 4K: 1.0–1.8 ms avg. Only fps dip observed was at the `--loop` file restart (decoder reinit), not in the copy path. +### Pacing / judder and HDR (2026-07-10, same machine, viewer with interval instrumentation) + +Metrics: *present iv sd* — stddev of intervals between texture uploads +(viewer clock, rAF-quantized); *src iv sd* — stddev of intervals between +produced frames (helper clock); *late1.5x* — present intervals > 1.5× the +producer's median interval (a missed beat). + +| Scenario | New fps | present iv sd | src iv sd | late (steady state) | Notes | +| --- | --- | --- | --- | --- | --- | +| 4K60 HEVC hwdec (steady state) | 60.0 | 0.5–1.4 ms | 0.7–1.2 ms | 0–1 per 2 s | worst intervals only at `--loop` restart | +| 1080p50 testsrc2 (50→120 Hz cadence) | 50.0 | ~4.1 ms | ~1.9 ms | 0; LONGRUN 30 s: 0.13 % (startup only) | sd is 120 Hz rAF grid quantization (16.7/25 ms alternation around 20 ms), not lost frames | +| 1080p25 testsrc2 | 25.0 | ~4.1 ms | ~2.7 ms | 0 | same grid effect | +| 4K25 HDR10 PQ/BT.2020 HEVC hwdec | 25.0 | ~5.5 ms | ~4.3 ms | 0 | mpv tonemaps to SDR before readback (PIXELPROBE spread 255); copy/upload costs unchanged | + +Cadence verdict on this hardware: the producer keeps a clean source cadence +(mpv's own pacing survives); the only jitter is display-grid quantization in +the rAF presenter, bounded by one 120 Hz tick (~8.3 ms). A future refinement +could reduce it with display-rate matching, but nothing here blocks the gate. + +HDR clip generation for repro: + +```bash +ffmpeg -y -f lavfi -i "testsrc2=size=3840x2160:rate=25" -t 10 \ + -c:v hevc_videotoolbox -profile:v main10 -pix_fmt p010le -b:v 30M -tag:v hvc1 /tmp/spike-4k-hdr.mp4 +ffmpeg -y -i /tmp/spike-4k-hdr.mp4 -c:v copy \ + -bsf:v "hevc_metadata=colour_primaries=9:transfer_characteristics=16:matrix_coefficients=9" \ + /tmp/spike-4k-hdr10.mp4 # videotoolbox omits VUI color tags; inject HDR10 ones +``` + Reference worst-case budget from the analysis doc, for orientation: 33 MB 4K memcpy est. 6–8 ms (measured ≈1.2 ms); end-to-end added latency est. 40–60 ms (measured ≈10 ms produce→uploaded, before compositor). diff --git a/spikes/mpv-frame-copy/viewer/addon/shm_reader.c b/spikes/mpv-frame-copy/viewer/addon/shm_reader.c index 9d5b3d12a..ac1fdaa00 100644 --- a/spikes/mpv-frame-copy/viewer/addon/shm_reader.c +++ b/spikes/mpv-frame-copy/viewer/addon/shm_reader.c @@ -148,6 +148,9 @@ static napi_value CopyLatest(napi_env env, napi_callback_info info) { pts_us == SPIKE_PTS_UNKNOWN ? -1.0 : (double)pts_us / 1e6); set_named_double(env, result, "ageMs", age_ms); set_named_double(env, result, "copyMs", (double)(t1 - t0) / 1e6); + /* producer timestamp on the shared monotonic clock, for pacing stats */ + set_named_double(env, result, "produceMs", + (double)slot->produce_time_ns / 1e6); napi_value torn_value; napi_get_boolean(env, torn, &torn_value); napi_set_named_property(env, result, "torn", torn_value); diff --git a/spikes/mpv-frame-copy/viewer/index.html b/spikes/mpv-frame-copy/viewer/index.html index 6d3874757..1cdf8ae81 100644 --- a/spikes/mpv-frame-copy/viewer/index.html +++ b/spikes/mpv-frame-copy/viewer/index.html @@ -52,6 +52,19 @@ samples.length ? samples.reduce((sum, v) => sum + v, 0) / samples.length : 0; + function stddev(samples) { + if (samples.length < 2) return 0; + const mean = avg(samples); + const variance = + samples.reduce((sum, v) => sum + (v - mean) * (v - mean), 0) / + (samples.length - 1); + return Math.sqrt(variance); + } + function median(samples) { + if (!samples.length) return 0; + const sorted = [...samples].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; + } function start(info) { const width = info.width; @@ -135,6 +148,21 @@ const copyMs = []; const uploadMs = []; const ageMs = []; + // Pacing/judder instrumentation: intervals between consecutive + // presented frames (viewer clock) and between produced frames + // (helper clock via produceMs). The gate is variance, not avg fps. + const presentIntervals = []; + const producerIntervals = []; + let lastPresentMs = 0; + let lastProduceMs = 0; + const longRun = { + start: performance.now(), + frames: 0, + late15: 0, // present interval > 1.5x nominal (one missed beat) + late25: 0, // present interval > 2.5x nominal (visible stutter) + worstIntervalMs: 0, + }; + let lastSummaryMs = performance.now(); let windowStart = performance.now(); function tick() { @@ -163,6 +191,17 @@ uploadMs.push(upload); ageMs.push(result.ageMs + upload); if (result.torn) torn++; + const tPresent = performance.now(); + if (lastPresentMs > 0) { + presentIntervals.push(tPresent - lastPresentMs); + } + lastPresentMs = tPresent; + if (lastProduceMs > 0) { + producerIntervals.push( + result.produceMs - lastProduceMs, + ); + } + lastProduceMs = result.produceMs; } } gl.drawArrays(gl.TRIANGLES, 0, 3); @@ -170,6 +209,26 @@ const now = performance.now(); if (now - windowStart >= 2000) { const seconds = (now - windowStart) / 1000; + // Nominal frame interval from the producer's own cadence — + // robust against rAF quantization on high-Hz displays. + const nominal = + median(producerIntervals) || + (shm.producerFps() > 0 ? 1000 / shm.producerFps() : 0); + let late15 = 0; + let late25 = 0; + let worst = 0; + for (const interval of presentIntervals) { + if (interval > worst) worst = interval; + if (nominal > 0 && interval > nominal * 1.5) late15++; + if (nominal > 0 && interval > nominal * 2.5) late25++; + } + longRun.frames += newFrames; + longRun.late15 += late15; + longRun.late25 += late25; + longRun.worstIntervalMs = Math.max( + longRun.worstIntervalMs, + worst, + ); const line = `STATS draw=${(drawTicks / seconds).toFixed(1)}fps ` + `new=${(newFrames / seconds).toFixed(1)}fps ` + @@ -177,16 +236,39 @@ `copy ms avg=${avg(copyMs).toFixed(2)} p95=${percentile(copyMs, 0.95).toFixed(2)} | ` + `upload ms avg=${avg(uploadMs).toFixed(2)} p95=${percentile(uploadMs, 0.95).toFixed(2)} | ` + `age ms avg=${avg(ageMs).toFixed(1)} p95=${percentile(ageMs, 0.95).toFixed(1)} | ` + + `present iv ms sd=${stddev(presentIntervals).toFixed(2)} ` + + `p99=${percentile(presentIntervals, 0.99).toFixed(1)} ` + + `max=${worst.toFixed(1)} late1.5x=${late15} | ` + + `src iv ms sd=${stddev(producerIntervals).toFixed(2)} ` + + `p99=${percentile(producerIntervals, 0.99).toFixed(1)} | ` + `dropped=${dropped} torn=${torn}`; console.log(line); hud.textContent = `shm ${shmName} ${width}x${height}\n` + line.replace('STATS ', '').replaceAll(' | ', '\n'); + if (now - lastSummaryMs >= 30000) { + lastSummaryMs = now; + const totalSeconds = (now - longRun.start) / 1000; + const latePct = ( + (100 * longRun.late15) / Math.max(1, longRun.frames) + ).toFixed(3); + console.log( + `LONGRUN t=${totalSeconds.toFixed(0)}s ` + + `frames=${longRun.frames} ` + + `avg=${(longRun.frames / totalSeconds).toFixed(2)}fps ` + + `late1.5x=${longRun.late15} (${latePct}%) ` + + `late2.5x=${longRun.late25} ` + + `worstIv=${longRun.worstIntervalMs.toFixed(1)}ms ` + + `dropped=${dropped} torn=${torn}`, + ); + } drawTicks = 0; newFrames = 0; copyMs.length = 0; uploadMs.length = 0; ageMs.length = 0; + presentIntervals.length = 0; + producerIntervals.length = 0; windowStart = now; } }