The first dynamic `await import("./render.js")` cold-load takes >5 s on
Windows runners — long enough to blow vitest's default 5 s timeout in
whichever test ran it first. Subsequent imports are <10 ms because the
module is now cached, so only test #1 ever times out.
The downstream failure is more subtle: when test #1 times out, vitest
moves on, but its leaked async function eventually hits the synchronous
`producer.createRenderJob(...)` line and pushes a stale config to
`producerState.createdJobs`. That push lands AFTER test #2's `beforeEach`
clears the array, so test #2's `createdJobs[0]` is the leaked test #1
entry instead of its own. That's why test #2 saw `browserGpuMode: 'software'`
when it expected `'auto'`.
Hoist the import into `beforeAll` (matching the pattern the existing
`parseVariablesArg` and `validateVariablesAgainstProject` describe blocks
in this file already use). Cold-load happens once outside any test's
timeout window, every test stays fast, no leaked promise can corrupt
state.
Failing run: https://github.com/heygen-com/hyperframes/actions/runs/25470257972/job/74732502915
Started failing on main with the merge of #642 (auto-detect-browser-gpu),
which added the "forwards browserGpuMode='auto'" test as test #2.
Drive-by fix: hf#631 (composition flag) merged with two test calls
using `browserGpu: false`, but hf#642 (browserGpuMode auto) merged
shortly after and removed that field from RenderOptions in favour of
the tri-state `browserGpuMode`. Main has been failing typecheck since
hf#642 landed (every PR inherits the failure).
Renaming `browserGpu: false` → `browserGpuMode: "software"` matches
the new shape; both tests still verify what they were written for
(forwards entryFile / omits entryFile to createRenderJob).
After hf#641 inlined the runtime IIFE into every bundle, lint tools
inspecting bundled output (including Abhay's c2v eval) started flagging
empty `catch {}` blocks across the runtime. The source had explanatory
comments inside, but esbuild's minifier strips them — the IIFE ships
~10 visible patterns of `}catch{}` and consumers' linters fire on each.
Each empty catch is intentional best-effort error swallowing —
postMessage to a parent frame that may not exist, `media.play()` /
`pause()` that throw under autoplay restrictions, timeline `seek()` on
a disposed timeline, anime.js / lottie feature detection on hosts that
don't load those libraries, etc. The right behaviour stays "tried,
didn't work, move on", but doing it visibly improves three things:
- lint clean: helper call is a real statement; no `no-empty` warnings
survive minification
- debuggable: flip `window.__hfDebug = true` in DevTools to see every
swallow site with `console.debug` (silent in prod by default)
- observable: studio / embeddings can install
`window.__hf.onSwallowed = handler` to collect runtime swallow
events without polluting the page console
Implementation: `packages/core/src/runtime/diagnostics.ts` exports
`swallow(label, err?)`. 41 catch sites across 12 runtime files
converted via mechanical pass (auto-generated `runtime.<module>.siteN`
labels — labels can be tightened site-by-site as a follow-up; the
shape of the change is what matters here).
Verification:
- core 674/674 (incl. 6 new diagnostics tests covering silent default,
__hfDebug logging, legacy __HYPERFRAMES_DEBUG flag, handler hook,
handler-throws-doesn't-recurse, both-active)
- typecheck clean
- format / lint clean
- runtime IIFE rebuilds successfully (`bun run build:hyperframes-runtime`)
Refs Abhay's c2v eval — bundler artefacts now lint-clean with the
runtime body inlined.
Remove dollar amounts, render-time figures, and credit allowances from the
Vercel/Cloudflare tabs. These were sourced from the template READMEs but go
stale fast (pricing changes) and are load-bearing on a single composition's
quirks (perf isn't proportional to duration). Keep qualitative framing and
link out to the canonical pricing pages instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reword frontmatter description to describe capability, not providers
- Soften "deploy in one click" — Cloudflare requires Workers Paid plan
- Qualify Cloudflare ~25s perf number as a local-Docker measurement
on a 6-vCPU host, not a standard-4 production figure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the two official one-click deployment templates
(heygen-com/hyperframes-vercel-template, heygen-com/hyperframes-cloudflare-template)
in the docs site. Previously they only existed as GitHub READMEs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two files on main fail `bun run format:check`:
- `registry/registry.json` (missing trailing newline)
- `registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html` (whitespace
+ quote-style)
Local format-check on the auto-detect-browser-gpu branch only flagged
these once rebased onto current main (post-#647 / v0.5.2). Pure
whitespace fix; no semantic change.
Three follow-ups from Vai's staff-eng review:
1. Concurrent-probe race (real bug): the parallel coordinator runs N
workers via Promise.all, so `--workers 4` on a no-GPU host fired 4
simultaneous probe Chromes — each paying the same 240 ms launch cost.
Cache the *Promise* (not the resolved value): first caller assigns
the in-flight Promise, every other concurrent caller awaits the same
one. Verified with a new test asserting all concurrent callers get
the identical Promise reference.
2. Stale rendering.md (lines 23, 29): user-visible contract said
"browser GPU enabled by default", which was wrong post-auto. Now
describes the auto / hardware / software trichotomy explicitly.
3. Silent fallback: auto-mode produced no output, so a regression to
"always falls back to software even with GPU present" would have
been invisible in production logs. Added a single stderr line per
process when the probe resolves: `[hyperframes] browserGpuMode auto
→ <mode> (<reason>)`. Cache hits don't re-log.
Verification:
- Engine 536/536 (incl. new concurrent-dedup test asserting Promise
reference equality across simultaneous callers)
- CLI 256/256
- Format / lint / typecheck clean
Initial PR carried a backwards-compat shim where RenderOptions had both
`browserGpu?: boolean` (for docker) and `browserGpuMode?` (for local).
Since renderLocal/renderDocker have no external callers, simplify to a
single field. The boolean → docker-args conversion now happens inline at
the one site that needs it (`browserGpu: options.browserGpuMode === "hardware"`
when handing off to dockerRunArgs).
No behaviour change. 535/535 engine + 256/256 CLI still pass.
When the host doesn't have a usable GPU (CI containers, eval rigs without
GPU passthrough, dev VMs), Chrome's hardware-mode WebGL flags
(`--use-gl=egl/metal/d3d11`) silently leave WebGL unavailable —
`getContext("webgl")` returns null, three.js' WebGLRenderer dies, the
canvas stays black. Surfaced today by Abhay's c2v-eval failing on a
docker render of an hf bundle that uses three.js + a custom fragment
shader.
The fix that's been there: `--use-gl=angle --use-angle=swiftshader` (CPU
software WebGL, ~5-50× slower but pixel-identical). The engine already
exposed `browserGpuMode: "software"` for this. The gap was discovery —
users had to know to pass `--no-browser-gpu` on no-GPU hosts.
This change adds `browserGpuMode: "auto"` (now the CLI default for local
renders): on first launch in the process, probe Chrome with hardware
args, check `canvas.getContext("webgl") !== null`, cache the result.
~1-2 s on first render, free on every subsequent render in the same
worker. Hardware GPUs keep their fast path; no-GPU hosts get SwiftShader
without ceremony.
Behaviour matrix:
- No flag, no env, local → "auto" (NEW default)
- `--browser-gpu` → "hardware" (force; errors if no GPU)
- `--no-browser-gpu` → "software" (force SwiftShader)
- `PRODUCER_BROWSER_GPU_MODE` → "hardware" / "software" / "auto" / unset
- Docker mode → forced "software" (unchanged)
Engine-config default stays "software" (conservative for embedders); the
"auto" default lives in the CLI's `resolveBrowserGpuForCli` so producer
embedders aren't surprised by a probe-on-launch.
Also adds `--enable-unsafe-swiftshader` to the software flag set —
Chrome 120+ deprecated implicit SwiftShader fallback and emits a
deprecation warning unless the flag is set explicitly. Despite the
"unsafe" name this is exactly the pre-deprecation behaviour; the rename
is about Chrome's threat model on the open web, not about the rendering
itself.
Verification:
- Engine 535/535 + CLI 256/256 (incl. new probe tests + tri-state CLI test)
- Empirical: probe on this no-GPU devbox returns "software" in 240 ms,
cached 0 ms on subsequent calls
- Format / lint / typecheck clean across all packages
Refs the Abhay/Slack thread on c2v-eval rendering without a GPU node.
Per CodeQL's `js/bad-tag-filter` recommendation, replace the regex-based
`<script>` body extraction with a `parseHTML` + `querySelectorAll`
walk. The rule explicitly says "use a parser library" — and linkedom
is already imported in this file, so the diff is small.
This eliminates the regex entirely, so the rule can no longer fire on
this site (instead of chasing whitespace / case / trailing-content
edge cases one at a time).
CodeQL still flagged `</script\s*>` as too narrow — the rule wants
tolerance for `</script\t\n bar>` (HTML parser treats trailing content
in a close tag as part of the tag). Switched to `</script[^>]*>` for
full coverage.
The bundler still always emits the canonical `</script>`; this is
test-side hardening, not a runtime fix.
CodeQL's `js/bad-tag-filter` rule flagged `</script>` as too strict —
`</script >` (with whitespace before `>`) is valid HTML and would slip
past the matcher. Changed to `</script\s*>` for full defense-in-depth.
The bundler always emits the canonical form, so no real-traffic miss —
this is hardening the test's parse-loop, not fixing a downstream bug.
Addresses CodeQL alert on #641.
CodeQL flagged the inline `<script>...</script>` regex as case-sensitive,
which would miss `<SCRIPT>` tags. The bundler always emits lowercase, so
this is a defense-in-depth fix matching the `/i` flag already used by the
sibling regexes in this file (lines 37 & 75).
Addresses CodeQL review on #641.
- Extract applyMask helper from postprocess and add 5 unit tests pinning
the contract this PR is selling: fg.alpha + bg.alpha === 255 per pixel,
RGB triples byte-identical between fg and bg, and bg=null path leaves
the bg buffer untouched. Without these, a future postprocess change
(mask threshold, premultiplied alpha, gamma) could silently break the
inverse-alpha relationship and the existing plumbing tests would all
still pass.
- Add stdin 'error' listener inside spawnFfmpeg. If either encoder dies
mid-render, Node emits an unhandled error on the dead writable on the
next .write() and crashes the CLI before waitForExit's reject path
can surface the encoder's stderr tail. Doubled encoder count = doubled
failure surface, so this is worth pinning down.
- Tighten stdio param to a 3-tuple so an accidental 1-element array fails
at type-check.
- Sharpen backpressure comment: write→true means "highWaterMark not
exceeded," not "libuv flushed." Reuse-without-corruption is safe only
because session.process is slow enough that libuv drains in between.
Addresses review on PR #637.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Emit an inverse-alpha background plate alongside the cutout in a single
inference pass. Same source RGB, alpha = 255 − mask. Dual-encoder pipeline
runs in parallel; both outputs share the same --quality preset.
This is a hole-cut plate (subject region transparent), not an inpainted
clean plate — composite something opaque under it to fill the hole.
Docs and skill cover when each is the right tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four regression tests (font-variant-numeric, many-cuts, missing-host-comp-id,
variables-prod) failed on this PR with `Unable to parse PSNR output at <last
checkpoint>s`. Root cause: the harness derived all 100 checkpoints from the
*rendered* video's container duration, then asked ffmpeg's PSNR filter to
compare the same frame index from both videos.
The encoder changes earlier in this PR add `-avoid_negative_ts make_zero` to
the mux step. With AAC audio that shifts the first audio sample to t=0
instead of the encoder-delay offset, extending reported container duration
by ~20ms without changing video frame count. For the four failing tests,
the i=99 checkpoint then landed on a frame index that exists in the rendered
video but not in the snapshot baseline (e.g. round(2.98998 * 24) = 72 in a
72-frame baseline). ffmpeg's PSNR filter ran on zero matched frames and
emitted no `average:` line, so the parser threw.
Fix: probe both videos and use min(rendered, snapshot) duration when
spreading checkpoints. This is the correct semantics for symmetric PSNR
comparison anyway — both videos must have a frame at every sampled time.
The change is local to the harness; no encoder behavior changes, no
baselines regenerated.
Other regression tests with audio (chat, sub-composition-video,
vignelli-stacking) passed because their checkpoint-99 frame index landed
inside the baseline's frame range with several frames of slack. The four
failing tests had round-number durations where a 20ms drift was enough to
push the last checkpoint past `nb_frames - 1`.
The model removes background from any video with a person — we tested
with avatars because they were convenient, but anyone can bring a
talking-head clip, presenter footage, vlog, etc. Replace avatar-specific
filenames (avatar.mp4 / brandon.mp4) with neutral subject.mp4 (or
presenter.mp4 in the text-behind-subject example) and rephrase
copy that read as if avatars were the only use case.
Touches docs/guides/remove-background.mdx, hyperframes-media SKILL.md,
and hyperframes/patterns.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- engine/chunkEncoder, engine/streamingEncoder: extend `-bf 0` to GPU h264
paths (nvenc, qsv, vaapi) and `-b_strategy 0` for qsv so GPU-encoded
outputs avoid negative-DTS freezes too — not just SW libx264.
- engine/videoFrameExtractor: detect mid-path traversal (e.g.
`assets/../../foo.mp4`) by normalizing first and re-anchoring at the
project root. Adds a regression test.
- engine/videoFrameExtractor: dedupe stderr "src not resolvable" warnings
by `video.src` so a comp with N broken sources logs once, not N times.
- engine/videoFrameExtractor.test: drop dynamic `require("node:fs")`,
use ES `import { writeFileSync } from "node:fs"`.
- engine/ffprobe: extract `readTagCI` helper for case-insensitive ffprobe
tag reads (will recur for other libavformat-versioned sidecar tags).
- cli/background-removal/pipeline: collapse Quality / QUALITIES /
QUALITY_CRF / DEFAULT_QUALITY / isQuality surface using
`Quality = keyof typeof QUALITY_CRF`.
- producer/renderOrchestrator: replace `v.src.startsWith("/")` with
`isAbsolute(v.src)` in the HDR probe path so Windows absolute paths
(`C:\...`) aren't treated as relative — matches the audioMixer guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tag-based alpha detection (alpha_mode / ALPHA_MODE / pix_fmt yuva*) is
fundamentally brittle. Failure modes seen in the wild:
- case-sensitivity across ffmpeg versions (alpha_mode vs ALPHA_MODE)
- older muxers that omit the sidecar tag entirely
- mp4-as-webm rewraps that drop the tag
- ffprobe reporting yuv420p for VP9-with-alpha because the alpha plane
lives in a Matroska BlockAdditional sidecar, not the main pix_fmt
Each of those silently strips alpha at extraction time. The bug doesn't
surface until the rendered output is missing layers — frustrating to debug,
silent in stdout. The previous case-insensitive fix patched one of the
failure modes; this commit removes the class.
The robust alternative is codec-based: any bitstream that CAN carry alpha
(VP9, VP8, ProRes 4444) gets the alpha-aware decoder and PNG output by
default, regardless of what the tag says. The cost is a small file-size
increase on opaque VP9/VP8 sources (cached PNGs vs JPGs); the benefit is
no class of silent alpha loss from tag misdetection.
- Adds codecMayHaveAlpha() + decoderForCodec() helpers and exports them.
- Updates extractVideoFramesRange to force libvpx-vp9 / libvpx for VP9 / VP8
unconditionally (was: only when metadata.hasAlpha).
- Updates resolveFrameFormat to default to PNG for any alpha-capable codec
(was: only when metadata.hasAlpha).
- +4 unit tests covering the codec table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locks in the case-insensitive behavior alongside the existing alpha_mode
(lowercase) test. If either path regresses, the producer would silently
extract alpha-having webms as opaque JPGs and the injected <img> overlays
would cover every element below them on the z-stack — a bug that doesn't
surface in the studio preview, only in production renders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Newer libavformat builds write the VP9-alpha sidecar tag as 'ALPHA_MODE'
(uppercase); older builds write 'alpha_mode'. ffprobe.ts only checked the
lowercase form, so files produced by recent ffmpeg encoders (including the
output of 'hyperframes remove-background' itself) were misclassified as
having no alpha channel. Knock-on effect: the producer extracted them as
JPGs (no alpha), the injected <img> overlays were fully opaque rectangles,
and any element below them on the z-stack (text, captions, other layers)
silently disappeared from the rendered output — even though the studio
preview rendered the same composition correctly via native <video> playback.
Symptom in our repro: a text-behind-subject composition showed the
headline correctly in studio preview but the production render covered
the headline entirely with the opaque avatar image.
Fix: read videoStream.tags.alpha_mode OR videoStream.tags.ALPHA_MODE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skill (hyperframes-cli): three-pattern table (cutout-over-different-scene
vs over-its-own-source vs over-different-take) + the two non-obvious rules
(wrap video in non-timed div for opacity control, both videos data-start=0
for sync). Skill (hyperframes/patterns): worked text-behind-subject example.
Docs: --quality flag, compositing pitfalls section, quality preset table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A <video src='../assets/foo.mp4'> inside a sub-composition silently dropped
from extraction; the rendered output froze on the first decoded frame for
the entire clip, with no error in stdout.
Root cause: browser URL resolver clamps '..' at origin root (studio preview
loads fine), but path.join(projectDir, '../assets/foo.mp4') normalizes to
parent-of-project/assets/foo.mp4, which usually doesn't exist. existsSync
returns false, extraction is skipped, no frame lookup is built, the
per-frame injector has nothing to swap, and the <video> element's first
decoded frame paints every screenshot.
- Adds resolveProjectRelativeSrc in videoFrameExtractor that mirrors browser
clamping (literal join first, then leading '..' stripped).
- Surfaces a loud stderr warning when the resolver misses.
- Mirrors fix in audioMixer.ts (same bug for <audio src='../'>) and
renderOrchestrator HDR probe loop.
- +6 regression tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related render robustness fixes:
1. frameCapture.ts: bump videos-ready check from `readyState >= 1`
(HAVE_METADATA — only dimensions known) to `>= 2` (HAVE_CURRENT_DATA —
first frame is rasterized). Without this, when two `<video>` elements
with different codecs (h264 mp4 + VP9 webm) decode at different rates,
the faster one passes readiness while the slower one still hasn't
painted, producing a black "first frame" for the slower clip.
2. chunkEncoder.ts (libx264 path) + streamingEncoder.ts: disable B-frames
for h264 (`-bf 0`). Standard libx264 with B-frames produces negative
DTS at stream start (the first B-frame's decode order is "before" the
first I-frame's presentation time). VS Code preview, several browser
<video> implementations, and some HW decoders freeze on the first
frame and only audio plays. -bf 0 makes PTS == DTS at every frame,
eliminating the issue at the source. Quality cost is ~5–10% larger
files at the same CRF — worthwhile for "the file plays everywhere".
3. chunkEncoder.ts (encoder + mux paths): add `-avoid_negative_ts make_zero`
as belt-and-suspenders against negative DTS sneaking back in via
`-c:v copy` mux passes when audio/video PTS bases differ.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- inference.ts: force `.toColourspace("b-w")` on the resized mask. Sharp upcasts
the 1-channel raw input to RGB-interleaved during resize, so `fullMask[i]`
was reading R,G,B,R,G,B... of pixels 0..691199 instead of the alpha for all
2,073,600 pixels. Visible symptom: horizontal scanline alpha artifact in
every transparent webm — the avatar appeared semi-transparent throughout.
- pipeline.ts: add BT.709 + limited-range colorspace tags so Chrome's YUV→RGB
matches the source mp4 (without these, ffmpeg's default RGB→YUV is BT.601
and skin tones drift visibly when the cutout is overlaid on its source).
- pipeline.ts: add Quality preset type ("fast"/"balanced"/"best" → CRF 30/18/12).
Default raised from CRF 30 → 18 ("balanced") so the most common pattern
(text-behind-subject) works out of the box without visible doubling.
- remove-background.ts: wire `--quality` flag with validation, +2 examples.
- Tests: BT.709 tags present, quality preset → CRF mapping, default is balanced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review on PR #619 caught two places that still pointed transcribe/tts
at hyperframes-cli — directly undercutting the description-trigger
goal of the split:
- skills/hyperframes/SKILL.md description ended with "For CLI commands
(init, lint, preview, render, transcribe, tts) see the
hyperframes-cli skill." Now splits the redirect: dev-loop commands
(init, lint, inspect, preview, render) → hyperframes-cli; asset
preprocessing (tts, transcribe, remove-background) →
hyperframes-media.
- packages/cli/src/templates/_shared/CLAUDE.md is the skills table
baked into every project bootstrapped by `hyperframes init`. Its
hyperframes-cli row still listed transcribe/tts. Trimmed to the
dev-loop commands and added a hyperframes-media row beside it, so
new projects pick up the correct mapping.
Also caught by greppping for stale skill lists:
- .codex-plugin/plugin.json longDescription bundled transcribe/tts
into "use the CLI for ...". Split into "use the CLI for the dev
loop (init/preview/render), preprocess assets
(tts/transcribe/remove-background)" so the Codex plugin store
surface matches reality.
Confirmed `npx hyperframes skills` shells out to `npx skills add
heygen-com/hyperframes --all` (packages/cli/src/commands/skills.ts),
so the skill list is read dynamically from the repo and picks up
hyperframes-media without code changes.
Code review found the new hyperframes-media skill was parallel
content with skills/hyperframes/references/tts.md and the "Whisper
Model Guide" section of transcript-guide.md — same voice table, same
.en-translates-non-English warning, same TTS→transcribe chain in
both places. Plus some scope creep in hyperframes-media (audio/video
HTML snippets that duplicate the canonical track docs in
hyperframes/SKILL.md:265+).
Consolidation:
- hyperframes-media is now the single source of truth for CLI
invocation, voice selection, multilingual phonemization, whisper
model selection, and the .en gotcha. Picked up the multilingual
prefix decoding from the deleted tts.md.
- skills/hyperframes/references/tts.md deleted; the bullet in
hyperframes/SKILL.md is removed (no replacement — agents land on
hyperframes-media via its own description).
- skills/hyperframes/references/transcript-guide.md keeps only the
caption-side concerns: input-format table, mandatory quality
check, cleaning JS, external-API import path, and the
"if no transcript exists" flow. The intro bash recipe and Whisper
Model Guide section both moved to hyperframes-media. Top of the
file now points to hyperframes-media for CLI/model details.
Other tightening in hyperframes-media:
- Dropped WHAT-narration filler and the inline <audio>/<video> HTML
snippets — they duplicate the canonical track-attribute docs in
hyperframes/SKILL.md.
- Added the `id` field (`w0`, `w1`, ...) to the transcript output
shape — the actual Word interface in
packages/cli/src/whisper/normalize.ts includes it (optional for
backwards compat), used by caption override logic.
- Compressed the TTS → Transcribe → Captions chain section.
Net: hyperframes-media 147 → 136 lines, transcript-guide.md 152 →
106 lines, tts.md gone (-75 lines).
Move tts/transcribe/remove-background guidance into a new
hyperframes-media sibling skill so the CLI skill stays focused on
the dev loop (init/lint/inspect/preview/render/doctor).
Two motivations:
1. Description bloat. The CLI skill listed every subcommand as a
trigger keyword, which made agents auto-load it for any mention
of audio, transcription, or backgrounds — even when the task
was just rendering a composition.
2. Body bloat. Voice tables, the .en-translates-non-English
whisper rule, and codec selection guidance all loaded on
every CLI invocation. With three preprocessing commands now
in the CLI (tts, transcribe, remove-background), this is only
going to grow.
The split keeps a single sibling (hyperframes-media), not three:
the commands share a workflow (preprocess asset → drop into
composition) and the same first-run-downloads-a-model pattern,
so they belong together. CLI skill now references hyperframes-media
from a one-paragraph "Asset Preprocessing" stub.
Doc references updated in README.md, CLAUDE.md,
docs/quickstart.mdx, and docs/guides/prompting.mdx.
Align the no-op timeline duration with the root's data-duration. The
fixture's root has data-duration="3" but the placeholder timeline tween
was still { duration: 2 } — leftover from when I bumped the duration
from 2s to 3s to dodge the PSNR-checkpoint-at-1.99s parse edge case.
The tween is a no-op (no targets, no visible effect) so rendered pixels
don't change; baseline still passes Docker regression at 100/100
checkpoints.
Reuse + efficiency reviews otherwise clean. Two findings deferred:
silence.wav duplication is real but only 2 fixtures share it today —
worth extracting to tests/_shared/ when the third fixture lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end Docker regression test that exercises the full variables
chain: meta.json renderConfig.variables → harness → createRenderJob →
RenderConfig → CaptureOptions → engine evaluateOnNewDocument →
window.__hfVariables → getVariables() → DOM text → rendered pixels.
Fixture (packages/producer/tests/variables-prod/):
- src/index.html: composition with three declared variables (title,
subtitle, bgColor) read via window.__hyperframes.getVariables() and
rendered as positioned text on a colored background. No animation —
keeps the regression frame-stable so it isolates "did the variables
flow through?" from motion concerns.
- meta.json: tags ["variables", "composition"] (runs in the existing
fast shard's tag filter), renderConfig.variables provides override
values the baseline reflects ("Override Title", "Override subtitle",
#0a3d62). Defaults would produce a visibly different frame, so a
failing baseline that reflects defaults means the variables didn't
propagate.
- output/output.mp4: Docker-generated baseline per the project's
CLAUDE.md golden-baseline rule.
Harness change (packages/producer/src/regression-harness.ts):
- TestMetadata.renderConfig gains an optional variables field,
validated as a JSON object in the meta.json validator.
- The createRenderJob call site forwards renderConfig.variables to
RenderConfig.variables, which the engine already consumes via
evaluateOnNewDocument (PR #600).
Verified: docker:test variables-prod passes 100/100 visual checkpoints
and audio correlation 1.000 against the committed baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on the PR #603 review. The previous fix named both attributes
but didn't make their distinct shapes / roles obvious; a reader could
still wonder "are these two views of the same data?". Now the doc
opens with the shape contrast (array of declarations vs object of
values) and the section closes with a numbered precedence layering so
the merge order is unambiguous.
- compositions.md: replaced the bullet list with a shape-first
description ("JSON array of declarations" vs "JSON object keyed by
variable id"), an explicit "they aren't redundant" line, and a
numbered list of the three precedence layers (declared default →
host data-variable-values → CLI --variables).
- skills/hyperframes-cli/SKILL.md: highlighted the same shape contrast
inside the parametrized-renders paragraph (declarations array vs
values object).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
James pointed out (compositions.md:33, SKILL.md:121) that the prose
referenced `data-variable-values` while the example below showed
`data-composition-variables`, leaving readers to wonder if the two
names referred to the same thing. They don't: one declares, the other
overrides per-instance. Both are now named at first mention and the
declare-vs-override split is called out explicitly.
- packages/cli/src/docs/compositions.md: replaced the single intro
sentence with a two-bullet list ("data-composition-variables
declares, data-variable-values overrides per-instance") and a
follow-up explaining where the CLI fits in.
- skills/hyperframes-cli/SKILL.md: rewrote the parametrized-renders
paragraph so declaration (data-composition-variables) and override
(--variables) are distinct sentences, with the per-instance attribute
parenthetical for completeness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Distribution PR for the variables feature stack: tells agents how to
declare, read, and override variables across the four authoring
surfaces.
skills/hyperframes/SKILL.md:
- Added data-variable-values + data-composition-variables to the
data-attributes tables (host element + <html> root respectively).
- New "Variables (Parametrized Compositions)" section right after
"Composition Structure". Three-step pattern (declare / read /
override), full worked example with enum variable, sub-comp
per-instance pattern with two hosts sharing a source, and rules
of thumb (always provide defaults; read once, not in frame loops;
use --strict-variables in CI; type validation behavior).
skills/hyperframes-cli/SKILL.md:
- Added --variables, --variables-file, --strict-variables to the
render flag table.
- Short paragraph below the table explaining the parametrized-render
pattern with a forward reference to the hyperframes skill.
docs/packages/core.mdx:
- Added a code snippet showing getVariables<T>() inside a composition
and validateVariables/formatVariableValidationIssue for tooling.
packages/cli/src/docs/compositions.md (the in-CLI `npx hyperframes
docs compositions` content):
- Replaced the hand-rolled JSON.parse(host.dataset.variableValues)
pattern with the modern getVariables() pattern.
This is PR 4 of the 4-PR stack. The openai/plugins mirror is a
separate follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- core.types.ts: export COMPOSITION_VARIABLE_TYPES, a runtime tuple of
every CompositionVariableType variant guarded by `as const satisfies
readonly CompositionVariableType[]`. Adding a new variant to the union
without also adding it to the tuple becomes a compile error rather
than silent drift in callers that maintain their own list.
- composition.ts (lint rule): the local
`new Set(["string","number","color","boolean","enum"])` now derives
from COMPOSITION_VARIABLE_TYPES instead of duplicating the list.
- index.ts: export COMPOSITION_VARIABLE_TYPES alongside the rest of the
variable type guards.
Reuse + efficiency reviews otherwise clean. The other reuse finding
(loadProjectHtml helper to dedupe readFileSync + ensureDOMParser across
3 callers) is real but reaches files outside this PR's scope; it's a
better fit as a follow-up cleanup once the variable-feature stack lands.
All 48 composition lint tests + 49 core suite tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two lint rules + render-time validation built on top of the existing
data-composition-variables schema.
Lint rules (packages/core/src/lint/rules/composition.ts):
- invalid_variable_values_json — host's data-variable-values must parse as
a JSON object. Today the runtime swallows parse failures silently and
falls back to declared defaults, masking typos.
- invalid_composition_variables_declaration — root <html>'s
data-composition-variables must parse as an array of objects with
`id` (string), `type` (one of string/number/color/boolean/enum), `label`
(string), and `default`. Per-entry findings report which fields are
missing or invalid.
Both rules read attributes via a new `readJsonAttr` helper in lint/utils.ts.
The existing `readAttr` regex `["']([^"']+)["']` truncates JSON-in-attribute
values at the first internal quote (e.g. `data-variable-values='{"x":"y"}'`
captures only `{`); `readJsonAttr` alternates double-vs-single-quoted
branches with quote-specific char classes so JSON values round-trip cleanly.
A second helper `findHtmlTag` returns the actual <html> open tag (where
data-composition-variables lives) — distinct from `findRootTag` which
returns the first in-body composition element.
Render-time validation (packages/core/src/runtime/validateVariables.ts):
- validateVariables(values, declarations) returns a structured array of
issues: undeclared keys, type mismatches, enum-out-of-range values.
Pure / sync; works in any environment.
- formatVariableValidationIssue(issue) renders a one-line user-facing
string for CLI output.
- Both exported from @hyperframes/core for studio/tooling reuse.
CLI integration (packages/cli/src/commands/render.ts):
- New --strict-variables flag. Default behavior: print warnings and
continue. With --strict-variables: print warnings then exit 1.
- New `validateVariablesAgainstProject(indexPath, values)` helper:
reads the project's index.html, runs extractCompositionMetadata to
pull the declared schema, validates the CLI's --variables payload
against it. ensureDOMParser polyfill for Node-side parsing (same
pattern as compositions.ts).
Tests:
- 11 new validateVariables unit tests covering happy path, undeclared
keys, type mismatches (string/number/boolean/color/enum), enum range,
multiple-issue aggregation, and formatter output.
- 11 new composition.test.ts cases for both lint rules: parse errors,
shape errors, per-entry validation, unknown types, missing fields,
positive cases.
- 5 new render.test.ts cases for validateVariablesAgainstProject:
no-declarations, happy path, undeclared, type-mismatch, missing-file.
- All 646 core tests + 213 cli tests still green.
Docs:
- docs/packages/cli.mdx — added --strict-variables flag row.
This is PR 3 of a 4-PR stack. PR 4 ships skill/scaffold distribution.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- compositionLoader.ts: drop the redundant inline `Window` cast; the
ambient `__hfVariablesByComp?` declaration in runtime/window.d.ts
already covers it within the same package.
- compositionScoping.test.ts: drop the `__captured: undefined as unknown`
initializers — `Record<string, unknown>` already permits the key, the
init was noise.
Reuse + efficiency reviews returned clean. The scoped getVariables's
per-call Object.assign({}, scoped) is consistent with the file's
existing scoped-utility conventions (gsap proxy returns fresh bound
functions per access) and acceptable since the idiomatic usage
destructures once at script init.
All 44 touched core tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Building on PR 1's getVariables() helper, this PR routes per-instance
values into the correct sub-composition. Same composition source can
now be embedded N times with different content via data-variable-values
on each host element.
How it works:
- compositionLoader, before injecting wrapped scripts, layers the host
element's data-variable-values JSON over the sub-comp's declared
defaults (its own data-composition-variables) and writes the merged
object to window.__hfVariablesByComp[compositionId]. Skipped when
both sides are empty so the table only grows for instances that
actually carry values.
- compositionScoping's wrapper IIFE now takes a fourth parameter
__hyperframes alongside the existing scoped document/gsap/window.
The scoped __hyperframes shadows getVariables() to read from
__hfVariablesByComp[__hfCompId], returning a fresh object each call
so script mutations don't leak into the shared table.
- Top-level scripts (not wrapped by compositionScoping) keep using the
unscoped window.__hyperframes.getVariables(), which reads
data-composition-variables defaults plus the CLI override
(window.__hfVariables) — same path as PR 1.
- readDeclaredDefaults is exported from getVariables.ts so the loader
reuses the exact same defaults-extraction logic the helper uses for
the top-level path.
Inline templates (no separate <html> document root) get host overrides
only — no declared defaults — since there's no separate <html> to read
data-composition-variables from. External sub-comps fetched via
data-composition-src get the full declared defaults + host overrides
merge.
Tests: 3 new compositionScoping tests covering scoped getVariables
invocation, missing-entry fallback, and mutation isolation. 5 new
compositionLoader tests covering merge order, declared-only path,
empty-skip, invalid-host-JSON resilience, and per-instance scoping
across two hosts sharing a source. 3 new getVariables tests covering
the newly-public readDeclaredDefaults. All 622 core tests green.
Docs: docs/concepts/compositions.mdx switched its sub-comp example from
hand-rolled JSON.parse(host.dataset.variableValues) to the new
__hyperframes.getVariables() pattern. data-attributes.mdx clarifies
per-instance scoping behavior.
This is PR 2 of a 4-PR stack. PR 3 adds schema validation + lint;
PR 4 ships skill / scaffold updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address Miguel's review on #612.
- Normalization std was (1, 1, 1) — that's the base u2net session, not
u2net_human_seg. Switch to ImageNet (0.229, 0.224, 0.225) to match
rembg's U2netHumanSegSession reference. Add a parity test pinning the
exact MEAN/STD values.
- waitForExit treated `code === null` as success, but per Node child_process
docs that's the signal-killed case — a SIGTERM'd ffmpeg encoder was
reporting success with a partial output. Switch to (code, signal) and
reject with the signal in the error message. Add four signal-handling
tests (clean exit, signal-killed, non-zero code, SIGKILL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `hyperframes remove-background` — a local-AI subcommand that mattes a
video or image with the u2net_human_seg ONNX model and emits a transparent
WebM (VP9-alpha), ProRes 4444 .mov, or RGBA PNG. Drops directly into any
composition's <video> tag — no green screen, no API keys, no upload.
Auto-picks the fastest available execution provider via onnxruntime-node:
CoreML on Apple Silicon, CUDA when HYPERFRAMES_CUDA=1, CPU otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- core/runtime/getVariables.ts: collapse the noisy three-step type-guard
re-cast into a single `Record<string, unknown>` narrow with early-continue
guards. Same behaviour, ~6 lines shorter.
- cli/commands/render.ts: separate VariablesParseError from UI strings.
parseVariablesArg now returns a kind-discriminated error
(`conflict | read-error | parse-error | shape-error`) and the wrapper
resolveVariablesArg owns the title/message mapping via
`variablesErrorMessage`. Keeps the parser pure of presentation strings.
- cli/commands/render.test.ts: lift the `await import("./render.js")` into
a `beforeAll`, add a typed `expectErr` helper, assert on the structured
error kind instead of message-string regexes. Same coverage, less noise.
- engine/services/frameCapture.ts: replace the `as unknown as { ... }`
double-cast with a single named `WindowWithVariables` alias inside the
page closure.
All affected suites green (core getVariables 9, cli render 12, cli
dockerRunArgs 13).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the parametrized-render primitive from hf#592 by reusing the existing
data-composition-variables schema as the source of declared defaults.
- Runtime helper window.__hyperframes.getVariables() (also exported from
@hyperframes/core) reads data-composition-variables defaults from the
document root and merges window.__hfVariables (CLI override) on top.
Returns Partial<T> for typed access; supports a generic for editor
ergonomics. Same code path runs in dev preview and at render time.
- CLI render --variables '<json>' / --variables-file <path> populates the
override. Mutually exclusive; fail-fast on conflicting flags, missing
file, unparseable JSON, or non-object payloads. parseVariablesArg is
exported as a pure function so validation paths stay unit-testable.
- Engine injects window.__hfVariables via evaluateOnNewDocument before
any page script runs, so the helper sees the merged values on its
first call. Empty payloads are skipped to avoid pointless init scripts.
- Producer threads variables through RenderConfig and into the engine's
CaptureOptions; Docker mode forwards --variables to the in-container
CLI invocation via dockerRunArgs.
Composition authors declare variables once on the root <html> element:
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Hello"}
]'>
and read them in any composition script:
const { title } = window.__hyperframes.getVariables();
A render with `--variables '{"title":"Q4 Report"}'` overrides the default
without modifying the composition source. Missing keys fall through to
the declared defaults, so dev preview and CLI renders without --variables
behave identically.
This is PR 1 of a 4-PR stack. Sub-comp per-instance scoping (carrying
host data-variable-values through the inlined sub-comp's getVariables()
call) lands in PR 2; schema validation and lint in PR 3; skill / scaffold
distribution in PR 4.
Tests: 9 new unit tests for getVariables() (jsdom), 11 new CLI tests
covering parseVariablesArg validation paths and Docker passthrough,
2 new dockerRunArgs assertions for the --variables flag. All existing
tests green (core 611, cli 208, engine 519).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the canonical ADOPTERS.md table at the repo root and adds a
Mintlify CardGroup for visual presentation. Logos are intentionally
optional — orgs can self-add via PR with just the table row, and
upgrade to a logo later.
Wires the page in under a new Community group in the nav.
Lists organizations using HyperFrames in production or actively
evaluating it, with HeyGen as the first entry. Lowers the barrier for
new users to find peers shipping with HyperFrames and gives the
community a public record of where the project is being used.
Adoption is opt-in — orgs add themselves via PR, or reach out on
Discord if they prefer not to be listed publicly.
User feedback (jasonpurdy on X, https://x.com/jasonpurdy/status/2049985508701556855)
flagged that the remotion-to-hyperframes skill auto-triggered during an A/B
test of HyperFrames vs Remotion, producing a translated output instead of a
native HyperFrames composition. The user preferred the native version once he
disabled the skill.
The previous SKILL.md description listed four triggering conditions, three
of which were context-detection patterns (the user provides Remotion source,
pastes a Remotion entry point, links a Remotion repo). Agents could
interpret any of those as authoritative even when the user wasn't asking for
a migration.
Tighten the trigger gate so the skill only fires on an explicit migration
verb (port, convert, migrate, translate, rewrite as HyperFrames). Add
explicit NOT clauses for the common false-positive cases — including the
specific A/B-test case (the same video as my Remotion one — treat as a
fresh build). Default recommendation when uncertain: use the hyperframes
skill instead.
The body of the SKILL.md is unchanged — translation guidance is correct
once the gate is passed; this only tightens the gate itself.
The leaf PR. Replaces the placeholder SKILL.md from PR 1 with the real
5-step workflow that loads the per-topic references on demand
(skill-creator's progressive-disclosure pattern), and adds a top-level
orchestrator that runs every tier and reports a pass/fail summary.
SKILL.md changes:
- Frontmatter unchanged from PR 1 (already covers the trigger phrases
and out-of-scope cases)
- Body rewritten as a 5-step workflow:
1. Lint (load escape-hatch.md if blockers)
2. Plan (load api-map.md, then per-topic references on demand)
3. Generate (HF index.html with paused GSAP timeline)
4. Validate (render_diff.sh against per-tier threshold)
5. Document gaps (TRANSLATION_NOTES.md if needed)
- Includes a "Source contains -> Load reference" table so the agent
only loads the references the source actually needs
- Documents the validated baseline numbers (T1 0.974, T2 0.985,
T3 0.953, T4 8/8) so reviewers can reproduce
- Calls out the critical Remotion encoder config (PNG + BT.709) that
avoids the ~0.05 SSIM hit from yuvj420p vs yuv420p
Orchestrator (assets/test-corpus/run.sh):
- Iterates tier-1-* through tier-4-* directories
- T1-T3: setup -> lint -> npm install (lazy) -> render Remotion ->
render HF -> SSIM diff at the fixture's expected threshold ->
generate strip on failure
- T4: validate.sh (lint-only)
- Emits run-report.json with per-tier pass/fail and aggregate counts
- Accepts a single-tier argument for fast iteration: ./run.sh tier-1-title-card
Validated end-to-end on a clean checkout:
▶ tier-1-title-card → mean SSIM 0.9739 (≥ 0.95) ✓
▶ tier-2-multi-scene → mean SSIM 0.985292 (≥ 0.95) ✓
▶ tier-3-data-driven → mean SSIM 0.952941 (≥ 0.9) ✓
▶ tier-4-escape-hatch → 8/8 cases ✓
passed 4/4, failed 0, skipped 0
Closes the 7-PR stack: scaffold, eval harness, 4 tiers of corpus,
references, and now the SKILL.md body that ties everything together.
Adds 11 progressively-disclosed reference files that the skill loads on
demand during translation. Total ~1500 LOC, every file under 200 lines
(skill-creator's progressive-disclosure budget).
api-map.md the comprehensive Remotion -> HF translation table
(the index; loaded at start of translation)
timing.md interpolate, spring (validated configs), easing,
count-up, stagger
sequencing.md Sequence, Series, Loop, Freeze, AbsoluteFill,
Composition root
media.md Audio, Video, Img, IFrame, OffthreadVideo,
staticFile, asset paths
transitions.md @remotion/transitions presentations -> manual GSAP
crossfades or HF shader-transitions
lottie.md @remotion/lottie -> HF lottie adapter (incl. AE
feature limitations note)
fonts.md Google Fonts loading, local @font-face, system
fallback noise floor
parameters.md Zod schemas, defaultProps, sync vs async
calculateMetadata
escape-hatch.md when to bow out + the runtime interop pattern
from PR #214
limitations.md known caveat patterns (volume ramps, Loop with
state, custom presentations, code-split components)
eval.md how to run the validation harness, threshold rule
of thumb, what the noise floor looks like
The references are evidence-driven rather than speculative: every spring
config, easing curve, and SSIM threshold is documented from the
validated T1/T2/T3 calibration runs (mean 0.974 / 0.985 / 0.953). The
escape-hatch boundaries match the lint blockers in PR 2 and the T4
fixtures in PR 5.
Replaces the placeholder .gitkeep from PR 1.
Adds the escape-hatch tier — lint-only fixtures that test the skill's
ability to refuse translation cleanly when it sees patterns that don't map
to HF's seek-driven model.
Cases (8 total):
01-use-state.tsx blocker: r2hf/use-state
02-use-effect-deps.tsx blocker: r2hf/use-effect-deps (multi-line body
with internal commas — regression target for
the regex bug fix in PR 2)
03-async-metadata.tsx blocker: r2hf/async-metadata
04-third-party-react.tsx blocker: r2hf/third-party-react-ui (@mui/material)
05-lambda-config.tsx blocker: r2hf/lambda-import
06-warnings-only.tsx warnings: delayRender / useCallback / useMemo
(no blockers — translates after dropping wrappers)
07-custom-hook.tsx warning: r2hf/custom-hook (pure useFadeIn)
08-mixed.tsx multiple blockers + warnings (aggregate test)
Each case documents:
- The Remotion pattern it demonstrates
- Why it's a blocker / warning / info
- What the skill should do (refuse / drop-and-translate / translate-as-is)
Validation harness (validate.sh):
Runs lint_source.py against each case, asserts:
- Each expected blocker rule fires with severity="blocker"
- Each expected warning rule fires with severity="warning"
- lint_source.py exit code is 1 when blockers expected, 0 otherwise
T4 has no renders to diff. The skill is graded on lint correctness — that's
the gate that decides whether to translate or recommend the runtime interop
pattern from PR #214.
Result: 8/8 cases pass.
Adds the data-driven tier — a purpose-built fixture (option 2 from the
stack discussion, not a port of PR #214's examples/remotion-full/) that
exercises the realistic shape of a production Remotion composition
without using the runtime adapter.
Stargazed.tsx (10s @ 30fps, 1280x720):
Sequence 0-3s TitleScene (title + subtitle)
Sequence 3-7s StatsScene (3 reused StatCards staggered 12 frames apart)
Sequence 7-10s OutroScene (UnderlinedText with scaleX-from-left underline)
Composition shape exercises:
- <Composition schema={z.object({...})} defaultProps={...} />
- nested array prop (stats[]) materialized as repeated HTML
- custom React subcomponents (StatCard, AnimatedNumber, UnderlinedText)
reused with different props
- per-instance delay via prop (delayInFrames -> GSAP timeline offset)
- frame-driven count-up (AnimatedNumber, manual cubic ease-out)
- two different spring configs in the same composition
(damping:12 -> back.out(1.4), damping:14 -> back.out(1.2))
- useCurrentFrame, useVideoConfig
Translation choices documented in README.md and expected.json:
- Zod props -> data-* on root #stage div
- Custom subcomponents inline as repeated HTML using prop interface
as the template
- AnimatedNumber's frame-driven count-up -> GSAP onUpdate tween on a
{ v: 0 } counter object, ease power3.out
- Two different spring configs -> two different back.out overshoots
(1.4 vs 1.2 approximates the damping difference)
- delayInFrames={i * 12} -> GSAP offset (i * 0.4)s
Validated end-to-end: rendered Remotion baseline + HF translation, ran
scripts/render_diff.sh.
measured mean SSIM 0.953
measured min SSIM 0.927
measured p05 SSIM 0.938
threshold 0.90 (~0.04 below p05)
The wider gap vs T1/T2 reflects T3's bigger approximation budget
(2 spring instances + count-up timing + font fallback on multiple text
sizes). Mean SSIM below 0.90 = structural mismatch (wrong durations,
wrong stagger, missing prop wiring), not approximation drift.
Same Remotion config as PR 3: setVideoImageFormat("png") +
setColorSpace("bt709") to match HF's yuv420p output.
Lint: 9 files scanned, 0 blockers / 0 warnings / 0 infos.
oxlint, oxfmt, typecheck all pass.
The fixture is not yet wired into CI; render + diff is documented in
README.md and runs by hand via the harness from PR 2. PR 7's orchestrator
will wire all four tiers into a CI eval run.
Adds the first two test fixtures the skill is graded against. Each fixture
ships:
- remotion-src/ full Remotion project (package.json, src/, remotion.config.ts, tsconfig.json)
- hf-src/ hand-translated HyperFrames composition (index.html)
- expected.json tier metadata + SSIM threshold + translation notes + measured validation
- README.md human walk-through of the translation choices
- setup.sh (T2 only) generates binary assets (PNG, WAV) via ffmpeg
T1 — title-card-fade
- 3 s @ 30 fps, 1280x720
- Single AbsoluteFill, single useCurrentFrame interpolate
with multi-segment input [0,15,75,90] -> [0,1,1,0]
- Validated mean SSIM 0.974, threshold 0.95
(~0.025 gap from font-fallback divergence between Remotion's bundled
Chromium and HF's chrome-headless-shell)
T2 — title-image-outro
- 6 s @ 30 fps, 1280x720, three Sequences (TitleScene, ImageScene, OutroScene)
- Exercises spring, interpolate, Audio, Img, staticFile
- Spring -> GSAP back.out(1.4) translation
- Validated mean SSIM 0.985, threshold 0.95
(translation came out cleaner than predicted; spring->back.out drift was
smaller than the ~0.05 budget I'd expected)
- setup.sh generates a 200x200 blue PNG and a 6 s silent WAV via ffmpeg
so binaries stay out of the repo
Calibration done end-to-end: rendered Remotion baseline + HF translation,
ran scripts/render_diff.sh, set thresholds ~0.02 below measured p05.
Critical Remotion config: setVideoImageFormat("png") + setColorSpace("bt709").
The default JPEG output writes yuvj420p (full-range) which costs ~0.05 SSIM
vs HF's yuv420p (limited-range). Both fixtures' remotion.config.ts encode
this so render_diff.sh measures translation fidelity, not encoder differences.
Both fixtures lint clean (0 blockers via scripts/lint_source.py).
T2 staticFile() references correctly flagged as info-level findings.
The fixtures are not yet wired into CI — that comes with PR 7's orchestrator.
For now, render and eval are documented in each README and run by hand.
Adds the deterministic eval primitives the skill calls into:
scripts/render_diff.sh SSIM diff between two MP4s, JSON summary, configurable threshold
scripts/frame_strip.sh side-by-side comparison strip for visual debugging
scripts/lint_source.py pre-translation lint over Remotion source — blocks/warnings/infos
The harness is decoupled from the render pipeline: it accepts paths to
already-rendered MP4s. The skill orchestrator (PR 7) drives both renders
and feeds the outputs in. This keeps the harness usable in CI, in
sandboxes, and on any machine that has ffmpeg without needing the full
Remotion + HyperFrames toolchain.
Lint catches the patterns from the skill's out-of-scope list:
- useState / useReducer (state-machine driven animation)
- useEffect with deps (side effects)
- async calculateMetadata (Promise-returning composition metadata)
- @remotion/lambda imports
- third-party React UI libraries (MUI, Chakra, Mantine, antd, shadcn, Radix, NextUI)
- delayRender / useCallback / useMemo (warnings)
- staticFile / interpolateColors (info — translatable but flagged)
Smoke test (scripts/tests/smoke.sh) exercises all three scripts against
synthetic inputs: identical ffmpeg testsrc videos pass at threshold 0.99,
different ffmpeg testsrc videos fail at 0.99, frame_strip produces a
strip.png, lint produces 0 blockers on a clean fixture and >=3 blockers
on a fixture that uses useState + useEffect + MUI + async metadata.
Validated locally: smoke.sh exits 0.
Adds the directory + SKILL.md frontmatter for a new skill that translates
Remotion (React) compositions to HyperFrames (HTML+GSAP). This is the
foundation PR; subsequent PRs in the stack add the eval harness, test
corpus, translation references, and finally the SKILL.md body.
The frontmatter description enumerates trigger phrases and explicit
out-of-scope cases (useState/useEffect, async metadata, @remotion/lambda)
so the skill bows out cleanly when a Remotion composition isn't a clean
translation target — those should use the runtime interop pattern from
PR #214 instead.
Validated with skill-creator's package_skill.py.
## What
Forwards the new per-phase extraction breakdown and `tmpPeakBytes` fields from `RenderPerfSummary` (added in #444 and #446) to PostHog via the CLI's existing `render_complete` telemetry event.
## Why
The CLI already ships `render_complete` events to PostHog (`packages/cli/src/telemetry/client.ts`), but `events.ts:trackRenderComplete` only carried a subset of `RenderPerfSummary` — top-level timings, composition dims, and memory snapshots. After #444 added per-phase extraction breakdown (`videoExtractBreakdown`) and #446 added cache hit/miss counters, the data lives on `job.perfSummary` at render-complete but never reaches PostHog dashboards.
Without this, any PostHog insight built around "how often are we hitting the cache?", "what's the median HDR preflight cost?", or "where in the extract phase do compositions spend time?" has to be answered by Datadog log scraping instead.
## How
- **`packages/cli/src/telemetry/events.ts`** — extend `trackRenderComplete` props with 17 new optional fields: `tmpPeakBytes`, the six named stage timings, and the ten `videoExtractBreakdown` fields. All sent as flat properties (`extract_cache_hits`, `stage_capture_ms`, etc.) — PostHog insights query flat keys more ergonomically than nested objects.
- **`packages/cli/src/commands/render.ts`** — wire `job.perfSummary.videoExtractBreakdown` / `stages` / `tmpPeakBytes` into the `trackRenderMetrics` → `trackRenderComplete` hand-off.
- Naming: `extract_phase3_ms` deliberately disambiguates from `stage_video_extract_ms` — the former is just the parallel ffmpeg extract inside Phase 3; the latter is the full stage (resolve + probe + preflight + extract).
- All new fields are optional. The Docker-subprocess branch of `render.ts` that doesn't have a local `perfSummary` still compiles and ships events without them.
## Test plan
- [x] `bun run --cwd packages/cli test` — 161/161 pass
- [x] `bunx tsc -p packages/cli/tsconfig.json --noEmit` — no errors
- [x] `bunx oxlint` + `bunx oxfmt` — clean
- [ ] Once merged, verify PostHog receives the new properties on a real render event (run `hyperframes render` against a fixture and watch PostHog ingestion — telemetry auto-disables in CI, so this requires a local dev render with `HYPERFRAMES_NO_TELEMETRY` unset).
## Stack
Depends on #444 (adds the `videoExtractBreakdown` + `tmpPeakBytes` fields to `RenderPerfSummary`) and transitively on #445 → #446.
## Future work (not in this PR)
- The HeyGen internal producer server (`hyperframes-internal/packages/producer/src/server.ts`) logs `perfSummary` to Datadog via `log.info` but has no PostHog integration. Production renders are the bulk of the traffic — separate PR to either ship perfSummary to PostHog from the internal server, or materialize Datadog log-based metrics for per-phase timings.
## What
Adds a content-addressed cache for extracted video frames, keyed on the tuple `(path, mtime, size, mediaStart, duration, fps, format)`. Repeat renders of the same composition (studio edit → re-render, preview → final) skip the ffmpeg extraction entirely.
## Why
Video frame extraction is the dominant non-capture phase for video-heavy compositions. Studio iteration workflows extract the same frames over and over — each render burns ffmpeg time that adds no value.
Validated on `/tmp/hf-fixtures/cfr-sdr-cache`:
```
Cold (miss): extractMs=69, videoExtractMs=70, totalElapsedMs=2052
Warm (hit): extractMs=1, videoExtractMs=2, totalElapsedMs=1964
cacheHits: 0→1, cacheMisses: 1→0
```
The fixture is tiny (3s CFR SDR @ 30fps), so the wall-clock delta is small; the extraction-time delta (69→1ms, 98%) scales linearly with source length. For heavy-iteration workflows (a user rendering the same composition while tuning encoding params), extraction time goes to zero on every repeat render.
Depends on #444 (instrumentation surface) and #445 (segment-scope HDR preflight — otherwise cache keys would be unstable across renders on mixed-HDR compositions).
## How
- New `packages/engine/src/services/extractionCache.ts`:
- SHA-256 key over a stable JSON encoding of `(path, mtime_ms, size, mediaStart, duration, fps, format)`. Infinity duration is normalized to `-1` so unresolved natural-duration sources still produce stable keys.
- Truncates to 16 hex chars in the entry directory name — 64 bits of entropy is plenty at cache scale and keeps `ls` output short.
- `hfcache-v2-` schema prefix — bumping it invalidates old entries (callers own gc policy; the cache owns keys).
- `.hf-complete` dotfile sentinel. An entry dir without the sentinel is treated as a miss (covers crash-mid-extract and abandoned writes); the next render re-extracts over the partial frames with `-y`.
- `FRAME_FILENAME_PREFIX = "frame_"` shared with the extractor — future refactors only need to touch one place to rename frames.
- `EngineConfig.extractCacheDir` (env: `HYPERFRAMES_EXTRACT_CACHE_DIR`) gates the feature. Undefined disables caching — extraction runs into the render's workDir and cleanup removes it on render end, preserving the prior behaviour exactly. No default root is chosen by the engine; the caller (CLI, app, studio) owns the location policy.
- `ExtractedFrames.ownedByLookup` flag prevents `FrameLookupTable.cleanup` from rm'ing a shared cache dir at render end. Set to `true` on both hits and misses (misses own the directory they wrote into, but hand it over to the cache rather than deleting it).
- Phase 3 extractor flow:
1. Snapshot `(videoPath, mediaStart, start, end)` per resolved video BEFORE Phase 2a/2b preflight mutates them — so cache keys are stable across renders that use workDir-local normalized files (those files have fresh mtimes every render).
2. Compute key, `lookupCacheEntry`.
3. On hit: rebuild `ExtractedFrames` from the cache dir plus the Phase 2-probed `VideoMetadata` — no re-ffprobe.
4. On miss: `ensureCacheEntryDir`, extract with `extractVideoFramesRange(..., outputDirOverride)`, then `markCacheEntryComplete` (the sentinel write is the last step so a crash leaves the dir un-sentineled).
- `extractVideoFramesRange` gains an `outputDirOverride` parameter so cache-miss writes land directly in the keyed dir (no `join(outputDir, videoId)` wrapping).
## Test plan
- [x] 19 unit tests in `extractionCache.test.ts` covering key determinism, mtime/size invalidation, format/fps/mediaStart/duration invalidation, Infinity normalization, sentinel semantics, missing-file tolerance
- [x] 2 integration tests in `videoFrameExtractor.test.ts`:
- "reuses extracted frames on a warm cache hit" — asserts `cacheHits=1`, `extractMs<50ms` on second call against a CFR SDR fixture
- "invalidates the cache when fps changes" — different fps on second call forces a new miss
- [x] End-to-end validation with `HYPERFRAMES_EXTRACT_CACHE_DIR` set, two runs of the same fixture
- [x] Lint + format (oxlint + oxfmt)
- [x] Typecheck (engine + producer)
## What
Scopes the SDR→HDR preflight re-encode to the segment the composition actually uses, mirroring the existing VFR→CFR segment-scope fix.
## Why
`convertSdrToHdr` was re-encoding entire source files, so a 30-minute SDR screen recording contributing a 2-second clip in a mixed HDR/SDR composition ate multi-second preflight time that produced frames no one would ever read. Validated on a mixed 30s-SDR + 2s-HDR fixture: `hdrPreflightMs` drops **87%** (1162→148ms), `videoExtractMs` drops **82%** (1272→231ms), `tmpPeakBytes` drops **45%** (8.2MB→4.5MB).
Depends on #444 (phase-level instrumentation) for the measurement surface.
## How
- `convertSdrToHdr` gains `startTime` and `duration` parameters ahead of the upstream `targetTransfer` arg added by #370. New signature: `convertSdrToHdr(input, output, startTime, duration, targetTransfer, signal, config)`. `-ss $start -t $duration` is added to the ffmpeg args.
- Phase 2 now captures the full `VideoMetadata` per `resolvedVideos` entry (previously just `colorSpace`) so the caller can compute `segDuration` from `video.end - video.start` with a fallback to `metadata.durationSeconds - video.mediaStart` for unbounded (Infinity) clips — without firing another ffprobe.
- After a successful convert, `entry.video.mediaStart` is zeroed out via shallow-copy (doesn't mutate the caller's `VideoElement`) so downstream extraction seeks from 0 instead of the original offset. Mirrors what the VFR→CFR path already does.
## Test plan
Validation on `/tmp/hf-fixtures/hdr-sdr-mixed-scope`:
```
hdrPreflightMs: >1000 → 150 (gate: <300) ✓
videoExtractMs: 1272 → 237 (-82%)
tmpPeakBytes: 8.2MB → 4.5MB (-45%)
```
- [x] Unit test: new regression test synthesizes 10s SDR + 2s HDR fixture inline and asserts the converted file's duration matches the 2s used segment (pre-fix matched the 10s source)
- [x] Lint + format
- [x] Typecheck
- [x] Manual perf validation against synthesized fixture
## What
Adds per-phase timings and counters to `extractAllVideoFrames` and surfaces them on the producer's `RenderPerfSummary` as `videoExtractBreakdown` alongside a new `tmpPeakBytes` workDir size sample.
## Why
Phase 2 video extraction has five distinct sub-phases (resolve, HDR probe, HDR preflight, VFR probe, VFR preflight, per-video extract) and today they collapse into a single `videoExtractMs` stage timing. That makes every subsequent perf PR in this stack immeasurable — you can't tell whether a win came from cache hits, preflight scope reduction, or pure extraction speed.
This PR is foundational for PR #445 (segment-scope HDR preflight) and PR #446 (content-addressed extraction cache).
## How
- New `ExtractionPhaseBreakdown` type with `resolveMs`, `hdrProbeMs`, `hdrPreflightMs/Count`, `vfrProbeMs`, `vfrPreflightMs/Count`, `extractMs`, `cacheHits`, `cacheMisses`. Populated inline with `Date.now()` wrappers — overhead is sub-millisecond on every phase.
- Returned on `ExtractionResult.phaseBreakdown`.
- Producer extends `RenderPerfSummary` with `videoExtractBreakdown?: ExtractionPhaseBreakdown` and `tmpPeakBytes?: number`. `tmpPeakBytes` is sampled from the workDir right before cleanup via a new recursive-size helper that swallows errors (purely observational — a missing workDir must never fail the render).
No changes to the capture-lifecycle resource tracking — earlier versions of this instrumentation plumbed injector LRU stats through `RenderOrchestrator`, which conflicted hard with upstream #371 (`buildHdrCaptureOptions` refactor). Dropped that piece for a marginal observability loss.
## Test plan
Validation on `packages/producer/tests/vfr-screen-recording`:
```json
"videoExtractBreakdown": {
"resolveMs": 0, "hdrProbeMs": 0, "hdrPreflightMs": 0, "hdrPreflightCount": 0,
"vfrProbeMs": 0, "vfrPreflightMs": 166, "vfrPreflightCount": 1,
"extractMs": 97, "cacheHits": 0, "cacheMisses": 0
},
"tmpPeakBytes": 4578598
```
Total elapsed within noise of pre-PR baseline (2665 → 2673 → 3228ms across hosts).
- [x] Unit test: phase-breakdown assertion added to `videoFrameExtractor.test.ts`
- [x] Lint + format (oxlint + oxfmt)
- [x] Typecheck (engine + producer)
- [x] Manual perf validation against VFR fixture
Adds .cursor-plugin/plugin.json at the repo root alongside the existing
.codex-plugin/, so this repo is the single source of truth for the Codex
plugin AND the new Cursor Marketplace submission. Refreshes the shared
assets/logo.png + assets/icon.png to a 1024x1024 / 512x512 symbol-only
mark rasterized from docs/favicon.svg (white background), and renames the
marketplace display title from "HyperFrames" to "HyperFrames by HeyGen"
in both Codex and Cursor manifests.
No skill content changes; purely marketplace-visible branding and the new
Cursor manifest file.
* ci(regression): build test Docker image once, share across shards
Splits regression.yml into a `build-image` job + the existing
`regression-shards` matrix. The build job produces a Docker tarball via
`docker/build-push-action` with `outputs: type=docker,dest=...`, uploads
it as a GHA artifact (retention 1 day, gzip level 1), and each shard
downloads + `docker load`s it instead of rebuilding.
Measured on PR #419 regression runs before the change:
- Docker build step: ~234s per shard WITH GHA layer cache hit
- 11 shards × ~234s = ~43 min of runner time per PR just on redundant
image builds
Cold-cache cases are much worse — happening right now on PR #419 after
release commit b6f50ce bumped every `packages/*/package.json`, invalidating
the COPY layer that feeds `bun install --frozen-lockfile`. All 10 shards
are currently 25-30+ min into a parallel rebuild, thundering-herding
the same npm packages from 10 runners.
After this change:
- 1× build (~4 min warm, ~15 min cold) + 11× (download + `docker load`)
- Expected ~15-20s overhead per shard for artifact download + load
- Net savings: ~30-40 min of runner time per PR run on warm cache,
substantially more on cold cache
The build job doesn't checkout LFS — Dockerfile.test only COPYs source +
package manifests, never the golden baselines, so the image build never
needed LFS. Shards still need LFS for the tests/**/output/output.mp4
baselines they validate against.
* ci(regression): add explicit least-privilege permissions
Addresses CodeQL warning 'Workflow does not contain permissions'.
Defaults the workflow GITHUB_TOKEN to `contents: read` only. The
build-image job elevates to `actions: write` because
`docker/build-push-action` with `cache-from/to: type=gha` uses the
GitHub Actions cache API, which needs read+write on the actions scope.
* docs(readme): note git-lfs requirement for full clones
Repo uses Git LFS for regression-test baselines (~240 MB of .mp4 files
under packages/producer/tests/**/output.mp4). Users cloning without
git-lfs installed hit a cryptic 'git-lfs: command not found' error, as
reported in #407.
Document the requirement with install instructions and the
GIT_LFS_SKIP_SMUDGE=1 escape hatch.
* docs(readme): add Windows install instructions for git-lfs
Per review from @miguel-heygen.
* chore(ci): fix oxfmt formatting on renovate.json
Drive-by to unblock CI. Landed unformatted in #422 because
Renovate's config-migration PR bypasses the lefthook pre-commit hook,
so every subsequent PR's `bun run format:check` (which scans the whole
repo) was failing on this file.
* ci: skip PR runs when targeting a non-main base branch
Adds `branches: [main]` to the `pull_request:` trigger of each workflow
that runs on PRs (CI, regression, Windows render verification, Docs,
Catalog Previews). PRs whose base is something other than main — typical
for stacked PRs — no longer trigger these workflows.
On a 5-PR Graphite stack this turns 5× CI runs into 1× (when the tip
of the stack reaches main). When a child PR is rebased/promoted so its
base becomes main, CI fires as normal.
publish.yml and the default CodeQL setup are untouched: publish already
filters to main, and CodeQL is default-setup (org UI, not a repo YAML).
* chore(ci): fix oxfmt formatting on renovate.json
Same drive-by as #423. Renovate's config-migration PR #422 landed
unformatted (Renovate bot skips lefthook), so every PR branched from
current main fails `bun run format:check`. Whichever of #423 / #426
merges first cleans it up.
Matches the pattern already in place on ci.yml, docs.yml,
windows-render.yml, and catalog-previews.yml. The regression workflow
was the only one without it.
Without this, rapid pushes to a PR leave prior regression runs still
executing their full matrix (~10 parallel shards across styles-a..g,
fast, render-compat, hdr) even though they'll be thrown away. On a busy
day this alone can eat a double-digit share of the GitHub hosted runner
pool and stretch queues for every open PR.
* fix(engine): auto-normalize VFR video inputs to CFR before frame extraction
Screen recordings (macOS ScreenCaptureKit, QuickTime, phone videos) are
commonly variable-frame-rate. When such inputs hit the extractor's
`-ss <start> -i <video> -t <dur> -vf fps=N` pipeline, the fps filter
can emit fewer frames than requested — for a 4-second 30fps segment
starting mid-file, the output was ~90 frames instead of 120.
`FrameLookupTable.getFrameAtTime` returns null for out-of-range indices,
so the compositor held the last valid frame and the user perceived the
video as freezing. This matches the bug report from an X community post
where a user said "all of them freezes" on their screen recording scenes.
The engine already detects VFR via `metadata.isVFR` in ffprobe.ts but
never acted on it — the compiler only logged a warning. This change
mirrors the existing SDR→HDR normalization pattern: when a source is
detected as VFR, re-encode only the used segment with
`-fps_mode cfr -r <fps> -preset fast -crf 18` before extraction.
Scoping the re-encode to `[mediaStart, mediaStart+duration]` means a
30-second clip cut from a 60-minute screen recording pays ~1s of
transcode cost, not 18s. Benchmarked locally:
Baseline (current): 32-39% duplicate frames, 25% frame-count
shortfall on mid-file segments.
Tier 1 (flag changes only): ~same — fps filter issue is not flag-fixable.
Tier 2 (CFR preflight): 1.7-6% duplicate frames, correct frame
count in every scenario tested.
The compiler warning that previously told users to manually re-encode
is downgraded to `console.info` since the engine now handles it.
— Rames Jusso
* refactor(engine): clean up VFR normalization loop after review
- Drop the `vfrNormDirCreated` flag; `mkdirSync({recursive:true})` is
idempotent and cheap.
- Don't re-wrap the `VFR→CFR conversion failed` prefix — `convertVfrToCfr`
already throws a message with that label; adding it again in the catch
produced "VFR→CFR conversion failed: VFR→CFR conversion failed (exit 1)".
- Shorten the Phase 2b header comment; the function docstring above
`convertVfrToCfr` already explains the failure modes and rationale.
- Note which frame windows the VFR fixture's select filter drops so the
magic numbers are scannable.
No behavior change; 311/311 engine tests still pass.
— Rames Jusso
* test(engine): add VFR regression unit tests
Adds a describe block that synthesizes a VFR fixture via ffmpeg and asserts
the extractor produces the expected frame count (no shortfall) and no long
runs of duplicate frames — the user-visible "frozen screen recording"
symptom. Covers both a mid-file segment and the full-file case.
Guarded with describe.skipIf(!HAS_FFMPEG) because the CI Test job on
ubuntu-24.04 and the Windows test-windows job don't install ffmpeg. The
producer-level regression test in packages/producer/tests/vfr-screen-recording/
runs inside Dockerfile.test (which has ffmpeg) and is the primary CI signal
for this bug; these unit tests are supplementary coverage for local and
any ffmpeg-equipped CI environment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(producer): add vfr-screen-recording regression test
End-to-end CI regression coverage for PR #360 via the existing
regression-harness: renders a 3s composition containing a real macOS
ScreenCaptureKit clip (r_frame_rate=120, avg≈36fps) seeked to
mediaStart=1, then PSNR-compares against a committed output.mp4.
Fixture src/clip.mp4 (108 KB) is a 5-second excerpt downscaled to 480×332
with -fps_mode passthrough to preserve the VFR timestamps. Content is the
public hyperframes OSS repo root page — see NOTICE.md for provenance.
With the fix applied, all 100 PSNR checkpoints pass. With the fix reverted,
66 of 100 fail (PSNR drops from ~43 dB to ~20 dB in the duplicate-frame
windows). Tagged "regression,video,vfr" so it runs in the fast shard
of .github/workflows/regression.yml automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(producer): regenerate vfr-screen-recording baseline in Docker
The committed golden output.mp4 was initially rendered on the host machine;
CI runs the renderer inside Dockerfile.test with a different Chrome +
ffmpeg build, producing pixel-level drift that failed PSNR at 54/100
checkpoints (~20 dB vs 41 dB in the VFR sparse-content windows). Both
renders are valid — the VFR source has inherent sampling ambiguity in
static segments, and different Chrome/ffmpeg builds make different valid
choices.
Regenerated the baseline via `bun run docker:test:update vfr-screen-recording`
so it matches the Docker environment CI actually uses. Matches the flow
the existing sub-composition-video, hdr-pq, etc. baselines were captured
with.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: document that producer test baselines must be captured in Docker
Hit this 2026-04-21 with the vfr-screen-recording regression test:
host-generated output.mp4 baseline tripped 54/100 PSNR checkpoints in CI
because Chrome + ffmpeg drift between the host and Dockerfile.test.
Document the `bun run --cwd packages/producer docker:test:update <name>`
flow so future contributors don't repeat the mistake.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(player): inject runtime immediately for nested compositions
Compositions that use `data-composition-src` on child elements require
the HyperFrames runtime to load those scenes — there is no way for the
iframe to render without it. The existing probe loop delayed runtime
injection behind a 5-tick attempts gate so the adapter path could try
to resolve a timeline first.
For nested compositions that race lost: a composition like the
`product-promo` registry example registers an inline pre-runtime GSAP
timeline at `window.__timelines["main"]` (covering only a partial
duration, e.g. 14s of a 20s master) while the iframe document loads.
The probe's adapter check finds that timeline and locks the player into
a "ready" state against it — which short-circuits the attempts gate and
the runtime never gets injected. The iframe ends up blank because the
runtime is what would have loaded the child scenes via
`data-composition-src`.
This change splits the injection decision into a pure helper,
`shouldInjectRuntime(state)`, and treats nested compositions as
"inject immediately, skip the gate." Self-contained GSAP-only
compositions retain the 5-tick grace period so the adapter path keeps
first shot for them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): propagate play/pause to all sibling timelines
Pausing or playing the master timeline only called `.pause()` / `.play()`
on `state.capturedTimeline` — the single adapter-selected timeline. In a
nested composition (a master with `data-composition-src` children), each
scene's own timeline is registered as a sibling in `window.__timelines`,
so they would keep advancing after the user clicked pause. The player UI
froze at the paused time while the visual content continued to animate,
eventually finishing all scene-level animations and landing on an empty
end-state.
Wire `window.__timelines` into the runtime player via a new
`getTimelineRegistry` dep, iterate the registry on play/pause, and
forward `timeScale` to siblings when play() starts so a changed
playback-rate applies uniformly.
Covered by 7 new unit tests in player.test.ts, including the identity-
equality check (don't double-invoke the master), playbackRate
propagation, a broken-sibling swallow, and a back-compat case with no
registry supplied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds honest Hyperframes vs Remotion comparison: README section with
paragraph + table + open-source-vs-source-available callout, plus a
full guide at docs/guides/hyperframes-vs-remotion.mdx walking through
the core React-vs-HTML decision, practical differences (including a
GSAP side-by-side), and licensing.
Closes#318
* feat(cli): add --lang and auto-infer phonemizer locale from voice prefix
`hyperframes tts` was calling Kokoro's `model.create(text, voice=, speed=)`
with no language argument, so Kokoro's default phonemizer (en-us) was
applied regardless of the voice selected. Picking `ef_dora` or `jf_alpha`
and feeding it Spanish or Japanese text produced English-phonemized
output.
Closes#349.
- `manager.ts`: add `SUPPORTED_LANGS`, `inferLangFromVoiceId`, and
`isSupportedLang`. Attach a `defaultLang` field to every bundled voice
and expand the bundled list with `ef_dora`, `ff_siwis`, `jf_alpha`,
`zf_xiaobei` so `--list` surfaces multilingual options.
- `synthesize.ts`: accept optional `lang: SupportedLang` in
`SynthesizeOptions`, forward it to the Python worker as `argv[7]`.
The worker introspects `Kokoro.create`'s signature and only passes
`lang=` when the installed kokoro-onnx version supports it. Returned
metadata now includes `lang` and `langApplied` so callers can detect
silent no-ops. Bump the cached script filename to `synth-v2.py` so
existing installs pick up the new script automatically.
- `commands/tts.ts`: add `--lang, -l` with validation against
`SUPPORTED_LANGS`. Resolution order is explicit `--lang` > inferred
from voice prefix > `en-us`. When explicit lang disagrees with the
voice-implied lang (legitimate for stylized accents), emit a
dim-level hint; suppress under `--json`. When kokoro-onnx silently
ignores the kwarg, log that too. Update `--list` with a new
"Lang code" column and add multilingual examples.
- Tests: new `manager.test.ts` covering every supported prefix, the
unknown-prefix fallback, case-insensitivity, `isSupportedLang`
validation, and a regression guard that every bundled voice has a
valid `defaultLang` matching its ID.
- Docs: `docs/packages/cli.mdx` and `skills/hyperframes/references/tts.md`
updated with the flag, examples, the espeak-ng dependency note for
non-English phonemization, and the voice-prefix → lang table.
Backward compatibility:
- English voices (a*/b* prefixes) continue to phonemize as en-us / en-gb
— no change.
- Non-English voices now phonemize correctly by default (bug fix, not a
regression).
- Older kokoro-onnx versions that don't know the `lang` kwarg keep
working via signature introspection; the CLI logs a dim note if
`--lang` was requested but ignored.
Verification:
- `bun --cwd packages/cli test` — 128 tests pass (incl. 17 new).
- `bunx oxlint` and `bunx oxfmt --check` clean on changed files.
- `bun run build` succeeds.
- `npx tsx packages/cli/src/cli.ts tts --help` / `--list` render cleanly;
invalid `--lang` produces a clean error with the valid-codes list.
* refactor(cli): simplify tts --lang implementation
Post-review cleanup on #351. Net -21 lines.
- Drop `defaultLang` field + `makeVoice()` helper from VoiceInfo —
compute via `inferLangFromVoiceId(v.id)` at read time in listVoices.
The only reader was the --list table; caching the derived value on
every voice added a self-consistency invariant we had to test.
- Drop redundant `lang` field from SynthesizeResult — caller already
knows the requested lang since it passed it in; only `langApplied`
carries information the caller can't derive.
- Use `errorBox` for --lang validation to match the house style in
render.ts (other validation errors already use errorBox).
- Reuse existing `langList` module constant in the validation error
instead of re-joining SUPPORTED_LANGS.
- Inline `DEFAULT_LANG` — used once in inferLangFromVoiceId.
- Trim WHAT-restating comments and the duplicate prefix-enumeration
JSDoc on inferLangFromVoiceId (VOICE_PREFIX_LANG already carries
per-row comments).
- Clean up orphaned `synth*.py` files in ~/.cache/hyperframes/tts
when writing the current versioned script, so repeated upgrades
don't leak files.
- Drop the `EN-US` case-sensitive-rejection test assertion — the CLI
lowercases input before validation, so accepting mixed case is a
feature, not a bug.
Tests: 16/16 in `manager.test.ts`, 127/127 full CLI suite pass.
Lint + format + typecheck clean.
* refactor(engine): restructure frame reorder buffer with Map-keyed storage
Rewrites createFrameReorderBuffer to use a Map<number, Array<() => void>>
keyed by frame index instead of a flat Array<{frame, resolve}> scanned on
every advance. O(1) lookups in enqueue/flush, fast-paths for the matching-
cursor and overshoot cases, and a small fix: waitForAllDone now coexists
with the writer still waiting on the final frame instead of colliding on
the same waiter slot.
Also adds 5 unit tests (there were none before) covering the fast-path,
out-of-order gating, multi-waiter-per-frame semantics, waitForAllDone
normal path, and the overshoot case.
Comment tweaks on buildChromeArgs — the flag profile is the standard
headless-for-capture set (Puppeteer / Playwright / Chrome headless-shell
all converge on similar flags); rephrased for clarity.
* refactor(cli): simplify port availability probe with async/await
Rewrites isPortAvailableOnHost from a single new-Promise callback into an
async/await form with an intermediate `bindError: ErrnoException | null`
variable. Makes the bind-then-release flow explicit as two sequential
awaits, and broadens the non-EADDRINUSE errno commentary (EADDRNOTAVAIL
for disabled IPv6, EACCES for privileged ports, EAFNOSUPPORT for missing
address families — all treated as "this host doesn't apply", not "port
occupied").
No behavior change to existing callers; all four portUtils tests still
pass.
* docs: add CREDITS.md and surface website-to-hyperframes skill
- New CREDITS.md acknowledging prior art in the browser-based video
rendering space (Remotion) and the ecosystem HyperFrames builds on
(Puppeteer, FFmpeg, GSAP, Hono). Standard OSS practice.
- Adds the `website-to-hyperframes` skill to the skills tables in
README.md, docs/guides/prompting.mdx, and the project template at
packages/cli/src/templates/_shared/CLAUDE.md. The skill ships in
skills/ but was missing from every table.
- Adds `/hyperframes-registry` to the prose mention in the repo
CLAUDE.md.
* fix(core): drive adapter seeks when composition has no GSAP timeline
renderSeek returned early when deps.getTimeline() was null, skipping the
onDeterministicSeek call that drives all frame adapters (CSS, WAAPI,
Lottie, Three.js). That meant compositions using any non-GSAP animation
primitive froze on their initial frame during capture.
Now we still quantize the seek time and fire onDeterministicSeek even
without a timeline, so each adapter gets a chance to advance.
GSAP compositions are unaffected — timeline-driven seek still takes the
same path it did before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(producer): auto-fallback screenshot capture for raf and iframes
Co-Authored-By: Codex <codex@openai.com>
* test(producer): add render compatibility regression fixtures
Co-Authored-By: Codex <codex@openai.com>
* fix(core): scrub CSS animations via WAAPI currentTime
Co-Authored-By: Codex <codex@openai.com>
* test(producer): cover css keyframe renders
Co-Authored-By: Codex <codex@openai.com>
* fix(producer): propagate virtual time into iframe documents
Co-Authored-By: Codex <codex@openai.com>
* test(producer): refresh iframe docker golden
Co-Authored-By: Codex <codex@openai.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex <codex@openai.com>
* docs(guides): add performance guide and preview-stutter troubleshooting
Adds a dedicated Performance guide covering preview-vs-render cost model,
expensive CSS patterns (backdrop-filter, filter, shadows), image sizing,
and how to diagnose slow compositions with Chrome DevTools.
Cross-links from troubleshooting (new "Preview stutters" accordion) and
common-mistakes (new "Oversized source images" and "Heavy backdrop-filter
stacks" accordions). Wires the new page into docs.json nav.
Also fixes a pre-commit format hook edge case: oxfmt would exit 2 when
the only staged files matching the format glob were all covered by
.prettierignore (e.g. docs-only changes). Add --no-error-on-unmatched-pattern
to the lefthook oxfmt invocation so docs-only commits are not blocked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: call out preview performance limits at the entry points
The preview command, studio package, and determinism concept pages all
frame preview as visually equivalent to render — correct for fidelity,
misleading for playback smoothness. A user who reads those pages and
then hits a paint-heavy composition has no way to know why preview
stutters, short of drilling into troubleshooting.
Adds short notes at each entry point linking out to the new Performance
guide, so users hit the "preview is hardware-bound, render isn't"
explanation wherever they land first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>