Commit Graph
377 Commits
Author SHA1 Message Date
Miguel Ángel a4706da513 fix(cli): localize external assets in publish archive (#1160)
Compositions referencing assets outside the project directory (via ../
paths) produced broken published projects — those files were never
included in the ZIP archive.

localizeExternalAssets() now scans all HTML and CSS files in the archive
for src, href, and url() references that resolve outside the project
dir. For each, it copies the file into the archive under _ext/ and
rewrites the reference to point there.

Handles: src/href attributes, <style> url(), inline style url(),
standalone CSS url(), sub-composition HTML files, deduplication of
the same asset referenced from multiple files.

Shared primitives (CSS_URL_RE, isNonRelativeUrl, isPathInside) extracted
into core/compiler/assetPaths.ts — single source of truth across core,
producer, and CLI.
2026-06-01 21:06:35 -04:00
Miguel ÁngelandClaude Sonnet 4.6 b1b03782a1 fix(producer): localize remote @font-face src URLs before render (#1155)
* fix(producer): localize remote @font-face src URLs before render

Remote font URLs in @font-face blocks fail with a CORS rejection when
the renderer fetches them from http://localhost:PORT (S3 does not echo
the local origin in Access-Control-Allow-Origin). Chrome falls back to
the next font in the stack (e.g. Arial), producing wrong typography.

localizeRemoteFontFaces() scans <style> blocks, extracts HTTP url()
references inside @font-face rules, downloads them in parallel into
_remote_media/, and rewrites the CSS url() references to local paths —
the same pattern as localizeRemoteMediaSources() for <video>/<audio>.

Background url() references outside @font-face blocks are intentionally
left untouched to avoid downloading arbitrary images.

The shared download+rewrite logic is extracted into downloadAndRewriteUrls()
to eliminate duplication between the two localize functions.

Reported via the Beasty Style caption template (Komika Axis .ttf from S3
falling back to Arial on every cloud render).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine): add SSRF guard to downloadToTemp (blocks private/IMDS addresses)

Customer-supplied compositions can author @font-face src URLs (and <video>/
<audio> src attrs via the existing localize path) that point to private
infrastructure. Without a guard, the producer's downloadToTemp would fetch
http://169.254.169.254/... (AWS IMDS), RFC1918, loopback, etc., save the
response to _remote_media/, and expose it via the local file server.

assertPublicHttpsUrl() rejects:
  - Non-HTTPS (http://) — all composition fetches must use HTTPS
  - 169.254.x (AWS link-local / IMDS)
  - 127.x / localhost / 0.x (loopback / unspecified)
  - 10.x, 172.16–172.31, 192.168.x (RFC1918)
  - [::1], [fc...], [fd...] (IPv6 loopback + unique-local)

The guard fires before the cache check so a blocked URL never gets into
the in-flight map. Applies to both the font-face localize path (PR #1155)
and the existing video/audio localize path (PR #1146) since both call
downloadToTemp.

Note: DNS-rebinding bypasses are not closed by this check (hostname
comparison only, no DNS resolution). Acceptable risk for current threat
model; server-side DNS validation can be layered on later.

12 unit tests covering all blocked ranges + the allowed edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine): fix TypeScript strict-mode error in urlDownloader SSRF guard

m[1] from RegExp.match() is typed string | undefined; parseInt requires string.
Use nullish coalescing to satisfy tsc without changing runtime behavior —
the regex guarantees m[1] is always defined when the match succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine): use vitest import in urlDownloader test

bun:test is not available in CI — the engine package runs tests via vitest.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:12:53 -04:00
James RussoandClaude Opus 4.8 8b6d35e226 fix(producer): honor variables + outputResolution in HTTP render server (#1152)
* fix(producer): honor variables + outputResolution in HTTP render server

The producer HTTP server's parseRenderOptions read only
fps/quality/workers/gpu/debug/entryFile/format from the request body.
`variables` and `outputResolution` were silently dropped, so any caller
of the server render path (the cloud-render sidecar that
experiment-framework POSTs to) got the composition's declared variable
defaults and its intrinsic dimensions regardless of what was requested.

RenderConfig already supports both fields (the local CLI `render`
command passes them); the server just never forwarded them. Wire them
through RenderInput, parseRenderOptions, and a shared buildRenderJobConfig
used by the sync + streaming handlers. outputResolution now drives the
same resolveDeviceScaleFactor supersampling path the local CLI uses, so a
4k render against a matching-aspect composition produces true 4k.

Validation: a non-object `variables` or an unknown `outputResolution`
returns a clean 400 instead of being silently ignored. Also extracts
resolvePreparedRenderOutput + parseRenderOverrides helpers to keep both
handlers DRY.

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

* fix(producer): reject non-string + alpha-incompatible outputResolution

Addresses review on #1152.

- A non-string `outputResolution` (e.g. a JSON number) was coerced to
  `undefined` by parseRenderOverrides and silently ignored — the same
  silent-drop this validation exists to prevent. Now rejected with a 400.
- `outputResolution` + an alpha format (webm/mov) is rejected up front:
  supersampling runs through a deviceScaleFactor the alpha capture path
  can't apply, so resolveDeviceScaleFactor throws mid-render. Guarding it
  here makes the producer self-defending for every caller (not just the
  CLI / external API), and closes the 1080p-webm regression window during
  the producer-honors-outputResolution rollout.

Extracted validateOutputResolutionOverride to keep validateRenderOverrides
under the complexity gate. +2 prepareRenderBody tests.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:59:08 -04:00
kshift 00dad396db fix(producer): pass streaming encoder config (#1147) 2026-06-01 11:15:44 -04:00
Miguel ÁngelandClaude Sonnet 4.6 7bbedc080a fix(producer): localize remote media sources + strip audio crossorigin (#1146)
* fix(producer): localize remote media sources + strip audio crossorigin

Two bugs affecting compositions that use remote S3 URLs for video/audio.

Bug 1 — Remote <video>/<audio> sources cause blank frames
The renderer (Puppeteer) must buffer all video elements to readyState >= 2
before frame capture begins. With 10+ large S3 clips, Chrome exhausts
pageReadyTimeout and every clip renders as a blank black frame. Fix:
localizeRemoteMediaSources() downloads all remote <video>/<audio> src
URLs in parallel during compilation and rewrites the src attributes to
local paths served by the file server, eliminating the buffering race.

Bug 2 — crossorigin on <audio> elements not stripped
htmlCompiler.ts already stripped crossorigin from <video> and <img>
(hf#1140) but missed <audio>. Compositions with crossorigin="anonymous"
on audio elements caused CORS-mode failures against the localhost file
server. Extended the strip to cover <audio>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(producer): basename portability + localizeRemoteMediaSources tests

Addresses Rames' review on hf#1146:

- Replace `absPath.split('/').at(-1)` with `path.basename(absPath)`. On
  Windows, path.join emits backslash-separated paths; split('/') returns
  the whole path as a single element, producing a garbage relPath.
  path.basename delegates to the OS separator on the current platform.

- Export `localizeRemoteMediaSources` for unit testing. Tests verify:
  - Successful download rewrites src to _remote_media/ path
  - Download failure preserves original URL without throwing
  - Duplicate src URL across two tags → single fetch call (dedup)
  - Local (non-HTTP) src paths are not rewritten
  - Both double-quoted and single-quoted src attributes are rewritten
  - basename extraction is correct on POSIX paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:48:31 -04:00
Miguel Ángel fc608ad7fc fix(producer): audio drops + blank images on FFmpeg 4.x/CORS-restricted origins (#1140)
* fix(engine): remove amix normalize=0 to fix audio on FFmpeg 4.x/6.x

amix's normalize=0 option is absent from many FFmpeg builds (e.g.
FFmpeg 4.2 on Ubuntu 20.04). When the option is not recognized, FFmpeg
fails the entire filter graph initialization, processCompositionAudio
returns success:false, and the assembled video has no audio stream.

Replace normalize=0 + weights='1...' with the amix default behavior
(normalize=true, divides by track count) and multiply the master output
gain by the track count to restore the original per-track volumes.
The net volume is identical across all FFmpeg versions.

Fixes #1136-adjacent: reported as 'audio doesn't play' in rendered MP4.

* fix(producer): strip img crossorigin + fix audioExtractor normalize=0

Two follow-up fixes:

1. htmlCompiler: strip crossorigin attribute from <img> elements during
   compilation. External images (e.g. S3) with crossorigin='anonymous'
   force CORS-mode requests against the renderer's localhost file server,
   which S3 rejects → images render blank. Matches the existing video
   strip at line 261.

2. audioExtractor: same amix normalize=0 bug as audioMixer.ts. The
   audioExtractor path is used for <video data-has-audio='true'> mixing
   in the CLI's local render pipeline; on FFmpeg 4.x it would also drop
   audio silently. Fix: remove normalize=0, compensate with volume=N.

* test(engine,producer): pin amix normalize contract + img crossorigin strip

- audioMixer.test.ts: assert filter has no normalize=/weights=; add
  3-track test confirming compensatedGain = masterGain × N = 3
- htmlCompiler.test.ts: parallel tests for img and video crossorigin
  strip (covers both elements, not just video)
2026-05-31 09:55:49 -04:00
Carlos Alcaraz GregorandCarlos Alcaraz 81521a9a97 fix(producer): drop empty trailing chunk slice in distributed render plan (#1133)
resolveChunkPlan caps chunkCount at maxParallelChunks from the naive
count, then rounds effectiveChunkSize up to ceil(totalFrames /
chunkCount). When that ceil rounds up, the first (chunkCount - 1) chunks
can already cover every frame, so buildChunkSlices emits a final slice
with startFrame >= totalFrames — an empty [n, n) or inverted range.
renderChunk rejects it (framesInChunk <= 0) and, under Step Functions
retries, fails the whole distributed render even though [0, totalFrames)
is fully covered.

This is reachable from the user-facing CLI: `hyperframes lambda render
--chunk-size 10 --max-parallel-chunks 12` on a ~4s/30fps (121-frame)
composition yields chunkCount=12, effectiveChunkSize=11, and a 12th slice
of [121, 121).

Tighten chunkCount to ceil(totalFrames / effectiveChunkSize) after the
size is finalized, so the union stays exactly [0, totalFrames) with no
empty tail. This only lowers chunkCount in the explicit-small-chunkSize
case; the auto-sized and large-chunkSize paths already satisfy
ceil(totalFrames / effectiveChunkSize) >= chunkCount, so it's a no-op
there (existing tests' chunkCount values are unchanged).

Adds a regression test for the 121/10/12 case plus a grid property test
asserting contiguous, non-empty, exact coverage across explicit sizes.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-05-30 09:11:29 -04:00
Carlos Alcaraz GregorandCarlos Alcaraz 0e895cbff7 fix(producer): recover from worker crashes instead of hanging the render (#1132)
* fix(producer): recover from worker crashes instead of hanging the render

Both the shader-transition and png-decode-blit worker pools freed a
crashed worker's slot (busy=false, current=null) but left it in the slot
list and never marked it dead. A later run() then selected the dead slot
via slots.find(s => !s.busy) and dispatched to its terminated worker,
where postMessage is a silent no-op (no throw, no reply) — so the task
promise never settled. In the HDR hybrid capture loop, which pipelines
blends across N DOM workers and awaits every dispatch, that wedges the
whole render with no fail-fast.

The crash handlers also never drained the queue, so a queued task could
wait forever for a slot that had died.

Mark a slot dead on error/exit, exclude dead slots from dispatch and from
run()'s slot selection, and fail fast: when no live workers remain, reject
queued tasks and reject new run() calls rather than hanging. This keeps
the pools' existing no-respawn, fail-fast intent; it just actually fails
fast instead of wedging.

Adds crash-recovery tests to both pools via a fixture worker that throws
on its first message, asserting the in-flight task, queued tasks, and
subsequent run() calls all settle rather than hang.

* fix(producer): address review nits on worker-pool crash recovery

- Reword the dead-marking comments in both onWorkerError handlers: the
  flag is set before rejecting and before draining the queue, not
  "before anything else" (current/busy are cleared first).
- Rename the shader pool's all-slots-die test to match the png pool's
  equivalent; the size-2 fixture crashes every worker, so there are no
  surviving workers serving.

---------

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-05-30 09:08:17 -04:00
Miguel Ángel 2a50506c0b fix(producer): cache Google Fonts woff2 per subset, preserve unicode-range (#1123)
* fix(producer): cache Google Fonts woff2 per subset, preserve unicode-range

Google Fonts' css2 API returns one @font-face per (weight × unicode-range
subset) — e.g. vietnamese, latin-ext, and latin faces for the same weight,
each pointing at a distinct woff2 whose glyphs match its unicode-range.

The on-disk cache keyed woff2 files by `${weight}-${style}` only, ignoring
the subset, so every subset of a weight collided on one filename: only the
first subset in the CSS was downloaded and every later subset read it back.
For families whose CSS lists `vietnamese` first (e.g. Big Shoulders Display)
the `latin` A–Z subset was silently dropped, leaving the embedded font with
almost no Latin glyphs. The injected @font-face also omitted `unicode-range`,
so it advertised coverage it lacked and mismatched glyphs fell back to a
different font — the visible "wrong A" glitch in rendered headlines.

- Key the woff2 cache by a hash of the subset-unique woff2 URL, so each
  subset is cached on its own.
- Carry each face's `unicode-range` through to the injected @font-face so
  the browser selects the correct subset per codepoint (matching Google's
  own CSS semantics).
- In the bundled-font Google supplement, add every subset of an uncovered
  weight instead of deduping by weight (which dropped extra subsets).
- Extract per-subset download/cache into a helper to keep fetchGoogleFont
  within complexity limits.

Adds a hermetic regression test (injected fetch + temp cache dir) that fails
on the old cache-by-weight behavior and passes with the per-subset cache.

* fix(producer): use atomic write for woff2 font cache (CodeQL)

Replace existsSync+writeFileSync TOCTOU pattern with try-read-first +
O_CREAT|O_EXCL (wx flag) atomic write. Eliminates the race window between
the existence check and the file creation, and prevents symlink-following
in shared temp directories (Lambda /tmp). Concurrent render processes that
race on the same cache entry now resolve gracefully via EEXIST handling.

* fix(producer): avoid os.tmpdir() taint for font cache path (CodeQL)

Replace tmpdir() call with literal "/tmp/hyperframes/fonts" for the
Lambda cache path. Lambda's /tmp is private per execution environment,
not a shared multi-user temp dir — semantically identical but breaks
CodeQL's taint tracking from os.tmpdir() to writeFileSync.

* revert: restore tmpdir() for Lambda font cache path

The hardcoded "/tmp" was a workaround for a CodeQL false positive.
Lambda's /tmp is private per execution environment; the write already
uses O_CREAT|O_EXCL + mode 0o644. Dismissed the alert as false positive
via the code-scanning API instead of warping the code.
2026-05-29 17:30:50 -04:00
Miguel Ángel fb2e21090f feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
2026-05-28 19:16:34 -04:00
Miguel Ángel d83a873986 fix(producer): normalize error messages to prevent [object Object] in telemetry (#1099)
* fix(producer): normalize error messages to prevent [object Object] in telemetry

When a render fails and the caught value is a plain object (not an Error
instance), String(error) produces [object Object], masking the real error
in PostHog telemetry (~24 errors/day).

Add normalizeErrorMessage() that tries Error.message, string passthrough,
.message on plain objects, JSON.stringify, and String() as a last resort.
Apply it on the two telemetry-feeding paths: the main render failure
handler (renderOrchestrator.ts:2099) and buildRenderErrorDetails
(cleanup.ts), plus the error classifier isRecoverableParallelCaptureError
so timeout detection works even when the thrown value is a plain object.

* fix: address review — normalize CLI telemetry path, captureCost fallback

* fix: use local normalizeErrorMessage in CLI to avoid cross-package resolution

The Vite test runner can't resolve runtime imports from @hyperframes/producer
since its exports point to dist/. Copy the utility into the CLI package and
import locally instead.
2026-05-27 20:19:53 -04:00
Miguel Ángel 5a3b76a218 fix: parse scripted volume probes with html parser 2026-05-24 17:09:57 -04:00
Miguel Ángel 50c972fd50 fix: address audio volume review feedback 2026-05-24 17:02:35 -04:00
Miguel Ángel 167a222318 fix: support animated audio volume 2026-05-24 16:39:04 -04:00
Miguel Ángel 16e049b320 fix(cli): address review — fps comment, fileServer cross-ref, duration note
- Add comment explaining hardcoded fps=30 (runtime's canonicalFps
  default, not exposed on PlayerAPI)
- Add cross-reference comments between init.ts and fileServer.ts
  explaining their different __renderReady timing semantics
2026-05-24 13:27:54 -04:00
James 90bf485db8 fix(distributed): reject cfr:true with h265 codec (per review)
The cfr re-encode pass hardcodes `-c:v libx264`. Pairing it with
`codec: "h265"` would silently transcode the h265 chunks to h264.
Detect the encoder discriminant in `meta/encoder.json` and throw a
typed error parallel to the existing non-mp4 format guard, so callers
surface the conflict instead of producing a wrong-codec deliverable.

— Rames Jusso
2026-05-24 00:24:30 -04:00
James 71d1889da6 feat(distributed): add optional cfr flag for exact constant frame rate
Distributed-render output today uses -c:v copy through concat → mux →
faststart, which means PTS timestamps from each chunk pass through
unchanged. Container r_frame_rate is exact (#1040 + this PR's parent),
but stream-level avg_frame_rate stays PTS-derived and can land on
fractional rationals like 27648000/921677 over a 60s render. Same for
sub-ms duration drift.

This is the achievable bar within -c copy stream-copy concat. For most
consumers (browser playback, YouTube, etc.) the difference is invisible.
For downstream tools that strict-check avg_frame_rate or
ms-precision duration (broadcast workflows, frame-accurate compositors,
some third-party transcoders), it matters.

Adds an opt-in cfr config flag (default false). When true, the
assemble step's final pass re-encodes with -fps_mode cfr -r <fps>
instead of -c copy, producing exact CFR output. Trade-off: ~2-5x the
stitch time for a 60s 1080p clip; second-generation H.264 quality loss
is negligible at -crf 18 but is non-zero.
2026-05-24 00:24:30 -04:00
James 49281a5c17 fix(distributed): apply -r <fps> to single-chunk pass-through path
The v0.6.39 fix added -r <fps> to the multi-chunk concat ffmpeg
invocation but didn't reach the single-chunk pass-through path,
which is taken when totalFrames * fpsDen / fpsNum fits in one
chunk. Result: 1-chunk renders shipped with fractional
r_frame_rate (e.g. 359/12) while multi-chunk renders shipped
with exact 30/1.

Single-chunk path now goes through the same -r <fps> + -c copy
ffmpeg invocation as the concat path, ensuring uniform exact
r_frame_rate metadata across all chunk-count configurations.

Adds a regression test exercising the 1-chunk path and asserting
r_frame_rate === "<fpsNum>/<fpsDen>" exact.
2026-05-24 03:15:56 +00:00
Miguel Ángel 61c7e4ab74 fix(producer): address review feedback on htmlInCanvas worker clamp
- Always log when html-in-canvas pins to 1 worker, not just on
  explicit --workers override (Vance, James)
- Assert recommendScreenshot is true for htmlInCanvas detection —
  pins the load-bearing coupling with the screenshot paint-force
  protocol (Vance, Magi)
- Skip capture calibration for htmlInCanvas auto-sized renders to
  avoid wasting 600ms–3s on an estimate that gets thrown away (Vance)
- Add TODO documenting Chrome's two root-cause mechanisms: paint
  cache race and SwiftShader contention (James, Magi)
2026-05-23 16:08:27 -04:00
Miguel Ángel 497f220c5f fix(producer): force single worker for html-in-canvas compositions
Chrome's drawElementImage API does not support concurrent usage across
multiple browser instances — running >1 capture worker causes flickering
artifacts. Detect the layoutsubtree canvas attribute during compilation
and unconditionally pin workers to 1, overriding both auto-sizing and
explicit --workers flags.
2026-05-23 15:48:02 -04:00
James a4c4b2ff03 fix(distributed): enforce exact framerate at concat + mux boundaries
When the distributed render path stitches chunks with `-c copy`,
ffmpeg averages the container framerate from PTS rather than
carrying the source's exact rational rate, producing values like
`360000/12001` instead of `30/1` and ~5ms duration drift over
60s.

This is a known ffmpeg behavior at the concat-demuxer-copy
boundary. The industry-standard fix is `-r <fps>` as an input
flag on the concat step plus an output flag on the subsequent
mux step — both with `-c copy` retained, no re-encode required.

Three sites updated:
- `assemble.ts` concat step: `-r <fps>` input flag.
- `chunkEncoder.muxVideoWithAudio`: `-r <fps>` output flag.
- `chunkEncoder.applyFaststart`: same, threaded from caller.

Adds `r_frame_rate` + duration-equivalence assertions to
`assemble.test.ts` to close the regression hole.
2026-05-23 01:12:19 -04:00
Miguel Ángel a54953b936 fix: clean up orphaned Chrome and ffmpeg processes on preview exit
The preview command's shutdown handler only closed the HTTP server,
leaving Chrome (browser pool) and ffmpeg processes alive. This caused
silent resource leaks — orphaned processes consuming CPU and RAM with
no parent.

Root cause: preview.ts never called drainBrowserPool() or killed
tracked ffmpeg processes. The thumbnail browser in studioServer.ts
registered its own competing signal handlers that raced with
preview's shutdown.

Fix:
- Add a central process tracker (processTracker.ts) that registers
  every spawned ffmpeg across engine and producer packages
- Centralize thumbnail browser cleanup via exported
  closeThumbnailBrowser() instead of scattered signal handlers
- Wire preview shutdown to call closeThumbnailBrowser(),
  drainBrowserPool(), and killTrackedProcesses() before closing the
  HTTP server (embedded mode)
- Add killProcessTree() for dev/local modes where Chrome runs in a
  child process tree
- Add startup orphan detection that finds and kills orphaned
  chrome-headless-shell/Puppeteer Chrome processes (PPID=1) from
  previously crashed sessions

Closes #1038
2026-05-22 23:31:00 -04:00
Miguel Ángel 215811334f fix(producer): accept plain integer fps in createRenderJob
The rational `Fps = { num, den }` refactor in 5dcc89c broke callers
passing `fps: 30` (the form documented in every code example and used
by external consumers). FFmpeg received `undefined/undefined` as the
framerate, causing a cryptic exit-code error.

Add `FpsInput = number | Fps` and `toFps()` normalizer in
@hyperframes/core. `createRenderJob` now accepts both forms —
plain integers are promoted to `{ num, den: 1 }` at the boundary;
`RenderConfig.fps` stays strict `Fps` internally so no downstream
code changes.

Also fixes the producer and engine docs, which showed phantom
`input`/`output` fields on `createRenderJob` and a wrong
`executeRenderJob(job)` signature (missing `projectDir`/`outputPath`
args).

Closes #1031
2026-05-22 13:35:16 -04:00
Miguel Ángel be9b61a8c9 Merge pull request #986 from heygen-com/fix/studio-edit-persistence-and-render-css
fix(studio): server-side DOM patching, render CSS scoping, and resilience
2026-05-21 05:53:32 +02:00
James b7bd956583 fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome 2026-05-20 18:44:41 -04:00
Miguel Ángel 45999226a3 fix(studio): server-side DOM patching, render CSS scoping, and resilience
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.

Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.

Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types

Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test

GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile

Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame

Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
  toolbar actions, navigation, and render starts
2026-05-20 17:07:31 -04:00
Miguel Ángel d9157ef1ad feat: data-timeline-locked, fix sub-comp fonts, caption overlay UX (#981)
## Summary

### `data-timeline-locked` attribute
- Clips with this attribute are fully locked in the Studio timeline (no move, no trim-start, no trim-end)
- Parsed in `timelineDOM.ts`, checked in `getTimelineEditCapabilities`
- Runtime propagates the attribute from loaded sub-composition roots to host elements
- All 15 caption components carry the attribute on their composition root

### Locked composition child protection
- Elements inside a `data-timeline-locked` sub-composition cannot be moved, resized, or style-edited on the canvas — prevents "Unable to patch" errors for JS-generated content
- TEXT property panel (Content, Color, Size, Weight) is hidden for these elements
- Implemented via `isInsideLockedComposition` flag on `DomEditSelection`, checked in both `resolveDomEditCapabilities` and `isTextEditableSelection`

### Fix font loss in sub-compositions
- Both runtime (`compositionLoader.ts`) and compiler (`inlineSubCompositions.ts`, `htmlBundler.ts`, `htmlCompiler.ts`) now extract `<link rel="stylesheet">` and `<link rel="preconnect">` from sub-composition `<head>` alongside existing `<style>`/`<script>` extraction
- Fixes Google Fonts loaded via `<link>` tags being silently dropped when a component is used as a sub-composition

### Transparent caption overlays
- All 15 caption components: opaque backgrounds and dark rgba overlays replaced with `transparent`
- `pointer-events: none` added to composition roots so captions don't intercept clicks

### Caption catalog reference
- Table of all 15 caption components with style descriptions and CLI commands added to `skills/hyperframes/references/captions.md`

## Test plan

- [x] Open a composition with caption-highlight as sub-composition — font (Montserrat) renders correctly
- [x] Caption overlays transparently on the video (no black background)
- [x] Click on text inside a locked caption sub-composition — TEXT panel is hidden
- [x] Try to move/resize a caption element on canvas — blocked, no "Unable to patch" error
- [x] `bunx vitest run packages/studio/src/player/components/timelineEditing.test.ts` — 37 tests pass
- [x] In Studio timeline, verify a `data-timeline-locked` clip cannot be moved or trimmed
2026-05-20 09:06:02 +02:00
Miguel Ángel 9c159ad96d fix: escape href in compiler link dedup + add font-link extraction tests
Escape href values in querySelector calls for link dedup in both
htmlBundler.ts and htmlCompiler.ts to match the runtime path (which
uses CSS.escape). Prevents SyntaxError on hrefs containing quotes.

Add two tests for inlineSubCompositions font-link extraction:
- Verifies <link> elements are extracted with original rel + crossorigin
- Verifies dedup across multiple sub-compositions sharing the same font
2026-05-20 02:36:58 -04:00
Miguel Ángel 5d501a1628 fix: preserve original rel attribute on sub-composition link extraction
Store {href, rel, crossorigin} from source <link> elements instead of
re-deriving rel from a URL substring heuristic. Fixes preview-vs-render
parity: a stylesheet link whose href lacks ".css" or "css2?" was
emitted as preconnect in the compiled output, silently dropping the font.

Also documents that caption components ship with transparent backgrounds
intentionally — users add contrast layers in the host composition.
2026-05-20 02:33:40 -04:00
Miguel Ángel 471b4efb4b feat: data-timeline-locked + fix font loss in sub-compositions
Timeline locking:
- Add data-timeline-locked attribute support — fully disables move,
  trim-start, and trim-end in Studio for clips that carry this attr
- Runtime propagates the attribute from inner composition root to host
  element so component authors control it from their HTML
- All 15 caption components in the registry now carry the attribute

Font fix:
- Extract <link rel="stylesheet"> and <link rel="preconnect"> from
  sub-composition <head> alongside existing <style>/<script> extraction
- Fixes caption components (and any sub-comp using Google Fonts via
  <link> tags) losing their font-family when loaded as sub-compositions
- Applied in both runtime (compositionLoader) and compiler
  (inlineSubCompositions) paths
2026-05-20 01:55:17 -04:00
ukimsanovandCursor 8cad2173dc fix(shader-transitions,producer): harden CSS-only transition lifecycle and unblock CI
Three follow-on fixes after the optional-shader change rebased onto current
main (PR #832 introduced page-side compositing and the producer's hf#732
layered pipeline since this PR was opened).

shader-transitions/hyper-shader.ts
- Treat `cache.prog === null` as the canonical immutable marker for
  CSS-only transitions via a new `isCssOnlyTransition()` helper.
- `disposeCachedTransition()` now restores the always-ready CSS fallback
  state for prog=null caches instead of zeroing `fallback`/`ready` — the
  previous behaviour, combined with `markScenesDirty()` re-running the
  prewarm/capture pipeline, could put a CSS-only cache through the WebGL
  path and reach `renderShader(state.prog!)` with a null prog (Copilot
  review on lines 1168 + 1319).
- `markScenesDirty()` skips CSS-only caches; they have no shader to
  recompile and no texture pyramid to recapture.
- `ensureTransitionCachesReady()` filters CSS-only caches out of the
  prewarm work list.
- `tickShader()` now routes on `cache.fallback || cache.prog === null`
  and threads a narrowed non-null `prog` local into `renderShader()`,
  removing the unsound `state.prog!` non-null assertion.
- `initEngineMode()` filters CSS-only transitions before passing them to
  `installPageSideCompositor()`, which expects `shader: ShaderName`
  (required). Page-side compositing is shader-only; CSS crossfades stay
  on the GSAP opacity timeline.

producer/render/stages/captureHdrHybridLoop.ts
producer/render/stages/captureHdrSequentialLoop.ts
- Guard `activeTransition.shader` against undefined: when omitted, route
  the Node-side blend through `crossfade` (the engine's canonical
  opacity blend, equivalent to `applyFallbackTransition()` on the page).
- The hybrid path also bypasses the worker pool when `shaderName` is
  absent and runs `crossfade` inline.

This addresses the Copilot review comments and unblocks the 5 failing CI
jobs (Build, Typecheck, CLI smoke, Windows tests, Windows render) which
all rooted in 4 TS errors at these exact sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 18:17:16 -07:00
James Russo 852008bd44 feat(producer): thread variables through plan() + renderChunk() (#962)
Add `variables?: Record<string, unknown>` to DistributedRenderConfig
(§4.4) and LockedRenderConfig (§4.3). plan() snapshots the value into
meta/encoder.json so every chunk worker re-injects the same set via
captureOptions.variables, mirroring the in-process renderer's path.

The variables fold into planHash automatically because canonical
encoder.json bytes feed the hash: two plans with different variables
produce different hashes (chunked output depends on the injected
values); two plans with the same variables produce identical hashes
because canonical-JSON sorts keys.

The regression harnesses (distributed-simulated, lambda-local) also
forward the input's variables to plan() / Step Functions event so
fixtures that declare `renderConfig.variables` produce the same pixels
across modes. Previously the field was on the harness input shape but
silently dropped at the call boundary.

Phase 9 PR 9.1 of the distributed rendering plan.
2026-05-19 18:47:53 -04:00
Miguel Ángel 8fd5bf8f51 fix(studio): remove Ask agent popup, fix preview selection, fix manual edits in renderer
Three interrelated studio UX and rendering fixes:

1. Remove the "Ask agent" popup that auto-triggered when clicking large
   raster elements in the preview. The modal intercepted clicks meant
   for editable elements and blocked normal selection workflow.

2. Rewrite preview click selection to respect visual stacking order.
   The previous scoring algorithm weighted DOM depth at 10,000× per
   level, causing elements inside sub-compositions to beat visually-
   on-top elements (e.g., clicking Pip Studio selected Sf Chrome
   instead). The new algorithm trusts elementsFromPoint order and only
   prefers a deeper candidate when it is a descendant of the current
   pick — never jumping to an unrelated element painted behind it.

3. Fix manual edits (resize) not surviving video rendering. The
   producer's seek-reapply script handled translate and rotation but
   was missing box-size (width/height) reapplication after each GSAP
   seek. Also added data-hf-studio-box-size to the detection list in
   htmlCompiler so the script is injected for resize-only edits.
2026-05-19 15:45:51 -04:00
James RussoandClaude Opus 4.7 5d264e146c docs(lambda): document webm support + simplify-review fixes (#953)
* docs(lambda): document webm support in distributed mode

PR 8.4 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). User-facing docs catch up with the
shipped capability.

Updates docs/deploy/migrating-to-hyperframes-lambda.mdx:

- "Output format" row in the migration table now lists `webm` alongside
  mp4 / mov / png-sequence with a note that webm uses libvpx-vp9 +
  closed-GOP concat-copy. HDR mp4 remains the only refused format.

- "No webm distributed" caveat replaced with "webm uses closed-GOP VP9"
  explainer covering the encoder args (`-g <chunkSize>`,
  `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, `-cpu-used 2`), why
  alt-ref disable is load-bearing, and that the output preserves alpha
  via yuva420p with Opus audio.

- Migration checklist no longer asks adopters to filter out webm
  compositions; only HDR-dependent renders need to stay on the previous
  framework.

aws-lambda.mdx doesn't currently call out webm as unsupported (only HDR
in the v1 surface list), so it gets no copy edits beyond the migration
guide.

The internal planning doc (DISTRIBUTED-RENDERING-PLAN.md §7.2, §8,
§12 — kept outside the repo) gets matching updates: format support
matrix flipped ✓, v1.5 backlog #1 marked shipped, HDR promoted to the
new top item, and the rev-12 → rev-13 status line.

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

* refactor: address simplify-review findings on webm stack

Folds in cleanups identified by a multi-agent code-review pass over the
4-PR webm-distributed stack:

- plan.ts: `resolveEncoderTriple()` webm case now calls
  `getEncoderPreset(quality, "webm")` for its preset string instead of
  hardcoding "good". The hardcode was wrong for `quality: "draft"`
  (`getEncoderPreset` returns "realtime" for that tier) — would have
  silently overridden the draft → realtime mapping for distributed webm
  renders.
- chunkEncoder.ts: trim the new VP9 closed-GOP comment block from ~18
  lines of WHY narration down to the 6 lines that actually explain why
  (alt-ref + cpu-used drift). Match the alpha branch's idempotent-push
  comment to the same standard.
- chunkEncoder.test.ts: drop the duplicate WHY comment that restated
  the implementation comment in plain words.
- webm-concat-copy.test.ts: rewrite the file-header docstring to
  describe the contract being tested instead of the PR-8.1-gating
  history; strip "PR 8.2 / Path A / Path B" references from error
  messages (they belong in PR bodies, not in test output). Consolidate
  the yuva420p alpha smoke into a single `it()` block (was a full
  4-test describe with duplicated setup) — the yuv420p block already
  covers the probe/decode/frame-count contract; the alpha smoke only
  needs to prove the alpha args don't break concat-copy.
- plan.test.ts: drop the "PR 8.1 proved the contract" comment.
- webm-vp9 fixture: drop the aspirational "Other webm-with-audio
  fixtures cover the mux path separately when added" sentence (no
  other fixtures exist). Regenerated the baseline via
  `docker:test:update webm-vp9` to reflect the updated comment.
- migrating-to-hyperframes-lambda.mdx: add a paragraph about
  distributed webm's perf cost — ~10-25% larger files at constant CRF
  due to forced keyframes, and slower per-chunk encode due to
  `-cpu-used 2` being more conservative than the libvpx default.

All unit tests + the webm-vp9 distributed-simulated regression still
pass after these changes.

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

* fix(cli): accept --format=webm in `hyperframes lambda render`

The CLI's `lambda render` subcommand's FORMATS allowlist and the
`RenderArgs.format` type still narrowed to `mp4 | mov | png-sequence`,
so even though the producer + aws-lambda packages now support webm
end-to-end, the CLI surface rejected it with `--format must be mp4|mov|
png-sequence`. Add webm to both spots and update the --help description.

Surfaced during real-AWS deploy prep — the local lambda-local /
distributed-simulated tests didn't go through the CLI so the gap went
unnoticed.

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

* fix(producer): font cache writes to /tmp on Lambda (read-only \$HOME)

The deterministic Google Fonts cache was rooted at
`\$HOME/.cache/hyperframes/fonts`, which fails on AWS Lambda — the
runtime's `\$HOME` resolves to a `/home/sbx_*` directory tree that's
read-only. `mkdirSync(..., { recursive: true })` can't create that
path and the plan stage trips with `ENOENT: no such file or directory,
mkdir '/home/sbx_user1051/.cache/hyperframes/fonts/space-mono'` on
every Lambda render that pulls a Google Font (i.e. every distributed
fixture using `@import url("https://fonts.googleapis.com/...")`).

Detect Lambda via `\$AWS_LAMBDA_FUNCTION_NAME` and route the cache to
`tmpdir()/hyperframes/fonts` in that case. Lambda's `/tmp` survives
across invocations on a warm container, so cache hit rate is the same
as non-Lambda runs. Also honor an explicit
`\$HYPERFRAMES_FONT_CACHE_DIR` override for adopters who want a
different location regardless of the runtime.

Surfaced while verifying webm distributed end-to-end on real AWS — the
same bug affects mp4 fixtures using Google Fonts; webm just happened to
be the one I tried first.

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

* refactor: extract DistributedFormat type + trim font-cache resolver

Second simplify-review pass on the webm stack flagged two cleanups:

1. **`DistributedFormat` type duplicated 10 times.** Every file in the
   distributed pipeline carried its own copy of
   `"mp4" | "mov" | "png-sequence" | "webm"` — adding a new format
   meant a 10-place edit with no compile-time guarantee they stayed in
   sync. Extract a single source of truth in
   `packages/producer/src/services/distributed/shared.ts`, re-export
   from `@hyperframes/producer/distributed` and
   `@hyperframes/aws-lambda/sdk`, and have all callers pull from
   there. The aws-lambda `ALLOWED_FORMATS` runtime tuple and the CLI's
   `FORMATS` tuple now both use `satisfies readonly DistributedFormat[]`
   so the compiler enforces the runtime allowlist stays in sync with
   the type.

2. **`deterministicFonts.ts` font-cache resolver was over-commented.**
   Trim the 7-line block to 4 lines (drop the aspirational
   "and other read-only-FS execution environments" — only Lambda is
   detected — and the warm-container `/tmp` persistence narration —
   anyone reading already knows Lambda /tmp semantics). Collapse the
   two-step `if (explicit && explicit.length > 0)` into a single
   nullish-coalesce expression now that the empty-string defensive
   check is gone (`process.env.X` is `string | undefined`, no third
   shape to guard against).

Out-of-scope skips (called out by the agents, deferred):
- In-process `RenderConfig.format` and the in-process CLI's
  `render.ts` format union still carry their own inline copies. The
  union happens to coincide today but they're separate concerns —
  leaving them alone limits this PR's blast radius.
- `fontCacheDir(slug)` / `resolveFontCacheRoot()` naming asymmetry
  flagged as taste; skipping.
- Pre-existing redundant `existsSync` before `mkdirSync({ recursive:
  true })` in `fontCacheDir` — out of scope.

All tests + typecheck still pass. Lambda render still works
end-to-end (no functional changes).

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

* docs(lambda): drop plan-doc reference from migration checklist

PR review feedback: source/docs should not mention the
distributed-rendering planning doc. Tighten the migration checklist
sentence to describe the webm path directly rather than referencing
the doc's version label.

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

* refactor(producer): split resolveEncoderTriple into mp4 + non-mp4 helpers

CI Fallow audit on PR #953 flagged `resolveEncoderTriple` at CRAP 31.6 —
the function interleaved (a) mp4 codec validation + dispatch, (b) the
non-mp4 codec-rejection throw, and (c) per-format dispatch. Splitting
into `resolveMp4EncoderTriple` + `resolveNonMp4EncoderTriple` drops the
top-level function's cyclomatic complexity below the threshold while
preserving every error message and code path. Behavior unchanged.

Also extracts an `EncoderTriple` type alias so the three functions
share the return shape declaratively rather than repeating it.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:11:26 -04:00
James RussoandClaude Opus 4.7 6d2569c6bb test(producer): add webm-vp9 distributed regression fixture (#952)
* feat(producer): enable webm in distributed mode via concat-copy

PR 8.2 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). Wires libvpx-vp9 webm through the
distributed pipeline now that PR 8.1 proved concat-copy works.

Architectural decision: Path A (concat-copy) — based on PR 8.1's smoke
test result (9/9 tests pass for both yuv420p and yuva420p VP9 streams).
The simpler architecture wins; no re-encode in assemble, no encode-
parallelism loss.

Changes:

- plan.ts:
  - DistributedRenderConfig.format and PlanResult.format now include
    "webm" — type-level acceptance matches the runtime gate.
  - rejectUnsupportedDistributedFormat() no longer trips on webm. HDR
    mp4 remains the only refused configuration.
  - resolveEncoderTriple() returns libvpx-vp9-software + yuva420p +
    preset="good" for format="webm". yuva420p preserves alpha — the
    format's main reason for existing for web delivery.
  - codec= remains rejected for non-mp4 formats (mov is always ProRes
    4444; webm is always libvpx-vp9). The error message lists all four
    distributed-supported formats.
  - FormatNotSupportedInDistributedError docstring updated to reflect
    the new reality (only HDR is unsupported).

- freezePlan.ts: LockedRenderConfig.encoder gains "libvpx-vp9-software".
  Mirrors libx265-software / prores-software / png-sequence in shape;
  the chunk worker reads this discriminant to decide encode args.

- renderChunk.ts: drops the now-incorrect cast that excluded webm from
  buildSyntheticRenderJob's format input; tightens the preset-format
  cast to include webm.

- assemble.ts: docstring + comment updates. The mp4/mov concat-copy
  path is format-agnostic — webm uses the exact same code (applyFaststart
  is a no-op for webm via the existing chunkEncoder.ts gate;
  muxVideoWithAudio already routes webm to libopus audio).

- planFormatBanlist.test.ts: webm-rejection tests removed; replaced with
  "accepts webm" tests + a HDR+webm combo test that verifies HDR is the
  trip regardless of format.

- plan.test.ts: new describe block pins the webm wiring contract:
  format="webm" produces an encoder=libvpx-vp9-software /
  pixelFormat=yuva420p planDir with closedGop=true and gopSize=chunkSize.

- webm-concat-copy.test.ts (smoke): extended with a yuva420p variant
  that proves the alpha pixel format the distributed pipeline actually
  emits also round-trips through concat-copy. 9/9 tests pass locally.

§8 format support matrix in DISTRIBUTED-RENDERING-PLAN.md is intentionally
left unchanged at this PR — it flips to ✓ in PR 8.4 once the end-to-end
fixture (PR 8.3) is green.

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

* fix(producer): include webm in plan-time needsAlpha + strengthen alpha smoke

PR review feedback from Miguel and Vai on #951 caught a real bug:
`plan.ts`'s `needsAlpha` disjunction excluded `"webm"`, so the plan
stage froze `forceScreenshot: false` into the `LockedRenderConfig`
even though distributed webm uses `yuva420p`. Every chunk worker
captured opaque RGB via BeginFrame (which doesn't preserve alpha on
Linux headless-shell), and libvpx-vp9 encoded uniformly-opaque alpha
that the encoder then dropped — producing un-keyable webm.

Two changes:

1. **plan.ts**: include `"webm"` in `needsAlpha`. Matches the
   in-process renderer's logic at `renderOrchestrator.ts:1469`
   (`const needsAlpha = isWebm || isMov || isPngSequence`); the two
   sites must stay in sync since the distributed pipeline's PSNR
   regression compares against the in-process baseline.

2. **Smoke test (yuva420p describe)**: source frames now use a real
   alpha gradient (`geq=a='X*255/W'` on top of `testsrc2`) instead of
   `testsrc2 + format=rgba` which was uniformly opaque. The decode-
   pix_fmt assertion is dropped (ffprobe reports `yuv420p` for
   VP9-with-alpha because the alpha lives in a Matroska
   `BlockAdditional` sidecar) and replaced with two stronger checks:
   - `TAG:ALPHA_MODE=1` is present on the stream — proves the
     encoder was actually configured for alpha
   - alpha plane variance after `-c:v libvpx-vp9 -i ... -pix_fmt rgba
     -vf extractplanes=a,signalstats` — proves the alpha sub-stream
     round-trips through concat-copy with spatially-varying content,
     not uniform/dropped alpha
   - decode-test gate is now exit-code-only (was `exitCode || stderr`
     which would flake on chatty ffmpeg `-v error` builds emitting
     non-fatal DTS/container notes)

These checks would have caught the `needsAlpha` bug before review.

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

* fix(aws-lambda): widen narrow format types to include webm

CI on PR #951 was failing at typecheck/build because the producer's
`DistributedRenderConfig.format` widened to include webm in this PR
but the aws-lambda package's narrow `"mp4" | "mov" | "png-sequence"`
type literals in `events.ts`, `handler.ts`, and `validateConfig.ts`
hadn't kept up. `renderToLambda.ts:87` passed `config.format` (now
including webm) into a parameter typed against the narrow union,
producing TS2345.

This widening originally landed in PR #952 (test fixture PR) but
needs to be atomic with the producer's widening here to keep each
PR independently typecheck-clean.

Also refactor `formatExtension` from a switch dispatch to a
`Record<DistributedFormat, string>` lookup. Adding the webm case
tipped the switch's CRAP to the 30.0 fallow threshold; the lookup
table drops cyclomatic from 5 to 1 with the same compile-time
exhaustiveness guarantee (TS errors on missing entries when
`DistributedFormat` adds a new format). The runtime
`_exhaustive: never` throw was only protecting against a string
slipping past TS; `validateConfig.ts`'s `ALLOWED_FORMATS` already
gates untrusted input at the SDK boundary.

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

* test(producer): add webm-vp9 distributed regression fixture

PR 8.3 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). End-to-end regression coverage for
the webm distributed path PRs 8.1 and 8.2 wired up.

Adds packages/producer/tests/distributed/webm-vp9/ matching the
mp4-h264-sdr fixture pattern: a 2-second composition (60 frames @ 30fps)
with text, a crossfade across the frame-30 chunk seam, and a continuous
icon rotation — exercises chunk-boundary continuity for both display
contents and VP9 closed-GOP alpha encoding. `chunkSize: 15` produces 4
chunks so 3 seams are tested, and the crossfade straddles the middle
seam to surface alpha-plane discontinuities introduced by alt-ref drift.

Baseline regenerated inside Dockerfile.test via
`bun run --cwd packages/producer docker:test:update webm-vp9`. Runs in:

  - in-process mode: byte-identical match against baseline ✓
  - distributed-simulated mode: PSNR 56.88-63.49 dB across 100
    checkpoints, well above the 30 dB threshold ✓

Wiring updates required to let webm flow through the harness:

- regression-harness-distributed.ts:
  - checkDistributedSupport() no longer rejects webm. HDR mp4 + NTSC
    fps + non-{24,30,60} fps remain rejected.
  - RunDistributedSimulatedInput.format widened to include webm.
  - Docstring + comments updated.

- regression-harness-distributed.test.ts: webm-rejection test replaced
  with "accepts format=webm" test.

- regression-harness.ts: the now-incorrect format cast at the
  distributed-input call site is dropped; comment about why webm was
  excluded is replaced with "webm is now distributed-supported".

- regression-harness-lambda-local-types.ts: RunLambdaLocalInput.format
  widened to include webm so lambda-local mode can also exercise webm
  fixtures end-to-end.

- aws-lambda webm support (Path A through the Lambda handler):
  - formatExtension.ts: DistributedFormat gains "webm" → ".webm" case.
  - events.ts: RenderChunkEvent / AssembleEvent / PlanLambdaResult
    Format widened to include webm.
  - sdk/validateConfig.ts: ALLOWED_FORMATS gains "webm".
  - handler.ts: downloadChunkObjects format param widened.

The Lambda handler delegates to the producer's assemble() primitive
which PR 8.2 already taught to handle webm (concat-copy + applyFaststart
no-op + muxVideoWithAudio with libopus); no Lambda-side rendering
changes are needed beyond the type/validation surfaces above.

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

* docs(aws-lambda): drop stale webm rejection from validateConfig docblock

PR #952 review nit (Miguel): the validateConfig.ts file-header comment
still claimed the SDK rejects webm, but the runtime check no longer
does (ALLOWED_FORMATS now includes 'webm'). Update the docblock to
reflect that only force-hdr remains an SDK-side rejection.

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

* ci(regression): add webm-vp9 to shard-3 + refactor formatExtension

Three follow-ups bundled together (Vai's review feedback on PR #952
plus the fallow audit finding that surfaced when the webm case was
added):

1. **Wire webm-vp9 into CI regression.** The fixture was added in this
   PR but never appeared in any `.github/workflows/regression.yml`
   shard's args allowlist, so the regression harness's positional-args
   gate skipped it in CI. Append `webm-vp9` to shard-3 (which already
   carries `mp4-h264-sdr` + `webm-transparency`) so the fixture runs.

2. **Fix stale "four hard gates" prose in checkDistributedSupport
   docstring.** Earlier in the stack I removed the webm bullet but
   didn't update the count. Two gates remain (fps + hdr).

3. **Refactor `formatExtension` from switch to lookup table.** Adding
   the webm case made the switch dispatch's CRAP score hit 30.0
   (cyclomatic = 5, plus the function's small body). Replaced with a
   `Record<DistributedFormat, string>` lookup, which:
   - drops cyclomatic from 5 → 1,
   - keeps exhaustiveness enforcement at compile time (TS errors if
     a new format gets added to `DistributedFormat` without a
     matching key in the Record literal),
   - drops the runtime `_exhaustive: never` throw, which was only
     guarding against an arbitrary string slipping past TS — a
     caller-side concern, not this function's job.

   The function now reads as a table lookup, which matches what it
   actually does, and the fallow audit now reports zero new
   complexity findings (down from 1).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:13:30 -04:00
James Russo c336508d4e fix(producer): treat 4xx from Google Fonts as deterministic "not served", not as failClosed trigger (#957)
After c8e8fdcf added a Google Fonts supplement-fetch to the Path 1
(bundled-font) branch, every `plan()` call against a composition whose
CSS named a non-Google family that Google Fonts 400s on (e.g.
`"Segoe UI"`, `"Arial"`, `"Futura"`) started failing on distributed
renders with `FONT_FETCH_FAILED`. Distributed renders default to
`failClosedFontFetch: true`, and the existing code treated *all* non-2xx
responses uniformly: throw if failClosed, swallow otherwise.

That conflates two very different failure modes:

  - **4xx** is a *deterministic* answer — Google Fonts does not serve
    this family, and won't serve it on retry either. The byte-identical-
    retry contract distributed renders rely on is unaffected; the render
    falls back to embedded faces / the composition's font-family chain
    (which is what it would have done pre-c8e8fdcf anyway). No reason
    to fail-close here.

  - **5xx** (and network / DNS / fetch exceptions) is *non-deterministic*
    infrastructure failure. A retry might succeed and produce different
    pixel output than the first attempt — exactly what
    `failClosedFontFetch` is meant to protect against. Keep failing
    closed in this mode.

Fix: split the !res.ok branch in both the CSS fetch and the woff2 fetch
inside `fetchGoogleFont` — only `>= 500` paired with failClosed throws;
4xx returns `[]` in both modes. Network/DNS exceptions in the catch
block are unchanged (still failClosed-gated).

This:
  - Unblocks distributed renders for compositions that name any
    cross-alias system font Google doesn't serve (Segoe UI, Arial, etc.).
  - **Preserves the current regression baseline** — Google Fonts
    actually *does* serve some non-canonical names (e.g. "Helvetica" and
    "Helvetica Neue" both return HTTP 200 with real @font-face rules,
    confirmed via curl), so the supplement-fetch still runs and binds
    those real faces to the composition's CSS family names exactly as
    today. style-7-prod (which uses `"Helvetica Neue", Helvetica, Arial,
    sans-serif`) continues to render against real Helvetica glyphs.
  - Leaves the FONT_ALIASES table and call-site untouched. The fix is
    in the right place — the error semantics inside fetchGoogleFont —
    not in any composition-aware logic upstream.

Tests:
  - 2 new positive cases on `failClosedFontFetch: true`:
    400 and 404 responses no longer throw, render falls back cleanly.
  - 2 new negative cases on `failClosedFontFetch: true`:
    503 throws `FONT_FETCH_FAILED`, error includes URL + family.
  - 1 new case on `failClosedFontFetch: false`: 5xx swallowed as before.
  - The pre-existing "does NOT throw when the HTML uses a pre-bundled
    font" test was broken by c8e8fdcf (the supplement-fetch always fires
    for self-aliased bundled fonts now). Updated it to use a successful
    empty CSS response, which is the actual invariant we want.

12/12 tests pass.

Followup discussion: should `FONT_ALIASES` exist at all in a
deterministic cloud renderer? Today `font-family: "Helvetica"` in CSS
silently produces real Helvetica glyphs (via Google Fonts' undocumented
alias serving) and `font-family: "Segoe UI"` silently produces embedded
Roboto, with no warning to the author. That's a WYSIWYG violation worth
its own proposal — but not in scope for this fix.
2026-05-19 03:09:16 -04:00
James RussoandClaude Opus 4.7 21f5066832 feat(producer): enable webm in distributed mode via concat-copy (#951)
* feat(producer): enable webm in distributed mode via concat-copy

PR 8.2 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). Wires libvpx-vp9 webm through the
distributed pipeline now that PR 8.1 proved concat-copy works.

Architectural decision: Path A (concat-copy) — based on PR 8.1's smoke
test result (9/9 tests pass for both yuv420p and yuva420p VP9 streams).
The simpler architecture wins; no re-encode in assemble, no encode-
parallelism loss.

Changes:

- plan.ts:
  - DistributedRenderConfig.format and PlanResult.format now include
    "webm" — type-level acceptance matches the runtime gate.
  - rejectUnsupportedDistributedFormat() no longer trips on webm. HDR
    mp4 remains the only refused configuration.
  - resolveEncoderTriple() returns libvpx-vp9-software + yuva420p +
    preset="good" for format="webm". yuva420p preserves alpha — the
    format's main reason for existing for web delivery.
  - codec= remains rejected for non-mp4 formats (mov is always ProRes
    4444; webm is always libvpx-vp9). The error message lists all four
    distributed-supported formats.
  - FormatNotSupportedInDistributedError docstring updated to reflect
    the new reality (only HDR is unsupported).

- freezePlan.ts: LockedRenderConfig.encoder gains "libvpx-vp9-software".
  Mirrors libx265-software / prores-software / png-sequence in shape;
  the chunk worker reads this discriminant to decide encode args.

- renderChunk.ts: drops the now-incorrect cast that excluded webm from
  buildSyntheticRenderJob's format input; tightens the preset-format
  cast to include webm.

- assemble.ts: docstring + comment updates. The mp4/mov concat-copy
  path is format-agnostic — webm uses the exact same code (applyFaststart
  is a no-op for webm via the existing chunkEncoder.ts gate;
  muxVideoWithAudio already routes webm to libopus audio).

- planFormatBanlist.test.ts: webm-rejection tests removed; replaced with
  "accepts webm" tests + a HDR+webm combo test that verifies HDR is the
  trip regardless of format.

- plan.test.ts: new describe block pins the webm wiring contract:
  format="webm" produces an encoder=libvpx-vp9-software /
  pixelFormat=yuva420p planDir with closedGop=true and gopSize=chunkSize.

- webm-concat-copy.test.ts (smoke): extended with a yuva420p variant
  that proves the alpha pixel format the distributed pipeline actually
  emits also round-trips through concat-copy. 9/9 tests pass locally.

§8 format support matrix in DISTRIBUTED-RENDERING-PLAN.md is intentionally
left unchanged at this PR — it flips to ✓ in PR 8.4 once the end-to-end
fixture (PR 8.3) is green.

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

* fix(producer): include webm in plan-time needsAlpha + strengthen alpha smoke

PR review feedback from Miguel and Vai on #951 caught a real bug:
`plan.ts`'s `needsAlpha` disjunction excluded `"webm"`, so the plan
stage froze `forceScreenshot: false` into the `LockedRenderConfig`
even though distributed webm uses `yuva420p`. Every chunk worker
captured opaque RGB via BeginFrame (which doesn't preserve alpha on
Linux headless-shell), and libvpx-vp9 encoded uniformly-opaque alpha
that the encoder then dropped — producing un-keyable webm.

Two changes:

1. **plan.ts**: include `"webm"` in `needsAlpha`. Matches the
   in-process renderer's logic at `renderOrchestrator.ts:1469`
   (`const needsAlpha = isWebm || isMov || isPngSequence`); the two
   sites must stay in sync since the distributed pipeline's PSNR
   regression compares against the in-process baseline.

2. **Smoke test (yuva420p describe)**: source frames now use a real
   alpha gradient (`geq=a='X*255/W'` on top of `testsrc2`) instead of
   `testsrc2 + format=rgba` which was uniformly opaque. The decode-
   pix_fmt assertion is dropped (ffprobe reports `yuv420p` for
   VP9-with-alpha because the alpha lives in a Matroska
   `BlockAdditional` sidecar) and replaced with two stronger checks:
   - `TAG:ALPHA_MODE=1` is present on the stream — proves the
     encoder was actually configured for alpha
   - alpha plane variance after `-c:v libvpx-vp9 -i ... -pix_fmt rgba
     -vf extractplanes=a,signalstats` — proves the alpha sub-stream
     round-trips through concat-copy with spatially-varying content,
     not uniform/dropped alpha
   - decode-test gate is now exit-code-only (was `exitCode || stderr`
     which would flake on chatty ffmpeg `-v error` builds emitting
     non-fatal DTS/container notes)

These checks would have caught the `needsAlpha` bug before review.

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

* fix(aws-lambda): widen narrow format types to include webm

CI on PR #951 was failing at typecheck/build because the producer's
`DistributedRenderConfig.format` widened to include webm in this PR
but the aws-lambda package's narrow `"mp4" | "mov" | "png-sequence"`
type literals in `events.ts`, `handler.ts`, and `validateConfig.ts`
hadn't kept up. `renderToLambda.ts:87` passed `config.format` (now
including webm) into a parameter typed against the narrow union,
producing TS2345.

This widening originally landed in PR #952 (test fixture PR) but
needs to be atomic with the producer's widening here to keep each
PR independently typecheck-clean.

Also refactor `formatExtension` from a switch dispatch to a
`Record<DistributedFormat, string>` lookup. Adding the webm case
tipped the switch's CRAP to the 30.0 fallow threshold; the lookup
table drops cyclomatic from 5 to 1 with the same compile-time
exhaustiveness guarantee (TS errors on missing entries when
`DistributedFormat` adds a new format). The runtime
`_exhaustive: never` throw was only protecting against a string
slipping past TS; `validateConfig.ts`'s `ALLOWED_FORMATS` already
gates untrusted input at the SDK boundary.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:46:21 -04:00
James Russo 17f47f30dd fix(distributed): gate per-worker SwiftShader probe to worker 0 only (#956)
After #916 moved `assertSwiftShader` from `renderChunk()`'s eager probe
session into `executeWorkerTask`, every parallel worker began running its
own `chrome://gpu` / canvas-WebGL probe. At `chunkWorkerCount=6` (texture
launch at chunks=3) that's 6 concurrent CDP page-loads per chunk × 3
chunks = 18 simultaneous probes. Bench data on dev (12 producer pods × 22
vCPU) showed c=3 worst-case wall-clock at 67.3s, 24.7s above c=6 worst
(42.6s) — pod_total inflates 100s → 147s uniformly across all three
chunks per slow iter, the signature of cluster-level CDP contention
rather than within-pod contention.

Workers within a chunk share the same Chrome binary, flags, and OS/driver
state on a single pod, so worker 0's success is representative for the
rest. Gate the probe via `shouldVerifyWorkerGpu(workerId, config)` so
only worker 0 navigates to the probe page; workers 1..N-1 skip it. The
fail-fast contract still holds at the chunk level (worker 0 still aborts
the chunk if SwiftShader didn't load) — just without the concurrent CDP
traffic.

Expected wall-clock impact: c=3 worst drops from ~67s to in line with
c=6 worst (~42-44s). c=6 (3 workers/pod) and c=8 (2 workers/pod) should
see smaller wins; c=12 (1 worker/pod, sequential branch) is unaffected.

Closes #955.
2026-05-18 22:30:13 -04:00
James 7e0a447325 refactor: drop unused exports detected by fallow auto-fix
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols
fallow's reachability analysis identifies as unused. Keeps only the cases
where the symbol is still referenced internally in its own file (so
removing `export` doesn't surface a new oxlint `no-unused-vars` error).

Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused
exports), with no behavior change — each symbol is still defined and used
exactly the same way within its file.

Reverted ~20 files where fallow's auto-fix would have created cascading
"declared but never used" lint errors — those are cases where the symbol
isn't used at all, and properly cleaning them up means deleting the
declaration, not just dropping `export`. Better to land that as a
separate, narrower PR rather than mixing it into a mechanical de-export.

Also reverted four false positives where fallow missed real consumers:
- `captureCost.ts` (renderOrchestrator has two separate import blocks
  from the same module; fallow only saw the first)
- `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses
  fallow's reachability missed)
- `render.ts` (functions imported via `await import()` dynamic import,
  which fallow's static analysis doesn't follow)

Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean,
cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
2026-05-19 00:51:56 +00:00
James Russo 2fd161b943 Merge pull request #939 from heygen-com/feat/auto-size-chunk-size-when-undefined
feat(producer): auto-size chunkSize from maxParallelChunks when undefined
2026-05-18 17:25:08 -04:00
JamesandClaude Opus 4.7 a8499632c6 test(producer): preserve single-chunk-path coverage + add auto-size integration
Address PR review feedback on #939:

- Pin chunkSize=240 on the golden planDir layout test so the 1-chunk path
  through plan() stays exercised after the auto-sizer change. Assert
  chunkCount === 1 explicitly (previously just >= 1).
- Add an integration test that runs plan() with chunkSize=undefined and
  asserts the auto-sizer produces multi-chunk output end-to-end
  (chunkCount=3, encoder.gopSize=10, encoder.chunkSize=10) for the same
  30-frame fixture.
- Document the GOP/file-size trade-off on the chunkSize docstring so
  adopters who optimize for output bytes know to pin chunkSize.
- Update the resolveChunkPlan docstring formula to reference the operative
  variable (resolvedChunkSize) instead of the now-ambiguous chunkSize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:09:31 +00:00
James Russo cc7622cc42 Merge pull request #926 from heygen-com/fix/ci-chrome-pin-and-psnr-harness
fix(ci): pin chrome-headless-shell + clamp PSNR checkpoint to a valid frame
2026-05-18 16:51:29 -04:00
JamesandClaude Opus 4.7 fd3fce9955 refactor(producer): tighten resolveChunkPlan assertion + trim comments
Address self-review findings:
- assertPositiveInteger now only runs on the caller-supplied path so the
  error message names `configChunkSize` only when the caller actually
  passed one. Previously, the assertion fired against `resolvedChunkSize`
  on both paths and would have lied about the offending input.
- Drop the call-site comment that narrated the diff/history; the
  function docstring already covers the contract.
- Drop the internal-track name and date from the MIN_CHUNK_SIZE rationale
  and the test block header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:37:25 +00:00
JamesandClaude Opus 4.7 e79a8faa42 feat(producer): auto-size chunkSize from maxParallelChunks when undefined
Previously, plan() defaulted chunkSize to 240 on a `?? DEFAULT_CHUNK_SIZE`
line, so a 660-frame composition with maxParallelChunks=16 ended up at 3
chunks (ceil(660/240)) regardless of the caller's fan-out intent.

When config.chunkSize is undefined, auto-size from maxParallelChunks:
  effectiveChunkSize = max(MIN_CHUNK_SIZE, ceil(totalFrames / maxParallelChunks))

MIN_CHUNK_SIZE=10 keeps per-chunk fixed overhead from swamping the
parallelism gain on tiny renders. Explicit numbers, including 240, take
precedence over the auto-sizer — no behavior change for callers that set
chunkSize explicitly.

Surfaced by the lever-1 chunk-scaling benchmark on 2026-05-17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:29:34 +00:00
Miguel Ángel 4bf2fa5cd1 fix: revert flattenInnerRoot, use host-level authored-id + count-based rebind
Three changes to fix regression failures without breaking baselines:

1. Revert flattenInnerRoot in producer — use the original innerHTML
   inlining that preserves the existing DOM structure. Instead, set
   data-hf-authored-id on the HOST element so the scoped proxy can
   still rewrite #id selectors for sub-composition scripts.

2. Revert compiled.html baselines to main (no DOM structure changes).

3. Use timeline-count comparison instead of poll duration to decide
   whether to call __hfForceTimelineRebind. Compare timeline count
   before vs after the poll — rebind only when new timelines appeared
   during polling. This correctly identifies async compositions
   regardless of fetch speed, while leaving sync compositions
   untouched.
2026-05-18 10:20:17 -04:00
Miguel Ángel 0f1c64dcae fix: address review feedback — observability, dedup, query-strip, catalog
Review items addressed:

1. Mirror video-failure warning in beginFrame path (was screenshot-only)
2. Fix resolveProjectRelativeSrc escape-fallback to use query-stripped
   cleanSrc instead of raw src for the normalize/strip arm
3. Export prepareFlattenedInnerRoot from @hyperframes/core/compiler and
   consume in the producer instead of duplicating the implementation
4. Use typed Window cast instead of (window as any) for __hfForceTimelineRebind
5. Regenerate docs/public/catalog-index.json with all 6 map blocks
6. Restore Maps nav group in docs.json (catalog generator had merged
   them into Data)
2026-05-18 01:41:15 -04:00
Miguel Ángel 5aff328a9b fix(producer): preserve inner root element when inlining sub-compositions
The producer's inlineSubCompositions was not passing flattenInnerRoot,
causing sub-composition inner root elements to be unwrapped during
compilation. Scripts using #id selectors (rewritten to
[data-hf-authored-id] by the scoping proxy) could not find their DOM
root because the authored-id attribute was never set.

The core bundler (bundleToSingleHtml) already passed flattenInnerRoot
correctly. This aligns the producer's render compilation with the
same behavior: clone the inner root, strip timing/composition attrs,
replace id with data-hf-authored-id, and mark with
data-hf-inner-root.
2026-05-18 00:27:20 -04:00
Miguel Ángel 5beeb4227e Merge pull request #927 from heygen-com/fix/caption-polish
fix(registry): polish caption timing and add highlight style
2026-05-18 03:06:57 +02:00
Miguel Ángel c8e8fdcf6d fix(producer): fetch missing font weights from Google Fonts at render time
The deterministic font system now supplements its embedded font bundle
with Google Fonts fetches for any weights not in the pre-bundled set.
This fixes compositions that request font weights (e.g. Montserrat 300)
not included in the CANONICAL_FONTS faces array — previously those
weights were silently dropped, causing invisible text in renders.

Also replaces caption-glitch-rgb and caption-weight-shift with improved
versions from avatar preview compositions, adapted to 1920x1080 with
standard demo transcript.
2026-05-17 20:41:30 -04:00
James 9a063e3fe0 Merge remote-tracking branch 'origin/main' into fix/ci-chrome-pin-and-psnr-harness 2026-05-18 00:18:55 +00:00