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>
## Problem
I reproduced the selected open issue batch one by one and confirmed the reports were valid. The fixes all touch the CLI/runtime capture boundary, then the follow-up regression run exposed one over-broad runtime change in sub-composition host visibility and one CI-only baseline trap.
Closes#590, #589, #588, #587, #586, and #584.
## What this fixes
### CLI/runtime edge cases
- Makes the GSAP infinite-repeat lint rule ignore JavaScript comments, so literal `repeat:-1` text in comments is not flagged.
- Lets the compositions CLI inspect `<template>` content, count visual-only template descendants, estimate simple GSAP durations, and suppress root `data-start` warnings in sub-composition lint mode.
- Preserves runtime bootstrap scripts when body scripts are coalesced, and injects the runtime into a real `<head>` when source HTML has no head.
- Keeps #589 fixed by loading and rendering template-wrapped sub-composition content, while restoring host visibility to the shorter of the authored parent clip window and the child composition live timeline.
- Resolves snapshot/validate viewport size from root `data-width` / `data-height` instead of falling back to 1920x1080.
- Skips fully off-frame text boxes during contrast sampling and bounds-checks ring samples so contrast output no longer emits `null:1` / `NaN:1`.
- Marks muted videos as `data-has-audio="false"` in the core timing compiler, which fixes the same-src muted `<video>` + separate `<audio>` StaticGuard case.
- Keeps user-authored `hf-seek` listeners reachable during capture by preventing author scripts from being merged into the runtime bootstrap path.
### Shared helper cleanup
- Removes the stale producer-local timing compiler duplicate; producer compilation now consumes the core timing compiler.
- Centralizes HTML document helpers in core: fragment parsing, embedded runtime stripping, head/body script injection, and early-head injection.
- Centralizes the CLI layout/snapshot static HTML server.
- Adds browser-safe core subpath helpers for Lottie readiness and CLI screenshot clip calculation; Studio's Vite config keeps the screenshot clip helper self-contained so clean-checkout test startup does not value-import core `.ts` source.
- Replaces the engine parity-contract copy with a core re-export.
- De-duplicates render-job cleanup and Studio static file-serving callbacks.
### Regression hardening
- Replaces the embedded-runtime script stripping regex with a script-tag scanner that handles closing tags like `</script >`.
- Escapes inline script bodies before wrapping them in `<script>` tags, so authored `</script` and `<!--` text cannot break out of the injected wrapper script.
- Shares media-duration clamping between core and producer, with a 50 ms tolerance for ffprobe precision drift between local and CI media stacks.
- Pins the affected style fixture SFX durations in source so style-1 and style-9 compile deterministically.
- Restores the `vfr-screen-recording` video golden to the CI-stable baseline; the current CI failure showed the Linux render matches the old golden, while the locally refreshed macOS golden was the mismatch.
## Root cause
The CLI paths had accumulated assumptions that held for simple direct-root landscape compositions but not for current composition patterns: DOM queries did not enter template content, snapshot/validate used a fixed viewport, runtime and author scripts shared a coalescing bucket, and timing compilation treated every video as audio-bearing unless authors manually overrode it.
The style shard failures were not product regressions. Local and CI media probing disagreed on the short SFX clip duration by about 45 ms, and the compiler was clamping authored durations to the locally probed value. The shared clamp tolerance preserves explicit author/source durations for small probe precision differences while still clamping real overflows.
The vfr fast-shard failure was a bad baseline refresh: CI actual frames matched the old `vfr-screen-recording` baseline at 40+ dB PSNR, but mismatched the macOS-refreshed golden at ~18-22 dB. The fix is to keep the Docker/Linux-stable video golden and only retain the deterministic compiled snapshot change.
The sub-composition regression came from treating a host's authored parent window as the only visibility boundary. That made settled child overlays stay visible after their own live GSAP timeline ended. The corrected runtime behavior respects both contracts: parent clips still bound where the host can appear, and the child live timeline can end the host earlier.
## Verification
### Local checks
- `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bun run --cwd packages/core test src/runtime/init.test.ts`
- `bun run --cwd packages/cli test src/commands/compositions.test.ts src/utils/compositionViewport.test.ts`
- `bun run build:hyperframes-runtime`
- `bun run --cwd packages/producer test --keep-temp --sequential style-12-prod style-5-prod`
- `bun run --cwd packages/producer test --sequential vfr-screen-recording hdr-hlg-regression style-7-prod`
- `bun run --cwd packages/core test src/compiler/htmlCompiler.test.ts src/compiler/timingCompiler.test.ts src/index.test.ts`
- `bunx oxfmt --check packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bunx oxlint packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/producer typecheck`
- `bun run --cwd packages/producer test --sequential style-1-prod style-9-prod`
- `bun run --filter @hyperframes/studio test` with `packages/core/dist` temporarily hidden to simulate clean-checkout config loading
- `git diff --check`
### CI artifact checks
- Inspected failed run `25225854394` job `73969147096`: style-1 failed only on `click-sfx` `1.044898` vs `1` duration/end.
- Inspected failed run `25225854394` job `73969147061`: style-9 failed only on SFX `1.044898`-based duration/end mismatches.
- Inspected failed run `25225854394` job `73969147048`: `vfr-screen-recording` compilation/audio passed, visual failed after comparing against the macOS-refreshed golden.
- Compared the first 10 uploaded CI vfr failure frames against the restored old baseline; minimum PSNR was `40.444705`, above the fixture threshold of `28`.
### Repro checks
- `bun packages/cli/src/cli.ts lint /tmp/hf-590-repro` now passes without `gsap_infinite_repeat`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-587-repro --at 0.5 --timeout 1000` now writes a 1080x1920 PNG.
- `bun packages/cli/src/cli.ts validate /tmp/hf-588-repro --timeout 500` no longer emits `null:1` / `NaN:1` contrast output.
- `bun packages/cli/src/cli.ts validate /tmp/hf-586-repro --timeout 500 --contrast false` no longer emits the muted-video StaticGuard contract error.
- `bun packages/cli/src/cli.ts compositions /tmp/hf-589-gsap-repro` now reports `foo 0.5s 1920x1080 1 element`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-589-gsap-repro --at 0.25 --timeout 2000` captures the expected template-backed red frame.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-584-repro --at 0.5,1.5 --timeout 500` captures the expected post-seek green frame.
### Browser verification
- Refreshed the local side-by-side comparison page at `qa-artifacts/pr-591-video-compare/index.html`.
- Served the comparison page locally and used `agent-browser` to load `style-12-prod`, play both videos quickly to the failed window, pause, and inspect the side-by-side frame.
- Browser proof screenshot: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12-labeled.png`.
- Browser proof recording: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12.webm`.
- Earlier Studio proof artifacts remain local-only: `qa-artifacts/dedupe-refactor-preview.png`, `qa-artifacts/dedupe-refactor-preview-after-play.png`, `qa-artifacts/dedupe-refactor-preview.webm`.
## Notes
- Browser proof and CI diagnostic artifacts are intentionally local-only and not committed.
- Studio's Vite config intentionally keeps the thumbnail clip helper inline because Vite/Vitest config startup runs through Node's loader before package source `.ts` imports are transformed.
- The committed PR diff changes `vfr-screen-recording/output/compiled.html` but no longer changes `vfr-screen-recording/output/output.mp4` relative to `main`.
- I attempted a local `linux/amd64` Docker validation to mirror CI, but the local Docker build was blocked by Debian package download failures. The arm64 Docker image also cannot launch the x64 Puppeteer headless shell under OrbStack. The vfr baseline decision is therefore based on the uploaded CI artifact comparison above.
- I kept this validated issue batch in one PR because the fixes overlap the same CLI/runtime capture surfaces.
## Problem
Users who want Tailwind utilities in a plain HyperFrames composition currently have to know which Tailwind browser script to add and where to place it. The first pass added `--tailwind`, but review caught three production-facing gaps: the CDN version was major-only, the insertion helper could silently no-op on compact HTML, and the render pipeline did not explicitly wait for Tailwind's async browser compilation before capturing frame 0.
There is also a version-specific agent risk: HyperFrames `init --tailwind` uses Tailwind v4.2 through `@tailwindcss/browser@4.2.4`, while `packages/studio` still uses Tailwind v3. Without a dedicated skill, agents can easily mix v3 `tailwind.config.js` / `@tailwind` patterns into v4 browser-runtime composition HTML.
## What this fixes
- Adds `hyperframes init --tailwind`.
- Pins the Tailwind browser runtime to `@tailwindcss/browser@4.2.4/dist/index.global.js` with SRI and `crossorigin="anonymous"`.
- Injects a `window.__tailwindReady` promise next to the browser runtime.
- Makes frame capture wait for `window.__tailwindReady` in both screenshot and BeginFrame capture modes before capturing frame 0.
- Inserts Tailwind support before `</head>` case-insensitively, including single-line/minified heads, and falls back to prepending when there is no head tag.
- Skips recursive Tailwind injection under `.git`, `dist`, and `node_modules`.
- Tracks whether init used Tailwind in the existing `init_template` telemetry event.
- Adds a first-party `/tailwind` skill for Tailwind v4.2 browser-runtime HyperFrames composition work.
- Updates README, docs, generated project agent files, CLI skill guidance, and plugin metadata so the Tailwind skill is discoverable.
- Documents the browser-runtime tradeoff and production/offline guidance.
## Root cause
`scaffoldProject()` copied the selected example and patched media placeholders, then immediately wrote project metadata and `package.json`. There was no optional post-copy step for framework-specific HTML support. The initial Tailwind post-copy step also treated the browser runtime like a static script, but Tailwind compiles utilities asynchronously after scanning the DOM, so the capture engine needed an explicit readiness contract.
On the agent side, the repo exposed HyperFrames, CLI, GSAP, registry, and runtime adapter skills, but had no Tailwind-specific instruction to separate the v4 browser-runtime composition path from Studio's v3 internal setup.
## Verification
### Local checks
- `bunx vitest run packages/cli/src/commands/init.test.ts`
- `bun run --filter @hyperframes/cli test src/commands/init.test.ts`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run lint:skills`
- `bun run lint`
- `npx skills add . --list` showed 12 local skills, including `tailwind`.
- `bunx oxfmt --check packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts packages/cli/src/telemetry/events.ts packages/engine/src/services/frameCapture.ts docs/packages/cli.mdx`
- `bunx oxfmt --check README.md docs/quickstart.mdx docs/packages/cli.mdx CLAUDE.md packages/cli/src/templates/_shared/CLAUDE.md packages/cli/src/templates/_shared/AGENTS.md skills/hyperframes-cli/SKILL.md skills/tailwind/SKILL.md .codex-plugin/plugin.json .cursor-plugin/plugin.json`
- `bunx oxlint packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts packages/cli/src/telemetry/events.ts packages/engine/src/services/frameCapture.ts`
- `git diff --check`
- Lefthook pre-commit: lint/format/typecheck for code commit; format for docs/skill commit
- Lefthook commit-msg: commitlint
Generated-project render proof at `/tmp/hf-tailwind-render-proof`:
- `bun packages/cli/src/cli.ts init /tmp/hf-tailwind-render-proof --example blank --tailwind --non-interactive --skip-skills`
- Added a temporary Tailwind-only card using `flex`, `h-full`, `w-full`, `items-center`, `justify-center`, `bg-slate-950`, `rounded-3xl`, `bg-white`, `px-20`, `py-12`, `text-8xl`, `font-black`, `text-black`, and `shadow-2xl`.
- `bun packages/cli/src/cli.ts lint /tmp/hf-tailwind-render-proof` → 0 errors, 0 warnings.
- `bun packages/cli/src/cli.ts validate /tmp/hf-tailwind-render-proof` → 0 errors, 0 regular warnings; the temp proof still reports validator contrast warnings even though the rendered/browser pixels show black text on white background.
- `bun packages/cli/src/cli.ts render /tmp/hf-tailwind-render-proof --workers 1 --fps 24 --quality draft --output /tmp/hf-tailwind-render-proof-artifacts/output.mp4`
- Render compiler inlined both GSAP and `https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.2.4/dist/index.global.js`.
- `ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,r_frame_rate,duration -of default=noprint_wrappers=1 /tmp/hf-tailwind-render-proof-artifacts/output.mp4` → H.264, 1920x1080, 24fps, 10s.
- Extracted frame-0 proof: `/tmp/hf-tailwind-render-proof-artifacts/frame-000.png`.
### Browser verification
- Started Studio preview for `/tmp/hf-tailwind-render-proof`.
- Used `agent-browser` to open `http://localhost:5194`.
- Verified the Tailwind-styled composition rendered in Studio preview.
- Captured screenshot: `/tmp/hf-tailwind-render-proof-artifacts/browser/tailwind-preview.png`.
- Captured agent-browser-driven recording: `/tmp/hf-tailwind-render-proof-artifacts/browser/tailwind-preview.webm`.
- Served the PR worktree locally and used `agent-browser` to open the new Tailwind skill proof page.
- Verified the browser-visible skill content includes `@tailwindcss/browser@4.2.4`.
- Captured screenshot: `/Users/miguel07code/.codex/worktrees/pr-577-tailwind-comments/tmp/agent-browser-proof/tailwind-skill.png`.
- Captured agent-browser-driven recording: `/Users/miguel07code/.codex/worktrees/pr-577-tailwind-comments/tmp/agent-browser-proof/tailwind-skill.webm`.
## Notes
- This still intentionally uses Tailwind's browser runtime rather than adding a generated Tailwind build pipeline. That keeps `hyperframes init --tailwind` small and compatible with the current no-install generated project workflow.
- The `/tailwind` skill cites official Tailwind v4 docs plus community skill references, but its instructions are HyperFrames-specific and tuned for the pinned v4.2 browser runtime.
- Browser proof artifacts are local-only under `/tmp/hf-tailwind-render-proof-artifacts/` and `tmp/agent-browser-proof/` and intentionally not committed.
## Problem
New HyperFrames projects should feel like normal JavaScript projects immediately after `hyperframes init`: users should have a canonical `npm run dev`, `npm run check`, `npm run render`, and `npm run publish` loop without needing to memorize raw CLI commands.
At the same time, the scaffold should stay opinionated. Adding many aliases would make the project surface harder to explain and maintain.
## What this fixes
- Writes a default `package.json` during `hyperframes init` when the selected example does not already provide one.
- Adds four project scripts only:
- `dev` -> preview in Studio
- `check` -> lint, validate, and inspect in sequence
- `render` -> render the video
- `publish` -> publish the project
- Pins generated scripts to the CLI version that created the project in packaged builds, while keeping source-checkout tests on the unpinned dev fallback.
- Uses `npx --yes` inside scripts so first-run commands do not stop on an install confirmation prompt.
- Updates generated `AGENTS.md` and `CLAUDE.md` guidance to present the same four-command workflow.
- Updates the non-interactive init success message to include `npm run dev`, `npm run check`, and `npm run render`.
## Root cause
`scaffoldProject()` copied the example, wrote `meta.json` and `hyperframes.json`, then copied agent guidance files. It never created a package manifest, so generated projects had no project-local command contract even though the workflow has stable repeated commands.
This revision keeps the scaffold narrow: `package.json` is the project workflow contract, but direct CLI usage remains available for advanced or one-off commands.
## Verification
### Local checks
- TDD red check from the first pass: `bun run --filter @hyperframes/cli test src/commands/init.test.ts` failed after updating the expected generated UX because `npm run check` was not emitted yet.
- `bun run --filter @hyperframes/cli test src/commands/init.test.ts`
- `bunx oxlint packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts`
- `bunx oxfmt --check packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts packages/cli/src/templates/_shared/AGENTS.md packages/cli/src/templates/_shared/CLAUDE.md`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run --filter @hyperframes/cli test`
- `bun run --filter @hyperframes/cli build`
- `bun run --filter @hyperframes/studio build`
- `git diff --check`
- `node packages/cli/dist/cli.js --version`
Generated-project smoke at `/tmp/hf-init-package-scripts-opinionated`:
- `node packages/cli/dist/cli.js init /tmp/hf-init-package-scripts-opinionated --example blank --non-interactive --skip-skills`
- inspected generated `package.json` and confirmed exactly `dev`, `check`, `render`, and `publish`
- confirmed packaged scripts use `npx --yes hyperframes@0.4.39 ...`
- `npm run check`
- `npm run render -- --quality draft --workers 1 --fps 24 --output /tmp/hf-init-package-scripts-opinionated.mp4`
- `ffprobe -v error -select_streams v:0 -show_entries stream=width,height,avg_frame_rate,duration -show_entries format=duration,size -of json /tmp/hf-init-package-scripts-opinionated.mp4`
- `npm run publish -- --help`
### Browser verification
- Started the generated project through the new script: `npm run dev -- --port 5199`.
- Used `agent-browser` to open `http://localhost:5199/#project/hf-init-package-scripts-opinionated`.
- Verified the Studio project loaded with the expected project name, controls, timeline, and composition player frame.
- Captured screenshot: `/tmp/hf-init-package-scripts-opinionated-browser.png`.
- Captured agent-browser-driven recording: `/tmp/hf-init-package-scripts-opinionated-browser.webm`.
- Verified recording metadata with `ffprobe`: 14.4s, 61 KB.
## Notes
- The generated source-checkout test still expects unpinned `npx --yes hyperframes ...` because source mode reports `0.0.0-dev`; the packaged CLI smoke covers the real-user pinned path.
- `npm run publish` was verified with `--help` only to avoid creating a real publish side effect during PR validation.
## Problem
HyperFrames already had `--gpu`, but that flag only controlled FFmpeg hardware encoding. The browser capture path still forced Chrome/WebGL through SwiftShader software GL via `--use-angle=swiftshader`, so WebGL-heavy local renders could leave the biggest bottleneck on the CPU path.
That made the existing flag naming easy to misread: `--gpu` sounded like it accelerated the whole render, but it did not change the browser frame-capture backend.
## What this fixes
- Enables host browser GPU acceleration automatically for local CLI renders.
- Adds `--no-browser-gpu` as the local opt-out for software Chrome/WebGL capture.
- Keeps `--browser-gpu` as an explicit local browser-GPU request.
- Adds `browserGpuMode: "software" | "hardware"` to engine config, with `PRODUCER_BROWSER_GPU_MODE` env support for lower-level producer users.
- Keeps Docker browser capture on the deterministic software path.
- Maps hardware browser GPU mode to platform-native Chrome backends:
- macOS: Metal-backed ANGLE
- Windows: D3D11-backed ANGLE
- Linux: EGL
- Blocks explicit `--browser-gpu --docker` with a clear error because Docker browser GPU passthrough is not cross-platform.
- Clarifies docs so `--gpu` means FFmpeg encoder GPU and browser GPU means Chrome/WebGL capture GPU.
- Keeps encoder backend selection auto-detected from FFmpeg capabilities:
- NVIDIA: NVENC
- macOS: VideoToolbox
- Linux: VAAPI
- Intel: QSV
## Why two flags
There are two separate GPU surfaces in the render pipeline:
1. Browser GPU controls Chrome frame capture.
- Affects WebGL, canvas, CSS rendering, compositing, and screenshot capture inside the browser.
- This is enabled automatically for local CLI renders.
- Use `--no-browser-gpu` when you want the software browser baseline.
2. `--gpu` controls FFmpeg video encoding.
- Affects the final encode step after frames have already been captured.
- The concrete encoder is auto-detected from the host FFmpeg build and hardware.
- It can be faster for some machines/codecs, but it is not equivalent to browser rendering acceleration.
The controls stay independent because users may want:
- `hyperframes render` for the fast local default with browser GPU capture.
- `hyperframes render --no-browser-gpu` for the software-browser local baseline.
- `hyperframes render --gpu` for browser GPU capture plus hardware FFmpeg encoding.
- `hyperframes render --no-browser-gpu --gpu` for software browser capture plus hardware FFmpeg encoding.
- `hyperframes render --docker` for deterministic browser capture.
## Why `--gpu` does not imply browser GPU
Keeping `--gpu` scoped to FFmpeg encoding avoids a semantic break and keeps the risk profile explicit:
- `--gpu` already means encoder acceleration. Expanding it to also change Chrome capture would silently alter behavior for users who only wanted hardware encoding.
- Browser GPU and encoder GPU have different portability. Encoder GPU can work in Docker when the host exposes the right devices; browser GPU passthrough is not cross-platform, so this PR intentionally blocks explicit `--browser-gpu --docker`.
- The Apple presentation benchmark shows why the controls should stay separate: browser GPU capture was the useful improvement, while macOS VideoToolbox via `--gpu` was slower and produced larger output for this `standard` H.264 run.
If HyperFrames later wants a single umbrella acceleration control, it should be explicit, for example `--acceleration browser|encoder|all` or `--gpu=browser|encoder|all`, rather than changing the meaning of the existing boolean `--gpu`.
## Root cause
`buildChromeArgs()` always injected `--use-gl=angle --use-angle=swiftshader`. `disableGpu` only appended `--disable-gpu`; it did not provide a hardware-GPU mode. That made the public `--gpu` flag look broader than it was, because render capture stayed software-backed even when encoder GPU was requested.
## Verification
### Local checks
- `bun install`
- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/engine test src/config.test.ts src/services/browserManager.test.ts`
- `bun run --filter @hyperframes/cli test src/utils/dockerRunArgs.test.ts src/commands/render.test.ts`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `cd packages/producer && bunx vitest run src/services/renderOrchestrator.test.ts`
- `bunx oxlint packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts packages/cli/src/utils/dockerRunArgs.ts packages/cli/src/utils/dockerRunArgs.test.ts packages/engine/src/config.ts packages/engine/src/config.test.ts packages/engine/src/services/browserManager.ts packages/engine/src/services/browserManager.test.ts packages/producer/src/services/renderOrchestrator.test.ts`
- `bunx oxfmt --check ...` on changed source/docs files
- `git diff --check`
- `bun packages/cli/src/cli.ts render --help | rg -n "browser-gpu|no-browser-gpu|GPU"`
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --output /tmp/hf-auto-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict`
- Render plan prints `GPU: browser GPU (auto)`.
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --no-browser-gpu --output /tmp/hf-software-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict`
- Render plan does not print browser GPU.
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --docker --browser-gpu --output /tmp/should-not-render.mp4`
- Exits 1 with `Browser GPU is local-only`.
- `buildDockerRunArgs()` regression coverage asserts Docker container args include `--no-browser-gpu`, preventing nested container renders from re-enabling browser GPU through the local CLI default.
- `resolveBrowserGpuForCli()` regression coverage asserts `PRODUCER_BROWSER_GPU_MODE=software` opts out when no CLI browser-GPU flag is supplied, while explicit `--browser-gpu` / `--no-browser-gpu` still win.
- `ffmpeg -v error -i /tmp/hf-auto-browser-gpu-smoke.mp4 -f null -`
- `ffmpeg -v error -i /tmp/hf-software-browser-gpu-smoke.mp4 -f null -`
- `ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of json /tmp/hf-browser-gpu-smoke.mp4` -> H.264, 1920x1080, 24fps, 5.0s
### Apple presentation benchmark
Rendered `/Users/miguel07code/Downloads/apple-presentation.zip` as supplied after extracting to `/tmp/hf-apple-profile/apple-presentation`.
Fixed settings:
- 1920x1080
- 30fps
- `standard` quality
- 4240 frames
- 141.32s duration
- 8-worker cap; render auto-calibration used 6 capture workers
- macOS host detected FFmpeg GPU encoder: `videotoolbox`
| Mode | Equivalent flags after this PR | Wall time | vs software-browser baseline | Speed | Capture | Encode | Output |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Software browser + CPU encode | `--no-browser-gpu` | 120.77s | baseline | 1.17x | 97.87s | 10.04s | 8.38MB |
| Browser GPU + CPU encode | default local render | 70.10s | 42.0% faster | 2.02x | 50.72s | 9.91s | 8.39MB |
| Software browser + encoder GPU | `--no-browser-gpu --gpu` | 133.16s | 10.3% slower | 1.06x | 103.58s | 18.31s | 25.43MB |
| Browser GPU + encoder GPU | `--gpu` | 74.12s | 38.6% faster | 1.91x | 46.69s | 17.93s | 25.45MB |
Result: browser GPU capture is the meaningful improvement for this WebGL/browser-capture-heavy presentation. VideoToolbox encoding was slower and produced larger files for this current `standard` H.264 path, so `--gpu` should stay separate and opt-in.
Why `--gpu` plus browser GPU was slower than browser GPU alone: the combined run captured about 4.0s faster than browser GPU alone, but VideoToolbox encoding was about 8.0s slower than CPU x264 encoding, so the encode loss outweighed the capture gain.
### VideoToolbox flag check
I also isolated the encode stage against the already-captured Apple frames to check whether macOS GPU encoding only needed special flags.
`ffmpeg -h encoder=h264_videotoolbox` does not expose a CRF/CQ-style quality option like x264. It exposes bitrate-oriented and VideoToolbox-specific options such as `-b:v`, `-realtime`, `-profile`, `-coder`, `-prio_speed`, `-power_efficient`, and `-allow_sw`. That means our current `-q:v` mapping is not equivalent to x264 CRF and can produce very different bitrate/size behavior.
Measured full-frame encode variants on this host:
| VideoToolbox variant | Encode wall time | Output size | Bitrate |
| --- | ---: | ---: | ---: |
| Current `-q:v 64 -allow_sw 1` | 18.76s | 25.31MB | 1.43 Mbps |
| Current without `-allow_sw 1` | 18.21s | 25.31MB | 1.43 Mbps |
| `-b:v 500k -maxrate 750k -bufsize 1000k -profile high -coder cabac -realtime 1 -prio_speed 1 -power_efficient 0` | 20.58s | 7.42MB | 0.42 Mbps |
| Same with `-b:v 1500k` | 20.84s | 16.70MB | 0.95 Mbps |
| `-b:v 500k -profile baseline -coder cavlc -realtime 1 -prio_speed 1 -power_efficient 0` | 18.11s | 8.94MB | 0.51 Mbps |
Conclusion: VideoToolbox can be made size/bitrate-predictable with explicit `--video-bitrate`, but the tested speed-oriented flags did not make it faster than CPU x264 wall time for this render. That reinforces keeping `--gpu` encoder acceleration explicit and separate from browser GPU capture.
Artifacts from the local benchmark:
- `/tmp/hf-apple-profile/results/cpu.mp4`
- `/tmp/hf-apple-profile/results/browser-gpu.mp4`
- `/tmp/hf-apple-profile/results/encoder-gpu.mp4`
- `/tmp/hf-apple-profile/results/full-gpu.mp4`
- `/tmp/hf-apple-profile/results/summary.json`
All four benchmark MP4s completed `ffprobe` and full `ffmpeg -f null` decode checks.
### Pixel comparison
Compared decoded MP4 output between software-browser and browser-GPU renders:
- Apple presentation:
- 4240 frames compared
- 636 exact matching decoded frame hashes
- 3604 different decoded frame hashes
- Average PSNR: 57.79 dB
- `css-spinner-render-compat` clean fixture:
- 120 frames compared
- 0 exact matching decoded frame hashes
- Average PSNR: 61.57 dB
Interpretation: browser GPU output is not strict hash/pixel-identical to the software-browser path after lossy H.264 encode, but the measured deltas are visually tiny. Above 50 dB PSNR is typically visually indistinguishable for normal video review. Use `--no-browser-gpu` or Docker when strict cross-run/cross-machine reproducibility matters more than local speed.
### Browser verification
- Started HyperFrames Studio preview for `packages/producer/tests/css-spinner-render-compat/src`.
- Used `agent-browser` to open `http://localhost:5191#project/src` and verify the composition loaded in Studio.
- Screenshots:
- `/tmp/hf-gpu-browser-proof/preview-loaded.png`
- `/tmp/hf-gpu-browser-proof/preview-playing.png`
- `/tmp/hf-gpu-browser-proof/preview-frame-60.png`
- Agent-browser recordings:
- `/tmp/hf-gpu-browser-proof/preview-playback.webm`
- `/tmp/hf-gpu-browser-proof/preview-seek.webm`
## Notes
- Browser GPU is enabled automatically for local CLI renders and disabled in Docker.
- `--no-browser-gpu` is the opt-out for software Chrome/WebGL capture.
- `--gpu` remains encoder-only and opt-in.
- The Apple presentation zip has existing lint errors around unmanaged nested videos and imperative media `play()` calls. The benchmark still compares the same supplied source across modes, but it should not be treated as a clean deterministic-composition fixture.
## Problem
The Catalog did not include the four prompt-matched Stronkter one-shot HyperFrames projects, and registry metadata only supported a plain author string, so there was no structured way to show creator attribution or the original generation prompt on generated catalog pages.
## What this fixes
- Adds four Catalog blocks matching the provided prompts, in order:
- `north-korea-locked-down`
- `apple-money-count`
- `nyc-paris-flight`
- `goonvpn-youtube-spot`
- Attributes each block to [Stronkter](https://x.com/Stronkter).
- Stores and renders the original source prompt for each generated catalog page.
- Adds a local realistic map plate for the North Korea block so rendering does not depend on live map tile requests.
- Extends registry item metadata/schema with `authorUrl` and `sourcePrompt`.
- Updates catalog page generation to read items from `registry/registry.json`, keeping generated docs aligned to the public registry manifest.
- Ignores normal browser media preload `net::ERR_ABORTED` request failures for media assets during `hyperframes validate`, while preserving failures for real missing assets.
## Root cause
The imported projects are Catalog-ready compositions, but the registry/docs pipeline did not have first-class source-prompt or linked-author fields to expose creator credit on generated MDX pages. The audio-backed compositions also surfaced a validation edge case: Chrome can report aborted media preload requests as `net::ERR_ABORTED` even when the audio file exists and playback is valid.
## Verification
### Local
- `bun run --filter @hyperframes/cli test src/commands/validate.test.ts`
- `bun run --filter @hyperframes/core test src/registry/types.test.ts`
- `bun run sync-schemas:check`
- `bunx oxlint packages/cli/src/commands/validate.ts packages/cli/src/commands/validate.test.ts packages/core/src/registry/types.ts packages/core/src/registry/types.test.ts scripts/generate-catalog-pages.ts`
- `bunx oxfmt --check ...` on changed source, registry, docs, and composition files
- `git diff --check`
- `bun packages/cli/src/cli.ts lint` and `validate` against temp installed projects for all four blocks
- Lefthook pre-commit: lint/format/typecheck on the initial commit, plus format on the amend
- Lefthook commit-msg: commitlint
### Browser
- Exercised all four blocks through HyperFrames preview routes with `agent-browser`.
- Captured playback screenshots and WebM recordings for:
- `north-korea-locked-down`
- `apple-money-count`
- `nyc-paris-flight`
- `goonvpn-youtube-spot`
## Notes
- The zip also contained unrelated project directories, but this PR intentionally includes only the four prompt-matched Catalog blocks requested here.
- The imported one-shot compositions may trigger the existing large-composition lint warning, but there are no lint errors and runtime validation passes.
## Problem
Closes#555. Studio users could inspect the preview, but there was no first-class way to capture the current rendered frame as an image.
## What this fixes
- Adds a `Capture` action to the Studio header toolbar so it does not cover the video preview.
- Downloads the current composition frame as a PNG using the current player time.
- Extends the existing thumbnail route and Studio/CLI thumbnail generators with an explicit PNG format path while preserving JPEG thumbnails for existing previews.
- Adds URL/filename utility coverage plus thumbnail route coverage for PNG requests.
## Root cause
Studio already had frame thumbnail generation, but the API path was JPEG-oriented and the editor UI only used it for previews. There was no current-frame capture affordance wired to the player state.
## Verification
### Local
- `bun run --filter @hyperframes/core test src/studio-api/routes/thumbnail.test.ts`
- `bun run --filter @hyperframes/studio test src/utils/frameCapture.test.ts src/player/components/PlayerControls.test.ts`
- `bun run --filter @hyperframes/studio typecheck`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bunx oxlint packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts`
- `bunx oxfmt --check packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts`
- `git diff --check`
### Browser
<img width="1027" height="910" alt="image" src="https://github.com/user-attachments/assets/71973af4-0279-4074-9
<img width="1026" height="902" alt="Screenshot 2026-04-29 at 16 17 22" src="https://github.com/user-attachments/assets/a32e1c19-b793-40b9-82f8-de8bbb11f123" />
060-130839a2d419" />
Use loadRuntimeSource() from runtimeSource.ts instead of a single
hardcoded path. Tries: build from source (dev), inlined constant
(production), pre-built artifact (fallback). Fixes snapshot in dev
mode where __dirname is src/commands/ not dist/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(render): auto-detect HDR from media probes, add --sdr flag
Replace the --hdr opt-in model with automatic detection. When no flags
are passed, the renderer probes all video/image sources and enables HDR
output if any HDR color space is detected. Existing --hdr flag becomes
a force override. New --sdr flag forces SDR output.
Behavior matrix:
(no flags) + HDR content → HDR output
(no flags) + SDR content → SDR output
--hdr → force HDR (defaults to HLG if no HDR sources)
--sdr → force SDR (skips probing)
--hdr --sdr → error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: align HDR auto-detect docs and tests
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The snapshot command resolved the HyperFrames runtime IIFE via a
relative path that walked up three directories to packages/core/dist/.
This only worked in the monorepo dev layout — npm/npx installs have
a flat dist/ folder with cli.js and the runtime side by side.
Without the runtime, window.__player was never created and the
snapshot fell back to seeking every __timelines entry to the same
absolute time. Sub-composition timelines expect relative time
(offset from their data-start), so all beats rendered beat-1 content.
Fix: resolve("hyperframe.runtime.iife.js") from __dirname (the dist/
folder itself), where the build already copies the runtime IIFE.
`lintAudioSrcNotFound` resolves every `<audio src>` against the project
root, which is correct for index.html but wrong for sub-compositions —
their srcs are written relative to the sub-composition file (e.g.
`../assets/foo.mp3`), and the bundler rewrites them at compile time.
Carry the sub-composition path alongside each html source and run
`<audio src>` strings starting with `../` through the existing
`rewriteAssetPath` helper before checking existence on disk. Mirrors the
runtime/bundler behaviour so the lint check sees the same path the
renderer will fetch.
Original (un-rewritten) src is still surfaced in the finding message so
authors can grep for it in their HTML.
* fix(cli): shut down preview embedded-mode server on Ctrl+C
runEmbeddedMode awaited a promise that never resolved, relying on Node
to exit on SIGINT. Two things kept that from working in practice:
1. @hono/node-server's listening handle keeps the event loop alive after
the signal fires, so the process hangs even when SIGINT does arrive.
2. On Windows, some terminals (Git Bash / MSYS) don't deliver Ctrl+C to
the Node process as a SIGINT at all — the keystroke is eaten at the
TTY layer.
Register a SIGINT/SIGTERM handler that closes the server explicitly and
resolves the promise, with a 2s force-exit fallback. On Windows, run a
readline interface on stdin to catch Ctrl+C at the TTY and re-emit it
as SIGINT so the same handler fires.
Print "Shutting down studio..." as soon as the signal is received —
server.close() can take a second or two to drain keep-alive connections
and an unmarked pause reads as "stuck".
Exit code 0 because a user-initiated Ctrl+C isn't an error; non-zero
codes make pnpm print ELIFECYCLE right where the user just asked the
process to stop.
* fix(cli): close readline interface in shutdown to honour exit-code intent
After the SIGINT handler removes itself, the Windows readline interface
is still alive and listening. A second Ctrl+C during the 2s grace
period would re-emit SIGINT with no registered handler, triggering
Node's default exit-130 behaviour and contradicting the explicit
exit(0) we chose for clean teardown.
Hoist the readline handle out of the win32 branch so shutdown can
close it before invoking server.close(). Also pass the signal name
to process.emit("SIGINT", "SIGINT") to match Node's ProcessEvents
overload — runtime behaviour is unchanged.
## Summary
- preserve alpha for render-injected video frames by detecting alpha streams with ffprobe and extracting alpha video frames as PNG
- keep `<video loop>` semantics through static parsing, compiler duration resolution, browser media discovery, and render frame lookup
- fail embedded preview startup before opening a broken browser page when the Studio bundle is missing
- align snapshot frame injection with looped media timing and VP9 alpha extraction
## Why
The Studio preview and rendered MP4 could disagree for timed transparent looped videos. The Comfy funding composition exposed two separate parity bugs: render-injected frames needed alpha-preserving PNG extraction, and the compiler was clamping a looped `data-duration="4"` video down to the 3.125s source duration. After the first source cycle, render lookup treated the video as inactive, hid the native video, and produced the blank polygon/glow the user saw around the rounded `0:03` mark.
`hyperframes lint` and `hyperframes validate` did not catch this because they check syntax/load/console/accessibility, not preview-vs-render visual parity. This PR adds regression coverage for the compiler loop-duration path and frame lookup path.
## Verification
- `bun run --filter @hyperframes/core test -- src/compiler/timingCompiler.test.ts src/compiler/htmlCompiler.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bun run --filter @hyperframes/engine test -- videoFrameExtractor ffprobe`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run lint`
- `bun run format:check ...` on touched files
- Comfy project: `node packages/cli/dist/cli.js validate` -> no console errors, 44 text elements pass WCAG AA
- Comfy project patched render from source: `/tmp/comfy-render-compare/fixed6-comfy.mp4`, 1920x1080, 30fps, 21.8s, 654 frames
- 3.00s-3.97s render contact sheet: `/tmp/comfy-render-compare/fixed6-window-contact.png`
- targeted fixed render capture at 3.733s: `/tmp/comfy-render-compare/probe-capture-fixed/captured/frame_000112.jpg`
- agent-browser Studio proof screenshot at 3.7s: `/tmp/comfy-render-compare/agent-browser-studio-3_7-fixed.png`
- agent-browser-driven recording of 3s seek pass: `/tmp/comfy-render-compare/agent-browser-wysiwyg-3s-fixed.webm`
Note: `bun run --filter @hyperframes/cli dev -- validate` is blocked in source mode by the existing `contrast-audit.browser.js` default-export loader issue; packaged `node packages/cli/dist/cli.js validate` passes for this project.
## 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.