169 Commits
Author SHA1 Message Date
Miguel Ángel 857b870459 chore: release v0.4.36 2026-04-29 14:55:45 +00:00
Miguel Ángel 8b234be20c fix(producer): handle no-audio stream gracefully in resolveMediaDuration
When an <audio> element referenced a file with no audio stream (e.g. a
silent screen-recording used as an audio src, or a video-only clip),
extractAudioMetadata threw "[FFmpeg] No audio stream found". The error
propagated uncaught through Promise.all in compileHtmlFile and crashed
the entire render.

Apply the same graceful-skip pattern already used for missing files and
failed downloads: catch the probe error and return { duration: 0 } so
the element is excluded from the composition without aborting the render.

Confirmed via 7 production HyperframeRenderWorkflow failures all sharing
the same TemporalMagicEditActivity.RENDER_PREVIEW stack trace.
2026-04-29 16:53:35 +02:00
Miguel Ángel eb065260dc chore: release v0.4.35 2026-04-29 13:47:22 +00:00
Miguel Ángel 31c4f974ea chore: release v0.4.34 2026-04-28 23:39:28 +00:00
Vance Ingalls ad44c3133a perf(hdr): reduce layered composite overhead (#538) 2026-04-28 15:30:35 -07:00
Miguel Ángel 54cc0f1cba test: cover sub-composition audio extraction 2026-04-28 17:00:40 -04:00
Vance IngallsandClaude Opus 4.6 8f97edb2b9 fix(hdr): filter zero-opacity elements and support overflow:hidden clip rects (#522)
* fix(hdr): filter zero-opacity elements and support overflow:hidden clip rects in HDR compositor

Two bugs in the HDR render pipeline:

1. Child data-start elements inside a parent with opacity:0 were still
   composited as independent layers, painting over content in later scenes.
   Fix: filter elements with effective opacity 0 before groupIntoLayers().

2. CSS overflow:hidden on ancestor elements was ignored for HDR video layers,
   causing videos inside clipped containers (e.g. split-screen halves) to
   render full-frame. Fix: add clipRect to ElementStackingInfo, compute it
   from ancestor overflow:hidden in queryElementStacking(), and crop the
   source buffer to clip bounds before blitting in blitHdrVideoLayer().

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

* fix(hdr): move opacity filter into blit loop to preserve hide-list correctness

The previous approach filtered zero-opacity elements before groupIntoLayers(),
which broke the DOM screenshot hide-list — invisible video elements' <img>
replacements weren't properly hidden from sibling layer screenshots, causing
the vignelli-stacking regression.

Fix: keep all elements in groupIntoLayers() for correct hide-list generation.
Skip zero-opacity HDR elements only during the actual blit step with an early
`continue` in the compositing loop.

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

* fix(hdr): route identity-matrix HDR elements through region blit for clip rect support

parseTransformMatrix returns a valid matrix even for untransformed HDR
elements (Chrome reports matrix(1,0,0,1,0,0)). This made the affine blit
path always run, bypassing the region blit path which is the only one that
applies clip rects from overflow:hidden ancestors.

Fix: detect identity matrices and route them through the region path so
the cropRgb48le clip logic is reachable for split-screen layouts.

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

* fix(hdr): handle translation-only matrices for clip rect support

The previous isIdentity check only caught matrix(1,0,0,1,0,0). Elements
with layout translation (e.g. right-half split at left:960px reporting
matrix(1,0,0,1,960,0)) still routed through the affine path where clip
rects are not applied.

Fix: check for translation-only matrices (scale=1, rotation=0, any tx/ty)
and route those through the region blit path. el.x/el.y from
getBoundingClientRect already include the translation, so the region path
handles positioning correctly.

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

* feat(render): auto-detect HDR from media probes, add --sdr flag

Replace the --hdr opt-in model with automatic detection. When no flags
are passed, the renderer probes all video/image sources and enables HDR
output if any HDR color space is detected. Existing --hdr flag becomes
a force override. New --sdr flag forces SDR output.

Behavior matrix:
  (no flags) + HDR content → HDR output
  (no flags) + SDR content → SDR output
  --hdr → force HDR (defaults to HLG if no HDR sources)
  --sdr → force SDR (skips probing)
  --hdr --sdr → error

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

* Revert "feat(render): auto-detect HDR from media probes, add --sdr flag"

This reverts commit 69fb52196f.

* chore(hdr): simplify review fixes — remove redundant guard, add image clip warning

- Remove redundant viewportMatrix.length >= 6 check (parseTransformMatrix
  always returns 6-element array or null)
- Add clip rect warning log to blitHdrImageLayer for parity with video path

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 10:15:25 -07:00
Miguel Ángel 46a4cacea2 chore: release v0.4.33 2026-04-28 12:45:02 -04:00
Vance IngallsandClaude Opus 4.6 8e5593b6ba feat(render): auto-detect HDR from media probes, add --sdr flag (#526)
* feat(render): auto-detect HDR from media probes, add --sdr flag

Replace the --hdr opt-in model with automatic detection. When no flags
are passed, the renderer probes all video/image sources and enables HDR
output if any HDR color space is detected. Existing --hdr flag becomes
a force override. New --sdr flag forces SDR output.

Behavior matrix:
  (no flags) + HDR content → HDR output
  (no flags) + SDR content → SDR output
  --hdr → force HDR (defaults to HLG if no HDR sources)
  --sdr → force SDR (skips probing)
  --hdr --sdr → error

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

* fix: align HDR auto-detect docs and tests

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 01:39:51 -07:00
Miguel Ángel 36b3fc8cd9 fix: budget workers for expensive captures 2026-04-27 22:53:27 -04:00
Miguel Ángel 37827cdaec chore: release v0.4.32 2026-04-27 21:18:36 -04:00
Miguel Ángel 31afb4c1b3 Merge pull request #518 from heygen-com/fix/studio-preview-runtime-visibility
fix: isolate Studio sub-composition previews
2026-04-28 01:51:09 +02:00
Miguel Ángel 613f97793e test: add preview regression gate 2026-04-27 18:16:09 -04:00
Miguel Ángel 6ea2e54fcf fix: preserve anonymous inlined composition roots 2026-04-27 18:16:01 -04:00
Miguel Ángel d3b2900d6b fix: preserve inferred composition ids when inlining 2026-04-27 16:57:39 -04:00
Youssef Toufik 9e05322531 test(transparency): paint body background in fixture so it actually fails on the base SHA
Per Miguel's review: the previous fixture had no body / root background, so it
passed against both the buggy and fixed code. The fix this PR makes (the
initTransparentBackground stylesheet injection in initializeSession) only
matters when a composition paints over the CDP default-background-color
override — exactly what we tell users not to do, but exactly what a
regression test must do.

Reproduced locally:

- base SHA (2935be6): pixel (10,10) decodes as rgba [16,16,16,255]
  (opaque #111 body bg leaks through the pre-navigation override that
  Chrome resets on goto)
- this head: pixel (10,10) decodes as rgba [0,0,0,0]
  (initTransparentBackground injects [data-composition-id]{background:transparent !important}
  AFTER navigation, force-overriding the body bg)

The pixel-level assertions in transparency-test.ts are unchanged — they
already require alpha=0 at (10,10). With the body bg painted, that
assertion now fails on any code path that doesn't actually preserve alpha
end-to-end.
2026-04-27 21:05:56 +01:00
Miguel Ángel 3701bb9eeb fix: flatten inlined composition roots 2026-04-27 15:53:24 -04:00
Miguel Ángel c32db3ea0a fix: isolate studio sub-composition previews 2026-04-27 15:51:53 -04:00
Youssef Toufik fe017b48c7 feat(producer): true alpha output for webm, mov, and png-sequence
Extends RenderConfig.format with "png-sequence" and patches two correctness
gaps so the existing "webm" / "mov" values actually preserve the alpha
channel end-to-end.

Engine fixes:
- screenshotService.pageScreenshotCapture: drop optimizeForSpeed for PNG
  captures. The fast path uses an alpha-unaware codec that crushes real
  alpha values; kept for opaque jpeg captures where it is harmless.
- frameCapture: replace the inline setDefaultBackgroundColorOverride
  block (which fired pre-navigation and was reset by page.goto) with a
  proper initTransparentBackground() call inside initializeSession,
  after the window.__hf readiness poll. This also injects the
  html/body/[data-composition-id]{background:transparent !important}
  stylesheet so compositions with custom body / #root backgrounds do not
  defeat the override. Wired into both screenshot-mode and beginframe-mode
  branches.

Producer:
- RenderConfig.format extended to "mp4" | "webm" | "mov" | "png-sequence"
  with full JSDoc.
- Streaming encode is bypassed for png-sequence (frames go straight to
  disk). FORMAT_EXT extended.
- New Stage-5 png-sequence branch: mkdir outputPath, copy captured PNGs as
  frame_NNNNNN.png, copy audio.aac sidecar when audio is present.
- Stage-6 mux/faststart and the debug copy are wrapped in !isPngSequence.
- README.md: new "Transparent Video Output" section.

Tests:
- New fixture tests/transparency-regression/ tagged "transparency".
- New tsx script src/transparency-test.ts asserts pixel-level alpha for
  webm + png-sequence outputs. Wired as "test:transparency".
- Default "test" / "test:update" scripts pass --exclude-tags transparency
  so the golden-MP4 harness ignores the new fixture.

Verified locally on macOS arm64: typecheck clean across engine + producer,
producer renderOrchestrator vitest 10/10, transparency-test passes for
both webm and png-sequence with end-to-end pixel assertions.
2026-04-27 20:33:45 +01:00
Miguel Ángel 6e19d88e5f chore: release v0.4.31 2026-04-26 19:41:11 -04:00
James 2b836cc224 chore: release v0.4.30 2026-04-26 05:17:13 +00:00
Miguel Ángel 970367f5e6 chore: release v0.4.29 2026-04-25 19:11:53 -04:00
Miguel Ángel 34c710c44a chore: release v0.4.28 2026-04-25 17:20:25 -04:00
Miguel Ángel a65c0ce22e chore: release v0.4.27 2026-04-25 16:53:48 -04:00
Miguel Ángel 45f70004a1 chore: release v0.4.26 2026-04-25 11:14:36 -04:00
Miguel Ángel 8ac46b4596 chore: release v0.4.25 2026-04-25 10:59:45 -04:00
Miguel Ángel 82fab98e69 chore: release v0.4.24 2026-04-25 00:39:03 -04:00
Miguel Ángel 2f60dd3727 fix(producer): serve symlinked render assets (#486) 2026-04-25 06:36:27 +02:00
Miguel Ángel c427619cff chore: release v0.4.23 2026-04-24 17:24:53 -04:00
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
Vance Ingalls 6b21ead737 chore: release v0.4.22 2026-04-24 11:43:48 -07:00
Miguel Ángel a47f48a17f chore: release 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
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 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 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 2026-04-23 22:08:03 -04:00
Miguel Ángel 31cd0ea6e7 chore: release v0.4.19 2026-04-24 01:15:34 +00:00
Miguel Ángel f8cb2b17f0 chore: release v0.4.18 2026-04-24 01:06:54 +00:00
Miguel Ángel d3899b16ff chore: release v0.4.17 2026-04-23 18:20:18 -04: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 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.
2026-04-23 21:44:26 +02: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
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
Vance Ingalls 2e1a1d91a2 fix(engine,shader): handle matrix3d transforms and hide non-first scenes (#374)
## Summary

Two correctness fixes in the HDR transform & clipping pipeline: `parseTransformMatrix` now handles `matrix3d(...)` (GSAP's default `force3D: true`), and shader-transitions sets every non-first scene to `opacity: 0` at `t=0` so the engine doesn't over-composite at the start.

## Why

`Chunk 4` of `plans/hdr-followups.md`. Transform extraction and border-radius computation existed but were dead — an HDR video with `rotation: 45` rendered un-rotated, and 3-scene compositions ghosted at `t=0` because every scene defaulted to CSS `opacity: 1` and contributed to the first frame.

## What changed

**Matrix3d support in `parseTransformMatrix`.** `DOMMatrix.toString()` emits `matrix3d` whenever any ancestor in the chain has used a 3D transform — most importantly GSAP's default `force3D: true`, which converts `translate(...)` into `translate3d(..., 0)`. Without this, every GSAP-driven transform was silently dropped during HDR compositing because `videoFrameInjector.getViewportMatrix()` would return `matrix3d(...)` and the blit path would parse it as `null` and fall back to identity. The 16-value column-major form is converted to its 2D affine projection (indices 0, 1, 4, 5, 12, 13 → m11, m12, m21, m22, m41, m42); Z, perspective, and out-of-plane rotation components are dropped.

**Initial-state opacity in `initEngineMode`.** The browser preview branch uses a GL canvas overlay during transitions, so scene opacity at `t=0` doesn't matter visually. The engine branch reads scene opacity directly via `queryElementStacking()` to decide which layers to composite. Without an explicit initial-state tween, every scene defaulted to CSS `opacity: 1` and contributed to the very first frame, causing ghosting/overlap until the first transition fired. `tl.set()` at position 0 anchors the initial state in the timeline graph so reverse seeks from inside a later transition restore it correctly.

These two fixes together make `el.transform` and `el.borderRadius` (already wired in Chunk 7A's `compositeHdrFrame`) actually flow through the GSAP-animated case, and keep the engine's per-frame compositing aligned with what the user sees in browser preview.

## Test plan

- [x] 6 new `alphaBlit.test.ts` cases (identity matrix3d, translate3d, scale + translate3d, rotateZ, malformed arg count, non-finite values).
- [x] Existing `hdr-regression` Window H already CSS-sets `#scene-b { opacity: 0 }` as a fallback; the new `tl.set` is redundant for that case but harmless and removes the need for compositions to remember the CSS workaround.
- [x] Manual: rotated HDR video (`rotation: 45`) appears rotated; `border-radius: 50%` clips to circle; 3-scene composition has no overlap at `t=0`.

## Stack

Chunk 4 of `plans/hdr-followups.md`. Window F of the regression suite documents the bug; the next PR in the stack tightens the `maxFrameFailures` budget to 0.
2026-04-22 23:55:40 -07:00
Vance Ingalls a3d7cc1c95 refactor(producer): extract HDR compositing helpers and rename media metadata (#373)
## Summary

Four behavior-preserving refactors that reduce complexity in `renderOrchestrator.ts` and clarify the engine ffprobe utility surface. Lands after the correctness fixes (Chunks 1–5) so the refactored code is already correct.

## Why

`Chunk 7` of `plans/hdr-followups.md`. The HDR composite block had grown a ~200 LOC inline closure with 14 captured deps, a repeated capture-options spread, a `extractVideoMetadata` name that now also handles still images, and per-frame re-creation of debug helpers.

## What changed

**7A — Hoist `compositeToBuffer` into a module-scoped helper.** Extract the inline HDR closure into a top-level `compositeHdrFrame()` that takes an `HdrCompositeContext` struct. Construct the context once at the top of the HDR render block and pass it through. Removes a deeply-nested closure from the middle of the orchestrator.

**7B — `buildHdrCaptureOptions()` helper.** Factor the repeated `{ ...captureOptions, skipReadinessVideoIds: ... }` spread into a named helper at the call site.

**7C — Rename `extractVideoMetadata` → `extractMediaMetadata`.** Reflects that the helper handles still images (PNG/JPEG/WebP) in addition to video. Update all callers in engine + producer (`videoFrameExtractor`, `htmlCompiler`, regression-harness, producer ffprobe re-export, tests). Re-export the old name as a deprecated alias from `@hyperframes/engine` for backward compatibility, plus the producer re-export shim.

**7D — Hoist debug counters to module scope.** `countNonZeroAlpha` and `countNonZeroRgb48` are now module-scoped so they aren't re-created per frame and so the closure has fewer captures.

Also touches the `hdr-regression` and `hdr-hlg-regression` README + `meta.json` files reviewed during this refactor.

## Test plan

- [x] `bunx tsc --noEmit -p packages/producer && bunx tsc --noEmit -p packages/engine` clean.
- [x] Engine tests: 313 pass, 0 fail (1218 expect calls).
- [x] `bunx oxlint` + `bunx oxfmt --check` clean on 8 changed source files.
- [x] Diff is structural only — no behavioral changes.

## Stack

Chunk 7 of `plans/hdr-followups.md`. Lands after the correctness fixes (Chunks 1–5) per the suggested merge order.
2026-04-22 22:41:30 -07:00
Miguel Ángel e853dd7a1d chore: release v0.4.15 2026-04-23 01:13:37 -04:00
Vance Ingalls 53e1aeaadc fix(producer): wire --crf and --video-bitrate CLI overrides into encoders (#372)
## Summary

Re-wire the `--crf` and `--video-bitrate` CLI flags through the three encoder spawn sites in `renderOrchestrator.ts`. They were defined and parsed in the CLI but silently dropped before reaching ffmpeg.

## Why

`Chunk 10` of `plans/hdr-followups.md`. PR #292 originally wired these through with a `baseEncoderOpts` object using `effectiveQuality`/`effectiveBitrate`; PR #268 rewrote the encode paths and reverted to `preset.quality` only, accidentally dropping the override. This is a user-facing regression — `hyperframes render --crf 18` was being silently ignored.

## What changed

- At the three encoder spawn sites (HDR streaming, SDR streaming, disk-based encode), `quality` defaults to `preset.quality` but is overridden by `job.config.crf` when set, and `bitrate` is set from `job.config.videoBitrate`. Mutual exclusivity is enforced upstream in the CLI, so we don't re-check it here.
- Fix the contradictory note in `docs/packages/cli.mdx` that claimed CRF/bitrate were now driven only by `--quality`. The flags table now lists `--crf` and `--video-bitrate` consistent with `docs/guides/rendering.mdx`.

## Test plan

- [x] `hyperframes render --crf 18 ...` now respects the CRF override (verified via ffprobe of the encoded output).
- [x] `hyperframes render --hdr ...` still works (no behavior change at the default path).
- [x] `hyperframes render --help` shows all flags consistent with the docs.

## Stack

Chunk 10 of `plans/hdr-followups.md`. Independent of all other chunks.
2026-04-22 22:05:48 -07:00