Commit Graph
887 Commits
Author SHA1 Message Date
Miguel Ángel a99db41a32 docs: add TanStack, OptinMonster, and HeyGen as adopters (#817) 2026-05-14 02:12:25 +02:00
terencecho 27ae55a5c7 chore: release v0.6.6 (#818) v0.6.6 2026-05-13 17:01:15 -07:00
Miguel Ángel 1caeb28658 feat(studio): header logo, playbar cleanup, and I/O work-area markers (#811)
* 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
2026-05-14 01:06:55 +02:00
terencechoandClaude Sonnet 4.6 c08e8b2322 fix(player): drive composition ticks from widget-frame rAF via postMessage (#805)
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>
2026-05-13 15:29:48 -07:00
Vance Ingalls 30348af3f4 feat(producer): add shaderTransitionWorkerPool (hf#732 PR 3/5) (#758)
## 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
2026-05-13 15:17:58 -07:00
Miguel Ángel a52c73ebe1 fix(studio): serve favicon.svg from embedded preview server (#806)
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
2026-05-13 23:53:33 +02:00
Vance Ingalls 92bccfdf78 feat(producer): add pngDecodeBlitWorkerPool (hf#732 PR 2/5) (#757)
## 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
2026-05-13 14:52:38 -07:00
James Russo 3c58c23b16 Merge pull request #775 from heygen-com/feat/producer-fail-closed-font-fetch
feat(producer): fail-closed font fetch flag in deterministicFonts
2026-05-13 17:38:15 -04:00
James Russo 6fd402d6ba Merge pull request #774 from heygen-com/feat/producer-plan-validate-no-system-fonts
feat(producer): plan-time validator — reject system fonts
2026-05-13 17:32:14 -04:00
Vance Ingalls 57b6858323 perf(engine): bump worker count cap for high-core hosts (hf#732 PR 1/5) (#756)
## 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
2026-05-13 14:04:54 -07:00
Miguel Ángel 7703122a4d chore: release v0.6.5 v0.6.5 2026-05-13 13:40:37 -07:00
Miguel Ángel 38d02cd62a chore: ignore .worktrees directory 2026-05-13 13:40:16 -07:00
Miguel Ángel c3f70c91db fix(studio): restore saved positions on page refresh (#801)
* 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.
2026-05-13 22:39:30 +02:00
Miguel Ángel 246a1911b1 fix(studio): auto-reconnect when preview server is not running (#802)
* 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.
2026-05-13 22:39:14 +02:00
James Russo 0fcf9b62cc Merge pull request #773 from heygen-com/feat/producer-plan-validate-no-gpu
feat(producer): plan-time validator — reject GPU encode
2026-05-13 16:37:47 -04:00
Miguel Ángel 0d7d38849c fix(studio): align preview fonts with render (#799)
## 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
2026-05-13 21:56:44 +02:00
James Russo 21097f47e2 Merge pull request #772 from heygen-com/feat/producer-audio-pad-trim
feat(producer): audio post-pad/trim helper for assemble
2026-05-13 15:34:18 -04:00
Miguel Ángel 15389c21f5 docs: add variables concept page (#798)
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`.
2026-05-13 21:14:10 +02:00
James Russo 3408c3c3b3 Merge pull request #771 from heygen-com/feat/engine-discard-warmup-capture
feat(engine): first-frame warmup capture helper for distributed chunks
2026-05-13 15:08:33 -04:00
James Russo aa3e3f7c06 Merge pull request #770 from heygen-com/feat/producer-freeze-plan-runtime-env
feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars
2026-05-13 14:42:07 -04:00
James Russo d05382a11c Merge pull request #769 from heygen-com/feat/producer-seeded-random-shim
feat(producer): seedable Math.random / crypto.getRandomValues shim, gated
2026-05-13 14:11:54 -04:00
James Russo 836f804d20 Merge pull request #768 from heygen-com/feat/engine-lock-warmup-ticks
refactor(engine): clamp warmupTicks to fixed iteration count, gated
2026-05-13 13:55:22 -04:00
James Russo b86893ae33 Merge pull request #767 from heygen-com/feat/engine-assert-swiftshader
feat(engine): assertSwiftShader chrome://gpu validator
2026-05-13 13:41:07 -04:00
Miguel Ángel 9fe356be14 chore: bump version to 0.6.4 v0.6.4 2026-05-13 00:49:09 -07:00
Phuong Le 2bea226ea5 fix(studio): only seek layers on selection (#790) 2026-05-13 09:48:21 +02:00
Miguel Ángel 636db23197 chore: bump version to 0.6.3 v0.6.3 2026-05-13 00:46:36 -07:00
Miguel Ángel 2e6022972b fix(studio): add preview zoom controls (#761)
* 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.
2026-05-13 09:29:01 +02:00
Phuong Le 10fb968583 fix(studio): show inspector selection bounds after click (#789) 2026-05-13 09:08:59 +02:00
James Russo 9058ee39c8 Merge pull request #766 from heygen-com/feat/engine-lockgop-for-chunk-concat
feat(engine): add lockGopForChunkConcat option to buildEncoderArgs
2026-05-13 02:54:29 -04:00
236eabe248 fix: prevent SourceEditor recreation on every content change (#624)
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>
2026-05-13 08:25:42 +02:00
Miguel ÁngelandClaude Opus 4.6 6fe970651d feat(studio): add Layers panel as new inspector tab (#784)
* 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>
2026-05-13 08:22:26 +02:00
Phuong Le 51f968ec87 fix(studio): keep preview selection overlay visible for selected elements (#777) 2026-05-13 07:25:40 +02:00
JamesandClaude Opus 4.7 bd5c489eec feat(producer): fail-closed font fetch flag in deterministicFonts
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>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 a9f574d9c0 feat(producer): plan-time validator — reject system fonts
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>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 146ff0f3da feat(producer): plan-time validator — reject GPU encode
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>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 62317f7f3a feat(producer): audio post-pad/trim helper for assemble
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>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 c139197b37 feat(engine): first-frame warmup capture helper for distributed chunks
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>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 1d189aa26b feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars
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>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 8bac7ba0d1 feat(producer): seedable Math.random / crypto.getRandomValues shim, gated
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>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 d54ad9ca19 refactor(engine): clamp warmupTicks to fixed iteration count, gated
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>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 d8486a7c4d feat(engine): assertSwiftShader chrome://gpu validator
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>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 2d6372ac2a feat(engine): add lockGopForChunkConcat option to buildEncoderArgs
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>
2026-05-13 04:36:11 +00:00
James Russo c2648bc863 Merge pull request #778 from heygen-com/fix/ci-format-and-windows-esbuild
fix(ci): unbreak format check on main + skip cli build:fonts when present
2026-05-13 00:34:31 -04:00
James de946492e6 test(producer): add missing typegpu-adapter golden baseline
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\`).
2026-05-13 04:05:18 +00:00
James 067bc722e3 fix(deps): align @types/node + esbuild + tsx across workspace
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
2026-05-13 03:35:47 +00:00
James 8348e19fd9 fix(ci): switch Windows install to hoisted linker; narrow FormData iter
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
2026-05-13 03:27:58 +00:00
James c8a3e5fc4d fix(ci): unbreak format check on main + skip cli build:fonts when present
Two narrow fixes pulled out of a larger Windows-CI investigation:

## 1. Format check (`oxfmt`)

`packages/core/package.json` and `packages/shader-transitions/package.json`
had their `publishConfig` keys reordered to a non-canonical order by the
v0.6.1 release commit (`82fd2967`). Releases push directly to main without
going through PR CI, so the drift wasn't caught and `bun run format:check`
has been failing on every push since. Fix: re-run `oxfmt`.

## 2. `@hyperframes/cli` `build:fonts` skip-when-present

`packages/cli`'s `build:fonts` script regenerated
`packages/producer/src/services/fontData.generated.ts` unconditionally on
every cli build. The script reads `@fontsource/*` packages via
`require.resolve(...)`, which walks `packages/producer/node_modules/@fontsource/*`
junctions — these trip `EPERM: operation not permitted, stat` on Windows
GHA runners because of long-running bun-on-Windows workspace junction bugs
(see oven-sh/bun#23615, #18354, #10146).

`fontData.generated.ts` is committed to git, so the regeneration is only
needed when fonts actually change. Match the skip-when-present pattern
already in `@hyperframes/producer`'s own `build:fonts`. Doesn't fix the
Windows render verification end-to-end (the producer build itself still
trips junction issues — being tackled separately in #765), but at least
stops `cli build:fonts` from being its own failure point on Windows.
2026-05-13 03:06:39 +00:00
Miguel ÁngelandClaude Opus 4.6 86c5fd28e8 chore: release v0.6.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
v0.6.2
2026-05-13 01:35:21 +00:00
Miguel Ángel ac671bdf5c feat(core): add TypeGPU/WebGPU runtime adapter (#755)
* feat(core): add TypeGPU/WebGPU runtime adapter

Adds a deterministic seek adapter for compositions that render with
TypeGPU or raw WebGPU. Follows the same push+poll pattern as the
Three.js adapter:

- Sets `window.__hfTypegpuTime` on every seek so render loops can
  poll it instead of `performance.now()`.
- Dispatches a `"hf-seek"` CustomEvent on `window` so compositions
  can imperatively re-render a single frame at the new seek position.

Compositions listen for the event and update their time uniform:

```js
window.addEventListener("hf-seek", (e) => render(e.detail.time));
```

Works with TypeGPU (docs.swmansion.com/TypeGPU) and raw WebGPU alike.
No assumptions are made about pipeline construction — multiple canvases
or renderers are supported by sharing the same event.

- 9 unit tests, all pass
- wired in init.ts adapter array
- `__hfTypegpuTime` declared in window.d.ts

* fix(core): deduplicate hf-seek dispatch across GPU adapters

Both three and typegpu adapters previously dispatched the same
"hf-seek" CustomEvent independently, causing any composition that
registered a listener to receive two events per seek tick — doubling
per-scrub GPU work even though the renders are idempotent.

Fix: extract a shared `dispatchSeekEvent` helper (seek-dispatch.ts)
that deduplicates by exact float equality within the same synchronous
call stack. Both adapters now call this helper instead of dispatching
directly.

Also adds:
- `resetSeekDispatchState()` export for test isolation
- `beforeEach` reset in three.test.ts and typegpu.test.ts
- New typegpu test: "duplicate seek to same time fires event only once"
- Docstring additions to typegpu.ts: render-mode determinism contract
  (await device.queue.onSubmittedWorkDone()) and navigator.gpu feature
  detection guidance for composition authors

* feat(core): video-texture render compat + TypeGPU skill

Adds the missing pieces for video-backed WebGPU effects in render mode:

- `video-texture-compat.ts`: monkey-patches `GPUQueue.copyExternalImageToTexture`
  to detect the engine's injected `<img class="__render_frame__">` siblings and
  transparently substitute them for `<video>` sources. Headless Chrome can't
  supply decoded video frames to WebGPU, but the engine's pre-extracted frame
  images work. Falls through to the original path in preview mode.

- `patchVideoTextureCompat()` wired in init.ts after adapter array creation.

- `skills/typegpu/SKILL.md`: full authoring guide for TypeGPU/WebGPU compositions
  covering contract, timeline registration, video-backed effects, frosted blur
  via downsample pass, WGSL patterns, and deterministic rendering.

* test(producer): add typegpu-adapter regression test

Self-contained WebGPU composition with:
- Procedural gradient background (no video dependency)
- Animated ring driven by hf-seek time uniform
- Pulsing center glow
- Two GSAP-driven captions testing adapter sync

Verifies the TypeGPU adapter's hf-seek → WebGPU render pipeline
produces deterministic frames. workers: 1 for consistency.

Note: output.mp4 baseline needs to be generated in CI — the local
Docker image can't launch Chrome (ARM/x86 mismatch on Mac).
2026-05-13 03:31:56 +02:00
Miguel ÁngelandClaude Opus 4.6 82fd2967c4 chore: release v0.6.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
v0.6.1
2026-05-13 00:27:13 +00:00