Skip to content

feat(adapter-hyperframes): deterministic render via @hyperframes/producer - #87

Open
siamubee2-max wants to merge 1 commit into
nexu-io:mainfrom
siamubee2-max:feat/deterministic-hyperframes-render
Open

feat(adapter-hyperframes): deterministic render via @hyperframes/producer#87
siamubee2-max wants to merge 1 commit into
nexu-io:mainfrom
siamubee2-max:feat/deterministic-hyperframes-render

Conversation

@siamubee2-max

Copy link
Copy Markdown

What

Adds a deterministic render path to adapter-hyperframes that drives the real upstream Hyperframes pipeline (@hyperframes/producer) — paused master timeline, per-frame seek, BeginFrame capture, parallel workers — instead of realtime recordVideo capture, whenever:

  1. @hyperframes/producer is installed (new optional peer dependency, mirroring the existing optional hyperframes peer), and
  2. the template carries Hyperframes composition markers (data-composition-id, data-composition-src, data-duration, window.__hf, window.__timelines).

Everything else falls through to the existing realtime Playwright capture, unchanged. Without the producer installed, behavior is byte-for-byte identical to today.

Why

The current path records the page in wall-clock time, which the file itself acknowledges (render.ts: "Upstream Hyperframes was never required at runtime", renderToHtml: "Real upstream Hyperframes integration will replace the inject"). That forces a stack of heuristics — the global animation freeze/unfreeze, the font-load dance, the lead-in -ss trim, the duration probe — and structurally cannot deliver several capabilities this adapter already advertises:

realtime capture deterministic (this PR)
Frame accuracy wall-clock, drops frames under load exact — 900/900 frames for 15s @ 60fps
Duration probe + trim + tpad heuristics data-duration honored exactly
Audio none recorded (caps say audio: multi) AAC muxed natively
<video> elements CORS-stalled A-Roll records as frozen lead-in frames extracted & injected per-frame
Fonts freeze/unfreeze + 8s cap heuristic deterministic @font-face localization at compile
Reproducibility differs run-to-run bit-identical across runs

Test plan

Verified locally (Windows 11, Node 26):

  • frame-glitch-title (plain CSS keyframes, no markers) → realtime path, output unchanged — regression guard.
  • frame-kinetic-type (multi-composition) → deterministic path: exact 15 s / 900 frames @ 1080p60, AAC audio track muxed, 768 A-Roll video frames extracted and injected. The same template on the realtime path records no audio and a CORS-stalled opening.
  • tsc -p tsconfig.json builds with and without @hyperframes/producer installed (dynamic import uses a variable specifier).

9 of the 23 bundled templates carry markers and benefit immediately: decision-tree, kinetic-type, nyt-graph, play-mode, product-promo, product-promo-30s, swiss-grid, vignelli, warm-grain.

Notes / open questions (draft)

  • meta.durationSec/renderedFrames are reported from the request config, not read back from the produced file — happy to wire ffprobe readback if you'd prefer.
  • durationMode: "explicit" currently defers to the composition's own data-duration on the deterministic path (the Hyperframes model); realtime path semantics are unchanged. Open to trimming/padding to the explicit length instead.
  • The deterministic pipeline also unlocks webm/mov true alpha and png-sequence output — left out of this PR to keep it reviewable, but it's a one-line format pass-through if wanted.

🤖 Generated with Claude Code

…ucer

When @hyperframes/producer is installed and the template carries
Hyperframes composition markers (data-composition-id, data-duration,
window.__timelines, ...), render through the real upstream pipeline:
paused master timeline, per-frame seek, BeginFrame capture, parallel
workers, deterministic font localization, and native audio muxing.

Falls back to the existing realtime Playwright capture unchanged when
the producer is not installed or the template is a plain CSS-keyframe
single file, so nothing changes for current users. The producer is an
optional peer dependency, mirroring the existing optional `hyperframes`
peer; the dynamic import uses a variable specifier so tsc builds
without the package present.

Verified locally on Windows:
- frame-glitch-title (no markers) -> realtime path, output unchanged
- frame-kinetic-type -> deterministic path: exact 15s/900 frames at
  1080p60 from data-duration, AAC audio muxed, A-Roll video frames
  extracted and injected (768 frames) where the realtime path records
  a CORS-stalled blank lead-in and no audio at all
@lefarcen
lefarcen requested a review from nettee July 23, 2026 13:43
@lefarcen lefarcen added size/M Size M (100-299 LOC) risk/high High risk type/feature Feature change labels Jul 23, 2026
@siamubee2-max
siamubee2-max marked this pull request as ready for review July 23, 2026 13:48
@siamubee2-max

Copy link
Copy Markdown
Author

Some context on why I went with @hyperframes/producer rather than extending the recordVideo approach — I've spent a lot of time in the HyperFrames internals and the two models are fundamentally different:

HyperFrames compositions are built around a single paused master timeline that is seek-safe by contract: the engine (@hyperframes/engine) initializes the page, waits for window.__hf readiness + sub-composition timelines, then drives seek(frameTime) → BeginFrame/screenshot capture per frame. Nothing in the composition runs on the wall clock during capture. That's what makes renders bit-identical across runs and machines, lets workers capture disjoint frame ranges in parallel, and is also how <video> elements work upstream: frames are pre-extracted with ffmpeg and injected per-frame (videoFrameInjector), which is why the CORS-stalled A-Roll that breaks the realtime path here just works on this code path.

The realtime capture in this adapter necessarily fights that model — the freeze/unfreeze + document.fonts dance + lead-in trim in render.ts are all reimplementations of things the producer's compile stage does deterministically (injectDeterministicFontFaces, CDN script inlining, remote media localization). Delegating to the producer removes that whole class of heuristics instead of patching them one by one.

A few things this unlocks beyond the PR's scope, if there's interest in follow-ups:

  • format: "webm" (VP9 + yuva420p) and "mov" (ProRes 4444) give true alpha straight from the same pipeline — the alpha: true this adapter already advertises in its capabilities but can't currently honor through webm→h264.
  • png-sequence output for AE/Nuke ingest.
  • The producer also ships a distributed render path (chunked, Lambda/Cloud Run) that could back a "fast export" tier in the studio.

Happy to iterate on the open questions in the description — in particular whether durationMode: "explicit" should trim/pad the deterministic output or keep deferring to the composition's data-duration.

@lefarcen lefarcen added the needs-product-review Product review required before merge label Jul 23, 2026

@nettee nettee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two correctness issues in the new deterministic branch that should be fixed before merge. One is a silent fallback that masks real producer/render failures and returns a different render path than the caller requested; the other returns incorrect RenderOutput.meta values whenever the deterministic path renders an auto-duration composition.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +40 to +49
try {
const deterministic = await tryDeterministicRender(input, ctx);
if (deterministic) return deterministic;
} catch (err) {
if (ctx.signal?.aborted) throw err;
ctx.onProgress?.(
5,
`deterministic render failed (${err instanceof Error ? err.message : err}) — falling back to realtime capture`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tryDeterministicRender() already returns null for the two non-applicable cases (@hyperframes/producer missing or the template not being a Hyperframes composition), and its docstring says it throws only for real render failures once the deterministic path has been entered. Catching every non-abort error here converts those real failures into a successful realtime export instead. That means a broken producer install, a render job error, or a deterministic pipeline regression will quietly produce a different video with different duration/audio semantics while still reporting success, which violates the fail-fast rule in AGENTS.md and makes this path very hard to trust in production. Please only fall back on the explicit null return, and let actual deterministic render failures propagate as render-failed errors instead of suppressing them.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +128 to +140
const durationSec =
input.config.duration === 'auto' ? undefined : Number(input.config.duration);
ctx.onProgress?.(100, 'done');
return {
outputPath: input.config.outputPath,
meta: {
// The composition's own data-duration governs length in this path; fall
// back to the requested duration for reporting when it was explicit.
durationSec: durationSec ?? 0,
fileSizeBytes: st.size,
actualResolution: input.config.resolution,
fps,
renderedFrames: durationSec ? Math.round(durationSec * fps) : 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns incorrect metadata for every successful deterministic render with config.duration === 'auto': durationSec becomes 0 and renderedFrames becomes 0, even though the producer just emitted a non-empty file. The changed lines make that explicit by deriving both fields only from the request config, not from the produced composition duration. RenderOutput.meta is the adapter’s contract back to callers, and the existing Playwright path reports actual values here, so this creates a silent contract break for any consumer that shows render duration, frame count, or uses those values for later processing. Please compute these fields from the deterministic output instead of defaulting to zero, for example by reading the produced file with ffprobe or by pulling the resolved duration/frame count from the producer job result if that API exposes it.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-product-review Product review required before merge risk/high High risk size/M Size M (100-299 LOC) type/feature Feature change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants