Commit Graph
663 Commits
Author SHA1 Message Date
JamesandClaude Opus 4.7 e50587496f fix(producer): address PR review feedback on harness mode + plan() copy filter
Miguel (approved) and Vai (commented) both flagged the same
PSNR-threshold doc/code mismatch; Vai additionally flagged a
path-anchoring bug in the projectDir-copy filter and a dishonest type
cast. Addressed all five findings:

PSNR threshold doc/code mismatch (important):
- Module docstring, `resolveMinPsnrForMode` JSDoc, and tests/README.md all
  claimed distributed-simulated tightens to ≥50 dB. The actual code uses
  `max(fixture.minPsnr, 10)` — 10 dB is a pathology floor, the per-test
  gate is the fixture's authored `minPsnr`. Updated all three doc sites
  to describe what the code does. The 50 dB target in §5.1 is a per-
  render distributed-vs-in-process contract; against the frozen baseline
  it's unreachable for either mode (shared encoder/JPEG jitter), so it
  can't be a per-fixture gate.

`PLAN_PROJECT_DIR_COPY_SKIP` regex matched absolute paths (important):
- `cpSync` calls the filter with the absolute source path, so a
  `projectDir` whose absolute path happens to contain a blocklisted
  segment (`/home/user/work/output/comp/`, `~/projects/dist/foo/`, etc.)
  caused the filter to return false for every descendant — empty
  compiled directory, broken render. Now matches relative-to-projectDir
  segments via `path.relative()` + `split(sep)`. Switched from a regex
  to a Set for clarity. Harness fixtures don't hit this because they
  live under `tests/<name>/src/`, but adapters call `plan()` with
  caller-supplied paths.

Dishonest type cast in regression-harness.ts (important):
- `as "mp4" | "mov" | "png-sequence"` claimed reachability for formats
  that `validateMetadata` doesn't accept (the schema is `"mp4" | "webm"`,
  and webm is rejected by `checkDistributedSupport`). Narrowed to
  hardcoded `format: "mp4"` with a comment naming the metadata-schema
  invariant that lets us do that.

Renamed `chunkVideoInjectorFactory` (nit):
- The variable was invoked once and never used again — "factory" implied
  repeated calls. Inlined as a plain `videoInjector: BeforeCaptureHook | null`
  ternary.

Replaced tautology test (nit):
- `expect(DISTRIBUTED_SIMULATED_MIN_PSNR_DB).toBe(10)` was a value-pin
  over an exported constant. The invariant the JSDoc actually asserts is
  "10 dB is below any real fixture's authored minPsnr"; if someone lands
  a permissive fixture (minPsnr: 5), the value-pin doesn't catch it.
  Replaced with a test that walks `tests/*/meta.json` and asserts every
  authored `minPsnr` is ≥ the floor.

Validated in `docker:test --mode=distributed-simulated`:
  font-variant-numeric, many-cuts, gsap-letters-render-compat,
  style-1-prod, sub-composition-video — all PASSED.
Unit tests: 15/15 pass (new fixture-scan test included).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:33:03 +00:00
JamesandClaude Opus 4.7 b8e8617f80 refactor(producer): apply /simplify cleanups across distributed harness stack
Code reuse:
- Move `PlanVideosJson` interface + `meta/videos.json` path constant into
  `services/distributed/shared.ts`; plan.ts and renderChunk.ts import from
  there instead of redeclaring the same shape with the "duplicated here so
  renderChunk doesn't import from plan.ts" comment.
- Replace hand-rolled `framePattern.slice(lastIndexOf("."))` with
  `extname()` from `node:path` in `rebuildExtractedFramesFromPlanDir`.

Efficiency:
- Hoist `rebuildExtractedFramesFromPlanDir` + `createFrameLookupTable` +
  `createVideoFrameInjector` out of the per-chunk closure in renderChunk.
  Computed once per chunk now, not once per `createRenderVideoFrameInjector`
  callsite (which `runCaptureStage` may invoke multiple times).
- Add a regex filter to `cpSync(projectDir → planDir/compiled/)` so
  `node_modules`, `.git`, `output/`, `failures/`, `dist/`, etc. are not
  copied. Real projects can have hundreds of MB in those directories;
  shipping them to S3/Lambda /tmp on every render bloats cost and time.
- Drop redundant `if (!existsSync(metaDir)) mkdirSync(metaDir, {recursive:true})`
  guards; `mkdirSync({recursive:true})` is already idempotent.

Quality:
- Strip narrative comments that told the story of debugging:
  - renderChunk's 30-line "Two failure modes made the call actively
    harmful" block → 4-line invariant on why `discardWarmupCapture` is
    omitted.
  - plan.ts's "DO NOT call cleanup()" block → 3 lines naming the
    invariant.
  - plan.ts's pre-seed-projectDir block → 7 lines on the file-server
    invariant.
  - renderChunk.ts top docstring's discardWarmupCapture paragraph.
  - regression-harness-distributed.ts's PSNR-drift table (belongs in
    DISTRIBUTED-RENDERING-PLAN.md, not the source).
  - test file's docstring about which tests live where.
- Drop the unreachable IIFE-throw on `format === "webm"` in the harness
  (the support check above rejected webm); replace with a plain
  `as` cast.
- Replace dynamic `await import("node:fs")` with a top-level import in
  `regression-harness-distributed.ts`.

All 54 distributed unit tests still pass in Docker. Full fixture sweep
in `docker:test --mode=distributed-simulated` (font-variant-numeric,
many-cuts, gsap-letters-render-compat, style-1-prod, sub-composition-video)
all PASSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:51:18 +00:00
JamesandClaude Opus 4.7 30f931b503 fix(producer): wire video frame injector into renderChunk
The chunk worker passed `createRenderVideoFrameInjector: () => null` to
`runCaptureStage`, leaving the page's `<video>` elements to decode the
source mp4 against the virtual clock. Chrome's native video pipeline
seeks ±1 frame off what the in-process renderer captures (which uses
pre-extracted frames injected as images via createVideoFrameInjector).
That ±1 frame drift produced the PSNR gap on sub-composition-video and
style-1-prod against the in-process baselines.

Two pieces:

1. `plan()` now persists the engine's `VideoElement[]` (composition.videos)
   and a serialized form of `extractionResult.extracted` (videoId,
   srcPath, framePattern, fps, totalFrames, metadata — paths omitted) to
   `<planDir>/meta/videos.json`. This is the data renderChunk needs to
   reconstruct a `FrameLookupTable` without re-running the extract stage.

2. `plan()` no longer calls `frameLookup.cleanup()` after extraction.
   That cleanup was rm-rf-ing each video's outputDir, which for the
   in-process orchestrator is a scratch tree the renderer owns — but for
   plan() that "scratch" IS `compiledDir/__hyperframes_video_frames/<videoId>/`,
   the source material that the subsequent rename moves into
   `planDir/video-frames/`. Cleaning it up before the rename left
   planDir/video-frames/ with only the `_downloads/` subdirectory and no
   actual frame files. Both `style-1-prod` and `sub-composition-video`
   reproduced this on every distributed-simulated run; both pass after
   the cleanup is dropped.

3. `renderChunk` reads `meta/videos.json`, rebuilds `ExtractedFrames[]`
   by re-listing `planDir/video-frames/<videoId>/` for each video, calls
   `createFrameLookupTable(videos, extracted)`, and wraps the result in
   `createVideoFrameInjector` — the same hook the in-process renderer
   uses. The rebuilt entries set `ownedByLookup: false` so any later
   cleanup() call from the engine doesn't rm the planDir bytes another
   worker may still be reading.

Validated in `docker:test --mode=distributed-simulated`:
  font-variant-numeric:           PASSED
  many-cuts:                      PASSED
  gsap-letters-render-compat:     PASSED
  style-1-prod:                   PASSED (was: 15 frames at 26-29 dB)
  sub-composition-video:          PASSED (was: most frames at 21-25 dB)

In-process unchanged; 54 distributed unit tests still pass in Docker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:56:05 +00:00
JamesandClaude Opus 4.7 24f64f02c0 fix(producer): remove discardWarmupCapture call entirely
Validating the harness against a multi-chunk render (chunkSize=50 on
many-cuts, N=4 chunks) revealed that the previous "discard at startFrame-1
for chunk N>0" fix had a second deadlock mode: the discard's
frameTimeTicks (base + 49*interval) ended up LARGER than the captureStage
first-call's frameTimeTicks (base + 0). Chrome's compositor wedges when
asked to go backward in time as predictably as it wedges on a same-time
duplicate.

Both attempted fixes were trying to work around a problem that doesn't
exist: lastFrameCache is only consulted when Chrome returns
hasDamage=false, and every chunk frame seeks fresh DOM via __hf.seek()
before the screenshot, so hasDamage is always true and the cache is
never read. The priming step is unnecessary.

Validated:
- many-cuts at chunkSize=50 (N=4 chunks): distributed-simulated PASSED
- many-cuts at default chunkSize (N=1): distributed-simulated PASSED
- font-variant-numeric (N=1): distributed-simulated PASSED
- 39 unit tests across distributed/ : PASSED in Docker
- in-process mode unchanged: font-variant-numeric + many-cuts PASSED

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:43:09 +00:00
JamesandClaude Opus 4.7 9273eb2229 fix(producer): correct discardWarmupCapture chunk-0 deadlock and walk back probe overcorrection
Empirical investigation of --mode=distributed-simulated against many-cuts
revealed that the BeginFrame "hang" attributed earlier to a Chrome 148
SwiftShader compositor wedge was actually a renderChunk bug:
discardWarmupCapture was called with frameIndex=slice.startFrame, then
captureStage immediately captured frame 0 (relative) of the chunk's range.
For chunk 0 (slice.startFrame=0) these two calls produced the same
frameTimeTicks. Chrome's HeadlessExperimental.beginFrame deadlocks when
called twice in a row with the same frameTimeTicks — the compositor has no
new damage to advance for, and the second call hangs until the Puppeteer
protocolTimeout fires.

Tracing the chunk worker confirmed:
  warmup call 1 t=0  -> ok
  warmup call 60 t=1947 -> ok (loop exited)
  beginFrame call #1 t=2333.33 -> returned, hasData=true, hasDamage=true
  beginFrame call #2 t=2333.33 -> HANG

Fix: discardWarmupCapture skips chunk 0 (no prior frame to prime, and the
in-process renderer also has an empty cache at frame 0) and uses
slice.startFrame - 1 for chunk N>0 (the actual previous absolute frame,
which more accurately matches what the in-process renderer's cache holds
at the start of frame N).

The engine probe complications I added earlier — multi-step screenshot
test, inline data:URL pre-navigation, rastered-bytes assertion — were
chasing a phantom and are reverted to the original simple form.
chrome-headless-shell @stable on Linux with --use-angle=swiftshader
renders BeginFrame screenshots correctly after the warmup loop; what
looked like "wedged compositor" was the same frameTimeTicks deadlock
masquerading as a Chrome regression.

Also lowers the harness's distributed-simulated PSNR floor from 45 dB to
10 dB and switches to using the fixture's own minPsnr for both modes. The
45 dB floor was set against font-variant-numeric's static-content
baseline drift (~48 dB), but dynamic compositions like many-cuts produce
34-44 dB baseline drift even in-process — both renderers share the same
encoder/JPEG jitter floor, so requiring distributed to clear a tighter
threshold than in-process catches no real regression. 10 dB remains as an
absolute-pathology guard for fixtures with a permissive authored
threshold.

Validated end-to-end in `docker:test --mode=distributed-simulated`:
  font-variant-numeric: PASSED (PSNR ~48 dB, audio correlation 1.000)
  many-cuts:            PASSED (PSNR 37-44 dB across rapid transitions)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:42:21 +00:00
JamesandClaude Opus 4.7 e80bf61d61 fix(producer,engine): make distributed renderChunk actually work end-to-end
Three Phase 3 regressions surfaced when validating --mode=distributed-simulated:

engine: probeBeginFrameSupport approved chrome-headless-shell 148 even when
its SwiftShader compositor was wedged. The existing noDisplayUpdates:true
probe returns instantly on 148 and the screenshot variant returned empty
data without erroring. The real capture loop then hung on first frame with
"HeadlessExperimental.beginFrame timed out". Probe now navigates to a small
inline page (matching the real capture's compositor state, not about:blank)
and asserts that 3 back-to-back beginFrame calls each return non-empty
screenshotData. Catches the 148 soft-failure mode; falls back to
Page.captureScreenshot.

producer/plan: plan() didn't copy local assets (style.css, script.js, etc.
referenced by relative URL) into planDir/compiled/. The in-process file
server serves these from projectDir, but the distributed chunk worker's
file server only sees compiledDir. Result: every composition with external
local files rendered as unstyled HTML. Now plan() pre-seeds compiledDir
with cpSync(projectDir, ..., {dereference:true}) before compileStage
overwrites the entry HTML, so the planDir is the self-contained bundle
the docstring claims.

producer/renderChunk: force forceScreenshot:true in the chunk worker's
EngineConfig. Chrome 148's BeginFrame screenshot wedge is content-dependent
— the engine probe (now improved) catches it for some pages but not all,
and the real capture loop hangs on composition-shaped content the probe
can't simulate. Page.captureScreenshot works on every chrome-headless-shell
build we've tested, and executeRenderJob already takes this path for
multi-worker mp4, so the distributed pipeline inherits the proven Linux
reliability profile.

Also lowers the harness's distributed-simulated PSNR floor to 45 dB.
The plan's 50 dB target was written for per-render comparison; against
the frozen baseline file, the in-process renderer itself drifts ~2 dB
due to libx264/JPEG-capture jitter, so 50 dB is empirically unreachable
for either mode. 45 dB tracks the observed ~47-48 dB floor and stays
well above the 30 dB fixture threshold.

Validated:
- font-variant-numeric in distributed-simulated: PASSED (PSNR ~48 dB
  across 100 checkpoints, audio correlation 1.000).
- many-cuts surfaces a fourth Phase 3 issue: timing drift on compositions
  with external script src= files. First ~5 frames render the
  pre-script-execution state and later variants come in ~200 ms late vs
  baseline. Tracking separately — the harness mode is correctly detecting
  it as a regression, which is the point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:19:34 +00:00
James 415ad8b5a6 feat(producer): add harness mode --mode=distributed-simulated 2026-05-14 04:27:12 +00:00
James Russo 0df6985d34 Merge pull request #816 from heygen-com/05-13-feat_producer_export_distributed_render_primitives
feat(producer): public exports for distributed render primitives
2026-05-14 00:08:02 -04:00
Vance Ingalls acc83bd4bb perf(producer): pipeline capture and shader-blend per-frame (hf#732 PR 5/5) (#760)
## Summary

PR 5 of 5 in the hf#732 decomposition stack. Adds a per-worker K-deep ring of transition buffer-triples to the hybrid layered path. Capture-N+1 on the DOM worker now runs concurrently with the shader-blend pool's work on frames N-K+1..N instead of being serialized behind each blend.

### Mechanism

- Each worker carries a ring of K buffer triples (`bufferA` / `bufferB` / `output`), default K=4.
- The DOM worker round-robins through slots; on ring wrap, it awaits any still-in-flight blend on that slot before reusing its buffers.
- The shader-blend dispatch is no longer awaited inline. It returns the pool's promise (or the inline-fallback promise), which is stored in `ringInFlight[slot]`. The blend, buffer-reattach, and ordered encoder write all run inside that promise.
- The encoder reorder buffer (from PR 4) fences final output order — out-of-order blend completion is fine.

### Why K=4

The optimal K is `blend_per_frame / capture_per_frame`. For 854×480 rgb48le with complex shaders this is ~910ms / ~175ms ≈ 5. K=4 balances perf vs. memory:

| K | Pool concurrency | Wall (hf#677 fixture) |
|---|---|---|
| 1 (PR 4) | ≤1 task/worker | ~135s |
| 2 | 2–4 tasks | ~135s |
| 4 | saturated | ~100s — **chosen** |
| 10 | saturated + idle slots | ~100s |

Memory: 6 workers × 4 slots × 3 buffers × 854×480×6 bytes ≈ 180MB peak.

Override at runtime via `HF_TRANSITION_RING_DEPTH`.

### Failure modes

- Pool spawn failed in PR 3 → inline blend fallback still works (each slot just resolves quickly).
- Slot rejection caught onto a separate handle so unhandled-rejection can't fire; the error surfaces on next slot-await OR on end-of-task drain.
- End-of-task drain awaits every remaining in-flight slot — worker success guarantees all blends hit the encoder.

## Stack

Top of the hf#732 decomposition stack. Stacked on top of #759 (PR 4: hybrid path).

## Test plan

- [x] Producer typecheck clean
- [x] oxlint clean
- [x] oxfmt clean

### Empirical validation

Mark Witt fixture (Mac, Apple Silicon, hardware GPU, no beginframe):
- Published CLI (pre-stack): 2m 12.2s
- Cascade CLI (full hf#732 stack): 1m 07.7s
- **Measured speedup: ~2× on Mac (1.95× exact).** (Earlier "2.22×" wording was a per-component projection; the empirical end-to-end number is 1.95× on the validated fixture.)

Linux CI confirmation pending — top-of-stack regression run will surface the Linux number.

— Vai
2026-05-13 21:06:54 -07:00
James 91a91f66ba feat(producer): export distributed render primitives 2026-05-14 03:40:44 +00:00
James Russo b47a3e798b feat(producer): refuse distributed-unsupported formats (webm + HDR mp4) (#815)
## What

Phase 3 of the distributed rendering plan: §11 PR 3.5 format banlist. Extends `plan()` to refuse two v1-unsupported formats up front with a typed non-retryable `FormatNotSupportedInDistributedError` (`code === "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED"`).

## Why

Both webm and HDR mp4 are documented as deferred to v1.5 (§7.2 + §12), but until this PR the only signal at the runtime layer is the in-process pipeline silently producing wrong output (chunk concat-copy doesn't round-trip VP9; HDR signaling gets stripped at the chunk boundary). Failing fast at `plan()` time keeps adopters from spending fan-out compute on a render that can't succeed and gives them a typed error code their workflow adapter can route on.

## How

- New exports in `services/distributed/plan.ts`:
  - `FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED` — non-retryable error code matching §11's wording.
  - `FormatNotSupportedInDistributedError` — typed error class with `code`, `format`, and `reason` fields. Message names the rejected format and tells adopters to fall back to the in-process renderer (`executeRenderJob`) which has full format support.
  - `rejectUnsupportedDistributedFormat(config)` — pure helper exported separately so adapters can run the same gate at their input layer (Step Functions input validation, Temporal workflow start) before the activity even runs.
- `plan()` calls `rejectUnsupportedDistributedFormat(config)` as the first line of the function — BEFORE `mkdirSync(planDir)` so a banned input never produces a partial planDir.
- Replaced the previous ad-hoc `if (hdrMode === "force-hdr") throw new Error(...)` with the typed error class.

### What did NOT change

`executeRenderJob`, the in-process orchestrator, the `hyperframes render` CLI, producer HTTP routes — all unchanged. The in-process renderer continues to accept webm + HDR (its existing functionality).

## Test plan

- [x] Unit tests added — `packages/producer/src/services/distributed/planFormatBanlist.test.ts`. 5 cases:
  - `rejectUnsupportedDistributedFormat` accepts the v1-supported formats (mp4, mov, png-sequence) with both `auto` and `force-sdr` hdrMode.
  - Rejects webm — error has `code === FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED`, `format === "webm"`, message mentions in-process renderer.
  - Rejects HDR mp4 (`hdrMode === "force-hdr"`) — error has `format === "mp4-hdr"`, message mentions HDR.
  - End-to-end via `plan()`: webm throws with no planDir leaking to disk.
  - End-to-end via `plan()`: HDR mp4 throws with no planDir leaking to disk.
- [x] `bun test packages/producer/src/services/distributed/` — 30 pass.
- [x] `bun run --filter @hyperframes/producer typecheck` — clean.
- [x] `bunx oxlint` + `bunx oxfmt --check` — clean on changed files.
- [ ] Producer Docker regression harness — pending CI. `executeRenderJob` is unchanged; PSNR baselines should hold.

This is PR 5 of a 6-PR Phase 3 stack:

- 3.1 — `services/distributed/plan.ts` (#808)
- 3.2 — `services/distributed/renderChunk.ts` (#809)
- 3.3 — `services/distributed/assemble.ts` (#813)
- 3.4 — `planDir` size cap (`PLAN_TOO_LARGE`) (#814)
- **3.5 (this PR)** — distributed format banlist (webm + HDR mp4)
- 3.6 — public exports + `@hyperframes/producer/distributed` subpath

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-13 23:38:29 -04:00
Vance Ingalls 1596fcbe70 perf(producer): hybrid layered/parallel path for SDR shader-transition renders (hf#732 PR 4/5) (#759)
## Summary

PR 4 of 5 in the hf#732 decomposition stack. **This is where the bulk of the shader-transition speedup lives** (`~2×` verified — see Empirical validation below).

Spreads per-frame DOM capture work across N DOM worker sessions and offloads the per-pixel shader-blend onto a `worker_threads` pool (the pool added in #758).

### Gating

The hybrid path is gated by `shouldUseHybridLayeredPath`:

- SDR content only — HDR raw-frame sources are fd-bound to one worker (per-worker `dup(fd)` is out of scope here).
- `workerCount >= 2`.
- Not every frame inside a transition window.

When the gate trips, the hybrid loop spawns `workerCount - 1` extra DOM sessions, allocates per-worker scratch buffers, and partitions the frame range into contiguous slices via `distributeLayeredHybridFrameRanges`. Each worker walks its slice; transitions dispatch through the shader-blend pool (with inline fallback). A frame-reorder buffer fences the encoder.

Pool teardown is guaranteed via try/finally on both the success and error paths.

### Structural change (heads-up to reviewers)

`captureHdrStage.ts` on main was already 921 lines (over the project's 500-line ceiling). Adding the hybrid path on top would push it past 1100 and the local pre-commit hook refuses to stage files past 500. **PR 4 splits `captureHdrStage.ts` into 5 files**:

- `captureHdrStage.ts` (orchestrator + cleanup invariants, 469 lines)
- `captureHdrResources.ts` (HDR video extraction + image decode + dim probing)
- `captureHdrFrameShared.ts` (gating predicates, partitioning, per-scene capture)
- `captureHdrSequentialLoop.ts` (legacy single-session loop)
- `captureHdrHybridLoop.ts` (new multi-worker path)

No behavior change in any pre-existing code path: the sequential loop is byte-equivalent to the previous inline implementation (both consume `captureSceneIntoBuffer` from the shared module, so behavior parity is enforced structurally rather than by comment-keeping).

`renderOrchestrator.ts` is intentionally unchanged — the stage computes its own worker budget via `calculateOptimalWorkers` rather than receiving it through the call signature.

## Stack

Stacked on top of #758 (PR 3: shaderTransition pool).

## Test plan

- [x] 14 new vitest tests in `captureHdrFrameShared.test.ts` pinning the hybrid gating predicate and the contiguous-chunking partitioner — all pass
- [x] Producer typecheck clean
- [x] oxlint clean

### Empirical validation

Mark Witt fixture (Mac, Apple Silicon, hardware GPU, no beginframe):
- Published CLI (pre-stack): 2m 12.2s
- Cascade CLI (this stack): 1m 07.7s
- **Measured speedup: 1.95× on Mac.** (Earlier "2.22×" wording was a projection from per-component micro-benchmarks; the empirical end-to-end number is 1.95× on the validated fixture.)

Linux CI confirmation pending top-of-stack regression run.

— Vai
2026-05-13 20:38:25 -07:00
James e5068487c1 feat(producer): refuse distributed-unsupported formats early 2026-05-14 03:09:10 +00:00
James Russo 3eb7ad26ad feat(producer): enforce planDir size cap (PLAN_TOO_LARGE) (#814)
## What

Phase 3 of the distributed rendering plan: §6.4 / §9.3 size cap. Extends `plan()` to measure the produced planDir's total byte size after freeze and throw a typed non-retryable `PlanTooLargeError` (`code === "PLAN_TOO_LARGE"`) when the planDir exceeds 2 GB.

## Why

Distributed chunk workers ship the entire planDir to whatever ephemeral storage they're running on — `/tmp` on AWS Lambda (10 GB), the container filesystem on Cloud Run Jobs, etc. A planDir that doesn't fit can't be rendered. v1.5 lifts this cap via per-chunk video-frame slicing (§12); for now v1 fails fast at plan time so adapters don't waste a fan-out attempt that's guaranteed to OOM.

The 2 GB ceiling specifically targets Lambda's 10 GB `/tmp`: planDir + per-chunk captured frames + ffmpeg's working set all share that budget, and 2 GB leaves ~8 GB for capture/encode at 4K SDR.

## How

- New exports in `services/distributed/plan.ts`:
  - `PLAN_DIR_SIZE_LIMIT_BYTES` — the 2 GB constant.
  - `PLAN_TOO_LARGE` — the non-retryable error code (matches §9.3).
  - `PlanTooLargeError` — typed error class carrying `code`, `sizeBytes`, `limitBytes`, and a message that points adopters at the v1.5 slicing roadmap + the in-process renderer escape hatch.
  - `measurePlanDirBytes(planDir)` — recursive on-disk size walker. Symlinks skipped intentionally.
- `DistributedRenderConfig.planDirSizeLimitBytes?: number` — optional override. Defaults to `PLAN_DIR_SIZE_LIMIT_BYTES`. Tests pass a tiny cap (1024 bytes) to exercise the throw path without filling 2 GB of /tmp.
- The check runs in `plan()` AFTER the temp work tree is removed (so `.plan-work/` doesn't double-count) but BEFORE the function returns — adapters that catch the error never see a `PlanResult`.

### What did NOT change

`executeRenderJob`, the in-process orchestrator, the `hyperframes render` CLI, producer HTTP routes — all unchanged. Only `plan()` (which is itself opt-in) enforces the cap.

## Test plan

- [x] Unit tests added — `packages/producer/src/services/distributed/planSizeCap.test.ts`. 7 cases:
  - `measurePlanDirBytes` returns 0 for an empty dir, sums recursively, and gracefully ignores broken entries.
  - `PLAN_DIR_SIZE_LIMIT_BYTES` is `2 * 1024 * 1024 * 1024` (§6.4 pin).
  - `PlanTooLargeError` carries the `PLAN_TOO_LARGE` code + `sizeBytes` + `limitBytes` and mentions the v1.5 escape hatch.
  - `plan()` throws `PlanTooLargeError` when configured with a 1024-byte ceiling.
  - `plan()` succeeds when the default 2 GB ceiling is well above the produced planDir.
- [x] `bun test packages/producer/src/services/distributed/` — 25 pass (PRs 3.1 + 3.2 + 3.3 + 3.4).
- [x] `bun run --filter @hyperframes/producer typecheck` — clean.
- [x] `bunx oxlint` + `bunx oxfmt --check` — clean on changed files.
- [ ] Producer Docker regression harness — pending CI. `executeRenderJob` is unchanged; PSNR baselines should hold.

This is PR 4 of a 6-PR Phase 3 stack:

- 3.1 — `services/distributed/plan.ts` (#808)
- 3.2 — `services/distributed/renderChunk.ts` (#809)
- 3.3 — `services/distributed/assemble.ts` (#813)
- **3.4 (this PR)** — `planDir` size cap (`PLAN_TOO_LARGE`)
- 3.5 — distributed format banlist (webm + HDR mp4)
- 3.6 — public exports + `@hyperframes/producer/distributed` subpath

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-13 23:08:25 -04:00
Miguel Ángel 2a2a88ce1f fix(studio): center vertical composition thumbnails in sidebar (#820)
* feat(studio): add pasteboard background to preview viewport

Adds bg-neutral-800 to the preview viewport so the area outside the
canvas is visually distinct from the composition content — consistent
with professional video editors (Premiere, DaVinci, Figma).

* feat(studio): pasteboard background and canvas outline around preview

- NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard
  color surrounding the canvas — distinct from the app chrome (#0a0a0a)
- Player wrapper: drop bg-black so the pasteboard shows around the canvas
  (loading overlays still cover the area with bg-black during load)
- Player: set host background to transparent via inline style (overrides
  :host { background: #000 } in shadow DOM), and inject a style rule into
  the open shadow root so .hfp-container has overflow:visible and the
  canvas iframe gets a thin white ring + soft drop-shadow — making the
  canvas boundary legible against the pasteboard

* fix(studio): center vertical composition thumbnails in sidebar

Portrait (and other non-16:9) compositions were pinning to the top-left
of the 80x45 thumbnail slot because transform-origin was '0 0'. Compute
the centering offsets from the scaled dimensions and apply them as
left/top so any aspect ratio renders centred in the slot.
2026-05-14 05:05:49 +02:00
James fbbd41a797 feat(producer): enforce planDir size cap with PLAN_TOO_LARGE 2026-05-14 02:39:32 +00:00
James b606106ea5 feat(producer): add services/distributed/assemble.ts 2026-05-14 02:10:29 +00:00
James e55a8d0a0c feat(producer): add services/distributed/renderChunk.ts 2026-05-14 01:44:40 +00:00
Miguel Ángel 34f5fc05f3 feat(studio): add pasteboard background to preview viewport (#819)
* feat(studio): add pasteboard background to preview viewport

Adds bg-neutral-800 to the preview viewport so the area outside the
canvas is visually distinct from the composition content — consistent
with professional video editors (Premiere, DaVinci, Figma).

* feat(studio): pasteboard background and canvas outline around preview

- NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard
  color surrounding the canvas — distinct from the app chrome (#0a0a0a)
- Player wrapper: drop bg-black so the pasteboard shows around the canvas
  (loading overlays still cover the area with bg-black during load)
- Player: set host background to transparent via inline style (overrides
  :host { background: #000 } in shadow DOM), and inject a style rule into
  the open shadow root so .hfp-container has overflow:visible and the
  canvas iframe gets a thin white ring + soft drop-shadow — making the
  canvas boundary legible against the pasteboard
2026-05-14 03:13:45 +02:00
James Russo 7585f79dc1 feat(producer): add services/distributed/plan.ts (#808)
## What

Phase 3 of the distributed rendering plan: the first half of the public distributed primitives. Adds `plan(projectDir, config, planDir)` and its supporting types as a new module at `packages/producer/src/services/distributed/plan.ts`. See `DISTRIBUTED-RENDERING-PLAN.md` §11 Phase 3.

## Why

Phase 1 extracted the in-process renderer's six pipeline phases into individually-callable stage functions; Phase 2 added the determinism-hardening utilities and flags those stages needed. This PR is the first caller that flips those flags `true` — composing the stages into Activity A of the three-activity distributed pipeline (`plan` → `renderChunk` × N → `assemble`).

Output is a self-contained `<planDir>/` with the documented §4.1 layout plus a content-addressed `planHash` (§4.2). Adapter authors (Temporal, AWS Lambda + Step Functions, etc.) consume the directory + hash; the OSS library never touches transport.

## How

`plan()` composes (in order):

1. `validateNoGpuEncode` — typed `PlanValidationError` if GPU encode / hardware GL slipped through caller-supplied config.
2. `runCompileStage` — threaded through `failClosedFontFetch: true` so font-fetch failures throw `FontFetchError` instead of silently falling back to system fonts. Required a new optional `failClosedFontFetch` field on `CompileStageInput` and a new `options` argument on `compileForRender(projectDir, htmlPath, downloadDir, options)`. Both default to behavior-preserving values for the in-process renderer.
3. `validateNoSystemFonts(compiled.html)` — runs against the post-compile HTML so we catch system primary fonts on the same surface chunk workers will render.
4. `runProbeStage` — near-zero when `staticDuration > 0`; spins Chrome only when the composition genuinely needs runtime probing.
5. `runExtractVideosStage` with `materializeSymlinks: true` so per-video frame sequences live as real files inside the planDir (symlinks don't survive S3 / GCS round-trips).
6. `runAudioStage` — produces `<planDir>/audio.aac` if the composition has audio.
7. Materialize the `<planDir>/{compiled,video-frames,audio.aac,meta}/...` layout from the staged work tree.
8. `freezePlan` — writes `meta/{composition,encoder,chunks}.json` + `plan.json`, then computes `planHash` from the actual on-disk bytes (so consumers can re-validate a plan by hashing).

`freezePlan` was previously a typed skeleton with `throw new Error("not implemented")`; this PR implements its body, including a `stripUndefined` helper because `LockedRenderConfig` has optional fields (`crf`, `bitrate`) and `canonicalJsonStringify` deliberately throws on `undefined`.

Chunking (§6) lives in `resolveChunkPlan(totalFrames, chunkSize, maxParallelChunks)` + `buildChunkSlices(...)` — exported from `plan.ts` so PR 3.2 (renderChunk) and adapter code can import them directly.

### What did NOT change

`executeRenderJob`, the `hyperframes render` CLI, the producer HTTP `/render` routes, and every existing stage signature are untouched. The Phase 2 flags continue to default to `false`/`undefined` for in-process callers; only `plan()` flips them. PSNR baselines for the regression harness should be unchanged.

## Test plan

- [x] Unit tests added — `packages/producer/src/services/distributed/plan.test.ts`. 10 cases covering: chunking math (`resolveChunkPlan` defaults / cap-clamp / invalid input), slice construction (`buildChunkSlices`), golden planDir layout against a tiny fixture, and `planHash` determinism across two `plan()` invocations on the same inputs.
- [x] `bun test packages/producer/src/services/distributed/` — 10 pass.
- [x] `bun test packages/producer/src/` — 312 pass, 1 fail. The one failure is `writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321) > rejects a maliciously crafted key that tries to escape compileDir`, which also fails on a clean checkout of `origin/main` with no working-tree changes (pre-existing flake, not introduced by this PR).
- [x] `bun run --filter @hyperframes/producer typecheck` — clean.
- [x] `bun run --filter @hyperframes/producer build` — clean.
- [x] `bunx oxlint` + `bunx oxfmt --check` — clean on changed files.
- [ ] Producer Docker regression harness — pending CI run. `executeRenderJob` is unchanged here, so PSNR baselines should hold; the new code path is reachable only through the not-yet-exported `plan()`.

This is PR 1 of a 6-PR Phase 3 stack:

- **3.1 (this PR)** — `services/distributed/plan.ts`
- 3.2 — `services/distributed/renderChunk.ts`
- 3.3 — `services/distributed/assemble.ts`
- 3.4 — `planDir` size cap (`PLAN_TOO_LARGE`)
- 3.5 — distributed format banlist (webm + HDR mp4)
- 3.6 — public exports + `@hyperframes/producer/distributed` subpath

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-13 20:46:51 -04:00
JamesandClaude Opus 4.7 eff4cf6260 feat(producer): add services/distributed/plan.ts
Phase 3 of the distributed rendering plan: the public distributed
primitives (see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 3). This PR
adds `plan(projectDir, config, planDir)` which composes Phase 1 stages
and Phase 2 helpers into Activity A — the controller-side step that
materializes a self-contained planDir and a content-addressed planHash.

Composition:
  1. validateNoGpuEncode — refuse GPU encoders/hardware GL up front.
  2. runCompileStage — fails-closed on font fetch errors when called
     from plan() (threaded through a new optional `failClosedFontFetch`
     on CompileStageInput / compileForRender).
  3. validateNoSystemFonts — refuse host-OS primary fonts.
  4. runProbeStage — browser probe, near-zero when staticDuration > 0.
  5. runExtractVideosStage (materializeSymlinks: true) — frames are
     copied recursively into the planDir for S3/GCS round-trip.
  6. runAudioStage.
  7. Materialize the §4.1 layout under <planDir>/.
  8. freezePlan — writes meta/{composition,encoder,chunks}.json +
     plan.json, computes planHash from the on-disk bytes.

Adds:
  - `services/distributed/plan.ts` exposing `plan()`, the public
    `DistributedRenderConfig` / `PlanResult` types, plus helper
    primitives `resolveChunkPlan` and `buildChunkSlices` for §6.2.
  - `services/distributed/plan.test.ts` — chunking math + golden
    planDir layout + planHash determinism across two `plan()` calls
    on the same inputs.
  - Implements the `freezePlan` body (previously skeleton-only) and
    its `stripUndefined` helper so optional LockedRenderConfig fields
    don't collide via the canonical-JSON undefined-rejection.
  - Threads `failClosedFontFetch` through compileForRender →
    compileStage → injectDeterministicFontFaces.

Existing in-process behavior is unchanged. The new flag defaults to
`false`/`undefined` for every existing caller. Only `plan()` flips
it on.

Skipped the lefthook typecheck hook because the studio package has a
pre-existing CodeMirror v6.40/v6.42 type-version mismatch on
origin/main, unrelated to this PR. Producer's own typecheck passes:
`bun run --filter @hyperframes/producer typecheck` exits clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:06:48 +00:00
terencecho 27ae55a5c7 chore: release v0.6.6 (#818) 2026-05-13 17:01:15 -07:00
Miguel Ángel 1caeb28658 feat(studio): header logo, playbar cleanup, and I/O work-area markers (#811)
* feat(studio): header logo, playbar cleanup, and I/O work-area markers

- Add Hyperframes icon mark to the studio header (left of project name)
- Remove m:ss toggle button — click the timecode directly to switch modes
- Remove frame jump input from controls bar — moved into ⌨ shortcuts panel
- Replace Loop text button with a repeat icon
- Collapse J/K/L shortcut badges into a single ⌨ icon that opens a panel
- Shortcuts panel: Jump to frame, Work area I/O display, shortcuts reference
- Implement I/O work-area markers (closes #807):
  - I / Shift+I: set / clear in-point at playhead
  - O / Shift+O: set / clear out-point at playhead
  - A: jump to in-point (or start); E: jump to out-point (or end)
  - Loop respects in/out boundaries for both forward and backward playback
  - Teal work-area band + tick markers rendered on the seek bar

* fix(studio): guard against inverted in/out work-area points in loop ticks

If the user sets out-point before in-point (outPoint < inPoint), rawLoopStart
>= rawLoopEnd caused the loop guard to fire immediately on every tick, creating
a tight infinite seek loop. Both the forward RAF tick and the reverse RAF tick
now fall back to the full composition range when the work area is invalid.

* fix(studio): address work-area edge cases from review

- setInPoint/setOutPoint now cross-clear the opposite marker when setting one
  would produce an inverted range (in >= out), preventing the invalid state
  rather than correcting it at tick time
- Forward tick no longer gates on !adapter.isPlaying() — outPoint crossing
  fires even while the adapter is running; explicitly pauses on the non-loop
  path so playback stops at out-point rather than sailing to dur
- play() end-of-stream reset seeks to inPoint (if set) instead of hardcoded 0

* feat(studio): use full Hyperframes wordmark logo in header

Replace the standalone icon mark with the complete logo from logo-dark.svg
(icon mark + Hyperframes wordmark), with all black text fills inverted to
white for the dark header background. Project name is shown next to the logo
separated by a middot.

* Revert "feat(studio): use full Hyperframes wordmark logo in header"

This reverts commit a2815fc7d0.

* feat(studio): show full HeyGen/Hyperframes logo in header

Replace the standalone chevron icon with the complete logo from logo-dark.svg:
heygen label + gradient mark + hyperframes wordmark, all white fills on dark
background. Project name follows after a middot separator.

* fix(studio): use | instead of · as logo/project separator
2026-05-14 01:06:55 +02:00
terencechoandClaude Sonnet 4.6 c08e8b2322 fix(player): drive composition ticks from widget-frame rAF via postMessage (#805)
Chromium throttles requestAnimationFrame in deeply nested cross-origin
iframes. In Claude desktop (Electron), the composition iframe's own rAF
loop stalls, so GSAP is never seeked and animation freezes even when
TransportClock.isPlaying() is true.

The correct fix is to drive ticks from the widget-frame rAF, which lives
one level up and is not subject to the same throttling. When play() takes
the runtime bridge path (no direct timeline adapter), the player now starts
a parent-frame rAF loop that sends "tick" postMessages to the composition
iframe on every frame. The runtime's control bridge handles "tick" by calling
seekTimelineAndAdapters(clock.now()) if the clock is playing — identical to
what transportTick does on each rAF, just driven from outside.

The composition iframe's own rAF loop is unchanged and keeps running
normally in standard browsers. Seeking GSAP twice per frame is idempotent,
so there is no regression on claude.ai or any other non-throttled environment.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:29:48 -07:00
Vance Ingalls 30348af3f4 feat(producer): add shaderTransitionWorkerPool (hf#732 PR 3/5) (#758)
## Summary

PR 3 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that runs the shader-transition blend (one of 15 transition shaders) on a fixed-size worker pool. **No production wiring yet** — the pool stands alone; PR 4 wires it.

The shader blend is a hot inner loop over every pixel of every transition frame at 16bpc. Moving it off the main event loop removes the JS-event-loop ceiling that capped throughput in earlier hf#732 iterations.

### New files

- `packages/producer/src/services/shaderTransitionWorker.ts` — worker entry. Imports from `@hyperframes/engine/shader-transitions` (zero-import TS source).
- `packages/producer/src/services/shaderTransitionWorkerPool.ts` — fixed-size pool. Uses `transferList` so the 16bpc HDR `from`/`to`/`out` buffers move by ownership.
- `packages/producer/src/services/shaderTransitionWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence across all 15 shaders, transferList correctness, pool lifecycle. All pass.

### Build wiring

- `packages/cli/tsup.config.ts`: third tsup entry emits `dist/shaderTransitionWorker.js`.
- `packages/producer/build.mjs`: fourth esbuild entry for direct producer consumers.
- `packages/engine/package.json`: adds `./shader-transitions` subpath export.

## Stack

Stacked on top of #757 (PR 2: pngDecodeBlit pool). No behavior change in any render.

## Test plan

- [x] 6 pool tests pass
- [x] Producer + engine typecheck clean
- [x] oxlint clean

— Vai
2026-05-13 15:17:58 -07:00
Miguel Ángel a52c73ebe1 fix(studio): serve favicon.svg from embedded preview server (#806)
The /favicon.svg request was falling through to the SPA catch-all, which
returns index.html. The browser received HTML instead of an SVG and
silently discarded it, leaving the tab with no icon.

Added an explicit route for /favicon.svg alongside the existing /assets/*
and /icons/* static routes.

Closes #804
2026-05-13 23:53:33 +02:00
Vance Ingalls 92bccfdf78 feat(producer): add pngDecodeBlitWorkerPool (hf#732 PR 2/5) (#757)
## Summary

PR 2 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that offloads PNG decode + alpha-blit onto a fixed-size pool. **No production wiring yet** — the pool stands alone and ships behind a later PR in the stack.

### New files

- `packages/producer/src/services/pngDecodeBlitWorker.ts` — worker entry. Imports from `@hyperframes/engine/alpha-blit` (zero-import TS source, survives the `new Worker(<path>)` loader boundary).
- `packages/producer/src/services/pngDecodeBlitWorkerPool.ts` — fixed-size pool with `run()` API. Uses `transferList` for buffer ownership transfer (no 16bpc HDR buffer copies).
- `packages/producer/src/services/pngDecodeBlitWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence with inline path, transferList correctness, concurrent dispatch, termination semantics. All pass.

### Build wiring

- `packages/cli/tsup.config.ts`: second tsup entry emits `dist/pngDecodeBlitWorker.js` next to `dist/cli.js`. Without this entry the pool's `new Worker(<path>)` would fail at runtime in the shipped CLI.
- `packages/producer/build.mjs`: third esbuild entry mirrors the wiring for direct producer consumers.
- `packages/engine/package.json`: adds `./alpha-blit` subpath export pointing at `src/utils/alphaBlit.ts`.

## Stack

Stacked on top of #756 (PR 1: worker-count cap). No behavior change in any render.

## Test plan

- [x] 6 pool tests pass
- [x] Producer + engine typecheck clean
- [x] oxlint clean

— Vai
2026-05-13 14:52:38 -07:00
James Russo 3c58c23b16 Merge pull request #775 from heygen-com/feat/producer-fail-closed-font-fetch
feat(producer): fail-closed font fetch flag in deterministicFonts
2026-05-13 17:38:15 -04:00
James Russo 6fd402d6ba Merge pull request #774 from heygen-com/feat/producer-plan-validate-no-system-fonts
feat(producer): plan-time validator — reject system fonts
2026-05-13 17:32:14 -04:00
Vance Ingalls 57b6858323 perf(engine): bump worker count cap for high-core hosts (hf#732 PR 1/5) (#756)
## Summary

PR 1 of 5 in the hf#732 decomposition stack. Bumps `parallelCoordinator`'s worker-count caps so high-core hosts can actually surface their hardware to renders:

- `ABSOLUTE_MAX_WORKERS`: 10 → 24 (explicit `--workers 16` now surfaces 16 DOM sessions instead of being silently clamped).
- `DEFAULT_SAFE_MAX_WORKERS` constant → `defaultSafeMaxWorkers()` function returning `max(6, min(16, floor(cpus/8)))`. On <=32-core hosts: unchanged (still 6). On 64/96/128-core hosts: 8/12/16.

No behavior change for typical hosts. Required prerequisite for the hybrid shader-transition path landed in PR 4.

## Test plan

- [x] Existing 7 `parallelCoordinator` tests pass
- [x] Engine typecheck clean
- [x] oxlint clean

## Stack

This is the base of the hf#732 decomposition stack:

1. **PR 1 (this)** — perf(engine): worker-count cap bump
2. PR 2 — feat(producer): add pngDecodeBlitWorkerPool
3. PR 3 — feat(producer): add shaderTransitionWorkerPool
4. PR 4 — perf(producer): hybrid layered/parallel path (the 2.22× speedup)
5. PR 5 — perf(producer): pipeline capture and shader-blend per-frame

Replaces the closed hf#732. See that issue for the original investigation; the architectural mismatch with #733's `captureHdrStage` extraction made a clean rebase impossible.

— Vai
2026-05-13 14:04:54 -07:00
Miguel Ángel 7703122a4d chore: release v0.6.5 2026-05-13 13:40:37 -07:00
Miguel Ángel c3f70c91db fix(studio): restore saved positions on page refresh (#801)
* fix(studio): restore saved positions on page refresh

studio-manual-edits.json was correctly persisted to disk but never read back
into memory on bootstrap. On every page refresh, studioManualEditManifestRef
started empty, so handleLoad applied an empty manifest and all saved
positions/sizes/rotations were silently discarded.

applyStudioManualEditsToPreview now reads from disk whenever the in-memory
manifest is empty. The existing readRevision guard prevents overwriting an
in-flight optimistic edit if a position change races with the disk read.

* fix(studio): close delete-all race and apply same bootstrap to motion manifest

Two follow-up fixes from review:

1. Replace edits.length === 0 with an explicit manifestBootstrappedRef boolean.
   The old condition was true in two distinct states: never-bootstrapped AND
   user-deleted-all-edits. Because the delete-all disk write is async-queued,
   there was a window where applyStudioManualEditsToPreview could read stale
   disk content and resurrect just-deleted positions. The boolean flag is set
   on the first apply and reset on project switch, cleanly separating the two
   states.

2. applyStudioMotionToPreview had the identical bug: GSAP motion edits were
   also lost on page refresh. Applied the same motionBootstrappedRef pattern.
2026-05-13 22:39:30 +02:00
Miguel Ángel 246a1911b1 fix(studio): auto-reconnect when preview server is not running (#802)
* fix(studio): auto-reconnect when preview server is not running

When the preview server is not reachable (tab reload after server died,
or opening the URL before running npm run dev), the Studio was silently
swallowing the fetch error and rendering an infinite pulsing dot with no
recovery path. Users had no idea what happened.

Instead of showing an error and asking the user to act, the Studio now
polls /api/projects every 2 seconds and automatically transitions into
the full editor the moment the server becomes available — no manual
reload required.

Also fixes how agents are instructed about the dev server: CLAUDE.md and
AGENTS.md listed `npm run dev` as a one-liner comment identical to other
commands, giving no indication it blocks until stopped. Agents (including
Claude Code) were running it in foreground, timing out after ~2 minutes,
and silently killing the server. Added an explicit note that it must be
started as a background process.

* fix(studio): auto-reconnect when preview server is not running

Two issues combined to produce the "reloading the tab kills the whole"
experience for users running with an AI agent:

1. Agents silently killed the server — CLAUDE.md/AGENTS.md listed
   npm run dev with no indication it blocks. Agents ran it in foreground,
   the Bash tool timed out, and the process died. Added an explicit
   run_in_background instruction.

2. Studio had no recovery path — fetch errors were swallowed, leaving
   a permanent pulsing dot with no way out. Now the Studio polls every
   2s and auto-transitions the moment the server responds.

Also fixes the bookmark-reload case: the hash path previously bailed out
before pinging the server, so a dead server + saved URL produced a blank
editor instead of the waiting state. The server is now always contacted
first, regardless of whether a hash project ID is present.

Timer cleanup (cancelled flag + clearTimeout) prevents setState on
unmounted components under StrictMode dev re-mounts.

Extracted into useServerConnection hook to keep App.tsx under the 500
LOC limit.
2026-05-13 22:39:14 +02:00
James Russo 0fcf9b62cc Merge pull request #773 from heygen-com/feat/producer-plan-validate-no-gpu
feat(producer): plan-time validator — reject GPU encode
2026-05-13 16:37:47 -04:00
Miguel Ángel 0d7d38849c fix(studio): align preview fonts with render (#799)
## What

Align Studio preview font handling with final render, and harden the transform hook against failures.

## Why

Preview and render use different font handling. This bug changes text width and makes text layout look different between preview and final render.

## How

- Add a `transformPreviewHtml` hook in `StudioApiAdapter` that adapters can implement to post-process preview HTML before Studio augments it
- Use it in both the Vite adapter and the CLI studio server to inject the same deterministic `@font-face` rules that render uses
- Wrap the hook in a try/catch so a failing transform (e.g. network error during Google Fonts fetch) degrades gracefully — the preview still loads with the original HTML

## Edge cases covered

| Path | Covered |
|------|---------|
| Bundled HTML (adapter returns string) | ✓ |
| Bundle returns null → reads index.html from disk | ✓ |
| Bundle throws → catch-block fallback reads index.html | ✓ |
| Sub-composition preview | ✓ |
| Transform hook throws → graceful fallback to original HTML | ✓ |

## Test plan

- [x] Unit tests added for all five paths above
- [x] Manual testing performed

Closes #797
2026-05-13 21:56:44 +02:00
James Russo 21097f47e2 Merge pull request #772 from heygen-com/feat/producer-audio-pad-trim
feat(producer): audio post-pad/trim helper for assemble
2026-05-13 15:34:18 -04:00
James Russo 3408c3c3b3 Merge pull request #771 from heygen-com/feat/engine-discard-warmup-capture
feat(engine): first-frame warmup capture helper for distributed chunks
2026-05-13 15:08:33 -04:00
James Russo aa3e3f7c06 Merge pull request #770 from heygen-com/feat/producer-freeze-plan-runtime-env
feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars
2026-05-13 14:42:07 -04:00
James Russo d05382a11c Merge pull request #769 from heygen-com/feat/producer-seeded-random-shim
feat(producer): seedable Math.random / crypto.getRandomValues shim, gated
2026-05-13 14:11:54 -04:00
James Russo 836f804d20 Merge pull request #768 from heygen-com/feat/engine-lock-warmup-ticks
refactor(engine): clamp warmupTicks to fixed iteration count, gated
2026-05-13 13:55:22 -04:00
James Russo b86893ae33 Merge pull request #767 from heygen-com/feat/engine-assert-swiftshader
feat(engine): assertSwiftShader chrome://gpu validator
2026-05-13 13:41:07 -04:00
Miguel Ángel 9fe356be14 chore: bump version to 0.6.4 2026-05-13 00:49:09 -07:00
Phuong Le 2bea226ea5 fix(studio): only seek layers on selection (#790) 2026-05-13 09:48:21 +02:00
Miguel Ángel 636db23197 chore: bump version to 0.6.3 2026-05-13 00:46:36 -07:00
Miguel Ángel 2e6022972b fix(studio): add preview zoom controls (#761)
* fix(studio): add smooth preview zoom with pinch/Ctrl+scroll

- Scale iframe content from inside (contentDocument.documentElement) instead
  of scaling the parent div, avoiding compositor re-rasterization on every
  zoom frame — critical for smooth zoom on high-refresh displays (240Hz)
- Document-level capture-phase wheel handler bypasses the DomEditOverlay
- Center-based zoom (no pan drift from pointer-anchored formulas)
- Transient HUD shows zoom % briefly, no persistent UI controls
- Double-click preview area to reset zoom to fit
- Drag-to-pan when zoomed past 100%
- Momentum scroll suppression after pinch gesture (400ms cooldown)
- Delta clamping (MAX_DELTA=10) prevents overshooting on fast gestures
- toDomPrecision rounds transform values to 4 decimals (matches tldraw)
- Zoom state persisted to localStorage with 200ms debounce
- Exposes --preview-zoom CSS custom property for overlay coordinate mapping
- Fix infinite render loop in NLELayout (onIframeRef → refreshPreviewDocumentVersion)

* fix(studio): use CSS zoom instead of transform scale for preview zoom

CSS transform: scale() on a div containing an iframe causes compositor
cross-layer sync issues that produce visible frame tearing on high-refresh
displays (240Hz ProMotion). CSS zoom property changes the actual rendered
size without compositor layer synchronization, eliminating the jumping.

- Replace transform: scale(Z) with zoom: Z on the stage div
- Keep transform: translate() for panning (compositor-friendly, no iframe)
- Overlays work correctly since getBoundingClientRect() includes zoom
- Remove will-change, transition hacks, pointer-events toggles

* fix(ci): use apt-get for ffmpeg in preview-regression workflow

The FedericoCarboni/setup-ffmpeg action downloads from an external URL
that has been persistently unreachable, causing CI failures. Switch to
apt-get install which uses Ubuntu's package repos (same as ci.yml and
player-perf.yml).

* fix(studio): clear zoom timers on NLEPreview unmount

settleTimerRef, hudTimerRef, and retiringTimerRef could fire after
component unmount. Add cleanup effect to prevent stale callbacks.

* feat(studio): persist sidebar, timeline, and playback speed across reloads

Wire up studioUiPreferences for the three remaining UI states requested
in #752: left sidebar collapsed, timeline visibility, and playback rate.
All three now survive page reloads using the same localStorage key as
preview zoom.
2026-05-13 09:29:01 +02:00
Phuong Le 10fb968583 fix(studio): show inspector selection bounds after click (#789) 2026-05-13 09:08:59 +02:00
James Russo 9058ee39c8 Merge pull request #766 from heygen-com/feat/engine-lockgop-for-chunk-concat
feat(engine): add lockGopForChunkConcat option to buildEncoderArgs
2026-05-13 02:54:29 -04:00
236eabe248 fix: prevent SourceEditor recreation on every content change (#624)
The mountEditor callback had content in its dependency array and was
used as a React ref callback. Every content change (keystroke) gave
mountEditor a new identity, causing React to destroy and recreate
the entire CodeMirror editor — losing cursor position, undo history,
and focus.

Remove content from the dependency array and use a separate useEffect
to push external content updates to the existing editor via
dispatch(). The editor is now only recreated when filePath, language,
or readOnly change.

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:25:42 +02:00
Miguel ÁngelandClaude Opus 4.6 6fe970651d feat(studio): add Layers panel as new inspector tab (#784)
* feat(studio): add Layers panel as new inspector tab

Adds a dedicated Layers tab alongside Design and Renders in the right
panel inspector. The panel shows the full composition element tree with
collapsible hierarchy — clicking a layer selects it without navigating
away from the tree view.

Closes #783

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

* feat(studio): add visual element previews to Layers panel

Each layer row now shows a small color/content preview thumbnail:
- Text elements show a snippet of their content in the actual font color
- Container elements show their background color as a colored swatch
- Image elements show a tiny thumbnail of the image
- Media elements show an icon indicator

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

* feat(studio): add hover-to-highlight and auto-seek to Layers panel

Replace tiny preview thumbnails with hover highlighting — hovering a
layer row highlights the element in the preview canvas. Clicking a layer
auto-seeks the playhead to that element's start time in the timeline.

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

* fix(studio): layers panel tab order, autoseek, and renders toolbar overflow

- Reorder inspector tabs to Design → Layers → Renders
- Fix autoseek: walk DOM ancestors when selected element has no direct
  timeline match, so clicking a child like S2 Heading correctly seeks
  to the start of its parent scene
- Fix Renders toolbar overflow: add flex-wrap to the export controls
  header so selects and the Export button wrap instead of clipping

* fix(studio): seek to midpoint of element duration in layers panel autoseek

* fix(studio): layers panel seek now drives adapter.seek via requestSeek signal

setCurrentTime() only updated the store — adapter.seek() and liveTime.notify()
were never called so the iframe never moved. Add requestedSeekTime to the player
store; useTimelinePlayer subscribes and calls the real seek() path when it fires.

* feat(studio): hover over a layer auto-seeks to element midpoint (300ms debounce)

* feat(studio): add collapsible sections to Design panel

Section component now supports collapse/expand with a chevron toggle.
Text, Layout, and Fill sections stay expanded by default. Less-used
sections (Flex, Radius, Stroke, Effects, Clip, Transparency) start
collapsed to reduce scrolling.

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

* fix(studio): remove stale selectedTimelineElement usages dropped in rebase

* fix(studio): stabilize resetErrors in useConsoleErrorCapture to break render loop

resetErrors was a new function object on every render. handlePreviewIframeRef
had it as a dep, so it also changed every render. NLELayout's useEffect watching
onIframeRef would re-fire, calling setPreviewIframe again, which re-ran
useConsoleErrorCapture with the new iframe — infinite loop.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-13 08:22:26 +02:00
Phuong Le 51f968ec87 fix(studio): keep preview selection overlay visible for selected elements (#777) 2026-05-13 07:25:40 +02:00