mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
1 parent
fd0e56edea
commit
1272fca620
4 files changed
+130
-5
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user