tv-fps
v0.5.0
Published
Framework-agnostic FPS / frame-time instrumentation for TV / set-top-box UIs. Allocation-free ring buffer, rAF + render-driven sampling, LoAF, OTLP exporter, a zero-render React HUD, Playwright helper and an e2e soak harness — one package, independent sub
Maintainers
Readme
fps
Framework-agnostic FPS / frame-time instrumentation for TV / set-top-box UIs (WPE WebKit on BCM-class silicon, concurrent GC off). One package, independent subpath exports — import only what you need.
fps core sampler — allocation-free ring buffer, stall counters,
LoAF observer, rAF + render-driven sampling (zero deps)
fps/react zero-render React HUD + signal pump
fps/hud the same overlays in pure DOM — FPS meter, result dialog, key readout
fps/bench turnkey benchmark — one call mounts HUD + hotkey + sweep + publish
fps/sweep configurable D-pad sweep driver — drive any TV UI's nav, measure it
fps/publish post a finished sweep to a results collector / leaderboard
fps/otel OTLP/HTTP-JSON exporter (Grafana/Honeycomb/Collector)
fps/playwright page-FPS measurement helper for e2e CI gates
fps/soak e2e soak harness (driver + collector + aggregator, `fps-soak` CLI)
fps/mock-catalog license-clean 20×20 TV catalog fixtures + placeholder posters
dist/inject.js the benchmark as one self-contained IIFE for pages you don't own —
script tag or console paste, installs `window.__bench`Install
Published to npm as tv-fps (the bare fps name was taken). Alias it to
fps so imports read the same everywhere:
// package.json — pick one
"dependencies": { "fps": "npm:tv-fps@^0.3.0" } // npm registry
"dependencies": { "fps": "github:dobreadi/tv-fps" } // git dependencyThe prepare hook builds dist/ on install, so the git form works without a
published artifact (releases are tagged v* — .github/workflows/publish.yml
publishes with provenance via npm trusted publishing). import "fps" is
dependency-free; React / Playwright stay optional peers and only load on their
subpath. No npm at all? — load the console build from the deployed benchmark
site (/fps.js, or the version-pinned /fps-<version>.js — see “Console
benchmark” below), or point a webpack alias / tsconfig paths entry at a
checked-out tv-fps/dist.
Core sampler
import { startFrameSampler, sharedFrameRing, sharedStats, getStallCounts } from "fps";
startFrameSampler(); // always-on rAF loop → shared ring (ref-counted)
sharedFrameRing.stats(sharedStats); // { p50, p95, max } — no allocation
const { stalls16, stalls33 } = getStallCounts(); // superset bands: stalls16 ⊇ stalls33For render-on-demand apps, drive the ring from your scheduler instead of a rAF loop (no idle wakeups):
import { recordFrameDelta, startIdleHeartbeat } from "fps";
recordFrameDelta(dtMs); // called per rendered frame by your render pump
const beat = startIdleHeartbeat(); // low-rate idle probe; no rAF, no fake samplesZero-render HUD (fps/react)
Drop-in overlay that renders once and thereafter writes the numbers straight
to the DOM via a @preact/signals-core effect — it never triggers a React
re-render, so it can't perturb the measurement it reports. Works over DOM, Pixi,
or mixed render paths.
import { FpsHud } from "fps/react";
<FpsHud position="top-right" budgetMs={33} /> // self-starts the samplerNeed the numbers elsewhere (a custom overlay, a Pixi useTick)? Read the same
signals — still zero React renders:
import { startFpsSignals } from "fps/react";
const { signals, stop } = startFpsSignals();
// signals.fps.value, signals.p95.value, …Vanilla HUD (fps/hud)
The same overlays in pure DOM (self-injected inline styles, no framework), so any app — including WebGL engines (Lightning / Blits) that only have a canvas — gets the live meter, the 10-foot result dialog, and the last-key readout:
import { mountFpsMeter, mountSweepResult, mountKeyHud } from "fps/hud";
const meter = mountFpsMeter({ position: "top-right" }); // self-starts the sampler
const overlay = mountSweepResult(); // auto-raises on "benchmark:done" — or overlay.show(result)
mountKeyHud(); // which key/keyCode did the remote actually send?mountSweepResult({ onPublish }) grows a D-pad-focusable Publish results
button (wire it to fps/publish). meter.setVisible(false) hides the meter
while timing without stopping the sampler — the quiet path to the cleanest
reading.
Turnkey benchmark (fps/bench)
One call mounts the meter + result overlay, starts the sampler, and binds a
hotkey (b or a colour button) that runs a deterministic D-pad sweep and shows
the result. The sweep drives the app's REAL input path — synthetic KeyboardEvents
into its own navigation — so the host must move focus on arrow keys.
import { installBenchmark, installDomBenchmark } from "fps/bench";
const bench = installBenchmark({ rows: 20, cols: 20 }); // grid known → walks every cell
installDomBenchmark({ steps: 40, cycles: 8 }); // grid unknown → directional fling
// bench.run(); // trigger programmatically; bench.dispose() removes everythingPublishing auto-wires when a collector resolves (same-origin /api/results on
https). A registered 3rd-party app targets a board explicitly:
installBenchmark({
rows: 20, cols: 20,
publish: { board: "https://tv-benchmark.pages.dev", app: "acme-tv", key: ACME_BOARD_KEY },
});publish: false disables the button, quiet: true hides the HUD while timing,
and the sweep knobs (stepMs, target, eventTypes, keyMap, …) pass straight
through to the sweep driver below.
Sweep driver (fps/sweep)
Drive any TV UI's D-pad navigation through synthetic key events (the real input path — the same window/document keydown handler a remote hits, not a private scroll/focus API) and measure the result. Configurable on both axes (rows × per-row cols), plus cadence, cycles, dispatch target / event types / key map, and phase order.
import { measureGridSweep } from "fps/sweep";
const run = measureGridSweep({
rows: rails.length,
cols: (r) => rails[r].tiles.length, // a constant or per-row fn (variable rails)
verticalCycles: 10,
rowReturn: "back", // "rewind" | "back" | "none"
stepMs: 90, // ≈ key-repeat cadence
target: window, // document + ["keydown","keyup"] for some WebGL engines
exposeAs: "__benchmarkResult", // expose for an e2e poller
emitEvent: "benchmark:done", // dispatch for an on-screen result line
});
const result = await run.result; // { frames, fps, p50, p95, max, stalls16, stalls33, ... }Results also carry cadence-relative smoothness metrics, streamed over the
whole sweep (the ring only keeps the last 1024 frames): hitches / hitchMax /
hitchLostMs (frames > max(2× running cadence, 28ms) — a steady 30fps engine
scores 0, a 60fps engine's freezes all score), cv (frame-time coefficient of
variation), cluster33Max (longest >33ms run), and — when the optional
trackMotion: () => number probe is passed (a scalar scroll offset of ONE
continuous surface; allocation-free, never a layout read) — jumps/jumpMaxPx
displacement discontinuities (the freeze-then-jump a wall-clock tween produces).
Why these exist and how to read them: the consuming benchmark's
SMOOTHNESS.md. The building blocks are exported from the root:
getSmoothnessStats, setMotionSampler, resetStallCounts.
Compose the lower-level pieces directly when you need to: buildGridSweep(shape)
(pure step list, no DOM), createKeyDispatcher(opts) (the press(dir)), and
runSweep({ steps, press }) (the interval runner). The measurement variant reads
the shared fps ring, so keep the sampler running (startFrameSampler() or a
mounted <FpsHud/>).
Publishing (fps/publish)
Post a finished sweep to a results collector — the tv-benchmark leaderboard
worker's POST /api/results, or any worker implementing that endpoint (pass its
base URL as board). Deliberately tiny: one fetch, no retries; resolves (never
rejects) with { ok, rank?, error? } so a TV UI can render "Published #4"
without try/catch.
import { createPublisher } from "fps/publish";
// First-party app on the deployed site — everything defaults from the page URL.
const publish = createPublisher(); // undefined = no collector → hide the button
// Registered 3rd-party app (id + key issued by the board admin).
const publishAcme = createPublisher({
board: "https://tv-benchmark.pages.dev",
app: "acme-tv",
key: ACME_BOARD_KEY, // per-app secret — sent as x-bench-key
});
await publishAcme?.(result); // → { ok: true, rank: 4 }A key without an explicit board also yields undefined — the same-origin
default exists for the first-party site only, and a secret must never ride to
an endpoint the caller didn't name.
Three ways through the collector's door:
- first-party — same-origin (and localhost-dev) posts need no key;
- registered apps — the board admin issues an app id + per-app secret from the PIN dashboard's Registered apps section (only the key's sha256 is stored; origin binding optional). Their rows are first-class: no test marking, ranked like everyone else. Rotation = revoke + re-register;
- local-build hatch — the shared
BENCH_PUBLISH_KEYlets a local/pre-merge build on the LAN publish to the prod board; those rows are force-marked server-side (buildgets a-testsuffix). Never bake it into production.
Helpers: publishSweepResult (the single POST), defaultAppId /
defaultBenchFlags / defaultDeviceLabel (the URL-derived defaults),
resolveBoardEndpoint (base URL or full endpoint; a scheme-less host like
"tv-benchmark.pages.dev" gets https://), and buildPublishPayload — the exact JSON body the
worker validates, exported so client and validator can be unit-tested against
each other (the schema is hand-mirrored; change both together).
Console benchmark (inject.js)
dist/inject.js is the whole benchmark as one self-contained IIFE — no imports,
no framework — for a page you can't (or won't) modify. Load it with a script tag
(the deployed copy lives at https://tv-benchmark.pages.dev/fps.js):
<script src="https://tv-benchmark.pages.dev/fps.js" data-app="acme-tv"
data-board="https://tv-benchmark.pages.dev" data-key="…"
data-rows="12" data-cols="8"></script>…or paste it into the running app's devtools console (set
window.__benchConfig = {…} before pasting, or call __bench.configure({…})
after). Either way it mounts the live meter, the result overlay (with a Publish
button when a board is configured — publishing REQUIRES one here: inject runs
on a foreign page, so there is no same-origin default, and a key without a
board never publishes anywhere), a b/colour-button hotkey — which always
runs quiet — and window.__bench:
__bench.sweep({rows, cols, axis, steps, stepMs, keyMap, target, quiet, …})
__bench.run(…) alias of sweep
__bench.configure({…}) merge config at runtime
__bench.config effective merged config (defensive copy)
__bench.publish(result?) push the last (or a given) result to the board
__bench.keys(show?) toggle the last-key readout
__bench.stop() remove everythingConfig merges in order (later wins): defaults ← script data-* attributes ←
window.__benchConfig ← configure(). Each field's attribute is the kebab-case
data-* of its name; malformed values are ignored. keyMap / trackMotion /
flags ride any config-object layer (window.__benchConfig, configure()) —
only data-* attributes can't carry them; keyMap also works per call via
sweep() opts. Published flags default to {} — the host page's URL query is
NOT captured (a 3rd-party URL can carry tokens); set them via a config object:
data-app app id results publish under (a registered app's id)
data-board collector base URL — REQUIRED for publishing
(no same-origin default on a foreign page);
unset = publishing off (no button)
data-key x-bench-key — registered-app secret or the local-build hatch key
data-device-label friendly device name (boxes with generic UAs); ?device= also works
data-build build provenance (short commit SHA, optionally -dirty)
data-rows / data-cols BOTH set → the full V-H-V grid sweep (sweepType "full",
directly comparable to the first-party apps); else a fling
data-vertical-cycles / data-row-return grid knobs (default 8 / "rewind")
data-axis / data-steps / data-cycles fling knobs (default "vertical" / 40 / 8)
data-step-ms / data-settle-ms key cadence / pre-sweep settle (default 90 / 200)
data-target "window" | "document" | "body" | "active" — where synthetic
keys are dispatched. Spatial-nav TV UIs often act ONLY on
events targeted at document.body ("body") or the focused
element ("active", re-read each press), not window/document.
data-active-target "1" — same as target="active"
data-event-types comma list — add "keyup" for engines that need it ("keydown,keyup")
data-quiet "1" hides the HUD while timing (cleanest numbers)
data-hotkey "sweep" (default) | "measure" — what 'b'/a colour button starts;
"measure" = keyless window you drive with the real remote
(for trusted-input-only boxes)
data-key-hud "1" mounts the last-key readout on installCaveat: the sweep drives the page with synthetic KeyboardEvents
(isTrusted: false) — an app that ignores untrusted input won't move. Configure
keyMap / target / eventTypes when it uses non-arrow keys or listens
elsewhere (__bench.keys() shows what the remote actually sends).
When the sweep moves nothing — first suspect the dispatch target, not
isTrusted. Many spatial-nav TV UIs attach the key handler to document.body
or the focused element, and ignore events dispatched at window/document.
Try target: "body" first, then target: "active" (fires at
document.activeElement, re-read each press as focus moves). Diagnose directly —
fire one synthetic key and see which target moves focus:
document.body.dispatchEvent(new KeyboardEvent('keydown', {key:'ArrowRight', keyCode:39, bubbles:true}))Whichever moves focus is your target. Both drive the app even with
isTrusted:false — the block was the target, not the trust bit.
Only if that still moves nothing (a build that genuinely gates on e.isTrusted,
or takes input entirely outside the DOM — e.g. an Rx handler that preventDefaults
injected keys) fall back to __bench.measure({ durationMs }) — it records a
timed window with the same metrics and publish path but dispatches no keys;
you drive the real D-pad while it times. With rows/cols configured it stamps
the same grid so the row stays comparable; replay the V-H-V pattern for the
window, then __bench.publish().
For a SOTB over SSH, scripts/rcu-sweep.mjs automates the remote half — it
replays the exact buildGridSweep V-H-V pattern as trusted rcuemulator keys in
lockstep with a measure() window:
node scripts/rcu-sweep.mjs --host <box-ip> --rows 7 --cols 10 --cycles 10
# prints the __bench.measure({durationMs}) one-liner to paste, then on Enter
# drives UP/DOWN/LEFT/RIGHT over one SSH session at the configured cadence.
# --dry-run shows the plan without sending; SSHPASS=<pw> for password auth.Agent skill
skills/bench-integrate/SKILL.md teaches a coding agent to instrument any
TV/STB app with this package: the integration decision tree, the registered-app
flow, the comparability rules, and a publish-failure troubleshooting table.
Develop
npm install
npm run build # vite (multi-entry ESM) + tsc declarations
npm test # vitest
npm run typecheck