Commit Graph
992 Commits
Author SHA1 Message Date
terencecho efc16a945f fix(engine): treat ffmpegStreamingTimeout as per-frame inactivity, not total render time (#901)
## Summary

- Convert `streamingEncoder.ts`'s safety timer from a total-render hard cap to a per-frame inactivity timeout
- Reset the timer only on `accepted === true` writes — buffered writes don't count as consumer progress
- Update the `ffmpegStreamingTimeout` config doc to reflect the new semantics

## The bug

The timer was set once at spawn and fired SIGTERM unconditionally at `ffmpegStreamingTimeout` ms — turning a "FFmpeg is hung" guard into a hard cap on total render duration. Slow-but-progressing captures (CI runner under load, large compositions, slower compositor paths after [#838](https://github.com/heygen-com/hyperframes/pull/838)'s always-clip change) regularly exceeded the 600s default and were killed mid-encode. The symptom surfaced as:

```
Streaming encode failed: FFmpeg exited with code 255
video:NNNkB audio:0kB ...
[libx264 @ ...] frame I:3  Avg QP:12.91  size: 73263
[libx264 @ ...] frame P:431 Avg QP:14.72  size: 31633
...
[libx264 @ ...] kb/s:7661.05
Exiting normally, received signal 15.
```

libx264 had encoded most frames cleanly; SIGTERM arrived during the encode, libx264 printed its end-of-encode stats, and Node observed a non-zero exit. The `audio:0kB` in stderr is incidental — `streamingEncoder` is video-only; audio is muxed later in `assembleStage`.

Downstream reproduction: `style-13-prod` fails deterministically in `heygen-com/hyperframes-internal` CI after bumping `@hyperframes/producer` from 0.6.7 → 0.6.10. Bisects to #838 widening the SDR capture path at dpr=1 — same composition shape, slower per-frame, total render now crosses 600s.

## The fix

Convert the timer to a heartbeat: each `writeFrame` that goes through to the kernel pipe (i.e. `stdin.write` returns `true`) resets it. Only true hangs (no successful frame write for the timeout window) trip SIGTERM now; "slow but progressing" renders are unbounded.

Crucially, the heartbeat does **not** reset on `accepted === false`. A `false` return means Node had to buffer the write because FFmpeg hasn't drained the pipe yet — that's not proof of consumer progress, just proof we produced. Without this distinction, a hung FFmpeg with a live Chrome would queue frames into Node's writable buffer indefinitely (no backpressure path back to the capture loop) and grow until OOM. In steady state with a slow-but-alive FFmpeg, writes alternate between `true` and `false` as the buffer drains and refills; the `true`s are enough to keep the heartbeat ticking.

Renames are intentionally avoided — `ffmpegStreamingTimeout` keeps its name and `600_000` default; only the semantics changed. The config doc spells out the new behavior so downstream consumers know what 600s now means.

## Test plan

- [x] **Slow-but-progressing capture** (`accepted=true`): 9× `writeFrame` at 900ms intervals (under the 1000ms threshold) — encoder stays alive through 8.1s. Stall past the threshold — SIGTERM fires.
- [x] **Stalled FFmpeg with live producer** (`accepted=false`): override `stdin.write` to return false; pump 9× `writeFrame` at 900ms intervals. SIGTERM still fires inside the 1000ms window — buffered writes don't keep the heartbeat alive.
- [x] Existing 33 tests in `streamingEncoder.test.ts` still pass
- [x] Lint (`oxlint`) + format (`oxfmt --check`) clean
- [ ] CI regression suite

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-16 12:38:16 -07:00
na-naviandAnoKno 64b3ae755d feat(cli): add browser launch options to preview and play (#884)
* feat(cli): add browser launch options to preview and play

* fix(cli): add spawn error listener to prevent ENOENT crash

---------

Co-authored-by: AnoKno <122017492+AnoKno@users.noreply.github.com>
2026-05-16 21:05:00 +02:00
Miguel ÁngelandClaude Sonnet 4.6 883260aae3 chore: release v0.6.13
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v0.6.13
2026-05-16 16:35:46 +00:00
Phuong Le be33afb361 fix(core): align sub-composition scoping across runtime and bundler (#897) 2026-05-16 18:25:24 +02:00
Miguel Ángel 2355d505e1 chore: release v0.6.12 v0.6.12 2026-05-16 00:48:57 -07:00
Miguel Ángel cd0e6b02a0 feat(studio): Timing inspector + fix mixed-content text editing (#896)
* feat(studio): add clipboard payload types and ID deduplication

* feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements

* fix(studio): use duck-typing for cross-frame element access in clipboard

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.

* fix(studio): preserve playhead position after paste

reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.

* fix(studio): paste DOM elements as siblings, not at composition root

DOM element paste was inserting at the composition root, losing the
parent context that provides CSS styles and positioning. Now stores
the origin selector on copy and inserts the paste as a sibling
immediately after the original element, preserving style inheritance.
Falls back to root insertion if the selector can't be matched.

* fix(studio): address review — deduplicateIds, native copy, altKey guard

- deduplicateIds regex used \b which matched data-composition-id,
  data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone
  id="..." attributes are rewritten. Add test pinning this.
- Ctrl+C no longer calls preventDefault() before confirming there's
  a selected element. Native browser copy (text selections outside
  inputs) is preserved when nothing is selected in the Studio.
- Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V
  (paste-as-plain-text) and similar OS gestures.
- Remove no-op .replace(/"/g, '"') flagged by CodeQL.

* fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by

- Cmd+X now pre-checks selection state before preventDefault, mirroring
  the Cmd+C fix. Native cut preserved when nothing is selected.
- handleCut returns Promise<boolean> so the caller can gate on it.
- data-start rewrite scoped to the outermost opening tag only, so nested
  clip timing is preserved on paste.
- Removed system clipboard write (cross-tab paste unsupported, in-memory
  ref is the only read path).
- Reverted the reloadPreview drive-by (setRefreshKey→location.reload);
  the perf branch (#895) handles this properly via refreshPlayer().

* perf(studio): use lightweight iframe.src reload instead of Player teardown

Content refreshes (paste, move, resize, delete, asset drop) previously
triggered setRefreshKey which changed the Player's React key, causing
full web-component destruction + iframe teardown + crossfade animation
+ re-initialization of all event listeners and asset polling.

Now NLELayout intercepts refreshKey changes and calls refreshPlayer()
which just appends a cache-busting _t param to the iframe src. The
Player web component stays alive, event listeners persist, and the
reload is ~10x faster with no "waiting for media" flash.

Key-based teardown is preserved for actual structural changes (project
switch, composition drill-down via directUrl change).

* perf(studio): skip asset-loading overlay on content refreshes

The asset-loading overlay ("Preparing preview assets") polled for
video/audio readyState on every iframe load, including content
refreshes from paste/move/resize. On reloads the browser serves
assets from cache so they resolve near-instantly — the overlay
just created a disruptive flash. Now skips the polling on
subsequent loads (loadCountRef > 1), only showing it on the
initial cold load.

* feat(studio): add Timing section to inspector Design panel

Adds Start, End, and Duration fields to the Design panel when the
selected element has data-start/data-duration attributes. Editing
any field commits via the attribute patch pipeline (same as timeline
edits) and refreshes the preview. End is computed from start+duration
and writing End adjusts duration accordingly.

* fix(studio): preserve bare text nodes in mixed-content elements

collectDomEditTextFields only captured child HTML elements, ignoring
bare text nodes. For elements like:
  <div class="headline">If you're <span>turning 65</span> soon...</div>
only the <span> was collected as a text field. When commitDomTextFields
serialized back, "If you're " and " soon..." were lost.

Now walks childNodes and creates text-node fields for bare text nodes
alongside child element fields. serializeDomEditTextFields emits bare
text for text-node fields, preserving the complete mixed content.

* fix(studio): address #896 review — remove scrub from timing, add mixed-content test

- Remove scrub from Timing fields: 1px = 1 second is too coarse.
  Scroll-wheel and direct typing still work with sub-second precision.
- Add mixed-content text-node serialization test in a separate file
  (domEditingTextFields.test.ts) to avoid bloating the existing
  domEditing.test.ts past the filesize limit.
2026-05-16 09:46:52 +02:00
Miguel Ángel 83b3ebabf3 perf(studio): lightweight preview reload, skip asset overlay (#895)
* feat(studio): add clipboard payload types and ID deduplication

* feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements

* fix(studio): use duck-typing for cross-frame element access in clipboard

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.

* fix(studio): preserve playhead position after paste

reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.

* fix(studio): paste DOM elements as siblings, not at composition root

DOM element paste was inserting at the composition root, losing the
parent context that provides CSS styles and positioning. Now stores
the origin selector on copy and inserts the paste as a sibling
immediately after the original element, preserving style inheritance.
Falls back to root insertion if the selector can't be matched.

* fix(studio): address review — deduplicateIds, native copy, altKey guard

- deduplicateIds regex used \b which matched data-composition-id,
  data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone
  id="..." attributes are rewritten. Add test pinning this.
- Ctrl+C no longer calls preventDefault() before confirming there's
  a selected element. Native browser copy (text selections outside
  inputs) is preserved when nothing is selected in the Studio.
- Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V
  (paste-as-plain-text) and similar OS gestures.
- Remove no-op .replace(/"/g, '"') flagged by CodeQL.

* fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by

- Cmd+X now pre-checks selection state before preventDefault, mirroring
  the Cmd+C fix. Native cut preserved when nothing is selected.
- handleCut returns Promise<boolean> so the caller can gate on it.
- data-start rewrite scoped to the outermost opening tag only, so nested
  clip timing is preserved on paste.
- Removed system clipboard write (cross-tab paste unsupported, in-memory
  ref is the only read path).
- Reverted the reloadPreview drive-by (setRefreshKey→location.reload);
  the perf branch (#895) handles this properly via refreshPlayer().

* perf(studio): use lightweight iframe.src reload instead of Player teardown

Content refreshes (paste, move, resize, delete, asset drop) previously
triggered setRefreshKey which changed the Player's React key, causing
full web-component destruction + iframe teardown + crossfade animation
+ re-initialization of all event listeners and asset polling.

Now NLELayout intercepts refreshKey changes and calls refreshPlayer()
which just appends a cache-busting _t param to the iframe src. The
Player web component stays alive, event listeners persist, and the
reload is ~10x faster with no "waiting for media" flash.

Key-based teardown is preserved for actual structural changes (project
switch, composition drill-down via directUrl change).

* perf(studio): skip asset-loading overlay on content refreshes

The asset-loading overlay ("Preparing preview assets") polled for
video/audio readyState on every iframe load, including content
refreshes from paste/move/resize. On reloads the browser serves
assets from cache so they resolve near-instantly — the overlay
just created a disruptive flash. Now skips the polling on
subsequent loads (loadCountRef > 1), only showing it on the
initial cold load.
2026-05-16 09:46:33 +02:00
Miguel Ángel acd141b2ae feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements (#894)
* feat(studio): add clipboard payload types and ID deduplication

* feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements

* fix(studio): use duck-typing for cross-frame element access in clipboard

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.

* fix(studio): preserve playhead position after paste

reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.

* fix(studio): paste DOM elements as siblings, not at composition root

DOM element paste was inserting at the composition root, losing the
parent context that provides CSS styles and positioning. Now stores
the origin selector on copy and inserts the paste as a sibling
immediately after the original element, preserving style inheritance.
Falls back to root insertion if the selector can't be matched.

* fix(studio): address review — deduplicateIds, native copy, altKey guard

- deduplicateIds regex used \b which matched data-composition-id,
  data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone
  id="..." attributes are rewritten. Add test pinning this.
- Ctrl+C no longer calls preventDefault() before confirming there's
  a selected element. Native browser copy (text selections outside
  inputs) is preserved when nothing is selected in the Studio.
- Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V
  (paste-as-plain-text) and similar OS gestures.
- Remove no-op .replace(/"/g, '"') flagged by CodeQL.

* fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by

- Cmd+X now pre-checks selection state before preventDefault, mirroring
  the Cmd+C fix. Native cut preserved when nothing is selected.
- handleCut returns Promise<boolean> so the caller can gate on it.
- data-start rewrite scoped to the outermost opening tag only, so nested
  clip timing is preserved on paste.
- Removed system clipboard write (cross-tab paste unsupported, in-memory
  ref is the only read path).
- Reverted the reloadPreview drive-by (setRefreshKey→location.reload);
  the perf branch (#895) handles this properly via refreshPlayer().
2026-05-16 09:46:04 +02:00
Miguel Ángel 4212a28312 chore: release v0.6.11 v0.6.11 2026-05-15 23:43:21 -07:00
terencecho c003ef67e9 fix(player): pause parent audio proxy on seek to prevent stutter loop (#890)
## Summary
- `seek()` only called `seekAll()` under parent audio ownership, leaving the `<audio>` proxy playing while the timeline froze at the new seek target.
- The periodic `mirrorTime` drift-correction (`parent-media.ts`) would then yank `currentTime` back to the timeline position every ~80ms of accumulated drift, producing an audible stutter loop while the video frame stayed frozen.
- Fix: make `seek()` symmetric with `pause()` — pause the parent proxy before seeking it.

## Repro
1. Use the player in an environment where the runtime posts `media-autoplay-blocked` (mobile / autoplay-restricted contexts), promoting audio ownership to `"parent"`.
2. Start playback with audio.
3. Click anywhere on the scrubber while playing.
4. Before: audio stutters in a short loop while the video frame is frozen.
5. After: audio cleanly pauses at the new seek position.

## Test plan
- [x] Added regression test \`seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)\` in \`hyperframes-player.test.ts\`.
- [x] \`pnpm --filter @hyperframes/player test\` — 110/110 pass.
- [ ] Manual repro on a device where ownership flips to \`parent\`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 23:20:14 -07:00
Miguel Ángel 1e05d78378 fix(engine): enable browser pool and deduplicate concurrent Chrome launches (#889)
## Summary

- **Enable browser pool by default** (`enableBrowserPool: true`) — parallel capture workers now share a single Chrome process via reference-counted pool instead of each spawning their own (~256MB each). A 6-worker render drops from 7+ browser parent processes to 1 shared pool.
- **Add launch-promise deduplication** in `acquireBrowser` — when multiple workers race into the pool simultaneously (via `Promise.all`), they await the same launch Promise instead of each triggering a separate Chrome spawn. Same pattern as the existing `_autoBrowserGpuModeCache` for GPU probes.
- **Add `connected` health check** on pool hit — if Chrome crashes mid-render, subsequent acquires detect the dead browser and launch fresh instead of returning a stale reference.
- **Add `drainBrowserPool()`** for explicit cleanup between independent render jobs.
- **CLI studio server** now uses the shared pool instead of its own redundant `enableBrowserPool: false` singleton, so thumbnail generation shares Chrome with render workers.

## Problem

The engine had a reference-counted browser pool (`browserManager.ts:73-75`) but it was **disabled by default** (`enableBrowserPool: false`). This meant:

1. **Every parallel worker spawned its own Chrome** — a `--workers 6` render launched 7+ independent Chrome processes (1 probe + 6 workers), each ~256MB.
2. **The pool had a race condition** — even if manually enabled, concurrent workers calling `acquireBrowser()` via `Promise.all` could all see `pooledBrowser === null` before the first launch completed, spawning N Chromes instead of 1.
3. **No crash recovery** — if Chrome died, the pool still held the dead reference. Subsequent acquires got a disconnected browser.
4. **CLI studio server ran its own singleton** — `studioServer.ts` explicitly set `enableBrowserPool: false` and managed a separate browser, so thumbnails and renders could never share.

Over time, orphaned Chrome processes accumulated across renders and previews. We observed **344 headless Chrome processes** consuming **569% CPU and 20% memory** on a dev machine.

## Before / After (6-worker parallel render)

| Metric | Before (pool off) | After (pool on) |
|--------|-------------------|-----------------|
| Browser parent processes | 7+ (1 probe + 6 workers) | **2** (1 GPU probe + 1 shared) |
| Total Chrome processes (with helpers) | 40-50+ | **14** |
| Memory during capture | ~20%+ | **4.6%** |
| Render time (1200 frames, 30fps) | ~64s | **53s** (~17% faster) |
| Post-render orphans | Accumulated over time | **0** |

## Changes

| File | Change |
|------|--------|
| `engine/src/config.ts` | `enableBrowserPool` default `false` → `true` |
| `engine/src/services/browserManager.ts` | Extract `launchBrowser()`, add `_pooledBrowserLaunchPromise` dedup, add `connected` check on pool hit, add `drainBrowserPool()` and `_resetBrowserPoolForTests()` |
| `engine/src/index.ts` | Export `drainBrowserPool` |
| `engine/src/services/browserManager.test.ts` | Pool dedup and drain tests |
| `cli/src/server/studioServer.ts` | Remove `enableBrowserPool: false` override — thumbnails now share the pool |
| `producer/src/services/browserManager.ts` | Re-export `drainBrowserPool` |

## Backward compatibility

- `PRODUCER_ENABLE_BROWSER_POOL=false` env var disables pooling (same as before).
- Callers passing `{ enableBrowserPool: false }` explicitly still get isolated browsers.
- Tests that set `enableBrowserPool: false` in their config fixtures continue to work.

## Test plan

- [x] Engine tests pass (597/597)
- [x] Producer tests pass (406/407, 1 pre-existing flaky test in `pngDecodeBlitWorkerPool`)
- [x] Build passes (lint, format, typecheck all green via lefthook pre-commit)
- [x] Manual render: `shortform-financial` with `--workers 6` → 1200 frames in 53s, 0 orphaned Chrome processes after completion
- [x] Process monitoring during render confirmed 2 browser parents (1 GPU probe + 1 shared pool) instead of 7+
2026-05-16 08:17:29 +02:00
Miguel Ángel d3c32a0b4d chore: release v0.6.10 v0.6.10 2026-05-15 21:25:26 -07:00
terencecho a79d8acd7a fix: ship lottieReadiness + guard studio import.meta.env for non-Vite consumers (#861)
## Summary

Two small fixes that together make `@hyperframes/core` + `@hyperframes/studio` consumable from non-Vite hosts (Next.js / Turbopack, Node, etc.).

### 1. `core`: ship the missing `lottieReadiness` module

The `"./runtime/lottie-readiness"` subpath export in `@hyperframes/core` claims to ship at `./dist/runtime/adapters/lottieReadiness.js`, but that file is missing from the published 0.6.6 and 0.6.7 tarballs. Consumers that import the subpath — most notably `@hyperframes/studio`'s `Player.tsx` — fail to resolve the module and break downstream builds.

**Root cause:** `packages/core/tsconfig.json` excludes `src/runtime` (those files run in a browser context and are bundled separately into the IIFE artifact). Since nothing in the included tree imports `lottieReadiness.ts`, tsc never emits a compiled output, and the file silently goes missing from the publish.

**Fix:** `lottieReadiness.ts` is a pure helper — takes `unknown`, returns `boolean`, no DOM/`window` dependencies. It doesn't belong in `src/runtime/` in the first place; the runtime-exclude rule rightly caught it. Move it to `src/lottieReadiness.ts` so the standard library build picks it up.

The subpath export **name** stays `"./runtime/lottie-readiness"` — only the exports map's underlying file path changes — so existing consumers (studio) don't need any code change.

### 2. `studio`: guard `import.meta.env` for non-Vite hosts

`packages/studio/src/components/editor/manualEditingAvailability.ts` unconditionally reads `import.meta.env`. That's a Vite-only extension; in plain ESM hosts (Next.js / Turbopack, Node, jest in some configs) `import.meta` exists but `import.meta.env` is `undefined`. Reading any property off undefined throws at module evaluation time, so the studio fails to load the moment a non-Vite host imports anything from `@hyperframes/studio`.

Guarded the read so the module is loadable everywhere; outside Vite, every flag falls back to its declared default, preserving Vite behavior.

### Changes

**core:**
- `mv src/runtime/adapters/lottieReadiness.{ts,test.ts}` → `src/`
- Update `src/runtime/adapters/lottie.ts` re-export path
- Update `package.json` + `publishConfig.exports` to point at the new dist path (`./dist/lottieReadiness.{js,d.ts}`)

**studio:**
- One-line guard in `manualEditingAvailability.ts:30` with explanatory comment

## Test plan

- [x] `pnpm typecheck` (core, studio) — clean
- [x] `bun run build` (core) — `dist/lottieReadiness.{js,d.ts}` now present
- [x] `bunx vitest run` (core) — 862/862 passing
- [x] `bun run typecheck` (studio) — clean, resolves moved file via subpath export
- [ ] Publish 0.6.8 and verify the tarball contains `dist/lottieReadiness.js`
- [ ] Verify a non-Vite ESM consumer (e.g. a Next.js / Turbopack app) imports `@hyperframes/studio` without `import.meta.env` errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-16 06:24:30 +02:00
Miguel Ángel 3336fddd41 fix(studio): handle full HTML doc sub-compositions in preview (#885)
## Summary

- **Root cause**: `buildSubCompositionHtml` assumed all sub-compositions used `<template>` wrappers. Full HTML document blocks (like `north-korea-locked-down` and `nyc-paris-flight`) were nested as-is inside `<body>`, producing invalid HTML with nested `<html>` and `<head>` elements
- **Effect**: the composition's `<style>` tags ended up misplaced inside `<body>`, and `<img src="assets/...">` paths failed to resolve when combined with the injected `<base>` tag — resulting in missing map images in the Studio sub-composition preview
- **Fix**: detect full HTML documents and properly extract head styles/scripts and body content into separate sections, producing valid HTML where CSS lands in `<head>` and relative asset paths resolve correctly

## Test plan

- [x] New unit test: full HTML document composition produces clean output without nested `<html>` in `<body>`
- [x] Existing test: `<template>`-wrapped compositions still rewrite `../` asset paths correctly
- [x] Visual verification: captured sub-composition preview frames before/after fix — maps now render correctly for both blocks
- [x] Manual: open a project with `north-korea-locked-down` or `nyc-paris-flight` as a sub-composition in Studio, click on the sub-comp in the timeline → map should be visible
2026-05-16 06:23:55 +02:00
Miguel Ángel 82c9b6b5ea chore: release v0.6.9 v0.6.9 2026-05-15 19:45:01 -07:00
Phuong Le 395cbaf3f5 fix(studio): make optional file reads explicit (#883) 2026-05-16 04:42:56 +02:00
Miguel Ángel 4b501762e4 chore: release v0.6.8 v0.6.8 2026-05-15 19:20:28 -07:00
Miguel Ángel 58a370939c fix(studio): fix seek after code edit, improve scrub perf, add click-to-source (#881)
* feat(studio): html-backed motion panel — persist GSAP motion to element attributes

Re-architects the motion panel to store GSAP motion data as a JSON
data attribute (data-hf-studio-motion) on each element instead of a
.hyperframes/studio-motion.json sidecar file. Follows the same
pattern as position/resize/rotation edits: write to DOM, build patches,
persist to HTML source via commitPositionPatchToHtml.

Render pipeline: the studioPositionSeekReapplyRuntime now queries
[data-hf-studio-motion] elements after each seek, parses their JSON,
builds a GSAP timeline, and seeks it to the current frame time.

Studio preview: motion reapply is integrated into the manual edits seek
hook (reapplyPositionEditsAfterSeek). useManifestPersistence is slimmed
to only handle save queue and seek hooks.

* fix(studio): address PR review — html-escape attrs, cache timeline, migrate sidecar, add tests

Blocker: JSON attribute values are now HTML-entity-escaped before being
written into source HTML. Read-back unescapes automatically.

Perf: motion timeline is cached between seeks at render — only rebuilt
when the concatenated JSON key changes, not on every frame.

Migration: on mount, empties legacy .hyperframes/studio-motion.json so
the legacy render script no-ops.

Tests: 46 new tests for motion read/write/clear round-trips, JSON
attribute escaping, and source patcher entity handling.

Nits: removed unused activeCompositionPath param; tightened htmlCompiler
attribute substring check.

* fix(studio): fix seek after code edit, improve scrub performance, add click-to-source

Three issues addressed:

1. **Seek breaks after code edit**: During crossfade refreshes the retiring
   Player's cleanup unconditionally nulled `iframeRef.current`, clobbering the
   reference the new Player had already assigned. Guard the cleanup to only
   clear the ref when it still points to the retiring Player's own iframe.

2. **Scrubber/timeline drag jank**: Every pointermove during a drag called the
   full seek pipeline (adapter.seek + setCurrentTime + React re-render cascade).
   RAF-throttle the expensive onSeek call during drags while keeping slider and
   playhead visuals updated on every pointer event for instant feedback.

3. **Click-to-source**: Clicking an element in the preview now switches to the
   Code tab, opens the element's source file, and scrolls the editor to the
   element's opening tag. Uses the existing `findTagByTarget` source patcher to
   locate the element by id/selector in the HTML source.

* fix(studio): address PR review — gate click-to-source, fix fetch race, guard refs

- Gate click-to-source on Alt/Option+click so it doesn't steal the Code
  tab on every preview click, conflicting with select-to-inspect workflow
- Fix fetch race in openSourceForSelection: AbortController cancels the
  previous in-flight fetch, monotonic request ID prevents stale responses
  from applying the wrong file/offset
- Guard the callback-ref branch in Player cleanup (no-op — can't read
  back from a callback ref to check identity, and the path is unreachable
  today since the ref is always a MutableRefObject)
- Import SidebarTab type instead of duplicating the literal inline
2026-05-16 04:13:06 +02:00
f84cc492de perf(engine): faster shader transitions via page-side WebGL compositing (#832)
* fix(cli): prefer puppeteer cache + numeric version sort (staff review)

Two correctness fixes from PR #821 self-review:

1. Cache priority order. Previous order was hyperframes-managed cache →
   puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which
   lags 17+ releases behind upstream; if a user separately installed a
   newer chrome-headless-shell via @puppeteer/browsers install, the CLI
   would silently hand engine the older HF-cache binary while engine's
   own resolveHeadlessShellPath would have picked the newer one. Flip
   the priority so puppeteer cache wins, matching engine semantics.

2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()`
   over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would
   return `linux-99...` first because character '9' outranks '1'. Parse
   each name into integer segments and compare them numerically.

Tests: add both-caches-populated and linux-148-beats-linux-99 cases.

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

* perf(engine): page-side compositing for shader transitions (opt-in spike)

Add an opt-in `--page-side-compositing` flag (CLI) backed by a new engine
config field `enablePageSideCompositing` and env var `HF_PAGE_SIDE_COMPOSITING`.
When set, SDR shader-transition compositions skip the Node-side layered blend
(the hf#677 chain) and instead run the shader inside Chrome via a page-side
WebGL canvas; the engine then captures ONE opaque RGB frame per output frame
via the existing streaming capture path.

This is the strongest non-beginFrame perf lever for Mac users, who cannot
take the beginFrame `~5×` path (Chromium structural limit, crbug.com/40656275).
Stacks on top of the hf#677 1.95× baseline.

Default OFF — existing fixture pins (byte-exact MP4 output) are preserved.
Opt-in path is intentionally PSNR-pinned, not byte-equal (WebGL is f32; Node
is f64). HDR content forces the existing layered path regardless.

Implementation:
- engine: new `EngineConfig.enablePageSideCompositing` (default false).
- producer/fileServer: new `HF_PAGE_SIDE_COMPOSITING_STUB` early-page script
  injected into the served HTML head when the flag is on.
- producer/renderOrchestrator: when the flag + no HDR + no png-sequence,
  route SDR transitions through the streaming path instead of the layered
  HDR stage.
- shader-transitions: new `engineModePageComposite.ts` installs a fullscreen
  WebGL compositor overlay and wraps `window.__hf.seek` so each seek inside
  a transition window captures both scenes via the Chromium
  `drawElementImage` API to GL textures, runs the fragment shader, and
  displays the composited result on the overlay canvas. The engine takes
  one screenshot per frame and sees the composited overlay.
- cli: new `--page-side-compositing` flag sets `HF_PAGE_SIDE_COMPOSITING=true`
  before producer load.
- scripts/page-side-compositing-smoke: bundled-CLI smoke that renders a
  representative fixture with and without the flag, validates the canary
  strings are in the shipped bundles, and writes a wall-time pair.

Determinism trade documented in the engine config doc-comment. The smoke
script enforces the bundled-CLI validation discipline from prior perf work
(see internal feedback note `validate_bundled_cli_not_dev_path`).

Runtime requirement: Chromium's `CanvasDrawElement` feature (already
enabled by the engine's `--enable-features=CanvasDrawElement` launch flag).
When the runtime feature is unavailable, the page-side installer logs a
warning and falls back to opacity-flip mode — the engine still takes the
streaming path; the transition window degrades to a hard scene swap. Vance
will validate on Mac Chrome where the feature is supported.

Co-Authored-By: Vai <vai@heygen.com>

* fix(shader-transitions): use html2canvas for page-side compositor capture

The original drawElementImage approach fails in engine render mode because
the virtual-time shim prevents Chromium from generating paint records for
cloned elements. drawElementImage requires a cached paint record from the
browser's compositor — clones created at capture time never receive one
because (a) shimmed rAFs deadlock inside the seek wrapper, (b) original
rAFs don't produce real paints under virtual-time control, and
(c) layoutsubtree canvases don't apply CSS stylesheet rules to children.

Switch scene capture to html2canvas (foreignObjectRendering: false), the
same JS-based renderer already used by the preview-mode fallback path in
capture.ts. html2canvas reads computed styles and renders via its own
canvas drawing pipeline with no dependency on the browser paint cycle.

Also fixes:
- Engine seek must return the result so Puppeteer awaits async seek
  promises (frameCapture.ts).
- GSAP opacity cache: compositor must restore scene opacity before seek,
  not after — GSAP caches inline values and skips re-writes.
- Support check gates on WebGL availability, not drawElementImage.

Perf: 15-scene shader-perf fixture (28s, 14 transitions, 30fps)
  Baseline (Node-side layered): 137s
  Page-side (html2canvas+WebGL): 33s → 4.1× speedup

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

* refactor(shader-transitions): simplify review fixes for page-side compositor

- Use uploadTexture (zeroes canvas backing store after upload) to prevent
  ~2.2GB transient memory pressure across 280 html2canvas calls per render
- Add ignoreElements + stabilizeTransformedBoxShadows to html2canvas call,
  matching the preview-path capture.ts behavior
- Parallelize from/to scene captures with Promise.all
- Wrap post-capture render in try/finally so opacity is always restored
- Fix WebGL context leak in isPageSideCompositingSupported probe
- Remove dead ResolvedTransition.index field
- Export stabilizeTransformedBoxShadows from capture.ts

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

* fix(producer): unify page-side compositing gating and Docker forwarding

Addresses three issues from staff review:

1. ignoreElements filter stripped all in-scene canvases (Chart.js, D3,
   p5.js) — narrowed to data-no-capture only since the compositor canvas
   is a body sibling never in the scene subtree.

2. Docker mode silently dropped --page-side-compositing — thread
   pageSideCompositing through DockerRenderOptions/buildDockerRunArgs
   with regression tests.

3. Fragmented gating across 4 independent sites could disagree:
   - Stub injection gated only on cfg flag (leaked into HDR/alpha)
   - Probe-created fileServer never got the stub
   - needsAlpha (WebM/MOV) not excluded from the gate
   - WebGL-unavailable fallback claimed layered path would run but
     orchestrator had already disabled it

   Fix: compute stub injection at the same site as the layered-bypass
   decision (after hasHdrContent is known), using addPreHeadScript on
   the already-running fileServer. Single predicate now gates both
   decisions, including !needsAlpha.

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

* perf(engine): two-phase drawElementImage capture for page-side compositing

Replace html2canvas with native drawElementImage for scene capture in
the page-side compositor. drawElementImage reads from the browser's own
paint cache, giving pixel-identical output to the preview path.

The blocker was that cloned elements inside layoutsubtree canvases have
no cached paint record under virtual time — the compositor only paints
when explicitly triggered. Fix: split the seek+composite into two phases
with an engine-forced paint between them.

Phase 1 (seek wrapper, page-side):
  - GSAP seek positions the timeline
  - Clone FROM/TO scenes into visible layoutsubtree staging canvases
  - Set window.__hf_page_composite_pending flag

Engine paint force (frameCapture.ts):
  - Detect pending flag after seek returns
  - Fire micro Page.captureScreenshot (1x1 clip) via CDP to force the
    browser compositor to paint all visible elements including staging
    canvas children

Phase 2 (page.evaluate, page-side):
  - drawElementImage reads the now-valid paint records
  - Upload textures to WebGL, run shader, show GL overlay

Key insight: staging canvases must be visible (not opacity:0) for the
browser to paint their children. They sit at z-index:-9998, behind
the main DOM and covered by the GL overlay during transitions.

Perf: 15-scene fixture (28s, 14 transitions, 30fps):
  Baseline (Node-side layered): 137s
  html2canvas + WebGL:           33s (3.7×)
  drawElementImage + WebGL:      21s (6.6×)

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

* perf(engine): optimize two-phase compositor hot path

- uploadTextureSource instead of uploadTexture: eliminates ~2.3GB of
  canvas buffer alloc/dealloc churn (persistent staging canvases don't
  need the one-shot zeroing behavior)
- Fold hasPending check into seek page.evaluate: eliminates one CDP
  round-trip per frame (~700 unnecessary IPC calls on non-transition
  frames)
- Fix renderShader error handling: on failure, leave source scenes
  visible as fallback instead of hiding both scenes + GL overlay
  (which produced black frames)
- Move mutable state declarations above resolveComposite to prevent
  TDZ risk on refactor

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

* fix(engine): staff review — staging cleanup, pending flag, beginFrame guard

- Clear staging canvas children when leaving transition window (prevents
  visible clone bleed-through on transparent compositions)
- Clear __hf_page_composite_pending on all resolveComposite exit paths
- Guard micro-screenshot paint force against beginFrame mode (CDP
  Page.captureScreenshot conflicts with beginFrame compositor control)
- Update CLI flag description: document video/canvas limitation, remove
  stale PSNR claim

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

* feat(engine): default-on page-side compositing for SDR shader transitions

Page-side compositing is now enabled by default for SDR shader-transition
renders without video content. The 6.6× speedup applies automatically —
no flag needed.

Auto-disables when:
- HDR content detected
- Alpha output (WebM/MOV/PNG-sequence)
- Composition contains <video> elements (cloneNode loses playback state)
- beginFrame capture mode (Linux headless)

Use --no-page-side-compositing to force the Node-side layered path.

Changes:
- Engine config: enablePageSideCompositing defaults to true
- CLI: flag default flipped to true; --no-page-side-compositing disables
- Orchestrator: added composition.videos.length === 0 gate
- Docker: forwards --no-page-side-compositing when explicitly disabled
- Config tests updated for new default

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

* feat(engine): support video elements on page-side compositing fast path

Three-phase capture protocol lets shader transitions render video scenes
without falling back to the slow Node-side layered pipeline:

1. Seek → compositor records transition metadata, sets pending flag
2. onBeforeCapture → video frame injector updates <img> replacements
3. prepare → cloneNode picks up current video frames, img.decode() awaits
4. micro-screenshot → forces browser to paint cloned elements
5. resolve → drawElementImage reads paint records, shader composites

Key changes:
- Remove `composition.videos.length === 0` gate from orchestrator
- Split compositor resolve into prepare (clone) + resolve (shader)
- Move onBeforeCapture before compositor prepare in frameCapture.ts
- Await img.decode() on cloned data-URI images to prevent stale frames
- Stop manipulating scene opacity in compositor (GL canvas overlay suffices)
- Add gsap.set declaration for shader-transitions ambient types
- Add video_missing_timing_attrs lint rule for <video> without id/data-start/data-end

Performance: compositions with video now render at 7.5s (6 workers) instead
of 2m38s on the layered path.

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

* fix(core): auto-inject data-start on video/audio so frame extraction works without explicit attrs

The timing compiler now injects data-start="0" on <video> and <audio>
elements that lack it. This makes discoverMediaFromBrowser() find the
element (it queries video[data-start]), so the frame extraction pipeline
activates automatically. Videos "just work" without requiring authors to
add data-start, data-end, or id attributes.

Also removes the video_missing_timing_attrs lint rule — the compiler
handles the missing attributes automatically, so the lint rule would
only false-positive.

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

* feat(core): add data-hf-auto-start sentinel on auto-injected video timing

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

* feat(producer): add discoverVideoVisibilityFromTimeline for runtime video discovery

Seeks the GSAP timeline in Puppeteer to discover when each video's parent
scene is visible (opacity > 0). Uses coarse sampling at 100ms steps followed
by binary search refinement to frame-level precision (1/60s). Only processes
videos with the data-hf-auto-start sentinel so author-specified timing is
never overridden.

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

* feat(producer): integrate runtime video visibility discovery into probe stage

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

* fix(producer): trigger browser probe for auto-start videos, remove debug logging

The probe stage was skipping browser launch when composition duration was
already known, which meant discoverVideoVisibilityFromTimeline never ran.
Now needsBrowser also checks for data-hf-auto-start sentinel in compiled HTML.

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

* fix(scripts): use mkdtempSync for smoke test work directory

Replaces hardcoded /tmp/hf-page-side-smoke with a unique temp directory
via mkdtempSync to resolve CodeQL "insecure temporary file" alert.

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

* style: format smoke test script

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Vai <vai@heygen.com>
2026-05-15 16:23:12 -07:00
James Russo fb9002598d Merge pull request #877 from heygen-com/ci/fast-fail-and-preflight-gate
ci: fast-fail regression matrix + preflight gate before expensive jobs
2026-05-15 18:51:41 -04:00
JamesandClaude Opus 4.7 00984133fc ci(preflight): extract preflight steps into a composite action
Same 5-step preflight body (setup-bun, setup-node, cache, install,
lint, format:check) was duplicated across 5 workflows. Move it to
.github/actions/preflight/action.yml so future tweaks (adding
typecheck, swapping the cache key, etc.) are a single-file change.

Net diff: +33 / -65.

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

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

Addresses Vai's review on #877.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:31:29 +00:00
Miguel Ángel 4fd9520a90 feat(studio): per-composition render button in compositions tab (#874)
* feat(studio): add per-composition render button in compositions tab

Thread composition path through the full render pipeline so individual
compositions can be rendered independently from the studio UI.

- Add download icon button on each comp card (visible on hover)
- Accept `composition` field in POST /projects/:id/render
- Pass composition as `entryFile` to the producer's createRenderJob
- Make the Export button in the Renders panel composition-aware
  (renders the active composition instead of always index.html)

* fix(studio): make composition render buttons always visible

The hover-only opacity made them undiscoverable.

* fix(studio): address PR review — CLI adapter, path guard, a11y, tests, settings sync

- Wire `composition` → `entryFile` in CLI studio adapter (studioServer.ts)
  so `hyperframes preview` renders the correct composition, not always index.html
- Add path-traversal guard: reject composition paths that resolve outside projectDir
- Add `aria-label` to the icon-only render button for screen readers
- Add 4 tests: forwarding, empty/missing → undefined, path-traversal → 400
- Persist render settings (format/quality/fps) to localStorage so comp card
  buttons use the same settings as the Export panel

* refactor(studio): extract render settings persistence to own module

Move getPersistedRenderSettings/persistRenderSettings out of
RenderQueue.tsx into renderSettings.ts so code-splitting the
component doesn't drag along the helper.
2026-05-15 22:55:54 +02:00
Miguel Ángel 9b23ccf665 feat(studio): html-backed motion panel (#873)
## Summary

Re-architects the studio motion panel to persist GSAP motion data directly in HTML element attributes instead of a `.hyperframes/studio-motion.json` JSON sidecar file. Same pattern as position/resize/rotation edits.

### Before
```
MotionPanel → commitStudioMotionManifestOptimistically()
  → writes .hyperframes/studio-motion.json
  → applyStudioMotionManifest(doc, manifest)
```

### After
```html
<div id="hero" data-hf-studio-motion='{"start":0.5,"duration":1,"ease":"power3.out","from":{"opacity":0,"y":40},"to":{"opacity":1,"y":0}}'>
```
```
MotionPanel → writeStudioMotionToElement(element, motion)
  → buildMotionPatches(element)
  → commitPositionPatchToHtml(selection, patches)
```

## What changed

- **studioMotionOps.ts** — Added `readStudioMotionFromElement()`, `writeStudioMotionToElement()`, `clearStudioMotionFromElement()` for attribute-based CRUD
- **studioMotion.ts** — Added `applyStudioMotionFromDom()` that reads motion from DOM attributes and builds GSAP timeline (kept `applyStudioMotionManifest` for render script compat)
- **manualEditsDom.ts** — Added `buildMotionPatches()` / `buildClearMotionPatches()`, integrated motion into `reapplyPositionEditsAfterSeek()`
- **useDomEditCommits.ts** — Rewrote `handleDomMotionCommit` / `handleDomMotionClear` to use HTML patching instead of manifest persistence
- **useManifestPersistence.ts** — Removed all motion manifest state (~200 lines): `studioMotionManifestRef`, `commitStudioMotionManifestOptimistically`, `applyStudioMotionToPreview`, motion SSE handler
- **App.tsx** — Reads motion from element attribute (`readStudioMotionFromElement`) instead of manifest ref
- **manualEditsRenderScript.ts** — Extended `studioPositionSeekReapplyRuntime` to rebuild GSAP motion timeline from `data-hf-studio-motion` attributes after each seek, including CustomEase support
- **htmlCompiler.ts** — Trigger seek-reapply script injection on `data-hf-studio-motion=` attributes

## Benefits

- No sidecar file — motion survives git, copy-paste, and manual HTML editing
- Undo/redo works via HTML source history (same as position edits)
- Renders correctly via CLI — seek-reapply script handles motion timeline rebuild
- Simpler architecture — one persistence path for all studio edits

## Test plan

- [x] `bun run build` passes
- [x] Pre-commit hooks pass (lint, format, typecheck)
- [ ] Set motion on element in Studio → `data-hf-studio-motion` attribute appears in HTML source
- [ ] Reload page → motion persists and plays correctly
- [ ] Clear motion → attribute removed, element returns to original state
- [ ] Undo/redo motion changes
- [ ] Render via CLI → motion visible in rendered video
- [ ] Seek animation → motion timeline re-syncs correctly
2026-05-15 21:45:57 +02:00
na-naviandAnoKno adeb92ecb7 feat(cli): add --no-open flag to preview and play (#871)
Add --no-open boolean flag to both commands via citty's built-in
boolean negation (--no-open sets args.open to false).

- preview.ts: guard all 4 open() calls with args.open check
- play.ts: guard the open() call with args.open check
- Default is true (open browser), preserving existing behavior

Closes #1

Co-authored-by: AnoKno <122017492+AnoKno@users.noreply.github.com>
2026-05-15 21:03:37 +02:00
James Russo 4bc24068f4 Merge pull request #864 from heygen-com/05-15-ci_codeql_bump_codeql-action_v3_v4_to_address_deprecation
ci(codeql): bump codeql-action v3 → v4
2026-05-15 13:07:18 -04:00
Miguel ÁngelandClaude Opus 4.6 8e0cfc33a7 fix(engine): preserve video frame replacement geometry (#838)
* fix(engine): preserve video frame replacement geometry

* test(producer): cover video overlay stretch regression

* fix(engine): always pass clip to Page.captureScreenshot

Without an explicit clip, Chrome can resolve replaced-element sizing
differently at dpr=1 when full-bleed absolute videos interact with
overlay layers — producing anisotropic frame stretching on some
compositor paths. Always passing clip with scale=dpr (including 1)
ensures geometry is locked to the measured viewport dimensions.

Credit: brian-t-allen (#837)

* test(producer): regenerate style-9-prod baseline for always-clip capture path

The always-clip change in screenshotService.ts routes Chrome through a
different compositor capture path at dpr=1, producing different video
frame compression artifacts. Regenerated inside Dockerfile.test to match
CI environment.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 18:58:50 +02:00
James 436aebdc13 ci(codeql): bump codeql-action v3 → v4 to address deprecation 2026-05-15 16:57:48 +00:00
James Russo 952d2658b0 Merge pull request #860 from heygen-com/05-15-ci_security_add_advanced_codeql_setup_with_path-scoped_query_filters
ci(security): add advanced CodeQL setup with path-scoped query filters
2026-05-15 02:59:32 -04:00
James Russo e8372d45d6 Merge pull request #857 from heygen-com/05-15-test_producer_extend_cross-worker_idempotency_to_non-zero_chunks_dedupe_soft-skip_regex
test(producer): extend cross-worker idempotency to non-zero chunks + dedupe soft-skip regex
2026-05-15 02:55:09 -04:00
James Russo be5b450b5d Merge pull request #855 from heygen-com/05-15-ci_regression_rebalance_matrix_via_measured_per-test_durations
ci(regression): rebalance matrix via measured per-test durations
2026-05-15 02:52:24 -04:00
James Russo 5e56b11615 Merge pull request #856 from heygen-com/05-15-fix_security_close_codeql_critical_bad-code-sanitization
fix(security): close CodeQL critical command-line-injection and bad-code-sanitization
2026-05-15 02:50:25 -04:00
James 783e25a78f ci(security): add advanced CodeQL setup with path-scoped query filters 2026-05-15 04:58:10 +00:00
Miguel Ángel e8e2e81730 chore: release v0.6.7 v0.6.7 2026-05-14 21:54:56 -07:00
Miguel Ángel 225010800a feat(studio): persist element positions in HTML, fix resize overlay drift and GSAP double-translation (#829)
* feat(studio): add pasteboard background to preview viewport

Adds bg-neutral-800 to the preview viewport so the area outside the
canvas is visually distinct from the composition content — consistent
with professional video editors (Premiere, DaVinci, Figma).

* feat(studio): pasteboard background and canvas outline around preview

- NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard
  color surrounding the canvas — distinct from the app chrome (#0a0a0a)
- Player wrapper: drop bg-black so the pasteboard shows around the canvas
  (loading overlays still cover the area with bg-black during load)
- Player: set host background to transparent via inline style (overrides
  :host { background: #000 } in shadow DOM), and inject a style rule into
  the open shadow root so .hfp-container has overflow:visible and the
  canvas iframe gets a thin white ring + soft drop-shadow — making the
  canvas boundary legible against the pasteboard

* feat(studio): disable manual positioning JSON by default, add toggle

Manual edits were always stored in `.hyperframes/studio-manual-edits.json`,
making it hard to share source without the sidecar file and easy to
accidentally reposition elements via drag.

Changes:
- `enabled` field added to `StudioManualEditManifest` (defaults to `false`
  when absent — existing projects are unaffected until they opt in)
- Drag handles, resize, and rotation handles are hidden when disabled
- Layout X/Y/W/H/R fields in the Design panel are read-only when disabled
- "Manual positioning" toggle added at the bottom of the Design panel,
  visible whether or not an element is selected
- Toggle state is persisted to `.hyperframes/studio-manual-edits.json`
  so each project can opt in independently
- `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard
  cap (env off → feature off regardless of project setting)

* feat(studio): enable manual positioning by default (opt-out)

* feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle

* feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle

Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style
persistence baked directly into the HTML source. Drag/resize/rotation values
are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`,
`--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via
`persistDomEditOperations` — no re-apply step needed on load.

Key changes:
- `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the
  property/attribute from the HTML tag instead of setting it
- `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live
  element state into `PatchOperation[]` for HTML source writes; add
  `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers)
- `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target
  resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure
- `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest
  state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs
  seek hooks via `reapplyPositionEditsAfterSeek`
- `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with
  direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh)
- `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled`
  gates to just `canApplyManualOffset` — every draggable element is always draggable
- `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props
- `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and
  `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`,
  `roundRotationAngle`, and snapshot/CSS-property types

* fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test

* fix(studio): strip GSAP-cached translate from transform on path offset apply

* fix(studio): remove Reset edits button from design panel

* feat(studio): wire reloadPreview into manifest persistence; drop stale group-selection refresh

- Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads
  via the refresh-key path instead of directly touching the iframe.
- Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers;
  HTML is now the source of truth so no stale-ref refresh is needed.
- Add `manualEditsRenderScript` helper; export via studio-api and apply
  it in `htmlCompiler` during HTML compilation.

* fix(studio): prevent root composition from being selected; correct overlay drift on resize

- Guard `getDomLayerPatchTarget` against elements with `data-composition-id`
  so the root composition div is never returned as a visual selection target.
- Apply the same guard to the raw `elementFromPoint` fallback in
  `getPreviewTargetFromPointer`, which was the actual escape path.
- Thread `iframeRef` into gesture handler opts; after applying draft
  dimensions during resize, re-read the element BCR via `toOverlayRect`
  and update the overlay box position to compensate for visual drift on
  elements with centered transform-origin (e.g. GSAP scale tweens).

* fix(studio): correct resize overlay for scaled elements; block invisible element selection

- Resize: use BCR from `toOverlayRect` for both position and size after
  applying draft dimensions — GSAP scale makes visual size diverge from
  raw CSS size, BCR is the only accurate source during a gesture.
- Click selection: add `isElementComputedVisible` guard to the
  `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements
  cannot be selected even though the browser hit-test returns them.

* fix(studio): reload preview on external file changes via SSE/HMR

Share the app-level domEditSaveTimestampRef with useManifestPersistence
so the SSE/HMR handler can suppress echoes from all studio saves (code
tab, timeline, DOM edits), then call reloadPreview() for non-motion
external changes that aren't echoes of our own saves.

* fix(studio): suppress post-resize click to keep selection on resized element

* fix(studio): serve registry blocks without index.html in preview

Blocks ship as {id}.html + assets/ with no index.html. The preview
route hard-coded index.html so these projects returned 404 and their
assets (e.g. korea-map.png, map-nyc-paris.png) were never served.

Add resolveProjectMainHtml() that falls back to {id}.html, thread the
resolved compositionPath through transformPreviewHtml and
injectStudioPreviewAugmentations, and update listProjects() in the
vite adapter to surface block directories in the project list.

* fix(render): preserve studio drag/resize/rotation offsets in rendered video

Three issues caused studio-edited positions to be lost during rendering:

1. The seek-reapply script used setInterval to wrap window.__hf.seek, but
   Puppeteer's page.evaluate() calls don't yield the event loop for
   macrotasks — the interval never fired, so reapplyAll() never ran after
   GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek
   property, wrapping it synchronously the instant the bridge assigns it.

2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from <video> to proxy <img>
   during render) included "transform" but not "translate", "rotate", or
   "scale" — the CSS Transforms Level 2 individual properties used by
   studio drag/resize/rotation. The proxy was positioned at offsetLeft/
   offsetTop without the translate offset.

3. getViewportMatrix (HDR compositor) only read cs.transform, missing
   individual transform properties entirely. Added composeIndividualTransforms
   to build the translate × rotate × scale matrix and compose it before
   the legacy transform matrix.

* fix(studio): select elements with pointer-events: none in preview

Compositions often set pointer-events: none on scenes, avatar wrappers,
and decorative layers. elementsFromPoint() skips these elements entirely,
making them unselectable in the Studio. Fix: temporarily inject a
* { pointer-events: auto !important } stylesheet during hit-testing, then
remove it immediately after.

Also adds a pointer_events_none lint rule (info severity, visible with
--verbose) so authors know which selectors may affect Studio selection.
2026-05-15 06:43:00 +02:00
Carlos Alcaraz GregorandCarlos Alcaraz 83c29faaf9 fix(studio): auto-enable loop when work-area markers are set (#859)
Setting an in or out point now turns on loopEnabled so the playhead
respects the marker instead of running past the out-point. Closes the
last open sub-bug of #834.

Background: PR #811 wired the work-area RAF loop to read inPoint/outPoint
but kept the loop branch gated behind loopEnabled. Default for that flag
is false, so users who set markers without first toggling the loop button
saw playback sail past the out-point (or, with the L shuttle, overshoot
by a few frames before pausing). The original spec for the feature in
issue #807 described markers as logic that "constrains the playback
engine"; the actual UX did not match that until the toggle was on.

Fix: setInPoint and setOutPoint flip loopEnabled to true when given a
non-null value. This sits next to the existing "smart setter" behavior
already in the store (setting one marker past the other nullifies the
counterpart). Clearing a marker with null preserves the current
loopEnabled, so a user who manually toggles the loop button stays in
control after that point.

Tests: full coverage for setInPoint and setOutPoint (none existed
before), including overlap nullification, non-finite rejection,
auto-enable on set, and preserve-on-clear in both directions.

Closes #834

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-05-15 06:42:32 +02:00
James 47fe69aff0 test(producer): extend cross-worker idempotency to non-zero chunks + dedupe soft-skip regex 2026-05-15 03:51:11 +00:00
James fc3aa4d49e fix(security): close CodeQL critical + bad-code-sanitization 2026-05-15 03:42:47 +00:00
James b2a0262c3c ci(regression): rebalance matrix via measured per-test durations 2026-05-15 03:40:45 +00:00
James Russo 808b10cb0e Merge pull request #844 from heygen-com/05-14-test_producer_add_cross-worker_idempotency_unit_test
test(producer): add cross-worker idempotency unit test
2026-05-14 23:33:14 -04:00
James Russo cc6bc2e019 test(producer): add chunk-boundary fixtures per first-party adapter (#852)
## Description

Phase 4 of the distributed rendering plan: test fixtures (see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 4 + §10 test strategy). This is PR 4.7 of the Phase 4 remainder — the final fixture PR.

This PR adds six per-adapter chunk-boundary fixtures under `tests/distributed/{gsap,anime,three,lottie,css,waapi}-boundary/` plus a single `bun:test` driver (`chunkBoundary.test.ts`) that exercises each fixture's seek-determinism contract. Each fixture is a 60-frame composition (2s @ 30fps, 320×180) that drives the named adapter through the HyperFrames runtime's seek hook. The test renders each at `chunkSize=60` (N=1 chunk, no seams) and `chunkSize=15` (N=4 chunks, three seams at frames 15/30/45), then asserts every PNG frame is byte-identical across the two runs.

**Why png-sequence**: mp4 bitstreams encode keyframe placement directly. At `chunkSize=60` libx264 emits 1 IDR; at `chunkSize=15` it emits 4 IDRs at frames 0/15/30/45. Those are legitimately different bytes even when the captured pixels are identical. png-sequence's assemble path merges chunk frame directories with no re-encode, so per-frame byte equality is exactly pixel equality — the strongest contract a distributed render can satisfy.

**Fixture design** (each is ~60 lines of HTML):
- `gsap-boundary` — single GSAP `tl.to(...)` driving translateX + rotation linearly across 2s.
- `anime-boundary` — anime.js v4 timeline registered via `window.__hfAnime`.
- `three-boundary` — minimal Three.js scene; cube rotation derived from `window.__hfThreeTime`.
- `lottie-boundary` — inline Lottie JSON (rectangle layer animating position+rotation) loaded via `lottie-web` and registered via `window.__hfLottie`.
- `css-boundary` — pure `@keyframes` animation; the HyperFrames CSS adapter seeks via `animation-delay`.
- `waapi-boundary` — `element.animate()` with linear keyframes; runtime sets `currentTime` per frame.

The fixtures intentionally omit `meta.json` so the regression-harness discovery skips them with a clear `missing meta.json` log (they're driven exclusively by `chunkBoundary.test.ts`). The test passes `rejectOnSystemFonts: false` because some adapter bundles (notably anime.js's IIFE) embed CSS-shaped strings inside their JS source — `font-family: ui-monospace, monospace` for internal devtools styling — which `validateNoSystemFonts`'s document-wide regex would otherwise false-positive on every adapter fixture that loads such a bundle. The fixtures display no text, so the relaxed font validation doesn't affect the contract under test.

The 7th test case is a layout sanity check that asserts every expected `*-boundary` fixture directory exists.

## Testing

- `bun test packages/producer/src/services/distributed/chunkBoundary.test.ts` — 7 tests pass on host (6 adapters × byte-identical N=1 vs N=4 + the layout check, 41.6s)
- `bun test packages/producer/src/services/distributed/` — all 49 distributed unit tests pass (43.6s)
- `bun run --cwd packages/producer docker:test:distributed font-variant-numeric many-cuts gsap-letters-render-compat style-1-prod sub-composition-video mp4-h264-sdr png-sequence mov-prores mp4-h265-sdr -- --sequential` — full smoke set + all four prior stacked fixtures pass (9/9)
- `bunx oxlint` + `bunx oxfmt --check` clean
- `bunx tsc --noEmit` (producer package) clean

## After this stack lands

The Phase 4 fixture set is complete: PR 4.6 (#844) pins cross-worker idempotency; 4.2/4.3/4.4/4.5 (#845/#851/#847/#848) prove each format produces correct chunked output at the fixture's `minPsnr`; 4.3-pre (#850) added the codec knob H.265 needed; and this PR proves each first-party adapter's seek-determinism survives chunk seams. Phase 5 (CLI surface for `hyperframes plan/chunk/assemble`) and Phase 6 (AWS Lambda turnkey) are unblocked.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 23:09:56 -04:00
James 6b3ad09436 fix(producer): tighten chunk-boundary test gates + narrow VIDEO_EXT indexing
Address @vanceingalls and @miguel-heygen review findings on #852:

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

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

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

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

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

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

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:46:44 +00:00
James 0b31465b2e test(producer): add chunk-boundary fixtures per first-party adapter 2026-05-15 02:46:44 +00:00
James Russo 7bd4be13c1 test(producer): add mp4 H.265 SDR distributed fixture (#851)
## Description

Phase 4 of the distributed rendering plan: test fixtures (see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 4 + §10 test strategy). This is PR 4.3 of the Phase 4 remainder, stacked on PR 4.3-pre (#850) which added the codec knob to `DistributedRenderConfig`.

This PR adds the mp4 H.265 SDR fixture (`tests/distributed/mp4-h265-sdr/`) plus the small harness plumbing that exposes the codec knob through `meta.json`. Composition mirrors the H.264 fixture: 2 seconds (60 frames) at 30fps with text, a crossfade transition straddling the frame-30 chunk seam, and a continuously rotating SVG icon. `renderConfig.format: "mp4"` + `renderConfig.codec: "h265"` + `chunkSize: 15` routes the distributed pipeline through libx265 with closed-GOP keyint params (`min-keyint=N:scenecut=0:open-gop=0:repeat-headers=1`) so concat-copy at assemble time round-trips losslessly.

**Cross-codec PSNR assertion**: the in-process renderer doesn't expose a codec hint (its RenderConfig only switches codec via HDR mode), so the in-process baseline for this fixture is rendered as h264. The harness's PSNR comparison therefore measures "libx265 chunked + concat" against "libx264 single-pass" on the same source frames. At "high" quality both encoders are near-lossless on simple vector+text content; observed PSNR is ~48dB across all 100 checkpoints — well above the 30dB threshold. This catches gross codec/encoder failures (e.g. libx265 emitting wrong bit depth or losing the IDR-at-chunk-seam contract) while accepting normal cross-codec PSNR drift. The in-process arm renders h264 vs the h264 baseline byte-identically.

Harness extensions:

1. **`TestMetadata.renderConfig.codec`** field accepted by `validateMetadata`. Rejected with format ∉ {mp4} for symmetry with the `DistributedRenderConfig` runtime check from 4.3-pre.
2. **`RunDistributedSimulatedInput.codec`** plumbed through to `plan()`. The non-mp4 plan-config branch keeps the field structurally absent (so byte-identical to pre-codec planDirs for mov/png-sequence) rather than passing `undefined`, which would surface in JSON.

## Testing

- `bun run --cwd packages/producer docker:test:update mp4-h265-sdr` — baseline rendered inside `Dockerfile.test` (h264 mp4 from in-process, used as the cross-codec reference)
- `bun run --cwd packages/producer docker:test mp4-h265-sdr` — in-process passes (renders h264, byte-identical against h264 baseline)
- `bun run --cwd packages/producer docker:test:distributed mp4-h265-sdr` — distributed-simulated passes (h265 mp4, ~48dB PSNR across all 100 checkpoints vs h264 baseline)
- `bun run --cwd packages/producer docker:test:distributed font-variant-numeric many-cuts gsap-letters-render-compat style-1-prod sub-composition-video mp4-h264-sdr png-sequence mov-prores mp4-h265-sdr -- --sequential` — full smoke set + all 4 stacked fixtures pass (9/9)
- `bunx oxlint` + `bunx oxfmt --check` clean
- `bunx tsc --noEmit` (producer package) clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-14 22:46:35 -04:00
James 6dbdac8118 fix(producer): normalize default-format check + carry no-audio rationale to mp4-h265-sdr fixture
Address @vanceingalls review on #851:

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

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 02:22:19 +00:00
James 5298b12c79 test(producer): add mp4 H.265 SDR distributed fixture 2026-05-15 02:21:43 +00:00
James Russo 2886c2b24d feat(producer): add codec knob to DistributedRenderConfig (#850)
## Description

Phase 4 prerequisite for PR 4.3 (the mp4 H.265 SDR distributed fixture). Splits the codec selection out of `DistributedRenderConfig.format` so callers can ask for libx265 without changing the format.

**Surface change** (`@hyperframes/producer/distributed`):

- `DistributedRenderConfig.codec?: "h264" | "h265"` — defaults to `"h264"`, ignored for non-mp4 formats. Passing `codec` with `format !== "mp4"` throws at plan time with a clear error so caller mistakes surface immediately rather than producing a silently-wrong planDir.
- `FORMAT_ENCODER_TABLE` is replaced by `resolveEncoderTriple(config)` — a small function that switches on `(format, codec)`. mp4 + h265 → `{encoder: "libx265-software", pixelFormat: "yuv420p"}`. mov and png-sequence are unchanged.

**Plumbing through `renderChunk`**:

The chunk worker reads `LockedRenderConfig.encoder` from `meta/encoder.json`. When that's `"libx265-software"`, the worker overrides `getEncoderPreset(quality, "mp4")`'s default `codec: "h264"` with `"h265"` so `runEncodeStage` invokes libx265 with the closed-GOP keyint params (`min-keyint=N:scenecut=0:open-gop=0:repeat-headers=1`) that survive concat-copy at assemble time. The engine layer (`packages/engine/src/services/chunkEncoder.ts`) already supports both codecs — this PR is purely the distributed config surface.

**Bit depth**: SDR-only, 8-bit yuv420p for both codecs. h265 + 10-bit yuv420p10le is HDR territory and lives in v1.5 (see plan §12).

## Testing

- `bun test packages/producer/src/services/distributed/plan.test.ts` — 14 tests pass including 3 new codec cases (`codec` defaults to h264, `codec: "h265"` maps to libx265-software, non-mp4 + codec throws)
- `bun test packages/producer/src/services/distributed/` — all 42 distributed unit tests pass
- `bunx oxlint` + `bunx oxfmt --check` clean
- `bunx tsc --noEmit` (producer package) clean

The H.265 fixture that exercises this end-to-end inside `Dockerfile.test` lands in the follow-up PR 4.3.

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 01:57:08 +00:00