spike(embedded-mpv): pacing/judder instrumentation + 50/25 fps and HDR gate results

Viewer now measures inter-frame intervals on both clocks (present side and
producer side): stddev/p99/max, late-frame counters vs the producer's median
interval, and a cumulative LONGRUN summary every 30 s. The addon exposes the
producer timestamp (produceMs) for this.

Measured on M1 Pro: 50 fps and 25 fps cadences are clean (late frames only
at startup; residual jitter is 120 Hz rAF grid quantization, bounded by one
display tick), and 4K25 HDR10 PQ/BT.2020 is tonemapped to SDR by mpv before
readback at full rate with unchanged copy costs. RESULTS.md carries the
tables and HDR-clip repro commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-15 18:20:45 +02:00
1 parent fd0e56edea
commit 1272fca620
4 files changed
+130 -5

No files matched your search

+16 -5
View File
@@ -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.
+29
View File
@@ -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).
@@ -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);
+82
View File
@@ -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;
}
}