Commit Graph
1176 Commits
Author SHA1 Message Date
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
James ce95c9aea0 docs(nav): consolidate deploy pages into one sidebar group v0.6.28 2026-05-19 21:11:08 -04:00
Miguel Ángel e5bad2b80a Merge pull request #978 from heygen-com/worktree-fix+studio-timeline-resize-and-perf
fix(studio): enable timeline resize for all elements, improve perf and UX
2026-05-20 03:07:08 +02:00
Miguel Ángel 20c82980d5 chore: address remaining review nits
Add mediabunny (MPL-2.0) to CREDITS.md third-party licenses section.

Add regression test for 4-5 clip compositions under the lowered lazy
threshold — verifies lazy mode activates and no spurious eviction churn
occurs when all clips fit within the promoted cap.
2026-05-19 21:06:22 -04:00
Miguel Ángel 366fcc2a64 fix(studio): address PR review — restore rollback, fix probe race, harden edit suppression
Restore rollback path: enqueueEdit now returns the queued promise so
Promise.resolve(handler(...)).catch(rollback) in useTimelineClipDrag
fires correctly on save failure. Handlers return the promise chain.

Fix lost-update race in probe enrichment: use zustand's functional
setState so concurrent probe completions each read the latest state
atomically instead of all reading the same stale snapshot.

Harden file-change suppression: pendingTimelineEditPathRef is now a
Set<string> with exact-match lookup instead of single-slot + endsWith.
Multiple concurrent edits on different files are all suppressed correctly.

Remove dead canOffsetTrimClipStart function and its tests — no longer
called after the capability gate simplification.

Document runtime sync mechanism: added comment explaining that the
runtime re-reads data attributes on each sync tick (init.ts:1324-1368).

Fix comment wording in patchIframeDomTiming catch block.
2026-05-19 20:59:45 -04:00
Miguel Ángel beb807493c refactor(studio): reduce complexity in timeline editing helpers
Extract resolveResizePlaybackStart, simplify patchIframeDomTiming to
accept attr tuples, inline findIframeElement. Reduces CRAP scores in
the resize handler lambda and DOM patching functions.
2026-05-19 20:54:45 -04:00
James 8080e1f920 docs(concepts): note that media data-duration can be variable-driven 2026-05-19 20:47:46 -04:00
James d28c082416 docs(concepts): clarify what can and can't be a variable 2026-05-19 20:47:46 -04:00
Miguel Ángel 0769dc4b9c refactor(studio): rename useManifestPersistence to usePreviewPersistence 2026-05-19 20:44:45 -04:00
Miguel Ángel 89f9ca196d fix(studio): enable timeline resize for all elements, improve perf and UX
Enable trim-start and trim-end for all authored timeline elements (divs,
sections, compositions) — not just video/audio/img. The deterministic-window
gate was overly restrictive since all non-implicit elements have authored
data-start/data-duration that define their timeline window.

Replace iframe reload after resize/move with direct DOM attribute patching
via patchIframeDomTiming(). This eliminates playhead-jump-to-zero, visual
blinking, and race conditions from file-watcher echoes. File persistence
runs in a serialized background queue (persistTimelineEdit + enqueueEdit)
so rapid edits don't overwrite each other.

Add mediabunny-based media probe service (mediaProbe.ts) for fast metadata
extraction from file headers. Timeline elements missing sourceDuration are
enriched asynchronously without waiting for DOM loadedmetadata events.

Tune the runtime media preloader: lower lazy threshold from 6 to 3 clips,
add 3s lookbehind window for reverse scrub, adaptive promoted-clip cap.

Deduplicate getTimelineEditCapabilities — computed once in TimelineCanvas
and passed as a prop to TimelineClip instead of recomputing per clip.

Remove dead PlaybackAdapter re-export from useTimelinePlayer — all consumers
import directly from playbackTypes.
2026-05-19 20:40:39 -04:00
James 480d0cfa5a docs(deploy): templates-on-lambda guide for personalised video at scale
User-facing guide for the automated template-rendering pipeline now
shippable end-to-end after PRs 9.1-9.4:

  - What a template is (composition + data-composition-variables)
  - Declaring variables (syntax, types, defaults, getVariables())
  - Local iteration loop (hyperframes render --variables / --variables-file
    / --strict-variables)
  - Deploying to Lambda (pointers to deploy guide + sites create)
  - Single personalised render (lambda render --variables)
  - Batch pipeline (lambda render-batch --batch users.jsonl, with a worked
    5-row example, manifest output, progress polling, --dry-run)
  - Programmatic via SDK (TypeScript example with deploySite +
    Promise.all(renderToLambda))
  - Working with large variables (the 256 KiB Step Functions ceiling,
    URL-your-assets convention, the one-line escape note for genuine
    >256 KiB cases)
  - Cost + scale considerations (Lambda concurrency, max-parallel-chunks
    vs max-concurrent, in-process vs distributed crossover)
  - Migrating from @remotion/lambda inputProps (side-by-side table; same
    256 KiB cap and same URL-your-assets convention, so migration is
    mechanical)

Includes a Mermaid architecture diagram for the site-upload-once +
N-execution fan-out flow at the top.

Adds the guide to the Deploy navigation group in docs.json (between
the existing aws-lambda and migrating-to-hyperframes-lambda pages).

Phase 9 PR 9.5 of the distributed rendering plan — the load-bearing
artifact for the user-facing pitch.
2026-05-19 19:54:30 -04:00
James f0a2740f6e feat(cli): hyperframes lambda render-batch verb
New subcommand for automated template-rendering pipelines. Given a
project dir + a JSONL batch file, fans out N personalised renders by
calling renderToLambda once per batch row with per-entry variables and
outputKey:

  hyperframes lambda render-batch ./my-template \
    --batch ./users.jsonl \
    --width 1920 --height 1080 \
    --max-concurrent 10

JSONL format (one JSON object per line):

  {"outputKey": "renders/alice.mp4", "variables": {"name": "Alice"}}
  {"outputKey": "renders/bob.mp4",   "variables": {"name": "Bob"}}

The verb deploys the site once and reuses it across renders (--site-id
skips the deploy when the project was pre-uploaded). Concurrent Step
Functions starts are capped at --max-concurrent (default 50) via a
semaphore so a 10 000-entry batch doesn't try to spawn 10 000
executions simultaneously and trip the AWS account's concurrent-
execution quota.

Per-entry results land in a manifest (one row per input line) with
executionArn + status. --json emits the manifest as machine-readable
JSON. --dry-run prints the manifest with status: "would-invoke" for
every entry without calling AWS, so callers can lint their batch file
before paying for N executions.

Variables in each batch entry pre-validate against the composition's
data-composition-variables declaration (mirroring the local
hyperframes render UX). --strict-variables aborts the run on the first
failing entry before any AWS call. The reportVariableIssues helper from
PR 9.3 is reused so the warning format matches the single-render path
exactly.

Distinction from --max-parallel-chunks: --max-concurrent caps
ORCHESTRATOR-side fan-out (how many StartExecution calls run at once);
--max-parallel-chunks caps chunks PER render. AWS account-level Lambda
concurrent-execution limits live one level up and render-batch can't
enforce those; pick --max-concurrent based on your account quota +
the reserved concurrency you provisioned via lambda deploy.

Tests cover the concurrency-cap semaphore (preserve-order,
peak-in-flight, empty-input, limit > inputs.length, propagate
rejection) and the JSONL parser (blank-line handling, malformed JSON,
missing outputKey, non-object variables).

Phase 9 PR 9.4 of the distributed rendering plan.
2026-05-19 19:54:30 -04:00
James cb948d5fcf feat(cli): hyperframes lambda render --variables / --variables-file / --strict-variables
Mirror the local hyperframes render variables UX on the Lambda CLI:

- --variables '<json>'       inline JSON object of variable values
- --variables-file <path>    path to a JSON file with variable values
- --strict-variables          fail on type/declared-mismatch (warn by default)

Resolution + validation logic is hoisted to packages/cli/src/utils/variables.ts
so both surfaces share one parser. The new reportVariableIssues helper formats
the warning block + handles --strict-variables exit, deduping the per-CLI
issue-handling block.

Variables flow into SerializableDistributedRenderConfig.variables and reach
every chunk worker via the path PR 9.1 + 9.2 wired up (plan() →
meta/encoder.json → renderChunk() → window.__hfVariables). Pre-validation
against the composition's data-composition-variables declaration runs only
when the project's index.html is on disk — --site-id pointing at a
pre-uploaded site that was packaged elsewhere skips the check, matching how
the local CLI treats unreadable index files.

The render.ts re-exports of parseVariablesArg / resolveVariablesArg /
validateVariablesAgainstProject are dropped; the matching tests move to
packages/cli/src/utils/variables.test.ts where the implementations now live.

Docs: docs/packages/cli.mdx adds a section on --variables / --variables-file /
--strict-variables for lambda render, including the 256 KiB Step Functions
execution-input cap and a pointer to the upcoming templates-on-lambda guide
(PR 9.5).

Phase 9 PR 9.3 of the distributed rendering plan.
2026-05-19 19:54:30 -04:00
James Russo 87fdd556c4 feat(aws-lambda): validate variables + 256 KiB Step Functions input cap (#976)
Add client-side validation for the new config.variables field
(introduced in PR 9.1) and a 256 KiB cap on the full Step Functions
Standard execution input. Both checks throw a typed InvalidConfigError
BEFORE the SDK calls StartExecution — catching the obvious mistakes
locally instead of as a States.DataLimitExceeded 50 ms into the
execution.

validateVariablesPayload walks the variables tree and rejects:
- functions, Symbols, BigInts, non-finite numbers
- undefined leaves (silently dropped by JSON.stringify — would
  surprise the caller when their value doesn't show up in the render)
- non-plain objects (Date, Map, class instances) — Date's toJSON does
  round-trip as a string, but the composition gets a string, not a
  Date, so explicit reject is clearer

validateStepFunctionsInputSize measures the actual UTF-8 byte length
of JSON.stringify(input) against the 256 KiB cap. We use Standard
workflows (per the plan §6.2 / §15.2) for execution-history
visibility, so the cap is 256 KiB (Express would be 32 KiB). The error
message names the actual byte count, the cap, and points at the
templates-on-lambda#working-with-large-variables section so users
know to URL-reference media assets instead of inlining them.

Both helpers are exported from @hyperframes/aws-lambda/sdk so adapters
that build custom Step Functions inputs (batch verbs, future Temporal
ports) can reuse the same gates.

Phase 9 PR 9.2 of the distributed rendering plan.
2026-05-19 19:53:31 -04:00
Miguel Ángel 0decb88946 chore: release v0.6.28 2026-05-19 19:38:08 -04:00
Miguel Ángel 8f0545738c Merge pull request #977 from func25/ceil-duration-preview
fix(core): ceil timeline payload duration to match render frames
2026-05-20 01:33:15 +02:00
func25 0fc3937809 fix(core): ceil timeline payload duration to match render frames 2026-05-20 06:04:29 +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 4237165517 chore: release v0.6.27 v0.6.27 2026-05-19 17:12:32 -04:00
Miguel Ángel 83e01e0d44 Merge pull request #965 from heygen-com/fix/sub-comp-timeline-t0
fix: activate nested child timelines on renderSeek (sub-comp at t=0)
2026-05-19 23:11:32 +02:00
Miguel Ángel d16e5d6359 test: add compiled.html golden snapshots for regression fixtures
Generated via regression-harness --update. The harness requires both
compiled.html and output.mp4 in the output/ directory.
2026-05-19 16:36:16 -04:00
Miguel Ángel a48350bf00 fix(studio): remove Ask agent popup, fix preview selection, fix manual edits in renderer (#963)
## Summary

Three interrelated studio UX and rendering fixes, plus a critical fix for manual edits not surviving video export.

### 1. Remove "Ask agent" popup on preview click
The "Ask agent" modal auto-triggered whenever clicking on a large raster element (images, backgrounds covering >40% of the viewport). This intercepted clicks meant for editable elements underneath, making it very annoying to select elements in compositions with large backgrounds or sub-composition screenshots.

**Removed:** the `isLargeRasterDomEditSelection` check + `setAgentModalOpen(true)` trigger in `usePreviewInteraction.ts`. The manual "Ask agent" button via the context menu still works — only the auto-popup is gone.

**Files:** `usePreviewInteraction.ts`, `useDomEditSession.ts`

### 2. Fix preview click selecting wrong element across visual layers
Clicking the PiP video overlay would select the Sf Chrome image behind it, because the scoring algorithm weighted DOM depth at 10,000× per level. Elements inside sub-compositions (deeper in the DOM tree) always won over visually-on-top elements at the root level, regardless of z-order.

**Fix:** replaced the weighted scoring with a visual-stacking-order-first algorithm. `resolveVisualDomEditSelectionTarget` now trusts `elementsFromPoint` order (topmost first) and only prefers a deeper candidate when it's a direct descendant of the current pick — never jumping to an unrelated element painted behind it.

**Files:** `domEditingElement.ts`

### 3. Fix manual edits not surviving video export

Two gaps in the producer's seek-reapply script (`studioPositionSeekReapplyRuntime`):

**a) Missing box-size reapplication.** The script handled translate and rotation but not width/height. Also, `data-hf-studio-box-size` was absent from the detection list in `htmlCompiler.ts`, so the script wasn't even injected for compositions with resize-only edits.

**b) GSAP transform matrix clobbering translate.** When GSAP animates an element (e.g. `scale`, `opacity`), it captures the element's translate into its internal transform matrix (`m41`/`m42`). The render script was setting the CSS `translate` property but leaving GSAP's translate baked into `transform`, causing the manual edit offset to be ignored. Ported the same `stripGsapTranslateFromTransform` logic the studio uses: parse the DOMMatrix, zero out m41/m42, and remove or rewrite the `transform` property so the CSS `translate` takes effect cleanly.

**Files:** `manualEditsRenderScript.ts`, `htmlCompiler.ts`

## Test plan

- [x] `bun test` — 50 tests pass across `domEditing.test.ts` and `manualEditsRenderScript.test.ts`
- [x] Pre-commit hooks pass (typecheck, lint, format, commitlint)
- [x] Verified in studio preview: clicking elements selects the visually-on-top one, "Ask agent" popup no longer appears
- [x] Rendered a composition with a manually-moved PiP video — position survives in the output video
2026-05-19 22:29:02 +02:00
Miguel Ángel 6652fae243 fix(#969): producer path propagates data-hf-authored-id to host element
When the producer inlines a sub-composition with compId match, it takes
innerRoot.innerHTML which strips the wrapper div and its id attribute.
CSS/GSAP selectors rewritten from #ID to [data-hf-authored-id=ID] then
match nothing.

Fix: after innerHTML injection, copy the inner root's id as
data-hf-authored-id on the host element. This makes #ID selectors work
identically in both preview (bundler) and render (producer) paths.

Closes #969
2026-05-19 16:07:24 -04:00
Miguel Ángel c44b1aaea7 chore: remove duplicate runtime fixtures, producer tests are the source of truth 2026-05-19 16:05:56 -04:00
Miguel Ángel f7d63fbebd fix(studio): address PR review — add tests, strip GSAP transform for rotation, remove dead exports
Addresses review feedback from Rames and Vai:

1. Add 7 new tests for createStudioPositionSeekReapplyScript:
   box-size reapplication, GSAP translate stripping (identity removal,
   scale+translate preservation, transform:none no-op), and rotation-
   only elements with GSAP-baked translate.

2. Add pinning test for the PiP-over-sub-composition selection bug:
   elementsFromPoint returns [pipVideo, subCompRoot, sfChromeImg] as
   siblings — assert the topmost (pipVideo) wins.

3. Apply stripGsapTranslateFromTransform to rotation-only elements
   too, not just path-offset elements. A rotation-only element with
   a GSAP-animated translate would have its position clobbered.

4. Remove dead exports: getPreviewLocalPointer,
   buildRasterClickSelectionContext, getPreviewPlayer,
   seekStudioPreview, PreviewPlayerCompat, PreviewLocalPointer from
   studioPreviewHelpers.ts. Unexport resolvePreviewLocalPointer.
2026-05-19 16:05:37 -04:00
Miguel Ángel 26450c1a27 refactor: address review — rename activateSiblingTimelines, opts arg, FIXME tracking
- Rename activateNestedChildTimelines → activateSiblingTimelines (matches player.ts)
- Use tl.play() instead of tl.paused(false) for consistency
- Convert positional activateChildren boolean to { activateChildren } opts
- Add FIXME(#969) to divergence test with tracking issue link
- Add [id="intro"] no-rewrite boundary test
- Add comment about deliberate no-restore behavior in render-seek path
2026-05-19 16:02:33 -04:00
Miguel Ángel e2f7f6a58a fix: add regression fixtures with golden baselines + address review
- Create sub-comp-t0 and sub-comp-id-selector as proper regression tests
  under packages/producer/tests/ with golden MP4 baselines
- Add both to shard-7 in regression.yml
- Add clarifying comment on activateNestedChildTimelines scope
- Confirm test fixture network safety in comment
2026-05-19 15:59:09 -04:00
Miguel Ángel 6498807e1a fix(core): strip GSAP translate from transform after reapplying manual edits
When GSAP animates an element (e.g. scale, opacity), it captures the
element's translate into its internal transform matrix (m41/m42). The
render reapply script was setting the CSS `translate` property but
leaving GSAP's translate baked into `transform`, causing the manual
edit offset to be ignored or doubled.

Port the same stripGsapTranslateFromTransform logic the studio uses:
parse the transform matrix, zero out m41/m42, and remove or rewrite
the transform property so the CSS `translate` takes effect cleanly.
2026-05-19 15:45:51 -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
Miguel Ángel 0d12a465a3 fix: activate nested child timelines during renderSeek
The renderSeek override in init.ts called seekTimelineAndAdapters() which
only did rootTimeline.totalTime(t) without activating child timelines.
GSAP does not propagate totalTime() to internally paused children.

Also simplifies pollSubCompositionTimelines to always call rebind when
timelines are ready, removing the before/after count comparison that
could skip the rebind on fast page loads.
2026-05-19 15:42:07 -04:00
Miguel Ángel 29ad7df90c style: format fixture HTML files 2026-05-19 15:41:38 -04:00
Miguel Ángel 60cdf8c66b test: add regression fixture for sub-comp #ID selector scoping divergence
The producer inlining path strips the inner root element (taking innerHTML
when compId matches), losing the id attribute. The bundler path preserves it
via flattenInnerRoot + data-hf-authored-root-id. This causes #ID selectors
in sub-comp CSS and GSAP to fail silently during render while working in
preview.

Adds a minimal fixture with a sub-comp using #intro scope to catch this
divergence in future compiler changes.
2026-05-19 15:38:59 -04:00
James f4e96a58ed chore: release v0.6.26 v0.6.26 2026-05-19 18:16:54 +00:00
Miguel Ángel 1d51a8a975 docs: add setup guides for Google Antigravity and GitHub Copilot CLI (#960)
## Summary

Closes #744 — adds dedicated setup guides for two AI coding tools that were missing from the documentation.

- **`docs/guides/antigravity.mdx`** — Google Antigravity IDE guide covering skills installation (workspace + global), semantic skill matching, Manager view parallelism for multi-scene videos, MCP alternative, and CLAUDE.md compatibility
- **`docs/guides/copilot-cli.mdx`** — GitHub Copilot CLI guide covering skills installation, slash command invocation, `/skills` management commands, agent mode for multi-step tasks, and MCP alternative
- **`docs/quickstart.mdx`** — updated agent list to mention both new tools with links to their guides
- **`docs/guides/prompting.mdx`** — updated description to include both tools
- **`docs/docs.json`** — added both guides to the Guides navigation group

## Test plan

- [x] Verify Mintlify docs build passes (`npx mintlify dev` in `docs/`)
- [x] Check navigation: both guides appear under Documentation → Guides
- [x] Verify links from quickstart.mdx to the new guide pages resolve correctly
- [x] Review guide content for accuracy against official Antigravity and Copilot CLI documentation
2026-05-19 19:13:53 +02:00
Miguel Ángel 00cbdd1d6e docs: address PR review — fix skill paths, Copilot CLI commands, MCP flag
- Fix project skill directory to .agents/skills/ (plural) in both guides,
  matching vercel-labs/skills agent registry
- Replace invented --mcp-server flag with correct --additional-mcp-config
  flag and JSON/file syntax for Copilot CLI
- Remove non-existent /skills reload, /skills list, /skills info commands;
  document only /skills (picker) and /skills add
- Remove non-existent github-copilot binary alias
- Antigravity MCP section now defers to Antigravity's own MCP docs for
  the exact settings UI path
- Rename CLAUDE.md section to "Agent instruction files", document both
  AGENTS.md (cross-agent) and CLAUDE.md
- Use --agent long form instead of -a in examples
- Add link to Antigravity Manager view docs
- Add token budget caveat for large skill sets in Copilot CLI
2026-05-19 13:02:43 -04:00
Miguel Ángel 5939226650 fix(studio): stop injecting inline z-index on all clips during timeline edits (#959)
## Summary

- Remove the z-index injection loops from timeline move, delete, and asset-drop commit paths — only timing/track attributes are now patched on the affected clip, leaving all other clips untouched
- Fix `patchInlineStyleInTag` to handle self-closing void elements (`<img />`, `<audio />`) — the old code produced malformed `<img ... / style="z-index: 7">` output
- Delete the now-unused `buildTrackZIndexMap` helper and its tests

## Root cause

Three timeline operations (`handleTimelineElementMove`, `handleTimelineElementDelete`, `handleTimelineAssetDrop`) looped over every clip in the file on each commit and injected `style="z-index: N"` derived from an inverted `data-track-index` mapping via `buildTrackZIndexMap`. This overrode the author's CSS z-index — contradicting the documented contract that `data-track-index` does not affect visual layering — and persisted the corruption in the source HTML.

## Test plan

- [x] Reproduction test covering old bug behavior (inline z-index injection on all clips, inverted layering)
- [x] Verification tests confirming move/delete only patches the affected clip's timing attributes
- [x] Void element tests confirming `<img ... style="..." />` output (not `<img ... / style="...">`)
- [x] End-to-end browser test: opened Studio, dragged badge clip in timeline via CDP, verified only `data-start` changed on the dragged clip with zero inline z-index injections
- [x] Full test suite: 585 tests pass (54 files)
- [x] Build, lint, format, typecheck all green

Closes #958
2026-05-19 18:51:53 +02:00
Miguel Ángel 9342d3dea8 docs: add setup guides for Google Antigravity and GitHub Copilot CLI
Add dedicated guide pages for two AI coding tools that were missing
from the documentation (closes #744):

- docs/guides/antigravity.mdx — skills install, semantic matching,
  Manager view parallelism, MCP alternative, CLAUDE.md compat
- docs/guides/copilot-cli.mdx — skills install, slash commands,
  /skills management, agent mode, MCP alternative

Also updates quickstart.mdx and prompting.mdx to mention both tools
alongside the existing agent list, and adds both pages to the docs
navigation in docs.json.
2026-05-19 12:44:14 -04:00
Miguel Ángel 4916d6580c fix(studio): stop injecting inline z-index on all clips during timeline edits
Timeline move, delete, and asset-drop operations were looping over every
clip in the file and writing style="z-index: N" derived from an inverted
data-track-index mapping. This silently overrode the author's CSS z-index
— contradicting the documented contract that data-track-index does not
affect visual layering — and persisted the corruption in the source HTML.

Remove the z-index injection loops from all three timeline commit paths.
Move and delete now only patch timing/track attributes on the affected
clip. Asset drop still sets z-index on the newly created element via the
generated HTML, without touching existing clips. Delete the now-unused
buildTrackZIndexMap helper.

Also fix patchInlineStyleInTag to handle self-closing void elements: the
old code produced malformed `<img ... / style="z-index: 7">` because it
didn't account for the trailing `/` before appending the style attribute.

Closes #958
2026-05-19 12:42:46 -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 07de7e61ed feat(engine): closed-GOP VP9 encoder args + concat-copy smoke test (#950)
## Description

PR 1 of 4 in the WebM (VP9) distributed-rendering series. A gating
experiment that proves closed-GOP libvpx-vp9 chunks survive
`ffmpeg -f concat -c copy` losslessly, so the rest of the stack can
ship Path A (concat-copy) rather than the slower
re-encode-in-assemble fallback.

Two changes:

1. **Closed-GOP VP9 encoder args.** `buildEncoderArgs` now lays
   `-g <chunkSize>`, `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, and
   `-cpu-used 2` on libvpx-vp9 when `lockGopForChunkConcat=true`.
   Mirrors the existing libx264/libx265 branches. The alt-ref disable
   is load-bearing — libvpx-vp9's default non-displayable alt-ref
   frames can reach across chunk seams and break concat-copy.
   `-cpu-used 2` pins the speed/quality tradeoff so chunks encoded on
   workers with different libvpx-vp9 defaults produce visually
   consistent output across seams. Default (`lockGopForChunkConcat`
   unset) preserves the existing in-process VP9 path unchanged.

2. **Concat-copy smoke test** at
   `packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts`.
   Generates 60 PNGs via lavfi `testsrc2`, encodes them as 4 VP9 chunks
   of 15 frames using `buildEncoderArgs` with
   `lockGopForChunkConcat=true`, concat-copies via `ffmpeg -f concat -c
   copy`, then runs three independent verifications:
   `ffprobe -show_streams`, `ffmpeg -f null -` decode test, and
   `ffprobe -count_frames`. Each verification surfaces its failure
   fingerprint in the error message.

Smoke test passes 6/6 locally → Path A works; the rest of the stack
takes it.

Also exports `buildEncoderArgs` from `@hyperframes/engine` so
adapters / tests can construct args without re-implementing the
contract.

## Testing

- [x] `bunx vitest run --root packages/engine src/services/chunkEncoder.test.ts` — 62/62 pass (new VP9 closed-GOP tests included)
- [x] `bun test packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts` — passes
- [x] `bunx oxlint` + `bunx oxfmt --check` on all changed files — clean
- [x] `bunx tsc --noEmit -p packages/engine/tsconfig.json` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-19 01:53:08 -04:00
James Russo efc5f0584b ci: post sticky PR comment with fallow audit findings (#954)
* ci: run fallow audit in lefthook pre-commit

Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.

Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.

Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.

The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.

* refactor: delete orphan declarations flagged by fallow

After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.

Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.

Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.

Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.

Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.

* ci: post sticky PR comment with fallow audit findings

Reviewers shouldn't have to dig through CI logs to see what fallow
flagged. With this change, on every PR the fallow job posts (or
updates) a sticky comment containing the full audit report formatted
as a collapsible markdown table.

The comment uses fallow's built-in `pr-comment-github` format, which
already emits a `<!-- fallow-id: fallow-results -->` sentinel.
`marocchino/sticky-pull-request-comment@v2.9.1` matches that header so
each run replaces the previous comment instead of stacking new ones.

The job now runs in three steps:
1. Run `fallow audit ... --format pr-comment-github` with
   `continue-on-error: true` so the comment posts even when the audit
   fails. Exit code is captured.
2. Post (or update) the sticky comment with the captured output.
3. Re-emit the audit exit code so the job still fails-the-build on
   new findings.

Bumps the workflow's `pull-requests` permission from read to write,
needed for the sticky-comment poster to call the issues API.
2026-05-18 21:28:41 -07:00
James Russo 2729ee5087 refactor: delete orphan declarations flagged by fallow (#949)
* ci: run fallow audit in lefthook pre-commit

Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.

Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.

Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.

The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.

* refactor: delete orphan declarations flagged by fallow

After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.

Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.

Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.

Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.

Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.
2026-05-18 21:11:03 -07:00
James Russo b6b7bcb51a ci: run fallow audit in lefthook pre-commit (#948)
Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.

Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.

Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.

The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.
2026-05-18 19:39:34 -07:00
James 2dc2531cf7 chore: release v0.6.25 v0.6.25 2026-05-19 02:33:31 +00: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
Miguel Ángel 7354d61371 chore: release v0.6.24 v0.6.24 2026-05-18 22:00:56 -04:00
Miguel Ángel 72a18a0116 Merge pull request #947 from heygen-com/feat/studio-blocks-panel
feat(studio): full Blocks panel — browse, search, add, drag-and-drop registry items
2026-05-19 03:58:25 +02:00