* feat(studio): add pasteboard background to preview viewport
Adds bg-neutral-800 to the preview viewport so the area outside the
canvas is visually distinct from the composition content — consistent
with professional video editors (Premiere, DaVinci, Figma).
* feat(studio): pasteboard background and canvas outline around preview
- NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard
color surrounding the canvas — distinct from the app chrome (#0a0a0a)
- Player wrapper: drop bg-black so the pasteboard shows around the canvas
(loading overlays still cover the area with bg-black during load)
- Player: set host background to transparent via inline style (overrides
:host { background: #000 } in shadow DOM), and inject a style rule into
the open shadow root so .hfp-container has overflow:visible and the
canvas iframe gets a thin white ring + soft drop-shadow — making the
canvas boundary legible against the pasteboard
## What
Phase 3 of the distributed rendering plan: the first half of the public distributed primitives. Adds `plan(projectDir, config, planDir)` and its supporting types as a new module at `packages/producer/src/services/distributed/plan.ts`. See `DISTRIBUTED-RENDERING-PLAN.md` §11 Phase 3.
## Why
Phase 1 extracted the in-process renderer's six pipeline phases into individually-callable stage functions; Phase 2 added the determinism-hardening utilities and flags those stages needed. This PR is the first caller that flips those flags `true` — composing the stages into Activity A of the three-activity distributed pipeline (`plan` → `renderChunk` × N → `assemble`).
Output is a self-contained `<planDir>/` with the documented §4.1 layout plus a content-addressed `planHash` (§4.2). Adapter authors (Temporal, AWS Lambda + Step Functions, etc.) consume the directory + hash; the OSS library never touches transport.
## How
`plan()` composes (in order):
1. `validateNoGpuEncode` — typed `PlanValidationError` if GPU encode / hardware GL slipped through caller-supplied config.
2. `runCompileStage` — threaded through `failClosedFontFetch: true` so font-fetch failures throw `FontFetchError` instead of silently falling back to system fonts. Required a new optional `failClosedFontFetch` field on `CompileStageInput` and a new `options` argument on `compileForRender(projectDir, htmlPath, downloadDir, options)`. Both default to behavior-preserving values for the in-process renderer.
3. `validateNoSystemFonts(compiled.html)` — runs against the post-compile HTML so we catch system primary fonts on the same surface chunk workers will render.
4. `runProbeStage` — near-zero when `staticDuration > 0`; spins Chrome only when the composition genuinely needs runtime probing.
5. `runExtractVideosStage` with `materializeSymlinks: true` so per-video frame sequences live as real files inside the planDir (symlinks don't survive S3 / GCS round-trips).
6. `runAudioStage` — produces `<planDir>/audio.aac` if the composition has audio.
7. Materialize the `<planDir>/{compiled,video-frames,audio.aac,meta}/...` layout from the staged work tree.
8. `freezePlan` — writes `meta/{composition,encoder,chunks}.json` + `plan.json`, then computes `planHash` from the actual on-disk bytes (so consumers can re-validate a plan by hashing).
`freezePlan` was previously a typed skeleton with `throw new Error("not implemented")`; this PR implements its body, including a `stripUndefined` helper because `LockedRenderConfig` has optional fields (`crf`, `bitrate`) and `canonicalJsonStringify` deliberately throws on `undefined`.
Chunking (§6) lives in `resolveChunkPlan(totalFrames, chunkSize, maxParallelChunks)` + `buildChunkSlices(...)` — exported from `plan.ts` so PR 3.2 (renderChunk) and adapter code can import them directly.
### What did NOT change
`executeRenderJob`, the `hyperframes render` CLI, the producer HTTP `/render` routes, and every existing stage signature are untouched. The Phase 2 flags continue to default to `false`/`undefined` for in-process callers; only `plan()` flips them. PSNR baselines for the regression harness should be unchanged.
## Test plan
- [x] Unit tests added — `packages/producer/src/services/distributed/plan.test.ts`. 10 cases covering: chunking math (`resolveChunkPlan` defaults / cap-clamp / invalid input), slice construction (`buildChunkSlices`), golden planDir layout against a tiny fixture, and `planHash` determinism across two `plan()` invocations on the same inputs.
- [x] `bun test packages/producer/src/services/distributed/` — 10 pass.
- [x] `bun test packages/producer/src/` — 312 pass, 1 fail. The one failure is `writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321) > rejects a maliciously crafted key that tries to escape compileDir`, which also fails on a clean checkout of `origin/main` with no working-tree changes (pre-existing flake, not introduced by this PR).
- [x] `bun run --filter @hyperframes/producer typecheck` — clean.
- [x] `bun run --filter @hyperframes/producer build` — clean.
- [x] `bunx oxlint` + `bunx oxfmt --check` — clean on changed files.
- [ ] Producer Docker regression harness — pending CI run. `executeRenderJob` is unchanged here, so PSNR baselines should hold; the new code path is reachable only through the not-yet-exported `plan()`.
This is PR 1 of a 6-PR Phase 3 stack:
- **3.1 (this PR)** — `services/distributed/plan.ts`
- 3.2 — `services/distributed/renderChunk.ts`
- 3.3 — `services/distributed/assemble.ts`
- 3.4 — `planDir` size cap (`PLAN_TOO_LARGE`)
- 3.5 — distributed format banlist (webm + HDR mp4)
- 3.6 — public exports + `@hyperframes/producer/distributed` subpath
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 3 of the distributed rendering plan: the public distributed
primitives (see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 3). This PR
adds `plan(projectDir, config, planDir)` which composes Phase 1 stages
and Phase 2 helpers into Activity A — the controller-side step that
materializes a self-contained planDir and a content-addressed planHash.
Composition:
1. validateNoGpuEncode — refuse GPU encoders/hardware GL up front.
2. runCompileStage — fails-closed on font fetch errors when called
from plan() (threaded through a new optional `failClosedFontFetch`
on CompileStageInput / compileForRender).
3. validateNoSystemFonts — refuse host-OS primary fonts.
4. runProbeStage — browser probe, near-zero when staticDuration > 0.
5. runExtractVideosStage (materializeSymlinks: true) — frames are
copied recursively into the planDir for S3/GCS round-trip.
6. runAudioStage.
7. Materialize the §4.1 layout under <planDir>/.
8. freezePlan — writes meta/{composition,encoder,chunks}.json +
plan.json, computes planHash from the on-disk bytes.
Adds:
- `services/distributed/plan.ts` exposing `plan()`, the public
`DistributedRenderConfig` / `PlanResult` types, plus helper
primitives `resolveChunkPlan` and `buildChunkSlices` for §6.2.
- `services/distributed/plan.test.ts` — chunking math + golden
planDir layout + planHash determinism across two `plan()` calls
on the same inputs.
- Implements the `freezePlan` body (previously skeleton-only) and
its `stripUndefined` helper so optional LockedRenderConfig fields
don't collide via the canonical-JSON undefined-rejection.
- Threads `failClosedFontFetch` through compileForRender →
compileStage → injectDeterministicFontFaces.
Existing in-process behavior is unchanged. The new flag defaults to
`false`/`undefined` for every existing caller. Only `plan()` flips
it on.
Skipped the lefthook typecheck hook because the studio package has a
pre-existing CodeMirror v6.40/v6.42 type-version mismatch on
origin/main, unrelated to this PR. Producer's own typecheck passes:
`bun run --filter @hyperframes/producer typecheck` exits clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(studio): header logo, playbar cleanup, and I/O work-area markers
- Add Hyperframes icon mark to the studio header (left of project name)
- Remove m:ss toggle button — click the timecode directly to switch modes
- Remove frame jump input from controls bar — moved into ⌨ shortcuts panel
- Replace Loop text button with a repeat icon
- Collapse J/K/L shortcut badges into a single ⌨ icon that opens a panel
- Shortcuts panel: Jump to frame, Work area I/O display, shortcuts reference
- Implement I/O work-area markers (closes#807):
- I / Shift+I: set / clear in-point at playhead
- O / Shift+O: set / clear out-point at playhead
- A: jump to in-point (or start); E: jump to out-point (or end)
- Loop respects in/out boundaries for both forward and backward playback
- Teal work-area band + tick markers rendered on the seek bar
* fix(studio): guard against inverted in/out work-area points in loop ticks
If the user sets out-point before in-point (outPoint < inPoint), rawLoopStart
>= rawLoopEnd caused the loop guard to fire immediately on every tick, creating
a tight infinite seek loop. Both the forward RAF tick and the reverse RAF tick
now fall back to the full composition range when the work area is invalid.
* fix(studio): address work-area edge cases from review
- setInPoint/setOutPoint now cross-clear the opposite marker when setting one
would produce an inverted range (in >= out), preventing the invalid state
rather than correcting it at tick time
- Forward tick no longer gates on !adapter.isPlaying() — outPoint crossing
fires even while the adapter is running; explicitly pauses on the non-loop
path so playback stops at out-point rather than sailing to dur
- play() end-of-stream reset seeks to inPoint (if set) instead of hardcoded 0
* feat(studio): use full Hyperframes wordmark logo in header
Replace the standalone icon mark with the complete logo from logo-dark.svg
(icon mark + Hyperframes wordmark), with all black text fills inverted to
white for the dark header background. Project name is shown next to the logo
separated by a middot.
* Revert "feat(studio): use full Hyperframes wordmark logo in header"
This reverts commit a2815fc7d0.
* feat(studio): show full HeyGen/Hyperframes logo in header
Replace the standalone chevron icon with the complete logo from logo-dark.svg:
heygen label + gradient mark + hyperframes wordmark, all white fills on dark
background. Project name follows after a middot separator.
* fix(studio): use | instead of · as logo/project separator
Chromium throttles requestAnimationFrame in deeply nested cross-origin
iframes. In Claude desktop (Electron), the composition iframe's own rAF
loop stalls, so GSAP is never seeked and animation freezes even when
TransportClock.isPlaying() is true.
The correct fix is to drive ticks from the widget-frame rAF, which lives
one level up and is not subject to the same throttling. When play() takes
the runtime bridge path (no direct timeline adapter), the player now starts
a parent-frame rAF loop that sends "tick" postMessages to the composition
iframe on every frame. The runtime's control bridge handles "tick" by calling
seekTimelineAndAdapters(clock.now()) if the clock is playing — identical to
what transportTick does on each rAF, just driven from outside.
The composition iframe's own rAF loop is unchanged and keeps running
normally in standard browsers. Seeking GSAP twice per frame is idempotent,
so there is no regression on claude.ai or any other non-throttled environment.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
PR 3 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that runs the shader-transition blend (one of 15 transition shaders) on a fixed-size worker pool. **No production wiring yet** — the pool stands alone; PR 4 wires it.
The shader blend is a hot inner loop over every pixel of every transition frame at 16bpc. Moving it off the main event loop removes the JS-event-loop ceiling that capped throughput in earlier hf#732 iterations.
### New files
- `packages/producer/src/services/shaderTransitionWorker.ts` — worker entry. Imports from `@hyperframes/engine/shader-transitions` (zero-import TS source).
- `packages/producer/src/services/shaderTransitionWorkerPool.ts` — fixed-size pool. Uses `transferList` so the 16bpc HDR `from`/`to`/`out` buffers move by ownership.
- `packages/producer/src/services/shaderTransitionWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence across all 15 shaders, transferList correctness, pool lifecycle. All pass.
### Build wiring
- `packages/cli/tsup.config.ts`: third tsup entry emits `dist/shaderTransitionWorker.js`.
- `packages/producer/build.mjs`: fourth esbuild entry for direct producer consumers.
- `packages/engine/package.json`: adds `./shader-transitions` subpath export.
## Stack
Stacked on top of #757 (PR 2: pngDecodeBlit pool). No behavior change in any render.
## Test plan
- [x] 6 pool tests pass
- [x] Producer + engine typecheck clean
- [x] oxlint clean
— Vai
The /favicon.svg request was falling through to the SPA catch-all, which
returns index.html. The browser received HTML instead of an SVG and
silently discarded it, leaving the tab with no icon.
Added an explicit route for /favicon.svg alongside the existing /assets/*
and /icons/* static routes.
Closes#804
## Summary
PR 2 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that offloads PNG decode + alpha-blit onto a fixed-size pool. **No production wiring yet** — the pool stands alone and ships behind a later PR in the stack.
### New files
- `packages/producer/src/services/pngDecodeBlitWorker.ts` — worker entry. Imports from `@hyperframes/engine/alpha-blit` (zero-import TS source, survives the `new Worker(<path>)` loader boundary).
- `packages/producer/src/services/pngDecodeBlitWorkerPool.ts` — fixed-size pool with `run()` API. Uses `transferList` for buffer ownership transfer (no 16bpc HDR buffer copies).
- `packages/producer/src/services/pngDecodeBlitWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence with inline path, transferList correctness, concurrent dispatch, termination semantics. All pass.
### Build wiring
- `packages/cli/tsup.config.ts`: second tsup entry emits `dist/pngDecodeBlitWorker.js` next to `dist/cli.js`. Without this entry the pool's `new Worker(<path>)` would fail at runtime in the shipped CLI.
- `packages/producer/build.mjs`: third esbuild entry mirrors the wiring for direct producer consumers.
- `packages/engine/package.json`: adds `./alpha-blit` subpath export pointing at `src/utils/alphaBlit.ts`.
## Stack
Stacked on top of #756 (PR 1: worker-count cap). No behavior change in any render.
## Test plan
- [x] 6 pool tests pass
- [x] Producer + engine typecheck clean
- [x] oxlint clean
— Vai
## Summary
PR 1 of 5 in the hf#732 decomposition stack. Bumps `parallelCoordinator`'s worker-count caps so high-core hosts can actually surface their hardware to renders:
- `ABSOLUTE_MAX_WORKERS`: 10 → 24 (explicit `--workers 16` now surfaces 16 DOM sessions instead of being silently clamped).
- `DEFAULT_SAFE_MAX_WORKERS` constant → `defaultSafeMaxWorkers()` function returning `max(6, min(16, floor(cpus/8)))`. On <=32-core hosts: unchanged (still 6). On 64/96/128-core hosts: 8/12/16.
No behavior change for typical hosts. Required prerequisite for the hybrid shader-transition path landed in PR 4.
## Test plan
- [x] Existing 7 `parallelCoordinator` tests pass
- [x] Engine typecheck clean
- [x] oxlint clean
## Stack
This is the base of the hf#732 decomposition stack:
1. **PR 1 (this)** — perf(engine): worker-count cap bump
2. PR 2 — feat(producer): add pngDecodeBlitWorkerPool
3. PR 3 — feat(producer): add shaderTransitionWorkerPool
4. PR 4 — perf(producer): hybrid layered/parallel path (the 2.22× speedup)
5. PR 5 — perf(producer): pipeline capture and shader-blend per-frame
Replaces the closed hf#732. See that issue for the original investigation; the architectural mismatch with #733's `captureHdrStage` extraction made a clean rebase impossible.
— Vai
* fix(studio): restore saved positions on page refresh
studio-manual-edits.json was correctly persisted to disk but never read back
into memory on bootstrap. On every page refresh, studioManualEditManifestRef
started empty, so handleLoad applied an empty manifest and all saved
positions/sizes/rotations were silently discarded.
applyStudioManualEditsToPreview now reads from disk whenever the in-memory
manifest is empty. The existing readRevision guard prevents overwriting an
in-flight optimistic edit if a position change races with the disk read.
* fix(studio): close delete-all race and apply same bootstrap to motion manifest
Two follow-up fixes from review:
1. Replace edits.length === 0 with an explicit manifestBootstrappedRef boolean.
The old condition was true in two distinct states: never-bootstrapped AND
user-deleted-all-edits. Because the delete-all disk write is async-queued,
there was a window where applyStudioManualEditsToPreview could read stale
disk content and resurrect just-deleted positions. The boolean flag is set
on the first apply and reset on project switch, cleanly separating the two
states.
2. applyStudioMotionToPreview had the identical bug: GSAP motion edits were
also lost on page refresh. Applied the same motionBootstrappedRef pattern.
* fix(studio): auto-reconnect when preview server is not running
When the preview server is not reachable (tab reload after server died,
or opening the URL before running npm run dev), the Studio was silently
swallowing the fetch error and rendering an infinite pulsing dot with no
recovery path. Users had no idea what happened.
Instead of showing an error and asking the user to act, the Studio now
polls /api/projects every 2 seconds and automatically transitions into
the full editor the moment the server becomes available — no manual
reload required.
Also fixes how agents are instructed about the dev server: CLAUDE.md and
AGENTS.md listed `npm run dev` as a one-liner comment identical to other
commands, giving no indication it blocks until stopped. Agents (including
Claude Code) were running it in foreground, timing out after ~2 minutes,
and silently killing the server. Added an explicit note that it must be
started as a background process.
* fix(studio): auto-reconnect when preview server is not running
Two issues combined to produce the "reloading the tab kills the whole"
experience for users running with an AI agent:
1. Agents silently killed the server — CLAUDE.md/AGENTS.md listed
npm run dev with no indication it blocks. Agents ran it in foreground,
the Bash tool timed out, and the process died. Added an explicit
run_in_background instruction.
2. Studio had no recovery path — fetch errors were swallowed, leaving
a permanent pulsing dot with no way out. Now the Studio polls every
2s and auto-transitions the moment the server responds.
Also fixes the bookmark-reload case: the hash path previously bailed out
before pinging the server, so a dead server + saved URL produced a blank
editor instead of the waiting state. The server is now always contacted
first, regardless of whether a hash project ID is present.
Timer cleanup (cancelled flag + clearTimeout) prevents setState on
unmounted components under StrictMode dev re-mounts.
Extracted into useServerConnection hook to keep App.tsx under the 500
LOC limit.
## What
Align Studio preview font handling with final render, and harden the transform hook against failures.
## Why
Preview and render use different font handling. This bug changes text width and makes text layout look different between preview and final render.
## How
- Add a `transformPreviewHtml` hook in `StudioApiAdapter` that adapters can implement to post-process preview HTML before Studio augments it
- Use it in both the Vite adapter and the CLI studio server to inject the same deterministic `@font-face` rules that render uses
- Wrap the hook in a try/catch so a failing transform (e.g. network error during Google Fonts fetch) degrades gracefully — the preview still loads with the original HTML
## Edge cases covered
| Path | Covered |
|------|---------|
| Bundled HTML (adapter returns string) | ✓ |
| Bundle returns null → reads index.html from disk | ✓ |
| Bundle throws → catch-block fallback reads index.html | ✓ |
| Sub-composition preview | ✓ |
| Transform hook throws → graceful fallback to original HTML | ✓ |
## Test plan
- [x] Unit tests added for all five paths above
- [x] Manual testing performed
Closes#797
Adds a dedicated concept page documenting how composition variables work end-to-end, from declaration to runtime resolution.
## What's covered
- Declaring variables via `data-composition-variables` on the `<html>` root — full schema with all 5 types (`string`, `number`, `color`, `boolean`, `enum`) and their type-specific options
- Reading resolved values in composition scripts with `__hyperframes.getVariables()`
- Per-instance overrides via `data-variable-values` on host elements (sub-composition embeds)
- CLI overrides via `--variables` / `--variables-file` and `--strict-variables` for strict validation
- Layering/precedence table showing how the three sources merge
- Lint and runtime validation (what undeclared/type-mismatch/enum-out-of-range mean)
- Programmatic access via `extractCompositionMetadata()` for tooling authors
Also adds the page to the Concepts nav group in `docs.json`.
* fix(studio): add smooth preview zoom with pinch/Ctrl+scroll
- Scale iframe content from inside (contentDocument.documentElement) instead
of scaling the parent div, avoiding compositor re-rasterization on every
zoom frame — critical for smooth zoom on high-refresh displays (240Hz)
- Document-level capture-phase wheel handler bypasses the DomEditOverlay
- Center-based zoom (no pan drift from pointer-anchored formulas)
- Transient HUD shows zoom % briefly, no persistent UI controls
- Double-click preview area to reset zoom to fit
- Drag-to-pan when zoomed past 100%
- Momentum scroll suppression after pinch gesture (400ms cooldown)
- Delta clamping (MAX_DELTA=10) prevents overshooting on fast gestures
- toDomPrecision rounds transform values to 4 decimals (matches tldraw)
- Zoom state persisted to localStorage with 200ms debounce
- Exposes --preview-zoom CSS custom property for overlay coordinate mapping
- Fix infinite render loop in NLELayout (onIframeRef → refreshPreviewDocumentVersion)
* fix(studio): use CSS zoom instead of transform scale for preview zoom
CSS transform: scale() on a div containing an iframe causes compositor
cross-layer sync issues that produce visible frame tearing on high-refresh
displays (240Hz ProMotion). CSS zoom property changes the actual rendered
size without compositor layer synchronization, eliminating the jumping.
- Replace transform: scale(Z) with zoom: Z on the stage div
- Keep transform: translate() for panning (compositor-friendly, no iframe)
- Overlays work correctly since getBoundingClientRect() includes zoom
- Remove will-change, transition hacks, pointer-events toggles
* fix(ci): use apt-get for ffmpeg in preview-regression workflow
The FedericoCarboni/setup-ffmpeg action downloads from an external URL
that has been persistently unreachable, causing CI failures. Switch to
apt-get install which uses Ubuntu's package repos (same as ci.yml and
player-perf.yml).
* fix(studio): clear zoom timers on NLEPreview unmount
settleTimerRef, hudTimerRef, and retiringTimerRef could fire after
component unmount. Add cleanup effect to prevent stale callbacks.
* feat(studio): persist sidebar, timeline, and playback speed across reloads
Wire up studioUiPreferences for the three remaining UI states requested
in #752: left sidebar collapsed, timeline visibility, and playback rate.
All three now survive page reloads using the same localStorage key as
preview zoom.
The mountEditor callback had content in its dependency array and was
used as a React ref callback. Every content change (keystroke) gave
mountEditor a new identity, causing React to destroy and recreate
the entire CodeMirror editor — losing cursor position, undo history,
and focus.
Remove content from the dependency array and use a separate useEffect
to push external content updates to the existing editor via
dispatch(). The editor is now only recreated when filePath, language,
or readOnly change.
Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(studio): add Layers panel as new inspector tab
Adds a dedicated Layers tab alongside Design and Renders in the right
panel inspector. The panel shows the full composition element tree with
collapsible hierarchy — clicking a layer selects it without navigating
away from the tree view.
Closes#783
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(studio): add visual element previews to Layers panel
Each layer row now shows a small color/content preview thumbnail:
- Text elements show a snippet of their content in the actual font color
- Container elements show their background color as a colored swatch
- Image elements show a tiny thumbnail of the image
- Media elements show an icon indicator
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(studio): add hover-to-highlight and auto-seek to Layers panel
Replace tiny preview thumbnails with hover highlighting — hovering a
layer row highlights the element in the preview canvas. Clicking a layer
auto-seeks the playhead to that element's start time in the timeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): layers panel tab order, autoseek, and renders toolbar overflow
- Reorder inspector tabs to Design → Layers → Renders
- Fix autoseek: walk DOM ancestors when selected element has no direct
timeline match, so clicking a child like S2 Heading correctly seeks
to the start of its parent scene
- Fix Renders toolbar overflow: add flex-wrap to the export controls
header so selects and the Export button wrap instead of clipping
* fix(studio): seek to midpoint of element duration in layers panel autoseek
* fix(studio): layers panel seek now drives adapter.seek via requestSeek signal
setCurrentTime() only updated the store — adapter.seek() and liveTime.notify()
were never called so the iframe never moved. Add requestedSeekTime to the player
store; useTimelinePlayer subscribes and calls the real seek() path when it fires.
* feat(studio): hover over a layer auto-seeks to element midpoint (300ms debounce)
* feat(studio): add collapsible sections to Design panel
Section component now supports collapse/expand with a chevron toggle.
Text, Layout, and Fill sections stay expanded by default. Less-used
sections (Flex, Radius, Stroke, Effects, Clip, Transparency) start
collapsed to reduce scrolling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): remove stale selectedTimelineElement usages dropped in rebase
* fix(studio): stabilize resetErrors in useConsoleErrorCapture to break render loop
resetErrors was a new function object on every render. handlePreviewIframeRef
had it as a dep, so it also changed every render. NLELayout's useEffect watching
onIframeRef would re-fire, calling setPreviewIframe again, which re-ran
useConsoleErrorCapture with the new iframe — infinite loop.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (banned in distributed mode) and
§9.3 (typed non-retryable failures).
Today `injectDeterministicFontFaces(html)` swallows external font-fetch
failures: a failed Google Fonts CSS request or woff2 download returns
empty arrays, the composition warns via `warnUnresolvedFonts`, and Chrome
falls back to system fonts. That fallback would silently desync chunk
workers in distributed mode (workers run in a Linux container that
doesn't have macOS / Windows system fonts), so distributed renders need
to fail closed.
This change adds an options bag to `injectDeterministicFontFaces`:
injectDeterministicFontFaces(html, {
failClosedFontFetch?: boolean; // default false
fetchImpl?: typeof fetch; // default global fetch
})
When `failClosedFontFetch === true`, any non-OK CSS response, any non-OK
woff2 response, and any network error during either fetch throws a typed
`FontFetchError` with `code === FONT_FETCH_FAILED`. When `false` (the
default), behavior is unchanged.
`fetchImpl` lets unit tests inject failing-fetch stubs without going over
the network.
The in-process caller (`htmlCompiler.ts`) continues to call
`injectDeterministicFontFaces(html)` without options and gets the legacy
behavior. Phase 3's `plan()` will pass `failClosedFontFetch: true`.
Producer regression baselines remain byte-identical: no caller flips the
flag.
10 unit tests at packages/producer/src/services/
deterministicFonts-failClosed.test.ts pin both branches (default
swallows network error / 404; locked throws FontFetchError with correct
code, URL, and family name) plus the "no fetch happens for bundled
fonts" carve-out.
This is part of a stack of 10 PRs; this is PR 10 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).
Extends packages/producer/src/services/render/planValidation.ts with:
- validateNoSystemFonts(compiledHtml) — scans `font-family:` declarations
and `data-font-family=…` attributes. If the PRIMARY family (first
entry in the comma-separated list) resolves to a host-OS / CSS-generic
family, throws PlanValidationError with code SYSTEM_FONT_USED.
- parseFontFamilyValue(value) — pure helper that splits a font-family
declaration value, stripping whitespace + quotes.
Banned primary families: sans-serif, serif, monospace, cursive, fantasy,
system-ui, ui-sans-serif, ui-serif, ui-monospace, emoji, math, fangsong,
-apple-system, BlinkMacSystemFont. Mirrors the GENERIC_FAMILIES list in
deterministicFonts.ts (deliberately a separate copy — they're two
different concerns that happen to overlap today).
Generic families remain acceptable as CSS fallbacks; only the primary
slot is rejected. `font-family: "Inter", -apple-system, sans-serif` is
fine; `font-family: -apple-system, BlinkMacSystemFont` is rejected.
No caller invokes the validator yet. Phase 3's `plan()` will run it on
the compiled HTML before freezing the plan, so chunk workers (Linux
containers without macOS / Windows system fonts) never see compositions
that would render differently between the controller and the workers.
In-process behavior is unchanged.
14 unit tests added to packages/producer/src/services/render/
planValidation.test.ts cover: clean compositions, missing font-family,
each banned primary family, data-font-family= surface, case-insensitive
matching, fallback acceptance, and parser edge cases.
This is part of a stack of 10 PRs; this is PR 9 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).
Adds packages/producer/src/services/render/planValidation.ts:
- PlanValidationError — typed plan-time error carrying a `code` field
matching plan §9.3, so Phase 3 adapter retry policies (Temporal /
Step Functions) can mark these as non-retryable.
- validateNoGpuEncode(config) — throws with code BROWSER_GPU_NOT_SOFTWARE
when:
* config.useGpu === true — distributed retries must be byte-
identical, but NVENC/QSV/VAAPI produce different output across
machines.
* config.browserGpuMode !== "software" — hardware GL is bitwise
unstable across drivers; pairs with the runtime
assertSwiftShader check from PR 2.2.
The BROWSER_GPU_NOT_SOFTWARE constant is re-exported from
@hyperframes/engine (where PR 2.2 declared it) and re-exported again from
this module, so the Phase 3 distributed adapter can match the typed code
without a cross-package import.
No caller invokes the validator yet. Phase 3's `plan()` will run it
before freezing the plan, so banned configs fail fast with a typed
non-retryable error instead of leaking into a planDir.
In-process behavior is unchanged — the in-process renderer continues to
accept useGpu=true and browserGpuMode="auto".
9 unit tests at packages/producer/src/services/render/
planValidation.test.ts pin both gates and the precedence (useGpu checked
before browserGpuMode).
This is part of a stack of 10 PRs; this is PR 8 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §17.2 (PR 2.7 row).
Distributed renders mix audio once at `plan()` time against the
composition's declared duration; the actual assembled video duration is
`Σ(chunkFrames) / fps`. Even with closed-GOP concat-copy the absolute
result is deterministic, but downstream muxers (especially ffmpeg's
`-shortest` plus Apple's mov demuxer) are sensitive to ±1ms audio/video
drift and produce silent "audio cuts off early" or "video freezes on the
last frame" bugs.
Adds packages/producer/src/services/render/audioPadTrim.ts:
- buildPadTrimAudioArgs(audio, out, sourceSec, targetSec) — pure helper
that decides the operation (pad/trim/copy) and emits the matching
ffmpeg argv. Uses `apad=pad_dur=Δ` (re-encode to AAC because filters
can't combine with `-c:a copy`), `-t target -c:a copy` (trim is a
lossless AAC packet boundary snap), or a plain `-c:a copy` when the
delta is below ~1ms.
- padOrTrimAudioToVideoFrameCount(input) — probes the assembled video
for exact frame count (`-count_packets` + `nb_read_packets`, which
equals frame count when chunks were encoded with `-bf 0` as Phase 2's
PR 2.1 already enforces), probes the audio for current duration,
computes target = `frameCount * fpsDen / fpsNum`, runs ffmpeg with the
args from the pure helper. Probes and ffmpeg runner are injectable so
unit tests don't shell out.
Six-decimal-place seconds formatting avoids ffmpeg's inconsistent handling
of scientific notation in time args across versions.
No caller invokes either function yet — Phase 3's `assemble()` will run
this after the chunk concat-copy step, before muxing audio onto the final
mp4/mov output.
15 unit tests at packages/producer/src/services/render/
audioPadTrim.test.ts pin both layers: the pure arg builder for all three
operations (incl. NTSC fps), and the wrapper for normal flow, probe
failures, invalid video info, and ffmpeg failures.
In-process behavior is unchanged. The producer's existing
`muxVideoWithAudio` path in chunkEncoder is untouched.
This is part of a stack of 10 PRs; this is PR 7 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (lastFrameCache row) and §17.2
(gating table).
Adds `discardWarmupCapture(session, frameIndex=0, time=0, innerCapture?)`
in packages/engine/src/services/frameCapture.ts. Performs one capture
through the standard `captureFrameCore` path, throws the buffer away, and
restores the session's perf and BeginFrame damage counters.
Distributed chunk workers need this because Chrome's BeginFrame screenshot
pipeline maintains a per-process `lastFrameCache`: when a captured frame's
`hasDamage` reports `false`, the screenshot path returns the previously
captured buffer. For chunk N (N > 0) the worker has no prior frame in its
cache, so the very first capture's `hasDamage` reporting diverges from
what an in-process render at the same absolute frame index would see (the
in-process renderer always has frame N-1 cached). Running a discarded
warmup capture before the first real capture primes the cache so chunk
output is byte-identical to in-process output.
The wrapper:
- Takes an injectable `innerCapture` so tests can stub the Chrome path
(default is the real `captureFrameCore`).
- Restores `session.capturePerf`, `beginFrameHasDamageCount`, and
`beginFrameNoDamageCount` after the inner call — even on error — so
warmup captures don't pollute `getCapturePerfSummary()` averages.
- Writes no file to disk.
In-process behavior is unchanged: no caller invokes the new helper yet.
Phase 3's `renderChunk()` will run it as the first step after
`initializeSession` resolves.
Re-exported from packages/engine/src/index.ts.
7 unit tests at packages/engine/src/services/
frameCapture-discardWarmup.test.ts cover the post-conditional contract:
single inner-capture invocation, perf/damage restoration on success,
restoration on error, no-fs-write.
This is part of a stack of 10 PRs; this is PR 6 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §4.3 (LockedRenderConfig.runtimeEnv) and
§5.2 (RENDER_SEEK_MODE row).
`fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
env vars at module-load time (RENDER_SEEK_MODE, RENDER_SEEK_STEP,
RENDER_SEEK_OFFSET_FRACTION, …) and bakes them into the served HTML's
RENDER_MODE_SCRIPT. Distributed chunk workers are separate processes that
may inherit a different environment, so the plan needs to freeze a
snapshot.
Adds `snapshotRuntimeEnv(env = process.env)` in
packages/producer/src/services/render/stages/freezePlan.ts. Captures keys
matching `PRODUCER_RUNTIME_` or `PRODUCER_RENDER_` prefixes into a fresh
plain object, ignoring everything else. Phase 3's `renderChunk` will
materialize the snapshot back into `process.env` before launching its
file server.
Also exports `RUNTIME_ENV_SNAPSHOT_PREFIXES` so the chunk-worker side can
apply the same prefix filter (asymmetric handling would leak stale
controller env into worker behavior).
The freezePlan function body remains a skeleton — Phase 3 owns the full
implementation. The snapshot helper is exported on its own so this gate's
unit test can pin the behavior without depending on the not-yet-written
freezePlan body.
In-process behavior is unchanged: no in-process caller invokes
freezePlan or snapshotRuntimeEnv yet.
9 unit tests at packages/producer/src/services/render/stages/
freezePlan.test.ts cover: prefix matches (both families), non-matching
keys ignored, undefined values skipped, fresh-object contract, and
default-to-process.env behavior.
This is part of a stack of 10 PRs; this is PR 5 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (Math.random row) and §17.2
(gating table).
The existing `VIRTUAL_TIME_SHIM` freezes Date.now / performance.now / rAF
on a render seek but leaves `Math.random` and `crypto.getRandomValues` as
native non-deterministic. Compositions that paint stochastic visuals
through these APIs produce different pixels on distributed retries.
This change adds `buildVirtualTimeShim({ seedRandomFromFrame: boolean })`.
Default `false` returns a string byte-identical to today's
`VIRTUAL_TIME_SHIM` (pinned by a new unit test). When `true`, the script
additionally:
- Installs a Mulberry32 PRNG with a single uint32 state
- Reseeds the state from the current virtual time on every
`seekToTime(ms)` call (Knuth multiplicative hash + golden-ratio offset)
- Replaces `Math.random` with the PRNG output
- Replaces `crypto.getRandomValues` to fill the buffer from the PRNG
`VIRTUAL_TIME_SHIM` (the const consumed by `renderOrchestrator` +
`probeStage`) is now `buildVirtualTimeShim({ seedRandomFromFrame: false })`
— in-process behavior unchanged, producer regression baselines unaffected.
Phase 3 distributed primitives will pass `true` when building the chunk
worker's file-server scripts.
10 new unit tests at packages/producer/src/services/
fileServer-seededRandom.test.ts use node:vm to evaluate the shim in
isolated contexts and pin both branches:
- default emits no RNG override and leaves Math.random native
- locked emits the seeded block, produces identical sequences across
fresh VMs at the same time, and yields different sequences for
different times
This is part of a stack of 10 PRs; this is PR 4 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (warmupTicks row) and §17.2 (gating
table).
The BeginFrame warmup loop in `initializeSession` is driven by wall-clock
during page load — different hosts accumulate different tick counts before
page-readiness completes. That shifts `session.beginFrameTimeTicks` and
yields non-byte-identical captures on distributed workers.
This change adds `lockWarmupTicks: boolean` (default false) to
`CaptureOptions`. When false, behavior is unchanged. When true, the loop
runs exactly `LOCKED_WARMUP_TICKS = 60` iterations regardless of page-load
wall clock, and `session.beginFrameTimeTicks` is computed from the
constant — pinning the baseline across hosts.
Refactoring:
- Extract `driveWarmupTicks(options, state)` as a pure helper. Tests
drive it with a stub `tick` callback and an injected `sleep`, so the
iteration-count contract is unit-testable without real Chrome.
- `initializeSession`'s warmup body is now a thin adapter that calls
`driveWarmupTicks` with a CDP-backed tick.
Producer regression baselines remain byte-identical: the in-process
renderer never passes `lockWarmupTicks: true`. Phase 3 distributed
primitives will flip it true when launching chunk workers.
11 new unit tests at packages/engine/src/services/
frameCapture-warmupTicks.test.ts pin both branches (unlocked drifts with
simulated load time; locked produces identical counts).
This is part of a stack of 10 PRs; this is PR 3 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (browserGpuMode row) and §9.3
(BROWSER_GPU_NOT_SOFTWARE typed failure).
Adds packages/engine/src/utils/assertSwiftShader.ts:
- assertSwiftShader(page, readInfo?) — navigates to chrome://gpu, reads
the GL_VENDOR / GL_RENDERER rows from browserBridge.gpuInfo_, throws
SwiftShaderAssertionError ({ code: "BROWSER_GPU_NOT_SOFTWARE" }) if
the active backend isn't SwiftShader.
- readWebGlVendorInfo(page) — extracted helper so tests can stub the
info read without spinning up real Chrome.
- SwiftShaderAssertionError + BROWSER_GPU_NOT_SOFTWARE constant exposed
so the Phase 3 distributed adapter can match typed non-retryable
failures.
Re-exported from packages/engine/src/index.ts. No caller invokes it yet;
Phase 3 renderChunk() will run it post-launch.
In-process behavior is unchanged — assertSwiftShader is a new pure utility.
Producer regression baselines remain byte-identical.
This is part of a stack of 10 PRs; this is PR 2 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §7.1 and §17.2 (gating table).
Adds two optional fields to EncoderOptions:
lockGopForChunkConcat?: boolean // default false
gopSize?: number // required when lockGopForChunkConcat=true
When the flag is true on the SW libx264 / libx265 paths, buildEncoderArgs
emits closed-GOP / forced-keyframe args so the resulting chunk file can be
losslessly concatenated (`ffmpeg -f concat -c copy`) with sibling chunks:
-g <gopSize>
-keyint_min <gopSize>
-sc_threshold 0
-force_key_frames "expr:eq(mod(n,<gopSize>),0)"
-x264-params "...:scenecut=0:open-gop=0:repeat-headers=1"
-x265-params "keyint=<gopSize>:min-keyint=<gopSize>:scenecut=0:open-gop=0:repeat-headers=1"
-bf 0 (added for h265 too when locked)
GPU encoders, vp9, and prores ignore the flag (their concat-copy story is
separate — see plan §7.2 / §8).
In-process behavior is unchanged: the default (false) path emits no new
args. New unit tests pin both branches in packages/engine/src/services/
chunkEncoder.test.ts.
This is part of a stack of 10 PRs; this is PR 1 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #755 added the typegpu-adapter regression test scaffolding (meta.json,
src/index.html, output/compiled.html) but left the output.mp4 golden
baseline ungenerated:
> Note: output.mp4 baseline needs to be generated in CI — the local …
Every \`regression-shards (fast)\` run since #755 merged has failed with
\`Snapshot not found: /app/packages/producer/tests/typegpu-adapter/output/output.mp4.
Run with --update to create it.\`
Generated via the canonical Docker path per CLAUDE.md:
bun run --cwd packages/producer docker:test \\
--update --suite typegpu-adapter
Stored via Git LFS (already configured for
\`packages/producer/tests/*/output/output.mp4\` in \`.gitattributes\`).
The Windows install failures (`ENOENT: failed copying files from cache to
destination for package @types/node` / `esbuild`) are caused by bun creating
workspace-scoped nested installs under
`node_modules/@hyperframes/<pkg>/node_modules/...`. Those nested paths only
exist because each workspace package pinned a different `@types/node` /
`esbuild` major:
- root: `@types/node ^25.0.10`, core: `^24.10.13`, cli/engine/producer: `^22`
- core/cli: `esbuild ^0.25.x`, producer: `^0.27.2`
Each major-version gap forces bun to install a workspace-scoped copy in a
deep `node_modules/@hyperframes/<pkg>/node_modules/<dep>/node_modules/...`
tree that bun can't reliably materialize on Windows GHA runners. Aligning
versions lets bun dedup to a single root-hoisted install per dep, and the
nested workspace block disappears from `bun.lock` entirely.
## Alignment
- `@types/node` → `^25.0.10` across root, core, cli, engine, producer
- `esbuild` → `^0.25.12` across cli, core, producer
- `tsx` → `^4.21.0` across producer (matches root + core)
## Source-level v25 compat (already in this PR)
@types/node v25 declares `File` as an interface (not a class) and exposes a
conditional global where `FormData.entries()` narrows to `[string, string]`
when an `onmessage` global is in scope. `packages/core/src/studio-api/routes/files.ts`'s
`value instanceof File` check was relying on the v24 class declaration —
already cast the iterator to `Iterable<[string, FileLike | string]>` in the
prior commit.
Two more v25 source fixes here:
- `packages/cli/src/commands/init.ts`
- `packages/cli/src/whisper/normalize.ts`
`Dirent.path` was removed in @types/node v25 (deprecated alias for
`parentPath` since Node 20.12). Drop the `?? e.path` fallback.
## Verification
Both install layouts now build clean end-to-end:
- `bun install` (isolated, default): full build green, 853 core tests pass,
typecheck green across all 7 packages
- `bun install --linker=hoisted` (Windows CI): same result
- `bun.lock` no longer contains any `@hyperframes/<pkg>/<dep>` nested
workspace entries — 70+ lines of nested install blocks gone
Pushing further to actually get Windows render verification green, not just
work around it.
## What's wrong on Windows
Bun 1.3's default `isolated` linker creates nested workspace junctions under
`packages/*/node_modules/` on Windows GHA runners. Those junctions don't
materialize reliably — Node's `realpathSync` returns `EPERM` on stat, and
ESM resolution returns `ERR_MODULE_NOT_FOUND`. Every Windows build since
PR #748 has tripped this in one of three places:
- `packages/producer/build.mjs` importing `esbuild`
- `packages/producer/scripts/generate-font-data.ts` reading `@fontsource/*`
- `packages/producer` running `tsc` to emit `.d.ts`s
Long-running bun bugs: oven-sh/bun#23615, #18354, #10146.
## Fix
**1. `--linker=hoisted` for the Windows install step** (workflow change,
Windows only). Hoisted layout puts deps as real directories at the workspace
root + workspace package node_modules. No junctions, no Windows-specific
path quirks. Linux CI keeps the default isolated linker; the lockfile is
linker-agnostic so `--frozen-lockfile` is still valid.
**2. Source-level FormData narrowing in `packages/core/src/studio-api/routes/files.ts`**
(needed because the hoisted layout exposes a `@types/node@25` typecheck
issue that the isolated layout hides). With v25 + an `onmessage` global in
scope, the ambient `FormData.entries()` infers `[string, string]` instead of
`[string, File | string]`, so the `value instanceof File` check breaks at
`TS2358`. Cast the iterator to a `[string, FileLike | string]` shape and
narrow via `typeof value === "string"`. Identical runtime behavior; works
under both v24 (isolated layout, what Linux CI sees) and v25 (hoisted, what
Windows CI sees with this change).
## Verification
- `bun install --frozen-lockfile` (isolated, default): full build green
- `bun install --frozen-lockfile --linker=hoisted`: full build green, core
typecheck passes, `@hyperframes/core` 853 tests pass
- Format/lint clean on both layouts