100 Commits
Author SHA1 Message Date
James Russo fb9002598d Merge pull request #877 from heygen-com/ci/fast-fail-and-preflight-gate
ci: fast-fail regression matrix + preflight gate before expensive jobs
2026-05-15 18:51:41 -04:00
JamesandClaude Opus 4.7 00984133fc ci(preflight): extract preflight steps into a composite action
Same 5-step preflight body (setup-bun, setup-node, cache, install,
lint, format:check) was duplicated across 5 workflows. Move it to
.github/actions/preflight/action.yml so future tweaks (adding
typecheck, swapping the cache key, etc.) are a single-file change.

Net diff: +33 / -65.

Addresses the "shared preflight" follow-up Vai called out on #877.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:40:32 +00:00
JamesandClaude Opus 4.7 e29d7db331 ci(preflight): cache ~/.bun/install/cache keyed on bun.lock
Each of the 5 preflight gates was doing a cold bun install, costing
~30-60s of redundant install time per PR. Cache the install dir
keyed on bun.lock so subsequent preflights (and reruns) hit warm.

Addresses Vai's review on #877.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:36:49 +00:00
JamesandClaude Opus 4.7 18e49cb69c ci: fast-fail regression matrix + preflight gate before expensive jobs
Don't burn 60+ runner-minutes on regression shards, perf shards,
preview-parity, Windows renders, or catalog-preview renders when
the PR is already failing lint or format.

- regression: matrix fail-fast: false → true (first failing shard
  cancels the rest), plus a new preflight (lint + format:check)
  job gating regression-shards.
- player-perf: matrix fail-fast → true, plus preflight gate.
- preview-regression, windows-render, catalog-previews: preflight
  gate added; heavy jobs now needs: [..., preflight].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:31:29 +00:00
James Russo 4bc24068f4 Merge pull request #864 from heygen-com/05-15-ci_codeql_bump_codeql-action_v3_v4_to_address_deprecation
ci(codeql): bump codeql-action v3 → v4
2026-05-15 13:07:18 -04:00
James 436aebdc13 ci(codeql): bump codeql-action v3 → v4 to address deprecation 2026-05-15 16:57:48 +00:00
James Russo 952d2658b0 Merge pull request #860 from heygen-com/05-15-ci_security_add_advanced_codeql_setup_with_path-scoped_query_filters
ci(security): add advanced CodeQL setup with path-scoped query filters
2026-05-15 02:59:32 -04:00
James Russo e8372d45d6 Merge pull request #857 from heygen-com/05-15-test_producer_extend_cross-worker_idempotency_to_non-zero_chunks_dedupe_soft-skip_regex
test(producer): extend cross-worker idempotency to non-zero chunks + dedupe soft-skip regex
2026-05-15 02:55:09 -04:00
James Russo be5b450b5d Merge pull request #855 from heygen-com/05-15-ci_regression_rebalance_matrix_via_measured_per-test_durations
ci(regression): rebalance matrix via measured per-test durations
2026-05-15 02:52:24 -04:00
James Russo 5e56b11615 Merge pull request #856 from heygen-com/05-15-fix_security_close_codeql_critical_bad-code-sanitization
fix(security): close CodeQL critical command-line-injection and bad-code-sanitization
2026-05-15 02:50:25 -04:00
James 783e25a78f ci(security): add advanced CodeQL setup with path-scoped query filters 2026-05-15 04:58:10 +00:00
James 47fe69aff0 test(producer): extend cross-worker idempotency to non-zero chunks + dedupe soft-skip regex 2026-05-15 03:51:11 +00:00
James fc3aa4d49e fix(security): close CodeQL critical + bad-code-sanitization 2026-05-15 03:42:47 +00:00
James b2a0262c3c ci(regression): rebalance matrix via measured per-test durations 2026-05-15 03:40:45 +00:00
James Russo 808b10cb0e Merge pull request #844 from heygen-com/05-14-test_producer_add_cross-worker_idempotency_unit_test
test(producer): add cross-worker idempotency unit test
2026-05-14 23:33:14 -04:00
James 6b3ad09436 fix(producer): tighten chunk-boundary test gates + narrow VIDEO_EXT indexing
Address @vanceingalls and @miguel-heygen review findings on #852:

1. Asymmetric soft-skip — only the N=1 plan+render+assemble call was
   wrapped in the host-Chrome-failure catch; an SwiftShader / cold-Chrome
   flake on the N=4 call would hard-fail instead of soft-skip. Factor a
   local runRender() helper and wrap both calls.

2. Vacuously-passing length assertion — 'expect(framesOne.length).toBe(
   framesFour.length)' passes when both runs produce 0 frames. Pin the
   absolute count (EXPECTED_FRAME_COUNT = 60) so a regression that
   identically truncates both renders shows red.

3. CDN version drift — anime-boundary loaded gsap@3.14.2 from jsdelivr
   while every other boundary fixture loaded 3.12.2 from cdnjs. Unify on
   cdnjs@3.12.2 so the next reader doesn't have to wonder why one fixture
   diverges. (gsap is an empty duration-driver in all six fixtures so
   the version was never load-bearing — but the divergence reads as
   intentional and isn't.)

4. VIDEO_EXT type narrowing — the lookup is Record<"mp4"|"mov"|"webm">
   but outputFormat includes "png-sequence". The isPngSequence ternary
   short-circuits before png-sequence can reach the indexing site, but TS
   can't narrow through that. Add an explicit cast at the indexing site
   (not the lookup definition — over-widening to include "png-sequence":
   undefined would defeat the existence guarantee).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:47:20 +00:00
James bd21d00b13 refactor(producer): /simplify Phase 4 distributed-rendering changes
Address findings from a three-agent code-review pass over the Phase 4 stack:

- regression-harness: hoist `readdirSync` out of the per-checkpoint
  failure-extraction loop (was running 20 redundant syscalls on every
  failing png-sequence test). Drop redundant `existsSync` guards before
  `mkdirSync(recursive: true)` and `rmSync(force: true)`. Replace the
  three-deep ternary that built the output filename suffix with a
  single `Record<format, ext>` lookup.
- regression-harness-distributed: flatten the `format === "mp4" ? {...} : {...}`
  branching in the `plan()` call into a single config object with a
  conditional spread. `plan()` already accepts `codec: undefined` for
  non-mp4 formats, so the duplicate object was unnecessary.
- chunkBoundary.test: rename the stale "byte-identical mp4" test title
  to "byte-identical frames" (the test now uses png-sequence). Trim the
  10-line comment justifying `rejectOnSystemFonts: false` to the
  essential WHY.
- renderChunk / plan.test / regression-harness: drop trailing-edge
  comment phrases that pinned the prose to the PR's calendar context
  ("today", "v1.5", "pre-codec-knob output", section-numbered cross-
  references to the planning doc).

No behavior change. All 49 distributed unit tests pass. Smoke + four
distributed format fixtures pass in --mode=distributed-simulated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:46:44 +00:00
James 0b31465b2e test(producer): add chunk-boundary fixtures per first-party adapter 2026-05-15 02:46:44 +00:00
James 6dbdac8118 fix(producer): normalize default-format check + carry no-audio rationale to mp4-h265-sdr fixture
Address @vanceingalls review on #851:

1. validateMetadata's codec/format check read 'rc.codec !== undefined &&
   rc.format !== undefined && rc.format !== "mp4"'. The behavior was
   correct (omitted format defaults to mp4 downstream so codec is legal)
   but relied on the reader knowing that default. Normalize 'effectiveFormat
   = rc.format ?? "mp4"' before the comparison so the intent reads
   directly.

2. The mp4-h264-sdr sibling carries inline rationale for the no-audio
   choice (AAC frame quantization extends container.duration past
   nb_frames/fps and trips the harness PSNR sampler) and the chunk-seam
   mapping (crossfade window 0.9-1.1s straddles frame 30). mp4-h265-sdr
   stripped both. Carry them back so the two fixtures stay parallel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:22:19 +00:00
James 5298b12c79 test(producer): add mp4 H.265 SDR distributed fixture 2026-05-15 02:21:43 +00:00
James 38e08e81c7 style(producer): apply oxfmt to resolvePresetForLockedEncoder signature
The generic parameter constraint exceeded oxfmt's line width, so the
formatter wraps the type-param list onto its own line. Applies the same
formatting locally that CI's 'Format' job would have produced via
'bun run format:check' — no behavior change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 01:57:08 +00:00
James db62e31d39 fix(producer): reject unknown codec strings + extract testable preset-override helper
Address @vanceingalls review on #850:

1. Unknown codec strings (typos like 'H265', future additions like 'av1')
   silently fell through to libx264 in resolveEncoderTriple. Add an
   explicit throw symmetric to the non-mp4-format branch already there.
   A JS caller building config from JSON who passes 'codec: "h266"'
   now gets a clear error at plan time instead of unflagged h264 output.

2. The preset.codec override in renderChunk had no fast unit coverage —
   only the heavyweight Docker fixture in #851 would catch a regression
   if someone refactored the spread (e.g. moved it into getEncoderPreset
   itself). Extract resolvePresetForLockedEncoder() and add 4 fast unit
   tests pinning the four encoder shapes (libx265/libx264/prores/png-seq).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 01:57:08 +00:00
James eccea88daf feat(producer): add codec knob to DistributedRenderConfig 2026-05-15 01:57:08 +00:00
James Russo 9cc09fca83 Merge pull request #848 from heygen-com/05-14-test_producer_add_mov_prores_distributed_fixture
test(producer): add mov ProRes distributed fixture
2026-05-14 21:13:44 -04:00
James Russo 85dd8bbf01 Merge pull request #847 from heygen-com/05-14-test_producer_add_png-sequence_distributed_fixture
test(producer): add png-sequence distributed fixture
2026-05-14 21:07:12 -04:00
James Russo ea5892b0ff Merge pull request #845 from heygen-com/05-14-test_producer_add_mp4_h.264_sdr_distributed_fixture
test(producer): add mp4 H.264 SDR distributed fixture
2026-05-14 20:25:38 -04:00
James 967ebe69cd docs(producer): carry chunk-seam + ProRes-intra-only rationale into mov-prores fixture
Address @vanceingalls review on #848: the mp4-h264-sdr sibling fixture
explains the crossfade-straddles-frame-30 and continuous-rotation
chunk-seam design choices inline; mov-prores didn't. Add the parallel
comment so the next contributor reading either fixture finds the same
context. Notes specifically that ProRes is intra-only and therefore
exercises the QuickTime atom / -c copy contract rather than frame-level
state continuity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 23:51:44 +00:00
James 3c29060e87 docs(producer): note Chromium/zlib byte-drift as known failure mode for png-sequence fixture
Address @vanceingalls review on #847: the maxFrameFailures=0 byte-identity
threshold will fail when Chromium's CDP screenshot bytes or libpng's
deflate output shifts on a Docker image bump. Pin the recovery procedure
in the fixture's description so a future on-call sees 'regenerate
baselines' rather than spending time investigating a non-regression.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 23:51:44 +00:00
James 5997845899 test(producer): add mov ProRes distributed fixture 2026-05-14 23:51:44 +00:00
James ff503cd5c0 test(producer): track png-sequence distributed fixture baseline frames via LFS 2026-05-14 23:51:44 +00:00
James 29436d997c test(producer): add png-sequence distributed fixture 2026-05-14 23:51:44 +00:00
James 6c98393ec9 fix(producer): detect duplicate fixture IDs across tests/<x>/ and tests/distributed/<x>/
Address @vanceingalls review on #845: the new discoverTestSuites
dispatch was silently allowing a future tests/distributed/<x>/ fixture
to collide with an existing tests/<x>/ fixture of the same name. Both
would push under the same suite.id and stomp each other's failures/
output, baseline lookup, and CLI --filter match.

Detect the collision at discovery time and throw with both source dirs
named, so the conflict is fixable at author time. Easier to enforce now
(one fixture in the new namespace) than after the rest of the Phase 4
fixtures land.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 23:41:51 +00:00
James 4b9a17d520 test(producer): add cross-worker idempotency unit test 2026-05-14 23:30:55 +00:00
James 447d428452 test(producer): track distributed fixture baselines via LFS 2026-05-14 23:30:55 +00:00
James 2974bcb1a9 test(producer): add mp4 H.264 SDR distributed fixture 2026-05-14 23:30:55 +00:00
James Russo 5a6965090b Merge pull request #843 from heygen-com/feat/registry-vignette-takeover
feat(registry): add vignette CSS component
2026-05-14 17:56:55 -04:00
James Russo 73cb5a5b55 fix(studio,registry): unbreak studio test on main + make vignette demo legible
Two surgical changes, both isolated to the catalog-previews flow:

1. `packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts`
   PR #842 changed `seek()` to take `(time, { keepPlaying: true })` for the
   A/E shortcuts. The keyboard-layout tests added by #839 still asserted the
   single-arg form. Both landed on main without cross-checking, so `main`
   itself has been failing Test/Windows since. Update the two assertions
   to match the new signature. Same fix Miguel already authored on
   `feat/studio-preview-pasteboard-bg`.

2. `registry/components/vignette/demo.html`
   The original demo captured a frame where the vignette was at its
   weakest point — the effect was nearly invisible in the static preview
   used by docs. Reworked the demo so:
   - The backdrop is a layered "cinematic still" (warm key + teal rim +
     dark falloff) and includes a centered subject ("moon"), so the
     vignette has a focal point to frame.
   - Vignette starts soft (size 70%, alpha 0.35) and ramps to a dramatic
     cinematic vignette (size 26%, alpha 0.92) over 1.6s.
   - Peak intensity holds across t≈3.0s, which is exactly where the
     catalog script samples the thumbnail (`Math.min(3.0, duration*0.6)`
     with duration=5).
   - Breathing motion in t=3.4–5.2s gives the video loop visible life
     without disturbing the still frame.
2026-05-14 21:36:24 +00:00
James Russo 91f811d4d5 fix(scripts): pass rational fps to capture/render in catalog previews
`scripts/generate-catalog-previews.ts` still called `createCaptureSession`
with `fps: 30` and `createRenderJob` with `fps: 24`. Since commit 5dcc89c9
("feat(cli): accept ffmpeg-style rational fps") `CaptureOptions.fps` and
`RenderConfig.fps` are `Fps = { num, den }` rationals — a plain number
yields `options.fps.den === undefined` and:

```ts
beginFrameIntervalMs: (1000 * options.fps.den) / Math.max(1, options.fps.num),
// = (1000 * undefined) / Math.max(1, undefined) = NaN / NaN = NaN
```

After warmup, `session.beginFrameTimeTicks = (baseTickCount + 10) * NaN = NaN`,
and the next `HeadlessExperimental.beginFrame` CDP call fails with:

```
Protocol error (HeadlessExperimental.beginFrame): Invalid parameters
Failed to deserialize params.frameTimeTicks - BINDINGS: double value expected
```

This regression didn't surface earlier because the Catalog Previews workflow
only re-renders items whose files changed in the PR, so existing components
were never exercised against the new fps contract. The vignette addition is
the first new item since the refactor.

Fix: pass `{ num: 30, den: 1 }` and `{ num: 24, den: 1 }`.
2026-05-14 21:03:01 +00:00
James Russo 804a57cbc9 Merge pull request #827 from heygen-com/05-14-feat_producer_add_harness_mode_--mode_distributed-simulated
feat(producer): add harness mode --mode=distributed-simulated
2026-05-14 16:59:44 -04:00
James Russo 264f2f06c8 Merge pull request #826 from func25/selection-scrub-freeze
fix(studio): keep preview animations active after selection scrub
2026-05-14 16:39:10 -04:00
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
James Russo 4177c32e73 Merge pull request #839 from calcarazgre646/fix/studio-playback-shortcuts-keyboard-layout
fix(studio): use e.key for playback shortcuts so non-QWERTY layouts work
2026-05-14 16:31:09 -04: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
James Russo 46ef31fd70 Merge pull request #841 from heygen-com/chore/gitattributes-normalize-line-endings
chore: normalize line endings to LF via .gitattributes
2026-05-14 13:45:22 -04: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 bfdb341494 chore: normalize line endings to LF via .gitattributes
Add `* text=auto eol=lf` so text files are checked in with LF regardless
of the contributor's OS. Without this, Windows editors can save files
with CRLF (and sometimes a UTF-8 BOM), which makes every line differ at
the byte level on diff and trips GitHub's "Binary file not shown"
heuristic — see #840 for an example where a ~30-line change was
unreviewable for this reason.

Existing LFS rules already carry `-text` and remain unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:42:01 -07: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
James 91a91f66ba feat(producer): export distributed render primitives 2026-05-14 03:40:44 +00:00
James e5068487c1 feat(producer): refuse distributed-unsupported formats early 2026-05-14 03:09:10 +00: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
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
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
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
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
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
JamesandClaude Opus 4.7 bd5c489eec feat(producer): fail-closed font fetch flag in deterministicFonts
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (banned in distributed mode) and
§9.3 (typed non-retryable failures).

Today `injectDeterministicFontFaces(html)` swallows external font-fetch
failures: a failed Google Fonts CSS request or woff2 download returns
empty arrays, the composition warns via `warnUnresolvedFonts`, and Chrome
falls back to system fonts. That fallback would silently desync chunk
workers in distributed mode (workers run in a Linux container that
doesn't have macOS / Windows system fonts), so distributed renders need
to fail closed.

This change adds an options bag to `injectDeterministicFontFaces`:

  injectDeterministicFontFaces(html, {
    failClosedFontFetch?: boolean;  // default false
    fetchImpl?: typeof fetch;       // default global fetch
  })

When `failClosedFontFetch === true`, any non-OK CSS response, any non-OK
woff2 response, and any network error during either fetch throws a typed
`FontFetchError` with `code === FONT_FETCH_FAILED`. When `false` (the
default), behavior is unchanged.

`fetchImpl` lets unit tests inject failing-fetch stubs without going over
the network.

The in-process caller (`htmlCompiler.ts`) continues to call
`injectDeterministicFontFaces(html)` without options and gets the legacy
behavior. Phase 3's `plan()` will pass `failClosedFontFetch: true`.

Producer regression baselines remain byte-identical: no caller flips the
flag.

10 unit tests at packages/producer/src/services/
deterministicFonts-failClosed.test.ts pin both branches (default
swallows network error / 404; locked throws FontFetchError with correct
code, URL, and family name) plus the "no fetch happens for bundled
fonts" carve-out.

This is part of a stack of 10 PRs; this is PR 10 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 a9f574d9c0 feat(producer): plan-time validator — reject system fonts
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).

Extends packages/producer/src/services/render/planValidation.ts with:

  - validateNoSystemFonts(compiledHtml) — scans `font-family:` declarations
    and `data-font-family=…` attributes. If the PRIMARY family (first
    entry in the comma-separated list) resolves to a host-OS / CSS-generic
    family, throws PlanValidationError with code SYSTEM_FONT_USED.
  - parseFontFamilyValue(value) — pure helper that splits a font-family
    declaration value, stripping whitespace + quotes.

Banned primary families: sans-serif, serif, monospace, cursive, fantasy,
system-ui, ui-sans-serif, ui-serif, ui-monospace, emoji, math, fangsong,
-apple-system, BlinkMacSystemFont. Mirrors the GENERIC_FAMILIES list in
deterministicFonts.ts (deliberately a separate copy — they're two
different concerns that happen to overlap today).

Generic families remain acceptable as CSS fallbacks; only the primary
slot is rejected. `font-family: "Inter", -apple-system, sans-serif` is
fine; `font-family: -apple-system, BlinkMacSystemFont` is rejected.

No caller invokes the validator yet. Phase 3's `plan()` will run it on
the compiled HTML before freezing the plan, so chunk workers (Linux
containers without macOS / Windows system fonts) never see compositions
that would render differently between the controller and the workers.

In-process behavior is unchanged.

14 unit tests added to packages/producer/src/services/render/
planValidation.test.ts cover: clean compositions, missing font-family,
each banned primary family, data-font-family= surface, case-insensitive
matching, fallback acceptance, and parser edge cases.

This is part of a stack of 10 PRs; this is PR 9 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 146ff0f3da feat(producer): plan-time validator — reject GPU encode
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.3 (Banned in distributed mode) and
§9.3 (typed non-retryable failures).

Adds packages/producer/src/services/render/planValidation.ts:

  - PlanValidationError — typed plan-time error carrying a `code` field
    matching plan §9.3, so Phase 3 adapter retry policies (Temporal /
    Step Functions) can mark these as non-retryable.
  - validateNoGpuEncode(config) — throws with code BROWSER_GPU_NOT_SOFTWARE
    when:
      * config.useGpu === true  — distributed retries must be byte-
        identical, but NVENC/QSV/VAAPI produce different output across
        machines.
      * config.browserGpuMode !== "software" — hardware GL is bitwise
        unstable across drivers; pairs with the runtime
        assertSwiftShader check from PR 2.2.

The BROWSER_GPU_NOT_SOFTWARE constant is re-exported from
@hyperframes/engine (where PR 2.2 declared it) and re-exported again from
this module, so the Phase 3 distributed adapter can match the typed code
without a cross-package import.

No caller invokes the validator yet. Phase 3's `plan()` will run it
before freezing the plan, so banned configs fail fast with a typed
non-retryable error instead of leaking into a planDir.

In-process behavior is unchanged — the in-process renderer continues to
accept useGpu=true and browserGpuMode="auto".

9 unit tests at packages/producer/src/services/render/
planValidation.test.ts pin both gates and the precedence (useGpu checked
before browserGpuMode).

This is part of a stack of 10 PRs; this is PR 8 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:12 +00:00
JamesandClaude Opus 4.7 62317f7f3a feat(producer): audio post-pad/trim helper for assemble
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §17.2 (PR 2.7 row).

Distributed renders mix audio once at `plan()` time against the
composition's declared duration; the actual assembled video duration is
`Σ(chunkFrames) / fps`. Even with closed-GOP concat-copy the absolute
result is deterministic, but downstream muxers (especially ffmpeg's
`-shortest` plus Apple's mov demuxer) are sensitive to ±1ms audio/video
drift and produce silent "audio cuts off early" or "video freezes on the
last frame" bugs.

Adds packages/producer/src/services/render/audioPadTrim.ts:

  - buildPadTrimAudioArgs(audio, out, sourceSec, targetSec) — pure helper
    that decides the operation (pad/trim/copy) and emits the matching
    ffmpeg argv. Uses `apad=pad_dur=Δ` (re-encode to AAC because filters
    can't combine with `-c:a copy`), `-t target -c:a copy` (trim is a
    lossless AAC packet boundary snap), or a plain `-c:a copy` when the
    delta is below ~1ms.
  - padOrTrimAudioToVideoFrameCount(input) — probes the assembled video
    for exact frame count (`-count_packets` + `nb_read_packets`, which
    equals frame count when chunks were encoded with `-bf 0` as Phase 2's
    PR 2.1 already enforces), probes the audio for current duration,
    computes target = `frameCount * fpsDen / fpsNum`, runs ffmpeg with the
    args from the pure helper. Probes and ffmpeg runner are injectable so
    unit tests don't shell out.

Six-decimal-place seconds formatting avoids ffmpeg's inconsistent handling
of scientific notation in time args across versions.

No caller invokes either function yet — Phase 3's `assemble()` will run
this after the chunk concat-copy step, before muxing audio onto the final
mp4/mov output.

15 unit tests at packages/producer/src/services/render/
audioPadTrim.test.ts pin both layers: the pure arg builder for all three
operations (incl. NTSC fps), and the wrapper for normal flow, probe
failures, invalid video info, and ffmpeg failures.

In-process behavior is unchanged. The producer's existing
`muxVideoWithAudio` path in chunkEncoder is untouched.

This is part of a stack of 10 PRs; this is PR 7 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 c139197b37 feat(engine): first-frame warmup capture helper for distributed chunks
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (lastFrameCache row) and §17.2
(gating table).

Adds `discardWarmupCapture(session, frameIndex=0, time=0, innerCapture?)`
in packages/engine/src/services/frameCapture.ts. Performs one capture
through the standard `captureFrameCore` path, throws the buffer away, and
restores the session's perf and BeginFrame damage counters.

Distributed chunk workers need this because Chrome's BeginFrame screenshot
pipeline maintains a per-process `lastFrameCache`: when a captured frame's
`hasDamage` reports `false`, the screenshot path returns the previously
captured buffer. For chunk N (N > 0) the worker has no prior frame in its
cache, so the very first capture's `hasDamage` reporting diverges from
what an in-process render at the same absolute frame index would see (the
in-process renderer always has frame N-1 cached). Running a discarded
warmup capture before the first real capture primes the cache so chunk
output is byte-identical to in-process output.

The wrapper:
  - Takes an injectable `innerCapture` so tests can stub the Chrome path
    (default is the real `captureFrameCore`).
  - Restores `session.capturePerf`, `beginFrameHasDamageCount`, and
    `beginFrameNoDamageCount` after the inner call — even on error — so
    warmup captures don't pollute `getCapturePerfSummary()` averages.
  - Writes no file to disk.

In-process behavior is unchanged: no caller invokes the new helper yet.
Phase 3's `renderChunk()` will run it as the first step after
`initializeSession` resolves.

Re-exported from packages/engine/src/index.ts.

7 unit tests at packages/engine/src/services/
frameCapture-discardWarmup.test.ts cover the post-conditional contract:
single inner-capture invocation, perf/damage restoration on success,
restoration on error, no-fs-write.

This is part of a stack of 10 PRs; this is PR 6 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 1d189aa26b feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §4.3 (LockedRenderConfig.runtimeEnv) and
§5.2 (RENDER_SEEK_MODE row).

`fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
env vars at module-load time (RENDER_SEEK_MODE, RENDER_SEEK_STEP,
RENDER_SEEK_OFFSET_FRACTION, …) and bakes them into the served HTML's
RENDER_MODE_SCRIPT. Distributed chunk workers are separate processes that
may inherit a different environment, so the plan needs to freeze a
snapshot.

Adds `snapshotRuntimeEnv(env = process.env)` in
packages/producer/src/services/render/stages/freezePlan.ts. Captures keys
matching `PRODUCER_RUNTIME_` or `PRODUCER_RENDER_` prefixes into a fresh
plain object, ignoring everything else. Phase 3's `renderChunk` will
materialize the snapshot back into `process.env` before launching its
file server.

Also exports `RUNTIME_ENV_SNAPSHOT_PREFIXES` so the chunk-worker side can
apply the same prefix filter (asymmetric handling would leak stale
controller env into worker behavior).

The freezePlan function body remains a skeleton — Phase 3 owns the full
implementation. The snapshot helper is exported on its own so this gate's
unit test can pin the behavior without depending on the not-yet-written
freezePlan body.

In-process behavior is unchanged: no in-process caller invokes
freezePlan or snapshotRuntimeEnv yet.

9 unit tests at packages/producer/src/services/render/stages/
freezePlan.test.ts cover: prefix matches (both families), non-matching
keys ignored, undefined values skipped, fresh-object contract, and
default-to-process.env behavior.

This is part of a stack of 10 PRs; this is PR 5 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 8bac7ba0d1 feat(producer): seedable Math.random / crypto.getRandomValues shim, gated
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (Math.random row) and §17.2
(gating table).

The existing `VIRTUAL_TIME_SHIM` freezes Date.now / performance.now / rAF
on a render seek but leaves `Math.random` and `crypto.getRandomValues` as
native non-deterministic. Compositions that paint stochastic visuals
through these APIs produce different pixels on distributed retries.

This change adds `buildVirtualTimeShim({ seedRandomFromFrame: boolean })`.
Default `false` returns a string byte-identical to today's
`VIRTUAL_TIME_SHIM` (pinned by a new unit test). When `true`, the script
additionally:

  - Installs a Mulberry32 PRNG with a single uint32 state
  - Reseeds the state from the current virtual time on every
    `seekToTime(ms)` call (Knuth multiplicative hash + golden-ratio offset)
  - Replaces `Math.random` with the PRNG output
  - Replaces `crypto.getRandomValues` to fill the buffer from the PRNG

`VIRTUAL_TIME_SHIM` (the const consumed by `renderOrchestrator` +
`probeStage`) is now `buildVirtualTimeShim({ seedRandomFromFrame: false })`
— in-process behavior unchanged, producer regression baselines unaffected.

Phase 3 distributed primitives will pass `true` when building the chunk
worker's file-server scripts.

10 new unit tests at packages/producer/src/services/
fileServer-seededRandom.test.ts use node:vm to evaluate the shim in
isolated contexts and pin both branches:

  - default emits no RNG override and leaves Math.random native
  - locked emits the seeded block, produces identical sequences across
    fresh VMs at the same time, and yields different sequences for
    different times

This is part of a stack of 10 PRs; this is PR 4 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 d54ad9ca19 refactor(engine): clamp warmupTicks to fixed iteration count, gated
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (warmupTicks row) and §17.2 (gating
table).

The BeginFrame warmup loop in `initializeSession` is driven by wall-clock
during page load — different hosts accumulate different tick counts before
page-readiness completes. That shifts `session.beginFrameTimeTicks` and
yields non-byte-identical captures on distributed workers.

This change adds `lockWarmupTicks: boolean` (default false) to
`CaptureOptions`. When false, behavior is unchanged. When true, the loop
runs exactly `LOCKED_WARMUP_TICKS = 60` iterations regardless of page-load
wall clock, and `session.beginFrameTimeTicks` is computed from the
constant — pinning the baseline across hosts.

Refactoring:

  - Extract `driveWarmupTicks(options, state)` as a pure helper. Tests
    drive it with a stub `tick` callback and an injected `sleep`, so the
    iteration-count contract is unit-testable without real Chrome.
  - `initializeSession`'s warmup body is now a thin adapter that calls
    `driveWarmupTicks` with a CDP-backed tick.

Producer regression baselines remain byte-identical: the in-process
renderer never passes `lockWarmupTicks: true`. Phase 3 distributed
primitives will flip it true when launching chunk workers.

11 new unit tests at packages/engine/src/services/
frameCapture-warmupTicks.test.ts pin both branches (unlocked drifts with
simulated load time; locked produces identical counts).

This is part of a stack of 10 PRs; this is PR 3 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 d8486a7c4d feat(engine): assertSwiftShader chrome://gpu validator
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (browserGpuMode row) and §9.3
(BROWSER_GPU_NOT_SOFTWARE typed failure).

Adds packages/engine/src/utils/assertSwiftShader.ts:

  - assertSwiftShader(page, readInfo?) — navigates to chrome://gpu, reads
    the GL_VENDOR / GL_RENDERER rows from browserBridge.gpuInfo_, throws
    SwiftShaderAssertionError ({ code: "BROWSER_GPU_NOT_SOFTWARE" }) if
    the active backend isn't SwiftShader.
  - readWebGlVendorInfo(page) — extracted helper so tests can stub the
    info read without spinning up real Chrome.
  - SwiftShaderAssertionError + BROWSER_GPU_NOT_SOFTWARE constant exposed
    so the Phase 3 distributed adapter can match typed non-retryable
    failures.

Re-exported from packages/engine/src/index.ts. No caller invokes it yet;
Phase 3 renderChunk() will run it post-launch.

In-process behavior is unchanged — assertSwiftShader is a new pure utility.
Producer regression baselines remain byte-identical.

This is part of a stack of 10 PRs; this is PR 2 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
JamesandClaude Opus 4.7 2d6372ac2a feat(engine): add lockGopForChunkConcat option to buildEncoderArgs
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §7.1 and §17.2 (gating table).

Adds two optional fields to EncoderOptions:

  lockGopForChunkConcat?: boolean  // default false
  gopSize?: number                 // required when lockGopForChunkConcat=true

When the flag is true on the SW libx264 / libx265 paths, buildEncoderArgs
emits closed-GOP / forced-keyframe args so the resulting chunk file can be
losslessly concatenated (`ffmpeg -f concat -c copy`) with sibling chunks:

  -g <gopSize>
  -keyint_min <gopSize>
  -sc_threshold 0
  -force_key_frames "expr:eq(mod(n,<gopSize>),0)"
  -x264-params "...:scenecut=0:open-gop=0:repeat-headers=1"
  -x265-params "keyint=<gopSize>:min-keyint=<gopSize>:scenecut=0:open-gop=0:repeat-headers=1"
  -bf 0   (added for h265 too when locked)

GPU encoders, vp9, and prores ignore the flag (their concat-copy story is
separate — see plan §7.2 / §8).

In-process behavior is unchanged: the default (false) path emits no new
args. New unit tests pin both branches in packages/engine/src/services/
chunkEncoder.test.ts.

This is part of a stack of 10 PRs; this is PR 1 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:36:11 +00:00
James Russo c2648bc863 Merge pull request #778 from heygen-com/fix/ci-format-and-windows-esbuild
fix(ci): unbreak format check on main + skip cli build:fonts when present
2026-05-13 00:34:31 -04:00
James de946492e6 test(producer): add missing typegpu-adapter golden baseline
PR #755 added the typegpu-adapter regression test scaffolding (meta.json,
src/index.html, output/compiled.html) but left the output.mp4 golden
baseline ungenerated:

> Note: output.mp4 baseline needs to be generated in CI — the local …

Every \`regression-shards (fast)\` run since #755 merged has failed with
\`Snapshot not found: /app/packages/producer/tests/typegpu-adapter/output/output.mp4.
Run with --update to create it.\`

Generated via the canonical Docker path per CLAUDE.md:

    bun run --cwd packages/producer docker:test \\
      --update --suite typegpu-adapter

Stored via Git LFS (already configured for
\`packages/producer/tests/*/output/output.mp4\` in \`.gitattributes\`).
2026-05-13 04:05:18 +00:00
James 067bc722e3 fix(deps): align @types/node + esbuild + tsx across workspace
The Windows install failures (`ENOENT: failed copying files from cache to
destination for package @types/node` / `esbuild`) are caused by bun creating
workspace-scoped nested installs under
`node_modules/@hyperframes/<pkg>/node_modules/...`. Those nested paths only
exist because each workspace package pinned a different `@types/node` /
`esbuild` major:

- root: `@types/node ^25.0.10`, core: `^24.10.13`, cli/engine/producer: `^22`
- core/cli: `esbuild ^0.25.x`, producer: `^0.27.2`

Each major-version gap forces bun to install a workspace-scoped copy in a
deep `node_modules/@hyperframes/<pkg>/node_modules/<dep>/node_modules/...`
tree that bun can't reliably materialize on Windows GHA runners. Aligning
versions lets bun dedup to a single root-hoisted install per dep, and the
nested workspace block disappears from `bun.lock` entirely.

## Alignment

- `@types/node` → `^25.0.10` across root, core, cli, engine, producer
- `esbuild` → `^0.25.12` across cli, core, producer
- `tsx` → `^4.21.0` across producer (matches root + core)

## Source-level v25 compat (already in this PR)

@types/node v25 declares `File` as an interface (not a class) and exposes a
conditional global where `FormData.entries()` narrows to `[string, string]`
when an `onmessage` global is in scope. `packages/core/src/studio-api/routes/files.ts`'s
`value instanceof File` check was relying on the v24 class declaration —
already cast the iterator to `Iterable<[string, FileLike | string]>` in the
prior commit.

Two more v25 source fixes here:
- `packages/cli/src/commands/init.ts`
- `packages/cli/src/whisper/normalize.ts`

`Dirent.path` was removed in @types/node v25 (deprecated alias for
`parentPath` since Node 20.12). Drop the `?? e.path` fallback.

## Verification

Both install layouts now build clean end-to-end:

- `bun install` (isolated, default): full build green, 853 core tests pass,
  typecheck green across all 7 packages
- `bun install --linker=hoisted` (Windows CI): same result
- `bun.lock` no longer contains any `@hyperframes/<pkg>/<dep>` nested
  workspace entries — 70+ lines of nested install blocks gone
2026-05-13 03:35:47 +00:00
James 8348e19fd9 fix(ci): switch Windows install to hoisted linker; narrow FormData iter
Pushing further to actually get Windows render verification green, not just
work around it.

## What's wrong on Windows

Bun 1.3's default `isolated` linker creates nested workspace junctions under
`packages/*/node_modules/` on Windows GHA runners. Those junctions don't
materialize reliably — Node's `realpathSync` returns `EPERM` on stat, and
ESM resolution returns `ERR_MODULE_NOT_FOUND`. Every Windows build since
PR #748 has tripped this in one of three places:

- `packages/producer/build.mjs` importing `esbuild`
- `packages/producer/scripts/generate-font-data.ts` reading `@fontsource/*`
- `packages/producer` running `tsc` to emit `.d.ts`s

Long-running bun bugs: oven-sh/bun#23615, #18354, #10146.

## Fix

**1. `--linker=hoisted` for the Windows install step** (workflow change,
Windows only). Hoisted layout puts deps as real directories at the workspace
root + workspace package node_modules. No junctions, no Windows-specific
path quirks. Linux CI keeps the default isolated linker; the lockfile is
linker-agnostic so `--frozen-lockfile` is still valid.

**2. Source-level FormData narrowing in `packages/core/src/studio-api/routes/files.ts`**
(needed because the hoisted layout exposes a `@types/node@25` typecheck
issue that the isolated layout hides). With v25 + an `onmessage` global in
scope, the ambient `FormData.entries()` infers `[string, string]` instead of
`[string, File | string]`, so the `value instanceof File` check breaks at
`TS2358`. Cast the iterator to a `[string, FileLike | string]` shape and
narrow via `typeof value === "string"`. Identical runtime behavior; works
under both v24 (isolated layout, what Linux CI sees) and v25 (hoisted, what
Windows CI sees with this change).

## Verification

- `bun install --frozen-lockfile` (isolated, default): full build green
- `bun install --frozen-lockfile --linker=hoisted`: full build green, core
  typecheck passes, `@hyperframes/core` 853 tests pass
- Format/lint clean on both layouts
2026-05-13 03:27:58 +00:00
James c8a3e5fc4d fix(ci): unbreak format check on main + skip cli build:fonts when present
Two narrow fixes pulled out of a larger Windows-CI investigation:

## 1. Format check (`oxfmt`)

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

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

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

`fontData.generated.ts` is committed to git, so the regeneration is only
needed when fonts actually change. Match the skip-when-present pattern
already in `@hyperframes/producer`'s own `build:fonts`. Doesn't fix the
Windows render verification end-to-end (the producer build itself still
trips junction issues — being tackled separately in #765), but at least
stops `cli build:fonts` from being its own failure point on Windows.
2026-05-13 03:06:39 +00:00
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
James Russo b7ae24de0c Merge pull request #548 from heygen-com/docs/hyperframes-mcp
docs(guides): add HyperFrames MCP guide
2026-05-12 16:53:58 -04: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
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
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
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
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
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