From 0ea4190a297d7f05d2ea7f50519b4627658db49b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:43:34 +0200 Subject: [PATCH] test(embedded-mpv): commit the Linux frame-copy measurement probe linux-frame-probe.mjs reproduces the RESULTS.md Linux rows: spawns the production helper, attaches the frame-reader addon to the announced shm generation, and reports new-frame fps, copy wall time, produce->copy age, torn reads and pixel spread. Usage documented in RESULTS.md. Co-Authored-By: Claude Fable 5 --- spikes/mpv-frame-copy/RESULTS.md | 7 +- spikes/mpv-frame-copy/linux-frame-probe.mjs | 139 ++++++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 spikes/mpv-frame-copy/linux-frame-probe.mjs diff --git a/spikes/mpv-frame-copy/RESULTS.md b/spikes/mpv-frame-copy/RESULTS.md index 5f529115e..494231abc 100644 --- a/spikes/mpv-frame-copy/RESULTS.md +++ b/spikes/mpv-frame-copy/RESULTS.md @@ -111,9 +111,10 @@ Windows/Linux. Source: Linux port branch (headless-EGL `frame_helper_gl.h` backend), system libmpv 2.5.0 (mpv 0.40), Mesa 25.0 iris. Measured with the production helper -binary + `embedded_mpv_frame_reader.node` in a Node probe loop (the spike -viewer harness is macOS-only), so *age* here is produce→reader-copy and -excludes the renderer texture upload. hwdec was NOT active — this machine +binary + `embedded_mpv_frame_reader.node` in a Node probe loop +(`linux-frame-probe.mjs` in this directory — the spike viewer harness is +macOS-only), so *age* here is produce→reader-copy and excludes the renderer +texture upload. hwdec was NOT active — this machine has no VAAPI driver installed (`intel-media-va-driver`), so HEVC rows are software decode; treat them as a decode-limited floor, not a pipeline ceiling. diff --git a/spikes/mpv-frame-copy/linux-frame-probe.mjs b/spikes/mpv-frame-copy/linux-frame-probe.mjs new file mode 100644 index 000000000..2707675a8 --- /dev/null +++ b/spikes/mpv-frame-copy/linux-frame-probe.mjs @@ -0,0 +1,139 @@ +/* + * Linux frame-copy probe: spawns iptvnator_mpv_helper, attaches the + * embedded_mpv_frame_reader addon to the announced shm generation, and + * reports producer fps, copy latency (ageMs), copy wall time, torn reads + * and pixel spread. Usage: + * node frame-probe.mjs [--hwdec auto] + */ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const releaseDir = process.env.RELEASE_DIR; +const [url, width = '1280', height = '720', seconds = '5', ...rest] = + process.argv.slice(2); +if (!url || !releaseDir) { + console.error('usage: RELEASE_DIR= node frame-probe.mjs [w] [h] [s]'); + process.exit(1); +} +const hwdecIdx = rest.indexOf('--hwdec'); +const hwdec = hwdecIdx >= 0 ? rest[hwdecIdx + 1] : null; + +const reader = require(path.join(releaseDir, 'embedded_mpv_frame_reader.node')); +const helperPath = path.join(releaseDir, 'iptvnator_mpv_helper'); +const shmBase = `/impv-probe-${process.pid}`; + +const args = ['--shm-base', shmBase, '--width', width, '--height', height]; +if (hwdec) args.push('--hwdec', hwdec); +const child = spawn(helperPath, args, { stdio: ['pipe', 'pipe', 'inherit'] }); + +let buffer = ''; +let currentSource = null; +const generations = []; +let statusLog = []; + +child.stdout.on('data', (chunk) => { + buffer += chunk.toString(); + let i; + while ((i = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, i); + buffer = buffer.slice(i + 1); + if (!line.trim()) continue; + let ev; + try { ev = JSON.parse(line); } catch { continue; } + if (ev.event === 'shm') { + generations.push(`${ev.name} ${ev.width}x${ev.height}`); + currentSource = ev; + } else if (ev.event === 'snapshot') { + if (statusLog[statusLog.length - 1] !== ev.status) statusLog.push(ev.status); + } else if (ev.event === 'fatal') { + console.error('FATAL:', ev.error); + process.exit(2); + } + } +}); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +await sleep(300); +child.stdin.write(`load\turl=${url.replace(/%/g, '%25')}\n`); + +// Wait for playback + a possibly-resized generation to settle. +const settleDeadline = Date.now() + 10000; +while (Date.now() < settleDeadline) { + await sleep(100); + if (statusLog.includes('playing') && currentSource) { + await sleep(700); // allow the aspect-fit resize generation to land + break; + } +} +if (!currentSource) { + console.error('no shm source announced'); + child.kill('SIGKILL'); + process.exit(2); +} + +const info = reader.open(currentSource.name); +console.log(`attached ${currentSource.name}: ${info.width}x${info.height} stride=${info.stride} gen=${info.generation}`); + +const frameBuf = new ArrayBuffer(info.frameBytes); +let lastSeq = 0; +let frames = 0; +let torn = 0; +const ages = []; +const copyTimes = []; +const durationMs = Number(seconds) * 1000; +const start = Date.now(); +let pixelMin = 255, pixelMax = 0; + +while (Date.now() - start < durationMs) { + const seq = reader.latestSeq(); + if (seq !== lastSeq) { + const t0 = process.hrtime.bigint(); + const result = reader.copyLatest(frameBuf); + const t1 = process.hrtime.bigint(); + if (result) { + frames += 1; + lastSeq = result.seq; + ages.push(result.ageMs); + copyTimes.push(Number(t1 - t0) / 1e6); + if (result.torn) torn += 1; + } + } + await sleep(2); +} +const elapsed = (Date.now() - start) / 1000; + +// pixel spread over the last frame +const view = new Uint8Array(frameBuf); +for (let i = 0; i < view.length; i += 4001 * 4) { + for (let c = 0; c < 3; c++) { + const v = view[i + c]; + if (v < pixelMin) pixelMin = v; + if (v > pixelMax) pixelMax = v; + } +} + +const pct = (arr, p) => { + if (!arr.length) return NaN; + const sorted = [...arr].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]; +}; +const avg = (arr) => arr.reduce((a, b) => a + b, 0) / (arr.length || 1); + +console.log(`generations seen: ${generations.join(' | ')}`); +console.log(`status transitions: ${statusLog.join(' -> ')}`); +console.log(`producerAliveMs: ${reader.producerAliveMs().toFixed(1)}`); +console.log(`new frames: ${frames} in ${elapsed.toFixed(1)}s = ${(frames / elapsed).toFixed(1)} fps`); +console.log(`copy ms avg/p95: ${avg(copyTimes).toFixed(2)} / ${pct(copyTimes, 0.95).toFixed(2)}`); +console.log(`age ms avg/p95: ${avg(ages).toFixed(2)} / ${pct(ages, 0.95).toFixed(2)}`); +console.log(`torn: ${torn}`); +console.log(`pixel spread (BGR sampled): min=${pixelMin} max=${pixelMax}`); + +child.stdin.write('quit\n'); +child.stdin.end(); +await sleep(500); +if (child.exitCode === null) child.kill('SIGKILL'); +reader.close();