Per Miguel's review: the previous fixture had no body / root background, so it
passed against both the buggy and fixed code. The fix this PR makes (the
initTransparentBackground stylesheet injection in initializeSession) only
matters when a composition paints over the CDP default-background-color
override — exactly what we tell users not to do, but exactly what a
regression test must do.
Reproduced locally:
- base SHA (2935be6): pixel (10,10) decodes as rgba [16,16,16,255]
(opaque #111 body bg leaks through the pre-navigation override that
Chrome resets on goto)
- this head: pixel (10,10) decodes as rgba [0,0,0,0]
(initTransparentBackground injects [data-composition-id]{background:transparent !important}
AFTER navigation, force-overriding the body bg)
The pixel-level assertions in transparency-test.ts are unchanged — they
already require alpha=0 at (10,10). With the body bg painted, that
assertion now fails on any code path that doesn't actually preserve alpha
end-to-end.
Extends RenderConfig.format with "png-sequence" and patches two correctness
gaps so the existing "webm" / "mov" values actually preserve the alpha
channel end-to-end.
Engine fixes:
- screenshotService.pageScreenshotCapture: drop optimizeForSpeed for PNG
captures. The fast path uses an alpha-unaware codec that crushes real
alpha values; kept for opaque jpeg captures where it is harmless.
- frameCapture: replace the inline setDefaultBackgroundColorOverride
block (which fired pre-navigation and was reset by page.goto) with a
proper initTransparentBackground() call inside initializeSession,
after the window.__hf readiness poll. This also injects the
html/body/[data-composition-id]{background:transparent !important}
stylesheet so compositions with custom body / #root backgrounds do not
defeat the override. Wired into both screenshot-mode and beginframe-mode
branches.
Producer:
- RenderConfig.format extended to "mp4" | "webm" | "mov" | "png-sequence"
with full JSDoc.
- Streaming encode is bypassed for png-sequence (frames go straight to
disk). FORMAT_EXT extended.
- New Stage-5 png-sequence branch: mkdir outputPath, copy captured PNGs as
frame_NNNNNN.png, copy audio.aac sidecar when audio is present.
- Stage-6 mux/faststart and the debug copy are wrapped in !isPngSequence.
- README.md: new "Transparent Video Output" section.
Tests:
- New fixture tests/transparency-regression/ tagged "transparency".
- New tsx script src/transparency-test.ts asserts pixel-level alpha for
webm + png-sequence outputs. Wired as "test:transparency".
- Default "test" / "test:update" scripts pass --exclude-tags transparency
so the golden-MP4 harness ignores the new fixture.
Verified locally on macOS arm64: typecheck clean across engine + producer,
producer renderOrchestrator vitest 10/10, transparency-test passes for
both webm and png-sequence with end-to-end pixel assertions.
Change build order from concurrent to staged to prevent @hyperframes/engine
from starting TypeScript compilation before @hyperframes/core generates
src/generated/runtime-inline.ts.
This fixes intermittent "Cannot find module './generated/runtime-inline'"
errors when running bun run build.
Co-Authored-By: Claude Opus 4.7 <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.
- Link to claude.ai/design instead of claude.ai
- Remove raw.githubusercontent download links (just GitHub with ↓ button)
- Fix stale SKILL.md link text in prompting guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(lint): remove root_composition_missing_data_duration
Lint cannot statically observe the runtime's true Infinity-emission
condition: it requires a finite GSAP timeline duration AND a finite
media/sub-comp window AND timeline > floor + 1, none of which are
visible to the static linter. The looping shapes that drive the
condition are already covered by `gsap_infinite_repeat` and
`gsap_repeat_ceil_overshoot` (both from #243), which point at the
real authoring mistake — flagging the missing duration separately
was a noisy proxy for the same signal.
Per #490 review discussion, deprecate the static rule and let those
two GSAP rules carry the pre-render coverage. If perfect precision
on `durationInFrames = Infinity` is needed, that belongs on the
runtime/render path where `shouldEmitNonDeterministicInf` is
actually known.
Add regression tests pinning the removal: a docs-compliant root
without `data-duration` no longer warns, and the canonical
loop-inflated shape now surfaces only via `gsap_infinite_repeat`
instead of two duplicate findings.
* docs(skills): update step-6 build checklist after rule removal
Drops the `root_composition_missing_data_duration` reference now that
the rule is gone. Keeps the authoring recommendation (and explains the
runtime Infinity case) but points authors at the GSAP rules that
actually carry the lint signal: `gsap_infinite_repeat` and
`gsap_repeat_ceil_overshoot`.
`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.
The studio shell paints a #0a0a0a body but never declares `color-scheme`,
so browsers render native UA chrome (scrollbars, form controls, focus
rings) in the light palette and the mismatch is obvious — especially
scrollbars, which appear as light tracks sitting on top of a near-black
panel.
Studio doesn't expose a theme toggle; the UI is dark-only. Declaring
`color-scheme: dark` on `:root` lines up the browser-native surfaces
with the rest of the chrome.
* fix(studio): restore flex layout inside the NLE timeline panel
Without `flex flex-col`, the toolbar and Timeline children render as
block elements and Timeline's `h-full` takes the panel's entire height.
The toolbar still occupies its own ~45px of flow, pushing the scroll
area below the viewport and clipping the scrollbar at the bottom of the
right-side panel.
* fix(studio): pin toolbar height with flex-shrink-0 and trim comment
Wrap the toolbar slot in a flex-shrink-0 div so Timeline back-pressure
can't squeeze the toolbar below its natural height. The visible bug
doesn't manifest at current sizes, but in a flex-col container both
children are flex items and the toolbar has no shrink guard of its own.
Trim the inline comment to a single line — the rationale belongs in
the commit message, not at the call site.
* 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.
* fix(engine): suppress benign AbortError spam from frame-capture pageerror
Frame capture pauses → seeks → screenshots → plays audio/video many times
per second. HTMLMediaElement.play() returns a promise that rejects with
AbortError whenever another pause() lands before it resolves — which it
does, every frame. The rejection is benign (output frames and mixed
audio are unaffected) but the frameCapture pageerror handler was logging
it to stderr, producing dozens of identical lines per render:
[Browser:PAGEERROR] AbortError: The play() request was interrupted by
a call to pause(). https://goo.gl/LdLk22
Filter out exactly this pattern before console.error — still pushed to
browserConsoleBuffer so it's available in the failure-diagnostic dump.
* fix(engine): trim play-abort filter comment and drop unnecessary regex flags
The why-it-exists explanation belongs in the commit message and PR
description, not as a 12-line comment block at the call site.
Chrome's play()/pause() AbortError message is always lowercase, so
the case-insensitive flag implies uncertainty that doesn't exist.
Replace the two /play\(\)/i and /pause\(\)/i regexes with plain
String.prototype.includes — same outcome, less ceremony.
## 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.
## Problem
Studio seek could still wake nested composition media even when the transport itself stayed paused.
In the real repro from `apple-presentation`, scrubbing to `0:29` without pressing play lands on the `slide-translation` composition. That composition contains `Multilingual_Journey.mp4` inside the composition host. On the broken path:
- the main Studio transport remained paused
- the nested video advanced and stayed playing anyway
- the user saw autoplay-like behavior even though the only action was a seek
That was especially confusing because the seek was otherwise correct: the timeline moved to the right point, but the nested media stopped obeying the paused transport state.
## What this fixes
### Nested media now participates in runtime media sync
- the runtime media cache no longer assumes only `video[data-start]` / `audio[data-start]` are relevant
- nested media inside a composition host can now be included in the same timed-media sync pass even when the inner media element does not carry its own authored `data-start`
### Nested media timing is resolved in the host composition window
- nested media start time is resolved against the enclosing composition host instead of falling back to scene-local `0`
- nested media duration is clamped to the enclosing composition window so it stays aligned with the authored host clip timing
### Paused seeks land on the right frame and stay paused
- after seeking into a nested composition, the inner media is now seeked to the correct frame relative to the host timeline
- because it is now part of the managed media set, the runtime also keeps it paused when the transport is paused instead of letting it continue playing on its own
### Regression coverage
- adds a runtime regression test that covers a nested composition video with no local `data-start`
- the test verifies that `player.seek(29)` leaves the nested video paused while landing it at the expected `currentTime`
## Root cause
The bug came from a mismatch between deterministic timeline seeking and media ownership.
### 1. The runtime only managed media with direct timing attrs
`refreshRuntimeMediaCache()` only collected `video[data-start]` and `audio[data-start]`. That works for root-level timed media, but not for media embedded inside a composition host where timing is inherited from the host composition rather than duplicated onto the inner media node.
### 2. Nested composition seek could still advance inner media
The runtime intentionally rearms sibling timelines during deterministic seek so nested timelines land on the right local offsets. That part is necessary and correct.
But because the nested video was not part of the managed media cache, it could advance during that seek path without being brought back under the paused transport state afterward.
### 3. The runtime had no way to reconcile the two
So the system had an inconsistent split:
- timeline seek knew about the nested composition timeline
- media sync did not know about the nested media inside it
The fix closes that split by resolving nested media start/duration from the enclosing composition context and running it through the same sync logic as other managed media.
## Verification
### Local checks
- `bun run --filter @hyperframes/core typecheck`
- `bun run test -- src/runtime/init.test.ts src/runtime/media.test.ts src/runtime/player.test.ts` in `packages/core`
- `bunx oxlint packages/core/src/runtime/media.ts packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bunx oxfmt --check packages/core/src/runtime/media.ts packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
### Browser verification
Verified against a repo-backed local Studio preview of `apple-presentation`:
- opened `http://127.0.0.1:3014/#project/apple-presentation`
- seeked to `0:29` without pressing play
- confirmed the visible composition switched to `slide-translation`
- confirmed `Multilingual_Journey.mp4` landed at a non-zero `currentTime` (`3.067` in the verified run)
- confirmed the nested video stayed `paused` and its `currentTime` remained stable across a follow-up check instead of autoplaying
## Notes
- the local browser proof artifacts under `qa-artifacts/autoplay-seek/` are verification-only and are not part of this PR
- this PR is intentionally scoped to nested media ownership during paused seek; it does not broaden into unrelated runtime media refactors beyond bringing inherited nested media under the existing sync contract