Commit Graph
581 Commits
Author SHA1 Message Date
Miguel Ángel 31e8144304 fix: render parity for transparent looped videos (#478)
## Summary
- preserve alpha for render-injected video frames by detecting alpha streams with ffprobe and extracting alpha video frames as PNG
- keep `<video loop>` semantics through static parsing, compiler duration resolution, browser media discovery, and render frame lookup
- fail embedded preview startup before opening a broken browser page when the Studio bundle is missing
- align snapshot frame injection with looped media timing and VP9 alpha extraction

## Why
The Studio preview and rendered MP4 could disagree for timed transparent looped videos. The Comfy funding composition exposed two separate parity bugs: render-injected frames needed alpha-preserving PNG extraction, and the compiler was clamping a looped `data-duration="4"` video down to the 3.125s source duration. After the first source cycle, render lookup treated the video as inactive, hid the native video, and produced the blank polygon/glow the user saw around the rounded `0:03` mark.

`hyperframes lint` and `hyperframes validate` did not catch this because they check syntax/load/console/accessibility, not preview-vs-render visual parity. This PR adds regression coverage for the compiler loop-duration path and frame lookup path.

## Verification
- `bun run --filter @hyperframes/core test -- src/compiler/timingCompiler.test.ts src/compiler/htmlCompiler.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bun run --filter @hyperframes/engine test -- videoFrameExtractor ffprobe`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run lint`
- `bun run format:check ...` on touched files
- Comfy project: `node packages/cli/dist/cli.js validate` -> no console errors, 44 text elements pass WCAG AA
- Comfy project patched render from source: `/tmp/comfy-render-compare/fixed6-comfy.mp4`, 1920x1080, 30fps, 21.8s, 654 frames
- 3.00s-3.97s render contact sheet: `/tmp/comfy-render-compare/fixed6-window-contact.png`
- targeted fixed render capture at 3.733s: `/tmp/comfy-render-compare/probe-capture-fixed/captured/frame_000112.jpg`
- agent-browser Studio proof screenshot at 3.7s: `/tmp/comfy-render-compare/agent-browser-studio-3_7-fixed.png`
- agent-browser-driven recording of 3s seek pass: `/tmp/comfy-render-compare/agent-browser-wysiwyg-3s-fixed.webm`

Note: `bun run --filter @hyperframes/cli dev -- validate` is blocked in source mode by the existing `contrast-audit.browser.js` default-export loader issue; packaged `node packages/cli/dist/cli.js validate` passes for this project.
2026-04-24 23:18:20 +02:00
Miguel Ángel e8c43f0889 fix: prevent nested composition videos from autoplaying on seek (#477)
## Problem

Studio seek could still wake nested composition media even when the transport itself stayed paused.

In the real repro from `apple-presentation`, scrubbing to `0:29` without pressing play lands on the `slide-translation` composition. That composition contains `Multilingual_Journey.mp4` inside the composition host. On the broken path:

- the main Studio transport remained paused
- the nested video advanced and stayed playing anyway
- the user saw autoplay-like behavior even though the only action was a seek

That was especially confusing because the seek was otherwise correct: the timeline moved to the right point, but the nested media stopped obeying the paused transport state.

## What this fixes

### Nested media now participates in runtime media sync

- the runtime media cache no longer assumes only `video[data-start]` / `audio[data-start]` are relevant
- nested media inside a composition host can now be included in the same timed-media sync pass even when the inner media element does not carry its own authored `data-start`

### Nested media timing is resolved in the host composition window

- nested media start time is resolved against the enclosing composition host instead of falling back to scene-local `0`
- nested media duration is clamped to the enclosing composition window so it stays aligned with the authored host clip timing

### Paused seeks land on the right frame and stay paused

- after seeking into a nested composition, the inner media is now seeked to the correct frame relative to the host timeline
- because it is now part of the managed media set, the runtime also keeps it paused when the transport is paused instead of letting it continue playing on its own

### Regression coverage

- adds a runtime regression test that covers a nested composition video with no local `data-start`
- the test verifies that `player.seek(29)` leaves the nested video paused while landing it at the expected `currentTime`

## Root cause

The bug came from a mismatch between deterministic timeline seeking and media ownership.

### 1. The runtime only managed media with direct timing attrs

`refreshRuntimeMediaCache()` only collected `video[data-start]` and `audio[data-start]`. That works for root-level timed media, but not for media embedded inside a composition host where timing is inherited from the host composition rather than duplicated onto the inner media node.

### 2. Nested composition seek could still advance inner media

The runtime intentionally rearms sibling timelines during deterministic seek so nested timelines land on the right local offsets. That part is necessary and correct.

But because the nested video was not part of the managed media cache, it could advance during that seek path without being brought back under the paused transport state afterward.

### 3. The runtime had no way to reconcile the two

So the system had an inconsistent split:

- timeline seek knew about the nested composition timeline
- media sync did not know about the nested media inside it

The fix closes that split by resolving nested media start/duration from the enclosing composition context and running it through the same sync logic as other managed media.

## Verification

### Local checks

- `bun run --filter @hyperframes/core typecheck`
- `bun run test -- src/runtime/init.test.ts src/runtime/media.test.ts src/runtime/player.test.ts` in `packages/core`
- `bunx oxlint packages/core/src/runtime/media.ts packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bunx oxfmt --check packages/core/src/runtime/media.ts packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`

### Browser verification

Verified against a repo-backed local Studio preview of `apple-presentation`:

- opened `http://127.0.0.1:3014/#project/apple-presentation`
- seeked to `0:29` without pressing play
- confirmed the visible composition switched to `slide-translation`
- confirmed `Multilingual_Journey.mp4` landed at a non-zero `currentTime` (`3.067` in the verified run)
- confirmed the nested video stayed `paused` and its `currentTime` remained stable across a follow-up check instead of autoplaying

## Notes

- the local browser proof artifacts under `qa-artifacts/autoplay-seek/` are verification-only and are not part of this PR
- this PR is intentionally scoped to nested media ownership during paused seek; it does not broaden into unrelated runtime media refactors beyond bringing inherited nested media under the existing sync contract
2026-04-24 22:10:11 +02:00
Vance Ingalls 6b21ead737 chore: release v0.4.22 v0.4.22 2026-04-24 11:43:48 -07:00
Alex Coulombe 2cb933df61 style: format 2026-04-24 14:21:34 -04:00
Miguel Ángel a47f48a17f chore: release v0.4.21 v0.4.21 2026-04-24 17:11:05 +00:00
Miguel Ángel 267ffd3fca fix(engine,producer): preserve template-wrapped sub-composition media offsets (#476)
## Problem

Template-wrapped sub-compositions could still lose correct parent timing during render in more than one place.

In the validated repros, a host sub-composition starting after the intro (and in one follow-up repro, starting at `20s` after earlier compositions) contained scene-local media inside it. On the broken paths:

- template-wrapped media could be missed during compile and scheduled at raw scene-local time
- already-correct first-pass offsets could be clobbered during `recompileWithResolutions()`
- even after those two fixes, the browser-metadata reconcile step in `executeRenderJob()` could still overwrite a compiled global `end` with a scene-local `data-end` from the inlined DOM, clipping the tail off late-start sub-composition media

## What this fixes

### Template-wrapped media discovery

- `parseVideoElements`, `parseImageElements`, and `parseAudioElements` now unwrap a single top-level `<template>` wrapper before scraping media
- the unwrap helper is DOM-based, not regex-based, so it avoids the CodeQL backtracking warning and only unwraps the exact single-wrapper shape we want
- multiple sibling templates or other top-level content are left untouched instead of being rewritten heuristically

### Offset preservation after duration resolution

- `recompileWithResolutions()` now preserves the first-pass sub-composition media arrays when the already-inlined HTML no longer contains `[data-composition-src]` hosts
- that prevents correctly offset media metadata from being overwritten by scene-local media parsed from the merged DOM

### Browser metadata reconciliation in the compiled time origin

- browser-discovered media can still report scene-local `data-start` / `data-end` from the merged DOM after inlining
- the producer now reprojects browser `end` values into the compiled element's time origin before reconciling them back into `composition.videos` / `composition.audios`
- this prevents late-start sub-composition media from getting truncated back to a scene-local end during the probe phase

### Regression coverage

- adds focused engine tests for the template unwrap helper
- adds producer regression coverage for both the initial compile path and the post-inline `recompileWithResolutions()` path
- adds producer regression coverage for late-start host compositions (`t≈20`) with scene-local media inside them
- adds producer unit coverage for the browser-end reprojection helper used by the reconcile path

## Root cause

There were three distinct renderer failures behind the bug:

### 1. Template contents were invisible to the media scrapers

`parseSubCompositions()` reads raw sub-composition HTML and applies the host offset to discovered media. But the engine media helpers were querying the parsed document directly, and linkedom follows browser semantics here: top-level `<template>` contents live in a `DocumentFragment`, so `querySelectorAll()` never saw those `<video>` / `<audio>` / `<img>` nodes.

That meant template-wrapped sub-compositions could silently produce zero discovered media during the first pass.

### 2. The duration-resolution recompile could clobber already-correct offsets

After the browser resolves composition durations, `recompileWithResolutions()` reparses the already-inlined HTML. By that point the original `[data-composition-src]` hosts are gone, so `parseSubCompositions()` legitimately returns no nested media.

The old code still rebuilt the deduped media arrays from the merged DOM, which let scene-local media parsed from the inlined HTML overwrite the correctly offset first-pass metadata.

### 3. The browser probe reconcile path mixed two timing coordinate systems

`discoverMediaFromBrowser()` reads `data-start` / `data-end` directly from the live DOM after sub-compositions are already inlined. For nested media, those attributes can still be scene-local even though the compiled metadata has already been offset into the parent host timeline.

The old reconcile path compared those values directly and overwrote `existing.end` whenever the numbers differed. For a late-start sub-composition, that could replace a correct global end like `25.5` with a scene-local end like `5.5`, cutting the clip off during render.

## Verification

### Local checks

- `bun test packages/engine/src/utils/htmlTemplate.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bunx vitest run packages/producer/src/services/renderOrchestrator.test.ts`
- `bun run --filter @hyperframes/engine test`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bunx oxlint packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts packages/producer/src/services/renderOrchestrator.ts packages/producer/src/services/renderOrchestrator.test.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxfmt --check packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts`
- `bun run build:producer`

### Render / browser verification

Verified against two local repros:

1. **Early offset repro**
   - host starts at `2s`
   - child media is scene-local `0-4s`
   - compiled render summary keeps the child video/audio at `start: 2`
   - browser verification via `agent-browser` confirmed the `2.2s` frame still shows the child clip active in the host timeline

2. **Late offset repro**
   - earlier compositions run first, then the target host starts at `20s`
   - child media starts scene-local at `1.5s` and should remain visible through `24.5s`
   - compiled render summary keeps the child video/audio at `start: 21.5`, `end: 25.5`
   - browser verification via `agent-browser` confirmed the `24.5s` frame still shows the late clip visible, which is the exact tail-clipping case the old reconcile path could break

## Notes

- the `/tmp/hf-pr475-repro` and `/tmp/hf-pr476-late-offset-repro` projects plus their browser-proof artifacts are verification-only and are not part of this PR
- this PR stays narrowly scoped to sub-composition media timing across compile, recompile, and browser probe reconciliation; it does not broaden into general sub-composition HTML normalization beyond the single-wrapper case
2026-04-24 19:00:42 +02:00
Miguel Ángel fbec7bb1c4 fix(core): warn on GSAP boundary exits without hard kill (#474)
Fixes #473.

## Problem

The HyperFrames skill now tells agents to add deterministic `tl.set()` hard-kills after elements fade out at beat / scene boundaries, but the linter did not enforce that rule outside the narrow caption-specific check.

That made the rule easy for sub-agents to ignore: an element could fade to `opacity: 0` exactly as the next clip starts, with no explicit hidden-state set at the boundary. During non-linear seeking or frame capture, that leaves the final visibility state dependent on tween interpolation instead of an authored deterministic kill.

## What this fixes

This PR adds a generalized GSAP lint warning for scene-boundary exits:

- detects GSAP `to` / `fromTo` exit tweens that end at or near a clip `data-start` boundary
- treats `opacity: 0`, `autoAlpha: 0`, `visibility: "hidden"`, and `display: "none"` as hidden exit states
- requires a matching same-selector `tl.set(...)` hidden state at the same boundary
- scopes clip-boundary matching to the timeline's registered composition so sub-composition exits do not match unrelated root boundaries
- reports `gsap_exit_missing_hard_kill` with the selector, boundary time, source snippet, and a fix hint that preserves the authored hidden property when possible
- keeps valid compositions quiet when the boundary hard-kill already exists

## Why

Clip boundaries are the exact points where rendered frames are most sensitive to stale DOM state. A fade-out tween describes a transition, but it does not give the linter or the authoring model an explicit deterministic state to land on when seeking around the boundary.

The existing caption rule proved the class of bug was worth catching, but it only applied to caption-loop patterns. The issue in #473 is broader: any element inside a timed composition can exit at a scene boundary and need the same deterministic cleanup.

## Root cause

The GSAP lint rule parser already calculated tween windows and clip metadata existed in the lint context, but no rule connected those two facts:

- clip `data-start` values were not used as scene-boundary checkpoints for GSAP exits
- parsed GSAP windows tracked property names, but not enough property values to tell whether a tween ended in a hidden state
- hard-kill detection only existed as a caption-specific regex, so normal scene elements were missed

This PR extends the existing GSAP window metadata with parsed property values, then checks hidden-state exits against same-composition clip start boundaries and same-selector `tl.set` calls.

## Verification

### Local checks

- `bun run --filter @hyperframes/core test src/lint/rules/gsap.test.ts`
- `bunx oxlint packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts`
- `bunx oxfmt --check packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/core test`
- `bun run --filter @hyperframes/core build`

### CLI verification

Verified against local fixtures where `#headline` exits at the next clip boundary without a hard kill:

- opacity fixture reports `gsap_exit_missing_hard_kill` for `#headline` at `3.00s`
- autoAlpha fixture reports the same warning and suggests `tl.set("#headline", { autoAlpha: 0 }, 3.00)`
- sub-composition regression test confirms a `sub` timeline exit no longer matches an unrelated root composition boundary

### Browser verification

Verified the Studio lint flow with `agent-browser` against the autoAlpha fixture:

- opened Studio at `http://127.0.0.1:43174/#project/issue-473-autoalpha`
- clicked the real `Lint` button
- confirmed the lint modal shows the new warning and the property-preserving `{ autoAlpha: 0 }` fix hint
- saved local proof artifacts under `qa-artifacts/issue-473/`

## Notes

- the `tmp/issue-473-*` fixtures and `qa-artifacts/issue-473` browser proof are local-only and are not part of this PR
- this intentionally stays heuristic-based: it warns near clip start boundaries instead of trying to build a full GSAP execution model
- expression-valued GSAP props and deeper regex-parser limitations remain outside this PR's scope; those are parser-hardening work, not required for the bug in #473
2026-04-24 18:07:02 +02:00
Alex Coulombe 924fd2f145 docs(step-6-build): sharpen Rule 2 — name from() seek-past-end reset as root cause
Per review feedback on PR #364: the 'hero vanishes' failure has two
mechanisms. Primary: second tween's immediateRender overwrites the first
at construction time. Secondary: tl.from() resets to its declared from-
state when seeked past timeline end, which the capture engine triggers.
Both are now named so the rule has precise rationale, not just a pattern
to avoid.

Ref: https://github.com/heygen-com/hyperframes/pull/364#pullrequestreview-4167523103
2026-04-24 10:56:59 -04:00
Vance Ingalls e9c56961fb docs(engine): document __name polyfill and add regression test (#385)
## Summary

Document why the `window.__name` polyfill in `frameCapture.ts` is necessary, expand the inline comment with the full per-runtime matrix, and add a regression test that surfaces transpiler behavior on the next failure.

Outcome of the Chunk 12 investigation: **keep the polyfill**.

## Why

`Chunk 12` of `plans/hdr-followups.md`. The polyfill had a vague comment and no test, so it was unclear whether it was still needed or could be deleted.

## Empirical findings

Probe in `/tmp/hf-name-probe`:

| Runtime / build | Injects `__name(fn, "name")` wrappers in `Function.prototype.toString()`? |
|-----------------|---------------------------------------------------------------------------|
| `bun` (TS loader) | No — verified for top-level and nested named functions / arrow expressions. |
| `tsx` (esbuild loader, `keepNames=true`) | **Yes** for nested named functions / arrows; observed crash mode in dev/test. |
| `tsc` (`noEmit` and emit) | No — does not inject the helper. |
| `tsup` for `@hyperframes/cli` (`noExternal: ["@hyperframes/engine"]`) | Polyfill *definition* is bundled, but `__name(...)` *call sites* are absent in `packages/cli/dist/cli.js` (grepped). |

**Root cause.** `@hyperframes/engine`'s `package.json` exports raw TypeScript (`main`/`exports` → `./src/index.ts`), so every consumer's transpiler decides whether to inject `__name`. Anything that runs through `tsx` (producer parity-harness, ad-hoc dev scripts, `bun run --filter @hyperframes/engine test` via Vitest's loader) will serialize wrapped function bodies into `page.evaluate(...)` and crash with `ReferenceError: __name is not defined`.

**Decision.** Keep the no-op `window.__name` shim. Cost is one `evaluateOnNewDocument` call. The alternative (rewriting every `page.evaluate(fn)` site to `page.addScriptTag({ content: "..." })`, like `packages/cli/src/commands/contrast-audit.browser.js` already does) is far more invasive and easy to regress.

## What changed

- Expanded the inline comment in `packages/engine/src/services/frameCapture.ts` to explain the per-runtime matrix above and point to the script-tag alternative.
- New `packages/engine/src/services/frameCapture-namePolyfill.test.ts` — a pure unit test (matches the rest of the engine package's no-browser-launch convention) that:
  1. Asserts the polyfill is wired up via `evaluateOnNewDocument` and runs before the first awaited `browser.version()` call.
  2. Probes the active Vitest transpiler for `__name(...)` injection so the next maintainer can see at a glance whether the upstream behavior has shifted.

## Test plan

- [x] `bun run --filter @hyperframes/engine test` → 408/408 pass (3 new tests in this file).
- [x] `bunx tsc --noEmit -p packages/engine` clean.
- [x] `bunx oxlint` and `bunx oxfmt --check` clean on edited files.

## Stack

Chunk 12 of `plans/hdr-followups.md`. Independent of all other chunks; closes out the investigation item.
2026-04-24 01:03:20 -07:00
Vance Ingalls bc27f9cee3 perf(producer): cache transfer-converted hdr image buffers per render job (#384)
## Summary

Add `HdrImageTransferCache` — a per-render-job bounded LRU keyed by `(imageId, targetTransfer)` — so static HDR image layers whose source transfer differs from the render's effective transfer (PQ↔HLG) are converted **once per job** instead of **once per composited frame**.

## Why

`Chunk 8B` of `plans/hdr-followups.md`. `blitHdrImageLayer` was running `Buffer.from` + `convertTransfer` on every composited frame, even though the converted buffer is identical for the entire job. For a multi-second comp at 30 fps this is hundreds of redundant transfer conversions on the hot path.

## What changed

- New `packages/producer/src/services/hdrImageTransferCache.ts` — bounded LRU keyed by `(imageId, targetTransfer)` that owns the converted HDR rgb48 buffer for static HDR image layers:
  - Same-transfer requests return the source buffer untouched (zero copy).
  - Cross-transfer requests pay one `Buffer.from` + `convertTransfer` on first miss, reuse the cached copy on every subsequent frame.
- Wired into `renderOrchestrator.ts` via `HdrCompositeContext.hdrImageTransferCache`, instantiated once per render job, and consumed by `blitHdrImageLayer` on both the main composite path and the transition path.

## Test plan

- [x] `packages/producer/src/services/hdrImageTransferCache.test.ts` — 12 tests:
  - hit/miss semantics
  - distinct keys per image and per target transfer
  - LRU eviction + promotion
  - `maxEntries=0` passthrough
  - source-buffer immutability for cached entries
  - invalid options
- [x] Re-ran the Chunk 8A HDR benchmark — for the `hdr-regression` fixture (which has cross-transfer image layers) the cache hits 100% after the first frame; for HDR fixtures without cross-transfer images the same-transfer passthrough is a no-op.

## Stack

Chunk 8B of `plans/hdr-followups.md`. Sits on top of Chunk 8C (logger gating) and Chunk 8A (benchmark harness) so the win is measurable.
2026-04-24 00:15:12 -07:00
James Russo b42a8e4e03 feat(cli): forward perf breakdown + tmpPeakBytes to PostHog telemetry (#454)
## What

Forwards the new per-phase extraction breakdown and `tmpPeakBytes` fields from `RenderPerfSummary` (added in #444 and #446) to PostHog via the CLI's existing `render_complete` telemetry event.

## Why

The CLI already ships `render_complete` events to PostHog (`packages/cli/src/telemetry/client.ts`), but `events.ts:trackRenderComplete` only carried a subset of `RenderPerfSummary` — top-level timings, composition dims, and memory snapshots. After #444 added per-phase extraction breakdown (`videoExtractBreakdown`) and #446 added cache hit/miss counters, the data lives on `job.perfSummary` at render-complete but never reaches PostHog dashboards.

Without this, any PostHog insight built around "how often are we hitting the cache?", "what's the median HDR preflight cost?", or "where in the extract phase do compositions spend time?" has to be answered by Datadog log scraping instead.

## How

- **`packages/cli/src/telemetry/events.ts`** — extend `trackRenderComplete` props with 17 new optional fields: `tmpPeakBytes`, the six named stage timings, and the ten `videoExtractBreakdown` fields. All sent as flat properties (`extract_cache_hits`, `stage_capture_ms`, etc.) — PostHog insights query flat keys more ergonomically than nested objects.
- **`packages/cli/src/commands/render.ts`** — wire `job.perfSummary.videoExtractBreakdown` / `stages` / `tmpPeakBytes` into the `trackRenderMetrics` → `trackRenderComplete` hand-off.
- Naming: `extract_phase3_ms` deliberately disambiguates from `stage_video_extract_ms` — the former is just the parallel ffmpeg extract inside Phase 3; the latter is the full stage (resolve + probe + preflight + extract).
- All new fields are optional. The Docker-subprocess branch of `render.ts` that doesn't have a local `perfSummary` still compiles and ships events without them.

## Test plan

- [x] `bun run --cwd packages/cli test` — 161/161 pass
- [x] `bunx tsc -p packages/cli/tsconfig.json --noEmit` — no errors
- [x] `bunx oxlint` + `bunx oxfmt` — clean
- [ ] Once merged, verify PostHog receives the new properties on a real render event (run `hyperframes render` against a fixture and watch PostHog ingestion — telemetry auto-disables in CI, so this requires a local dev render with `HYPERFRAMES_NO_TELEMETRY` unset).

## Stack

Depends on #444 (adds the `videoExtractBreakdown` + `tmpPeakBytes` fields to `RenderPerfSummary`) and transitively on #445#446.

## Future work (not in this PR)

- The HeyGen internal producer server (`hyperframes-internal/packages/producer/src/server.ts`) logs `perfSummary` to Datadog via `log.info` but has no PostHog integration. Production renders are the bulk of the traffic — separate PR to either ship perfSummary to PostHog from the internal server, or materialize Datadog log-based metrics for per-phase timings.
2026-04-24 00:40:44 -04:00
James Russo 57cdf7d80e perf(engine): content-addressed extraction cache for video frames (#446)
## What

Adds a content-addressed cache for extracted video frames, keyed on the tuple `(path, mtime, size, mediaStart, duration, fps, format)`. Repeat renders of the same composition (studio edit → re-render, preview → final) skip the ffmpeg extraction entirely.

## Why

Video frame extraction is the dominant non-capture phase for video-heavy compositions. Studio iteration workflows extract the same frames over and over — each render burns ffmpeg time that adds no value.

Validated on `/tmp/hf-fixtures/cfr-sdr-cache`:
```
Cold (miss): extractMs=69,  videoExtractMs=70,  totalElapsedMs=2052
Warm (hit):  extractMs=1,   videoExtractMs=2,   totalElapsedMs=1964
cacheHits: 0→1, cacheMisses: 1→0
```
The fixture is tiny (3s CFR SDR @ 30fps), so the wall-clock delta is small; the extraction-time delta (69→1ms, 98%) scales linearly with source length. For heavy-iteration workflows (a user rendering the same composition while tuning encoding params), extraction time goes to zero on every repeat render.

Depends on #444 (instrumentation surface) and #445 (segment-scope HDR preflight — otherwise cache keys would be unstable across renders on mixed-HDR compositions).

## How

- New `packages/engine/src/services/extractionCache.ts`:
  - SHA-256 key over a stable JSON encoding of `(path, mtime_ms, size, mediaStart, duration, fps, format)`. Infinity duration is normalized to `-1` so unresolved natural-duration sources still produce stable keys.
  - Truncates to 16 hex chars in the entry directory name — 64 bits of entropy is plenty at cache scale and keeps `ls` output short.
  - `hfcache-v2-` schema prefix — bumping it invalidates old entries (callers own gc policy; the cache owns keys).
  - `.hf-complete` dotfile sentinel. An entry dir without the sentinel is treated as a miss (covers crash-mid-extract and abandoned writes); the next render re-extracts over the partial frames with `-y`.
  - `FRAME_FILENAME_PREFIX = "frame_"` shared with the extractor — future refactors only need to touch one place to rename frames.
- `EngineConfig.extractCacheDir` (env: `HYPERFRAMES_EXTRACT_CACHE_DIR`) gates the feature. Undefined disables caching — extraction runs into the render's workDir and cleanup removes it on render end, preserving the prior behaviour exactly. No default root is chosen by the engine; the caller (CLI, app, studio) owns the location policy.
- `ExtractedFrames.ownedByLookup` flag prevents `FrameLookupTable.cleanup` from rm'ing a shared cache dir at render end. Set to `true` on both hits and misses (misses own the directory they wrote into, but hand it over to the cache rather than deleting it).
- Phase 3 extractor flow:
  1. Snapshot `(videoPath, mediaStart, start, end)` per resolved video BEFORE Phase 2a/2b preflight mutates them — so cache keys are stable across renders that use workDir-local normalized files (those files have fresh mtimes every render).
  2. Compute key, `lookupCacheEntry`.
  3. On hit: rebuild `ExtractedFrames` from the cache dir plus the Phase 2-probed `VideoMetadata` — no re-ffprobe.
  4. On miss: `ensureCacheEntryDir`, extract with `extractVideoFramesRange(..., outputDirOverride)`, then `markCacheEntryComplete` (the sentinel write is the last step so a crash leaves the dir un-sentineled).
- `extractVideoFramesRange` gains an `outputDirOverride` parameter so cache-miss writes land directly in the keyed dir (no `join(outputDir, videoId)` wrapping).

## Test plan

- [x] 19 unit tests in `extractionCache.test.ts` covering key determinism, mtime/size invalidation, format/fps/mediaStart/duration invalidation, Infinity normalization, sentinel semantics, missing-file tolerance
- [x] 2 integration tests in `videoFrameExtractor.test.ts`:
  - "reuses extracted frames on a warm cache hit" — asserts `cacheHits=1`, `extractMs<50ms` on second call against a CFR SDR fixture
  - "invalidates the cache when fps changes" — different fps on second call forces a new miss
- [x] End-to-end validation with `HYPERFRAMES_EXTRACT_CACHE_DIR` set, two runs of the same fixture
- [x] Lint + format (oxlint + oxfmt)
- [x] Typecheck (engine + producer)
2026-04-24 00:35:31 -04:00
James Russo 9912d3730a perf(engine): segment-scope SDR→HDR preflight (#445)
## What

Scopes the SDR→HDR preflight re-encode to the segment the composition actually uses, mirroring the existing VFR→CFR segment-scope fix.

## Why

`convertSdrToHdr` was re-encoding entire source files, so a 30-minute SDR screen recording contributing a 2-second clip in a mixed HDR/SDR composition ate multi-second preflight time that produced frames no one would ever read. Validated on a mixed 30s-SDR + 2s-HDR fixture: `hdrPreflightMs` drops **87%** (1162→148ms), `videoExtractMs` drops **82%** (1272→231ms), `tmpPeakBytes` drops **45%** (8.2MB→4.5MB).

Depends on #444 (phase-level instrumentation) for the measurement surface.

## How

- `convertSdrToHdr` gains `startTime` and `duration` parameters ahead of the upstream `targetTransfer` arg added by #370. New signature: `convertSdrToHdr(input, output, startTime, duration, targetTransfer, signal, config)`. `-ss $start -t $duration` is added to the ffmpeg args.
- Phase 2 now captures the full `VideoMetadata` per `resolvedVideos` entry (previously just `colorSpace`) so the caller can compute `segDuration` from `video.end - video.start` with a fallback to `metadata.durationSeconds - video.mediaStart` for unbounded (Infinity) clips — without firing another ffprobe.
- After a successful convert, `entry.video.mediaStart` is zeroed out via shallow-copy (doesn't mutate the caller's `VideoElement`) so downstream extraction seeks from 0 instead of the original offset. Mirrors what the VFR→CFR path already does.

## Test plan

Validation on `/tmp/hf-fixtures/hdr-sdr-mixed-scope`:
```
hdrPreflightMs: >1000 → 150   (gate: <300)   ✓
videoExtractMs: 1272  → 237   (-82%)
tmpPeakBytes:   8.2MB → 4.5MB (-45%)
```

- [x] Unit test: new regression test synthesizes 10s SDR + 2s HDR fixture inline and asserts the converted file's duration matches the 2s used segment (pre-fix matched the 10s source)
- [x] Lint + format
- [x] Typecheck
- [x] Manual perf validation against synthesized fixture
2026-04-24 00:15:04 -04:00
James Russo 31354d52da perf(engine): extraction-phase instrumentation (#444)
## What

Adds per-phase timings and counters to `extractAllVideoFrames` and surfaces them on the producer's `RenderPerfSummary` as `videoExtractBreakdown` alongside a new `tmpPeakBytes` workDir size sample.

## Why

Phase 2 video extraction has five distinct sub-phases (resolve, HDR probe, HDR preflight, VFR probe, VFR preflight, per-video extract) and today they collapse into a single `videoExtractMs` stage timing. That makes every subsequent perf PR in this stack immeasurable — you can't tell whether a win came from cache hits, preflight scope reduction, or pure extraction speed.

This PR is foundational for PR #445 (segment-scope HDR preflight) and PR #446 (content-addressed extraction cache).

## How

- New `ExtractionPhaseBreakdown` type with `resolveMs`, `hdrProbeMs`, `hdrPreflightMs/Count`, `vfrProbeMs`, `vfrPreflightMs/Count`, `extractMs`, `cacheHits`, `cacheMisses`. Populated inline with `Date.now()` wrappers — overhead is sub-millisecond on every phase.
- Returned on `ExtractionResult.phaseBreakdown`.
- Producer extends `RenderPerfSummary` with `videoExtractBreakdown?: ExtractionPhaseBreakdown` and `tmpPeakBytes?: number`. `tmpPeakBytes` is sampled from the workDir right before cleanup via a new recursive-size helper that swallows errors (purely observational — a missing workDir must never fail the render).

No changes to the capture-lifecycle resource tracking — earlier versions of this instrumentation plumbed injector LRU stats through `RenderOrchestrator`, which conflicted hard with upstream #371 (`buildHdrCaptureOptions` refactor). Dropped that piece for a marginal observability loss.

## Test plan

Validation on `packages/producer/tests/vfr-screen-recording`:
```json
"videoExtractBreakdown": {
  "resolveMs": 0, "hdrProbeMs": 0, "hdrPreflightMs": 0, "hdrPreflightCount": 0,
  "vfrProbeMs": 0, "vfrPreflightMs": 166, "vfrPreflightCount": 1,
  "extractMs": 97, "cacheHits": 0, "cacheMisses": 0
},
"tmpPeakBytes": 4578598
```
Total elapsed within noise of pre-PR baseline (2665 → 2673 → 3228ms across hosts).

- [x] Unit test: phase-breakdown assertion added to `videoFrameExtractor.test.ts`
- [x] Lint + format (oxlint + oxfmt)
- [x] Typecheck (engine + producer)
- [x] Manual perf validation against VFR fixture
2026-04-23 23:54:56 -04:00
Miguel Ángel bcfaded48c chore: release v0.4.20 v0.4.20 2026-04-23 22:08:03 -04:00
Ular Kimsanov 35bb21f2b8 Merge pull request #469 from heygen-com/fix/shader-transitions-dynamic-resolution
feat(shader-transitions): support any aspect ratio (vertical, square)
2026-04-23 21:46:19 -04:00
ukimsanovandClaude Opus 4.6 a8f0752bf6 fix(shader-transitions): address review — NaN guard, canvas sync, shared defaults
- Validate data-width/data-height: fall back to defaults if NaN or <= 0
- Sync existing #gl-canvas dimensions on reuse (if init called twice)
- Import DEFAULT_WIDTH/DEFAULT_HEIGHT in capture.ts instead of
  hardcoding 1920/1080 in parameter defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 21:41:42 -04:00
ukimsanovandClaude Opus 4.6 c81ef50e2f feat(shader-transitions): support any aspect ratio + update skill
Add dynamic resolution to shader transitions and update Skeleton A
to use shaders on vertical compositions.

shader-transitions changes:
- webgl.ts: read dimensions from params instead of hardcoded constants
- capture.ts: accept width/height for html2canvas
- hyper-shader.ts: read data-width/data-height from composition root

skill + docs changes:
- Skeleton A now has 1 shader at hero reveal (s3→s4 midpoint)
- Removed "no shaders on vertical" limitation from docs
- Updated claude-design.mdx known limitations section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 21:32:09 -04:00
ukimsanovandClaude Opus 4.6 6b84971f6b feat(shader-transitions): support any aspect ratio (vertical, square)
Read data-width/data-height from the composition root instead of
hardcoding 1920x1080. Enables shader transitions on vertical
(1080x1920) and square (1080x1080) compositions.

Changes across 3 files:
- webgl.ts: WIDTH/HEIGHT constants → DEFAULT_WIDTH/DEFAULT_HEIGHT,
  createContext and renderShader accept width/height params
- capture.ts: captureScene and captureIncomingScene accept
  width/height params for html2canvas
- hyper-shader.ts: reads data-width/data-height from root element,
  passes dimensions to all webgl and capture calls

GLSL shaders unchanged — they already use u_resolution uniform for
all coordinate math and work at any aspect ratio.

Backwards compatible: all params default to 1920x1080 when not
provided or when data-width/data-height are missing from the DOM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 21:19:49 -04:00
Miguel Ángel 31cd0ea6e7 chore: release v0.4.19 v0.4.19 2026-04-24 01:15:34 +00:00
Ular Kimsanov 60d1494279 Merge pull request #468 from heygen-com/fix/claude-design-docs-download-ux
docs: fix SKILL.md download UX across all references
2026-04-23 21:09:20 -04:00
Miguel Ángel f8cb2b17f0 chore: release v0.4.18 v0.4.18 2026-04-24 01:06:54 +00:00
ukimsanovandClaude Opus 4.6 c6da00d9c8 docs: fix SKILL.md download UX across all references
Raw GitHub URLs serve as text/plain — clicking opens a text tab
instead of downloading. Updated all 5 references across 4 files:

- claude-design.mdx (2 refs): "right-click → Save Link As"
- prompting.mdx (1 ref): "right-click → Save Link As"
- README.md (1 ref): "click download button on GitHub"
- quickstart.mdx (1 ref): "click download button on GitHub"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 21:06:27 -04:00
Ular Kimsanov 95cc264fe7 Merge pull request #467 from heygen-com/feat/claude-design-skill-template-first
refactor(claude-design-skill): template-first rewrite with bug fixes
2026-04-23 20:59:24 -04:00
ukimsanovandClaude Opus 4.6 95ca0200d4 docs: update all Claude Design references for template-first skill
Update docs, quickstart, prompting guide, and README to reflect:
- Template-first approach (attach file, not paste URL)
- Claude Design produces drafts, refine in any AI coding agent
- Known limitations (vertical shaders, seeking, no linting)
- Practical example prompts (feature announcement, founder pitch)
- Removed outdated references (invisible bridges, fetch-the-skills-tree)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 20:52:40 -04:00
Miguel Ángel 970b446c49 feat(studio): drag assets from the sidebar onto the timeline (#464)
## Problem

Studio still broke down in three concrete authoring flows around timeline assets:

- you could import media into Assets, but not drag an already-imported asset from the Assets tab onto the timeline and persist it into source
- dragging a file from outside the app onto the timeline only uploaded it into Assets instead of placing it at the dropped time/track
- once a clip was on the timeline, there was no reliable keyboard delete flow for removing it safely from source

While implementing direct external drops, another real bug showed up:

- valid binary uploads like `raycast.mp4` from `Downloads` were being rejected as unsupported media in Studio dev because the Vite API bridge was corrupting multipart request bodies before they reached the upload route

## What this fixes

### Timeline asset placement from inside Studio

- asset cards in the Assets tab are draggable
- the timeline accepts asset drops even when it already has clips
- dropping an asset onto the timeline inserts a new clip into the active composition source at the dropped time / track
- asset paths are rewritten relative to the target composition file so drops into sub-compositions resolve correctly
- the new clip is persisted immediately and the preview refreshes

### Direct external file drops onto the timeline

- dropping a file from outside the app onto the timeline now uploads it and places it onto the dropped track/time in one shot
- it no longer stops halfway by only adding the file into Assets
- multiple dropped files are placed using the same drop start and successive tracks

### Delete key support

- selected timeline clips can now be deleted with `Delete` / `Backspace`
- deletion is persisted back to source, not just removed from local state
- the delete path now uses a server-side DOM mutation helper with LinkeDOM for structural safety instead of client-side string surgery

### Binary upload fix for media files

- the Studio Vite API bridge now forwards non-GET request bodies as raw bytes instead of decoding them as UTF-8 text
- that preserves multipart uploads for binary media like MP4s
- valid local videos from `Downloads` no longer get rejected as `Unsupported media skipped` just because the dev bridge corrupted the request body
- upload validation now probes buffered media through a temp file path that preserves the file extension before saving into the project

## Root cause

There were really two separate gaps:

### 1. Asset placement / deletion workflow gaps

The timeline and asset systems already existed, but they were disconnected:

- `AssetsTab` only supported copy/import flows
- `Timeline` only handled raw file import, not positioned placement for existing assets
- there was no utility layer for converting a dropped asset into persisted timeline HTML
- there was no structurally safe deletion path for arbitrary selected timeline clips

### 2. Binary upload corruption in Studio dev

The Studio Vite API bridge rebuilt non-GET request bodies like this:

- read each request chunk
- call `chunk.toString()`
- concatenate into a string
- construct the Fetch `Request` from that string body

That works for text, but it corrupts multipart binary uploads. By the time the upload route wrote the received file and ran `ffprobe`, otherwise valid MP4s had already been mangled in-flight.

## Behavior

- dropping on `index.html` inserts the asset into the root composition
- dropping while drilled into a composition inserts into that composition file instead
- drop X position maps to `data-start`
- drop Y position maps to the current visible track row, with a new bottom track created if the drop lands below existing rows
- images default to a short finite duration
- audio/video default to their metadata duration when available, with a fallback duration if metadata cannot be read quickly
- pressing `Delete` on a selected clip removes that clip from the underlying HTML source and clears selection in Studio
- valid uploaded MP4s now survive the Studio dev API bridge intact instead of being rejected during upload validation

## Verification

### Local checks

- `bunx oxlint packages/core/src/studio-api/helpers/sourceMutation.ts packages/core/src/studio-api/helpers/sourceMutation.test.ts packages/core/src/studio-api/helpers/mediaValidation.ts packages/core/src/studio-api/helpers/mediaValidation.test.ts packages/core/src/studio-api/routes/files.ts packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/sidebar/AssetsTab.tsx packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/Timeline.test.ts packages/studio/src/utils/timelineAssetDrop.ts packages/studio/src/utils/timelineAssetDrop.test.ts packages/studio/vite.config.ts packages/studio/vite.request-body.ts packages/studio/vite.request-body.test.ts`
- `bunx oxfmt --check` on the touched files
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/studio typecheck`
- `bun test packages/core/src/studio-api/helpers/sourceMutation.test.ts packages/core/src/studio-api/helpers/mediaValidation.test.ts packages/studio/src/player/components/Timeline.test.ts packages/studio/src/utils/timelineAssetDrop.test.ts packages/studio/vite.request-body.test.ts`

### Browser / live verification

Verified against a live local Studio fixture:

- dragging an existing asset from the Assets tab onto the timeline creates a persisted clip at the dropped position
- dropping a file from outside the app directly onto the timeline uploads it and creates a persisted clip at the dropped position
- selecting a dropped clip and pressing `Delete` removes it from both the live timeline and the saved source HTML
- valid MP4 uploads like `raycast.mp4` now succeed through the live Studio upload route instead of being rejected as unsupported media

## Notes

- the local `timeline-trio-verify` and `timeline-overlap-debug` projects used for verification are local-only and are not part of this PR
- this PR is about asset placement, upload correctness, and deletion safety; it does not broaden into richer editing workflows beyond placing/removing clips from the timeline
2026-04-24 02:50:36 +02:00
Ular Kimsanov 078a8b349b Merge pull request #456 from heygen-com/fix/shader-transitions-capture-robustness
fix(shader-transitions): harden capture against visibility, scrub, Safari taint
2026-04-23 20:46:43 -04:00
ukimsanovandClaude Opus 4.6 18136d4eb5 fix(shader-transitions): add inWindow guard to .catch fallback handler
Address Vance's review on #456: the .catch handler was missing the
same tl.time() window check that .then has. Late-rejecting captures
(Safari + SVG-filter compositions) could fire gsap.to/fromTo on
scenes the playhead already left, causing flash-to-black mid-scene.

Wraps the CSS crossfade fallback in the same inWindow guard so
stale catch handlers are no-ops, matching the .then behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 20:17:13 -04:00
ukimsanov ee7c6c3c24 refactor(claude-design-skill): template-first rewrite with bug fixes
Replace the rule-heavy Claude Design skill with a template-first
approach. Instead of teaching all rules from scratch (863 lines),
provide pre-valid skeletons where structural rules are embedded.
Claude Design fills in palette, content, and animations.

Key changes:
- Pre-valid HTML skeletons for social reel (vertical), launch teaser,
  product explainer, and cinematic title
- Mixed transitions: 2-3 shader transitions at key moments, rest
  hard cuts (matches professional video practice)
- autoAlpha toggles for non-anchor scenes (fixes invisible middle
  scenes caused by HyperShader's blanket opacity reset)
- Explicit first-anchor opacity fix (HyperShader browser mode never
  auto-shows the first anchor scene)
- No shaders on vertical (1080x1920) — WebGL canvas is hardcoded
  to 1920x1080 in webgl.ts
- Inline animation patterns (counter, stroke draw, stagger, float,
  bar chart, orbit, highlight sweep, safe CSS grain)
- Claude Design → Claude Code handoff workflow documented in
  README template and delivery step
- Troubleshooting table for black preview, invisible scenes, seeking
- Self-review checklist split: structural validity, brand accuracy,
  animation baseline
- Skill description updated per review feedback on PR #353 to
  disambiguate from the hyperframes skill on cross-surface routing

Bugs fixed by this rewrite (confirmed across demov4-1 through
demov4-6 and demov5-1 through demov5-5):
- First shader anchor invisible in every composition
- Non-anchor scenes killed by blanket querySelectorAll(".scene")
  opacity reset
- visibility toggles insufficient (opacity:0 persists after reset)
- Shaders on vertical compositions produce distorted transitions
2026-04-23 20:12:20 -04:00
Miguel Ángel d3899b16ff chore: release v0.4.17 v0.4.17 2026-04-23 18:20:18 -04:00
Miguel Ángel 6610b8ad00 fix: harden studio timeline editing and local renders (#463)
* fix: harden studio timeline editing and local renders

* test: cover studio local render fallback

* fix(studio): scale composition hover previews to stage size

* test: normalize studio producer fallback paths

* fix(studio): preserve move surface and retry render fallback
2026-04-24 00:18:37 +02:00
Vance Ingalls 21063c66d9 perf(producer): gate per-frame debug meta via optional isLevelEnabled (#383)
## Summary

Add an optional `isLevelEnabled(level)` method to `ProducerLogger` and use it to short-circuit per-frame HDR composite metadata construction in `renderOrchestrator` when the log level is above debug.

Closes Chunks 8C and 8D from `plans/hdr-followups.md`.

## Why

`Chunk 8C` of `plans/hdr-followups.md`. The per-frame HDR composite snapshot (every 30 frames) was building an `Array.find` + `toFixed` + struct allocation unconditionally and handing it to a debug logger that immediately discarded it at `level="info"`. On long renders, this is allocation pressure and CPU time wasted on log meta nobody reads.

`Chunk 8D` was investigated in the same pass and found to already be guarded — see below.

## What changed

- New optional `isLevelEnabled(level: ProducerLogLevel): boolean` on `ProducerLogger`.
- `createConsoleLogger` implements it.
- `renderOrchestrator.ts` per-frame HDR composite snapshot is now gated on `i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)` — production runs at `level="info"` skip the meta-object construction entirely; custom loggers without the new method keep their existing behavior thanks to the `?? true` fallback.
- New `packages/producer/src/logger.test.ts` (17 tests) covering level filtering, meta formatting, the `isLevelEnabled` path, a hot-loop call-site simulation that asserts zero builder invocations at info level, and the `?? true` fallback for loggers that omit the method.
- `docs/packages/producer.mdx` gains a new "Logging" section documenting `ProducerLogger`, `createConsoleLogger`, `defaultLogger`, and the `isLevelEnabled` gating pattern.

**8D resolution (no code change).** `countNonZeroAlpha` / `countNonZeroRgb48` calls live behind `shouldLog = debugDumpEnabled && debugFrameIndex >= 0`, where `debugDumpEnabled` is itself driven by `KEEP_TEMP=1`. The pixel iteration is fully skipped on production runs already, so 8D needed no fix — verified during the 8C work.

## Test plan

- [x] `bun test` in producer — 17/17 logger tests pass; existing service tests unchanged.
- [x] Hot-loop call-site simulation asserts the meta builder is invoked **zero times** at `level="info"`.
- [x] `?? true` fallback preserves prior behavior for custom logger implementations that don't define the method.
- [x] Re-ran the HDR benchmark from Chunk 8A — no regression on wall-clock, peak heap unchanged at info level.

## Stack

Chunks 8C + 8D of `plans/hdr-followups.md`. Sits on top of the benchmark harness PR (Chunk 8A) so the optimization is measurable.
2026-04-23 15:11:17 -07:00
Vance Ingalls 3da8c2e969 perf(producer): hdr benchmark harness — --tags filter, peak heap/RSS tracking, bench:hdr script (#382)
## Summary

Make the existing benchmark harness genuinely useful for HDR perf work: positive `--tags` filter, peak heap/RSS sampling, a `bench:hdr` script, and a perf README documenting the captured April-2026 baseline. Lands first in the Chunk 8 sub-stack so subsequent perf PRs can be measured against a known starting point.

## Why

`Chunk 8A` of `plans/hdr-followups.md`. Wall-clock timing alone can't catch slow memory regressions like an unbounded image cache — peak RSS does. And the existing harness only had `--exclude-tags`, so HDR runs had to wait for unrelated SDR fixtures.

## What changed

**1. Positive `--tags` filter** in `benchmark.ts`. Adds `--tags hdr` so HDR runs don't have to wait for unrelated fixtures. Filters compose: a fixture must match `--tags` (if provided) AND must not match `--exclude-tags`.

**2. Peak heap + RSS tracking** in `executeRenderJob`. A 250 ms periodic `process.memoryUsage()` sampler runs alongside every render and reports `peakRssMb` / `peakHeapUsedMb` in `RenderPerfSummary`. Sampler is `unref`'d and always cleared in `finally` so it never keeps the event loop alive or leaks across jobs. Both fields are optional on the interface for back-compat with serialized older summaries.

**3. `bench:hdr` convenience script** plus a perf README at `tests/perf/README.md` documenting the harness, the new flags, and the captured April-2026 HDR baseline (PQ regression: 34.5 s / 272 MiB RSS, HLG regression: 11.5 s / 227 MiB RSS, both 1080p / 1 worker / 1 run).

The benchmark output table is widened and gains `PeakRSS` / `PeakHeap` columns. A new `avgOrNull` helper preserves `null` in the JSON when no run reported memory (avoids silently coercing missing data to 0 in older snapshots).

No behavior change for non-benchmark renders — the sampler runs in every `executeRenderJob` but its overhead is a single `process.memoryUsage()` call every 250 ms, well below noise.

## Test plan

- [x] `bunx tsc --noEmit -p packages/producer` — clean.
- [x] `bunx oxlint` / `bunx oxfmt --check` on changed files — clean.
- [x] `bun test src/services/` — 60/60 pass (frameDirCache, orchestrator, etc.).
- [x] `bunx tsx src/benchmark.ts --tags hdr --runs 1` — both HDR fixtures render successfully, summary table prints `PeakRSS`/`PeakHeap` columns, per-run output shows new memory line.
- [x] `bunx tsx src/benchmark.ts --tags nonexistent` — exits 1 with a helpful message naming the active filters.

## Stack

Chunk 8A of `plans/hdr-followups.md`. First PR in the Chunk 8 perf sub-stack; subsequent PRs (image cache, logger gating) measured against this baseline.
2026-04-23 14:49:49 -07:00
Vance Ingalls cc9403b6bd test(producer): extract frameDirMaxIndexCache to its own module and pin cross-job isolation (#381)
## Summary

Extract the `frameDirMaxIndexCache` from a private module-scoped Map inside `renderOrchestrator.ts` into its own `frameDirCache.ts` module, then add a 11-test bun:test suite that pins the cross-job isolation contract added in Chunk 5B.

## Why

`Chunk 9E` of `plans/hdr-followups.md`. The cache lived as a private Map inside `renderOrchestrator.ts`, which made the cross-job isolation contract from Chunk 5B impossible to unit-test directly. Extracting it both makes the contract testable and reduces orchestrator complexity slightly.

## What changed

- New `packages/producer/src/services/frameDirCache.ts` exposes `getMaxFrameIndex` / `clearMaxFrameIndex` / `getMaxFrameIndexCacheSize` (plus a test-only `__resetMaxFrameIndexCacheForTests` helper). Behavior is unchanged: callers still get the same module-scoped sharing inside a job, and `renderOrchestrator`'s outer `finally` still clears every entry it registered so the cache cannot grow monotonically across renders.
- `renderOrchestrator.ts`: imports the new helpers, drops the unused `readdirSync` import, updates inline comments, and replaces two `frameDirMaxIndexCache.delete` sites with `clearMaxFrameIndex`.
- New `frameDirCache.test.ts` (bun:test, 11 tests) covering:
  - Reading the max index from a populated directory.
  - Ignoring filenames that don't match `frame_NNNN.png` (wrong ext, wrong prefix, wrong case, double extension, empty index group, same-named subdirectory).
  - Empty- and missing-directory paths returning `0` and being cached.
  - Intra-job invariant: subsequent readdir mutations not observed once cached.
  - `clearMaxFrameIndex` forcing a re-read; returns `false` for paths that were never cached.
  - Per-directory isolation when multiple directories are registered.
  - The cross-job contract from Chunk 5B: cache empty between well-behaved jobs, doesn't grow monotonically across 20 simulated renders with 3 HDR videos each (steady-state cache size stays at 3), and a buggy job that forgets to clear leaks exactly its own entries rather than affecting unrelated jobs.

## Test plan

- [x] `frameDirCache.test.ts` 11/11 pass.
- [x] Existing producer tests unchanged.
- [x] Behavior preserved: same module-scoped sharing inside a job, same outer-`finally` eviction.

## Stack

Chunk 9E of `plans/hdr-followups.md`. Test-driven extraction; complements Chunk 5B.
2026-04-23 14:29:36 -07:00
Miguel Ángel 154511247a feat: add Claude Code plugin manifest (#462)
* feat(claude-code-plugin): add Claude Code plugin manifest

Adds .claude-plugin/plugin.json so the repo can be loaded as a Claude
Code plugin. Reuses the existing skills/ directory — all 5 skills
(hyperframes, gsap, hyperframes-cli, hyperframes-registry,
website-to-hyperframes) are auto-discovered under the hyperframes:
namespace.

* docs(readme): document Claude Code plugin usage

* chore(claude-code-plugin): add submission branding assets

* docs(claude-code-plugin): address review notes
2026-04-23 23:07:57 +02:00
Miguel Ángel 25d7a54330 docs: add Claude Design HyperFrames entry point (#353)
## Summary
- add a GitHub-hosted `claude-design-hyperframes` skill entry point that tells Claude Design to fetch the upstream HyperFrames skills tree
- add a dedicated Claude Design docs guide and link it from quickstart, prompting, and the README
- fix `@hyperframes/player` CDN docs to show a working ESM include and the explicit global-build fallback

## Verification
- `bunx oxfmt --check README.md docs/docs.json docs/guides/prompting.mdx docs/packages/player.mdx docs/quickstart.mdx packages/player/README.md docs/guides/claude-design.mdx skills/claude-design-hyperframes/SKILL.md`
- `bun run lint:skills`
- `bunx mintlify broken-links`
- browser-engine screenshots captured with Playwright CLI for the changed docs/source surfaces:
  - `/tmp/hyperframes-pr-artifacts/claude-design-guide-source.png`
  - `/tmp/hyperframes-pr-artifacts/player-docs-source.png`

## Notes
- `mintlify dev`, `mintlify validate`, and `mintlify export` stalled in this environment during preview/bootstrap, so I used the broken-links check plus screenshot-based browser fallback instead of claiming a full rendered-site pass.
- The GitHub entry-point setup reflects current Claude Design behavior discussed in the task: point Claude Design at the repo-hosted skill URL rather than a ZIP upload flow.
2026-04-23 22:55:00 +02:00
James Russo d125d65164 feat(plugins): add Cursor plugin manifest + refresh marketplace branding (#461)
Adds .cursor-plugin/plugin.json at the repo root alongside the existing
.codex-plugin/, so this repo is the single source of truth for the Codex
plugin AND the new Cursor Marketplace submission. Refreshes the shared
assets/logo.png + assets/icon.png to a 1024x1024 / 512x512 symbol-only
mark rasterized from docs/favicon.svg (white background), and renames the
marketplace display title from "HyperFrames" to "HyperFrames by HeyGen"
in both Codex and Cursor manifests.

No skill content changes; purely marketplace-visible branding and the new
Cursor manifest file.
2026-04-23 16:35:56 -04:00
ukimsanov 41b4017273 docs(shader-transitions): clarify allowTaint caveat in capture comment
Address Copilot review comment on #456: the old `allowTaint` doc comment
said the resulting canvas is "still usable as a WebGL texture via
gl.texImage2D (no pixel read-back required)", which is wrong. A tainted
canvas CANNOT be uploaded to WebGL — the spec requires SecurityError on
non-origin-clean sources with no opt-out. That's exactly what we observe
in Safari + SVG-filter compositions, and what hyper-shader.ts's catch
handler now handles via CSS crossfade.

Update the comment to correctly describe the flag's effect: it only
moves the failure point from html2canvas to texImage2D; the end-user
UX is the same (smooth CSS fade in either case). The flag remains
defensively correct for the non-taint branches where it genuinely
helps (cross-origin images with `Access-Control-Allow-Origin`).

No code change — comment only.

Made-with: Cursor
2026-04-23 16:20:32 -04:00
Vance Ingalls 2b25023e10 test(engine): cover spawnStreamingEncoder lifecycle and cleanup paths (#380)
## Summary

Add unit tests that mock `child_process.spawn` to drive an in-memory "ffmpeg" through the success/failure paths used by the producer's HDR encoder and by Chunk 5A's defensive `close()` in `renderOrchestrator`.

## Why

`Chunk 9D` of `plans/hdr-followups.md`. The contracts the orchestrator's try/finally cleanup (Chunk 5A) and the abort path rely on were entirely uncovered. A regression in `spawnStreamingEncoder`'s lifecycle handling would surface as a leaked ffmpeg process or a hung render, both of which are hard to diagnose after the fact.

## What changed

`packages/engine/src/services/streamingEncoder.test.ts`: 7 new specs covering:

- Successful exit after explicit `close()`.
- Non-zero exit before `close()` returns a failure result (no throw).
- `ENOENT` on spawn returns a failure result (no throw).
- Abort signal triggers `SIGTERM` and a `"cancelled"` result.
- `close()` is idempotent and never throws on a second call.
- `writeFrame` returns `false` after the encoder has exited.
- `close()` detaches the abort listener so post-close aborts don't re-kill ffmpeg.

These contracts are what the `renderOrchestrator` try/finally cleanup added in Chunk 5A relies on, and what the ffprobe-unavailable test (Chunk 9B) hinted at for the encoder side.

## Test plan

- [x] All new specs pass.
- [x] No production code changes — pure regression coverage of existing lifecycle behavior.

## Stack

Chunk 9D of `plans/hdr-followups.md`. Test-only change, complements Chunk 5A.
2026-04-23 13:10:41 -07:00
Miguel Ángel 072814e65c fix(core,cli,ci): harden runtime resolution + inline constant + smoke test (#458)
Guard buildHyperframesRuntimeScript() against missing entry.ts so it
returns null instead of crashing with esbuild stderr output. Add
getHyperframeRuntimeScript() that returns the pre-built IIFE as a
baked-in string constant — no esbuild, no file I/O, no import.meta.url.

Consolidate CLI runtime source resolution into a single module with
a clear priority chain: esbuild from source (dev) → inlined constant
(production) → pre-built artifact file (fallback).

Add CI smoke test that npm-packs the CLI, installs globally, runs
hyperframes preview, and asserts no stderr errors + runtime endpoint
returns JS.

Bump version to 0.4.16.
v0.4.16
2026-04-23 21:44:26 +02:00
ukimsanov b3b458fda4 fix(shader-transitions): harden capture against visibility, scrub, Safari taint
Three interrelated fixes for the live-player path in
@hyperframes/shader-transitions (Studio preview, <hyperframes-player>
embeds, Claude Design in-pane iframe). Zero changes to engine mode —
initEngineMode is byte-identical; producer render pipeline and CLI
hyperframes render produce byte-identical output.

1. captureIncomingScene now forces visibility:visible during capture.
   The HF runtime sets visibility:hidden on [data-start] elements outside
   their playback window. With centered shader timing (transition.time =
   boundary - duration/2), html2canvas captures the incoming scene while
   it's still hidden → blank texture → visible blink mid-transition.
   Fix saves, overrides, captures, and restores visibility only for the
   capture window. Empirically validated via a direct html2canvas probe:
   captures of visibility:hidden elements return blank; with override,
   they return real content.

2. post-capture.dom guards on tl.time() window before mutating DOM.
   On scrub across multiple shader transitions, tl.call() fires several
   transitions' callbacks in rapid succession; each launches async
   html2canvas; each .then() unconditionally set all .scene opacities to
   0, enabled shader canvas, and pointed state at that transition. The
   last to resolve won — often for a transition the playhead had left.
   Result: scenes stuck opacity:0 mid-scene; blank screen until the next
   transition's end.call ran. Fix: check tl.time() is still inside
   [T, T+dur] before applying state; otherwise skip.

3. .catch fallback does CSS crossfade instead of hard cut.
   When capture fails (Safari canvas taint from SVG data URLs, CORS
   errors, extreme DOM complexity) the old catch snapped all scenes to
   opacity:0 then set incoming to opacity:1 — jarring instant jump. Fix
   uses gsap.to/fromTo on opacity over the intended transition duration;
   smooth 0.5s fade is strictly better UX. Hard cut preserved as
   last-resort if elements are missing.

Also adds defensive useCORS: true and allowTaint: true to the
html2canvas call. No behavior change in Chrome (capture normally
succeeds); adds resilience for cross-origin images with CORS headers
and SVG-tainted canvases respectively.

Known limitations (out of scope, follow-up tracked):

- Safari + cross-origin iframe: html2canvas is 10-12x slower than Chrome
  due to WebKit's DocumentCloner.cloneNode perf (html2canvas#3108),
  causing perceptible per-transition freezes (1.5-2s each) in Claude
  Design's in-pane preview. Needs pre-capture architecture (cache
  incoming-scene textures at init) to eliminate per-transition cost.
- SVG filter data URLs fundamentally taint html2canvas output in Safari;
  WebGL's texImage2D has no framework opt-out (WebGL spec). Addressed
  at the composition level via the Claude Design skill's anti-pattern 4
  in a parallel PR.

Made-with: Cursor
2026-04-23 15:15:58 -04:00
Miguel Ángel 34db66ef0a fix(cli): prevent esbuild runtime error in global/npx installs (#452)
* fix(cli): resolve runtime fallback for globally-installed hyperframes

When hyperframes is installed globally via npm, the `loadRuntimeSourceFallback()`
path that dynamically imports @hyperframes/core and runs esbuild fails because
@hyperframes/core is inlined into cli.js and import.meta.url resolves to the
wrong location for the entry.ts source file.

Add a disk-based fallback that searches for the pre-built IIFE runtime artifact
in multiple locations:
- Alongside the bundled CLI (dist/hyperframe-runtime.js, dist/hyperframe.runtime.iife.js)
- Walking up from __dirname through node_modules

The esbuild path is tried first to preserve live-rebuild behavior in dev,
with the pre-built artifact search as a safety net for the bundled context.

Also adds the IIFE artifact name variant to resolveRuntimePath() in the
studio server so it checks both naming conventions.

* fix(cli): gate esbuild fallback on source availability

The previous fix still triggered esbuild's stderr output before the
catch could suppress it. Now check whether the runtime entry.ts source
file actually exists before attempting the on-the-fly build, avoiding
the noisy error in global installs entirely.

* fix(cli): remove noisy console.warn from runtime fallback

The caller already handles a null return — no need to warn about
something the user can't act on. If both paths fail, the /api/runtime.js
route returns a 404 which the studio handles gracefully.

* style(engine): fix oxfmt trailing blank line in chunkEncoder test

* fix(cli): guard against null/undefined from loadHyperframeRuntimeSource

Fall through to the pre-built artifact if the function returns a
falsy value without throwing.

* refactor(cli): consolidate runtime source resolution into single module

Replace the scattered path-probing logic with a single loadRuntimeSource()
that encodes the full priority chain: esbuild from source (dev only,
gated on entry.ts existence) → pre-built artifact alongside cli.js →
core/dist artifact → node_modules walk.

Rename loadRuntimeSourceFallback → loadRuntimeSource since it's now
the primary resolution function, not a fallback.
2026-04-23 21:04:48 +02:00
Vance Ingalls 147bb737c9 test(engine): add ffprobe-unavailable fallback regression tests (#379)
## Summary

Mock `node:child_process.spawn` to surface `ENOENT` and verify ffprobe's three callers behave correctly when ffprobe is missing.

## Why

`Chunk 9B` of `plans/hdr-followups.md`. The PNG cICP fallback in `extractMediaMetadata` was added to support environments without ffprobe, but no test pinned the behavior — silently regressing it would break HDR image support on any system without ffprobe installed.

## What changed

`packages/engine/src/utils/ffprobe.test.ts`: mocks `child_process.spawn` to surface `ENOENT` and asserts:

- `extractMediaMetadata` falls back to PNG cICP metadata for image inputs.
- `extractMediaMetadata` rethrows for non-image inputs lacking a still-image fallback.
- `extractAudioMetadata` + `analyzeKeyframeIntervals` propagate the install-hint error verbatim.

## Test plan

- [x] All new tests pass.
- [x] No production code changes — pure regression coverage of existing fallback behavior.

## Stack

Chunk 9B of `plans/hdr-followups.md`. Test-only change, independent of all other chunks.
2026-04-23 11:42:42 -07:00
Vance Ingalls 4101cb721a test(shader-transitions): add midpoint (p=0.5) regression invariants for all shaders (#378)
## Summary

Add four midpoint (`p=0.5`) regression invariants applied via a `describe` loop over `ALL_SHADERS`, so every existing and future shader transition automatically gets coverage at the most viewer-visible point in the animation.

## Why

`Chunk 9G` of `plans/hdr-followups.md`. Existing smoke tests cover only the endpoints (`p=0 ≈ from`, `p=1 ≈ to`), which miss a class of regressions that surface specifically at the midpoint and let shaders silently rot in CI:

- A shader becomes a no-op (returns input as-is)
- A shader prematurely completes (returns target at midpoint)
- A shader doesn't write to the output buffer at all
- A shader loses determinism (`Math.random` / `Date.now` / leaked state)

## What changed

`packages/engine/src/utils/shaderTransitions.test.ts`: a single `describe` loop over `ALL_SHADERS` that asserts at `p=0.5`:

1. `output ≠ from` — catches no-ops
2. `output ≠ to` — catches premature completion
3. `output` is non-zero — catches blank output
4. `output` is deterministic — catches accidental non-determinism

Uses two distinct uniform input colors (40000/30000/20000 vs 10000/10000/10000) so equality checks have distinct byte patterns to compare against. Even shaders that warp UVs (which would be no-ops on uniform input alone) produce `mix16(from, to, 0.5)` at every pixel, distinct from both inputs.

## Test plan

- [x] 60 new tests (4 invariants × 15 shaders), all passing.
- [x] Any new transition added to the registry automatically picks up the same coverage.

## Stack

Chunk 9G of `plans/hdr-followups.md`. Test-only change, independent of all other chunks.
2026-04-23 11:11:30 -07:00
Vance Ingalls ba6197a2e4 ci(windows-ffmpeg): pin BtbN release to specific autobuild tag (#447)
## What

Pin the BtbN/FFmpeg-Builds Windows download to a specific `autobuild-2026-04-23-13-16` release tag instead of the rolling `latest` nightly.

## Why

Follow-up to #436. The `release-url` input description claimed "pinned by default" but the actual default pointed at `releases/latest/download/…` — a nightly rolling build. A new upstream build could silently change encoder behavior or ABI between CI runs. The feature-inventory check catches codec removals but not within-codec behavioral shifts across nightlies.

## How

Replaced the `latest` URL with a specific dated autobuild tag + its git-hash-stamped asset filename. Updated the input description to note that both the tag and filename must be bumped together when upgrading (the asset filename embeds the git hash).

## Test plan

- [x] Verified pinned URL resolves (302 → 200) via `curl -sI -L`
- [x] Confirmed feature-inventory step uses `ffmpeg -encoders`/`-decoders` output, not the asset filename — no assumptions broken by the rename
- [ ] **Note:** Windows install/render/test jobs were skipped on this PR (YAML-only change didn't trigger Windows paths). The pinned download will be exercised by the next Windows-touching PR.
2026-04-23 10:53:19 -07:00
Vance Ingalls bb9e6bdf05 test(engine): lock down sRGB→BT.2020 LUT with byte-exact reference values (#377)
## Summary

Add a 12-row reference table covering the full sRGB range with byte-exact 16-bit HLG and PQ signal values, plus three guard tests, locking down the `buildSrgbToHdrLut()` math.

## Why

`Chunk 9F` of `plans/hdr-followups.md`. The matrix-free fast path through `blitRgba8OverRgb48le` runs every DOM pixel through `buildSrgbToHdrLut()` (sRGB EOTF → linear → HDR OETF → 16-bit). Any drift in the EOTF/OETF math — constant changes, branch swaps, rounding-mode regressions — would silently corrupt every text / UI / overlay pixel composited onto an HDR frame.

Existing tests covered structural invariants (transparent passthrough, opaque overwrite, alpha blending, channel symmetry, HLG ≠ PQ) but no byte-exact reference values, so a uniform scale or constant tweak could pass everything.

## What changed

- 12-row reference table in `alphaBlit.test.ts` covering black, shadow, mid-grays, highlight, near-white, and white with exact 16-bit HLG and PQ signal values.
- Three guard tests:
  - **Asymmetric R/G/B (HLG):** each channel hits the LUT independently.
  - **Asymmetric R/G/B (PQ):** same, on the PQ path.
  - **BT.2408 SDR-white invariant:** PQ caps sRGB 255 at 38055 (~203 nits), well below HLG's 65535. This is the load-bearing detail that makes PQ headroom work — locking the exact value prevents a future "fix" that would re-scale PQ to peak-at-SDR-white and clip every real HDR pixel.

Reference values mirror `buildSrgbToHdrLut()` exactly and were verified against the existing HLG mid-gray comment in the file.

## Test plan

- [x] All new tests pass against the current LUT.
- [x] Existing `alphaBlit.test.ts` invariants unchanged.

## Stack

Chunk 9F of `plans/hdr-followups.md`. Test-only change, independent of all other chunks.
2026-04-23 10:37:45 -07:00
Vance Ingalls 3089c8ee3a build(lfs): track tests/*/src/*.png via Git LFS (#376)
## Summary

Track `tests/*/src/*.png` via Git LFS to mirror the existing policy for golden videos and `.mp4` fixtures.

## Why

`Chunk 11C` of `plans/hdr-followups.md`. Without this rule, regression suites that grow PNG fixtures over time would bloat the working-tree history and slow shallow clones.

## What changed

- `.gitattributes`: add `tests/*/src/*.png` to the LFS-tracked patterns.
- Migrates the six existing PNG fixtures (1.6 MB combined: `hdr-photo-pq.png` plus `heygen-promo-preview-assets/` screenshots) onto LFS in the same commit so the rule applies retroactively.

## Test plan

- [x] `git lfs ls-files` includes the HDR PNG fixtures after commit.
- [x] Working tree size for these files goes from 1.6 MB to 6 × ~130 B LFS pointers.

## Stack

Chunk 11C of `plans/hdr-followups.md`. Independent of all code changes.
2026-04-23 09:31:48 -07:00
roiizchakandroi32 c11a332ef8 test(engine): cover h265 NVENC in preset-mapping regression tests (#443)
hevc_nvenc uses the same p1..p7 preset vocabulary as h264_nvenc, so the
mapping in `mapPresetForGpuEncoder` applies to both codecs. The initial
regression suite only covered `codec: "h264"`, which left a gap: a
future refactor that split the H.264 and H.265 NVENC paths could
silently regress one codec without any test catching it.

Add three-case loops (ultrafast → p1, medium → p4, veryslow → p7) under
`codec: "h265"` to both `buildEncoderArgs` and `buildStreamingArgs`
test blocks. Each case also asserts that `-c:v hevc_nvenc` is selected
so the test fails loudly if the codec plumbing is broken, not just the
preset translation.

Follow-up to #442 per review comment from @jrusso1020.

Co-authored-by: roi32 <75878108+roi32@users.noreply.github.com>
2026-04-23 09:20:44 -07:00
roiizchakandroi32 3b8de7a5eb fix(engine): accept libx264 preset names with NVENC and QSV (#442)
NVENC rejects the libx264 preset vocabulary (ultrafast / medium / slow /
...) with AVERROR(EINVAL) ("Error applying encoder options: Invalid
argument"), which surfaces as a bare `FFmpeg exited with code -22` from
spawn(). Because ENCODER_PRESETS passes these names straight through to
h264_nvenc / hevc_nvenc, every `--gpu` render using the `draft` tier
failed; `standard` (medium) and `high` (slow) only worked coincidentally
on ffmpeg builds that happened to accept those aliases. QSV has the same
problem on a narrower set (ultrafast / superfast / placebo).

Add `mapPresetForGpuEncoder` in utils/gpuEncoder.ts that translates the
libx264 vocabulary to each encoder's native names:

- nvenc: libx264 -> p1..p7 (already-native pN values pass through);
  unknown values fall back to p4 (medium)
- qsv:   ultrafast / superfast -> veryfast; placebo -> veryslow;
  everything else passes through
- videotoolbox / vaapi / null: unchanged

Both buildEncoderArgs (chunkEncoder.ts) and buildStreamingArgs
(streamingEncoder.ts) now route through the helper before pushing
`-preset` to the ffmpeg arg vector.

To make the next encoder-options failure diagnosable without re-running
ffmpeg by hand, \`formatFfmpegError\` in utils/runFfmpeg.ts now appends
the last 15 non-empty stderr lines to the error string. The four call
sites that previously swallowed stderr (encodeFramesFromDir,
muxVideoWithAudio, applyFaststart, and the streaming encoder exit
handler) have been updated.

Tested end-to-end on an RTX 4080 with ffmpeg 8.1 NVENC across
\`--quality draft|standard|high\` plus \`--video-bitrate\` and \`--crf\`
overrides; the 6 renders were visually equivalent to the CPU baseline.

Co-authored-by: roi32 <75878108+roi32@users.noreply.github.com>
2026-04-23 08:38:12 -07:00
Vance Ingalls 8ffd007716 test(hdr-regression): tighten Window F maxFrameFailures budget after Chunk 4 fix (#375)
## Summary

Tighten `hdr-regression` Window F `maxFrameFailures` from 5 → 0 now that Chunk 4 (matrix3d support + scene initial-state) has landed.

## Why

Window F (transform + scale + border-radius on the video itself) was the remaining known-fail in the `hdr-regression` suite, baked into the golden so the suite stayed green while Chunk 4 was outstanding.

After Chunk 4 fixed `parseTransformMatrix` (matrix3d support) and the shader-transitions initial-state, re-running the suite shows **0 failed frames** against the existing golden — the encoder is byte-deterministic, and Window F's GSAP rotation/scale happens to emit 2D `matrix()` rather than `matrix3d()`, so the same golden is still correct after the fix. Tightening the budget catches any drift in the layered HDR compositor immediately.

## What changed

- `tests/hdr-regression/meta.json`: `maxFrameFailures` 5 → 0 (matches `hdr-hlg-regression`).
- `tests/hdr-regression/README.md`: Window F row + Fix history section updated to reflect the new state.

## Test plan

- [x] `bun run test --filter hdr-regression` — passes with 0 failed frames at the new budget.

## Stack

Follow-up to Chunk 4 (transform & clipping). Reviewable separately so the budget tightening is decoupled from the code fix.
2026-04-23 01:45:19 -07:00