refactor(studio): contexts, PropertyPanel split, duration fix, perf (#748)

* feat(studio): add manual DOM editing inspector (#466)

* fix: stabilize studio preview and runtime sync

* fix: pass selector through timeline thumbnails

* feat: add studio timeline editing

* fix: disambiguate timeline edit targets

* fix: stop timeline auto-scroll in fit mode

* feat: use percentage-based timeline zoom

* fix: sync timeline playhead on zoom changes

* fix: reset timeline scroll when returning to fit

* feat(studio): add manual DOM editing inspector

* docs: update studio manual dom editing guide

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

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

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

* fix(studio): restore toast plumbing after rebase

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

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

* feat(studio): refine manual design inspector

* fix(studio): polish manual design inspector

* fix(studio): keep color picker in viewport

* fix(studio): clarify color picker selection

* docs: update manual DOM editing guide

* fix(studio): keep gradient color picker open

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

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

* fix(studio): address manual editing review feedback

* fix(studio): make local font selection reliable

* fix(studio): improve dom picking and thumbnails

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

* fix(studio): prevent timeline track cutoff

* fix: copy Studio agent prompts in Safari

* fix(studio): hold canvas movement from inspector

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Mean pixel diffs for preview vs capture were:

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

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

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

* feat: persist studio manual edits via manifest

* fix(studio): stabilize manual edit manifest rendering

* fix(studio): allow master canvas layer selection

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

* fix(studio): reapply manual edits during playback

* fix(studio): keep rotation edit base stable

* feat(studio): highlight hovered canvas target

* fix(studio): drag hovered canvas targets immediately

* fix(studio): rotate manual edits around center

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

* fix(studio): allow small rotation adjustments

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

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

* feat(studio): reset selected manual edits

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

* feat: add studio group repositioning

* fix: preserve studio group selections

* fix: seed additive studio selection groups

* fix: select studio groups on pointerdown

* fix: harden studio group overlay events

* fix: address studio manual edit review feedback

* fix: apply nested manual edits in drilled previews

* fix: commit drag offsets from gesture math

* fix: persist manual preview edits on refresh

* fix: harden manual edit refresh apply

* fix: share manual edit render runtime

* chore: release v0.5.0-alpha.15

* feat(core): add studio animation preview APIs

* feat(studio): add alpha editor layer inspector

* chore: release v0.6.0-alpha.1

* feat(studio): enable inspector panels by default

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

* chore: release v0.6.0-alpha.2

* feat: auto-open timeline clip layers

* feat: show composition loading in studio

* feat: disable Studio timeline while composition loads

* chore: ignore .claude directory

* chore: release v0.6.0-alpha.3

* feat(studio): simplify inspector selection ux

* fix(studio): keep notion preview playback moving

* fix(studio): handle raster inspector clicks

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

Fixes and improvements based on power-user testing feedback:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Power-user audit fixes for the alpha studio:

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

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

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

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

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

* chore: release v0.6.0-alpha.4

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

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

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

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

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

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

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

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

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

* chore: release v0.6.0-alpha.5

* chore: release v0.6.0-alpha.6

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

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

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

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

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

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

* chore: release v0.6.0-alpha.7

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

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

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

* chore: release v0.6.0-alpha.8

* fix(runtime): comprehensive audio stutter fix

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

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

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

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

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

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

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

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

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

* chore: release v0.6.0-alpha.9

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(studio): remove unused deleteInFlightRef from Timeline

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: release v0.6.0-alpha.10

* chore: release v0.6.0-alpha.11

* fix(studio): clean next alpha inspector artifacts

* chore: release v0.6.0-alpha.12

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

Three bugs that compound in Studio preview:

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

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

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

Also fixes pre-existing ResolutionPreset type missing square variants.

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

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

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

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

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

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

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

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

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

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

* chore: release v0.6.0-alpha.13

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

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

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

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

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

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

Break the monolithic StudioApp component into focused modules:

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

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

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

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

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

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

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

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

* refactor(studio): consolidate duplicate helpers in useDomEditSession

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

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

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

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

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

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

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

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

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

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

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

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

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

* chore: upgrade to React 19

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

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

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

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

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

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

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

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

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

* chore: gitignore docs/superpowers

* feat(studio): add favicon

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Break the monolithic StudioApp component into focused modules:

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

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

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

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

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

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

* refactor(studio): consolidate duplicate helpers in useDomEditSession

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

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

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: gitignore docs/superpowers

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: prefix unused probeResult variable

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

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

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

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

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

---------

Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-05-12 20:18:52 +02:00
committed by GitHub
co-authored by Vance Ingalls Claude Opus 4.6
parent b8a2c48291
commit 38efe168e2
472 changed files with 274736 additions and 3513 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* Shared types and pure helpers used by the staged render pipeline.
*
* Lives in its own module so the stage files in `./stages/` can import the
* helpers they need without reaching back into `renderOrchestrator.ts` —
* the orchestrator imports the stage functions, so a runtime cycle would
* otherwise form (and grow as more stages are extracted).
*
* `renderOrchestrator.ts` re-exports everything declared here for
* backwards compatibility with existing test files and external callers.
*/
import { type CanvasResolution } from "@hyperframes/core";
import type { AudioElement, EngineConfig, ImageElement, VideoElement } from "@hyperframes/engine";
import type { CompiledComposition } from "../htmlCompiler.js";
import { type ProducerLogger } from "../../logger.js";
import type { ProgressCallback, RenderJob, RenderStatus } from "../renderOrchestrator.js";
export interface CompositionMetadata {
duration: number;
videos: VideoElement[];
audios: AudioElement[];
images: ImageElement[];
width: number;
height: number;
}
/**
* Floating-point tolerance for reconciling browser-discovered media timing
* against statically-parsed metadata. Used when the browser reports a
* slightly different `end` / `mediaStart` / `volume` than the compiled
* HTML and we want to ignore sub-millisecond float noise.
*/
export declare const BROWSER_MEDIA_EPSILON = 0.0001;
/**
* Browser-discovered media inside inlined sub-compositions can still report
* scene-local timing from the merged DOM (e.g. start=0, end=85.52) while the
* compiled metadata is already offset into the parent host timeline
* (e.g. start=4.417, end=89.937). Reproject browser end-time into the
* compiled element's time origin before reconciling it back into the render
* metadata.
*/
export declare function projectBrowserEndToCompositionTimeline(existingStart: number, browserStart: number, browserEnd: number): number;
/**
* Translate the user-facing `--resolution` flag into a Chrome
* `deviceScaleFactor`. The composition's intrinsic dimensions stay the
* page-layout viewport; the screenshot lands at output dims via DPR.
*
* The scale must be a positive integer ≥ 1 — fractional DPRs introduce
* visible aliasing and we'd rather fail loudly than produce a blurry
* 4K render. Downsampling (output < composition) is rejected because
* the user is unlikely to have intended it; if the use case appears
* we can plumb a separate flag.
*
* Throws on:
* - HDR + outputResolution (HDR compositor processes raw pixel buffers
* at composition dimensions and would need parallel scaling).
* - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k).
* - Non-integer scale ratio.
* - Downsampling (output dimensions smaller than composition).
*/
export declare function resolveDeviceScaleFactor(input: {
compositionWidth: number;
compositionHeight: number;
outputResolution: CanvasResolution | undefined;
hdrRequested: boolean;
alphaRequested: boolean;
}): number;
/**
* Write compiled HTML and sub-compositions to the work directory.
*
* Exported for integration tests. Not part of the stable public API —
* callers outside this package should use `executeRenderJob` instead.
*/
export declare function writeCompiledArtifacts(compiled: CompiledComposition, workDir: string, includeSummary: boolean): void;
export declare function applyRenderModeHints(cfg: EngineConfig, compiled: CompiledComposition, log?: ProducerLogger): void;
/**
* Mutate the `RenderJob` view of the pipeline's progress and fire the
* caller's `onProgress` callback. Hoisted here (out of `renderOrchestrator.ts`)
* so the stage modules can call it without forming a runtime cycle.
*
* `completedAt` is stamped on the terminal `"failed"` / `"complete"`
* transitions so callers that poll the job state can tell when the
* pipeline finished.
*/
export declare function updateJobStatus(job: RenderJob, status: RenderStatus, stage: string, progress: number, onProgress?: ProgressCallback): void;
//# sourceMappingURL=shared.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../src/services/render/shared.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC7E,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAClG,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAErE,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAE1F,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,SAAS,CAAC;AAE5C;;;;;;;GAOG;AACH,wBAAgB,sCAAsC,CACpD,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,MAAM,GACjB,MAAM,CAER;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE;IAC9C,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC/C,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACzB,GAAG,MAAM,CAgDT;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,OAAO,GACtB,IAAI,CAoDN;AAED,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,YAAY,EACjB,QAAQ,EAAE,mBAAmB,EAC7B,GAAG,GAAE,cAA8B,GAClC,IAAI,CAQN;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,SAAS,EACd,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,gBAAgB,GAC5B,IAAI,CAMN"}
@@ -0,0 +1,37 @@
/**
* assembleStage — Stage 6 of `executeRenderJob`. Final mux + faststart.
*
* Skipped entirely for png-sequence (there's no container to mux; the
* frames were copied directly to `outputPath` by `encodeStage`).
*
* When the composition has audio, runs `muxVideoWithAudio(videoOnlyPath,
* audioOutputPath, outputPath)`. When it doesn't, runs
* `applyFaststart(videoOnlyPath, outputPath)` to move the `moov` atom to
* the front so the file plays from a partial download.
*
* Hard constraints preserved verbatim:
* - The "Assembling final video" `updateJobStatus` payload fires at
* 90% at the start of the stage.
* - "Audio muxing failed: <err>" / "Faststart failed: <err>" throw
* verbatim on the respective `success: false` results.
*/
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
export interface AssembleStageInput {
job: RenderJob;
/** Encoded video produced by `encodeStage` or `captureStreamingStage`. */
videoOnlyPath: string;
/** Mixed audio path (only read when `hasAudio` is true). */
audioOutputPath: string;
/** Final on-disk output. */
outputPath: string;
hasAudio: boolean;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
}
export interface AssembleStageResult {
/** Wall-clock ms for the assemble phase. */
assembleMs: number;
}
export declare function runAssembleStage(input: AssembleStageInput): Promise<AssembleStageResult>;
//# sourceMappingURL=assembleStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"assembleStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/assembleStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAG/E,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,SAAS,CAAC;IACf,0EAA0E;IAC1E,aAAa,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,eAAe,EAAE,MAAM,CAAC;IACxB,4BAA4B;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B;AAED,MAAM,WAAW,mBAAmB;IAClC,4CAA4C;IAC5C,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAmC9F"}
@@ -0,0 +1,39 @@
/**
* audioStage — mix the composition's audio tracks into `workDir/audio.aac`.
*
* Trivial wrapper around `processCompositionAudio`. The stage is skipped
* (no ffmpeg invocation) when the composition has no audio elements; the
* timer is still set so the perf summary stays consistent across renders.
*
* Hard constraints preserved verbatim:
* - `audioOutputPath` is always `join(workDir, "audio.aac")`, regardless
* of whether any audio was actually produced.
* - `hasAudio` reflects `audioResult.success` from
* `processCompositionAudio`; it is `false` when there are no audio
* elements (skips the call entirely) and also when the call returns
* `success: false`.
* - `perfStages.audioProcessMs` is set whether or not the call ran.
*/
import type { CompositionMetadata } from "../shared.js";
export interface AudioStageInput {
projectDir: string;
workDir: string;
/** `join(workDir, "compiled")`; passed through to the audio mixer for asset resolution. */
compiledDir: string;
/** Composition duration (post-probe). Must be > 0 — probeStage guarantees this. */
duration: number;
/** Read-only view of `composition.audios`. */
audios: CompositionMetadata["audios"];
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
}
export interface AudioStageResult {
/** Always `join(workDir, "audio.aac")`. */
audioOutputPath: string;
/** True iff the audio mix actually produced a file. False when there are no audio elements. */
hasAudio: boolean;
/** Wall-clock ms for the audio mix phase. Zero-elements path is near-zero but always set. */
audioProcessMs: number;
}
export declare function runAudioStage(input: AudioStageInput): Promise<AudioStageResult>;
//# sourceMappingURL=audioStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"audioStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/audioStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,WAAW,EAAE,MAAM,CAAC;IACpB,mFAAmF;IACnF,QAAQ,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,MAAM,EAAE,mBAAmB,CAAC,QAAQ,CAAC,CAAC;IACtC,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,eAAe,EAAE,MAAM,CAAC;IACxB,+FAA+F;IAC/F,QAAQ,EAAE,OAAO,CAAC;IAClB,6FAA6F;IAC7F,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA0BrF"}
@@ -0,0 +1,78 @@
/**
* captureHdrStage — Z-ordered HDR / shader-transition layered composite.
*
* The most complex capture path:
* - Spawns a dedicated `domSession` for transparent-background screenshots.
* - Spawns an `hdrEncoder` (`spawnStreamingEncoder` with
* `rawInputFormat: "rgb48le"`) accepting pre-composited HDR frames.
* - Opens raw HDR video frame files (`hdrVideoFrameSources`) and reads
* them per-frame for native-HDR video layers.
* - Decodes 16-bit HDR PNGs once and blits them as image layers.
* - Queries Chrome z-order at layout-change boundaries and groups
* elements into DOM / HDR video / HDR image layers.
* - Composites bottom-to-top in Node memory, writing rgb48le buffers
* to the encoder's stdin.
*
* Cleanup invariants the design doc explicitly flags as risky —
* preserved verbatim from the in-process renderer:
* - `hdrEncoderClosed` / `domSessionClosed` flags gate 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 exits.
* - `cfg.forceScreenshot = true` is set unconditionally inside the
* layered path because `captureAlphaPng` hangs under
* `--enable-begin-frame-control`.
*
* Known follow-up: same runtime import cycle pattern as the other
* capture stages — the stage imports HDR helpers from
* `renderOrchestrator.ts` (runtime), which imports the stage back.
* Safe at runtime; a future PR will consolidate these helpers.
*/
import { type BeforeCaptureHook, type CaptureOptions, type EngineConfig, type HdrTransfer, getEncoderPreset } from "@hyperframes/engine";
import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import { type HdrDiagnostics, type HdrPerfCollector, type ProgressCallback, type RenderJob } from "../../renderOrchestrator.js";
import { type CompositionMetadata } from "../shared.js";
export interface CaptureHdrStageInput {
job: RenderJob;
cfg: EngineConfig;
log: ProducerLogger;
projectDir: string;
compiledDir: string;
framesDir: string;
videoOnlyPath: string;
width: number;
height: number;
totalFrames: number;
composition: CompositionMetadata;
hasHdrContent: boolean;
effectiveHdr: {
transfer: HdrTransfer;
} | undefined;
nativeHdrVideoIds: Set<string>;
nativeHdrImageIds: Set<string>;
videoTransfers: Map<string, HdrTransfer>;
imageTransfers: Map<string, HdrTransfer>;
hdrImageSrcPaths: Map<string, string>;
preset: ReturnType<typeof getEncoderPreset>;
effectiveQuality: number;
effectiveBitrate: string | undefined;
fileServer: FileServerHandle;
buildCaptureOptions: () => CaptureOptions;
createRenderVideoFrameInjector: () => BeforeCaptureHook | null;
/** Mutated in place (counters incremented). */
hdrDiagnostics: HdrDiagnostics;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
}
export interface CaptureHdrStageResult {
lastBrowserConsole: string[];
hdrPerf: HdrPerfCollector | undefined;
/** Wall-clock ms for the HDR capture phase. */
captureDurationMs: number;
/** ffmpeg-reported encode duration; overlapped with capture. */
encodeMs: number;
}
export declare function runCaptureHdrStage(input: CaptureHdrStageInput): Promise<CaptureHdrStageResult>;
//# sourceMappingURL=captureHdrStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"captureHdrStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/captureHdrStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAYH,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,WAAW,EAYhB,gBAAgB,EASjB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzD,OAAO,EAEL,KAAK,cAAc,EAEnB,KAAK,gBAAgB,EAGrB,KAAK,gBAAgB,EACrB,KAAK,SAAS,EASf,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAmB,KAAK,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEzE,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,EAAE,YAAY,CAAC;IAClB,GAAG,EAAE,cAAc,CAAC;IAEpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IAEtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IAEpB,WAAW,EAAE,mBAAmB,CAAC;IACjC,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE;QAAE,QAAQ,EAAE,WAAW,CAAA;KAAE,GAAG,SAAS,CAAC;IACpD,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACzC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACzC,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEtC,MAAM,EAAE,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;IAC5C,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;IAErC,UAAU,EAAE,gBAAgB,CAAC;IAC7B,mBAAmB,EAAE,MAAM,cAAc,CAAC;IAC1C,8BAA8B,EAAE,MAAM,iBAAiB,GAAG,IAAI,CAAC;IAE/D,+CAA+C;IAC/C,cAAc,EAAE,cAAc,CAAC;IAE/B,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B;AAED,MAAM,WAAW,qBAAqB;IACpC,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,OAAO,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACtC,+CAA+C;IAC/C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,qBAAqB,CAAC,CA2wBhC"}
@@ -0,0 +1,79 @@
/**
* captureStage — SDR disk-capture path of `executeRenderJob`.
*
* Handles both branches of the SDR / DOM-only-HDR disk-capture flow:
* - `workerCount > 1`: parallel capture with adaptive retry via
* `executeDiskCaptureWithAdaptiveRetry`.
* - `workerCount === 1`: sequential capture in the orchestrator process,
* reusing `probeSession` when available.
*
* The HDR layered branch (`useLayeredComposite === true`) and the streaming
* encode fusion path (`useStreamingEncode === true` with successful encoder
* spawn) live in separate stages.
*
* Hard constraints preserved verbatim:
* - `probeSession` is closed (and the local binding nulled) once the
* stage no longer needs it. The sequencer's `let probeSession` is
* updated via the returned result.
* - `captureAttempts` is mutated in place — the parallel path appends
* each retry attempt to the array the sequencer owns.
* - `workerCount` may be reduced by an adaptive retry; the returned
* value reflects the final worker count for the perf summary.
* - `lastBrowserConsole` is set to the buffer of whichever session was
* active last (probe session in the parallel close path; sequential
* session in the sequential path).
* - `job.framesRendered` is updated at the same per-frame / per-progress
* points; the same `Capturing frame N/M` `updateJobStatus` payloads
* fire at 30-frame and completion checkpoints (parallel) or every
* frame (sequential).
*
* Known follow-up: this stage imports `executeDiskCaptureWithAdaptiveRetry`
* from `renderOrchestrator.ts`, which itself imports the stage — a runtime
* cycle that resolves at module-init time because no stage function is
* invoked during load. A subsequent PR will consolidate the capture
* helpers (`executeDiskCaptureWithAdaptiveRetry`, `countFrameRanges`,
* `safeCleanup`, `sampleDirectoryBytes`, etc.) into a shared module so
* the stages can import them without reaching back into the orchestrator.
*/
import { type BeforeCaptureHook, type CaptureOptions, type CaptureSession, type EngineConfig } from "@hyperframes/engine";
import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import { type CaptureAttemptSummary, type ProgressCallback, type RenderJob } from "../../renderOrchestrator.js";
export interface CaptureStageInput {
fileServer: FileServerHandle;
workDir: string;
framesDir: string;
job: RenderJob;
/**
* `job.totalFrames` is `number | undefined` in the public type — the
* sequencer narrows it to a `number` via the probeStage result before
* calling this stage. Passed in explicitly here so the stage doesn't
* have to re-narrow on every reference.
*/
totalFrames: number;
cfg: EngineConfig;
log: ProducerLogger;
/** Initial worker count from `resolveRenderWorkerCount`; adaptive retry may reduce it. */
workerCount: number;
/** Reused for the sequential path's first session if non-null. */
probeSession: CaptureSession | null;
/** True for webm / mov / png-sequence (controls capture format + extension). */
needsAlpha: boolean;
/** Mutated in place — each parallel retry attempt is appended. */
captureAttempts: CaptureAttemptSummary[];
buildCaptureOptions: () => CaptureOptions;
createRenderVideoFrameInjector: () => BeforeCaptureHook | null;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
}
export interface CaptureStageResult {
/** Final worker count after any adaptive retry. */
workerCount: number;
/** Always `null` after the stage — the probe session is closed before the stage returns. */
probeSession: CaptureSession | null;
/** Browser console buffer from whichever session was active last. */
lastBrowserConsole: string[];
}
export declare function runCaptureStage(input: CaptureStageInput): Promise<CaptureStageResult>;
//# sourceMappingURL=captureStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"captureStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/captureStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,YAAY,EAMlB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAEL,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACf,MAAM,6BAA6B,CAAC;AAGrC,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,gBAAgB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,SAAS,CAAC;IACf;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,YAAY,CAAC;IAClB,GAAG,EAAE,cAAc,CAAC;IACpB,0FAA0F;IAC1F,WAAW,EAAE,MAAM,CAAC;IACpB,kEAAkE;IAClE,YAAY,EAAE,cAAc,GAAG,IAAI,CAAC;IACpC,gFAAgF;IAChF,UAAU,EAAE,OAAO,CAAC;IACpB,kEAAkE;IAClE,eAAe,EAAE,qBAAqB,EAAE,CAAC;IACzC,mBAAmB,EAAE,MAAM,cAAc,CAAC;IAC1C,8BAA8B,EAAE,MAAM,iBAAiB,GAAG,IAAI,CAAC;IAC/D,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAC;IACpB,4FAA4F;IAC5F,YAAY,EAAE,cAAc,GAAG,IAAI,CAAC;IACpC,qEAAqE;IACrE,kBAAkB,EAAE,MAAM,EAAE,CAAC;CAC9B;AAED,wBAAsB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAiH3F"}
@@ -0,0 +1,92 @@
/**
* captureStreamingStage — single-machine fused capture + encode path.
*
* Streaming mode pipes captured frame buffers directly into ffmpeg's stdin
* via `spawnStreamingEncoder`, skipping disk writes and the separate
* Stage 5 encode step. In effect, Stage 4 (capture) absorbs Stage 5
* (encode) for renders that fit the single-machine fusion path.
*
* The streaming path is gated by `shouldUseStreamingEncode(...)` upstream:
* - Disabled when output is png-sequence (no encoder).
* - Disabled for parallel renders auto-selected by calibration where the
* ordered streaming writer would stall later workers behind earlier
* ranges (the orchestrator decides this; the stage is told via input).
* - Disabled in distributed mode (which writes chunks to disk).
*
* If `spawnStreamingEncoder` fails for any non-abort reason, the stage
* returns `{ success: false }` and the sequencer falls back to the disk
* capture path. This mirrors the original orchestrator's flag-flip
* (`useStreamingEncode = false`).
*
* Hard constraints preserved verbatim from the in-process renderer:
* - `probeSession` is closed when the parallel path takes over, OR in
* the sequential session's `finally`. Either way the local binding
* is nulled and the result returns the updated value.
* - `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`
* `updateJobStatus` payloads fire at the same 30-frame and
* completion checkpoints (parallel) or every frame (sequential).
* - Encoder close + result inspection happens inside the stage; a
* `Streaming encode failed: ...` error throws on `success: false`.
* - Defensive cleanup of `streamingEncoder` happens in the stage's
* own `finally` regardless of success/failure, gated on
* `streamingEncoderClosed` so it's idempotent.
*
* Known follow-up (same as captureStage): this stage imports
* `updateJobStatus` from `renderOrchestrator.ts`, forming a runtime
* cycle with the orchestrator's import of `runCaptureStreamingStage`.
* Safe at runtime; a subsequent change will move the capture helpers
* into a shared module so the stages can import without reaching back.
*/
import { type BeforeCaptureHook, type CaptureOptions, type CaptureSession, type EngineConfig, spawnStreamingEncoder } from "@hyperframes/engine";
import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
/**
* Pre-built ffmpeg streaming-encoder options, exactly matching the
* second argument to `spawnStreamingEncoder`. The sequencer constructs
* this from its in-scope preset / dimensions / quality fields and
* passes it through so the stage doesn't have to reach back for the
* preset's internal shape.
*/
export type StreamingEncoderOptions = Parameters<typeof spawnStreamingEncoder>[1];
export interface CaptureStreamingStageInput {
fileServer: FileServerHandle;
workDir: string;
framesDir: string;
videoOnlyPath: string;
job: RenderJob;
/**
* `job.totalFrames` is `number | undefined` in the public type — the
* sequencer narrows it via the probeStage result before calling here.
*/
totalFrames: number;
cfg: EngineConfig;
log: ProducerLogger;
workerCount: number;
probeSession: CaptureSession | null;
/** For the spawn-failure log message context only. */
outputFormat: string;
/** Pre-built encoder options; passed straight to `spawnStreamingEncoder`. */
streamingEncoderOptions: StreamingEncoderOptions;
buildCaptureOptions: () => CaptureOptions;
createRenderVideoFrameInjector: () => BeforeCaptureHook | null;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
}
export type CaptureStreamingStageResult = {
/** Streaming path ran successfully — sequencer should skip the disk path AND Stage 5 encode. */
success: true;
/** Wall-clock ms for the encode phase (overlapped with capture; from the encoder's own report). */
encodeMs: number;
probeSession: CaptureSession | null;
lastBrowserConsole: string[];
workerCount: number;
} | {
/** Spawn failed (non-abort) — sequencer should fall back to the disk path. */
success: false;
};
export declare function runCaptureStreamingStage(input: CaptureStreamingStageInput): Promise<CaptureStreamingStageResult>;
//# sourceMappingURL=captureStreamingStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"captureStreamingStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/captureStreamingStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,YAAY,EAUjB,qBAAqB,EACtB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAG/E;;;;;;GAMG;AACH,MAAM,MAAM,uBAAuB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAC;AAElF,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,gBAAgB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,GAAG,EAAE,SAAS,CAAC;IACf;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,YAAY,CAAC;IAClB,GAAG,EAAE,cAAc,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,cAAc,GAAG,IAAI,CAAC;IACpC,sDAAsD;IACtD,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,uBAAuB,EAAE,uBAAuB,CAAC;IACjD,mBAAmB,EAAE,MAAM,cAAc,CAAC;IAC1C,8BAA8B,EAAE,MAAM,iBAAiB,GAAG,IAAI,CAAC;IAC/D,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B;AAED,MAAM,MAAM,2BAA2B,GACnC;IACE,gGAAgG;IAChG,OAAO,EAAE,IAAI,CAAC;IACd,mGAAmG;IACnG,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,cAAc,GAAG,IAAI,CAAC;IACpC,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,WAAW,EAAE,MAAM,CAAC;CACrB,GACD;IACE,8EAA8E;IAC9E,OAAO,EAAE,KAAK,CAAC;CAChB,CAAC;AAEN,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,0BAA0B,GAChC,OAAO,CAAC,2BAA2B,CAAC,CAuLtC"}
@@ -0,0 +1,55 @@
/**
* compileStage — pure compile pass of `executeRenderJob`.
*
* Runs `compileForRender` on the entry HTML, applies render-mode hints
* (which may flip `cfg.forceScreenshot` on for compositions that need it),
* writes compiled artifacts to `workDir/compiled/`, builds the
* `CompositionMetadata` view of the result, and resolves the
* `deviceScaleFactor` for supersampling.
*
* The probe sub-stage (browser launch, duration discovery, recompile,
* media reconciliation) lives in a sibling stage. This stage stops at
* the point where the in-process renderer enters the `if (needsBrowser)`
* branch.
*
* Hard constraints preserved verbatim from the in-process renderer:
* - `applyRenderModeHints(cfg, ...)` is allowed to mutate `cfg.forceScreenshot`.
* - `perfStages.compileOnlyMs` is set to wall-clock ms around the
* `compileForRender` call only.
* - The `log.info("Compiled composition metadata", ...)` line is emitted
* after writing artifacts, with the same payload shape as before.
* - The `log.info("Supersampling composition via deviceScaleFactor", ...)`
* line is emitted only when `deviceScaleFactor > 1`.
*/
import type { EngineConfig } from "@hyperframes/engine";
import type { CompiledComposition } from "../../htmlCompiler.js";
import type { ProducerLogger } from "../../../logger.js";
import { type CompositionMetadata } from "../shared.js";
import type { RenderJob } from "../../renderOrchestrator.js";
export interface CompileStageInput {
projectDir: string;
workDir: string;
/** Absolute path to the entry HTML (already resolved to standalone-entry if needed). */
htmlPath: string;
/** The relative `entryFile` string, used only for log payloads. */
entryFile: string;
job: RenderJob;
/** EngineConfig — may be mutated via `cfg.forceScreenshot = true`. */
cfg: EngineConfig;
/** True when the output format requires an alpha channel (webm/mov/png-sequence). */
needsAlpha: boolean;
log: ProducerLogger;
/** Cooperative-cancellation probe; throws `RenderCancelledError` when aborted. */
assertNotAborted: () => void;
}
export interface CompileStageResult {
compiled: CompiledComposition;
composition: CompositionMetadata;
deviceScaleFactor: number;
outputWidth: number;
outputHeight: number;
/** Wall-clock ms for the pure `compileForRender` call only (excludes artifact writes). */
compileOnlyMs: number;
}
export declare function runCompileStage(input: CompileStageInput): Promise<CompileStageResult>;
//# sourceMappingURL=compileStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"compileStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/compileStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAEjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAE7D,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,SAAS,CAAC;IACf,sEAAsE;IACtE,GAAG,EAAE,YAAY,CAAC;IAClB,qFAAqF;IACrF,UAAU,EAAE,OAAO,CAAC;IACpB,GAAG,EAAE,cAAc,CAAC;IACpB,kFAAkF;IAClF,gBAAgB,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,WAAW,EAAE,mBAAmB,CAAC;IACjC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,0FAA0F;IAC1F,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,wBAAsB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAwD3F"}
@@ -0,0 +1,67 @@
/**
* encodeStage — Stage 5 of `executeRenderJob`. Two paths share the stage:
*
* 1. png-sequence: no encoder. Captured PNGs are renamed to
* `frame_NNNNNN.png` and copied to `outputPath`. Audio (if any) is
* written as an `audio.aac` sidecar.
* 2. mp4 / webm / mov: invokes `encodeFramesFromDir` (or the chunked-
* concat variant when `enableChunkedEncode` is on) to produce
* `videoOnlyPath`. The mux + faststart pass lives in `assembleStage`.
*
* Skipped entirely when the streaming-encode fusion path
* (`captureStreamingStage`) already produced `videoOnlyPath` — the
* sequencer gates the call on `!streamingHandled`.
*
* Hard constraints preserved verbatim:
* - The "Writing PNG sequence" / "Encoding video" `updateJobStatus`
* payload fires at 75% from inside the stage.
* - The png-sequence path throws "png-sequence output requested but no
* PNGs were captured to ..." if `framesDir` is empty.
* - The png-sequence audio sidecar is only written when
* `hasAudio && existsSync(audioOutputPath)`.
* - For encoded output, `enableChunkedEncode` selects
* `encodeFramesChunkedConcat` vs `encodeFramesFromDir` — same
* branch + same args.
* - `Encoding failed: <err>` throws on the encoder's
* `success: false`.
*/
import { getEncoderPreset } from "@hyperframes/engine";
import type { ProducerLogger } from "../../../logger.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
export interface EncodeStageInput {
job: RenderJob;
log: ProducerLogger;
/** Output path: a directory for png-sequence, a file for everything else. */
outputPath: string;
/** Where captured frames live on disk. */
framesDir: string;
/** Encoded video output (ignored on the png-sequence path). */
videoOnlyPath: string;
/** Output dimensions (post-deviceScaleFactor). */
width: number;
height: number;
/** True when the output format requires an alpha channel; selects frame extension. */
needsAlpha: boolean;
/** True iff the composition has audio. Drives the sidecar copy. */
hasAudio: boolean;
/** Path to the mixed audio (only read when `hasAudio` is true). */
audioOutputPath: string;
/** Mp4 vs png-sequence vs … gates the entire stage branch. */
isPngSequence: boolean;
/** Encoder preset (codec, preset, pixelFormat, hdr). Only used on the non-png path. */
preset: ReturnType<typeof getEncoderPreset>;
effectiveQuality: number;
effectiveBitrate: string | undefined;
/** Producer config — enables the chunked-concat encoder when on. */
enableChunkedEncode: boolean;
chunkedEncodeSize: number;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
onProgress?: ProgressCallback;
}
export interface EncodeStageResult {
/** Wall-clock ms for the encode (or png-copy) phase. */
encodeMs: number;
}
export declare function runEncodeStage(input: EncodeStageInput): Promise<EncodeStageResult>;
//# sourceMappingURL=encodeStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"encodeStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/encodeStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH,OAAO,EAGL,gBAAgB,EACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAG/E,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,EAAE,cAAc,CAAC;IACpB,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,aAAa,EAAE,MAAM,CAAC;IACtB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,sFAAsF;IACtF,UAAU,EAAE,OAAO,CAAC;IACpB,mEAAmE;IACnE,QAAQ,EAAE,OAAO,CAAC;IAClB,mEAAmE;IACnE,eAAe,EAAE,MAAM,CAAC;IACxB,8DAA8D;IAC9D,aAAa,EAAE,OAAO,CAAC;IACvB,uFAAuF;IACvF,MAAM,EAAE,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;IAC5C,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,oEAAoE;IACpE,mBAAmB,EAAE,OAAO,CAAC;IAC7B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,cAAc,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAyFxF"}
@@ -0,0 +1,74 @@
/**
* extractVideosStage — pre-extract source-video JPEG sequences, plus the
* HDR color-space pre-detection that runs against the originals.
*
* The stage runs the existing video-frame extraction pipeline
* (`extractAllVideoFrames`) but also probes BOTH videos and images for
* native HDR color spaces before extraction (since extraction may convert
* SDR → HDR). The HDR maps are returned so the downstream HDR auto-detect
* block and the HDR composite path can identify which sources are natively
* HDR vs. converted-SDR.
*
* Hard constraints preserved verbatim from the in-process renderer:
* - `composition.audios` is mutated in place to add audio entries
* auto-discovered from video files via ffprobe (preserves the
* "video had audio, no explicit <audio> tag" path).
* - `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` (default `false`) — when `true`, the stage
* instructs `materializeExtractedFramesForCompiledDir` to recursively
* copy frames into `compiledDir/__hyperframes_video_frames/<videoId>/`
* instead of creating a single symlink. Required for distributed
* plan() output where the planDir must be self-contained across
* machines (symlinks don't survive S3 / GCS round-trips). Default
* `false` preserves the in-process renderer's symlink behavior.
*/
import { type CaptureVideoMetadataHint, type EngineConfig, type FrameLookupTable, type HdrTransfer, type VideoColorSpace, extractAllVideoFrames } from "@hyperframes/engine";
import { type RenderJob } from "../../renderOrchestrator.js";
import { type CompositionMetadata } from "../shared.js";
export interface ExtractVideosStageInput {
projectDir: string;
/** `join(workDir, "compiled")`; the directory the file server roots at. */
compiledDir: string;
job: RenderJob;
cfg: EngineConfig;
/** Mutated in place — audio entries auto-discovered from video files are pushed onto `composition.audios`. */
composition: CompositionMetadata;
abortSignal: AbortSignal | undefined;
assertNotAborted: () => void;
/**
* Whether to materialize symlinks into real files when staging extracted
* frames inside `compiledDir`. Default `false` preserves the in-process
* renderer's behavior (single symlink per video). Distributed `plan()`
* passes `true` so the planDir is self-contained.
*/
materializeSymlinks?: boolean;
}
export interface ExtractVideosStageResult {
/** Result of `extractAllVideoFrames`, or `null` if the composition has no videos. */
extractionResult: Awaited<ReturnType<typeof extractAllVideoFrames>> | null;
/** Frame-lookup table for the runtime video-frame injector, or `null` if no frames were extracted. */
frameLookup: FrameLookupTable | null;
videoReadinessSkipIds: string[];
videoMetadataHints: CaptureVideoMetadataHint[];
/** Set of video IDs whose ORIGINAL color space was HDR (pre-extraction). */
nativeHdrVideoIds: Set<string>;
/** Per-video original transfer function (BT.2020 PQ/HLG). */
videoTransfers: Map<string, HdrTransfer>;
/** Set of image IDs whose ORIGINAL color space was HDR. */
nativeHdrImageIds: Set<string>;
/** Per-image original transfer function. */
imageTransfers: Map<string, HdrTransfer>;
/** Per-image resolved on-disk source path (used by the HDR composite path). */
hdrImageSrcPaths: Map<string, string>;
/** Per-image probed color space, or `null` for images that couldn't be probed. */
imageColorSpaces: (VideoColorSpace | null)[];
/** Wall-clock ms for the video extraction phase. */
videoExtractMs: number;
}
export declare function runExtractVideosStage(input: ExtractVideosStageInput): Promise<ExtractVideosStageResult>;
//# sourceMappingURL=extractVideosStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"extractVideosStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/extractVideosStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,eAAe,EAGpB,qBAAqB,EAItB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,KAAK,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAExD,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,EAAE,YAAY,CAAC;IAClB,8GAA8G;IAC9G,WAAW,EAAE,mBAAmB,CAAC;IACjC,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,qFAAqF;IACrF,gBAAgB,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC,GAAG,IAAI,CAAC;IAC3E,sGAAsG;IACtG,WAAW,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACrC,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,kBAAkB,EAAE,wBAAwB,EAAE,CAAC;IAC/C,4EAA4E;IAC5E,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,6DAA6D;IAC7D,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACzC,2DAA2D;IAC3D,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,4CAA4C;IAC5C,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACzC,+EAA+E;IAC/E,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,kFAAkF;IAClF,gBAAgB,EAAE,CAAC,eAAe,GAAG,IAAI,CAAC,EAAE,CAAC;IAC7C,oDAAoD;IACpD,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,wBAAwB,CAAC,CAiJnC"}
@@ -0,0 +1,87 @@
/**
* freezePlan — write the meta/{composition,encoder,chunks}.json + plan.json
* manifest at the end of `plan()`, compute the planHash from the frozen
* artifacts, and return the manifest path.
*
* Signature-only skeleton: there are no callers yet. The function body
* lands when `services/distributed/plan.ts` is added and composes the
* stage primitives.
*
* See DISTRIBUTED-RENDERING-PLAN.md §2.1 phase 6, §4.1 directory layout,
* §4.3 LockedRenderConfig.
*/
import type { Fps } from "@hyperframes/core";
import type { PlanDimensions } from "./planHash.js";
/**
* The encoder configuration locked in at plan time. Mirrors §4.3
* LockedRenderConfig in the design doc.
*/
export interface LockedRenderConfig {
captureMode: "beginframe" | "screenshot";
forceScreenshot: boolean;
deviceScaleFactor: number;
useLayeredHdrComposite: boolean;
/** Hard-pinned to "software" in v1 distributed renders. */
browserGpuMode: "software";
warmupTicks: number;
encoder: "libx264-software" | "libx265-software" | "prores-software" | "png-sequence";
ffmpegVersion: string;
preset: string;
crf?: number;
bitrate?: string;
/** Equal to chunkSize for closed-GOP concat-copy. */
gopSize: number;
closedGop: true;
forceKeyframes: "n=0";
pixelFormat: string;
chunkSize: number;
chunkCount: number;
/** Snapshot of `PRODUCER_RUNTIME_*` env vars at plan time. */
runtimeEnv: Record<string, string>;
}
export interface CompositionMetadataJson {
durationSeconds: number;
width: number;
height: number;
fps: Fps;
videoCount: number;
audioCount: number;
imageCount: number;
}
export interface ChunkSliceJson {
index: number;
startFrame: number;
/** Inclusive end frame for the chunk. */
endFrame: number;
}
/**
* Inputs to `freezePlan`. `planDir` already contains `compiled/`,
* `video-frames/`, and (optionally) `audio.aac` by the time freezePlan
* runs — see §2.1 phases 1-5.
*/
export interface FreezePlanInput {
/** Absolute path to the plan directory being frozen. */
planDir: string;
composition: CompositionMetadataJson;
encoder: LockedRenderConfig;
chunks: readonly ChunkSliceJson[];
dimensions: PlanDimensions;
producerVersion: string;
/** Hash of the deterministic-font snapshot baked into the plan. */
fontSnapshotSha: string;
}
export interface FreezePlanResult {
/** Absolute path to `plan.json`. */
planJsonPath: string;
/** Content-addressed planHash; see §4.2. */
planHash: string;
}
/**
* Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then
* compute `planHash` over the canonicalized contents.
*
* Skeleton — body lands when the distributed-render primitives compose the
* stage functions.
*/
export declare function freezePlan(_input: FreezePlanInput): Promise<FreezePlanResult>;
//# sourceMappingURL=freezePlan.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"freezePlan.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/freezePlan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mBAAmB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IAEjC,WAAW,EAAE,YAAY,GAAG,YAAY,CAAC;IACzC,eAAe,EAAE,OAAO,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,sBAAsB,EAAE,OAAO,CAAC;IAChC,2DAA2D;IAC3D,cAAc,EAAE,UAAU,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC;IAGpB,OAAO,EAAE,kBAAkB,GAAG,kBAAkB,GAAG,iBAAiB,GAAG,cAAc,CAAC;IACtF,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;IAChB,cAAc,EAAE,KAAK,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IAGpB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IAEnB,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,uBAAuB;IACtC,eAAe,EAAE,MAAM,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,GAAG,CAAC;IACT,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,uBAAuB,CAAC;IACrC,OAAO,EAAE,kBAAkB,CAAC;IAC5B,MAAM,EAAE,SAAS,cAAc,EAAE,CAAC;IAClC,UAAU,EAAE,cAAc,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,mEAAmE;IACnE,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oCAAoC;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAEnF"}
@@ -0,0 +1,95 @@
/**
* planHash — content-addressed hash for distributed render plans.
*
* See DISTRIBUTED-RENDERING-PLAN.md §4.2 for the contract:
*
* planHash = sha256(
* SCHEMA_PREFIX
* ⊕ composition_html_bytes
* ⊕ asset_shas (sorted by relative path)
* ⊕ font_snapshot_sha
* ⊕ encoder_config_canonical_json
* ⊕ producer_version
* ⊕ ffmpeg_version
* ⊕ fps ⊕ width ⊕ height ⊕ format
* )
*
* Two invocations with identical inputs MUST produce the same hash. Adapters
* use this to short-circuit `plan()` on workflow replay and to detect
* cross-version mismatches (§9.3 PLAN_HASH_MISMATCH).
*
* Pure utility; no caller exists yet — the distributed-render
* `services/distributed/plan.ts` will compose it.
*
* ## Encoding contract
*
* Every string-typed component (`fontSnapshotSha`,
* `encoderConfigCanonicalJson`, `producerVersion`, `ffmpegVersion`, asset
* paths and shas, the dimensions tuple) is hashed as UTF-8. External
* verifiers must encode the same way. Binary fields (`compositionHtml`)
* are hashed verbatim.
*/
/**
* SHA-256 hex digest of an asset, paired with its plan-relative path. Sort
* order across an asset list is by `path` (byte-wise ascending) to keep the
* digest deterministic regardless of filesystem walk order.
*/
export interface PlanAssetHash {
/** Plan-relative path. Stable across machines (no absolute paths). */
path: string;
/** Hex-encoded sha256 of the asset bytes. */
sha256: string;
}
/**
* Render dimensions + frame rate that affect the encoded output. Kept as a
* separate type so callers can reuse it for log lines and adapter payloads.
*/
export interface PlanDimensions {
/** Frame rate numerator (e.g. 30 or 30000 for NTSC). */
fpsNum: number;
/** Frame rate denominator (e.g. 1 or 1001 for NTSC). */
fpsDen: number;
width: number;
height: number;
format: "mp4" | "mov" | "png-sequence" | "webm";
}
export interface PlanHashInput {
/** Raw bytes of `compiled/index.html` after recompile. */
compositionHtml: Uint8Array;
/** All non-HTML assets referenced from the composition, in any order. */
assets: readonly PlanAssetHash[];
/** Hash of the deterministic-font snapshot used to render. */
fontSnapshotSha: string;
/** Canonical-JSON serialization of `meta/encoder.json` (LockedRenderConfig). */
encoderConfigCanonicalJson: string;
/** `@hyperframes/producer` package version that produced the plan. */
producerVersion: string;
/** ffmpeg `--version` line (e.g. "ffmpeg version 6.1.1"). */
ffmpegVersion: string;
dimensions: PlanDimensions;
}
/**
* Compute the content-addressed planHash for a frozen plan.
*
* The hash incorporates each component as a separate `update()` call after a
* fixed delimiter byte; that prevents two distinct inputs from accidentally
* sharing a hash if their concatenation happens to collide (e.g. asset count
* vs. asset bytes).
*/
export declare function computePlanHash(input: PlanHashInput): string;
/**
* Canonical-JSON serialization helper. JSON keys are emitted in
* byte-wise-sorted order recursively, with no whitespace. Used to feed the
* encoder config into `computePlanHash` such that semantically-equal configs
* produce equal hashes regardless of source key ordering.
*
* Supports the subset that LockedRenderConfig values use: primitives, plain
* objects, and arrays. Throws on functions, symbols, BigInts, and Maps.
*/
export declare function canonicalJsonStringify(value: unknown): string;
/**
* Convenience helper: sha256 a file path or buffer, return hex digest. Used
* by the eventual `freezePlan` to hash assets on disk.
*/
export declare function sha256Hex(bytes: Uint8Array | string): string;
//# sourceMappingURL=planHash.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"planHash.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/planHash.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAmBH;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,KAAK,GAAG,KAAK,GAAG,cAAc,GAAG,MAAM,CAAC;CACjD;AAED,MAAM,WAAW,aAAa;IAC5B,0DAA0D;IAC1D,eAAe,EAAE,UAAU,CAAC;IAC5B,yEAAyE;IACzE,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;IACjC,8DAA8D;IAC9D,eAAe,EAAE,MAAM,CAAC;IACxB,gFAAgF;IAChF,0BAA0B,EAAE,MAAM,CAAC;IACnC,sEAAsE;IACtE,eAAe,EAAE,MAAM,CAAC;IACxB,6DAA6D;IAC7D,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,cAAc,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CA+B5D;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAoB7D;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,MAAM,CAI5D"}
@@ -0,0 +1,65 @@
/**
* probeStage — browser probe + recompile + media reconciliation.
*
* Runs only when `needsBrowser` is true (root duration unknown OR there are
* unresolved nested compositions). Owns the `FileServerHandle` and the
* `CaptureSession` it creates and returns them so the sequencer can both
* reuse them downstream (the capture stage reuses the probe session) and
* clean them up in its `finally` block.
*
* Hard constraints preserved verbatim from the in-process renderer:
* - `recompileWithResolutions` runs inside this stage because it depends
* on browser-resolved durations, even though §2.1 of the distributed
* plan lists recompile as a sibling phase.
* - `composition` (videos/audios/duration) is mutated in place — callers
* downstream see the reconciled view through the same object reference.
* - The stage computes the final composition `duration` and `totalFrames`
* and returns them. Assigning those values onto the `RenderJob` is the
* sequencer's responsibility — a future chunk worker can't mutate the
* orchestrator's `job` object, and keeping the assignment in one place
* prevents the same value living in two writers.
* - 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.
*/
import { type CaptureSession, type EngineConfig } from "@hyperframes/engine";
import type { CompiledComposition } from "../../htmlCompiler.js";
import { type FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import { type CompositionMetadata } from "../shared.js";
import type { RenderJob } from "../../renderOrchestrator.js";
export interface ProbeStageInput {
projectDir: string;
workDir: string;
job: RenderJob;
cfg: EngineConfig;
log: ProducerLogger;
assertNotAborted: () => void;
/** From compileStage. May be replaced via `recompileWithResolutions`. */
compiled: CompiledComposition;
/** From compileStage. Mutated in place (videos/audios pushed, duration set). */
composition: CompositionMetadata;
width: number;
height: number;
needsAlpha: boolean;
deviceScaleFactor: number;
}
export interface ProbeStageResult {
/** May be reassigned from `recompileWithResolutions`. */
compiled: CompiledComposition;
/** Created when `needsBrowser` was true; `null` otherwise. */
fileServer: FileServerHandle | null;
/** Created when `needsBrowser` was true; `null` otherwise. */
probeSession: CaptureSession | null;
/** The probeSession's `browserConsoleBuffer`, or `[]` if no probe ran. */
lastBrowserConsole: string[];
/** Composition duration (post-probe). Guaranteed > 0 — the stage throws on <= 0. */
duration: number;
totalFrames: number;
/** Wall-clock ms for the entire probe phase (near-zero when `needsBrowser` was false). */
browserProbeMs: number;
}
export declare function runProbeStage(input: ProbeStageInput): Promise<ProbeStageResult>;
//# sourceMappingURL=probeStage.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"probeStage.d.ts","sourceRoot":"","sources":["../../../../src/services/render/stages/probeStage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,YAAY,EAIlB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAMjE,OAAO,EAAoB,KAAK,gBAAgB,EAAqB,MAAM,qBAAqB,CAAC;AACjG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAE7D,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,EAAE,YAAY,CAAC;IAClB,GAAG,EAAE,cAAc,CAAC;IACpB,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,yEAAyE;IACzE,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,gFAAgF;IAChF,WAAW,EAAE,mBAAmB,CAAC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,OAAO,CAAC;IACpB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,yDAAyD;IACzD,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,8DAA8D;IAC9D,UAAU,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACpC,8DAA8D;IAC9D,YAAY,EAAE,cAAc,GAAG,IAAI,CAAC;IACpC,0EAA0E;IAC1E,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,0FAA0F;IAC1F,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA0RrF"}