Commit Graph
4070 Commits
Author SHA1 Message Date
James Russo 4a06fb6e84 Merge pull request #637 from heygen-com/feat/remove-background-bg-output
feat(cli): add --background-output to remove-background
2026-05-05 19:38:42 -07:00
JamesandClaude Opus 4.7 a707b6a882 fix(cli): pin inverse-alpha invariants, harden encoder stdin
- 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>
2026-05-05 18:18:23 -07:00
James Russo c3c6034bc4 Merge pull request #638 from heygen-com/fix/lint-warning-wording
fix(lint): rephrase too-large composition warnings to give actionable reasoning
2026-05-05 18:04:21 -07:00
Rames JussoandClaude Opus 4.7 8700826518 fix(lint): rephrase too-large composition warnings to give actionable reasoning
Both `composition_file_too_large` and `timeline_track_too_dense` previously
said "Agents produce better results when large scenes are split into smaller
sub-compositions." The audience-flavored framing ("Agents produce better
results") doesn't tell a reader (agent or human) WHY smaller is better.

Reframe to concrete properties of smaller compositions: easier to read,
iterate on, and diff. The fixHint already covers the inspect/revise/validate
detail; the message now leads with a tight reason.

Per Abhay in #C0ACCNHLG3U:
> "an agent reading 'Agents produce better results' sounds weird. We should
>  give the agent an actual reason why smaller is better for them."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:07:49 +00:00
JamesandClaude Opus 4.7 c2bc2aa1c1 feat(cli): add --background-output to remove-background
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>
2026-05-05 16:47:30 -07:00
Miguel Ángel 21ec5f800a fix(cli): use size-adaptive timeouts for publish uploads (#635)
## Summary
- Replace the flat 30s upload timeout with a size-adaptive calculation: `max(120s, bytes / 500KB/s)`
- Metadata requests (presigned URL, complete) keep the original 30s timeout
- Companion to the backend change removing the 64 MB upload limit in experiment-framework

## Context
With the backend size limit removed, large projects (78 MB+) need proportionally longer to upload. A 78 MB project now gets ~164s, a 500 MB project ~17 min. The old 30s timeout would abort any upload over ~15 MB on a typical connection.

## Test plan
- [x] All 4 existing vitest tests pass
- [x] Build succeeds, no type errors
- [x] Lint + format pass (oxlint + oxfmt)
- [x] Timeout values verified for 10/78/200/500/1000 MB archives
2026-05-06 00:00:08 +02:00
Kiyeon Jeon a57d63b531 fix(cli): use bun in command hints 2026-05-05 18:33:51 +09:00
Miguel Ángel cd277ca312 feat(catalog): add Blue Sweater intro showcase (#630)
## Problem

The Blue Sweater intro HyperFrames project was only available as a standalone exported project zip. It was not installable from the public registry or visible in the Catalog Showcases group.

## What this fixes

Adds `blue-sweater-intro-video` as a registry block with its composition, avatar image, and sound mix asset. The block is exposed through the generated catalog page, `docs/public/catalog-index.json`, and the Showcases navigation.

The manifest and generated catalog page credit the creator as [Joe Sai](https://x.com/_blue_sweater_).

## Root cause

Catalog-visible blocks are driven by `registry/registry.json`, each block's `registry-item.json`, generated docs/catalog files, and CDN-hosted preview media. The exported project had a valid standalone composition, but it had not been converted into that registry/catalog contract or uploaded to the docs preview CDN.

## Verification

### Local checks

- `bun install`
- `bun run build`
- `bunx tsx scripts/generate-catalog-pages.ts`
- `bun run generate:catalog-previews -- --only blue-sweater-intro-video`
- `bun packages/cli/src/cli.ts add blue-sweater-intro-video --dir /tmp/hf-blue-sweater-install-test --no-clipboard --json` against a locally served registry
- `bun packages/cli/src/cli.ts lint /tmp/hf-blue-sweater-install-test` returned 0 errors and 3 static GSAP overlap warnings from the supplied timeline/parser path
- `bun packages/cli/src/cli.ts validate /tmp/hf-blue-sweater-install-test --timeout 5000` returned 0 runtime errors and 0 warnings, with contrast audit warnings only
- `bun packages/cli/src/cli.ts inspect /tmp/hf-blue-sweater-install-test --at 0.5,2.5,5.5,9.8,11.2 --json` returned 0 layout issues
- `bun packages/cli/src/cli.ts render /tmp/hf-blue-sweater-install-test --output /tmp/hf-blue-sweater-install-test/blue-sweater-intro-video-render.mp4 --fps 24 --quality draft --workers 3`
- `ffprobe` reported the installed render duration as `12.000000`
- `bunx oxfmt --check registry/registry.json registry/blocks/blue-sweater-intro-video/registry-item.json registry/blocks/blue-sweater-intro-video/blue-sweater-intro-video.html docs/docs.json docs/public/catalog-index.json docs/catalog/blocks/blue-sweater-intro-video.mdx`
- `git diff --check`
- `bunx vitest run packages/cli/src/commands/add.test.ts packages/core/src/registry/types.test.ts`

### Browser verification

- Started a real local HyperFrames preview for the installed test project.
- Used `agent-browser` to open `http://localhost:5198/api/projects/hf-blue-sweater-install-test/preview` at 1920x1080.
- Verified the runtime registered `install-test` and `blue-sweater-intro-video` timelines.
- Sought the block to the final card and verified `@_blue_sweater_` and the following state were visible.
- Recorded an `agent-browser`-driven full animation pass; `ffprobe` confirmed a 1920x1080 WebM with 110 video frames.
- Checked the fresh `agent-browser` session for page errors after the direct preview flow: `errors: []`.
- Used `agent-browser` to load an HTML page with the exact generated CDN `video`/`poster` URLs; the browser reported `readyState: 4`, `videoWidth: 1920`, `videoHeight: 1080`, and `paused: false`.

### CDN upload

Uploaded the generated preview media with AWS CLI to the existing docs image bucket path:

- `s3://heygen-public/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.mp4`
- `s3://heygen-public/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.png`

Verified both public CDN URLs return `HTTP 200` with correct content type and immutable cache headers:

- `https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.mp4` (`video/mp4`)
- `https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.png` (`image/png`)

## Notes

- Local-only browser proof artifacts:
  - `/tmp/hf-blue-sweater-browser-proof/fresh-final-card.png`
  - `/tmp/hf-blue-sweater-browser-proof/fresh-browser-flow.webm`
  - `/tmp/hf-blue-sweater-cdn-check.png`
- Local-only installed render artifact:
  - `/tmp/hf-blue-sweater-install-test/blue-sweater-intro-video-render.mp4`
2026-05-05 08:17:01 +02:00
James 584627546a chore: release v0.4.45 v0.4.45 2026-05-05 04:41:23 +00:00
James Russo 09732292fe Merge pull request #627 from heygen-com/fix/sub-comp-video-path-resolution-and-render-fixes
fix: render robustness — sub-comp src paths, alpha tag case, encoder + matter improvements
2026-05-04 21:38:50 -07:00
James 7d1d8ead60 fix(producer): sample PSNR checkpoints from common duration of rendered+snapshot
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`.
2026-05-05 04:18:32 +00:00
JamesandClaude Opus 4.7 b8b82fa8d2 docs(remove-background): broaden framing from avatar-specific to any person video
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>
2026-05-04 20:34:37 -07:00
JamesandClaude Opus 4.7 f4ecf96918 fix(engine,cli,producer): address PR #627 review feedback
- 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>
2026-05-04 20:29:34 -07:00
JamesandClaude Opus 4.7 39bc3749b4 fix(engine): default to codec-based alpha capability instead of relying on tags
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>
2026-05-04 20:29:33 -07:00
JamesandClaude Opus 4.7 6fb782fc09 test(engine): pin ALPHA_MODE uppercase ffprobe tag regression
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>
2026-05-04 20:29:33 -07:00
JamesandClaude Opus 4.7 b836941f09 fix(engine): detect VP9 alpha tag case-insensitively in ffprobe
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>
2026-05-04 20:29:33 -07:00
JamesandClaude Opus 4.7 5fca4becbc docs(remove-background): document compositing patterns and pitfalls
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>
2026-05-04 20:29:33 -07:00
JamesandClaude Opus 4.7 2f96d5c7ab fix(engine,producer): URL-clamp sub-comp src paths and warn on silent extraction misses
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>
2026-05-04 20:27:56 -07:00
JamesandClaude Opus 4.7 0e541673e0 fix(engine): wait for first frame decode + drop B-frames so renders play in every player
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>
2026-05-04 20:27:56 -07:00
JamesandClaude Opus 4.7 688052d368 fix(cli): correct sharp 3-channel mask + BT.709 + quality presets in remove-background
- 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>
2026-05-04 20:27:56 -07:00
James Russo 06f5422d34 Merge pull request #619 from heygen-com/split-cli-media-skills
refactor(skills): split asset preprocessing out of hyperframes-cli
2026-05-04 20:27:21 -07:00
James 94dc6c895e fix(skills): purge stale tts/transcribe references from CLI skill pointers
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.
2026-05-05 03:16:14 +00:00
Test UserandClaude Opus 4.7 49d4dbbadb fix: use JSON.stringify for caption text escaping in generated JS
The generated JavaScript used manual single-quote escaping
(replace(/\\/g, '\\\\').replace(/'/g, \"'\\\\''\")) which missed
newlines, carriage returns, and other special characters. Captions
containing line breaks would produce syntactically broken JS.

Use JSON.stringify which handles all JS string special characters
correctly, including newlines, unicode, and control characters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:35:00 +08:00
James 4413d25b0e refactor(skills): consolidate tts/whisper guidance into hyperframes-media
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).
2026-05-04 22:06:02 +00:00
James 051e985868 refactor(skills): split asset preprocessing out of hyperframes-cli
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.
2026-05-04 21:59:35 +00:00
Miguel Ángel 20895eecdd chore: release v0.4.44 v0.4.44 2026-05-04 14:37:16 -07:00
Miguel Ángel 7affa4a4e9 fix: handle player loop and render exit (#617)
## Problem

Two newly reported runtime issues break common local workflows:

- Fixes #615: `<hyperframes-player loop>` reaches the final frame, receives a paused runtime state, and stays paused instead of wrapping.
- Fixes #616: `hyperframes render` can finish writing the output and print `Render complete`, but still remain alive when a non-essential handle keeps Node's event loop open.

The catalog block also used old VPN branding and slug/file names that should now be neutral. Renaming registry items also exposed a catalog-preview CI bug where deleted registry paths were treated as still-renderable changed items.

## What this fixes

- detects player completion from the previous playing state before mutating the parent `_paused` cache from the runtime's final state
- wraps looping players back to `0` and immediately resumes playback even when the runtime posts `isPlaying: false` at the end frame
- keeps non-looping players dispatching the existing `ended` flow
- lets the CLI command path schedule a short unref'd `process.exit(0)` after a successful local or Docker render
- keeps `renderLocal()` importable for tests and internal callers without forcing process exit unless the CLI command explicitly opts in
- adds regression coverage for the player loop end-state and successful render exit scheduling
- renames the VPN catalog block to `vpn-youtube-spot` across registry, docs route, install command, composition filename, asset filename, composition id, and timeline key
- keeps visible block/app copy friendly and named `VPN`
- updates catalog-preview CI to ignore deleted registry paths when computing changed preview items

## Root cause

The player message handler updated `_paused = !data.isPlaying` before checking for end-of-composition loop behavior. The runtime's legitimate final-frame state has `isPlaying: false`, so the existing `currentTime >= duration && !paused` loop branch was skipped.

For render completion, the CLI returned after `printRenderComplete()`, leaving process lifetime entirely to Node's active handles. Most local renders in this checkout drain cleanly, but the reported npm flow shows a sleeping parent process after output is already complete. The CLI now schedules a short unref'd successful exit only from the command path after user-visible render work has completed.

The catalog block issue was content/metadata drift: registry/docs/code identifiers still used the old slug, so the catalog route, install command, composition id, file names, and source prompt did not match the requested neutral VPN naming. The preview workflow used plain `git diff --name-only`, which includes deleted paths during renames; it now filters to added/copied/modified/renamed live paths.

## Verification

### Local checks

- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/player test -- src/hyperframes-player.test.ts`
- `bun run --filter @hyperframes/cli test -- src/commands/render.test.ts`
- `bun run --filter @hyperframes/player typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bunx oxfmt --check packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bunx oxlint packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bun run --filter @hyperframes/player build`
- `bun run --filter @hyperframes/studio build`
- `bun run --filter @hyperframes/cli build`
- `bunx oxfmt --check registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxlint registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxfmt --check .github/workflows/catalog-previews.yml`
- `BASE_SHA=26b8e2a9853eb1a8f77c05fb0c8f0903cdb2cf18; git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ ...` returns only `vpn-youtube-spot`
- `npx tsx scripts/sync-schemas.ts --check`
- `npx mint validate` from `docs/`
- `npx mint broken-links` from `docs/`
- `git diff --check`
- Lefthook pre-commit: format pass
- Lefthook commit-msg: commitlint pass

### Browser verification

- Built the player bundle and served a real local reproduction using the built player, the built HyperFrames runtime, and GSAP.
- Used `agent-browser` to open the page, click `Seek near end`, and wait through the end-frame transition.
- Verified the browser state after playback: `stuck=false`, `looped=true`, and playback continued after wrapping from ~4s back to the start.
- Served `registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html` locally, used `agent-browser` to seek the timeline, and verified `window.__timelines` contains `vpn-youtube-spot`, not `goonvpn-youtube-spot`.
- Served the docs locally with Mintlify, opened `/catalog/blocks/vpn-youtube-spot`, and verified the install command is `npx hyperframes add vpn-youtube-spot` with no old slug visible.

### Composition verification

- `bun run --filter @hyperframes/cli dev lint /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if` returned 0 errors and 1 existing large-composition warning.
- `bun run --filter @hyperframes/cli dev validate /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --timeout 5000` returned 0 console errors; it reported existing non-fatal contrast audit warnings from the block styling.
- `bun run --filter @hyperframes/cli dev render /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --output /tmp/hf-vpn-renamed-proof.mp4 --fps 30 --quality draft --workers 1 --no-browser-gpu` completed successfully.
- `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 /tmp/hf-vpn-renamed-proof.mp4` reported `duration=7.000000`.

### Render verification

- Ran a real 1920x1080, 5-second render with `--gpu --workers 6 --quality draft --fps 24`.
- Verified the command printed `Render complete` and the parent process exited with code `0` in the wrapper: `RENDER_EXIT_PROOF code=0 signal=null sawComplete=true`.

## Notes

- I could not reproduce the exact indefinite #616 render hang on this checkout; both tiny and GPU/6-worker local renders exited cleanly before and after the patch. The CLI guard still addresses the reported leaked-handle failure mode because it fires only after successful render completion.
- Browser proof artifacts were local-only: `/tmp/hf-player-loop-proof-final.png`, `/tmp/hf-player-loop-proof-final.webm`, `/tmp/hf-vpn-code-rename-proof.png`, `/tmp/hf-vpn-code-rename-proof.webm`, `/tmp/hf-vpn-doc-route-rename-proof.png`, and `/tmp/hf-vpn-doc-route-rename-proof.webm`.
- The renamed composition render artifact was local-only: `/tmp/hf-vpn-renamed-proof.mp4`.
- The CLI exit guard is only enabled by the `render` command's top-level local/Docker calls. Direct test/internal calls to `renderLocal()` do not force process exit unless they pass `exitAfterComplete: true`.
2026-05-04 23:31:30 +02:00
James Russo f1d408eed3 test(producer): add variables-prod regression for the variables stack (PR 5/5) (#604)
## What

End-to-end Docker regression test that exercises the **full variables chain** shipped in PRs #600-#603. Closes the loop between unit-tested seams and the actual rendered output.

This is **PR 5 of 5**, the regression cap. Stacked on `feat/get-variables-skills` (PR #603).

## Why

PRs 1-4 ship unit tests that cover the seams independently:
- Engine: \`evaluateOnNewDocument\` injection (mocked Puppeteer)
- Helper: \`getVariables()\` merge (jsdom)
- CLI: \`parseVariablesArg\` validation (pure function)
- Loader: \`__hfVariablesByComp\` population (jsdom)
- Validator: \`validateVariables\` type-checking (pure)

But none of those check that the chain actually works front-to-back inside the production Chrome+ffmpeg+harness combo. A type signature change on \`CaptureOptions.variables\`, a regression in \`evaluateOnNewDocument\` ordering, a bug in the runtime helper's attribute parsing — any of those could pass unit tests and silently render the wrong text. This regression catches it.

## How

**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, no workflow YAML changes needed). \`renderConfig.variables\` provides override values that the baseline reflects (\"Override Title\", \"Override subtitle\", \`#0a3d62\`). **If variables don't propagate**, the rendered frame shows declared defaults (\"Default Title\", black) — visibly different from the baseline, so PSNR fails on dozens of frames.
- \`output/output.mp4\` — Docker-generated baseline per the project's CLAUDE.md golden-baseline rule. Host renders drift across Chrome/font versions and would fail PSNR even on green code.
- \`src/silence.wav\` — copied from \`missing-host-comp-id\`'s silence track to satisfy the audio-correlation check.

**Harness change** (\`packages/producer/src/regression-harness.ts\`):
- \`TestMetadata.renderConfig\` gains an optional \`variables: Record<string, unknown>\` field, validated as a JSON object (not array, not null) in the \`meta.json\` validator.
- The \`createRenderJob\` call site forwards \`renderConfig.variables\` to \`RenderConfig.variables\`, which the engine already consumes via \`evaluateOnNewDocument\` (PR #600).

## Test plan

- [x] **\`docker:test variables-prod\` PASSED** — 100/100 visual checkpoints, audio correlation 1.000.
- [x] **Defeated the bug it's meant to catch** — generated a baseline against an old image (without the harness change) and confirmed it shows defaults. After the harness change + image rebuild, the baseline correctly shows overrides.
- [x] **CI auto-includes** — fast shard's \`--exclude-tags slow,render-compat,hdr\` lets \`[variables, composition]\` through. No \`.github/workflows/regression.yml\` edits needed.

## Backwards compatibility

Additive. Existing fixtures don't set \`renderConfig.variables\` and behave identically. The harness validator only fires on the new field if it's present.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-04 13:54:08 -07:00
JamesandClaude Opus 4.7 c9d5fe61ff refactor(producer): apply /simplify findings on variables-prod
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>
2026-05-04 20:34:20 +00:00
JamesandClaude Opus 4.7 58b4234809 test(producer): add variables-prod regression test for the variables stack
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>
2026-05-04 20:33:43 +00:00
James Russo 62c2589839 docs(skills): document variables system in SKILL.md + docs (PR 4/4) (#603)
## What

Distribution PR for the variables feature stack. Tells agents how to declare, read, and override variables across the four authoring surfaces — the two skill files agents load (`hyperframes`, `hyperframes-cli`), the public docs (`docs/packages/core.mdx`), and the in-CLI docs (`npx hyperframes docs compositions`).

This is **PR 4 of 4**, the final PR in the stack. Stacked on `feat/get-variables-validation` (PR #602).

## Why

PRs 1–3 added the runtime helper, sub-comp scoping, and schema validation, but the only places that mention them are the docs in those PRs. Agents loading `/hyperframes` or `/hyperframes-cli` skills won't know the new attributes/flags exist. This PR closes the loop.

## How

- **`skills/hyperframes/SKILL.md`** — new "Variables (Parametrized Compositions)" section right after "Composition Structure" with: declare/read/override pattern, full worked example (with enum), sub-comp per-instance pattern (two hosts sharing a source), rules of thumb (defaults always, read-once, `--strict-variables` in CI, type validation behavior). Also added `data-variable-values` + `data-composition-variables` rows to the existing data-attributes tables.
- **`skills/hyperframes-cli/SKILL.md`** — added `--variables`, `--variables-file`, `--strict-variables` to the render flag table; short paragraph forwarding to the hyperframes skill for the full pattern.
- **`docs/packages/core.mdx`** — added a code snippet showing `getVariables<T>()` and `validateVariables` / `formatVariableValidationIssue` for tooling that validates CLI / host overrides.
- **`packages/cli/src/docs/compositions.md`** — replaced the obsolete `JSON.parse(host.dataset.variableValues)` example with the modern `getVariables()` pattern.

The `openai/plugins` mirror is intentionally out of scope. Skills in this repo are the source of truth; the downstream mirror is updated after each release as a separate workflow.

## Test plan

- [x] Doc-only PR — no source code, no tests.
- [x] Format check passes via `bunx oxfmt --check` on the touched markdown files.
- [x] Manual review confirms each example compiles in head against the runtime/CLI surface that PRs 1–3 ship.

## Backwards compatibility

Doc-only — no behavior change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-04 13:33:34 -07:00
JamesandClaude Opus 4.7 21243b6957 docs: lead with shape contrast for variable attributes
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>
2026-05-04 20:26:20 +00:00
JamesandClaude Opus 4.7 22bcd7a18b docs: clarify declaration vs override attributes (PR #603 review)
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>
2026-05-04 20:25:42 +00:00
JamesandClaude Opus 4.7 211a9214d0 docs(skills): teach agents the variables system across SKILL.md + docs
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>
2026-05-04 20:25:42 +00:00
James Russo 1c9d726b5e feat(core,cli): variable schema validation + lint rules (PR 3/4) (#602)
## What

Adds two lint rules + render-time validation for the variable system shipped in PR #600 + PR #601. Authors get fast feedback when JSON is malformed, declarations are missing required fields, or `--variables` values don't match the declared schema.

This is **PR 3 of a 4-PR stack** — based on `feat/get-variables-subcomp` (PR #601). Will retarget to `main` after PRs 1 + 2 merge.

## Why

The runtime today silently masks several classes of mistake:
- A typo in `data-variable-values` JSON makes the parser return `{}` and the script reads stale declared defaults, with no visible signal.
- A typo in a `data-composition-variables` declaration (missing `id`, wrong `type`) gets silently filtered out.
- `--variables '{"titel":"x"}'` instead of `"title"` renders fine and produces the wrong output.

These are exactly the cases lint + schema validation are good at catching.

## How

**Lint rules** (`packages/core/src/lint/rules/composition.ts`):
- `invalid_variable_values_json` — host's `data-variable-values` must parse as a JSON object.
- `invalid_composition_variables_declaration` — root `<html>`'s `data-composition-variables` must parse as an array of objects with `id`, `type` (one of string/number/color/boolean/enum), `label`, and `default`. Per-entry findings report which fields are missing or invalid.

Both rules read via a new `readJsonAttr` helper in `lint/utils.ts`. The existing `readAttr`'s regex `["']([^"']+)["']` truncates JSON-in-attribute values at the first internal quote — `data-variable-values='{"x":"y"}'` would capture only `{`. The new helper alternates double-vs-single-quoted branches with quote-specific char classes. A second helper `findHtmlTag` returns the `<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 `VariableValidationIssue`s — `undeclared`, `type-mismatch`, or `enum-out-of-range`. Pure / sync; works in any environment.
- `formatVariableValidationIssue` renders one-line user-facing strings for CLI output.
- Both exported from `@hyperframes/core`.

**CLI integration** (`packages/cli/src/commands/render.ts`):
- New `--strict-variables` flag. Default: print warnings, continue. Strict: print warnings, exit 1.
- New `validateVariablesAgainstProject(indexPath, values)` helper reads the project's `index.html`, pulls the declared schema via `extractCompositionMetadata`, validates the CLI payload. Uses the existing `ensureDOMParser` polyfill (same pattern as `compositions.ts`).

## Test plan

- [x] Unit tests added/updated
  - **11 `validateVariables` unit tests** — happy path, undeclared keys, type mismatches (every type), enum range, multi-issue aggregation, formatter output.
  - **11 `composition.test.ts` cases** — both lint rules: parse errors, shape errors, per-entry validation, unknown types, positive cases for valid declarations.
  - **5 `render.test.ts` cases** — `validateVariablesAgainstProject`: no-declarations, happy path, undeclared, type-mismatch, missing-file.
  - All existing tests green: core 646, cli 213.
- [x] Manual flow walkthrough
  - `--variables '{"title":"x"}'` against an index that declares `title` as string → no warnings.
  - `--variables '{"count":"three"}'` against `count: number` → warning printed, render continues.
  - Same with `--strict-variables` → exit 1 before render starts.
- [x] Documentation updated
  - `docs/packages/cli.mdx` — added `--strict-variables` flag row.

## Backwards compatibility

Fully additive. Existing compositions emit zero new lint findings (the rules only fire on malformed JSON or invalid declarations, which the runtime would have silently dropped anyway). Existing `hyperframes render` invocations behave identically without the new flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-04 13:25:33 -07:00
JamesandClaude Opus 4.7 09da5db436 refactor(core): apply /simplify findings on validation PR
- 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>
2026-05-04 20:06:10 +00:00
JamesandClaude Opus 4.7 c1b6efd9c5 feat(core,cli): variable schema validation + lint rules
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>
2026-05-04 20:05:33 +00:00
James Russo da92a17754 feat(core): scope getVariables() per sub-comp instance (PR 2/4) (#601)
## What

Building on **PR #600** (`getVariables()` helper + `--variables` flag), this PR scopes the helper so embedded sub-compositions see their own per-instance values. The same composition source can now be embedded N times with different content via `data-variable-values` on each host.

This is **PR 2 of a 4-PR stack** — based on `feat/get-variables`. Will retarget to `main` after PR #600 merges.

## Why

`data-variable-values` was already documented as the per-instance attribute for nested compositions, but readers had to hand-roll `JSON.parse(host.dataset.variableValues)` and there was no scoping system — every sub-comp script had to re-implement the same pattern. This PR routes the values to a scoped `getVariables()` so authors use one API in both top-level and sub-comp contexts.

## How

```
[host element]                                    [sub-comp source HTML]
data-variable-values='{...}'        +    <html data-composition-variables='[...]'>
                                                  ^ declared defaults
        ↓                                                  ↓
        └──────────── compositionLoader ────────────────────┘
                            ↓
            window.__hfVariablesByComp[compId] = merged
                            ↓
                  scripts wrapped by compositionScoping
                            ↓
            __hyperframes.getVariables() → scoped lookup
```

Three small surgical changes:

- **`compositionLoader.ts`** — before injecting wrapped scripts, parse the host's `data-variable-values` JSON, merge it over `readDeclaredDefaults(doc.documentElement)` (reused from PR 1's runtime helper), and write the result to `window.__hfVariablesByComp[compositionId]`. Skipped when both sides are empty so the table only grows when there's actual data. Inline templates (no separate `<html>` root) get host overrides only.

- **`compositionScoping.ts`** — wrapper IIFE now takes a fourth parameter `__hyperframes` alongside the existing scoped `document` / `gsap` / `window`. Same shadowing pattern that already works for the other three. The scoped `__hyperframes` overrides `getVariables` to read from `__hfVariablesByComp[__hfCompId]` and returns a fresh object each call so script mutations don't leak into the shared table.

- **`getVariables.ts`** — `readDeclaredDefaults` becomes a named export (was a private helper) so the loader reuses the exact same defaults-extraction logic the top-level helper uses.

Top-level scripts that aren't wrapped by `compositionScoping` keep calling `window.__hyperframes.getVariables()` and get the unscoped path from PR 1 (declared defaults + CLI `--variables` override).

## Test plan

- [x] Unit tests added/updated
  - **3 new `compositionScoping.test.ts`** — scoped `getVariables` invocation routes to `__hfVariablesByComp[compId]`; missing-entry returns `{}`; mutation of the returned object doesn't leak into the shared table.
  - **5 new `compositionLoader.test.ts`** — merge order (host overrides win); declared-only path when host has no `data-variable-values`; empty-skip when neither side has data; invalid-host-JSON falls through to declared defaults; per-instance scoping across two hosts that share a source.
  - **3 new `getVariables.test.ts`** — covering the newly-public `readDeclaredDefaults` export (null root, valid attribute, invalid JSON / non-array).
  - All 622 existing core tests green.
- [x] Manual flow walkthrough — host with `data-variable-values='{"title":"Pro"}'` → sub-comp's `__hyperframes.getVariables()` returns `{title:"Pro", ...declaredDefaults}`. Two hosts pointing at the same source with different overrides → each script sees its own values.
- [x] Documentation updated
  - `docs/concepts/compositions.mdx` — switched the sub-comp example from `JSON.parse(host.dataset.variableValues)` to `__hyperframes.getVariables()`; added declared-defaults pattern and per-instance scoping note.
  - `docs/concepts/data-attributes.mdx` — clarified per-instance behavior on `data-variable-values`.

## Backwards compatibility

Fully backwards compatible. Compositions that read `host.dataset.variableValues` directly keep working — the host attribute is still set as before. The new path is a strict addition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-04 13:05:25 -07:00
JamesandClaude Opus 4.7 1da6f456b7 refactor(core): apply /simplify findings on sub-comp scoping PR
- 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>
2026-05-04 19:42:04 +00:00
JamesandClaude Opus 4.7 484ab54442 feat(core): scope getVariables() per sub-comp instance
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>
2026-05-04 19:41:26 +00:00
James Russo 03b82e6ff8 feat(core,cli,engine,producer): getVariables() helper + --variables render flag (PR 1/4) (#600)
## What

Adds the parametrized-render primitive from [hf#592](https://github.com/heygen-com/hyperframes/issues/592) by introducing a `getVariables()` runtime helper plus a CLI `--variables` / `--variables-file` flag. Compositions declare variables once on the root `<html>` element (the existing `data-composition-variables` attribute, which already drives Studio editing UI), read them at runtime via `window.__hyperframes.getVariables()`, and CLI users override them at render time without touching the composition source.

This is **PR 1 of a 4-PR stack**:

1. **PR 1 (this one)** — runtime helper + CLI flag + engine injection (top-level renders).
2. PR 2 — sub-comp per-instance scoping (carry the host's `data-variable-values` into the inlined sub-comp's `getVariables()`).
3. PR 3 — schema validation + lint rules (warn on undeclared variable IDs, optional `--strict-variables`).
4. PR 4 — skill / scaffold distribution (SKILL.md, AGENTS.md scaffolds, openai/plugins mirror).

## Why

The existing `data-composition-variables` schema declares variable types and defaults but isn't readable from composition scripts and can't be overridden at render time. To produce N variations of a composition today, an agent has to fork the composition or edit the source HTML before each render. `--variables` collapses that into one render call per variation, matching Editframe's `--data` UX without copying their `getRenderData` framing — `getVariables()` is named for the codebase's existing "variables" terminology and works equally in dev preview and at render time.

## How

- **Runtime helper** (`packages/core/src/runtime/getVariables.ts`): reads `data-composition-variables` from `document.documentElement`, extracts `{id: default}` defaults, merges `window.__hfVariables` (override) on top, returns `Partial<T>`. Same code path in dev preview (no override) and at render (with override). Generic parameter for typed editor ergonomics. Exposed both as a named export from `@hyperframes/core` and on `window.__hyperframes.getVariables` for vanilla compositions.

- **CLI flag** (`packages/cli/src/commands/render.ts`): `--variables '<json>'` and `--variables-file <path>`. `parseVariablesArg` is split out as a pure function (returns a discriminated `{ ok: true } | { ok: false }` union) so all validation paths are unit-testable; the side-effecting `resolveVariablesArg` wraps it with `errorBox` + `process.exit`. Mutually exclusive with `--variables-file`; fail-fast on conflicts, missing file, unparseable JSON, or non-object payloads (string, number, array, null).

- **Engine injection** (`packages/engine/src/services/frameCapture.ts`): added an `evaluateOnNewDocument` step right after the `__name` polyfill that sets `window.__hfVariables` to the parsed JSON before any page script runs. Skipped when payload is empty so we don't add pointless init scripts. Plumbed through `CaptureOptions.variables` and `RenderConfig.variables`. Docker mode forwards the flag to the in-container CLI via `dockerRunArgs`.

- **Why a separate `__hfVariables` global** instead of writing into `__hyperframes.getVariables()` directly: the helper is an IIFE that has to be defined before composition scripts execute, but the *override* needs to land before *that*. `evaluateOnNewDocument` is the only reliable hook that runs before the runtime IIFE evaluates. Storing the raw value on `__hfVariables` and merging in the helper keeps both paths order-independent.

## Test plan

- [x] Unit tests added/updated
  - 9 jsdom tests for `getVariables()` covering empty state, declared defaults only, override merge, override-wins, declared-only, invalid JSON, non-array payloads, non-object overrides, typed generic.
  - 7 tests for `parseVariablesArg` covering all validation paths.
  - 2 integration tests for `renderLocal` confirming `variables` reach `createRenderJob`.
  - 3 new `dockerRunArgs` assertions for `--variables` passthrough (set / not-set / empty-object).
  - All existing tests green: core 611, cli 208, engine 519.
- [x] Manual testing performed
  - `npx tsx packages/cli/src/cli.ts render --help` shows both flags + the two new examples.
- [x] Documentation updated
  - `docs/packages/cli.mdx` — added flags to the table and a "Parametrized renders" section with a worked example.
  - `docs/concepts/data-attributes.mdx` — added `data-composition-variables` row.

## Backwards compatibility

Fully backwards compatible. Compositions without `data-composition-variables` work unchanged; `getVariables()` returns `{}` and the engine skips the injection step.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-04 12:41:18 -07:00
James Russo aeae676d20 Merge pull request #612 from heygen-com/feat/cli-remove-background
feat(cli): add remove-background command for transparent video
2026-05-04 12:40:48 -07:00
Miguel Ángel 26b8e2a985 Revert "feat: Persist Studio manual edits via manifest (#593)"
This reverts commit d0abe90a82.
2026-05-04 09:41:36 -07:00
Vance Ingalls 8d83d4f132 fix: make caption overrides refresh-safe (#609)
## Summary

This stacked PR makes caption overrides refresh-safe.

Caption edits are still saved to `caption-overrides.json`, but override targets are now stable across preview refreshes and regenerated caption HTML.

## Architecture

- **Stable word identity**: generated caption HTML preserves optional transcript word IDs in the `TRANSCRIPT` array and emits those IDs on word spans.
- **Parser continuity**: the caption parser preserves existing transcript word `id` fields instead of regenerating index-only identity.
- **Override loading**: Studio loads saved overrides by `wordId` first, with the existing `wordIndex` fallback kept for older overrides.
- **Idempotent runtime wrapping**: transform overrides reuse an existing `data-caption-wrapper="true"` wrapper instead of nesting wrappers on every refresh.
- **Animation compatibility**: overrides still wrap the word so inner word-level GSAP animation can continue to target the original span.

## User Impact

Users can edit caption word position, scale, rotation, color, opacity, font size, font weight, and font family, then refresh without overrides drifting to the wrong word or accumulating nested wrappers.

## Main Files

- `packages/core/src/runtime/captionOverrides.ts`
- `packages/studio/src/captions/generator.ts`
- `packages/studio/src/captions/parser.ts`
- `packages/studio/src/captions/hooks/useCaptionSync.ts`

## Test Plan

```bash
volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/runtime/captionOverrides.test.ts
volta run --node 22.20.0 packages/studio/node_modules/.bin/vitest run --root packages/studio --config /dev/null src/captions/parser.test.ts src/captions/generator.test.ts
volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck
volta run --node 22.20.0 bunx oxlint <changed files>
volta run --node 22.20.0 bunx oxfmt --check <changed files>
git diff --check
```
2026-05-03 23:29:16 -07:00
Vance Ingalls d0abe90a82 feat: Persist Studio manual edits via manifest (#593)
## Summary

Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture.

The manifest lives at:

```text
.hyperframes/studio-manual-edits.json
```

It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset.

## Architecture

- **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values.
- **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file.
- **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base.
- **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script.
- **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines.
- **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly.

## User Impact

Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state.

## Main Files

- `packages/studio/src/components/editor/manualEdits.ts`
- `packages/studio/src/components/editor/DomEditOverlay.tsx`
- `packages/studio/src/components/editor/PropertyPanel.tsx`
- `packages/studio/src/App.tsx`
- `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts`
- `packages/studio/vite.config.ts`
- `packages/cli/src/server/studioServer.ts`
- `packages/core/src/compiler/htmlBundler.ts`
- `packages/producer/src/services/htmlCompiler.ts`
- `packages/core/src/studio-api/routes/thumbnail.ts`
- `packages/producer/src/services/fileServer.ts`
- `packages/producer/src/services/renderOrchestrator.ts`

## Test Plan

```bash
volta run --node 22.20.0 bun run build
volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts
volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck
volta run --node 22.20.0 bunx oxlint <changed files>
volta run --node 22.20.0 bunx oxfmt --check <changed files>
git diff --check
```
2026-05-03 23:06:11 -07:00
Miguel Ángel 1d15845a13 chore: release v0.4.43 v0.4.43 2026-05-03 22:53:04 -07:00
JamesandClaude Opus 4.7 010c4f5576 fix(cli): correct u2net_human_seg std + reject signal-killed ffmpeg exits
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>
2026-05-04 04:46:01 +00:00
JamesandClaude Opus 4.7 d2ca45ef75 feat(cli): add remove-background command for transparent video
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>
2026-05-04 04:17:51 +00:00
Miguel Ángel 6bcf3ceddb fix: use target receiver for scoped proxy accessors (#607)
## Problem

`wrapScopedCompositionScript` wraps composition scripts with scoped `document`, `window`, and `gsap` proxies. The current published `latest` and `alpha` packages still pass the proxy as the `Reflect.get` receiver, so browser host accessors like `document.body` can throw `TypeError: Illegal invocation`.

When that happens, the wrapper catches the error and aborts the rest of the composition script. For components that start hidden and reveal themselves through GSAP/timeline setup, that means the timeline is never registered and the render can stay visually empty.

Closes #606.

## What this fixes

- Reads scoped proxy properties with the original target as the `Reflect.get` receiver.
- Applies the same target-receiver pattern to scoped proxy setters, including remapped timeline registry writes.
- Preserves the existing behavior of binding returned methods back to the real target.
- Covers document, window, remapped timeline registry, GSAP, and GSAP utils accessors/setters in regression tests that throw unless the receiver is the original target.

## Root cause

The previous proxy traps called `Reflect.get(target, prop, receiver)`. For accessors, that invokes the getter with `this === receiver`, and in this wrapper the receiver is the proxy. Browser host getters such as `Document.prototype.body` validate their receiver and reject the proxy, which makes ordinary composition code like `document.body` fail before timeline registration can run.

## Verification

### Local checks

- Confirmed current npm state with `npm view @hyperframes/core version dist-tags versions --json` and `npm view @hyperframes/producer version dist-tags versions --json`: `latest` is `0.4.42`, `alpha` is `0.5.0-alpha.14`.
- Packed `@hyperframes/core` and `@hyperframes/producer` at both `latest` and `alpha`; all four packed artifacts still contained the bad `Reflect.get(target, prop, receiver)` / `utilsReceiver` wrapper patterns before this fix.
- `bun run --cwd packages/core test -- src/compiler/compositionScoping.test.ts`
- `bunx oxlint packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.ts`
- `bunx oxfmt --check packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.ts`
- `bun run --cwd packages/core build`
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/producer typecheck`
- `bun run --cwd packages/producer build`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- Confirmed the rebuilt core runtime and producer bundles no longer contain the old `Reflect.get(..., receiver)` / `Reflect.set(..., receiver)` scoped-wrapper patterns.
- `git diff --check`
- Pre-commit also reran lint, format, and typecheck successfully.

### Browser verification

Used `agent-browser` against generated local repro pages:

- `core latest 0.4.42`: `bodyRead: false`, `titleOpacity: "0"`, `timelineRegistered: false`, `errorCount: 1`
- `core alpha 0.5.0-alpha.14`: `bodyRead: false`, `titleOpacity: "0"`, `timelineRegistered: false`, `errorCount: 1`
- Patched local wrapper: `bodyRead: true`, `titleOpacity: "1"`, `timelineRegistered: true`, `errorCount: 0`

## Notes

- Browser screenshots and the `agent-browser` recordings are local-only under `tmp/issue-606/browser/`, including `issue-606-browser-proof.webm` and `issue-606-after-comment.webm`.
- No generated `dist/` artifacts are committed.
2026-05-03 19:19:53 +02:00
JamesandClaude Opus 4.7 8c8dd6ad0c refactor(core,cli,engine): apply /simplify findings on getVariables PR
- 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>
2026-05-03 00:24:03 +00:00