Commit Graph
1014 Commits
Author SHA1 Message Date
JamesandClaude Opus 4.7 513775e659 refactor(producer): /simplify cleanup pass across stage modules
Comment + interface cleanup driven by the /simplify review. No code
change beyond removing dead fields.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add a MediaPreloadManager that gates preloading based on playhead position:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:05:31 +00:00
Vance IngallsandClaude Opus 4.6 a94ca4f5d3 fix(studio): adapt sidebar thumbnail container to composition aspect ratio (#728)
Portrait compositions (1080x1920) rendered pillarboxed inside a fixed 80x45px
landscape container, wasting space with black bars on both sides.

The thumbnail container now derives its dimensions from the composition's
stage size: landscape gets 80px wide, portrait gets 45px tall. The preview
scale calculation uses matching card dimensions so the iframe fills the
container without letterboxing.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 18:04:07 -07:00
JamesandClaude Opus 4.7 aec29fe599 refactor(producer): extract audioStage from executeRenderJob
Move the audio mixing sub-stage of `executeRenderJob` into
`services/render/stages/audioStage.ts`. Trivial wrapper around
`processCompositionAudio` with the same skip-when-empty path.

No behavior change:
- `audioOutputPath` is still `join(workDir, "audio.aac")` regardless of
  whether the composition has audio.
- `hasAudio` still reflects `audioResult.success` (false when no audio
  elements or when the mixer returns success: false).
- `perfStages.audioProcessMs` is set at the same end-of-stage point
  whether or not the mixer ran.
- The "Processing audio tracks" progress callback fires at 20% at the
  same code point.

Removes the now-unused `processCompositionAudio` import from the
orchestrator (oxlint flagged it).

Verified inside `Dockerfile.test` against `font-variant-numeric`,
`many-cuts`, `variables-prod` — 3/3 pass with audio correlations
1.000 / 0.994 / 0.975 (identical to prior PRs in the stack).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:35:16 +00:00
JamesandClaude Opus 4.7 8951ed939c refactor(producer): extract extractVideosStage + add materializeSymlinks param
Move the video frame extraction sub-stage out of `executeRenderJob` into
`services/render/stages/extractVideosStage.ts`. The stage covers HDR
color-space pre-detection for videos and images, the
`extractAllVideoFrames` call, frame-lookup-table construction, video
readiness skip-id collection, video metadata hints, and the auto-detect
of audio tracks from video files.

Hard constraints preserved verbatim:
- `composition.audios` is still mutated in place to add audio entries
  auto-discovered from video files via ffprobe.
- `perfStages.videoExtractMs` is set at the same end-of-stage point.
- `materializeExtractedFramesForCompiledDir` is still called once when
  `extractionResult.extracted` is non-empty.
- `force-sdr` mode still skips ALL ffprobe overhead.

New for distributed mode (`materializeSymlinks: boolean`, default false):
- Plumbs through to `materializeExtractedFramesForCompiledDir` via a new
  option of the same name. When `true`, the helper invokes
  `cpSync(recursive)` instead of `symlinkSync` so the staged frames are
  real files inside `compiledDir`. Symlinks don't survive S3 / GCS
  round-trips, so distributed `plan()` will pass `true` once it lands.
  Default `false` preserves the in-process renderer's symlink behavior.
- New unit test covers the copy path and asserts `symlinkSync` is NOT
  invoked; the existing symlink test was updated with the parallel
  guard that `cpSync` is NOT invoked.

Removes the imports the orchestrator no longer needs after the
extraction: `extractAllVideoFrames`, `resolveProjectRelativeSrc`,
`createFrameLookupTable`, `FrameLookupTable`, `detectTransfer`,
`isHdrColorSpace`, `extractMediaMetadata`, `VideoColorSpace` (oxlint
flagged each).

Verified:
- `bunx oxlint` + `bunx oxfmt --check` clean
- `bun run --filter @hyperframes/producer typecheck` + `build` clean
- `bun test packages/producer/src/services/` — 176 pass, 1 pre-existing
  unrelated failure
- `docker run hyperframes-producer:test` against `font-variant-numeric`,
  `many-cuts`, `variables-prod`, `sub-composition-video` — 4/4 PASS with
  correlations 1.000 / 0.994 / 0.947 / 0.975 (sub-composition-video is
  the one that exercises video frame extraction end-to-end)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:31:08 +00:00
James Russo 7b157dbc86 Merge pull request #720 from heygen-com/refactor/producer-stages-1.3.5-shared
refactor(producer): move shared render helpers to render/shared.ts
2026-05-11 17:04:52 -04:00
James Russo 70d3db4cf5 Merge pull request #719 from heygen-com/refactor/producer-stages-1.3-probe
refactor(producer): extract probeStage from executeRenderJob
2026-05-11 16:51:39 -04:00
James Russo 7134dd52d8 Merge pull request #718 from heygen-com/refactor/producer-stages-1.2-compile
refactor(producer): extract compileStage from executeRenderJob
2026-05-11 16:35:39 -04:00
James Russo fad8cb67ed Merge pull request #717 from heygen-com/refactor/producer-stages-1.1-scaffold-planhash
refactor(producer): scaffold services/render/stages/ + planHash utility
2026-05-11 16:27:08 -04:00
JamesandClaude Opus 4.7 89d83fbbee refactor(producer): migrate test imports to render/shared.ts, drop re-export
Three small follow-ups on the shared.ts extraction, addressing review
feedback on #720:

- Hoist `BROWSER_MEDIA_EPSILON` from `probeStage.ts` into `shared.ts`
  so any future stage that reconciles browser media (chunked rendering
  re-probe, for instance) doesn't have to redeclare it.
- Migrate `renderOrchestrator.test.ts` to import the five moved symbols
  (`applyRenderModeHints`, `projectBrowserEndToCompositionTimeline`,
  `resolveDeviceScaleFactor`, `writeCompiledArtifacts`,
  `CompositionMetadata`) directly from `./render/shared.js`. This is
  the clean end state — the back-compat re-export through
  `renderOrchestrator.ts` was a stepping-stone.
- Drop the back-compat re-export block from `renderOrchestrator.ts`.
  No remaining importers go through it (verified via grep across
  `packages/`). The five symbols now have exactly one path:
  `./render/shared.js`.

No behavior change. Renderer smoke-tested inside `Dockerfile.test`
against `font-variant-numeric`, `many-cuts`, and `variables-prod` —
audio correlations 1.000 / 0.994 / 0.975, matching every prior PR in
the stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:06:20 +00:00
JamesandClaude Opus 4.7 0a54078d25 refactor(producer): move shared render helpers to render/shared.ts
Breaks the runtime circular import between `renderOrchestrator.ts` and
the stage files under `services/render/stages/`. Before this change the
stages imported runtime helpers (`writeCompiledArtifacts`,
`applyRenderModeHints`, `resolveDeviceScaleFactor`,
`projectBrowserEndToCompositionTimeline`) and types (`CompositionMetadata`)
back from `renderOrchestrator.ts`, which itself imports the stage
functions. The cycle resolved at build time because both modules
finished initializing before any stage was invoked, but it was fragile
and would keep growing as more stages were extracted.

This PR:

- Adds `packages/producer/src/services/render/shared.ts` and moves the
  four functions plus the `CompositionMetadata` interface into it.
- Has `renderOrchestrator.ts` re-export everything from `shared.ts`, so
  external callers (the existing `renderOrchestrator.test.ts`, any code
  importing `applyRenderModeHints` etc. from the orchestrator) keep
  working with no churn on their side.
- Updates `compileStage.ts` and `probeStage.ts` to import the runtime
  helpers from `../shared.js`. The only remaining import from
  `renderOrchestrator.ts` in the stages is `import type { RenderJob }`,
  which is erased at runtime and creates no cycle.
- Removes the imports the orchestrator no longer needs after losing
  the four function definitions (`CANVAS_DIMENSIONS`, `VideoElement`,
  `AudioElement`, `ImageElement`).

No behavior change. Renderer smoke-tested inside `Dockerfile.test`
against `font-variant-numeric`, `many-cuts`, and `variables-prod` —
all PSNR / audio-correlation baselines match PR 1.3 exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:03:10 +00:00
JamesandClaude Opus 4.7 20242515ec refactor(producer): make sequencer the sole writer of job.duration/totalFrames
The probe stage previously assigned `job.duration` and `job.totalFrames`
inside its body AND the sequencer re-asserted them after the call to
restore TS narrowing. Two writers for the same field is a maintenance
hazard — a future refactor could drop one and create a silent skew.

Move ownership: the stage computes `duration` and `totalFrames` and
returns them; the sequencer is the sole writer onto the `RenderJob`.
This also aligns with the eventual chunk-worker model where a chunk
running in a separate process cannot mutate the orchestrator's `job`.

No observable behavior change. `job.duration` / `job.totalFrames` end
up with the same values; the zero-duration `throw` still happens
inside the stage (now using the local `duration` constant) before any
sequencer-side assignment. Verified by:

- `bun run --filter @hyperframes/producer typecheck` clean
- `bun test packages/producer/src/services/` 175 pass / 1 pre-existing
  unrelated failure on `main`

Review feedback addressed: vanceingalls on #719.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:03:02 +00:00
JamesandClaude Opus 4.7 56ac52384f refactor(producer): drop internal PR/phase identifiers from stages doc
Comment-only cleanup. Removes "PR 1.x", "Phase 1 PR", and "Phase 3 PR 3.1"
references from JSDoc blocks in `compileStage.ts`, `probeStage.ts`,
`planHash.ts`, and `freezePlan.ts`. Track / PR identifiers rot quickly and
belong in PR descriptions, not in source. Design-doc section citations
(DISTRIBUTED-RENDERING-PLAN.md §X.Y) are kept — those reference a stable
external artifact.

Also tightens the `probeStage.ts` `browserProbeMs` doc string to say
"near-zero when `needsBrowser` was false" instead of "0" — the Date.now()
delta around the function body is sub-ms but not literally zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:01:08 +00:00
JamesandClaude Opus 4.7 90f14703b2 refactor(producer): extract probeStage from executeRenderJob
Move the browser probe / duration discovery / recompile / media
reconciliation block out of `executeRenderJob` into
`services/render/stages/probeStage.ts`. No behavior change. The sequencer
calls `runProbeStage` at the same code point with identical inputs and
outputs.

The probe stage owns the `FileServerHandle` and the `CaptureSession` it
creates and returns them to the sequencer. The sequencer still tracks
them in its `let fileServer` / `let probeSession` bindings and closes
them in its `finally` block — the resource lifetime is unchanged.

`recompileWithResolutions` lives inside this stage because it depends on
browser-resolved durations even though §2.1 of the distributed plan
lists recompile as a sibling phase.

Preserved invariants:

- `composition` is mutated in place (videos / audios / duration) so
  downstream stages see the reconciled view through the same reference.
- `job.duration` and `job.totalFrames` end up with the same values at
  the same code points. The result type carries `duration: number`
  alongside `totalFrames: number`, and the sequencer re-asserts the
  assignments after the call so TypeScript's control-flow narrowing
  works for the rest of `executeRenderJob`.
- `perfStages.browserProbeMs` and `perfStages.compileMs` are written at
  the same code points with the same values.
- The "Composition duration is 0" diagnostic builds the same hint string
  from the same console-buffer regex and `__timelines` probe.
- The post-probe "failed network requests" warning fires with the same
  regex, the same first-10/first-5 slicing, and the same `console.warn`
  prefix.

Renderer smoke-tested inside `Dockerfile.test` against `font-variant-numeric`,
`many-cuts`, and `variables-prod` — all PSNR / audio correlation baselines
match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:01:08 +00:00
JamesandClaude Opus 4.7 d168397758 refactor(producer): flag cfg.forceScreenshot mutation as distributed-render TODO
Add a `TODO(distributed-render):` comment near the `applyRenderModeHints`
call documenting that this caller-owned-object mutation needs to move
into the result type before `freezePlan` wires up. The mutation pattern
works in-process but won't survive across processes / replays from a
frozen plan — the value belongs in `LockedRenderConfig`, not on a
mutated `EngineConfig`.

No behavior change. Comment-only.

Review feedback addressed: vanceingalls on #718.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:00:59 +00:00
JamesandClaude Opus 4.7 4f648c8122 refactor(producer): extract compileStage from executeRenderJob
Move the pure compile sub-stage (`compileForRender` + `applyRenderModeHints`
+ `writeCompiledArtifacts` + `CompositionMetadata` build + DPR resolution)
out of `executeRenderJob` into `services/render/stages/compileStage.ts`.

No behavior change. The sequencer calls `runCompileStage` at the same code
point with identical inputs and outputs. The following invariants are
preserved verbatim:

- `cfg.forceScreenshot` is still mutated by `applyRenderModeHints`.
- `perfStages.compileOnlyMs` is set to the same wall-clock interval (around
  the `compileForRender` call only).
- The "Compiled composition metadata" log line is emitted after artifact
  writes with the same payload shape.
- The "Supersampling composition via deviceScaleFactor" log line is emitted
  only when `deviceScaleFactor > 1`.
- `stage1Start`, `updateJobStatus(..., "Compiling composition", 5, ...)`,
  and `perfStages.compileMs` (set at the end of probe) remain at their
  current code points in the sequencer.

The probe sub-stage (`if (needsBrowser)`) is unchanged — it is extracted
separately in PR 1.3. `recompileWithResolutions` lives inside the probe
block because it depends on browser-resolved durations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:59:24 +00:00
JamesandClaude Opus 4.7 426bd983c9 refactor(producer): address review feedback on planHash
- Add `PLAN_HASH_SCHEMA_PREFIX = "hyperframes-plan-hash-v1\x00"` mixed
  into every digest. Future framing changes must bump the trailing
  integer; this makes cross-version mismatches visible at the wire
  format instead of producing silent collisions. Impossible to backfill
  later, easy to bake in now.
- Hoist the `0x00` field delimiter to module scope (`FIELD_DELIMITER`).
- Document the UTF-8 encoding contract for all string-typed input
  fields in the file-level JSDoc, so external verifiers know the
  encoding without reading the implementation.
- Add a known-digest test for one fixed reference input
  (`995b4105...`). If the framing changes silently this test fails,
  forcing the developer to also bump the schema prefix.
- Add an explicit `canonicalJsonStringify(undefined) → TypeError` test
  to pin the contract.

No callers yet, so no behavior change in any code path.

Review feedback addressed: vanceingalls on #717.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:59:07 +00:00
JamesandClaude Opus 4.7 4e1926be74 refactor(producer): scaffold services/render/stages/ + planHash utility
Seed PR for the Phase 1 staged refactor of `executeRenderJob`. Creates the
new `packages/producer/src/services/render/stages/` directory and adds two
files that subsequent stage-extraction PRs build on:

- `planHash.ts`: a content-addressed sha256 helper plus a canonical-JSON
  serializer, with unit tests covering determinism, asset-order
  independence, sensitivity to each hashed component, and a delimiter-
  framing test against path/sha boundary collisions.
- `freezePlan.ts`: signature-only skeleton (throws "not implemented") for
  the eventual plan-freeze step. No callers; the body lands later when the
  distributed-render primitives compose the Phase 1 stages.

Zero behavior change. No code in `executeRenderJob` is touched and no
existing exports move. The new files are not yet referenced anywhere
outside the `stages/` directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:49:54 +00:00
James Russo a81bfb8278 Merge pull request #715 from heygen-com/studio/render-resolution-auto-orientation
feat(studio): collapse render resolution dropdown to Auto/1080p/4K + add square preset
2026-05-11 13:13:32 -04:00
James aa715ca8a0 feat(core,studio,cli): add square + square-4k canvas resolutions
The four existing presets only cover 16:9 (landscape) and 9:16 (portrait)
aspect ratios. A 1080×1080 square comp had nowhere to land at any scale:
"Auto" rendered at the comp's authored 1080×1080, and picking 1080p or 4K
mapped to a landscape/portrait preset whose aspect ratio mismatched, which
the producer's resolveDeviceScaleFactor validator rejects with
"does not match the aspect ratio of the composition".

Add `square` (1080×1080) and `square-4k` (2160×2160) to CANVAS_DIMENSIONS
in core. The existing `keyof typeof CANVAS_DIMENSIONS` derivation
extends the `CanvasResolution` union and `VALID_CANVAS_RESOLUTIONS` array
automatically, so the producer's validator, the render API route, and
the CLI `--resolution` flag pick the new presets up without further
changes.

- core: extend CANVAS_DIMENSIONS, RESOLUTION_ALIASES, and the
  htmlParser to recognize `data-resolution="square|square-4k"` and to
  infer square from equal width/height (vs. the prior "square defaults
  to portrait" tie-breaker).
- studio: extend the local ResolutionPreset / CANVAS_DIMENSIONS mirrors;
  collapse isPortraitComp into a 3-way `compAspect` helper so
  resolveResolution returns the square preset for square comps.
- cli: update --resolution help text on `init` and `render` to mention
  the new presets.
- tests: add square cases to renderOrchestrator's resolveDeviceScaleFactor
  suite (returns 1 for square→square, 2 for square→square-4k, rejects
  landscape preset on square comp), update the htmlParser test that
  previously pinned the "square→portrait" tiebreaker.
2026-05-11 16:48:54 +00:00
James 976ceabedc refactor(studio): simplify dropdown helpers + use stage-size message for dims
Cleanup from the /simplify pass on PR #715.

- App.tsx: subscribe to the runtime's `stage-size` message (which
  carries authoritative width/height post-applyCompositionSizing)
  instead of re-parsing data-width/data-height from the iframe DOM.
  Drops the cross-origin try/catch, querySelector, and parseInt logic,
  and fires once per comp load instead of on every state/timeline tick.
- App.tsx: import CompositionDimensions from RenderQueue instead of
  inlining the shape.
- RenderQueue.tsx: replace scaleLabel() with a SCALE_LABEL record,
  inline the one-call formatDims helper, and trim the type comment to
  the WHY.
2026-05-11 16:01:29 +00:00
James 534c70e308 fix(studio): keep dev server alive when puppeteer thumbnail launch fails
Two bugs in getSharedBrowser() could take down the entire Vite dev
server:

1. Unhandled rejection from puppeteer.launch() — the timeout error
   surfaces through puppeteer's internal RxJS chain, and any uncaught
   path crashes the Node process. The thumbnail route's try/catch
   doesn't always intercept it.
2. _browserLaunchPromise was never reset on failure, so subsequent
   thumbnail requests reused a stale rejected promise instead of
   retrying.

Wrap the IIFE in try/catch, return null on any failure (the thumbnail
route already handles a null adapter result with a 500), and reset
_browserLaunchPromise in a finally block so a transient launch failure
doesn't poison the singleton. Also drop the launch timeout from
puppeteer's 30s default to 10s so a wedged handshake fails fast instead
of stalling every pending thumbnail.

Verified locally: the dev server now logs
"[Studio] puppeteer launch failed — thumbnails disabled: ..." and
keeps serving the studio UI after a thumbnail request fails.
2026-05-11 15:55:38 +00:00
James b22252ee8b feat(studio): collapse render resolution dropdown to Auto / 1080p / 4K
Orientation is a property of the composition, not a user choice — the
backend's portrait/landscape presets are tied to the comp's authored
aspect ratio. Letting users pick "1080p portrait" for a landscape
composition just produces a wrong-aspect render.

The dropdown now exposes three scale choices (Auto / 1080p / 4K) and
maps to the correct portrait/landscape preset based on the active
composition's data-width / data-height. Native <select title> tooltips
are unreliable across browsers, so the resolved dimensions render
inline in each option label (e.g. "1080p · 1920×1080") — always
visible, no hover needed.

App.tsx tracks the active comp's dimensions by listening for the
existing hf-preview state/timeline postMessages (same source the
caption-detection logic uses) and passes them to RenderQueue. The
useRenderQueue / backend contract is unchanged: RenderQueue still emits
"landscape" | "portrait" | "landscape-4k" | "portrait-4k" | "auto".
2026-05-11 15:54:08 +00:00
James Russo 553688c996 Merge pull request #714 from heygen-com/fix/713-webaudio-playback-rate
fix(core): thread playback rate into WebAudio audio sources
2026-05-11 10:40:56 -04:00
James 117029a719 refactor(core): tighten WebAudio rate fix per review
- Hoist duplicated test mock helpers (createMockAudioContext / setupTransport /
  mockBuffer / mockEl) from the two describe blocks to module scope.
- Drop redundant math-derivation comments in schedulePlayback; the dedicated
  rate-aware tests are the canonical proof.
- Tighten setRate JSDoc.
- Add no-op guard in setRate when the new rate equals the current rate, so a
  duplicate set-playback-rate postMessage doesn't re-anchor or walk active
  sources for nothing.
- Add a regression test for the no-op guard, and strengthen the clamp test
  to schedule at rate=2 first so the clamp-to-1 assertion is non-vacuous.
2026-05-11 14:11:54 +00:00
James Russo 15aac00704 Merge pull request #684 from TheodorKleynhans/feat/cli-fps-fraction-syntax
feat(cli): accept ffmpeg-style rational fps (NTSC, PAL, slow-mo)
2026-05-11 10:09:57 -04:00
James 89ee1e36d7 fix(core): thread playback rate into WebAudio audio sources
WebAudioTransport scheduled AudioBufferSourceNodes with the implicit
default playbackRate of 1, so non-1x transport rates desynced visuals
from audio: GSAP timelines, the transport clock, and native <video>
all sped up while WebAudio-routed <audio> clips kept playing at 1x.

- schedulePlayback now accepts a rate, sets sourceNode.playbackRate,
  and scales the future-clip start delay by the rate (the in-progress
  buffer offset stays elapsed + mediaStart, which is rate-independent).
- New setRate() updates active sources in place and rebases the
  getTime() reference frame so the audio-master clock stays continuous
  across mid-playback rate changes.
- Runtime onSetPlaybackRate now forwards into webAudio.setRate, and
  player.play() schedules each clip with state.playbackRate.

Fixes #713
2026-05-11 13:59:19 +00:00
Miguel Ángel d4ba9080f3 Merge pull request #704 from WadydX/fix/init-video-short-flag-conflict
fix(cli): resolve init --video short-flag collision with global -V
2026-05-10 22:43:43 +02:00
WadydX 2778845bd3 fix(cli): handle init -V with explicit migration error 2026-05-10 21:31:17 +01:00
Miguel Ángel d27c4a12be Merge pull request #705 from WadydX/docs/clarify-cli-interactivity-scope
docs(cli): align interactivity and --human-friendly guidance with actual behavior
2026-05-10 22:18:15 +02:00
Miguel Ángel 57ea5641fe chore: release v0.5.7 v0.5.7 2026-05-10 18:34:43 +00:00
Miguel Ángel 5595e38bfb Merge pull request #706 from heygen-com/fix/player-renderseek-setter 2026-05-10 20:33:27 +02:00
Miguel ÁngelandClaude Opus 4.6 711ac22fd8 fix(runtime): add setter to delegated __player properties
The property delegation on window.__player used Object.defineProperty
with only a getter, causing "Cannot set property renderSeek which has
only a getter" when Studio's motion-wrapping code tried to reassign
__player.renderSeek with a wrapped version. This cascaded into an
infinite error loop making the timeline unusable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 18:27:58 +00:00
WadydX e0573c1b94 docs(cli): remove remaining invalid init --human-friendly references 2026-05-10 19:13:14 +01:00
WadydX f083888030 docs(cli): clarify interactivity defaults and human-friendly scope 2026-05-10 19:02:36 +01:00
WadydX e61d1fe002 fix(cli): avoid -V collision for init video flag 2026-05-10 19:01:31 +01:00
Miguel Ángel bd7bbae42d chore: release v0.5.6 v0.5.6 2026-05-10 05:40:30 +00:00
terencechoandClaude Sonnet 4.6 dd375e2784 fix(player): clamp scrubber progress when postMessage frame exceeds duration (#700)
The postMessage state path set `_currentTime` without clamping, while the
direct timeline path already used `Math.min(currentTime, _duration)`. A
final-frame state message with a frame count slightly past the end would
set `_currentTime > _duration`, causing the progress bar (position:
absolute, no overflow guard) to bleed out of the scrubber track and
visually cover the volume button, and the time display to show values
like "0:05 / 0:04".

- Clamp `_currentTime` in `_onMessage` to match the direct timeline path
- Clamp defensively in `updateTime` so the display layer never overflows
- Add `overflow: hidden` + `min-width: 0` to `.hfp-scrubber` as a CSS
  safety net; remove now-redundant `border-radius` from `.hfp-progress`
  (parent `overflow: hidden` handles clipping to the rounded shape)
- Apply the same `overflow: hidden` fix to `.hfp-volume-slider` for
  consistency; remove redundant `border-radius` from `.hfp-volume-fill`
- Add regression test covering the postMessage over-duration case

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:23:19 -07:00
Miguel Ángel 811f309ea5 Merge pull request #685 from heygen-com/feat/contribute-skill
feat(skills): contribute skill for registry block authoring
2026-05-10 05:13:40 +02:00
Miguel ÁngelandClaude Opus 4.6 9fdedaf3e7 feat(docs): add catalog contributing guide and rename skill
Add mintlify docs page for contributing blocks/components to the
registry catalog. Rename skill from contribute to contribute-catalog
for clearer intent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 01:38:05 +00:00
Miguel ÁngelandClaude Opus 4.6 082e19df22 fix(skills): tighten trigger surface and add dimension/duration comments
- Narrow description to disambiguate from hyperframes-registry (install)
  and hyperframes (in-project authoring) skills
- Add "adjust" comments on dimensions/duration defaults so agents don't
  blindly copy 1920x1080/10s for all composition shapes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 23:45:35 +00:00