Commit Graph
613 Commits
Author SHA1 Message Date
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 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>
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>
2026-05-13 00:27:13 +00:00
Miguel Ángel 91bdffffe6 fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each)

* fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files

* feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson

Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux:
- Detects the platform automatically
- Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM)
- Falls back to clear manual instructions with exact commands
- 'hyperframes browser ensure' guides through the setup interactively
- After setup, all render commands work without any flags

* fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds

Path exclusions are insufficient — Defender re-scans new files created
during bun install before the exclusion takes effect. Disable real-time
monitoring for the entire job duration instead (standard CI practice).

* refactor(studio): split all files >500 LOC + extract useToast, delete allowlist

All 11 large files split into focused modules under 500 LOC.
App.tsx extracted toast logic into useToast hook (493 LOC now).
.filesize-allowlist deleted — no longer needed.

* fix: remove unused imports from split files, extract useToast from App.tsx

App.tsx: 504 → 493 lines (toast logic extracted to useToast hook)
timelineDOM.ts: remove unused imports from re-export pattern
MotionPanel.tsx: remove unused clampStudioCustomEasePoints import
studioMotionOps.ts: remove unused StudioGsapMotionDirection import

* fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs)

* fix(producer): use node --experimental-strip-types instead of tsx for build:fonts

Eliminates the tsx binary dependency that Windows Defender locks during
bun install, causing EPERM errors. Node 22.6+ strips TypeScript types
natively with no external binary.

* chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500)

* fix(ci): disable Windows Defender before checkout to prevent all EPERM races

* fix(producer): skip build:fonts if fontData.generated.ts already exists

The generated file is tracked in git, so CI doesn't need to regenerate
it. This avoids @fontsource/inter node_modules access on Windows which
triggers EPERM from Defender scanning during bun install.
2026-05-13 01:48:12 +02:00
James Russo 03475d54c6 Merge pull request #753 from heygen-com/refactor/producer-stages-1.5.2-thin-sequencer
refactor(producer): finish thinning executeRenderJob
2026-05-12 19:27:29 -04:00
James Russo de02160338 Merge pull request #751 from heygen-com/refactor/producer-stages-1.5.1-force-screenshot-snapshot
refactor(producer): snapshot cfg.forceScreenshot at compile time, stop mutating mid-pipeline
2026-05-12 19:22:22 -04:00
James abc102e3d6 refactor(producer): finish thinning executeRenderJob
Move file-level helpers and inline blocks out of renderOrchestrator.ts
into focused render/* modules. executeRenderJob shrinks from ~897 to
~675 lines; renderOrchestrator.ts from 2725 to ~2104.

New under packages/producer/src/services/render/:
- hdrPerf.ts: HdrPerfCollector + helpers
- captureCost.ts: capture-cost + calibration helpers, plus a new
  runCaptureCalibration helper that owns the BeginFrame->screenshot
  fallback
- hdrMode.ts: resolveEffectiveHdrMode
- perfSummary.ts: buildRenderPerfSummary
- cleanup.ts: safeCleanup, cleanupRenderResources,
  buildRenderErrorDetails

shared.ts adds createCompiledFrameSrcResolver,
materializeExtractedFramesForCompiledDir, createMemorySampler.

Moved symbols are re-exported from renderOrchestrator.ts for
backwards compatibility; tests update to import from the new paths.

No behavior change: producer smoke set is PSNR-identical to main
inside Dockerfile.test.

lefthook.yml: belt-and-suspenders fix so the filesize hook actually
skips .test.ts / .generated.ts files. The hook-level exclude regex
does not filter the staged_files expansion inside the shell loop,
so the loop now does its own check.
2026-05-12 22:52:26 +00:00
James e221cb8d5c refactor(producer): snapshot cfg.forceScreenshot at compile time, stop mutating mid-pipeline
Resolve the compileStage TODO from PR #720. cfg.forceScreenshot is now
computed exactly once inside compileStage (after applyRenderModeHints)
and returned on CompileStageResult.forceScreenshot. The sequencer stores
it on a local captureForceScreenshot; downstream capture stages take
the value as an explicit parameter and derive their own engine config
rather than reading cfg.forceScreenshot.

Mid-pipeline mutations removed:
- renderOrchestrator.ts: the pre-compile alpha-output mutation moved
  into compileStage so the resolution is one operation in one place.
- captureHdrStage.ts: stopped mutating caller-owned cfg; the layered
  composite path now uses a local hdrCfg derived from cfg plus
  forceScreenshot=true. The stage throws if called with
  forceScreenshot=false to make the contract explicit.
- BeginFrame auto-worker calibration fallback: still flips capture mode
  on a timeout, but flips the local boolean instead of cfg. The
  screenshot-mode retry uses a derived cfg view.

captureStage / captureStreamingStage add a forceScreenshot input and
derive captureCfg (identity-equal to cfg when the values already
agree, so no extra allocation on the common path).

lefthook.yml: grandfather renderOrchestrator.ts and captureHdrStage.ts
in the new 500-line filesize hook (#748). Both pre-date the hook and
are actively being shrunk in the producer stages stack.

Unblocks Phase 3 chunked rendering: LockedRenderConfig.forceScreenshot
in the distributed plan is computed here and survives across processes
without depending on shared mutable state.
2026-05-12 22:19:49 +00:00
Vance IngallsandClaude Opus 4.6 59e1d0787f fix(studio): prevent composition switch loop on sub-composition navigation (#754)
Circular state update between activeCompPath and compositionStack caused
the preview to flicker when navigating to sub-compositions and scrubbing.
The cycle: activeCompPath change → useEffect updates compositionStack →
onCompositionChange fires → setActiveCompPath + refreshPreviewDocumentVersion
→ re-render → effect re-evaluates → repeat. Fixed by guarding both sides:
onCompositionChange skips if path unchanged, updateCompositionStack skips
notification if top-of-stack ID unchanged.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 23:51:50 +02:00
Miguel Ángel e25a3b3a4b chore: release v0.6.0 2026-05-12 11:28:30 -07:00
38efe168e2 refactor(studio): contexts, PropertyPanel split, duration fix, perf (#748)
* feat(studio): add manual DOM editing inspector (#466)

* fix: stabilize studio preview and runtime sync

* fix: pass selector through timeline thumbnails

* feat: add studio timeline editing

* fix: disambiguate timeline edit targets

* fix: stop timeline auto-scroll in fit mode

* feat: use percentage-based timeline zoom

* fix: sync timeline playhead on zoom changes

* fix: reset timeline scroll when returning to fit

* feat(studio): add manual DOM editing inspector

* docs: update studio manual dom editing guide

* feat(studio): add image asset picker for fills

* feat(studio): add inline image uploads for fills

* fix(studio): use real file input for image fill uploads

* fix(studio): restore toast plumbing after rebase

* fix(studio): explain in-app upload limitation

* fix(studio): reuse asset-tab upload pattern in fills

* feat(studio): refine manual design inspector

* fix(studio): polish manual design inspector

* fix(studio): keep color picker in viewport

* fix(studio): clarify color picker selection

* docs: update manual DOM editing guide

* fix(studio): keep gradient color picker open

* fix(studio): scope text color to text layers

* fix(studio): add agent fallback for immovable layers

* fix(studio): address manual editing review feedback

* fix(studio): make local font selection reliable

* fix(studio): improve dom picking and thumbnails

* fix(studio): copy absolute paths in agent prompts

* fix(studio): prevent timeline track cutoff

* fix: copy Studio agent prompts in Safari

* fix(studio): hold canvas movement from inspector

* feat(studio): add persistent undo redo (#537)

Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass

- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.

- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.

* fix: align Studio capture with preview (#595)

Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404.

While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview.

- Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction.
- Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode.
- Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages.
- Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds.
- Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing.

Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched.

The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time.

The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`.

- `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts`
- `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts`
- `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bun run --cwd packages/studio typecheck`
- `bun run --cwd packages/core build:hyperframes-runtime`
- `bun run --cwd packages/core typecheck`
- `git diff --check`

Pre-commit also reran lint, format, and typecheck successfully for the committed files.

Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened:

```text
http://127.0.0.1:5197/#project/Notion%20Showcase
```

Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`.

After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared.

Mean pixel diffs for preview vs capture were:

- `0s`: `0.0`
- `2s`: `0.8641`
- `10s`: `0.3496`
- `18s`: `0.2309`

The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions.

- Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed.
- The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed.
- Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused.

* feat: persist studio manual edits via manifest

* fix(studio): stabilize manual edit manifest rendering

* fix(studio): allow master canvas layer selection

* fix(studio): scale master edits in source coordinates

* fix(studio): reapply manual edits during playback

* fix(studio): keep rotation edit base stable

* feat(studio): highlight hovered canvas target

* fix(studio): drag hovered canvas targets immediately

* fix(studio): rotate manual edits around center

* fix(studio): keep rotate handle aligned while dragging

* fix(studio): allow small rotation adjustments

* fix(studio): match rotate handle size to resize handle

* fix(studio): connect rotate handle line to selection

* feat(studio): reset selected manual edits

* fix(studio): route inspector geometry through manual edits

* feat: add studio group repositioning

* fix: preserve studio group selections

* fix: seed additive studio selection groups

* fix: select studio groups on pointerdown

* fix: harden studio group overlay events

* fix: address studio manual edit review feedback

* fix: apply nested manual edits in drilled previews

* fix: commit drag offsets from gesture math

* fix: persist manual preview edits on refresh

* fix: harden manual edit refresh apply

* fix: share manual edit render runtime

* chore: release v0.5.0-alpha.15

* feat(core): add studio animation preview APIs

* feat(studio): add alpha editor layer inspector

* chore: release v0.6.0-alpha.1

* feat(studio): enable inspector panels by default

* fix(studio): keep motion panel opt-in

* chore: release v0.6.0-alpha.2

* feat: auto-open timeline clip layers

* feat: show composition loading in studio

* feat: disable Studio timeline while composition loads

* chore: ignore .claude directory

* chore: release v0.6.0-alpha.3

* feat(studio): simplify inspector selection ux

* fix(studio): keep notion preview playback moving

* fix(studio): handle raster inspector clicks

* fix(studio): stale selection, rotation control, design panel polish

Fixes and improvements based on power-user testing feedback:

1. Fix stale selection after style edits — handleDomStyleCommit now
   calls refreshDomEditSelectionFromPreview after persisting, matching
   every other commit handler. Without this, the PropertyPanel showed
   frozen computedStyles after color/radius/shadow edits, making it
   look like editing "didn't work." Also adds error handling around
   the persist call.

2. Add rotation field to the Design panel Layout section — reads the
   current rotation angle from the manual edit manifest and commits
   via the existing handleDomRotationCommit handler.

3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now
   defaults to true so the Motion tab is discoverable without env vars.

4. Color controls only when element has color — fill color section now
   only shows when the element has an explicit non-transparent
   background-color. Text color shows only when the element has a
   color style. Prevents showing color pickers on elements where
   color edits have no visible effect.

5. Exclude canvas from selection — added "canvas" to
   DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the
   preview or listed in the layer panel.

6. Multi-selection feedback — shows "N elements selected" with
   guidance instead of the generic empty state when multiple elements
   are selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): prevent browser launch timeout from crashing dev server

The shared Puppeteer browser pool in getSharedBrowser() could throw a
30s TimeoutError during launch. This error propagated as an uncaught
rejection and killed the vite process, even though generateThumbnail
had its own try/catch — the browser launch promise rejected outside
that scope. Now getSharedBrowser itself catches launch failures and
returns null, so thumbnails degrade gracefully instead of crashing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): revert motion panel default to false

Motion panel stays opt-in via env var per product direction. Only
the Design panel is enabled by default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): prevent read-only property crash in manual edit wrappers

The seek/play/applyAfter wrapper functions in manualEdits.ts crashed
with "Cannot set property X which has only a getter" when the player
or timeline objects define seek/play as getter-only properties. This
prevented ALL manual edits (position, rotation, size) from persisting
to disk — the error thrown during applyCurrentStudioManualEditsToPreview
aborted the save queue.

Wrapped all three property assignments in try/catch so wrapping
gracefully degrades when the target object is non-configurable.

Verified: position edit (X=42px) now persists to
.hyperframes/studio-manual-edits.json and survives page refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: alpha preview e2e fixes — exports, init templates, EPIPE crash

Three bugs found via automated e2e testing of the v0.6.0-alpha preview:

1. core: add missing package.json export specifiers for
   studio-api/manual-edits-render-script and
   studio-api/studio-motion-render-script — the alpha.3 npm publish
   failed because the studio build could not resolve these sub-paths.

2. cli: fix init --example creating empty projects — tsup leaves empty
   template directories in dist/ during the build, causing
   existsSync(templateDir) to return true and skip the remote fetch
   fallback. Now checks for index.html inside the dir instead.

3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg
   stdin/stdout had no error handlers, so a write after the ffmpeg
   process exits throws an uncaught error that crashes the process.

Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): thumbnail crash, feature defaults, multi-select UX, fps selector

Power-user audit fixes for the alpha studio:

- vite.config.ts: wrap thumbnail generation in try/catch so Puppeteer
  TimeoutError doesn't crash the entire vite dev server as an uncaught
  rejection. Close the page on error to prevent browser session leaks.

- manualEditingAvailability.ts: enable motion panel and manual canvas
  drag editing by default (were both false, undiscoverable without
  knowing the env vars).

- PropertyPanel.tsx: show "N elements selected" feedback when multiple
  elements are selected instead of the generic "Select an element"
  empty state.

- RenderQueue.tsx + App.tsx: add FPS selector (24/30/60) to the render
  export bar instead of hardcoding 30fps. Pass the user's choice
  through to startRender.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: release v0.6.0-alpha.4

* fix(runtime): update clock duration when root timeline is late-bound

Compositions with external sub-compositions (like apple-presentation
with 7 slides) load child compositions via fetch(). The root GSAP
timeline is only bound after all external compositions finish loading,
but the TransportClock duration was only set during initial setup.

When bindRootTimelineIfAvailable runs after the external compositions
load, it captures the root timeline but never updates the clock.
player.getDuration() continues returning 0, so the player's probe
interval never fires the 'ready' event, and the Studio shows "Loading
composition" indefinitely.

Now bindRootTimelineIfAvailable updates clock.setDuration when the
root timeline is late-bound. Guarded with try/catch for the early call
site where clock is not yet initialized (temporal dead zone).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): block element selection while composition is loading

Prevent users from selecting elements in the preview while the
composition is still loading (showing "Loading composition" overlay).
Selection and hover highlighting are suppressed until the player fires
the ready event.

Also reverts motion panel and manual drag editing defaults to false —
these were accidentally set to true during the PR #693 merge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: release v0.6.0-alpha.5

* chore: release v0.6.0-alpha.6

* fix(runtime): remove per-tick timeline.pause() that causes audio stutter

The seekRuntimeTimeline helper added timeline.pause() before every
totalTime() seek. During transport-driven playback, this runs 60 times
per second, causing GSAP to cascade pause events to media elements on
every frame. The result: audio plays/stops/plays/stops in a stutter
pattern.

The captured root timeline is already paused once in player.play() —
the TransportClock drives it via totalTime(t) which keeps it paused.
The extra per-tick pause() was redundant for the root timeline but
actively harmful for media sync.

Fix: restore the original inline seek for the captured timeline
(totalTime without pause), keep seekRuntimeTimeline with pause() only
for standalone child timelines where explicit pause control is needed.

Also fixes rebase artifact: missing PropertyPanel props in App.tsx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: release v0.6.0-alpha.7

* fix(studio): restore text field handlers lost in rebase

Restores handleDomAddTextField and handleDomRemoveTextField that were
dropped when resolving App.tsx conflicts during the main→next rebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: release v0.6.0-alpha.8

* fix(runtime): comprehensive audio stutter fix

Three changes that together caused audio play/stop/play/stop stutter
during transport-driven playback:

1. seekRuntimeTimeline called timeline.pause() before every totalTime()
   seek, 60x per second. GSAP cascades pause to media elements on every
   frame. Fix: restore original inline seek for the captured timeline
   (totalTime without pause). The timeline is already paused once in
   player.play(). seekRuntimeTimeline with pause() remains only for
   standalone child timelines.

2. player.play() removed the !tl guard, allowing play without a
   captured timeline. But getSafeTimelineDurationSeconds(null) returns
   0, so the clock has no duration → immediately reaches end → stops →
   restarts. Fix: when no timeline provides duration, fall back to the
   root composition element's data-duration attribute.

3. Audio source attachment added networkState guard that could cause
   the clock to flicker between audio-source and monotonic timing
   on transient media states. Fix: keep !rawEl.error guard (prevents
   errored audio from freezing the clock) but drop the networkState
   check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(runtime): skip drift corrections on playing video elements

Seeking a playing video resets the browser's decoder pipeline, causing
a ~150ms freeze while it re-buffers. During that freeze the monotonic
clock advances, drift grows, and strict sync fires another seek —
creating a perpetual stutter loop (176 seek events / 8s observed on
the apple-presentation composition).

Skip strict and force drift corrections for playing video elements;
only hard sync (>0.5s catastrophic drift) warrants the decoder-reset
cost. Audio elements are unaffected and retain the full correction
tiers.

Also propagate the asset-loading overlay state to the timeline so
controls are disabled during "Preparing preview assets", matching the
existing behavior for the initial composition loading overlay.

* chore: release v0.6.0-alpha.9

* feat(studio): consolidate keyboard shortcuts into single handler

Move all window-level keyboard shortcuts from 4 separate files into
one `handleAppKeyDown` listener in App.tsx:

- Shift+T: toggle timeline (was App.tsx, separate useMountEffect)
- Cmd/Ctrl+Z: undo (was App.tsx, separate useEffect)
- Cmd/Ctrl+Shift+Z: redo (was App.tsx, separate useEffect)
- Cmd/Ctrl+1: sidebar Compositions tab (was LeftSidebar.tsx)
- Cmd/Ctrl+2: sidebar Assets tab (was LeftSidebar.tsx)
- Delete/Backspace: remove selected element (was Timeline.tsx)

LeftSidebar exposes a ref handle for tab switching. Timeline watches
selectedElement becoming null to clean up popover/range UI state.
History hotkey kept as named function for iframe forwarding.

Playback shortcuts (Space, J/K/L, arrows) and caption nudge remain
in their component hooks — tightly coupled to component state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): sidebar tab overflow + hot-reload double-refresh

1. Sidebar tabs: use equal 1fr columns, shorter "Comps" label, truncate
   on overflow, tighter padding. Fixes tabs clipping outside the rounded
   pill at narrow sidebar widths.

2. Hot reload: set domEditSaveTimestampRef before every save-then-refresh
   path (source editor, timeline move/resize/delete, asset drop). The
   file-change watcher already checks this timestamp and suppresses
   echoed events — but source editor saves and timeline operations
   weren't setting it, causing a double refreshKey increment that could
   leave the player in a non-playable state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): delete key removes preview-selected elements

The consolidated keyboard handler only checked selectedElementId
(timeline clips). When a user selected a child element in the
preview via the inspector, selectedElementId was null because
the element didn't correspond to a top-level timeline clip, so
Delete/Backspace did nothing.

Add handleDomEditElementDelete that removes the element referenced
by the current domEditSelection via the remove-element mutation
API. The Delete key handler now falls through from timeline
selection to DOM edit selection.

* fix(studio): remove unused deleteInFlightRef from Timeline

Leftover from moving Delete handling to the consolidated
keyboard handler in App.tsx. Also suppress pre-existing
exhaustive-deps warning on the intentional every-render
selection-change watcher.

* fix(studio): forward all keyboard shortcuts to preview iframe

The consolidated handleAppKeyDown was only added to the parent
window. When focus was inside the preview iframe (after clicking
an element), keydown events didn't reach the parent, so Delete
and other shortcuts didn't fire.

Replace the per-function iframe forwarding (handleTimelineToggleHotkey
only) with the full app-level handler via a ref-stable wrapper.
All app shortcuts (Delete, Undo/Redo, Shift+T, Cmd+1/2) now work
from within the preview iframe.

* fix(core): search inside <template> content when removing elements

linkedom's document.querySelectorAll does not traverse <template>
content. Elements in template-based compositions (like .title-word,
.bullet-text) were invisible to the removal logic, so delete
returned changed: false and the element survived the reload.

Fall back to template.querySelectorAll when the document-level
query returns no matches. Uses template.querySelectorAll directly
(not template.content.querySelectorAll) because removing from
the content DocumentFragment doesn't update the serialized output.

* fix(studio): suppress loading overlay on hot-reload

Only show the composition loading overlay on the first iframe load.
Hot-reloads (source editor save, timeline edits, element delete)
no longer flash the full-screen loading state.

* fix(studio): reorder design panel, fix stroke height, rename Blending

- Move Text section to the top of the panel (before Layout)
- Remove Selection Colors section
- Rename "Blending" to "Transparency"
- Fix stroke Width/Style height mismatch by making SelectField
  use inline label layout matching MetricField

* fix(studio): prevent panel scroll when wheel-adjusting metric inputs

React registers onWheel passively, so preventDefault had no effect
on the parent scroll container. Replace with a native wheel listener
(passive: false) that blocks both default scroll and propagation.

* chore: release v0.6.0-alpha.10

* chore: release v0.6.0-alpha.11

* fix(studio): clean next alpha inspector artifacts

* chore: release v0.6.0-alpha.12

* fix(studio,player,core): eliminate double audio and manifest polling loop (#722)

Three bugs that compound in Studio preview:

1. **Double audio on pause/resume**: syncRuntimeMedia played audio through
   the HTML <audio> element while WebAudioTransport simultaneously played
   the same source through AudioBufferSourceNode. Fixed by passing
   webAudio.isActive() as outputMuted so HTML elements stay muted when
   Web Audio owns playback. Also removed the priorMuted restore in
   stopAll() which raced with the next play cycle.

2. **Manifest polling loop**: applyStudioManualEditsToPreview and
   applyStudioMotionToPreview unconditionally fetched from disk on every
   call, even without forceFromDisk. The runtime posts state messages
   every frame via postMessage, triggering React re-renders that re-invoked
   these functions ~60x/second. Fixed by returning early when no disk read
   is requested, and using refs instead of callbacks in useEffect deps.

3. **Parent proxy double-play**: the player web component created parent-frame
   audio proxies even when the runtime bridge was available, causing two
   audio sources on autoplay-blocked promotion. Fixed by skipping proxy
   creation when _hasRuntimeBridge returns true, and synchronously muting
   iframe media on promotion to close the async race window.

Also fixes pre-existing ResolutionPreset type missing square variants.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): improve font picker and text property controls (#736)

- Line height and letter-spacing: convert from free-text to select with presets
- Font style: remove oblique (browser falls back to italic), keep normal/italic
- Font weight: detect available weights via document.fonts.check(), add labels
- Font source: local fonts matching Google catalog tagged as Google
- Font list: balanced per-source caps prevent any source from being cut off
- Sort order: Google fonts rank before Local so curated fonts appear first

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): inspector visibility, undo/redo blinking, and preview caching

Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0
because CSS opacity is not inherited — getComputedStyle on the child still
returns 1. Walk the ancestor chain in the picker, domEditing, and overlay
visibility checks to catch this.

Also:
- Containers with all-invisible children are no longer selectable
- Selection/hover overlay hides during playback and while loading
- Undo/redo no longer double-refreshes (echo suppression for all file writes)
- Undo/redo reloads iframe in-place instead of recreating the Player,
  preserving shader transition cache
- Preview routes return ETag + Cache-Control headers; composition HTML uses
  project signature for conditional 304, binary assets use mtime+size
- Loading overlay deferred 400ms so cached loads never flash it

* fix(studio): remove timeline inspector buttons, enable manual dragging

Remove the eye icon (inspector) and image icon (thumbnail toggle) from timeline
clips. The timeline layer inspector feature and all supporting code is removed.

Enable manual dragging in the preview by default. Add scrub-to-drag on X/Y/W/H
fields in the design panel. Hide the Radius section when the element has no
visible background. Fix pre-existing ResolutionPreset type for square presets.

* chore: release v0.6.0-alpha.13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): add rotation field, inline element drag, fix manifest load regression (#743)

- Add rotation (R) field to geometry row (X, Y, W, H, R) in property panel.
  Goes through manifest via handleDomRotationCommit, resettable with Reset Edits.
- Auto-promote display:inline elements to inline-block when dragged so
  translate works on inline spans.
- Fix regression from polling fix: iframe load now passes readFromDiskFirst
  to load manifest from disk, so Reset Edits finds existing entries.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(studio): decompose App.tsx monolith (4297 → 567 lines) (#741)

* refactor(studio): decompose App.tsx from 4297 to 567 lines

Break the monolithic StudioApp component into focused modules:

Hooks (12 new):
- usePanelLayout: resizable/collapsible panel state
- useFileManager: file tree, CRUD, uploads, derived lists
- useManifestPersistence: manual edit + motion manifest save queue
- useTimelineEditing: clip move/resize/delete/drop handlers
- useDomEditSession: DOM selection, style/text commits, preview interaction
- useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync
- useCaptionDetection: auto-detect caption compositions
- useRenderClipContent: timeline clip thumbnail rendering
- useConsoleErrorCapture: preview iframe console error capture
- useFrameCapture: frame capture download flow
- useLintModal: lint execution and modal state
- useCompositionDimensions: stage-size message listener

Components (6 new):
- AskAgentModal: agent prompt modal
- StudioHeader: toolbar with undo/redo, capture, inspector toggle
- StudioLeftSidebar: file tree + code editor (handles collapsed state)
- StudioPreviewArea: NLELayout + overlays + caption timeline
- StudioRightPanel: Design/Motion/Renders tab panel
- TimelineToolbar: zoom controls + timeline toggle

Utilities (4 new):
- studioHelpers: types, path helpers, DOM utilities
- studioPreviewHelpers: preview pointer/player interaction
- domEditHelpers: selection group algebra
- studioFontHelpers: font injection + @font-face management

Also removes dead timeline layer inspector code (eye icon, thumbnail
toggle, layer panel) that was disabled behind a feature flag.

* feat(studio): add Layer (z-index) field to design panel

Adds a scrub-enabled "Layer" field below the W/H inputs in the Layout
section. Available for all elements regardless of style editing
capability since z-index is fundamental to composition stacking order.

* docs: architecture spec for studio domain contexts, hook split, and file-size lint

* docs: implementation plan for studio contexts, hook split, and file-size lint

* refactor(studio): consolidate duplicate helpers in useDomEditSession

Remove ~370 lines of helper functions that were copied into the hook
instead of imported. All removed functions already exist in the
canonical utility files (studioHelpers, studioFontHelpers,
studioPreviewHelpers, domEditHelpers). Also removes the duplicate
local type definitions for RightPanelTab, AgentModalAnchorPoint, and
PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl,
importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport).

Temporarily excludes useDomEditSession.ts from the 500 LOC file-size
check until Tasks 3-5 split it into focused hooks.

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

* refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator

Split the 897-line useDomEditSession into focused hooks:
- useDomEditCommits (439 LOC): manifest commits (path offset, box size,
  rotation, manual edits reset, motion), persist operations, element delete,
  font asset resolution
- useDomEditTextCommits (329 LOC): style/text/text-field commits
- useDomEditSession (339 LOC): thin orchestrator wiring selection, agent
  modal, preview interaction, and commit hooks

All files now under 500 LOC limit. Removed the temporary lefthook
filesize exclusion for useDomEditSession.

* feat(studio): add 4 domain contexts (PanelLayout, FileManager, DomEdit, Studio)

Create context providers that wrap hook return values for prop-drilling
elimination. Each context destructures and reconstructs the value inside
useMemo so exhaustive-deps is satisfied and re-renders are minimized.

Not yet wired into App.tsx — that comes in a follow-up.

* refactor(studio): wire domain contexts, eliminate prop drilling in 4 components

Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and
DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar,
StudioPreviewArea, and StudioRightPanel to consume contexts instead
of props.

Prop counts reduced:
- StudioHeader: 13 -> 6
- StudioLeftSidebar: 19 -> 4
- StudioPreviewArea: 37 -> 11
- StudioRightPanel: 39 -> 3

Net: -118 lines, 108 props removed from call sites.

* chore: upgrade to React 19

Upgrade react and react-dom from 18.3 to 19.2.6 across the workspace.
Add resolutions/overrides in root package.json to prevent peer
dependency pins (e.g. @phosphor-icons/react) from pulling React 18.
Regenerate bun.lock.

This enables the React 19 context syntax (<Context value={...}>)
used by the new domain contexts.

* fix(studio): refresh preview after z-index change so stacking updates visually

* fix(studio): remove duplicate duration override causing oscillation

The timeline message handler set the duration twice: once via
processTimelineMessage and once via a raw durationInFrames override.
When drilled into a sub-composition, these could disagree, causing
the duration to oscillate after element deletion.

* fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs

Two changes to fix duration oscillation after deleting a timeline clip:

1. Replace setRefreshKey (full Player remount) with in-place
   iframe.contentWindow.location.reload() after deleting a clip.
   The full remount triggered a chaotic re-probing cycle with multiple
   duration sources (adapter, manifest, postMessage) fighting each
   other, causing the timeline to oscillate between durations.
   In-place reload preserves the Player web component and its state.

2. Remove window.confirm dialogs from both timeline clip delete and
   DOM element delete. Undo is available so the confirmation adds
   friction without value.

* chore: gitignore docs/superpowers

* feat(studio): add favicon

* perf(studio): skip no-op state updates in timeline sync

syncTimelineElements was called 60+ times per page load, each time
triggering setElements/setDuration/setTimelineReady even when nothing
changed. This caused massive re-render churn and memory usage.

Add early-return guards to skip updates when values haven't changed.
Also fixes the duration oscillation after element delete.

* refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules

The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit.
Split into cohesive modules by responsibility:

- propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants
- propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField,
  SliderControl, SegmentedControl, SelectField, Section
- propertyPanelColor.tsx (371) — ColorField, ColorSlider
- propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers
- propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers
- propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls
- propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill)
- PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers

All re-exports from PropertyPanel.tsx preserved for backwards compatibility.
No behavioral changes — pure structural split.

* fix(studio): use in-place iframe reload for all timeline operations

Replace setRefreshKey with in-place iframe reload for move, resize,
and asset drop — matching delete which was already fixed. Prevents
the Player remount probe cycle that causes duration oscillation.

* perf(studio): replace 5s polling loop with event-driven adapter init

The Player's onIframeLoad used a setInterval polling loop (25 attempts
× 200ms = 5 seconds) to detect when the runtime's __player/__timeline
globals appeared. Each poll that missed triggered wasted work, and
multiple duration sources fighting during the probe cycle caused
oscillation bugs.

Replace with event-driven initialization:
1. Fast path: try initializeAdapter() immediately (works for in-place
   reloads where the adapter is already present)
2. If not ready, listen for the runtime's "state"/"timeline" postMessage
   signals and initialize on the first one
3. Single 5s timeout as safety net (replaces 25 interval ticks)

This eliminates the polling overhead, reduces setDuration/setElements
calls to exactly 1 per load, and makes the Player responsive within
one frame of the runtime being ready instead of up to 200ms later.

* fix(studio): prevent duration oscillation after element delete

Two fixes for the duration display oscillating between sub-composition
and master durations after deleting an element in the preview:

1. Clear store elements before iframe reload in handleDomEditElementDelete.
   Without this, stale pre-delete elements remain in the store and cause
   mergeTimelineElementsPreservingDowngrades to alternate between REPLACE
   and PRESERVE modes as the element count fluctuates.

2. Add 500ms cooldown on enrichMissingCompositions after timeline messages.
   The "state" handler was calling enrichMissingCompositions every ~80ms,
   which added extra elements from GSAP timelines. These fought with the
   authoritative element list from "timeline" messages (~333ms), creating
   a feedback loop where element count oscillated and triggered alternating
   merge strategies with different durations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): single reloadPreview as source of truth for preview refresh

Create reloadPreview() in App.tsx that encapsulates the correct
behavior (in-place iframe reload with setRefreshKey fallback). Pass it
as the sole refresh mechanism to hooks, removing direct setRefreshKey
access from useTimelineEditing and useDomEditCommits.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(studio): decompose App.tsx from 4297 to 567 lines

Break the monolithic StudioApp component into focused modules:

Hooks (12 new):
- usePanelLayout: resizable/collapsible panel state
- useFileManager: file tree, CRUD, uploads, derived lists
- useManifestPersistence: manual edit + motion manifest save queue
- useTimelineEditing: clip move/resize/delete/drop handlers
- useDomEditSession: DOM selection, style/text commits, preview interaction
- useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync
- useCaptionDetection: auto-detect caption compositions
- useRenderClipContent: timeline clip thumbnail rendering
- useConsoleErrorCapture: preview iframe console error capture
- useFrameCapture: frame capture download flow
- useLintModal: lint execution and modal state
- useCompositionDimensions: stage-size message listener

Components (6 new):
- AskAgentModal: agent prompt modal
- StudioHeader: toolbar with undo/redo, capture, inspector toggle
- StudioLeftSidebar: file tree + code editor (handles collapsed state)
- StudioPreviewArea: NLELayout + overlays + caption timeline
- StudioRightPanel: Design/Motion/Renders tab panel
- TimelineToolbar: zoom controls + timeline toggle

Utilities (4 new):
- studioHelpers: types, path helpers, DOM utilities
- studioPreviewHelpers: preview pointer/player interaction
- domEditHelpers: selection group algebra
- studioFontHelpers: font injection + @font-face management

Also removes dead timeline layer inspector code (eye icon, thumbnail
toggle, layer panel) that was disabled behind a feature flag.

* docs: architecture spec for studio domain contexts, hook split, and file-size lint

* docs: implementation plan for studio contexts, hook split, and file-size lint

* refactor(studio): consolidate duplicate helpers in useDomEditSession

Remove ~370 lines of helper functions that were copied into the hook
instead of imported. All removed functions already exist in the
canonical utility files (studioHelpers, studioFontHelpers,
studioPreviewHelpers, domEditHelpers). Also removes the duplicate
local type definitions for RightPanelTab, AgentModalAnchorPoint, and
PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl,
importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport).

Temporarily excludes useDomEditSession.ts from the 500 LOC file-size
check until Tasks 3-5 split it into focused hooks.

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

* refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator

Split the 897-line useDomEditSession into focused hooks:
- useDomEditCommits (439 LOC): manifest commits (path offset, box size,
  rotation, manual edits reset, motion), persist operations, element delete,
  font asset resolution
- useDomEditTextCommits (329 LOC): style/text/text-field commits
- useDomEditSession (339 LOC): thin orchestrator wiring selection, agent
  modal, preview interaction, and commit hooks

All files now under 500 LOC limit. Removed the temporary lefthook
filesize exclusion for useDomEditSession.

* refactor(studio): wire domain contexts, eliminate prop drilling in 4 components

Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and
DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar,
StudioPreviewArea, and StudioRightPanel to consume contexts instead
of props.

Prop counts reduced:
- StudioHeader: 13 -> 6
- StudioLeftSidebar: 19 -> 4
- StudioPreviewArea: 37 -> 11
- StudioRightPanel: 39 -> 3

Net: -118 lines, 108 props removed from call sites.

* fix(studio): refresh preview after z-index change so stacking updates visually

* fix(studio): remove duplicate duration override causing oscillation

The timeline message handler set the duration twice: once via
processTimelineMessage and once via a raw durationInFrames override.
When drilled into a sub-composition, these could disagree, causing
the duration to oscillate after element deletion.

* fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs

Two changes to fix duration oscillation after deleting a timeline clip:

1. Replace setRefreshKey (full Player remount) with in-place
   iframe.contentWindow.location.reload() after deleting a clip.
   The full remount triggered a chaotic re-probing cycle with multiple
   duration sources (adapter, manifest, postMessage) fighting each
   other, causing the timeline to oscillate between durations.
   In-place reload preserves the Player web component and its state.

2. Remove window.confirm dialogs from both timeline clip delete and
   DOM element delete. Undo is available so the confirmation adds
   friction without value.

* chore: gitignore docs/superpowers

* perf(studio): skip no-op state updates in timeline sync

syncTimelineElements was called 60+ times per page load, each time
triggering setElements/setDuration/setTimelineReady even when nothing
changed. This caused massive re-render churn and memory usage.

Add early-return guards to skip updates when values haven't changed.
Also fixes the duration oscillation after element delete.

* refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules

The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit.
Split into cohesive modules by responsibility:

- propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants
- propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField,
  SliderControl, SegmentedControl, SelectField, Section
- propertyPanelColor.tsx (371) — ColorField, ColorSlider
- propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers
- propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers
- propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls
- propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill)
- PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers

All re-exports from PropertyPanel.tsx preserved for backwards compatibility.
No behavioral changes — pure structural split.

* fix(studio): use in-place iframe reload for all timeline operations

Replace setRefreshKey with in-place iframe reload for move, resize,
and asset drop — matching delete which was already fixed. Prevents
the Player remount probe cycle that causes duration oscillation.

* perf(studio): replace 5s polling loop with event-driven adapter init

The Player's onIframeLoad used a setInterval polling loop (25 attempts
× 200ms = 5 seconds) to detect when the runtime's __player/__timeline
globals appeared. Each poll that missed triggered wasted work, and
multiple duration sources fighting during the probe cycle caused
oscillation bugs.

Replace with event-driven initialization:
1. Fast path: try initializeAdapter() immediately (works for in-place
   reloads where the adapter is already present)
2. If not ready, listen for the runtime's "state"/"timeline" postMessage
   signals and initialize on the first one
3. Single 5s timeout as safety net (replaces 25 interval ticks)

This eliminates the polling overhead, reduces setDuration/setElements
calls to exactly 1 per load, and makes the Player responsive within
one frame of the runtime being ready instead of up to 200ms later.

* fix(studio): prevent duration oscillation after element delete

Two fixes for the duration display oscillating between sub-composition
and master durations after deleting an element in the preview:

1. Clear store elements before iframe reload in handleDomEditElementDelete.
   Without this, stale pre-delete elements remain in the store and cause
   mergeTimelineElementsPreservingDowngrades to alternate between REPLACE
   and PRESERVE modes as the element count fluctuates.

2. Add 500ms cooldown on enrichMissingCompositions after timeline messages.
   The "state" handler was calling enrichMissingCompositions every ~80ms,
   which added extra elements from GSAP timelines. These fought with the
   authoritative element list from "timeline" messages (~333ms), creating
   a feedback loop where element count oscillated and triggered alternating
   merge strategies with different durations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): single reloadPreview as source of truth for preview refresh

Create reloadPreview() in App.tsx that encapsulates the correct
behavior (in-place iframe reload with setRefreshKey fallback). Pass it
as the sole refresh mechanism to hooks, removing direct setRefreshKey
access from useTimelineEditing and useDomEditCommits.

* fix: resolve lint errors from rebase (unused imports, duplicate declarations)

* fix: prefix unused probeResult variable

* fix: restore renderOrchestrator.ts from origin/next (rebase conflict artifact)

* fix: resolve rebase conflicts by using main's producer and next's studio/player

* fix: restore rebase-conflicted files from origin/next

* fix: use 'load' instead of 'networkidle0' for Puppeteer waitUntil (type compatibility)

* fix: restore webAudioTransport.ts from main (test compatibility)

---------

Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 20:18:52 +02:00
James Russo b8a2c48291 Merge pull request #737 from heygen-com/05-12-refactor_producer_move_updatejobstatus_to_render_shared.ts
refactor(producer): move updateJobStatus to render/shared.ts
2026-05-12 12:54:29 -04:00
James Russo 314e04b14b Merge pull request #735 from heygen-com/05-12-refactor_producer_document_executerenderjob_as_a_thin_sequencer
refactor(producer): document executeRenderJob as a thin sequencer
2026-05-12 06:59:02 -04:00
James Russo 35c536e129 Merge pull request #734 from heygen-com/05-12-refactor_producer_extract_encodestage_and_assemblestage
refactor(producer): extract encodeStage and assembleStage
2026-05-12 06:51:22 -04:00
James Russo bd49dd3fc6 Merge pull request #733 from heygen-com/05-12-refactor_producer_extract_capturehdrstage_hdr___shader-transition_path_
refactor(producer): extract captureHdrStage (HDR / shader-transition path)
2026-05-12 06:28:34 -04:00
James Russo 14eecdd143 Merge pull request #731 from heygen-com/05-12-refactor_producer_extract_capturestreamingstage_single-machine_fusion_
refactor(producer): extract captureStreamingStage (single-machine fusion)
2026-05-12 06:08:44 -04:00
James Russo d62048cfad Merge pull request #730 from heygen-com/05-12-refactor_producer_extract_capturestage_sdr_disk_path_
refactor(producer): extract captureStage (SDR disk path)
2026-05-12 05:57:58 -04:00
James Russo 725de329e4 Merge pull request #726 from heygen-com/05-12-refactor_producer_extract_audiostage_from_executerenderjob
refactor(producer): extract audioStage from executeRenderJob
2026-05-12 05:18:22 -04:00
Miguel Ángel 224e588e88 Merge pull request #729 from heygen-com/feat/lazy-media-preloading
feat(core): lazy media preloading for heavy compositions
2026-05-12 06:48:29 +02:00
Miguel Ángel e704a33c69 fix(core): preserve per-element preload ordering in render mode
The lazy media preloading refactor split bindMediaMetadataListeners into
two loops: bind all listeners first, then preload all elements. This
changed when metadata listeners fire relative to .load(), shifting
timeline duration hydration and causing 3 transition frames to render at
a slightly different state in the style-9-prod regression test.

Move eager preload back inside the per-element binding loop so listener
attachment and .load() happen in the same iteration, matching the
original ordering. Lazy-mode demotion stays in a separate block after
mediaPreloader.refresh() since it needs the full clip list.
2026-05-11 21:21:35 -07:00
Miguel Ángel f13c30e1f2 fix(core): skip media preloader activation in render mode
mediaPreloader.refresh() was called unconditionally, setting lazy=true
for compositions with ≥6 clips even in render mode. player.seek then
called preloadAroundTime() which evicted clips via src clearing,
destroying buffered data needed for frame-accurate capture.

Skip refresh() when __HF_EXPORT_RENDER_SEEK_CONFIG is set so isLazy()
stays false and the preloader is completely inert during renders.
2026-05-11 20:56:47 -07:00
James Russo 34d732e6d8 Merge pull request #725 from heygen-com/05-12-refactor_producer_extract_extractvideosstage_add_materializesymlinks_param
refactor(producer): extract extractVideosStage + add materializeSymlinks param
2026-05-11 22:59:38 -04:00
Miguel Ángel 35eab94e69 fix(core): parent-frame proxy bypass, data-preload-eager opt-out, configurable threshold
- Player: _adoptIframeMedia now skips media with preload="metadata" or
  "none", preventing parent-frame proxies from bypassing the preloader.
  MutationObserver extended to watch preload attribute changes so proxies
  are created just-in-time when the preloader promotes a clip.

- init.ts: lazy-mode demotion loop skips elements with data-preload-eager,
  letting power users keep specific clips eagerly buffered.

- mediaPreloader: reads window.__HF_LAZY_PRELOAD_THRESHOLD as an override,
  falling back to the default 6.
2026-05-11 19:37:06 -07:00
JamesandClaude Opus 4.7 d351843ab8 refactor(producer): move updateJobStatus to render/shared.ts
First of several focused PRs that flatten the runtime cycle between
the capture stages and `renderOrchestrator.ts` (documented as a known
follow-up across PRs 1.6 / 1.7 / 1.8 / 1.9).

`updateJobStatus` was the most-imported orchestrator helper: 5 of the
6 capture / encode / assemble stages reach back into the orchestrator
for it. Moving it to `render/shared.ts` (where the other small
cross-cutting utilities already live) breaks the runtime cycle for
five stages in one move:

- captureStage
- captureStreamingStage
- captureHdrStage
- encodeStage
- assembleStage

Each of those stages now imports `updateJobStatus` from `../shared.js`
at runtime, and the only thing they pull from `renderOrchestrator.js`
is type-only (`RenderJob`, `ProgressCallback`, etc.) — type imports
are erased at runtime, so no cycle.

The orchestrator's own internal call sites (`updateJobStatus(...)` for
the inline progress updates and the `complete` / `failed` / `cancelled`
transitions) are unchanged in body; they now import the function from
the same shared module.

Follow-up PRs will move:
- `executeDiskCaptureWithAdaptiveRetry` + capture-retry helpers (breaks
  the captureStage cycle entirely)
- The six HDR helpers + `resolveCompositeTransfer` (breaks captureHdrStage)
- `collectVideoMetadataHints`, `collectVideoReadinessSkipIds`,
  `materializeExtractedFramesForCompiledDir` (breaks extractVideosStage)

No behavior change. Verified inside `Dockerfile.test`:
font-variant-numeric, many-cuts, gsap-letters-render-compat,
hdr-regression — 4/4 PASS with identical audio correlations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 02:33:51 +00:00
Miguel Ángel a96d99680f fix(core): address staff review — diagnostics, comments, test coverage
- Add onActivation callback to MediaPreloadManager; wired to
  postRuntimeDiagnosticOnce in init.ts for observability
- Document LAZY_THRESHOLD rationale (why 6) and MAX_PROMOTED
  defense-in-depth semantics
- Add render-mode bypass contract test (isLazy with exactly 6 clips)
- Add onActivation tests: fires once on lazy activation, skips below
  threshold, deduplicates across refreshes
2026-05-11 19:26:18 -07:00
JamesandClaude Opus 4.7 513775e659 refactor(producer): /simplify cleanup pass across stage modules
Comment + interface cleanup driven by the /simplify review. No code
change beyond removing dead fields.

- audioStage: drop unused `job: RenderJob` from `AudioStageInput` (the
  stage destructures it but never references the value).
- encodeStage: drop unused `fps` + `useGpu` from `EncodeStageInput`;
  read both from `job.config.*` inside the stage (matches the pattern
  used by captureStage and captureStreamingStage).
- captureStreamingStage: drop the unused `captureDurationMs` field
  from `CaptureStreamingStageResult` (sequencer never reads it — it
  uses its own `Date.now() - stage4Start` for `perfStages.captureMs`).
  Also drops the now-dead `streamStart` local.
- captureStreamingStage: rewrite the "Known follow-up" header comment
  to drop the "PR 1.3.5" reference per `feedback_no_internal_track_names_in_source`.
- captureHdrStage: drop the "Lifted verbatim from `executeRenderJob`"
  refactor-narration sentence in the header doc (the "Hard constraints
  preserved verbatim" list below it is real long-term documentation
  and stays).
- Sequencer call sites updated to drop the now-removed fields.

Verified inside `Dockerfile.test`: 4/4 fixtures pass with PSNR / audio
correlations unchanged (font-variant-numeric, many-cuts, gsap-letters,
hdr-regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 02:19:45 +00:00
Miguel Ángel 15f9fb711a fix(studio): skip deferred media in hasUnloadedAssets check
Elements with preload!='auto' are intentionally deferred by the
media preloader and should not block the loading overlay.
2026-05-11 19:17:04 -07:00
Miguel Ángel b7438fa03d fix(core): add missing mediaPreloader import dropped during rebase 2026-05-11 19:13:27 -07:00
JamesandClaude Opus 4.7 46954b7f57 refactor(producer): document executeRenderJob as a thin sequencer
Final polish PR of the Phase 1 stack. Comment-only — zero code change.

After PRs #725, #726, #730, #731, #733, #734, the `executeRenderJob`
function now composes eight stage modules instead of inlining the
pipeline. Updates the file-level JSDoc to point at each stage module
and explains the orchestrator's residual responsibilities: shared
resource lifetime, perf counters, error diagnostics, and the
`try/finally` cleanup. Adds JSDoc on `executeRenderJob` itself
summarising what it returns and when it throws.

The function body is unchanged. The line count dropped from ~2,200
(pre-Phase-1) to ~880; the remainder is in-sequencer setup that doesn't
naturally compose into a stage (calibration, worker resolution, HDR
auto-detection, preset selection, final perf-summary assembly) plus
the orchestrator's `try/finally` resource ownership.

Verified inside `Dockerfile.test`: font-variant-numeric (1.000),
many-cuts (0.994), variables-prod (0.975), hdr-regression (1.000) —
4/4 PASS with audio correlations identical to every prior PR in the
Phase 1 stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 02:01:41 +00:00
JamesandClaude Opus 4.7 434539e99a refactor(producer): extract encodeStage and assembleStage
Move the final two stages of `executeRenderJob` into their own files:

- `services/render/stages/encodeStage.ts` (Stage 5): handles both the
  png-sequence path (rename + copy + audio sidecar) and the encoded path
  (`encodeFramesFromDir` or `encodeFramesChunkedConcat`).
- `services/render/stages/assembleStage.ts` (Stage 6): runs
  `muxVideoWithAudio` when `hasAudio`, otherwise `applyFaststart`.
  Skipped for png-sequence (sequencer gates the call).

Both stages are mechanical extractions of small, self-contained blocks.
The sequencer's call sites preserve the same conditions and the same
`perfStages.encodeMs` / `perfStages.assembleMs` assignments.

Hard constraints preserved verbatim:
- The `updateJobStatus` payloads ("Writing PNG sequence" / "Encoding
  video" at 75%; "Assembling final video" at 90%) fire from inside the
  stages at the same code points.
- The png-sequence "no PNGs were captured" error throws verbatim.
- The png-sequence audio sidecar is only written when
  `hasAudio && existsSync(audioOutputPath)`.
- `enableChunkedEncode` selects `encodeFramesChunkedConcat` vs.
  `encodeFramesFromDir` with the same args.
- The mux + faststart error messages (`Audio muxing failed: ...`,
  `Faststart failed: ...`) throw verbatim on `success: false`.

Removes the now-orphaned imports from the orchestrator:
`encodeFramesFromDir`, `encodeFramesChunkedConcat`, `muxVideoWithAudio`,
`applyFaststart`.

Verified inside `Dockerfile.test`:
- font-variant-numeric (1.000), many-cuts (0.994),
  sub-composition-video (0.947), gsap-letters-render-compat (1.000),
  hdr-regression (1.000) — 5/5 PASS, audio correlations identical to
  prior PRs in the stack. Exercises encoded mp4 + HDR (encode + assemble
  both run) and the streaming-fusion path (encode skipped by sequencer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:53:13 +00:00
Miguel Ángel 0c6f438ae7 fix(core): add LRU eviction to media preloader, protect untimed media
Three root-cause fixes for the lazy media preloading feature:

1. Untimed media orphaned at preload="metadata": the else branch in
   bindMediaMetadataListeners demoted ALL media elements, but the
   mediaPreloader only manages timed clips (data-start). Untimed media
   (background audio, ambient loops) got stuck at metadata forever.
   Now only timed elements are demoted.

2. Monotonic promotion with no eviction: once promoted, clips stayed
   at preload="auto" forever. Scrubbing through the full timeline
   promoted everything, bringing back the OOM crash. Added LRU eviction
   with MAX_PROMOTED=5 — when clips leave the preload window, their src
   is cleared and load() called to release buffered data per MDN. On
   re-entry, the original src is restored.

3. Metadata preload without load(): setting preload="metadata" alone
   doesn't guarantee the metadata fetch in Chrome Lite mode or Firefox
   with media.preload.default=0. Now load() is called after demotion
   to ensure el.duration is populated for timeline computation.

Also adds exact-boundary tests for LAZY_THRESHOLD=6 and eviction
coverage (evict on scrub, src restoration, MAX_PROMOTED cap, load()
called on eviction).
2026-05-11 18:44:58 -07:00
Miguel Ángel 372da1cd28 feat(core): integrate media preloader into runtime and studio
Wire the MediaPreloadManager into init.ts:
- Detect render mode via __HF_EXPORT_RENDER_SEEK_CONFIG (keeps eager preload)
- Gate bindMediaMetadataListeners: lazy mode sets preload="metadata",
  eager mode keeps preload="auto" (unchanged for small compositions)
- Advance preload window in the timeline poll tick loop
- Call preloadAroundTime on seek for instant buffering at seek target

Studio Player.tsx: hasUnloadedAssets now skips elements with
preload!="auto" so deferred clips don't block the loading overlay.
2026-05-11 18:44:58 -07:00
Miguel Ángel 773c4261e2 feat(core): lazy media preloading for heavy compositions
Compositions with many large video files (e.g., 6GB across 20 clips) crash
the browser because the runtime eagerly sets preload="auto" + .load() on
every media element at startup. All files buffer simultaneously, exhausting
memory.

Add a MediaPreloadManager that gates preloading based on playhead position:

- Activates when a composition has ≥6 timed media elements
- Only preloads clips within a 10-second lookahead window (or next 2 clips)
- Far-away clips stay at preload="metadata" (resolves duration without
  downloading data)
- Advances the window on each transport tick and immediately on seek
- Render mode (window.__HF_EXPORT_RENDER_SEEK_CONFIG) keeps eager preload
  for deterministic frame capture
- Small compositions (<6 clips) keep eager preload — no behavior change

Studio's hasUnloadedAssets now skips elements with preload!="auto", so
deferred clips don't block the loading overlay.
2026-05-11 18:41:59 -07:00
JamesandClaude Opus 4.7 5e4641fb77 refactor(producer): extract captureHdrStage (HDR / shader-transition path)
Move the Z-ordered HDR / shader-transition layered composite branch
(`if (useLayeredComposite)`) out of `executeRenderJob` into
`services/render/stages/captureHdrStage.ts`. The largest extraction by
LOC (~745 lines of body lifted verbatim) and the riskiest by cleanup
invariants. Body is lifted byte-for-byte — only the surrounding scope
changes.

Cleanup invariants preserved verbatim (design doc §11 flagged these
explicitly):
- `hdrEncoderClosed` / `domSessionClosed` flags gate the
  defensive-close paths so they don't run twice when the success path
  already closed.
- `hdrVideoFrameSources` is drained + cleared in the outer `finally`
  regardless of how the body exited.
- `cfg.forceScreenshot = true` is set unconditionally inside the
  layered path because `captureAlphaPng` hangs under
  `--enable-begin-frame-control`.

Other invariants preserved:
- `hdrPerf` is created at the top of the stage and returned; the
  sequencer's `finalizeHdrPerf` consumes it for the perf summary.
- The `Layered compositing frame N/M` `updateJobStatus` payload fires
  at the same per-frame point with `25 + frameProgress * 55`.
- `composition` and `compiled` are read-only in the stage.
- `hdrDiagnostics` is mutated in place (counters incremented at the
  same code points).
- `nativeHdrIds` is recomputed inside the stage from
  `nativeHdrVideoIds` + `nativeHdrImageIds` (the sequencer's
  computation is unchanged; the stage just doesn't need it passed in).

To support the extraction, the following symbols are newly exported
from `renderOrchestrator.ts`:
- Helper functions: `createHdrPerfCollector`, `addHdrTiming`,
  `closeHdrVideoFrameSource`, `blitHdrVideoLayer`, `blitHdrImageLayer`,
  `compositeHdrFrame`.
- Types: `HdrPerfCollector`, `HdrPerfTimingKey`, `HdrVideoFrameSource`,
  `HdrImageBuffer`, `HdrCompositeContext`, `HdrTransitionMeta`,
  `TransitionRange`.

These are internal helpers — the stage is currently the only consumer,
and the cycle (orchestrator imports `runCaptureHdrStage`; stage imports
helpers back) is safe at runtime. A future PR will consolidate the
helpers into a shared module (same follow-up planned for the capture
helpers in PRs 1.6 and 1.7).

Removes the now-orphaned imports from the orchestrator:
`openSync`, `fpsToFfmpegArg`, `spawnStreamingEncoder`,
`StreamingEncoder` type, `runFfmpeg`, `initTransparentBackground`,
`decodePngToRgb48le`, `queryElementStacking`, `TRANSITIONS`,
`crossfade`, `resampleRgb48leObjectFit`, `normalizeObjectFit`,
`TransitionFn` type, `createHdrImageTransferCache`.

Verified inside `Dockerfile.test`:
- **HDR fixtures (3/3 PASS)**: hdr-regression, hdr-hlg-regression,
  vignelli-stacking — audio correlations 1.000 / 1.000 / 0.982.
- **Non-HDR fixtures (4/4 PASS)**: font-variant-numeric, many-cuts,
  sub-composition-video, gsap-letters-render-compat — audio
  correlations 1.000 / 0.994 / 0.947 / 1.000.
- 7/7 fixtures total pass with PSNR / audio correlations matching every
  prior PR in the stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:40:26 +00:00
JamesandClaude Opus 4.7 ba90b411ed refactor(producer): extract captureStreamingStage (single-machine fusion)
Move the streaming encode fusion path (`useStreamingEncode === true` with
successful encoder spawn) out of `executeRenderJob` into
`services/render/stages/captureStreamingStage.ts`. The stage owns:

- `spawnStreamingEncoder` invocation, including the abort-rethrow vs.
  graceful-fallback handling.
- Parallel + sequential capture-to-stdin loops (Stage 4 absorbs Stage 5
  for streaming renders).
- The streaming encoder's `close()` + result check.
- Defensive cleanup of the streaming encoder in the stage's own
  `try/finally`.

The stage returns either `{ success: true, ... }` (sequencer skips the
disk path AND inline Stage 5) or `{ success: false }` (sequencer falls
back to the disk path). The sequencer's `useStreamingEncode` flag is
no longer flipped imperatively — the result type makes the branch
selection explicit.

Hard constraints preserved verbatim:
- `probeSession` is closed at the same code points (parallel: after
  capture; sequential: in session finally). The local binding nulls
  via the returned result.
- `lastBrowserConsole` is set to the buffer of whichever session was
  active last (probe close path or sequential session finally).
- `job.framesRendered` is updated per-frame; `Streaming frame N/M
  [(K workers)]` `updateJobStatus` payloads fire at the same 30-frame
  and completion checkpoints (parallel) or every frame (sequential),
  with the same percentage math `25 + frameProgress * 55`.
- `Streaming encode failed: <err>` still throws on the encoder's
  `success: false` close result.
- The defensive `try/finally` close-on-throw is preserved, now inside
  the stage instead of the orchestrator.
- `perfStages.captureMs` is still set by the sequencer from
  `stage4Start`; the stage also returns `encodeMs` for the encoder's
  overlapped duration (assigned to `perfStages.encodeMs`).

Removes the orphaned `createFrameReorderBuffer` and
`prepareCaptureSessionForReuse` imports from the orchestrator after
the streaming code moved.

Verified inside `Dockerfile.test`:
- 5/5 fixtures PASS (font-variant-numeric, many-cuts, variables-prod,
  sub-composition-video, gsap-letters-render-compat).
- `gsap-letters-render-compat` (single-worker render, 4s duration)
  exercises the new streaming stage end-to-end —
  `streaming-encode gate enabled=true` confirmed in the log.
- The other 4 fixtures exercise the disk path (workerCount > 1).

Known follow-up: same runtime import cycle situation as captureStage —
the stage imports `updateJobStatus` and types from
`renderOrchestrator.ts`, which imports the stage back. Safe (deferred
to runtime); a future PR will flatten this once all 8 stages are
extracted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:15:03 +00:00
JamesandClaude Opus 4.7 d39df2de6c refactor(producer): extract captureStage (SDR disk path)
Move the SDR / DOM-only-HDR disk-capture body out of `executeRenderJob`
into `services/render/stages/captureStage.ts`. Covers both branches of
the disk path: parallel capture via `executeDiskCaptureWithAdaptiveRetry`
(`workerCount > 1`) and sequential per-process capture (`workerCount === 1`,
reusing `probeSession` when available).

The HDR layered branch (`useLayeredComposite === true`) and the streaming
encode fusion path (`useStreamingEncode === true` with successful encoder
spawn) stay inline in the sequencer — they will be extracted by the next
two PRs in the stack.

Hard constraints preserved verbatim:
- `probeSession` is closed (and the sequencer's `let probeSession`
  nulled via the returned result) at the same points.
- `captureAttempts` is mutated in place — the parallel retry loop still
  pushes each attempt onto the array the sequencer owns.
- `workerCount` reassignment from adaptive retry survives via the
  returned result.
- `lastBrowserConsole` is set to the buffer of whichever session was
  active last (probe close path or sequential capture finally).
- `job.framesRendered` is updated at the same per-frame / per-progress
  points; `Capturing frame N/M [(K workers)]` `updateJobStatus` payloads
  fire at the same 30-frame and completion checkpoints.
- `perfStages.captureMs` is still computed by the sequencer from the
  outer `stage4Start` so its window covers both the in-sequencer setup
  (fileServer init, calibration, worker resolution, preset selection)
  AND the capture call.

Two small new exports on `renderOrchestrator.ts`:
- `executeDiskCaptureWithAdaptiveRetry` — was a private helper; the
  stage calls it directly.
- `updateJobStatus` — was a private helper; the stage uses it for the
  per-frame progress callbacks so the `completedAt` branch matches.

These re-introduce a small runtime cycle between the stage and the
orchestrator (orchestrator imports `runCaptureStage`; stage imports
helpers back). The cycle is safe (both modules finish loading before
any stage function is invoked at runtime) and will be flattened in a
follow-up PR that consolidates capture helpers into a shared module.
Removes the now-orphaned `captureFrame` import from the orchestrator.

Verified inside `Dockerfile.test`:
- `font-variant-numeric`: audio correlation 1.000
- `many-cuts`: 0 failed frames, audio correlation 0.994
- `variables-prod`: PSNR ~69 dB, audio correlation 0.975
- `sub-composition-video`: PSNR ~43-52 dB, audio correlation 0.947
  (exercises video extraction + capture end-to-end)
- `gsap-letters-render-compat`: PSNR ~53-55 dB, audio correlation 1.000
  (exercises the parallel capture path; 5/5 PASS overall)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:05:31 +00:00