feat(hdr): layered HDR compositing, shader transitions, and HDR image support (#268)

* feat(hdr): shader transitions, --hdr flag, and SDR rendering fixes

- 15 GLSL→TypeScript shader transitions on rgb48le buffers
- Dual-scene compositing with scene detection via window.__hf.transitions
- --hdr flag gates ffprobe probing (zero overhead on SDR compositions)
- Cross-transfer conversion (PQ↔HLG) via OOTF-corrected composite LUT
- Buffer.from() copy in writeFrame() fixes streaming encoder race condition
- SDR rendering fixes (three stacked bugs)
- Object.assign fix for window.__hf preservation

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

* fix: tighten shader smoke thresholds + assert .scene contract

- Tighten the all-transitions smoke test thresholds: at progress=0 we now
  require the center pixel R-channel > 35000 (was > 25000) and at
  progress=1 < 15000 (was < 25000). The old midpoint of 25000 sat exactly
  halfway between the test from-pixel (40000) and to-pixel (10000), so a
  half-blended transition would silently pass.
- Add a runtime assertion in HyperShader.init() that every scene id
  resolves to a DOM element with the .scene class. Without this, missing
  ids silently no-op when textures + querySelectorAll(.scene) run later.

Addresses deferred review feedback from PR #268.

* fix(hdr): restore VIRTUAL_TIME_SHIM and applyRenderModeHints in renderOrchestrator

Commit c6b4619c ("feat(hdr): shader transitions, --hdr flag, and SDR
rendering fixes") accidentally removed two pieces of the deterministic
rendering pipeline:

1. The `VIRTUAL_TIME_SHIM` injected via `createFileServer.preHeadScripts`,
   which freezes `Date.now()` and `requestAnimationFrame` so RAF-driven
   animations advance only when `window.__hf.seek(t)` is called.
2. The `applyRenderModeHints` function and its post-`compileForRender`
   call site, which auto-forces screenshot capture mode for compositions
   the compiler flagged as needing it (RAF, iframes, etc.).

Without (1), RAF animations advanced by wall-clock between the main-loop
seek and the per-DOM-layer seek inside `compositeToBuffer`, producing the
sawtooth PSNR pattern on `raf-ball-render-compat` (high PSNR at integer
seconds, ~24 dB everywhere else). Without (2), `iframe-render-compat`
lost its automatic fallback to screenshot mode and the child-document
motion stopped being captured.

Both helpers are still produced by `htmlCompiler` and exercised by
`renderOrchestrator.test.ts` — the orchestrator just stopped calling
them. Restored:

- Re-import `VIRTUAL_TIME_SHIM` from `./fileServer.js`
- Pass `preHeadScripts: [VIRTUAL_TIME_SHIM]` to both `createFileServer`
  call sites (probe + main render)
- Re-add `applyRenderModeHints` (matching the test expectations) and
  call it immediately after `compileForRender`
- Persist `renderModeHints` in `summary.json` and the
  "Compiled composition metadata" log line

Fixes the `iframe-render-compat` and `raf-ball-render-compat` regression
failures on `feat/hdr-layered-compositing`.

Made-with: Cursor

* test(engine): expand sampleRgb48le coverage + audit Uint16Array alignment

Adds:
- 8 new sampleRgb48le bilinear-interpolation tests covering boundary
  pixels, sub-pixel weights, edge clamping, and odd-byte-offset Buffers.
- uint16-alignment-audit.test.ts documenting the alignment requirement
  for Uint16Array views over Buffer slices vs. readUInt16LE/writeUInt16LE.

Background: ~105 hot-loop sites in shader transitions still use
readUInt16LE/writeUInt16LE. Switching to Uint16Array views would cut
overhead but requires guaranteed even byteOffsets — these tests document
the contract before any future refactor lands.

* fix(engine,producer): mask DOM layers during HDR layered compositing

The HDR layered compositor blits z-ordered layers over a shared canvas. DOM
layers used a full-page screenshot from `captureAlphaPng`, which captures
*every* painted pixel on the page — root background, sibling-scene content,
overlay UI elements that aren't part of the current layer. Those opaque
pixels were then blitted over the canvas, overwriting any HDR content
composited beneath in earlier layers.

The previous workaround toggled `display:none` on hide ids via
`hideVideoElements`/`showVideoElements`. That correctly hid native videos
but did nothing about the root composition's background or about overlay
elements that the layer grouping considered part of a different layer.

This commit replaces the workaround with a precise CSS mask installed
before each DOM screenshot:

1. `applyDomLayerMask` injects a stylesheet that hides every `body *` and
   re-shows the layer's elements (and their descendants and their injected
   `__render_frame_*` siblings) with `visibility: visible !important`. CSS
   visibility is *not* multiplicative through descendants — a child with
   `visibility: visible` overrides an ancestor's `visibility: hidden`, so
   deeply nested layer content still paints even though every intermediate
   ancestor is hidden by the mass-hide rule.
2. Non-layer data-start ids are inline-hidden with
   `visibility: hidden !important`. Inline `!important` beats stylesheet
   `!important`, so this overrides the show rule for elements that fall
   under a show selector but should NOT paint — most importantly HDR
   videos and other-layer SDR videos that live as descendants of `#root`.
3. `removeDomLayerMask` tears the stylesheet down and clears the inline
   `visibility`/`opacity` properties so subsequent video frame injection
   gets a clean slate.

Crucially the mask only sets `visibility`, never `opacity`. CSS opacity
*is* multiplicative — `opacity: 0` on `#root` would zero out every
descendant including layer videos, even with `visibility: visible`. We
also extend `initTransparentBackground` to force the composition root
(`[data-composition-id]`) transparent in addition to `html`/`body`,
because compositions almost always set `#root { background: ... }` and
that background paints across the whole viewport otherwise.

Both compositing paths use the new helpers:
- The per-layer DOM branch (`compositeToBuffer`) for normal frames.
- The transition path (single DOM screenshot per scene) so transition
  frames also get a clean per-scene capture.

Adds extensive `KEEP_TEMP=1`-gated diagnostics to `compositeToBuffer`:
per-layer pixel-add accounting, dumps of every captured DOM PNG, and a
periodic raw `rgb48le` snapshot of the composite buffer. These were
essential to diagnosing the root-overwrite bug and stay zero-cost in
normal renders. Also stops the workDir / per-video frame-dir cleanup
when `KEEP_TEMP=1` so the dumps survive past frame N.

Made-with: Cursor

* fix(engine): preserve GSAP-applied opacity across DOM-layer captures

SDR clips inside an HDR composition were rendering at full opacity even
when the user had animated their wrapper opacity (e.g. fade-in or
yoyo). Two bugs in the per-layer screenshot path conspired to drop the
GSAP-applied opacity on the floor:

1. removeDomLayerMask was unconditionally calling
   `el.style.removeProperty("opacity")` on every wrapper after each
   layer capture. applyDomLayerMask only ever sets `visibility`, so the
   only inline opacity present is the value GSAP wrote. Stripping it
   between layer captures means that on the next capture (at the same
   timestamp), GSAP's `totalTime(t, false)` no-ops because the timeline
   is already at that time — the opacity is never restored, and the
   wrapper renders fully opaque.

2. injectVideoFramesBatch was reading the source <video>'s computed
   opacity via `parseFloat(computedStyle.opacity) || 1` and copying it
   onto the injected <img>. Because syncVideoFrameVisibility forces the
   <video> to `opacity: 0 !important` to hide it during capture, the
   computed value is always 0, which `|| 1` then silently flips to
   full opacity. The <img> is a sibling of the <video> inside the same
   wrapper, so it should inherit opacity from the wrapper directly
   instead of having a value hard-set on it.

Fix both: drop the opacity removal in removeDomLayerMask, skip opacity
when copying visual properties from <video> to <img>, and explicitly
clear any stale inline opacity on the <img> so it inherits from the
wrapper that GSAP is animating.

Made-with: Cursor

* fix(producer): correct hdrLayerStartTimes typo to hdrVideoStartTimes

The diagnostic logging block in executeRenderJob's HDR layer composite
path referenced an undeclared `hdrLayerStartTimes` map. The correct
variable, declared and populated earlier in the same function, is
`hdrVideoStartTimes`. The typo was introduced alongside the DOM-layer
masking work and broke the producer build/typecheck on CI.

Made-with: Cursor

* fix(engine): restore video opacity copy to injected frame img

Commit 188ebcca removed the opacity copy from `injectVideoFramesBatch` on
the assumption that the <img> sibling would inherit GSAP's opacity from
a shared wrapper. That breaks any composition where GSAP animates opacity
directly on the <video> element itself: the <img> has no animated
ancestor and renders at full opacity throughout any fade, even when the
user's intent is partial or zero opacity.

The CI `style-7-prod` and `style-8-prod` regressions caught this:
the <video id="aroll"> fade-in from 3.0-3.5s rendered as a hard cut
because the <img> inherited opacity 1 regardless of GSAP's tween.

Restore the old explicit copy from `computedStyle.opacity` to the
<img>'s inline opacity, with the `|| 1` fallback intentionally
preserved. The fallback is load-bearing: GSAP's seek does not re-apply
tweens that have already completed, so post-fade frames read opacity 0
from the stale `opacity: 0 !important` we apply to hide the native
<video>. The `|| 1` recovers the tween's end-state opacity 1 for
those frames, matching the final on-screen intent and the existing
baseline renders.

Handles both DOM shapes:
- GSAP on wrapper: video's own computed opacity is 1, img set to 1,
  wrapper's opacity applies via stacking as before.
- GSAP on <video>: video's computed opacity is the tween value, copied
  to img directly since they are siblings.

Fixes:
- style-7-prod: 0 failed frames (was 2 @ t=3.17, 3.33)
- style-8-prod: 0 failed frames (was 2 @ t=3.05, 3.24)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-04-19 19:00:58 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8548a17771
commit 99a903be2f
11 changed files with 2813 additions and 216 deletions
+4 -3
View File
@@ -465,14 +465,15 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--output` | path | `renders/<name>.mp4` | Output file path |
| `--format` | mp4, webm, mov | mp4 | Output format (WebM/MOV render with transparency) |
| `--fps` | 24, 30, 60 | 30 | Frames per second |
| `--quality` | draft, standard, high | standard | Encoding quality preset |
| `--crf` | 051 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` |
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--hdr` | — | off | HDR output (H.265 10-bit, BT.2020 HLG/PQ). MP4 only |
| `--workers` | 1-8 | 4 | Parallel render workers |
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
| `--quiet` | — | off | Suppress verbose output |
CRF and target bitrate are now driven by `--quality`. For programmatic renders, `RenderConfig.crf` and `RenderConfig.videoBitrate` still accept overrides.
#### WebM with Transparency
Use `--format webm` to render compositions with a transparent background. This produces VP9 video with alpha channel in a WebM container — the standard format for overlayable video.
+10 -61
View File
@@ -7,10 +7,9 @@ export const examples: Example[] = [
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
["Custom CRF for maximum quality", "hyperframes render --crf 15 --output pristine.mp4"],
["Target bitrate encoding", "hyperframes render --video-bitrate 10M --output hq.mp4"],
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"],
];
import { cpus, freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
@@ -81,19 +80,10 @@ export default defineCommand({
description: "Use Docker for deterministic render",
default: false,
},
crf: {
type: "string",
description:
"CRF (Constant Rate Factor) for the video encoder. " +
"Lower = higher quality / larger file. Range: 051 for H.264. " +
"Overrides the quality preset CRF. Cannot be used with --video-bitrate.",
},
"video-bitrate": {
type: "string",
description:
"Target video bitrate (e.g. '10M', '5000k'). " +
"Uses bitrate-based encoding instead of CRF. " +
"Cannot be used with --crf.",
hdr: {
type: "boolean",
description: "Enable HDR: probe sources for PQ/HLG, output H.265 10-bit BT.2020",
default: false,
},
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
quiet: {
@@ -144,36 +134,6 @@ export default defineCommand({
}
const format = formatRaw as "mp4" | "webm" | "mov";
// ── Validate CRF / video-bitrate ────────────────────────────────────
let crf: number | undefined;
let videoBitrate: string | undefined;
if (args.crf != null && args["video-bitrate"] != null) {
errorBox(
"Conflicting options",
"--crf and --video-bitrate cannot be used together. Choose one.",
);
process.exit(1);
}
if (args.crf != null) {
const parsed = parseInt(args.crf, 10);
if (isNaN(parsed) || parsed < 0 || parsed > 51) {
errorBox("Invalid CRF", `Got "${args.crf}". Must be a number between 0 and 51.`);
process.exit(1);
}
crf = parsed;
}
if (args["video-bitrate"] != null) {
const raw = args["video-bitrate"];
if (!/^\d+(\.\d+)?[kKM]$/.test(raw)) {
errorBox(
"Invalid video bitrate",
`Got "${raw}". Must be a number followed by k, K, or M (e.g. "10M", "5000k", "1.5M").`,
);
process.exit(1);
}
videoBitrate = raw;
}
// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
if (args.workers != null && args.workers !== "auto") {
@@ -231,12 +191,7 @@ export default defineCommand({
c.accent(project.name) +
c.dim(" \u2192 " + outputPath),
);
const encodeLabel = videoBitrate
? `bitrate ${videoBitrate}`
: crf != null
? `crf ${crf}`
: quality;
console.log(c.dim(" " + fps + "fps \u00B7 " + encodeLabel + " \u00B7 " + workerLabel));
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
console.log("");
}
@@ -316,9 +271,8 @@ export default defineCommand({
format,
workers: workerCount,
gpu: useGpu,
hdr: args.hdr ?? false,
quiet,
crf,
videoBitrate,
});
} else {
await renderLocal(project.dir, outputPath, {
@@ -327,10 +281,9 @@ export default defineCommand({
format,
workers: workerCount,
gpu: useGpu,
hdr: args.hdr ?? false,
quiet,
browserPath,
crf,
videoBitrate,
});
}
},
@@ -342,10 +295,9 @@ interface RenderOptions {
format: "mp4" | "webm" | "mov";
workers: number;
gpu: boolean;
hdr: boolean;
quiet: boolean;
browserPath?: string;
crf?: number;
videoBitrate?: string;
}
const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
@@ -480,8 +432,6 @@ async function renderDocker(
String(options.workers),
...(options.quiet ? ["--quiet"] : []),
...(options.gpu ? ["--gpu"] : []),
...(options.crf != null ? ["--crf", String(options.crf)] : []),
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
];
if (!options.quiet) {
@@ -543,8 +493,7 @@ async function renderLocal(
format: options.format,
workers: options.workers,
useGpu: options.gpu,
crf: options.crf,
videoBitrate: options.videoBitrate,
hdr: options.hdr,
});
const onProgress = options.quiet
+2
View File
@@ -82,6 +82,8 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
for (const tag of tags) {
if (tag.name === "video" || tag.name === "audio") continue;
// Skip the composition root — it uses data-start as a playback anchor, not as a clip timer
if (readAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-start")) {
timedTagPositions.push({
name: tag.name,
+17
View File
@@ -34,6 +34,7 @@
export type {
HfProtocol,
HfMediaElement,
HfTransitionMeta,
CaptureOptions,
CaptureResult,
CaptureBufferResult,
@@ -79,6 +80,9 @@ export {
cdpSessionCache,
initTransparentBackground,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
DOM_LAYER_MASK_STYLE_ID,
type BeginFrameResult,
} from "./services/screenshotService.js";
@@ -155,6 +159,7 @@ export {
} from "./utils/ffprobe.js";
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
export { runFfmpeg, type RunFfmpegOptions, type RunFfmpegResult } from "./utils/runFfmpeg.js";
export {
decodePng,
@@ -164,10 +169,22 @@ export {
blitRgb48leAffine,
parseTransformMatrix,
getSrgbToHdrLut,
roundedRectAlpha,
} from "./utils/alphaBlit.js";
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
// ── Shader transitions ────────────────────────────────────────────────────────
export {
type TransitionFn,
TRANSITIONS,
crossfade,
sampleRgb48le,
hdrToLinear,
linearToHdr,
convertTransfer,
} from "./utils/shaderTransitions.js";
export {
initHdrReadback,
uploadAndReadbackHdrFrame,
@@ -181,12 +181,34 @@ export async function captureScreenshotWithAlpha(
* Only use on sessions that are exclusively dedicated to transparent capture
* (e.g., the HDR two-pass DOM layer session) — the background will stay
* transparent for the lifetime of the session.
*
* NOTE on the injected stylesheet: `Emulation.setDefaultBackgroundColorOverride`
* only replaces the *default* page background. Compositions almost always set
* `body { background: ... }` and `#root { background: ... }`, which paint over
* the override and ruin alpha capture for layered HDR compositing — the
* composition root's full-frame background paints across the entire viewport
* and wipes out HDR content captured beneath it.
*
* We force `html`, `body`, and any element marked as a composition root
* (`[data-composition-id]`) to transparent. In HDR layered compositing the HDR
* video itself is the backdrop, so DOM layers must only contribute their
* foreground UI pixels — never a page-spanning solid backdrop.
*/
export const TRANSPARENT_BG_STYLE_ID = "__hf_transparent_bg__";
export async function initTransparentBackground(page: Page): Promise<void> {
const client = await getCdpSession(page);
await client.send("Emulation.setDefaultBackgroundColorOverride", {
color: { r: 0, g: 0, b: 0, a: 0 },
});
await page.evaluate((styleId: string) => {
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent =
"html,body,[data-composition-id]{background:transparent !important;background-color:transparent !important;background-image:none !important;}";
document.head.appendChild(style);
}, TRANSPARENT_BG_STYLE_ID);
}
/**
@@ -209,6 +231,135 @@ export async function captureAlphaPng(page: Page, width: number, height: number)
return Buffer.from(result.data, "base64");
}
/**
* Stylesheet ID used by applyDomLayerMask / removeDomLayerMask. Exposed so
* tests can assert presence/absence of the mask between captures.
*/
export const DOM_LAYER_MASK_STYLE_ID = "__hf_dom_layer_mask__";
/**
* Mask the DOM so a single layer screenshot captures ONLY the layer's pixels.
*
* The HDR layered compositor walks z-ordered layers and blits each one over a
* shared canvas. DOM layers are full-page screenshots — a naive screenshot
* captures every painted pixel on the page, which means root background +
* static overlays + sibling-scene content all overwrite previously composited
* HDR content beneath. The mask narrows each screenshot to the elements that
* actually belong to this layer.
*
* Strategy:
*
* 1. Inject a stylesheet that hides every body descendant
* (`body * { visibility: hidden !important }`) and re-shows the layer's
* elements (and their descendants and their injected `__render_frame_*`
* siblings) via `visibility: visible !important`. CSS `visibility: visible`
* on a descendant overrides an ancestor's `visibility: hidden`, so deep
* layer elements remain visible even though intermediate parents are
* hidden by the mass-hide rule.
* 2. Inline-hide each `extraHideId` (and its `__render_frame_*` sibling) with
* `visibility: hidden !important`. Inline `!important` beats stylesheet
* `!important`, so this overrides the show rule for elements that fall
* under a show selector but should NOT paint — typically other-layer
* elements that are descendants of a container layer (for example HDR
* videos and other-layer SDR videos are descendants of `#root` when we
* capture the root DOM layer).
*
* Only `visibility` is set on extraHideIds — never `opacity`. CSS opacity is
* multiplicative through the descendant chain and a descendant cannot escape
* an ancestor's `opacity: 0`. If `#root` is in `extraHideIds` and we set
* `opacity: 0` on it, every descendant — including `#vid-5-b` and its
* `__render_frame_vid-5-b__` IMG — becomes invisible even with
* `visibility: visible !important`. `visibility` does NOT have this problem:
* a descendant with `visibility: visible` overrides an ancestor's
* `visibility: hidden`.
*
* Layout is preserved (visibility doesn't trigger reflow), so border-radius
* clipping, overflow:hidden, and absolute positioning continue to apply to
* the visible layer elements. Opacity is also preserved — an ancestor at
* `opacity: 0` (e.g. an inactive scene during a transition) still
* propagates to its descendants, which is the desired behavior during
* cross-scene blends.
*
* Idempotent across calls: an existing mask stylesheet is removed before a
* new one is installed, so consecutive `applyDomLayerMask` invocations leave
* exactly one stylesheet attached.
*/
export async function applyDomLayerMask(
page: Page,
showIds: string[],
extraHideIds: string[],
): Promise<void> {
await page.evaluate(
(args: { show: string[]; hide: string[]; styleId: string }) => {
const existing = document.getElementById(args.styleId);
if (existing) existing.remove();
const showSelectors: string[] = [];
for (const id of args.show) {
const escaped = CSS.escape(id);
showSelectors.push(`#${escaped}`, `#${escaped} *`);
const renderEscaped = CSS.escape(`__render_frame_${id}__`);
showSelectors.push(`#${renderEscaped}`, `#${renderEscaped} *`);
}
const massHideRule = "body *{visibility:hidden !important;}";
const showRule =
showSelectors.length === 0
? ""
: `${showSelectors.join(",")}{visibility:visible !important;}`;
const style = document.createElement("style");
style.id = args.styleId;
style.textContent = `${massHideRule}\n${showRule}`;
document.head.appendChild(style);
for (const id of args.hide) {
const el = document.getElementById(id);
if (el) {
el.style.setProperty("visibility", "hidden", "important");
}
const img = document.getElementById(`__render_frame_${id}__`);
if (img) {
img.style.setProperty("visibility", "hidden", "important");
}
}
},
{ show: showIds, hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
);
}
/**
* Tear down the mask installed by applyDomLayerMask.
*
* Removes the mask stylesheet and clears the inline `visibility` properties
* set on `extraHideIds` (and their `__render_frame_*` siblings).
*
* IMPORTANT: We do NOT strip inline `opacity` here. applyDomLayerMask only
* ever sets `visibility` (never `opacity`), so any inline opacity present on
* a wrapper was put there by user animation code (typically GSAP) and must
* survive across per-layer captures. GSAP's seek with suppress-events does
* not re-apply tweens when the timeline is already at the target time, so if
* we strip opacity here and then seek to the same time for the next layer,
* GSAP won't put it back and the wrapper will render fully opaque.
*/
export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Promise<void> {
await page.evaluate(
(args: { hide: string[]; styleId: string }) => {
const style = document.getElementById(args.styleId);
if (style) style.remove();
for (const id of args.hide) {
const el = document.getElementById(id);
if (el) {
el.style.removeProperty("visibility");
}
const img = document.getElementById(`__render_frame_${id}__`);
if (img) img.style.removeProperty("visibility");
}
},
{ hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
);
}
export async function injectVideoFramesBatch(
page: Page,
updates: Array<{ videoId: string; dataUri: string }>,
@@ -224,6 +375,16 @@ export async function injectVideoFramesBatch(
let img = video.nextElementSibling as HTMLImageElement | null;
const isNewImage = !img || !img.classList.contains("__render_frame__");
const computedStyle = window.getComputedStyle(video);
// GSAP seeks re-apply tween values during an active tween, but do not
// re-apply tweens that have already completed. After an opacity fade-in
// finishes, GSAP's last set value is overwritten on subsequent frames
// by the `opacity: 0 !important` we apply at the bottom of this
// function to hide the native <video>. That leaves `computedOpacity`
// stuck at 0 even though the user's intent is opacity 1 (the tween's
// end state). The `|| 1` fallback treats computedOpacity === 0 as a
// hidden-native-video artifact and recovers opacity 1, matching the
// final on-screen state for the vast majority of compositions.
// For active tweens in the [0,1] exclusive range this is a no-op.
const computedOpacity = parseFloat(computedStyle.opacity) || 1;
const sourceIsStatic = !computedStyle.position || computedStyle.position === "static";
@@ -260,6 +421,14 @@ export async function injectVideoFramesBatch(
img.style.zIndex = computedStyle.zIndex;
for (const property of visualProperties) {
// Opacity is handled explicitly via `computedOpacity` below — copying
// via the generic loop would race against the opacity:0 hide applied
// to the <video> at the end of this function. GSAP may animate
// opacity either on a wrapper (the <img> inherits via the stacking
// context) or directly on the <video> (we must copy it to the <img>
// since they are siblings). Reading computedStyle.opacity before
// hiding the <video> handles both cases correctly.
if (property === "opacity") continue;
if (
sourceIsStatic &&
(property === "top" ||
+34
View File
@@ -31,6 +31,29 @@ export interface HfMediaElement {
hasAudio?: boolean;
}
/**
* Metadata for a shader transition between two scenes.
*
* Compositions using @hyperframes/shader-transitions populate
* `window.__hf.transitions` with one entry per transition so the
* producer can pre-compute scene ranges, capture per-scene buffers,
* and apply the transition in HDR-aware compositing.
*/
export interface HfTransitionMeta {
/** Time the transition starts (seconds) */
time: number;
/** Transition duration (seconds) */
duration: number;
/** Shader identifier (e.g. "fade", "wipe") */
shader: string;
/** GSAP easing string (e.g. "power2.inOut") */
ease: string;
/** Scene id the transition starts from */
fromScene: string;
/** Scene id the transition ends on */
toScene: string;
}
/**
* The seek protocol. The only contract between the engine and a page.
*
@@ -42,6 +65,15 @@ export interface HfMediaElement {
* GSAP, Framer Motion, CSS animations, Three.js anything works as long
* as `seek()` produces deterministic visual output for a given time.
*/
export interface HfTransitionMeta {
time: number;
duration: number;
shader: string;
ease: string;
fromScene: string;
toScene: string;
}
export interface HfProtocol {
/** Total duration of the composition in seconds */
duration: number;
@@ -49,6 +81,8 @@ export interface HfProtocol {
seek(time: number): void;
/** Optional: media elements the engine should handle */
media?: HfMediaElement[];
/** Optional: shader transition metadata, populated by @hyperframes/shader-transitions */
transitions?: HfTransitionMeta[];
}
// ── Capture Types ──────────────────────────────────────────────────────────────
@@ -0,0 +1,674 @@
import { describe, expect, it } from "vitest";
import {
sampleRgb48le,
mix16,
clamp16,
smoothstep,
hash,
vnoise,
fbm,
crossfade,
flashThroughWhite,
hdrToLinear,
linearToHdr,
convertTransfer,
TRANSITIONS,
type TransitionFn,
} from "./shaderTransitions.js";
// ── sampleRgb48le ─────────────────────────────────────────────────────────────
describe("sampleRgb48le", () => {
it("samples center pixel of a uniform 1x1 buffer", () => {
const buf = Buffer.alloc(6);
buf.writeUInt16LE(10000, 0); // R
buf.writeUInt16LE(20000, 2); // G
buf.writeUInt16LE(30000, 4); // B
const [r, g, b] = sampleRgb48le(buf, 0.5, 0.5, 1, 1);
expect(r).toBe(10000);
expect(g).toBe(20000);
expect(b).toBe(30000);
});
it("clamps out-of-bounds UV below 0 to first pixel", () => {
const buf = Buffer.alloc(6);
buf.writeUInt16LE(5000, 0);
buf.writeUInt16LE(6000, 2);
buf.writeUInt16LE(7000, 4);
const [r, g, b] = sampleRgb48le(buf, -0.5, -0.5, 1, 1);
expect(r).toBe(5000);
expect(g).toBe(6000);
expect(b).toBe(7000);
});
it("clamps out-of-bounds UV above 1 to last pixel", () => {
const buf = Buffer.alloc(6);
buf.writeUInt16LE(5000, 0);
buf.writeUInt16LE(6000, 2);
buf.writeUInt16LE(7000, 4);
const [r, g, b] = sampleRgb48le(buf, 1.5, 1.5, 1, 1);
expect(r).toBe(5000);
expect(g).toBe(6000);
expect(b).toBe(7000);
});
it("bilinearly interpolates between two horizontally adjacent pixels", () => {
// 2x1 buffer: pixel 0 = (0,0,0), pixel 1 = (65534,65534,65534)
const buf = Buffer.alloc(12);
buf.writeUInt16LE(0, 0);
buf.writeUInt16LE(0, 2);
buf.writeUInt16LE(0, 4);
buf.writeUInt16LE(65534, 6);
buf.writeUInt16LE(65534, 8);
buf.writeUInt16LE(65534, 10);
// u=0.5, w=2 → x = 0.5*(2-1) = 0.5 → equal blend of pixels 0 and 1
const [r, g, b] = sampleRgb48le(buf, 0.5, 0, 2, 1);
expect(r).toBe(32767);
expect(g).toBe(32767);
expect(b).toBe(32767);
});
it("samples from exact pixel 0 at u=0", () => {
const buf = Buffer.alloc(12);
buf.writeUInt16LE(1000, 0);
buf.writeUInt16LE(2000, 2);
buf.writeUInt16LE(3000, 4);
buf.writeUInt16LE(60000, 6);
buf.writeUInt16LE(60000, 8);
buf.writeUInt16LE(60000, 10);
const [r, g, b] = sampleRgb48le(buf, 0, 0, 2, 1);
expect(r).toBe(1000);
expect(g).toBe(2000);
expect(b).toBe(3000);
});
// ── Wider coverage for the perf-migration follow-up ────────────────────────
// These pin down sub-pixel sampling semantics so a future Uint16Array
// implementation can swap in and verify byte-equivalent output.
it("bilinearly interpolates between two vertically adjacent pixels", () => {
// 1x2 buffer: row 0 = (0,0,0), row 1 = (40000,40000,40000)
const buf = Buffer.alloc(12);
buf.writeUInt16LE(0, 0);
buf.writeUInt16LE(0, 2);
buf.writeUInt16LE(0, 4);
buf.writeUInt16LE(40000, 6);
buf.writeUInt16LE(40000, 8);
buf.writeUInt16LE(40000, 10);
const [r, g, b] = sampleRgb48le(buf, 0, 0.5, 1, 2);
expect(r).toBe(20000);
expect(g).toBe(20000);
expect(b).toBe(20000);
});
it("bilinearly interpolates the centroid of a 2x2 block", () => {
// Layout (R channel only, others mirror):
// (1000) (5000)
// (3000) (7000)
// Centroid (u=v=0.5) → average of all four = 4000
const buf = Buffer.alloc(24);
const corners = [1000, 5000, 3000, 7000];
for (let i = 0; i < 4; i++) {
const off = i * 6;
buf.writeUInt16LE(corners[i] ?? 0, off);
buf.writeUInt16LE(corners[i] ?? 0, off + 2);
buf.writeUInt16LE(corners[i] ?? 0, off + 4);
}
const [r, g, b] = sampleRgb48le(buf, 0.5, 0.5, 2, 2);
expect(r).toBe(4000);
expect(g).toBe(4000);
expect(b).toBe(4000);
});
it("does not bleed channels — R, G, B sampled independently", () => {
// 2x1 buffer with distinct per-channel gradients.
// pixel 0: R=1000 G=20000 B=50000
// pixel 1: R=9000 G=30000 B=60000
const buf = Buffer.alloc(12);
buf.writeUInt16LE(1000, 0);
buf.writeUInt16LE(20000, 2);
buf.writeUInt16LE(50000, 4);
buf.writeUInt16LE(9000, 6);
buf.writeUInt16LE(30000, 8);
buf.writeUInt16LE(60000, 10);
const [r, g, b] = sampleRgb48le(buf, 0.5, 0, 2, 1);
expect(r).toBe(5000);
expect(g).toBe(25000);
expect(b).toBe(55000);
});
it("samples last pixel exactly when u=v=1 (no overflow past the edge)", () => {
// 2x2 buffer where (1,1) corner has a unique value the first three pixels
// do not. If sampleRgb48le tried to read off-edge, the result would mix in
// out-of-bounds garbage.
const buf = Buffer.alloc(24);
const fill = [10, 20, 30, 65000];
for (let i = 0; i < 4; i++) {
const off = i * 6;
buf.writeUInt16LE(fill[i] ?? 0, off);
buf.writeUInt16LE(fill[i] ?? 0, off + 2);
buf.writeUInt16LE(fill[i] ?? 0, off + 4);
}
const [r, g, b] = sampleRgb48le(buf, 1, 1, 2, 2);
expect(r).toBe(65000);
expect(g).toBe(65000);
expect(b).toBe(65000);
});
it("respects asymmetric off-center UV weights", () => {
// 2x1 buffer, R-only differentiation: pixel 0 = 0, pixel 1 = 10000
// u=0.25 → x = 0.25 * (2 - 1) = 0.25
// weight on pixel 0 = 0.75, weight on pixel 1 = 0.25
// expected R = round(0 * 0.75 + 10000 * 0.25) = 2500
const buf = Buffer.alloc(12);
buf.writeUInt16LE(0, 0);
buf.writeUInt16LE(0, 2);
buf.writeUInt16LE(0, 4);
buf.writeUInt16LE(10000, 6);
buf.writeUInt16LE(10000, 8);
buf.writeUInt16LE(10000, 10);
const [r, g, b] = sampleRgb48le(buf, 0.25, 0, 2, 1);
expect(r).toBe(2500);
expect(g).toBe(2500);
expect(b).toBe(2500);
});
it("preserves max 16-bit values without clipping or rollover", () => {
// Verify the 65535 ceiling round-trips through bilinear weights without
// losing precision. A naïve 32-bit accumulator would still be fine here,
// but a future packed-Uint16 implementation must be checked for overflow
// in intermediate sums.
const buf = Buffer.alloc(24);
for (let i = 0; i < 4; i++) {
const off = i * 6;
buf.writeUInt16LE(65535, off);
buf.writeUInt16LE(65535, off + 2);
buf.writeUInt16LE(65535, off + 4);
}
const [r, g, b] = sampleRgb48le(buf, 0.5, 0.5, 2, 2);
expect(r).toBe(65535);
expect(g).toBe(65535);
expect(b).toBe(65535);
});
it("works on a large 256x256 canvas with sub-pixel UV", () => {
// Sanity check that buffer offset math scales — exercises the (y * w + x) * 6
// indexing on a non-trivial stride.
const w = 256;
const h = 256;
const buf = Buffer.alloc(w * h * 6);
// Diagonal gradient in R: pixel (x, y).R = x
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const off = (y * w + x) * 6;
buf.writeUInt16LE(x, off);
}
}
// u = 0.5 → sx = 0.5 * (w - 1) = 127.5 → R should be ≈ 127.5 → rounds to 128
const [r] = sampleRgb48le(buf, 0.5, 0.5, w, h);
expect(r).toBe(128);
});
it("handles 1x1 source with arbitrary UV (no x1=x0 division by zero)", () => {
// x0+1 gets clamped back to w-1=0, so x0 == x1. The weights still sum to 1
// and the result must equal the single pixel value.
const buf = Buffer.alloc(6);
buf.writeUInt16LE(12345, 0);
buf.writeUInt16LE(23456, 2);
buf.writeUInt16LE(34567, 4);
for (const [u, v] of [
[0, 0],
[0.5, 0.5],
[1, 1],
[0.7, 0.3],
] as const) {
const [r, g, b] = sampleRgb48le(buf, u, v, 1, 1);
expect(r).toBe(12345);
expect(g).toBe(23456);
expect(b).toBe(34567);
}
});
});
// ── mix16 ─────────────────────────────────────────────────────────────────────
describe("mix16", () => {
it("returns a at t=0", () => {
expect(mix16(1000, 60000, 0)).toBe(1000);
});
it("returns b at t=1", () => {
expect(mix16(1000, 60000, 1)).toBe(60000);
});
it("returns midpoint at t=0.5", () => {
expect(mix16(0, 60000, 0.5)).toBe(30000);
});
it("returns rounded result for non-integer midpoints", () => {
// 0 * 0.5 + 1 * 0.5 = 0.5 → rounds to 1
expect(mix16(0, 1, 0.5)).toBe(1);
});
});
// ── clamp16 ───────────────────────────────────────────────────────────────────
describe("clamp16", () => {
it("clamps negative to 0", () => {
expect(clamp16(-100)).toBe(0);
});
it("clamps overflow to 65535", () => {
expect(clamp16(70000)).toBe(65535);
});
it("passes normal values through", () => {
expect(clamp16(32768)).toBe(32768);
});
it("passes boundary values through", () => {
expect(clamp16(0)).toBe(0);
expect(clamp16(65535)).toBe(65535);
});
});
// ── smoothstep ────────────────────────────────────────────────────────────────
describe("smoothstep", () => {
it("returns 0 when x <= edge0", () => {
expect(smoothstep(0.2, 0.8, 0.1)).toBe(0);
expect(smoothstep(0.2, 0.8, 0.2)).toBe(0);
});
it("returns 1 when x >= edge1", () => {
expect(smoothstep(0.2, 0.8, 0.9)).toBe(1);
expect(smoothstep(0.2, 0.8, 0.8)).toBe(1);
});
it("returns ~0.5 at midpoint between edge0 and edge1", () => {
// t = (0.5 - 0.2) / (0.8 - 0.2) = 0.5; hermite(0.5) = 0.5*0.5*(3-2*0.5) = 0.5
expect(smoothstep(0.2, 0.8, 0.5)).toBeCloseTo(0.5, 10);
});
it("is monotonically increasing", () => {
const vals = [0.3, 0.4, 0.5, 0.6, 0.7].map((x) => smoothstep(0.2, 0.8, x));
for (let i = 1; i < vals.length; i++) {
expect(vals[i]).toBeGreaterThan(vals[i - 1] ?? 0);
}
});
});
// ── hash ──────────────────────────────────────────────────────────────────────
describe("hash", () => {
it("returns a value in [0, 1)", () => {
const h = hash(1.5, 2.7);
expect(h).toBeGreaterThanOrEqual(0);
expect(h).toBeLessThan(1);
});
it("is deterministic for the same inputs", () => {
expect(hash(3.14, 2.71)).toBe(hash(3.14, 2.71));
});
it("returns different values for different inputs", () => {
expect(hash(0, 0)).not.toBe(hash(1, 0));
expect(hash(0, 0)).not.toBe(hash(0, 1));
});
it("returns values in [0,1) for integer grid points", () => {
for (let i = 0; i < 5; i++) {
for (let j = 0; j < 5; j++) {
const h = hash(i, j);
expect(h).toBeGreaterThanOrEqual(0);
expect(h).toBeLessThan(1);
}
}
});
});
// ── vnoise ────────────────────────────────────────────────────────────────────
describe("vnoise", () => {
it("returns a value in [0, 1]", () => {
const v = vnoise(1.5, 2.3);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
});
it("is deterministic for the same inputs", () => {
expect(vnoise(3.14, 2.71)).toBe(vnoise(3.14, 2.71));
});
it("returns [0,1] range over a grid", () => {
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
const v = vnoise(i * 0.7, j * 0.7);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
}
});
it("produces variation across the domain (not constant)", () => {
const values = new Set([
vnoise(0, 0),
vnoise(1, 0),
vnoise(0, 1),
vnoise(1, 1),
vnoise(0.5, 0.5),
]);
// At least 2 distinct values among 5 samples
expect(values.size).toBeGreaterThan(1);
});
});
// ── fbm ───────────────────────────────────────────────────────────────────────
describe("fbm", () => {
it("is deterministic for the same inputs", () => {
expect(fbm(1.5, 2.3)).toBe(fbm(1.5, 2.3));
});
it("returns consistent known values", () => {
const v0 = fbm(0, 0);
const v1 = fbm(1, 0);
const v2 = fbm(0, 1);
// All should be finite numbers (not NaN/Infinity)
expect(Number.isFinite(v0)).toBe(true);
expect(Number.isFinite(v1)).toBe(true);
expect(Number.isFinite(v2)).toBe(true);
// Should produce different values for different inputs
expect(v0).not.toBe(v1);
expect(v0).not.toBe(v2);
});
it("produces values in a reasonable range", () => {
// fbm sums 5 octaves of vnoise (01) with amplitudes 0.5,0.25,0.125,0.0625,0.03125
// max possible ≈ 0.96875; values should be positive
const v = fbm(2.5, 3.7);
expect(v).toBeGreaterThan(0);
expect(v).toBeLessThan(1.1);
});
});
// ── transition helpers ────────────────────────────────────────────────────────
function makeBuffer(w: number, h: number, r: number, g: number, b: number): Buffer {
const buf = Buffer.alloc(w * h * 6);
for (let i = 0; i < w * h; i++) {
buf.writeUInt16LE(r, i * 6);
buf.writeUInt16LE(g, i * 6 + 2);
buf.writeUInt16LE(b, i * 6 + 4);
}
return buf;
}
function runTransition(
fn: TransitionFn,
w: number,
h: number,
fR: number,
fG: number,
fB: number,
tR: number,
tG: number,
tB: number,
progress: number,
): Buffer {
const from = makeBuffer(w, h, fR, fG, fB);
const to = makeBuffer(w, h, tR, tG, tB);
const out = Buffer.alloc(w * h * 6);
fn(from, to, out, w, h, progress);
return out;
}
// ── crossfade ─────────────────────────────────────────────────────────────────
describe("crossfade", () => {
it("at progress=0 output equals from", () => {
const out = runTransition(crossfade, 2, 2, 10000, 20000, 30000, 50000, 55000, 60000, 0);
for (let i = 0; i < 4; i++) {
expect(out.readUInt16LE(i * 6)).toBe(10000);
expect(out.readUInt16LE(i * 6 + 2)).toBe(20000);
expect(out.readUInt16LE(i * 6 + 4)).toBe(30000);
}
});
it("at progress=1 output equals to", () => {
const out = runTransition(crossfade, 2, 2, 10000, 20000, 30000, 50000, 55000, 60000, 1);
for (let i = 0; i < 4; i++) {
expect(out.readUInt16LE(i * 6)).toBe(50000);
expect(out.readUInt16LE(i * 6 + 2)).toBe(55000);
expect(out.readUInt16LE(i * 6 + 4)).toBe(60000);
}
});
it("at progress=0.5 output is midpoint of from and to", () => {
const out = runTransition(crossfade, 1, 1, 0, 0, 0, 60000, 60000, 60000, 0.5);
expect(out.readUInt16LE(0)).toBe(30000);
expect(out.readUInt16LE(2)).toBe(30000);
expect(out.readUInt16LE(4)).toBe(30000);
});
it("is registered in TRANSITIONS", () => {
expect(TRANSITIONS["crossfade"]).toBe(crossfade);
});
});
// ── flashThroughWhite ─────────────────────────────────────────────────────────
describe("flashThroughWhite", () => {
it("at progress=0 output approximates from", () => {
// toWhite = smoothstep(0,0.45,0) = 0, fromWhite = 1-smoothstep(0.5,1,0) = 1,
// blend = smoothstep(0.35,0.65,0) = 0 → output = fromC = from (untouched)
const out = runTransition(flashThroughWhite, 1, 1, 10000, 20000, 30000, 50000, 55000, 60000, 0);
expect(out.readUInt16LE(0)).toBe(10000);
expect(out.readUInt16LE(2)).toBe(20000);
expect(out.readUInt16LE(4)).toBe(30000);
});
it("at progress≈0.45 all channels are near white (>50000)", () => {
// toWhite = smoothstep(0,0.45,0.45) = 1 → fromC = white
// fromWhite = 1-smoothstep(0.5,1,0.45) = 1 → toC = white
// both inputs to blend are white → output is white
const out = runTransition(
flashThroughWhite,
1,
1,
10000,
20000,
30000,
50000,
55000,
60000,
0.45,
);
expect(out.readUInt16LE(0)).toBeGreaterThan(50000);
expect(out.readUInt16LE(2)).toBeGreaterThan(50000);
expect(out.readUInt16LE(4)).toBeGreaterThan(50000);
});
it("at progress=1 output approximates to", () => {
// toWhite = smoothstep(0,0.45,1) = 1, fromWhite = 1-smoothstep(0.5,1,1) = 0,
// blend = smoothstep(0.35,0.65,1) = 1 → output = toC = to (untouched)
const out = runTransition(flashThroughWhite, 1, 1, 10000, 20000, 30000, 50000, 55000, 60000, 1);
expect(out.readUInt16LE(0)).toBe(50000);
expect(out.readUInt16LE(2)).toBe(55000);
expect(out.readUInt16LE(4)).toBe(60000);
});
it("is registered in TRANSITIONS", () => {
expect(TRANSITIONS["flash-through-white"]).toBe(flashThroughWhite);
});
});
// ── all transitions smoke test ────────────────────────────────────────────────
const ALL_SHADERS = [
"crossfade",
"flash-through-white",
"chromatic-split",
"sdf-iris",
"whip-pan",
"cinematic-zoom",
"gravitational-lens",
"glitch",
"ripple-waves",
"swirl-vortex",
"thermal-distortion",
"domain-warp",
"ridged-burn",
"cross-warp-morph",
"light-leak",
];
// Pixel offset selector for the "at progress=0, center pixel ≈ from" test.
// Most transitions use the center pixel (4*8+4). Two shaders require a
// different test pixel because their design does not produce `from` at the
// center when p=0:
// sdf-iris: the iris reveal shows `to` inside and `from` outside.
// At any p>0 the center is inside → shows `to`. We use
// a corner pixel (row 0, col 0) that stays outside the
// iris until p is large.
// gravitational-lens: at p=0 the horizon mask is 0 at center (dist=0),
// producing black. A corner pixel (dist≈0.7) has
// horizon > 0 and shows a lensed version of `from`.
const P0_PIXEL: Record<string, number> = {
"sdf-iris": 0 * 6, // top-left corner: always outside iris at p=0
"gravitational-lens": (0 * 8 + 0) * 6, // top-left corner: non-zero dist
};
describe("all transitions smoke test", () => {
for (const name of ALL_SHADERS) {
describe(name, () => {
it("exists in registry", () => {
expect(TRANSITIONS[name]).toBeDefined();
});
it("at progress=0, center pixel ≈ from", () => {
const from = makeBuffer(8, 8, 40000, 30000, 20000);
const to = makeBuffer(8, 8, 10000, 10000, 10000);
const out = Buffer.alloc(8 * 8 * 6);
const fn = TRANSITIONS[name];
expect(fn).toBeDefined();
fn?.(from, to, out, 8, 8, 0);
const o = P0_PIXEL[name] ?? (4 * 8 + 4) * 6;
// At progress=0 the result should be the `from` pixel (R=40000).
// The midpoint between from (40000) and to (10000) is 25000, so a
// tighter threshold catches transitions that are halfway-blended
// when they should be fully on the `from` side.
expect(out.readUInt16LE(o)).toBeGreaterThan(35000);
});
it("at progress=1, center pixel ≈ to", () => {
const from = makeBuffer(8, 8, 40000, 30000, 20000);
const to = makeBuffer(8, 8, 10000, 10000, 10000);
const out = Buffer.alloc(8 * 8 * 6);
const fn = TRANSITIONS[name];
expect(fn).toBeDefined();
fn?.(from, to, out, 8, 8, 1);
const o = (4 * 8 + 4) * 6;
// At progress=1 the result should be the `to` pixel (R=10000).
// Tighter than the previous halfway midpoint (25000) so that any
// transition that is still half-blended will fail.
expect(out.readUInt16LE(o)).toBeLessThan(15000);
});
});
}
});
// ── hdrToLinear / linearToHdr roundtrip ────────────────────────────────────
describe("hdrToLinear / linearToHdr", () => {
for (const transfer of ["pq", "hlg"] as const) {
describe(transfer, () => {
it("roundtrip preserves mid-to-high values", () => {
// PQ concentrates precision in the dark range — linearizing then
// re-quantizing at 16-bit loses bits for values below ~16000.
// HLG squares small inputs, similar effect. Test the mid-to-high
// range where roundtrip error is bounded.
const values = [16384, 32768, 50000, 65535];
const buf = Buffer.alloc(values.length * 2);
for (let i = 0; i < values.length; i++) {
buf.writeUInt16LE(values[i] ?? 0, i * 2);
}
const original = Buffer.from(buf);
hdrToLinear(buf, transfer);
linearToHdr(buf, transfer);
for (let i = 0; i < values.length; i++) {
const got = buf.readUInt16LE(i * 2);
const want = original.readUInt16LE(i * 2);
expect(Math.abs(got - want)).toBeLessThanOrEqual(30);
}
});
it("zero maps to zero", () => {
const buf = Buffer.alloc(2);
buf.writeUInt16LE(0, 0);
hdrToLinear(buf, transfer);
expect(buf.readUInt16LE(0)).toBe(0);
});
it("65535 maps to 65535", () => {
const buf = Buffer.alloc(2);
buf.writeUInt16LE(65535, 0);
hdrToLinear(buf, transfer);
linearToHdr(buf, transfer);
expect(buf.readUInt16LE(0)).toBe(65535);
});
it("hdrToLinear produces monotonically increasing output", () => {
const steps = [0, 1000, 5000, 10000, 20000, 40000, 65535];
const buf = Buffer.alloc(steps.length * 2);
for (let i = 0; i < steps.length; i++) {
buf.writeUInt16LE(steps[i] ?? 0, i * 2);
}
hdrToLinear(buf, transfer);
let prev = 0;
for (let i = 0; i < steps.length; i++) {
const val = buf.readUInt16LE(i * 2);
expect(val).toBeGreaterThanOrEqual(prev);
prev = val;
}
});
});
}
});
// ── convertTransfer (HLG↔PQ) ─────────────────────────────────────────────
describe("convertTransfer", () => {
it("no-op when from === to", () => {
const buf = Buffer.alloc(6);
buf.writeUInt16LE(32768, 0);
buf.writeUInt16LE(16384, 2);
buf.writeUInt16LE(8192, 4);
const original = Buffer.from(buf);
convertTransfer(buf, "pq", "pq");
expect(buf.equals(original)).toBe(true);
});
it("hlg→pq→hlg roundtrip preserves mid-high values", () => {
const values = [16384, 32768, 50000, 65535];
const buf = Buffer.alloc(values.length * 2);
for (let i = 0; i < values.length; i++) {
buf.writeUInt16LE(values[i] ?? 0, i * 2);
}
const original = Buffer.from(buf);
convertTransfer(buf, "hlg", "pq");
expect(buf.equals(original)).toBe(false);
convertTransfer(buf, "pq", "hlg");
for (let i = 0; i < values.length; i++) {
const got = buf.readUInt16LE(i * 2);
const want = original.readUInt16LE(i * 2);
expect(Math.abs(got - want)).toBeLessThanOrEqual(30);
}
});
it("hlg→pq produces different values", () => {
const buf = Buffer.alloc(2);
buf.writeUInt16LE(32768, 0);
const before = buf.readUInt16LE(0);
convertTransfer(buf, "hlg", "pq");
expect(buf.readUInt16LE(0)).not.toBe(before);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
/**
* Audit test: Uint16Array vs Buffer.read/writeUInt16LE alignment.
*
* Captures the migration hazard documented in the HDR follow-up plan:
* "Uint16Array over readUInt16LE / writeUInt16LE ~105 touch points in the
* hot path with an alignment-correctness concern (odd byteOffset on sliced
* Buffers throws)."
*
* The hot path in `alphaBlit.ts` and `shaderTransitions.ts` reads/writes 16-bit
* channels via `Buffer.readUInt16LE` / `Buffer.writeUInt16LE`. Those methods
* accept arbitrary byte offsets odd offsets are fine. A future perf PR may
* migrate to `Uint16Array` views for ~2× throughput, but `Uint16Array` requires
* 2-byte alignment of the underlying ArrayBuffer offset. If the source `Buffer`
* was sliced from a parent at an odd byte offset, constructing a `Uint16Array`
* view directly will throw `RangeError`.
*
* These tests pin down the contract so the migration PR can:
* 1. Verify the migration is safe (current sub-buffers always start at even
* byte offsets) see `rgb48le row stride` test.
* 2. Provide a reference safe-wrap pattern when alignment is not guaranteed.
*/
import { describe, expect, it } from "vitest";
describe("Uint16 alignment audit", () => {
describe("Buffer.read/writeUInt16LE — current API", () => {
it("accepts odd byte offsets without throwing", () => {
const buf = Buffer.alloc(8);
buf.writeUInt16LE(0xabcd, 1);
expect(buf.readUInt16LE(1)).toBe(0xabcd);
buf.writeUInt16LE(0x1234, 3);
expect(buf.readUInt16LE(3)).toBe(0x1234);
});
it("preserves values across non-aligned slice round-trips", () => {
const buf = Buffer.alloc(10);
buf.writeUInt16LE(0xdead, 1);
buf.writeUInt16LE(0xbeef, 5);
const sub = buf.subarray(1);
expect(sub.readUInt16LE(0)).toBe(0xdead);
expect(sub.readUInt16LE(4)).toBe(0xbeef);
});
});
describe("Uint16Array — migration hazard", () => {
it("throws RangeError when byteOffset is odd", () => {
const ab = new ArrayBuffer(8);
expect(() => new Uint16Array(ab, 1, 2)).toThrow(RangeError);
expect(() => new Uint16Array(ab, 3, 1)).toThrow(RangeError);
});
it("succeeds when byteOffset is even", () => {
const ab = new ArrayBuffer(8);
expect(() => new Uint16Array(ab, 0, 4)).not.toThrow();
expect(() => new Uint16Array(ab, 2, 3)).not.toThrow();
});
it("a Buffer sliced at an odd offset cannot back a Uint16Array view directly", () => {
const parent = Buffer.alloc(8);
const odd = parent.subarray(1);
expect(odd.byteOffset % 2).toBe(1);
expect(
() => new Uint16Array(odd.buffer, odd.byteOffset, Math.floor(odd.byteLength / 2)),
).toThrow(RangeError);
});
it("safe-wrap pattern: copy to a fresh aligned Buffer when offset is odd", () => {
const parent = Buffer.alloc(8);
parent.writeUInt16LE(0xfeed, 1);
const odd = parent.subarray(1, 3);
// Pattern the migration PR should use when alignment is not guaranteed:
// realign by copying into a freshly allocated Buffer (always page-aligned).
const aligned = odd.byteOffset % 2 === 0 ? odd : Buffer.from(odd);
expect(aligned.byteOffset % 2).toBe(0);
const view = new Uint16Array(
aligned.buffer,
aligned.byteOffset,
Math.floor(aligned.byteLength / 2),
);
expect(view[0]).toBe(0xfeed);
});
});
describe("HDR canvas/row alignment invariants", () => {
it("rgb48le canvas row strides are always even-byte multiples", () => {
// A row of an rgb48le canvas is `width * 6` bytes (3 channels × 2 bytes).
// For any width, the row stride is even, so per-row subarrays inherit
// even byte offsets when sliced from a Buffer whose byteOffset is also
// even (true for `Buffer.alloc(N)`, which is fresh-allocator-aligned).
for (const width of [1, 7, 33, 256, 1920]) {
const stride = width * 6;
expect(stride % 2).toBe(0);
}
});
it("Buffer.alloc canvases produce subarrays with even byte offsets", () => {
// This is the invariant the alphaBlit hot path relies on: as long as the
// working canvas is built with `Buffer.alloc(width * height * 6)`, each
// row subarray (`canvas.subarray(y * stride, (y + 1) * stride)`) starts
// at an even byte offset, so a future Uint16Array migration is safe
// without any realignment copy.
const width = 17; // odd width; stride = 102 (still even)
const height = 4;
const canvas = Buffer.alloc(width * height * 6);
const stride = width * 6;
for (let y = 0; y < height; y++) {
const row = canvas.subarray(y * stride, (y + 1) * stride);
expect(row.byteOffset % 2).toBe(0);
}
});
it("a 3-byte rgb24 stride would NOT be alignment-safe (counter-example)", () => {
// Documents why the rgb48le format is migration-friendly: an rgba8 or
// rgb24 canvas with odd width produces sub-buffers at odd byte offsets.
// If a future PR wants to use Uint16Array views, it must keep the data
// in an even-stride format (rgb48le ✓) or pay for a realignment copy.
const width = 3;
const height = 2;
const rgb24 = Buffer.alloc(width * height * 3); // stride = 9, odd!
const stride = width * 3;
const row1 = rgb24.subarray(stride, stride * 2);
expect(row1.byteOffset % 2).toBe(1);
});
});
});
@@ -32,6 +32,7 @@ import {
type VideoElement,
FrameLookupTable,
type HdrTransfer,
detectTransfer,
createCaptureSession,
initializeSession,
closeCaptureSession,
@@ -58,18 +59,26 @@ import {
type StreamingEncoder,
analyzeCompositionHdr,
isHdrColorSpace,
runFfmpeg,
extractVideoMetadata,
initTransparentBackground,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
decodePng,
decodePngToRgb48le,
blitRgba8OverRgb48le,
blitRgb48leRegion,
hideVideoElements,
showVideoElements,
queryElementStacking,
groupIntoLayers,
blitRgb48leAffine,
parseTransformMatrix,
TRANSITIONS,
crossfade,
convertTransfer,
type TransitionFn,
type ElementStackingInfo,
type HfTransitionMeta,
} from "@hyperframes/engine";
import { join, dirname, resolve } from "path";
import { randomUUID } from "crypto";
@@ -136,6 +145,19 @@ function getMaxFrameIndex(frameDir: string): number {
return max;
}
/**
* Metadata for a shader transition between two scenes, extracted from
* `window.__hf.transitions`. Re-exported from the engine so the producer
* shares the contract with composition runtime code.
*/
type HdrTransitionMeta = HfTransitionMeta;
/** Pre-computed frame range for an active transition. */
interface TransitionRange extends HdrTransitionMeta {
startFrame: number;
endFrame: number;
}
export type RenderStatus =
| "queued"
| "preprocessing"
@@ -164,6 +186,8 @@ export interface RenderConfig {
crf?: number;
/** Target video bitrate (e.g. "10M"). Mutually exclusive with `crf`. */
videoBitrate?: string;
/** Enable HDR color space probing on video/image sources. */
hdr?: boolean;
}
export interface RenderPerfSummary {
@@ -360,6 +384,99 @@ export function applyRenderModeHints(
});
}
/**
* Blit a single HDR video layer onto an rgb48le canvas.
*
* Shared between the normal-frame compositing path (compositeToBuffer)
* and the transition dual-scene compositing loop to avoid duplicating
* the frame lookup, fallback, decode, transform, and blit logic.
*/
function blitHdrVideoLayer(
canvas: Buffer,
el: ElementStackingInfo,
time: number,
fps: number,
hdrFrameDirs: Map<string, string>,
hdrStartTimes: Map<string, number>,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
): void {
const frameDir = hdrFrameDirs.get(el.id);
const startTime = hdrStartTimes.get(el.id);
if (!frameDir || startTime === undefined) {
return;
}
// Frame index within the video (1-based for FFmpeg image2 output).
// Clamp against the highest extracted frame in the directory so that when
// the composition outlives the source clip we freeze on the last frame
// (matching Chrome's <video> behavior) without issuing an O(N) iterative
// existsSync sweep per requested time.
const videoFrameIndex = Math.round((time - startTime) * fps) + 1;
if (videoFrameIndex < 1) return;
const maxIndex = getMaxFrameIndex(frameDir);
const effectiveIndex = maxIndex > 0 ? Math.min(videoFrameIndex, maxIndex) : videoFrameIndex;
const framePath = join(frameDir, `frame_${String(effectiveIndex).padStart(4, "0")}.png`);
if (!existsSync(framePath)) {
return;
}
try {
const { data: hdrRgb, width: srcW, height: srcH } = decodePngToRgb48le(readFileSync(framePath));
// Convert between HDR transfer functions if source doesn't match output
if (sourceTransfer && targetTransfer && sourceTransfer !== targetTransfer) {
convertTransfer(hdrRgb, sourceTransfer, targetTransfer);
}
const viewportMatrix = parseTransformMatrix(el.transform);
// Pass border-radius for rounded-corner masking (only when non-zero)
const br = el.borderRadius;
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
if (viewportMatrix) {
// Use the full viewport transform (handles scale, rotation, translate)
blitRgb48leAffine(
canvas,
hdrRgb,
viewportMatrix,
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else {
// No transform — identity position, use fast region blit
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
}
} catch (err) {
if (log) {
log.debug(`HDR blit failed for ${el.id}`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
export function createRenderJob(config: RenderConfig): RenderJob {
return {
id: randomUUID(),
@@ -700,6 +817,7 @@ export async function executeRenderJob(
job.duration = composition.duration;
job.totalFrames = Math.ceil(composition.duration * job.config.fps);
const totalFrames = job.totalFrames;
if (job.duration <= 0) {
// Gather diagnostics to help users understand why the render would produce a black video.
@@ -777,9 +895,11 @@ export async function executeRenderJob(
// Probe ORIGINAL color spaces before extraction (which may convert SDR→HDR).
// This is needed to identify which videos are natively HDR vs converted-SDR
// for the two-pass compositing path.
// for the two-pass compositing path. Gated by --hdr flag to avoid ffprobe
// overhead on SDR-only compositions.
const nativeHdrVideoIds = new Set<string>();
if (composition.videos.length > 0) {
const videoTransfers = new Map<string, HdrTransfer>();
if (job.config.hdr && composition.videos.length > 0) {
await Promise.all(
composition.videos.map(async (v) => {
let videoPath = v.src;
@@ -793,6 +913,7 @@ export async function executeRenderJob(
const meta = await extractVideoMetadata(videoPath);
if (isHdrColorSpace(meta.colorSpace)) {
nativeHdrVideoIds.add(v.id);
videoTransfers.set(v.id, detectTransfer(meta.colorSpace));
}
}),
);
@@ -839,17 +960,24 @@ export async function executeRenderJob(
}
// ── HDR auto-detection ──────────────────────────────────────────────
// If any extracted video source has an HDR color space, the output
// automatically uses H.265 10-bit with the dominant transfer (PQ if any
// PQ source is present, otherwise HLG). No flag needed.
// When --hdr is set, analyze extracted video metadata AND probed images
// for HDR color spaces. If found, output uses H.265 10-bit with the
// dominant transfer (PQ if any PQ source is present, otherwise HLG).
let effectiveHdr: { transfer: HdrTransfer } | undefined;
if (frameLookup) {
if (job.config.hdr && frameLookup) {
const colorSpaces = (extractionResult?.extracted ?? []).map((ext) => ext.metadata.colorSpace);
const info = analyzeCompositionHdr(colorSpaces);
if (info.hasHdr && info.dominantTransfer) {
effectiveHdr = { transfer: info.dominantTransfer };
}
}
// Also detect HDR from probed images (no extraction result for images)
if (job.config.hdr && !effectiveHdr && nativeHdrVideoIds.size > 0) {
const firstTransfer = videoTransfers.values().next().value;
if (firstTransfer) {
effectiveHdr = { transfer: firstTransfer };
}
}
if (effectiveHdr && outputFormat !== "mp4") {
log.info(`[Render] HDR source detected but format is ${outputFormat} — using SDR`);
effectiveHdr = undefined;
@@ -912,7 +1040,7 @@ export async function executeRenderJob(
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
};
const workerCount = calculateOptimalWorkers(job.totalFrames!, job.config.workers, cfg);
const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
@@ -920,8 +1048,8 @@ export async function executeRenderJob(
// Only use the HDR encoder preset when there's HDR video to pass through.
// For SDR-only compositions, --hdr is a no-op — H.265 10-bit causes browser
// color management issues (orange shift) with no quality benefit.
const hasHdrVideo = effectiveHdr && composition.videos.length > 0 && frameLookup;
const encoderHdr = hasHdrVideo ? effectiveHdr : undefined;
const hasHdrContent = effectiveHdr && nativeHdrVideoIds.size > 0;
const encoderHdr = hasHdrContent ? effectiveHdr : undefined;
const preset = getEncoderPreset(job.config.quality, outputFormat, encoderHdr);
job.framesRendered = 0;
@@ -931,7 +1059,7 @@ export async function executeRenderJob(
// composite bottom-to-top in Node.js memory. HDR layers use native
// pre-extracted HLG pixels; DOM layers use Chrome alpha screenshots
// with sRGB→HLG conversion. Video position/opacity applied via queried bounds.
if (hasHdrVideo) {
if (hasHdrContent) {
log.info("[Render] HDR layered composite: z-ordered DOM + native HLG video layers");
// Use NATIVE HDR IDs (probed before SDR→HDR conversion) so only originally-HDR
@@ -955,10 +1083,12 @@ export async function executeRenderJob(
// Launch headless Chrome for DOM capture.
// Pass the video frame injector so SDR videos are rendered correctly in Chrome.
// HDR videos get injected too but are hidden via hideVideoElements before the
// DOM screenshot — only the native FFmpeg-extracted HLG frames are used for HDR.
// HDR videos get injected too but are masked out via applyDomLayerMask
// before each DOM screenshot — only the native FFmpeg-extracted HLG
// frames are used for HDR pixels.
if (!fileServer) throw new Error("fileServer must be initialized before HDR compositing");
const domSession = await createCaptureSession(
fileServer!.url,
fileServer.url,
framesDir,
captureOptions,
createVideoFrameInjector(frameLookup),
@@ -972,6 +1102,44 @@ export async function executeRenderJob(
// captureAlphaPng() per frame skips the per-frame CDP set/reset overhead.
await initTransparentBackground(domSession.page);
// ── Scene detection for shader transitions ──────────────────────────
// Query the browser for transition metadata written by @hyperframes/shader-transitions
// (window.__hf.transitions) and discover which elements belong to each scene.
const transitionMeta: HdrTransitionMeta[] = await domSession.page.evaluate(() => {
return window.__hf?.transitions ?? [];
});
// Contract: compositions using window.__hf.transitions must wrap each
// scene's elements in a <div class="scene" id="sceneName"> where the id
// matches the fromScene/toScene values declared in the transition metadata.
const sceneElements: Record<string, string[]> = await domSession.page.evaluate(() => {
const scenes = document.querySelectorAll(".scene");
const map: Record<string, string[]> = {};
for (const scene of scenes) {
const els = scene.querySelectorAll("[data-start]");
map[scene.id] = Array.from(els).map((e) => e.id);
}
return map;
});
const transitionRanges: TransitionRange[] = transitionMeta.map((t) => ({
...t,
startFrame: Math.floor(t.time * job.config.fps),
endFrame: Math.ceil((t.time + t.duration) * job.config.fps),
}));
if (transitionRanges.length > 0) {
log.info("[Render] Detected shader transitions for HDR compositing", {
count: transitionRanges.length,
transitions: transitionRanges.map((t) => ({
shader: t.shader,
from: t.fromScene,
to: t.toScene,
frames: `${t.startFrame}-${t.endFrame}`,
})),
});
}
// Spawn HDR streaming encoder accepting raw rgb48le composited frames
const hdrEncoder = await spawnStreamingEncoder(
videoOnlyPath,
@@ -991,23 +1159,41 @@ export async function executeRenderJob(
);
assertNotAborted();
const { execSync } = await import("child_process");
// ── Query initial element bounds for HDR extraction dimensions ──────
// ── Query element bounds for HDR extraction dimensions ────────────
// Extract at each HDR video's display dimensions (not composition dimensions)
// so the source stride matches the blit dimensions. Without this, a 1200x900
// video element would have stride mismatch against a 1920x1080 extraction.
// so the source stride matches the blit dimensions. Elements that aren't
// visible at t=0 (e.g., data-start > 0) need to be queried at their own
// start time so their layout dimensions are available.
const hdrExtractionDims = new Map<string, { width: number; height: number }>();
const hdrVideoStartTimes = new Map<string, number>();
for (const v of composition.videos) {
if (hdrVideoIds.includes(v.id)) {
hdrVideoStartTimes.set(v.id, v.start);
}
}
// Collect unique start times to minimize seek operations
const uniqueStartTimes = [...new Set(hdrVideoStartTimes.values())].sort((a, b) => a - b);
for (const seekTime of uniqueStartTimes) {
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, 0);
}, seekTime);
if (domSession.onBeforeCapture) {
await domSession.onBeforeCapture(domSession.page, 0);
await domSession.onBeforeCapture(domSession.page, seekTime);
}
const stacking = await queryElementStacking(domSession.page, nativeHdrVideoIds);
for (const el of stacking) {
// Use layout dimensions (offsetWidth/offsetHeight) for extraction — these
// are unaffected by CSS transforms (GSAP scale/rotation). getBoundingClientRect
// returns the transformed bounding box which can be wrong for extraction.
if (
el.isHdr &&
el.layoutWidth > 0 &&
el.layoutHeight > 0 &&
!hdrExtractionDims.has(el.id)
) {
hdrExtractionDims.set(el.id, { width: el.layoutWidth, height: el.layoutHeight });
}
const initialStacking = await queryElementStacking(domSession.page, nativeHdrVideoIds);
const hdrExtractionDims = new Map<string, { width: number; height: number }>();
for (const el of initialStacking) {
if (el.isHdr && el.width > 0 && el.height > 0) {
hdrExtractionDims.set(el.id, { width: el.width, height: el.height });
}
}
@@ -1020,22 +1206,35 @@ export async function executeRenderJob(
mkdirSync(frameDir, { recursive: true });
const duration = video.end - video.start;
const dims = hdrExtractionDims.get(videoId) ?? { width, height };
try {
execSync(
`ffmpeg -ss ${video.mediaStart} -i "${srcPath}" -t ${duration} -r ${job.config.fps} ` +
`-vf "scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}" ` +
`-pix_fmt rgb48le -c:v png "${join(frameDir, "frame_%04d.png")}"`,
{ maxBuffer: 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] },
);
} catch (err) {
const ffmpegArgs = [
"-ss",
String(video.mediaStart),
"-i",
srcPath,
"-t",
String(duration),
"-r",
String(job.config.fps),
"-vf",
`scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`,
"-pix_fmt",
"rgb48le",
"-c:v",
"png",
"-y",
join(frameDir, "frame_%04d.png"),
];
const result = await runFfmpeg(ffmpegArgs, { signal: abortSignal });
if (!result.success) {
log.warn("HDR frame pre-extraction failed; loop will fill with black", {
videoId,
srcPath,
error: err instanceof Error ? err.message : String(err),
stderr: result.stderr.slice(-400),
});
}
hdrFrameDirs.set(videoId, frameDir);
}
assertNotAborted();
try {
@@ -1043,7 +1242,234 @@ export async function executeRenderJob(
// We call it manually since the HDR loop doesn't use captureFrame().
const beforeCaptureHook = domSession.onBeforeCapture;
for (let i = 0; i < job.totalFrames!; i++) {
// Track which HDR video frame directories have been cleaned up.
// Once a video's last frame has been used (time > video.end), its
// extraction directory is deleted to free disk space. This prevents
// disk exhaustion on compositions with many HDR videos.
const cleanedUpVideos = new Set<string>();
// Build a map of video end times for quick lookup
const hdrVideoEndTimes = new Map<string, number>();
for (const v of composition.videos) {
if (hdrFrameDirs.has(v.id)) {
hdrVideoEndTimes.set(v.id, v.end);
}
}
// ── compositeToBuffer: layer compositing helper ────────────────────
// Extracted so the transition path can composite each scene independently.
// Closes over domSession, hdrFrameDirs, composition, nativeHdrVideoIds, etc.
//
// @param canvas - Pre-allocated rgb48le buffer (width * height * 6 bytes)
// @param time - Seek time in seconds
// @param fullStacking - Complete stacking info for ALL elements (used for hideIds)
// @param elementFilter - When set, only composite elements whose IDs are in this set.
// When undefined, all elements are included (non-transition frame).
// @param debugFrameIndex - Frame index used to label diagnostic dumps. -1 disables
// per-layer dumps even when KEEP_TEMP=1 (for warmup calls).
const debugDumpEnabled = process.env.KEEP_TEMP === "1";
const debugDumpDir = debugDumpEnabled ? join(framesDir, "debug-composite") : null;
if (debugDumpDir && !existsSync(debugDumpDir)) {
mkdirSync(debugDumpDir, { recursive: true });
}
function countNonZeroAlpha(rgba: Uint8Array): number {
let n = 0;
for (let p = 3; p < rgba.length; p += 4) {
if (rgba[p] !== 0) n++;
}
return n;
}
function countNonZeroRgb48(buf: Uint8Array): number {
let n = 0;
for (let p = 0; p < buf.length; p += 6) {
if (buf[p] !== 0 || buf[p + 1] !== 0 || buf[p + 2] !== 0) n++;
}
return n;
}
async function compositeToBuffer(
canvas: Buffer,
time: number,
fullStacking: ElementStackingInfo[],
elementFilter?: Set<string>,
debugFrameIndex: number = -1,
): Promise<void> {
// Filter stacking info when rendering a single scene
const filteredStacking = elementFilter
? fullStacking.filter((e) => elementFilter.has(e.id))
: fullStacking;
// Group filtered elements into z-ordered layers
const layers = groupIntoLayers(filteredStacking);
const shouldLog = debugDumpEnabled && debugFrameIndex >= 0;
if (shouldLog) {
log.info("[diag] compositeToBuffer plan", {
frame: debugFrameIndex,
time: time.toFixed(3),
filterSize: elementFilter?.size,
fullStackingCount: fullStacking.length,
filteredCount: filteredStacking.length,
layerCount: layers.length,
layers: layers.map((l) =>
l.type === "hdr"
? {
type: "hdr",
id: l.element.id,
z: l.element.zIndex,
visible: l.element.visible,
opacity: l.element.opacity,
bounds: `${Math.round(l.element.x)},${Math.round(l.element.y)} ${Math.round(l.element.width)}x${Math.round(l.element.height)}`,
}
: { type: "dom", ids: l.elementIds },
),
});
}
// Composite layers bottom-to-top
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
const layer = layers[layerIdx]!;
if (layer.type === "hdr") {
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
blitHdrVideoLayer(
canvas,
layer.element,
time,
job.config.fps,
hdrFrameDirs,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(layer.element.id),
effectiveHdr?.transfer,
);
if (shouldLog) {
const after = countNonZeroRgb48(canvas);
const frameDir = hdrFrameDirs.get(layer.element.id);
const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0;
const localTime = time - startTime;
const frameNum = Math.floor(localTime * job.config.fps) + 1;
const expectedFrame = frameDir
? join(frameDir, `frame_${String(frameNum).padStart(4, "0")}.png`)
: null;
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
id: layer.element.id,
pixelsAdded: after - before,
totalNonZero: after,
startTime,
localTime: localTime.toFixed(3),
hdrFrameNum: frameNum,
expectedFrame,
expectedFrameExists: expectedFrame ? existsSync(expectedFrame) : false,
});
}
} else {
// DOM layer: capture only elements in this layer.
//
// Each layer gets a fresh seek + inject cycle to guarantee correct
// visibility state — avoids fragile interactions between the frame
// injector, applyDomLayerMask, removeDomLayerMask, and GSAP re-seek.
//
// The mask:
// - mass-hides every body descendant via stylesheet
// - re-shows the layer's elements (and their descendants and
// their injected `__render_frame_*` siblings) so deep-nested
// content stays visible even though intermediate ancestors
// are hidden
// - inline-hides every other data-start element so they don't
// paint when they happen to be descendants of a layer element
// (most importantly: HDR videos and other-layer SDR videos
// that live inside `#root` when capturing the root DOM layer)
//
// Without the mask, every DOM screenshot captures the full page
// (root background, sibling scenes' static content, the painted
// border/box-shadow of cards, etc.) and the resulting opaque
// pixels overwrite previously composited HDR content beneath.
const allElementIds = fullStacking.map((e) => e.id);
const layerIds = new Set(layer.elementIds);
const hideIds = allElementIds.filter((id) => !layerIds.has(id));
// 1. Seek GSAP to restore all animated properties from clean state
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
// 2. Run frame injector to set correct SDR video visibility
if (beforeCaptureHook) {
await beforeCaptureHook(domSession.page, time);
}
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
await applyDomLayerMask(domSession.page, layer.elementIds, hideIds);
// 4. Screenshot
const domPng = await captureAlphaPng(domSession.page, width, height);
// 5. Tear down the mask
await removeDomLayerMask(domSession.page, hideIds);
try {
const { data: domRgba } = decodePng(domPng);
// Invariant: this branch is only reached when HDR output is active.
if (!effectiveHdr) {
throw new Error(
"Invariant violation: effectiveHdr is undefined inside HDR layer branch",
);
}
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer);
if (shouldLog && debugDumpDir) {
const after = countNonZeroRgb48(canvas);
const dumpName = `frame_${String(debugFrameIndex).padStart(4, "0")}_layer_${String(layerIdx).padStart(2, "0")}_dom.png`;
const dumpPath = join(debugDumpDir, dumpName);
writeFileSync(dumpPath, domPng);
log.info("[diag] dom layer blit", {
frame: debugFrameIndex,
layerIdx,
layerIds: layer.elementIds,
hideCount: hideIds.length,
pngBytes: domPng.length,
alphaPixels,
pixelsAdded: after - before,
totalNonZero: after,
dumpPath,
});
}
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay", {
layerIds: layer.elementIds,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
if (shouldLog && debugDumpDir) {
const finalNonZero = countNonZeroRgb48(canvas);
log.info("[diag] compositeToBuffer end", {
frame: debugFrameIndex,
finalNonZeroPixels: finalNonZero,
totalPixels: width * height,
coverage: ((finalNonZero / (width * height)) * 100).toFixed(1) + "%",
});
}
}
// ── Pre-allocate transition buffers ─────────────────────────────────
// Each buffer is width * height * 6 bytes (~37 MB at 1080p). Reused
// across frames to avoid per-frame allocation in the hot loop.
const bufSize = width * height * 6;
const hasTransitions = transitionRanges.length > 0;
const transBufferA = hasTransitions ? Buffer.alloc(bufSize) : null;
const transBufferB = hasTransitions ? Buffer.alloc(bufSize) : null;
const transOutput = hasTransitions ? Buffer.alloc(bufSize) : null;
// Pre-allocate the normal-frame canvas too — reused via .fill(0) each iteration
// to avoid ~37 MB allocation per frame in the hot loop.
const normalCanvas = Buffer.alloc(bufSize);
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
@@ -1060,147 +1486,164 @@ export async function executeRenderJob(
// Query ALL timed elements for z-order analysis
const stackingInfo = await queryElementStacking(domSession.page, nativeHdrVideoIds);
// Group into z-ordered layers
const layers = groupIntoLayers(stackingInfo);
// Find active transition for this frame (if any)
const activeTransition = transitionRanges.find(
(t) => i >= t.startFrame && i <= t.endFrame,
);
if (i % 30 === 0) {
const hdrEl = stackingInfo.find((e) => e.isHdr);
const hdrInLayers = layers.some((l) => l.type === "hdr");
log.debug("[Render] HDR layer composite frame", {
frame: i,
time: time.toFixed(2),
hdrElement: hdrEl
? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width }
: null,
hdrLayerPresent: hdrInLayers,
layerCount: layers.length,
stackingCount: stackingInfo.length,
activeTransition: activeTransition?.shader,
});
}
// Start with a black canvas
const canvas = Buffer.alloc(width * height * 6);
if (activeTransition && transBufferA && transBufferB && transOutput) {
// ── Transition frame: dual-scene compositing ──────────────────
const progress =
activeTransition.endFrame === activeTransition.startFrame
? 1
: (i - activeTransition.startFrame) /
(activeTransition.endFrame - activeTransition.startFrame);
// Composite layers bottom-to-top
for (const layer of layers) {
if (layer.type === "hdr") {
const el = layer.element;
const frameDir = hdrFrameDirs.get(el.id);
const video = composition.videos.find((v) => v.id === el.id);
if (!frameDir || !video) continue;
// Resolve scene element IDs
const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
// Frame index within the video (1-based for FFmpeg image2 output).
// Clamp against the highest extracted frame in the directory to
// avoid issuing an existsSync per requested time when the
// composition outlives the source clip. If the requested frame
// is past the end of the source, fall back to the last available
// frame (freeze on last frame, matching Chrome's <video> behavior).
const videoFrameIndex = Math.round((time - video.start) * job.config.fps) + 1;
const maxIndex = getMaxFrameIndex(frameDir);
const effectiveIndex =
videoFrameIndex >= 1
? maxIndex > 0
? Math.min(videoFrameIndex, maxIndex)
: videoFrameIndex
: 0;
const framePath =
effectiveIndex >= 1
? join(frameDir, `frame_${String(effectiveIndex).padStart(4, "0")}.png`)
: null;
// Zero-fill scene buffers (transition function writes every output pixel)
transBufferA.fill(0);
transBufferB.fill(0);
if (framePath !== null && existsSync(framePath)) {
try {
const {
data: hdrRgb,
width: srcW,
height: srcH,
} = decodePngToRgb48le(readFileSync(framePath));
// Derive the effective transform from the bounding rect.
// getBoundingClientRect() already reflects all ancestor transforms
// (GSAP sets transforms on wrapper divs, not video elements).
const scaleX = el.width / srcW;
const scaleY = el.height / srcH;
const needsAffine = Math.abs(scaleX - 1) > 0.001 || Math.abs(scaleY - 1) > 0.001;
if (needsAffine) {
// Element bounds differ from extraction dimensions — scale via affine blit
blitRgb48leAffine(
canvas,
hdrRgb,
[scaleX, 0, 0, scaleY, el.x, el.y],
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
);
} else {
// Same dimensions — fast path with row copy
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
);
}
} catch (err) {
log.warn("HDR layer decode/blit failed; skipping layer for frame", {
frameIndex: i,
videoId: el.id,
framePath,
error: err instanceof Error ? err.message : String(err),
});
}
}
} else {
// DOM layer: hide elements NOT in this layer + all HDR videos.
// All elements (including invisible SDR videos) are in the stacking
// info so their injected <img> replacements get hidden from other layers.
const allElementIds = stackingInfo.map((e) => e.id);
const layerIds = new Set(layer.elementIds);
const hideIds = allElementIds.filter(
(id) => !layerIds.has(id) || nativeHdrVideoIds.has(id),
);
await hideVideoElements(domSession.page, hideIds);
const domPng = await captureAlphaPng(domSession.page, width, height);
await showVideoElements(domSession.page, hideIds);
// Re-seek GSAP to restore animated properties (opacity, transforms)
// that showVideoElements clobbered via removeProperty.
for (const [sceneBuf, sceneIds] of [
[transBufferA, sceneAIds],
[transBufferB, sceneBIds],
] as const) {
// Fresh state: seek + inject
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
if (beforeCaptureHook) {
await beforeCaptureHook(domSession.page, time);
}
// Blit all HDR videos/images for this scene
for (const el of stackingInfo) {
if (!el.isHdr || !sceneIds.has(el.id)) continue;
blitHdrVideoLayer(
sceneBuf as Buffer,
el,
time,
job.config.fps,
hdrFrameDirs,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(el.id),
effectiveHdr?.transfer,
);
}
// Single DOM screenshot: mask the page so only this scene's DOM
// elements paint. Same masking strategy as the per-layer DOM
// branch — see applyDomLayerMask for details. Native HDR videos
// are always inline-hidden so their fallback poster/black frame
// doesn't bleed into the DOM overlay (HDR pixels are blitted
// separately by blitHdrVideoLayer above).
const showIds = Array.from(sceneIds);
const hideIds = stackingInfo
.map((e) => e.id)
.filter((id) => !sceneIds.has(id) || nativeHdrVideoIds.has(id));
await applyDomLayerMask(domSession.page, showIds, hideIds);
const domPng = await captureAlphaPng(domSession.page, width, height);
await removeDomLayerMask(domSession.page, hideIds);
try {
const { data: domRgba } = decodePng(domPng);
// Invariant: `hasHdrVideo` requires `effectiveHdr` to be set (see line ~870).
// Invariant: `hasHdrVideo` requires `effectiveHdr` to be set (see line ~919).
if (!effectiveHdr) {
throw new Error(
"Invariant violation: effectiveHdr is undefined inside hasHdrVideo branch",
);
}
blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer);
blitRgba8OverRgb48le(
domRgba,
sceneBuf as Buffer,
width,
height,
effectiveHdr.transfer,
);
} catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay for frame", {
log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", {
frameIndex: i,
layerIds: layer.elementIds,
sceneIds: Array.from(sceneIds),
error: err instanceof Error ? err.message : String(err),
});
}
}
// Apply shader transition blend directly in PQ/HLG signal space.
// Linearization was attempted but destroys dark PQ content — values below
// PQ ~5000 quantize to zero in 16-bit linear, wiping out the bottom portion
// of dark video content. PQ space is perceptual and works well enough
// for shader math since the shaders were designed for perceptual (sRGB) space.
const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade;
transitionFn(transBufferA, transBufferB, transOutput, width, height, progress);
hdrEncoder.writeFrame(transOutput);
} else {
// ── Normal frame: full layer composite (no transition) ─────────
normalCanvas.fill(0);
await compositeToBuffer(normalCanvas, time, stackingInfo, undefined, i);
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
const previewPath = join(
debugDumpDir,
`frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`,
);
writeFileSync(previewPath, normalCanvas);
}
hdrEncoder.writeFrame(normalCanvas);
}
hdrEncoder.writeFrame(canvas);
// Clean up HDR frame directories for videos that have ended.
// Frees disk space during long renders with many HDR videos.
// Skip when KEEP_TEMP=1 so we can inspect intermediate state.
if (process.env.KEEP_TEMP !== "1") {
for (const [videoId, endTime] of hdrVideoEndTimes) {
if (time > endTime && !cleanedUpVideos.has(videoId)) {
// Also check no active transition references this video's scene
const stillNeeded =
activeTransition &&
(sceneElements[activeTransition.fromScene]?.includes(videoId) ||
sceneElements[activeTransition.toScene]?.includes(videoId));
if (!stillNeeded) {
const frameDir = hdrFrameDirs.get(videoId);
if (frameDir) {
try {
rmSync(frameDir, { recursive: true, force: true });
} catch (err) {
log.warn("Failed to clean up HDR frame directory", {
videoId,
frameDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
cleanedUpVideos.add(videoId);
}
}
}
}
job.framesRendered = i + 1;
if ((i + 1) % 10 === 0 || i + 1 === job.totalFrames!) {
const frameProgress = (i + 1) / job.totalFrames!;
if ((i + 1) % 10 === 0 || i + 1 === totalFrames) {
const frameProgress = (i + 1) / totalFrames;
updateJobStatus(
job,
"rendering",
@@ -1251,7 +1694,7 @@ export async function executeRenderJob(
if (enableStreamingEncode && streamingEncoder) {
// ── Streaming capture + encode (Stage 4 absorbs Stage 5) ──────────
const reorderBuffer = createFrameReorderBuffer(0, job.totalFrames!);
const reorderBuffer = createFrameReorderBuffer(0, totalFrames);
const currentEncoder = streamingEncoder;
if (workerCount > 1) {
@@ -1323,7 +1766,7 @@ export async function executeRenderJob(
assertNotAborted();
lastBrowserConsole = session.browserConsoleBuffer;
for (let i = 0; i < job.totalFrames!; i++) {
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
const { buffer } = await captureFrameToBuffer(session, i, time);
@@ -1332,7 +1775,7 @@ export async function executeRenderJob(
reorderBuffer.advanceTo(i + 1);
job.framesRendered = i + 1;
const frameProgress = (i + 1) / job.totalFrames!;
const frameProgress = (i + 1) / totalFrames;
const progress = 25 + frameProgress * 55;
updateJobStatus(
@@ -1546,15 +1989,13 @@ export async function executeRenderJob(
chunkedEncode: enableChunkedEncode,
chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null,
compositionDurationSeconds: composition.duration,
totalFrames: job.totalFrames!,
totalFrames: totalFrames,
resolution: { width, height },
videoCount: composition.videos.length,
audioCount: composition.audios.length,
stages: perfStages,
captureAvgMs:
job.totalFrames! > 0
? Math.round((perfStages.captureMs ?? 0) / job.totalFrames!)
: undefined,
totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : undefined,
};
job.perfSummary = perfSummary;
if (job.config.debug) {
@@ -1575,6 +2016,8 @@ export async function executeRenderJob(
const debugOutput = join(workDir, `output${videoExt}`);
copyFileSync(outputPath, debugOutput);
}
} else if (process.env.KEEP_TEMP === "1") {
log.info("KEEP_TEMP=1 — leaving workDir on disk for inspection", { workDir });
} else {
await safeCleanup(
"remove workDir",
@@ -83,6 +83,59 @@ function deriveAccentColors(hex: string): AccentColors {
export function init(config: HyperShaderConfig): GsapTimeline {
const { bgColor, scenes, transitions } = config;
if (scenes.length !== transitions.length + 1) {
throw new Error(
`[HyperShader] init(): expected scenes.length === transitions.length + 1, got scenes=${scenes.length}, transitions=${transitions.length}`,
);
}
// Verify each scene id resolves to an element with the `.scene` class.
// Capture and compositing later assume both — without this guard the
// texture map gets stale ids and transitions silently no-op.
if (typeof document !== "undefined") {
const missing: string[] = [];
const notScene: string[] = [];
for (const id of scenes) {
const el = document.getElementById(id);
if (!el) {
missing.push(id);
} else if (!el.classList.contains("scene")) {
notScene.push(id);
}
}
if (missing.length > 0) {
throw new Error(`[HyperShader] init(): scene ids not found in DOM: ${missing.join(", ")}`);
}
if (notScene.length > 0) {
throw new Error(
`[HyperShader] init(): elements found but missing .scene class: ${notScene.join(", ")}`,
);
}
}
interface HfTransitionMeta {
time: number;
duration: number;
shader: string;
ease: string;
fromScene: string;
toScene: string;
}
type HfWindowWrite = { __hf?: { transitions?: HfTransitionMeta[] } };
if (typeof window !== "undefined") {
const hfWin = window as unknown as HfWindowWrite;
if (hfWin.__hf) {
hfWin.__hf.transitions = transitions.map((t: TransitionConfig, i: number) => ({
time: t.time,
duration: t.duration ?? 1,
shader: t.shader,
ease: t.ease ?? "none",
fromScene: scenes[i] ?? "",
toScene: scenes[i + 1] ?? "",
}));
}
}
const accentColors: AccentColors = config.accentColor
? deriveAccentColors(config.accentColor)
: { accent: [1, 0.6, 0.2], dark: [0.4, 0.15, 0], bright: [1, 0.85, 0.5] };