refactor(producer): simplify — extract HDR compositor, delete dead code, consolidate patterns (#1414)

* refactor(producer): extract HDR compositor from renderOrchestrator

Move ~700 LOC of HDR compositing primitives (countNonZeroAlpha,
countNonZeroRgb48, cropRgb48le, HdrVideoFrameSource,
closeHdrVideoFrameSource, blitHdrVideoLayer, HdrImageBuffer,
blitHdrImageLayer, CompositeTransfer, shouldUseLayeredComposite,
resolveCompositeTransfer, HdrCompositeContext, compositeHdrFrame,
HdrTransitionMeta, TransitionRange) into a dedicated
hdrCompositor.ts module.

Remove backward-compat re-exports from renderOrchestrator (hdrPerf,
captureCost, shared) and rewire all import sites to the
authoritative source modules.

* refactor(producer): delete 4 re-export shim files

screenshotService.ts, videoFrameExtractor.ts, videoFrameInjector.ts,
and streamingEncoder.ts existed solely to re-export symbols from
@hyperframes/engine. No internal consumer imported from them except
index.ts → videoFrameInjector, which now imports directly from engine.

* refactor(producer): delete unused PNG decode/blit worker pool

The pool (455 LOC) and worker (127 LOC) were built speculatively for
pipelining Chrome screenshots with PNG decode/blit but were never
wired into any capture path. Zero non-test source files imported them.

Also removed the esbuild entry point from producer/build.mjs, the
tsup entry point + alpha-blit alias from cli/tsup.config.ts, and
the PNG worker bootstrap from cli/src/cli.ts.

* refactor(producer): centralize frame filename construction

Replace 4 inline padStart(6) template literals with shared helpers:
- formatCaptureFrameName(index, ext): zero-based, for internal capture
- formatExportFrameName(index, ext): zero-based input, one-based output
  for user-facing png-sequence export

* perf(producer): hoist allElementIds out of compositing loop

Move fullStacking.map() from inside the per-layer iteration to before
the loop, computing the element ID list once per frame instead of once
per DOM layer per frame.

* refactor(producer): consolidate HDR timing instrumentation

* refactor(producer): remove typecasts and deduplicate HDR capture patterns

- Extract seekInjectAndQueryStacking() and seekAndInject() helpers to
  deduplicate the seek+inject+query pattern across sequential loop,
  hybrid loop, and per-scene transition capture (3 call sites → 1 helper)
- Fix sceneBuf as Buffer casts by properly typing the scene-capture
  arrays as [Buffer, Set<string>][] instead of using as const + cast
- Replace as NonNullable<> cast on outputFormat with as const fallback
- Add explanatory comments on inherent linkedom DOM casts

* refactor(producer): name constants, type matrix, extract opacity helper

- Replace magic 0.001/0.999 with TRANSFORM_IDENTITY_EPSILON and
  OPAQUE_ALPHA_THRESHOLD; replace BPP=6 with RGB48_BYTES_PER_PIXEL
- Add AffineMatrix tuple type + isAffineMatrix guard, eliminating
  all 4 non-null assertions on matrix indices
- Extract resolveBlitOpacity() to replace 5 identical ternaries
- Narrow fallow-ignore-file to line-level complexity suppressions
This commit is contained in:
Miguel Ángel
2026-06-13 18:49:19 -04:00
committed by GitHub
parent 7bff49ecf0
commit a0d7295367
26 changed files with 937 additions and 1894 deletions
+5 -15
View File
@@ -22,34 +22,24 @@ for (const stream of [process.stdout, process.stderr]) {
} }
// ── Worker entry path bootstrap (must run before any producer/engine load) ── // ── Worker entry path bootstrap (must run before any producer/engine load) ──
// The hf#677 worker_threads pools (`pngDecodeBlitWorkerPool`, // The shaderTransitionWorkerPool lives in the producer package and resolves
// `shaderTransitionWorkerPool`) live in the producer package and try to // its worker entry by probing for a sibling `.js` file next to
// resolve their worker entry by probing for sibling `.js` files next to
// `import.meta.url`. When this CLI is bundled by tsup, the producer code is // `import.meta.url`. When this CLI is bundled by tsup, the producer code is
// inlined into `cli.js`, but `import.meta.url` resolves to the producer's // inlined into `cli.js`, but `import.meta.url` resolves to the producer's
// own dist path (NOT cli.js) on some module-graph layouts — so the sibling // own dist path (NOT cli.js) on some module-graph layouts — so the sibling
// probe lands in a directory that does not contain the bundled workers. // probe lands in a directory that does not contain the bundled worker.
// We emit the worker entries next to cli.js (see tsup.config.ts) and tell // We emit the worker entry next to cli.js (see tsup.config.ts) and tell
// the pools where to find them via the published env-var overrides. The // the pool where to find it via the published env-var override.
// pools have an explicit `workerEntryPath` factory option as the canonical
// API, but setting the env vars here covers every call site without having
// to thread the path through the renderOrchestrator → captureHdrStage →
// captureHdrHybridLoop chain.
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
// fallow-ignore-next-line complexity
(() => { (() => {
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const shader = join(here, "shaderTransitionWorker.js"); const shader = join(here, "shaderTransitionWorker.js");
const png = join(here, "pngDecodeBlitWorker.js");
if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) { if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) {
process.env.HF_SHADER_WORKER_ENTRY = shader; process.env.HF_SHADER_WORKER_ENTRY = shader;
} }
if (!process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY && existsSync(png)) {
process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY = png;
}
})(); })();
// ── Fast-path exits ───────────────────────────────────────────────────────── // ── Fast-path exits ─────────────────────────────────────────────────────────
-16
View File
@@ -7,20 +7,8 @@ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url),
}; };
export default defineConfig({ export default defineConfig({
// hf#732 lever-4: emit BOTH the CLI bundle and the PNG decode + alpha-blit
// worker entry. The producer's `pngDecodeBlitWorkerPool` instantiates a
// Node `worker_threads` Worker via `new Worker(<path>)`, which is a
// filesystem load — it cannot share the parent module graph. The pool's
// path resolver probes for `pngDecodeBlitWorker.js` next to its own loaded
// module (which lives inside `dist/cli.js` after the producer is
// `noExternal`'d and bundled in). Without this entry the file would not
// exist at runtime and the pool would either crash or silently fall back
// to inline decode/blit, killing the perf gain.
entry: { entry: {
cli: "src/cli.ts", cli: "src/cli.ts",
pngDecodeBlitWorker: "../producer/src/services/pngDecodeBlitWorker.ts",
// hf#677/#732: shader-blend worker. Same `new Worker(<path>)`
// bundling rationale as `pngDecodeBlitWorker` above.
shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts", shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts",
}, },
format: ["esm"], format: ["esm"],
@@ -96,10 +84,6 @@ var __dirname = __hf_dirname(__filename);`,
"@hyperframes/aws-lambda/sdk": resolve(__dirname, "../aws-lambda/src/sdk/index.ts"), "@hyperframes/aws-lambda/sdk": resolve(__dirname, "../aws-lambda/src/sdk/index.ts"),
// Same for the GCP adapter's SDK subpath barrel. // Same for the GCP adapter's SDK subpath barrel.
"@hyperframes/gcp-cloud-run/sdk": resolve(__dirname, "../gcp-cloud-run/src/sdk/index.ts"), "@hyperframes/gcp-cloud-run/sdk": resolve(__dirname, "../gcp-cloud-run/src/sdk/index.ts"),
// hf#732 lever-4: alias for the PNG decode+blit worker's import.
// `alphaBlit.ts` is import-free (only zlib) so the worker survives
// the worker_thread loader boundary directly via this TS source.
"@hyperframes/engine/alpha-blit": resolve(__dirname, "../engine/src/utils/alphaBlit.ts"),
// hf#677 follow-up: the shader-blend worker imports from // hf#677 follow-up: the shader-blend worker imports from
// `@hyperframes/engine/shader-transitions` (subpath export) — a // `@hyperframes/engine/shader-transitions` (subpath export) — a
// standalone TS file with zero internal imports that survives the // standalone TS file with zero internal imports that survives the
-5
View File
@@ -59,11 +59,6 @@ const sharedOpts = {
await Promise.all([ await Promise.all([
build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }), build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }),
build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/public-server.js" }), build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/public-server.js" }),
build({
...sharedOpts,
entryPoints: ["src/services/pngDecodeBlitWorker.ts"],
outfile: "dist/services/pngDecodeBlitWorker.js",
}),
build({ build({
...sharedOpts, ...sharedOpts,
entryPoints: ["src/services/shaderTransitionWorker.ts"], entryPoints: ["src/services/shaderTransitionWorker.ts"],
+1 -1
View File
@@ -52,7 +52,7 @@ export {
} from "./services/fileServer.js"; } from "./services/fileServer.js";
// ── Video frame injection (Hyperframes-specific hook) ─────────────────────── // ── Video frame injection (Hyperframes-specific hook) ───────────────────────
export { createVideoFrameInjector } from "./services/videoFrameInjector.js"; export { createVideoFrameInjector } from "@hyperframes/engine";
// ── Configuration ─────────────────────────────────────────────────────────── // ── Configuration ───────────────────────────────────────────────────────────
export { resolveConfig, DEFAULT_CONFIG, type ProducerConfig } from "./config.js"; export { resolveConfig, DEFAULT_CONFIG, type ProducerConfig } from "./config.js";
@@ -37,6 +37,7 @@ import { dirname, join } from "node:path";
import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine"; import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine";
import { fpsToFfmpegArg } from "@hyperframes/core"; import { fpsToFfmpegArg } from "@hyperframes/core";
import { defaultLogger, type ProducerLogger } from "../../logger.js"; import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { formatExportFrameName } from "../../utils/paths.js";
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js"; import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
import type { ChunkSliceJson } from "../render/stages/freezePlan.js"; import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
import type { DistributedFormat } from "./shared.js"; import type { DistributedFormat } from "./shared.js";
@@ -397,7 +398,7 @@ function mergePngFrameDirs(
throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`); throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`);
} }
for (const frame of frames) { for (const frame of frames) {
const dst = join(outputPath, `frame_${String(globalIdx + 1).padStart(6, "0")}.png`); const dst = join(outputPath, formatExportFrameName(globalIdx, "png"));
cpSync(join(chunkDir, frame), dst); cpSync(join(chunkDir, frame), dst);
globalIdx += 1; globalIdx += 1;
} }
@@ -0,0 +1,698 @@
/**
* HDR Compositor — pixel-level compositing primitives for the HDR
* layered render path.
*
* Extracted from `renderOrchestrator.ts` so the ~600 LOC of HDR-specific
* buffer manipulation, video/image blit logic, and per-frame compositor
* live in a focused module that can be tested and evolved independently.
*
* Consumers: `captureHdrStage.ts`, `captureHdrSequentialLoop.ts`,
* `captureHdrHybridLoop.ts`, `captureHdrFrameShared.ts`,
* `captureHdrResources.ts`.
*/
import { readSync, closeSync } from "fs";
import { join } from "path";
import {
type CaptureSession,
type BeforeCaptureHook,
type HdrTransfer,
type ElementStackingInfo,
type HfTransitionMeta,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
decodePng,
blitRgba8OverRgb48le,
blitRgb48leRegion,
groupIntoLayers,
blitRgb48leAffine,
parseTransformMatrix,
convertTransfer,
} from "@hyperframes/engine";
import type { ProducerLogger } from "../logger.js";
import { type HdrImageTransferCache } from "./hdrImageTransferCache.js";
import { writeFileExclusiveSync } from "./render/shared.js";
import { type HdrPerfCollector, timeHdrPhase, timeHdrPhaseAsync } from "./render/hdrPerf.js";
// ─── Diagnostic helpers ────────────────────────────────────────────────────
// Diagnostic helpers used by the HDR layered compositor when KEEP_TEMP=1
// is set. They are pure (capture no state), so we keep them at module scope
// to avoid re-creating closures per frame and to make them callable from
// any future composite path that needs to log non-zero pixel counts.
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 ||
buf[p + 3] !== 0 ||
buf[p + 4] !== 0 ||
buf[p + 5] !== 0
)
n++;
}
return n;
}
// ─── Constants ────────────────────────────────────────────────────────────
const TRANSFORM_IDENTITY_EPSILON = 0.001;
const OPAQUE_ALPHA_THRESHOLD = 0.999;
const RGB48_BYTES_PER_PIXEL = 6;
type AffineMatrix = [number, number, number, number, number, number];
function isAffineMatrix(m: number[]): m is AffineMatrix {
return m.length === 6;
}
function resolveBlitOpacity(opacity: number): number | undefined {
return opacity < OPAQUE_ALPHA_THRESHOLD ? opacity : undefined;
}
// ─── Types ─────────────────────────────────────────────────────────────────
/**
* 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.
*/
export type HdrTransitionMeta = HfTransitionMeta;
/** Pre-computed frame range for an active transition. */
export interface TransitionRange extends HdrTransitionMeta {
startFrame: number;
endFrame: number;
}
// ─── Video frame source ────────────────────────────────────────────────────
/**
* Crop an rgb48le buffer to a sub-region. Returns a new Buffer containing
* only the cropped pixels.
*/
function cropRgb48le(
src: Buffer,
srcW: number,
srcH: number,
cropX: number,
cropY: number,
cropW: number,
cropH: number,
): Buffer {
const dst = Buffer.alloc(cropW * cropH * RGB48_BYTES_PER_PIXEL);
for (let row = 0; row < cropH; row++) {
const srcRow = cropY + row;
if (srcRow < 0 || srcRow >= srcH) continue;
const srcOff = (srcRow * srcW + cropX) * RGB48_BYTES_PER_PIXEL;
const dstOff = row * cropW * RGB48_BYTES_PER_PIXEL;
const copyLen = Math.min(cropW, srcW - cropX) * RGB48_BYTES_PER_PIXEL;
if (copyLen > 0) src.copy(dst, dstOff, srcOff, srcOff + copyLen);
}
return dst;
}
/**
* 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, raw read, transfer, transform, and blit logic.
*/
export interface HdrVideoFrameSource {
dir: string;
rawPath: string;
fd: number;
width: number;
height: number;
frameSize: number;
frameCount: number;
scratch: Buffer;
}
export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: ProducerLogger): void {
try {
closeSync(source.fd);
} catch (err) {
log?.warn("Failed to close HDR raw frame file", {
rawPath: source.rawPath,
error: err instanceof Error ? err.message : String(err),
});
}
}
// fallow-ignore-next-line complexity
export function blitHdrVideoLayer(
canvas: Buffer,
el: ElementStackingInfo,
time: number,
fps: number,
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>,
hdrStartTimes: Map<string, number>,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const frameSource = hdrVideoFrameSources.get(el.id);
const startTime = hdrStartTimes.get(el.id);
if (!frameSource || startTime === undefined || el.opacity <= 0) {
return;
}
// Frame index within the video. Clamp to the extracted raw frame count so
// a composition that outlives the source clip freezes on the last frame,
// matching Chrome's <video> behavior.
const videoFrameIndex = Math.round((time - startTime) * fps) + 1;
if (videoFrameIndex < 1) return;
const effectiveIndex = Math.min(videoFrameIndex, frameSource.frameCount);
if (effectiveIndex < 1) return;
const frameOffset = (effectiveIndex - 1) * frameSource.frameSize;
try {
if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1;
const bytesRead = timeHdrPhase(hdrPerf, "hdrVideoReadDecodeMs", () =>
readSync(frameSource.fd, frameSource.scratch, 0, frameSource.frameSize, frameOffset),
);
if (bytesRead !== frameSource.frameSize) return;
const hdrRgb = frameSource.scratch;
const srcW = frameSource.width;
const srcH = frameSource.height;
// Convert between HDR transfer functions if source doesn't match output
if (sourceTransfer && targetTransfer && sourceTransfer !== targetTransfer) {
timeHdrPhase(hdrPerf, "hdrVideoTransferMs", () =>
convertTransfer(hdrRgb, sourceTransfer, targetTransfer),
);
}
const rawMatrix = parseTransformMatrix(el.transform);
const matrix = rawMatrix && isAffineMatrix(rawMatrix) ? rawMatrix : null;
const br = el.borderRadius;
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
let blitX = el.x;
let blitY = el.y;
let blitSrcX = 0;
let blitSrcY = 0;
let blitW = srcW;
let blitH = srcH;
let clipped = false;
if (el.clipRect) {
const cr = el.clipRect;
const cx1 = Math.max(blitX, cr.x);
const cy1 = Math.max(blitY, cr.y);
const cx2 = Math.min(blitX + blitW, cr.x + cr.width);
const cy2 = Math.min(blitY + blitH, cr.y + cr.height);
if (cx2 <= cx1 || cy2 <= cy1) return;
blitSrcX = cx1 - blitX;
blitSrcY = cy1 - blitY;
blitW = cx2 - cx1;
blitH = cy2 - cy1;
blitX = cx1;
blitY = cy1;
clipped = true;
}
const isTranslationOnly = !!(
matrix &&
Math.abs(matrix[0] - 1) < TRANSFORM_IDENTITY_EPSILON &&
Math.abs(matrix[1]) < TRANSFORM_IDENTITY_EPSILON &&
Math.abs(matrix[2]) < TRANSFORM_IDENTITY_EPSILON &&
Math.abs(matrix[3] - 1) < TRANSFORM_IDENTITY_EPSILON
);
timeHdrPhase(hdrPerf, "hdrVideoBlitMs", () => {
if (matrix && !isTranslationOnly) {
if (clipped && log) {
log.debug(
`HDR clip rect on affine-transformed element ${el.id} — clip not applied (affine scissor not yet supported)`,
);
}
blitRgb48leAffine(
canvas,
hdrRgb,
matrix,
srcW,
srcH,
width,
height,
resolveBlitOpacity(el.opacity),
borderRadiusParam,
);
} else if (clipped) {
const croppedBuf = cropRgb48le(hdrRgb, srcW, srcH, blitSrcX, blitSrcY, blitW, blitH);
blitRgb48leRegion(
canvas,
croppedBuf,
blitX,
blitY,
blitW,
blitH,
width,
height,
resolveBlitOpacity(el.opacity),
borderRadiusParam,
);
} else {
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
srcW,
srcH,
width,
height,
resolveBlitOpacity(el.opacity),
borderRadiusParam,
);
}
});
} catch (err) {
if (log) {
log.debug(`HDR blit failed for ${el.id}`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
// ─── Image buffer ──────────────────────────────────────────────────────────
/**
* Pre-decoded HDR image buffer with its native pixel dimensions.
*
* Static images decode exactly once at setup time and are blitted on every
* visible frame, unlike video frames which are read fresh per timestamp.
*/
export interface HdrImageBuffer {
data: Buffer;
width: number;
height: number;
}
/**
* Blit a single HDR image layer onto an rgb48le canvas.
*
* Image-equivalent of `blitHdrVideoLayer` — the buffer is pre-decoded and
* static, so there's no time-based frame lookup or per-frame PNG read.
*/
export function blitHdrImageLayer(
canvas: Buffer,
el: ElementStackingInfo,
hdrImageBuffers: Map<string, HdrImageBuffer>,
hdrImageTransferCache: HdrImageTransferCache,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const buf = hdrImageBuffers.get(el.id);
if (!buf || el.opacity <= 0) {
return;
}
if (el.clipRect && log) {
log.debug(`HDR clip rect on image element ${el.id} — clip not yet supported for images`);
}
try {
if (hdrPerf) hdrPerf.hdrImageLayerBlits += 1;
// The cache returns `buf.data` unchanged when no conversion is needed,
// and otherwise returns a per-(imageId, targetTransfer) buffer that was
// converted exactly once and reused across every subsequent frame.
const hdrRgb = timeHdrPhase(hdrPerf, "hdrImageTransferMs", () =>
sourceTransfer && targetTransfer
? hdrImageTransferCache.getConverted(el.id, sourceTransfer, targetTransfer, buf.data)
: buf.data,
);
const rawMatrix = parseTransformMatrix(el.transform);
const matrix = rawMatrix && isAffineMatrix(rawMatrix) ? rawMatrix : null;
const br = el.borderRadius;
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
timeHdrPhase(hdrPerf, "hdrImageBlitMs", () => {
if (matrix) {
blitRgb48leAffine(
canvas,
hdrRgb,
matrix,
buf.width,
buf.height,
width,
height,
resolveBlitOpacity(el.opacity),
borderRadiusParam,
);
} else {
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
buf.width,
buf.height,
width,
height,
resolveBlitOpacity(el.opacity),
borderRadiusParam,
);
}
});
} catch (err) {
if (log) {
log.debug(`HDR image blit failed for ${el.id}`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
// ─── Composite transfer + strategy ─────────────────────────────────────────
/**
* Dependencies passed to `compositeHdrFrame`.
*
* Every field except the per-frame arguments is captured once when the HDR
* render path opens its `try { ... }` block and reused across every frame —
* extracting them into an explicit struct lets the helper live at module
* scope (no closure-over-renderJob) and keeps the per-call signature small.
*/
export type CompositeTransfer = HdrTransfer | "srgb";
export function shouldUseLayeredComposite(options: {
hasHdrContent: boolean;
hasShaderTransitions: boolean;
isPngSequence: boolean;
}): boolean {
return options.hasHdrContent || (options.hasShaderTransitions && !options.isPngSequence);
}
export function resolveCompositeTransfer(
hasHdrContent: boolean,
effectiveHdr: { transfer: HdrTransfer } | undefined,
): CompositeTransfer {
return hasHdrContent && effectiveHdr ? effectiveHdr.transfer : "srgb";
}
export interface HdrCompositeContext {
log: ProducerLogger;
domSession: CaptureSession;
beforeCaptureHook: BeforeCaptureHook | null;
width: number;
height: number;
fps: number;
compositeTransfer: CompositeTransfer;
nativeHdrImageIds: Set<string>;
hdrImageBuffers: Map<string, HdrImageBuffer>;
hdrImageTransferCache: HdrImageTransferCache;
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>;
hdrVideoStartTimes: Map<string, number>;
imageTransfers: Map<string, HdrTransfer>;
videoTransfers: Map<string, HdrTransfer>;
debugDumpEnabled: boolean;
debugDumpDir: string | null;
hdrPerf?: HdrPerfCollector;
}
// ─── Per-frame compositor ──────────────────────────────────────────────────
/**
* Composite a single HDR frame into a pre-allocated `rgb48le` canvas.
*
* Bottom-to-top z-order: HDR layers are blitted directly from cached image
* buffers / extracted video frames; DOM layers are screenshotted with a
* mass-hide mask (so each layer paints only its own elements) and then
* blended into the canvas via `blitRgba8OverRgb48le` in the active HDR
* transfer space.
*
* The `elementFilter` parameter exists so the transition path can composite
* each scene independently; pass `undefined` for whole-stack rendering.
*
* @param ctx - Long-lived dependencies (logger, browser session, dimensions,
* HDR layer maps). Captured once per render — see
* {@link HdrCompositeContext}.
* @param canvas - Pre-allocated `width * height * 6` byte buffer. Caller must
* zero-fill before every frame (this helper does not).
* @param time - Seek time in seconds.
* @param fullStacking - Stacking info for ALL elements at this time. Even when
* filtering, every other element id is needed to build
* the DOM-layer hide-list.
* @param elementFilter - When set, only elements whose id is in the set are
* composited.
* @param debugFrameIndex - Frame index used to label per-layer diagnostic
* dumps. Pass `-1` to disable per-layer dumps even
* when `KEEP_TEMP=1` (e.g. for warmup frames).
*/
// fallow-ignore-next-line complexity
export async function compositeHdrFrame(
ctx: HdrCompositeContext,
canvas: Buffer,
time: number,
fullStacking: ElementStackingInfo[],
elementFilter?: Set<string>,
debugFrameIndex: number = -1,
): Promise<void> {
const {
log,
domSession,
beforeCaptureHook,
width,
height,
fps,
compositeTransfer,
nativeHdrImageIds,
hdrImageBuffers,
hdrImageTransferCache,
hdrVideoFrameSources,
hdrVideoStartTimes,
imageTransfers,
videoTransfers,
debugDumpEnabled,
debugDumpDir,
hdrPerf,
} = ctx;
const filteredStacking = elementFilter
? fullStacking.filter((e) => elementFilter.has(e.id))
: fullStacking;
// Zero-opacity elements stay in the stacking for correct hide-list
// generation (their <img> replacements must be hidden from sibling
// screenshots). The actual blit is skipped in the compositing loop below.
const layers = groupIntoLayers(filteredStacking);
const allElementIds = fullStacking.map((e) => e.id);
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 },
),
});
}
for (const [layerIdx, layer] of layers.entries()) {
if (layer.type === "hdr") {
// Skip zero-opacity HDR elements — their parent scene may have faded out.
if (layer.element.opacity <= 0) continue;
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const isHdrImage = nativeHdrImageIds.has(layer.element.id);
const hdrTargetTransfer = compositeTransfer === "srgb" ? undefined : compositeTransfer;
if (isHdrImage) {
blitHdrImageLayer(
canvas,
layer.element,
hdrImageBuffers,
hdrImageTransferCache,
width,
height,
log,
imageTransfers.get(layer.element.id),
hdrTargetTransfer,
hdrPerf,
);
} else {
blitHdrVideoLayer(
canvas,
layer.element,
time,
fps,
hdrVideoFrameSources,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(layer.element.id),
hdrTargetTransfer,
hdrPerf,
);
}
if (shouldLog) {
const after = countNonZeroRgb48(canvas);
if (isHdrImage) {
const buf = hdrImageBuffers.get(layer.element.id);
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
id: layer.element.id,
kind: "image",
pixelsAdded: after - before,
totalNonZero: after,
bufferDecoded: !!buf,
bufferDims: buf ? `${buf.width}x${buf.height}` : null,
});
} else {
const frameSource = hdrVideoFrameSources.get(layer.element.id);
const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0;
const localTime = time - startTime;
const frameNum = Math.floor(localTime * fps) + 1;
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
id: layer.element.id,
kind: "video",
pixelsAdded: after - before,
totalNonZero: after,
startTime,
localTime: localTime.toFixed(3),
hdrFrameNum: frameNum,
rawPath: frameSource?.rawPath ?? null,
frameCount: frameSource?.frameCount ?? null,
});
}
}
} 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 layerIds = new Set(layer.elementIds);
const hideIds = allElementIds.filter((id) => !layerIds.has(id));
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
// 1. Seek GSAP to restore all animated properties from clean state
await timeHdrPhaseAsync(hdrPerf, "domLayerSeekMs", () =>
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 timeHdrPhaseAsync(hdrPerf, "domLayerInjectMs", () =>
beforeCaptureHook(domSession.page, time),
);
}
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
await timeHdrPhaseAsync(hdrPerf, "domMaskApplyMs", () =>
applyDomLayerMask(domSession.page, layer.elementIds, hideIds),
);
// 4. Screenshot
const domPng = await timeHdrPhaseAsync(hdrPerf, "domScreenshotMs", () =>
captureAlphaPng(domSession.page, width, height),
);
// 5. Tear down the mask
await timeHdrPhaseAsync(hdrPerf, "domMaskRemoveMs", () =>
removeDomLayerMask(domSession.page, hideIds),
);
try {
const { data: domRgba } = timeHdrPhase(hdrPerf, "domPngDecodeMs", () => decodePng(domPng));
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
timeHdrPhase(hdrPerf, "domBlitMs", () =>
blitRgba8OverRgb48le(domRgba, canvas, width, height, compositeTransfer),
);
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);
writeFileExclusiveSync(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) + "%",
});
}
}
@@ -1,122 +0,0 @@
/**
* Worker entry point for off-main-thread PNG decode + alpha blit. The
* companion to `pngDecodeBlitWorkerPool.ts`. See that file for the rationale
* (hf#732 lever-4: overlap Chrome's screenshot with Node's decode+blit).
*
* Lifecycle:
*
* 1. Pool constructor spawns N of these workers up front.
* 2. Main thread posts `{ png, pngOffset, pngLength, dest, destOffset,
* destLength, width, height, transfer }` with `transferList: [png, dest]`.
* Both underlying ArrayBuffers are detached on the sender; the caller
* must NOT touch them until the worker replies.
* 3. Worker wraps each ArrayBuffer as a Node Buffer view (zero-copy),
* runs `decodePng` to get an RGBA8 Uint8Array, then `blitRgba8OverRgb48le`
* to composite the decoded pixels onto the rgb48le `dest` buffer in
* the requested transfer space.
* 4. Worker posts `{ ok, png, dest, decodeMs, blitMs }` back with
* `transferList: [png, dest]`. Both ArrayBuffers return to the main
* thread; the caller swaps `result.dest` into its render state.
* 5. On decode/blit exception, worker posts `{ ok: false, error, png,
* dest }` — both ArrayBuffers still returned so the caller can release
* them.
*
* The worker holds no per-frame state. The intermediate RGBA8 decode
* buffer is allocated per-call and dropped on the worker side.
*
* Import strategy: identical to `shaderTransitionWorker.ts` — use the
* `./alpha-blit` subpath export of `@hyperframes/engine` rather than the
* package root, because the root pulls in the full engine graph and the
* tsx loader's `.js → .ts` rewrite does not survive the Worker boundary
* under dev/test.
*/
import { parentPort } from "node:worker_threads";
import { decodePng, blitRgba8OverRgb48le } from "@hyperframes/engine/alpha-blit";
interface DecodeBlitJobRequest {
png: ArrayBuffer;
pngOffset: number;
pngLength: number;
dest: ArrayBuffer;
destOffset: number;
destLength: number;
width: number;
height: number;
transfer: string;
}
interface DecodeBlitJobOk {
ok: true;
png: ArrayBuffer;
dest: ArrayBuffer;
decodeMs: number;
blitMs: number;
}
interface DecodeBlitJobErr {
ok: false;
error: string;
png: ArrayBuffer;
dest: ArrayBuffer;
}
export type DecodeBlitJobResult = DecodeBlitJobOk | DecodeBlitJobErr;
if (!parentPort) {
// Defensive — this module is only meaningful inside a worker_thread.
// eslint-disable-next-line no-console
console.warn("[pngDecodeBlitWorker] no parentPort; module loaded on main thread");
} else {
parentPort.on("message", (msg: DecodeBlitJobRequest) => {
const { png, pngOffset, pngLength, dest, destOffset, destLength, width, height, transfer } =
msg;
// Re-wrap the transferred ArrayBuffers as Node Buffer views. The
// dispatcher in the pool normalizes inputs to offset-0 ArrayBuffers
// before transfer (avoiding the 8KB shared-pool DataCloneError), so
// pngOffset / destOffset are 0 and pngLength / destLength match the
// backing ArrayBuffer byteLength in practice. We still honor the
// forwarded values so the worker is robust if the dispatcher ever
// changes (e.g. ships a slice over a larger transferred ArrayBuffer).
const pngBuf = Buffer.from(png, pngOffset, pngLength);
const destBuf = Buffer.from(dest, destOffset, destLength);
try {
const decodeStart = Date.now();
const { data: rgba } = decodePng(pngBuf);
const decodeMs = Date.now() - decodeStart;
const blitStart = Date.now();
// `blitRgba8OverRgb48le` accepts the CompositeTransfer string as a
// typed union. The pool's `transfer` field is `string` for transport
// simplicity; the actual values flow through unchanged from the
// orchestrator's HdrCompositeContext and the function validates at
// its own boundary.
blitRgba8OverRgb48le(
rgba,
destBuf,
width,
height,
transfer as Parameters<typeof blitRgba8OverRgb48le>[4],
);
const blitMs = Date.now() - blitStart;
const reply: DecodeBlitJobOk = {
ok: true,
png,
dest,
decodeMs,
blitMs,
};
parentPort!.postMessage(reply, [png, dest]);
} catch (err) {
const reply: DecodeBlitJobErr = {
ok: false,
error: err instanceof Error ? err.message : String(err),
png,
dest,
};
parentPort!.postMessage(reply, [png, dest]);
}
});
}
@@ -1,384 +0,0 @@
/**
* Tests for the hf#732 lever-4 PNG decode + alpha-blit worker pool. Like the
* shader-blend pool tests next door, these are correctness-critical: a
* regression either corrupts every composited DOM layer or leaks worker
* handles. Tests pin three properties:
*
* 1. Byte-equivalence with the inline path. The worker calls the exact
* same `decodePng` + `blitRgba8OverRgb48le` the inline path uses, so
* the round-trip must reproduce the inline result to the last byte.
* 2. Buffer-transfer correctness across the 8KB Node pool threshold.
* The pool's dispatcher must NOT throw `DataCloneError` for inputs
* that happen to live in the shared 8KB pool (small PNGs, etc.).
* 3. Concurrent dispatch + pipelining. N concurrent `run` calls
* against a pool sized to N all complete with correct output. The
* pipelining test asserts that decode/blit of frame N overlaps the
* kickoff of frame N+1's "capture" (simulated by a deferred Promise),
* proving the pool isn't accidentally serializing.
*
* The pool's clean-shutdown path is also exercised so a test failure
* doesn't leak handles into other tests.
*/
import { afterEach, describe, expect, it } from "vitest";
import { deflateSync } from "zlib";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { decodePng, blitRgba8OverRgb48le } from "@hyperframes/engine/alpha-blit";
import {
createPngDecodeBlitWorkerPool,
type PngDecodeBlitWorkerPool,
} from "./pngDecodeBlitWorkerPool.js";
const W = 16;
const H = 8;
const RGB48_BYTES = W * H * 6;
/**
* Synthesize a minimal RGBA8 PNG with a uniform color. Skips CRC32
* because `decodePng` does not verify checksums. Produces an output that's
* intentionally tiny (well under 8KB) so the test exercises the
* shared-pool-detection path in the dispatcher.
*/
function makeUniformRgbaPng(
w: number,
h: number,
r: number,
g: number,
b: number,
a: number,
): Buffer {
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; // 8-bit depth
ihdr[9] = 6; // RGBA
ihdr[10] = 0; // deflate
ihdr[11] = 0; // no filter
ihdr[12] = 0; // non-interlaced
const rows: Buffer[] = [];
for (let y = 0; y < h; y++) {
rows.push(Buffer.from([0])); // PNG row filter byte (None)
for (let x = 0; x < w; x++) {
rows.push(Buffer.from([r, g, b, a]));
}
}
const idatData = deflateSync(Buffer.concat(rows));
function chunk(type: string, data: Buffer): Buffer {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, "ascii");
const crc = Buffer.alloc(4); // skipped by decoder
return Buffer.concat([len, typeBuf, data, crc]);
}
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", idatData),
chunk("IEND", Buffer.alloc(0)),
]);
}
describe("PngDecodeBlitWorkerPool", () => {
const pools: PngDecodeBlitWorkerPool[] = [];
afterEach(async () => {
while (pools.length > 0) {
const p = pools.pop();
if (p) await p.terminate();
}
});
async function makePool(size: number): Promise<PngDecodeBlitWorkerPool> {
const p = await createPngDecodeBlitWorkerPool({ size });
pools.push(p);
return p;
}
it("produces byte-identical output to the inline decode+blit path", async () => {
const pool = await makePool(1);
const png = makeUniformRgbaPng(W, H, 255, 0, 0, 255);
// Pool path: blit onto a zero-filled rgb48le canvas.
const poolDest = Buffer.alloc(RGB48_BYTES);
const poolResult = await pool.run({
png: Buffer.from(png), // independent copy — the input may be detached
dest: poolDest,
width: W,
height: H,
transfer: "srgb",
});
// Reference: inline decode+blit onto a separately allocated canvas.
const refDest = Buffer.alloc(RGB48_BYTES);
const { data: refRgba } = decodePng(png);
blitRgba8OverRgb48le(refRgba, refDest, W, H, "srgb");
expect(Buffer.compare(poolResult.dest, refDest)).toBe(0);
});
it("composites OVER existing rgb48le content (alpha blend semantics)", async () => {
const pool = await makePool(1);
// Half-transparent red over a green background.
const png = makeUniformRgbaPng(W, H, 255, 0, 0, 128);
const poolDest = Buffer.alloc(RGB48_BYTES);
for (let i = 0; i < W * H; i++) {
const off = i * 6;
poolDest.writeUInt16LE(0, off); // R
poolDest.writeUInt16LE(60000, off + 2); // G
poolDest.writeUInt16LE(0, off + 4); // B
}
const poolResult = await pool.run({
png: Buffer.from(png),
dest: poolDest,
width: W,
height: H,
transfer: "srgb",
});
// Inline reference applies the same blend to its own copy of the
// green background.
const refDest = Buffer.alloc(RGB48_BYTES);
for (let i = 0; i < W * H; i++) {
const off = i * 6;
refDest.writeUInt16LE(0, off);
refDest.writeUInt16LE(60000, off + 2);
refDest.writeUInt16LE(0, off + 4);
}
const { data: refRgba } = decodePng(png);
blitRgba8OverRgb48le(refRgba, refDest, W, H, "srgb");
expect(Buffer.compare(poolResult.dest, refDest)).toBe(0);
});
it("survives small PNGs that would otherwise hit the Node 8KB shared pool", async () => {
// A 16×8 RGBA PNG compresses to a few hundred bytes — comfortably
// under the 8KB Buffer pool threshold. If the dispatcher fails to
// copy-out before postMessage, this throws DataCloneError.
const pool = await makePool(1);
const png = makeUniformRgbaPng(W, H, 50, 100, 200, 255);
expect(png.byteLength).toBeLessThan(8192);
const result = await pool.run({
png,
dest: Buffer.alloc(RGB48_BYTES),
width: W,
height: H,
transfer: "srgb",
});
expect(result.dest.byteLength).toBe(RGB48_BYTES);
});
it("runs N concurrent decode+blit tasks against an N-wide pool", async () => {
const pool = await makePool(4);
const png = makeUniformRgbaPng(W, H, 200, 100, 50, 255);
const tasks = Array.from({ length: 4 }, () =>
pool.run({
png: Buffer.from(png),
dest: Buffer.alloc(RGB48_BYTES),
width: W,
height: H,
transfer: "srgb",
}),
);
const results = await Promise.all(tasks);
// All 4 results must match each other (same input) AND match the
// inline reference.
const refDest = Buffer.alloc(RGB48_BYTES);
const { data: refRgba } = decodePng(png);
blitRgba8OverRgb48le(refRgba, refDest, W, H, "srgb");
for (const r of results) {
expect(Buffer.compare(r.dest, refDest)).toBe(0);
}
});
it("permits frame N+1 capture to proceed while frame N decode+blit is in flight", async () => {
// Pipelining proof: kick off a "decode+blit" task that we can hold by
// virtue of the worker actually running, then concurrently kick off a
// simulated next-frame capture (a Promise that resolves immediately).
// The simulated capture must resolve BEFORE the decode+blit, proving
// the decode+blit isn't blocking the main thread.
const pool = await makePool(2);
const png = makeUniformRgbaPng(W, H, 50, 100, 200, 255);
// Sentinel timestamps to verify overlap.
const captureStarted: number[] = [];
const captureDone: number[] = [];
const decodeBlitStarted: number[] = [];
const decodeBlitDone: number[] = [];
decodeBlitStarted.push(Date.now());
const decodeBlitPromise = pool
.run({
png: Buffer.from(png),
dest: Buffer.alloc(RGB48_BYTES),
width: W,
height: H,
transfer: "srgb",
})
.then((r) => {
decodeBlitDone.push(Date.now());
return r;
});
// Simulated next-frame CDP capture — a microtask-y await that yields.
// On a non-pipelined (synchronous-inline) path this would have to wait
// for the decode+blit, but the pool dispatches to a worker thread so
// the main thread is free to start the next "capture" immediately.
captureStarted.push(Date.now());
await new Promise((resolve) => setImmediate(resolve));
captureDone.push(Date.now());
await decodeBlitPromise;
expect(captureDone[0]).toBeDefined();
expect(decodeBlitDone[0]).toBeDefined();
// The simulated capture must have completed before the decode+blit —
// proof that the pool is NOT blocking the main thread.
expect(captureDone[0]!).toBeLessThanOrEqual(decodeBlitDone[0]!);
});
it("spawns from an explicit workerEntryPath, bypassing the import.meta.url resolver", async () => {
// Regression for the hf#677 bundled-CLI bug: when the pool is inlined
// into a separate bundle (e.g. cli.js), `import.meta.url` resolves to
// the bundle's path rather than the bundled worker's emitted path, and
// the sibling-probe fallback computes a path the worker file does not
// live at. The explicit `workerEntryPath` plumbed by the call site
// bypasses the heuristic entirely.
const here = dirname(fileURLToPath(import.meta.url));
const explicitPath = resolve(here, "pngDecodeBlitWorker.ts");
const pool = await createPngDecodeBlitWorkerPool({
size: 1,
workerEntryPath: explicitPath,
});
pools.push(pool);
const png = makeUniformRgbaPng(W, H, 64, 128, 192, 255);
const dest = Buffer.alloc(RGB48_BYTES);
const result = await pool.run({
png: Buffer.from(png),
dest,
width: W,
height: H,
transfer: "srgb",
});
// Compare to inline reference to confirm the explicit-path spawn actually
// ran real work (not just spawned and crashed silently).
const refDest = Buffer.alloc(RGB48_BYTES);
const { data: refRgba } = decodePng(png);
blitRgba8OverRgb48le(refRgba, refDest, W, H, "srgb");
expect(Buffer.compare(result.dest, refDest)).toBe(0);
});
it("terminates cleanly even with queued tasks", async () => {
const pool = await makePool(1);
const png = makeUniformRgbaPng(W, H, 1, 2, 3, 255);
// Two tasks: the first dispatches, the second queues.
const p1 = pool.run({
png: Buffer.from(png),
dest: Buffer.alloc(RGB48_BYTES),
width: W,
height: H,
transfer: "srgb",
});
const p2 = pool.run({
png: Buffer.from(png),
dest: Buffer.alloc(RGB48_BYTES),
width: W,
height: H,
transfer: "srgb",
});
await pool.terminate();
pools.pop(); // already terminated; don't try to re-terminate in afterEach
// Both must settle (resolve or reject). The first MAY resolve if the
// worker finished before terminate() raced in; the second MUST reject
// because it never got to dispatch.
const r1 = await p1.catch((e: unknown) => e);
const r2 = await p2.catch((e: unknown) => e);
if (r2 instanceof Error) {
expect(r2.message).toMatch(/terminated/);
} else {
// If both raced to resolve before terminate, that's also acceptable
// — but only the second one is guaranteed-rejected. Accept either
// shape; the goal of the test is "no leaked handles, no unhandled
// rejection".
expect(r2).toBeDefined();
}
// r1 is "either resolved with a result OR rejected with terminated";
// both are acceptable.
expect(r1).toBeDefined();
});
describe("crash recovery", () => {
const CRASH_WORKER = resolve(
dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
"crashOnMessageWorker.mjs",
);
async function makeCrashPool(size: number): Promise<PngDecodeBlitWorkerPool> {
const p = await createPngDecodeBlitWorkerPool({ size, workerEntryPath: CRASH_WORKER });
pools.push(p);
return p;
}
function blitReq(): Parameters<PngDecodeBlitWorkerPool["run"]>[0] {
return {
png: Buffer.from([0]),
dest: Buffer.alloc(RGB48_BYTES),
width: W,
height: H,
transfer: "srgb",
};
}
function settledWithin(p: Promise<unknown>, ms = 3000): Promise<string> {
return Promise.race([
p.then(
() => "resolved",
() => "rejected",
),
new Promise<string>((r) => setTimeout(() => r("hung"), ms)),
]);
}
it("rejects the in-flight task when its only worker crashes, then fails subsequent runs fast", async () => {
const pool = await makeCrashPool(1);
expect(await settledWithin(pool.run(blitReq()))).toBe("rejected");
// Dead slot must be excluded; a later run fails fast rather than hanging
// on a postMessage to the terminated worker.
expect(await settledWithin(pool.run(blitReq()))).toBe("rejected");
});
it("rejects a queued task on crash instead of leaving it to hang", async () => {
const pool = await makeCrashPool(1);
const inFlight = settledWithin(pool.run(blitReq()));
const queued = settledWithin(pool.run(blitReq()));
expect(await inFlight).toBe("rejected");
expect(await queued).toBe("rejected");
});
it("never wedges the pool when slots die", async () => {
const pool = await makeCrashPool(2);
const results = await Promise.all([
settledWithin(pool.run(blitReq())),
settledWithin(pool.run(blitReq())),
settledWithin(pool.run(blitReq())),
]);
expect(results).not.toContain("hung");
});
});
});
@@ -1,455 +0,0 @@
/**
* Pool of Node `worker_threads` Workers for off-main-thread PNG decode +
* rgba8-over-rgb48le blit. The hf#732 lever-4 follow-up: capture (Chrome
* compositor) and decode+blit (Node CPU) were previously serialized per
* frame inside `captureTransitionFrame` / `compositeHdrFrame`. Each frame
* waited ~30 ms (decode) + ~50 ms (blit) on the Node main thread before the
* next CDP screenshot could begin. With the decode+blit on a worker pool,
* the calling thread can kick off the next CDP capture immediately and the
* decode+blit overlaps Chrome's screenshot work.
*
* Why a SEPARATE pool from `shaderTransitionWorkerPool`:
*
* - Different work shapes: shader-blend reads 2× rgb48le, writes 1×
* rgb48le. Decode+blit reads 1× PNG bytes (variable size, often
* ~250 KB for 854×480 with sparse DOM content), allocates a temporary
* RGBA8 buffer, and writes 1× rgb48le (over an existing destination).
* - Different concurrency profile: shader-blend fires once per
* transition frame; decode+blit fires once per DOM layer per frame
* (typically 3-6× per normal frame, 2× per transition frame). The
* pools have very different dispatch rates and queuing characteristics.
* - Sizing them independently lets us tune each to its bottleneck without
* starving the other.
*
* API:
*
* const pool = await createPngDecodeBlitWorkerPool({ size, log });
* const result = await pool.run({
* png: pngBuffer, // alpha-channel PNG from CDP (zero-copy in)
* dest: rgb48leDestBuffer, // rgb48le canvas to blend onto (zero-copy in)
* width, height,
* transfer: "srgb" | "pq" | "hlg" | etc.,
* });
* // result.dest is the SAME memory as the input `dest`, but re-attached
* // to the main thread. The input Buffer is detached on dispatch — the
* // caller must use `result.dest` for any subsequent reads/writes.
* await pool.terminate();
*
* Buffer transfer contract:
*
* The PNG bytes are transferred IN with `transferList: [png.buffer]` so
* we don't copy them across the worker boundary. The `dest` rgb48le
* ArrayBuffer is also transferred IN (and back OUT) so the blit happens
* in place on the same memory the caller pre-allocated. After `run`
* resolves, the caller MUST swap its Buffer reference to `result.dest`
* — the original Buffer's underlying ArrayBuffer is detached on dispatch.
*
* The intermediate RGBA8 decode buffer is allocated INSIDE the worker
* and dropped on the worker side — there is no main-thread allocation
* for it. The PNG ArrayBuffer is transferred back too so the caller can
* release it.
*/
import { Worker } from "node:worker_threads";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { cpus } from "node:os";
interface PoolLogger {
info?: (msg: string, meta?: Record<string, unknown>) => void;
warn?: (msg: string, meta?: Record<string, unknown>) => void;
error?: (msg: string, meta?: Record<string, unknown>) => void;
}
export interface PngDecodeBlitPoolOptions {
/** Number of worker threads. Clamped to [1, cpus().length]. */
size: number;
/** Optional logger; falls back to no-op. */
log?: PoolLogger;
/**
* Absolute filesystem path to the worker entry module. When provided, the
* pool spawns workers from this exact path and skips the fallback
* `import.meta.url`-based resolver entirely. Required by callers that
* bundle the worker via a separate build (e.g. the CLI's tsup bundle):
* `import.meta.url` inside the bundled pool resolves to the bundle's own
* location, NOT the bundled worker entry's location, so the heuristic
* resolver below cannot find the worker. Path extension determines the
* loader behaviour (`.ts` → tsx/esm loader is appended to execArgv).
*/
workerEntryPath?: string;
}
export interface PngDecodeBlitRequest {
/** PNG bytes captured from CDP (Page.captureScreenshot). */
png: Buffer;
/**
* Pre-allocated rgb48le destination canvas (width*height*6 bytes). The
* blit composites the decoded RGBA8 image OVER this buffer's existing
* contents in `transfer` space.
*/
dest: Buffer;
width: number;
height: number;
/**
* Composite color space tag matching the engine's `CompositeTransfer`
* union. Passed through to `blitRgba8OverRgb48le` in the worker.
*/
transfer: string;
}
export interface PngDecodeBlitResult {
/**
* Re-attached destination buffer holding the composited rgb48le pixels.
* Same memory as the request's `dest`, viewed through a fresh Buffer.
*/
dest: Buffer;
/** Per-worker timing: decode duration in ms (excluding postMessage latency). */
decodeMs: number;
/** Per-worker timing: blit duration in ms. */
blitMs: number;
}
interface PendingTask {
req: PngDecodeBlitRequest;
resolve: (r: PngDecodeBlitResult) => void;
reject: (err: Error) => void;
enqueuedAtMs?: number;
traceId?: number;
}
interface WorkerSlot {
worker: Worker;
busy: boolean;
current: PendingTask | null;
/**
* Set once the worker has crashed (`error`) or exited unexpectedly. A dead
* slot must never be dispatched to again: `postMessage` to a terminated
* Worker is a silent no-op (no throw, no reply), so a task routed to it
* would hang forever. The pool does not respawn mid-render, so a dead slot
* stays dead until teardown.
*/
dead: boolean;
}
interface WorkerReply {
ok: boolean;
error?: string;
png: ArrayBuffer;
dest: ArrayBuffer;
decodeMs?: number;
blitMs?: number;
}
export interface PngDecodeBlitWorkerPool {
readonly size: number;
run(req: PngDecodeBlitRequest): Promise<PngDecodeBlitResult>;
terminate(): Promise<void>;
}
/**
* Resolve the path to the compiled worker module.
*
* Resolution order (first match wins):
* 1. Explicit `workerEntryPath` factory option — callers that bundle the
* worker via a separate build pipeline (e.g. the CLI's tsup bundle that
* emits `pngDecodeBlitWorker.js` next to `cli.js`) must use this. The
* bundled-CLI case is the *only* one where the fallback below cannot
* find the worker: `import.meta.url` inside the inlined pool resolves
* to the bundle path, not the worker's emitted path, so the sibling
* probe lands in the wrong directory.
* 2. `HF_PNG_DECODE_BLIT_WORKER_ENTRY` env var — test/dev infra override.
* 3. Same-directory `.js` sibling — works when both pool source and
* worker source compile into the same `dist/services/` directory
* (in-tree dev builds and the colocated tsc emit).
* 4. Same-directory `.ts` sibling — vitest/bun raw-TS execution path.
*/
function resolveWorkerEntry(explicit: string | undefined): { path: string; isTs: boolean } {
if (explicit && explicit.length > 0) {
return { path: explicit, isTs: explicit.endsWith(".ts") };
}
const override = process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY;
if (override && override.length > 0) {
const isTs = override.endsWith(".ts");
return { path: override, isTs };
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
const jsPath = join(moduleDir, "pngDecodeBlitWorker.js");
if (existsSync(jsPath)) return { path: jsPath, isTs: false };
const tsPath = join(moduleDir, "pngDecodeBlitWorker.ts");
return { path: tsPath, isTs: true };
}
/**
* Mirror of `shaderTransitionWorkerPool.buildExecArgv`. Worker threads
* inherit the parent's loader only if the relevant flag is present on
* `process.execArgv`; under vitest the tsx loader is NOT exposed there, so
* we append `--import tsx/esm` when the resolved entry is `.ts` and no
* loader is detected. Best-effort: silently no-ops if `tsx/esm` can't be
* resolved (prod bundle).
*/
function buildExecArgv(entryIsTs: boolean): string[] {
const inherited = [...process.execArgv];
if (!entryIsTs) return inherited;
const hasLoader = inherited.some(
(a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import"),
);
if (hasLoader) return inherited;
try {
const require = createRequire(import.meta.url);
const tsxEsm = require.resolve("tsx/esm");
inherited.push("--import", pathToFileURL(tsxEsm).href);
} catch {
// tsx not installed (prod) — leave execArgv as-is.
}
return inherited;
}
export async function createPngDecodeBlitWorkerPool(
opts: PngDecodeBlitPoolOptions,
): Promise<PngDecodeBlitWorkerPool> {
const cpuCount = Math.max(1, cpus().length);
const size = Math.max(1, Math.min(opts.size, cpuCount));
const log = opts.log ?? {};
const { path: entry, isTs: entryIsTs } = resolveWorkerEntry(opts.workerEntryPath);
const slots: WorkerSlot[] = [];
const queue: PendingTask[] = [];
let terminated = false;
const traceEnabled = process.env.HF_PNG_DECODE_BLIT_POOL_TRACE === "1";
let nextTaskId = 0;
const execArgv = buildExecArgv(entryIsTs);
// When every worker has died there is no live thread left to drain the
// queue, so any waiting tasks would hang forever. Reject them instead.
const failQueueIfNoLiveSlots = (): void => {
if (slots.some((s) => !s.dead)) return;
while (queue.length > 0) {
const t = queue.shift();
if (t) t.reject(new Error("png-decode-blit pool has no live workers; task abandoned"));
}
};
const dispatchNext = (slot: WorkerSlot): void => {
if (terminated || slot.busy || slot.dead) return;
const task = queue.shift();
if (!task) return;
slot.busy = true;
slot.current = task;
if (traceEnabled) {
const slotIdx = slots.indexOf(slot);
const waitMs = task.enqueuedAtMs ? Date.now() - task.enqueuedAtMs : 0;
const busyCount = slots.filter((s) => s.busy).length;
log.info?.("[pngDecodeBlitPool] dispatch", {
task: task.traceId,
slot: slotIdx,
waitMs,
busyCount,
queueDepth: queue.length,
});
}
const { png, dest, width, height, transfer } = task.req;
// Transfer the PNG bytes (input) and the rgb48le dest (in-place blit
// target) across the worker boundary. Both are detached on the main
// thread until the reply re-attaches them.
//
// Node `Buffer.alloc(N)` allocates a dedicated ArrayBuffer for buffers
// above 8KB (the pool threshold); below that, Buffers are slices over
// a shared 8KB pool ArrayBuffer. `postMessage` with `transferList`
// REJECTS the shared pool with `DataCloneError: Cannot transfer
// object of unsupported type`. The rgb48le `dest` buffers in the
// hybrid path are always > 8KB (854×480×6 ≈ 2.4MB) so they're fine,
// but PNG inputs (variable size, typically 30-300KB but can be < 8KB
// for empty layers) AND any small upstream Buffers can hit the pool.
//
// Safety net: if the underlying ArrayBuffer is larger than the Buffer
// view OR if the Buffer view doesn't cover the ArrayBuffer exactly,
// we copy into a fresh dedicated ArrayBuffer before transfer. Cost is
// one allocation + memcpy of the PNG bytes — small versus the
// postMessage round-trip we're already paying.
const pngBackingFitsExactly = png.byteOffset === 0 && png.byteLength === png.buffer.byteLength;
const pngSource: Buffer = pngBackingFitsExactly ? png : Buffer.from(png); // Buffer.from(Buffer) copies into a new pool slot
// For very small PNGs, `Buffer.from(buf)` may still land in the pool.
// Force a dedicated ArrayBuffer with `Uint8Array.slice().buffer`.
let abPng: ArrayBuffer;
let pngOffset: number;
let pngLength: number;
if (pngSource.byteOffset === 0 && pngSource.byteLength === pngSource.buffer.byteLength) {
abPng = pngSource.buffer as ArrayBuffer;
pngOffset = 0;
pngLength = pngSource.byteLength;
} else {
const copied = new Uint8Array(pngSource.byteLength);
copied.set(pngSource);
abPng = copied.buffer;
pngOffset = 0;
pngLength = copied.byteLength;
}
// The rgb48le `dest` should always have a dedicated ArrayBuffer
// (`Buffer.alloc` above the 8KB pool threshold gives one). Defensive
// path mirrors the PNG handling for symmetry.
let abDest: ArrayBuffer;
let destOffset: number;
let destLength: number;
if (dest.byteOffset === 0 && dest.byteLength === dest.buffer.byteLength) {
abDest = dest.buffer as ArrayBuffer;
destOffset = 0;
destLength = dest.byteLength;
} else {
// Should never happen for hybrid-path canvases. Take the
// copy-and-transfer path so we don't crash; but log so we surface
// any future allocator change that violates the invariant.
log.warn?.("[pngDecodeBlitPool] dest buffer is a slice over a larger ArrayBuffer; copying", {
offset: dest.byteOffset,
length: dest.byteLength,
backingSize: dest.buffer.byteLength,
});
const copied = new Uint8Array(dest.byteLength);
copied.set(dest);
abDest = copied.buffer;
destOffset = 0;
destLength = copied.byteLength;
}
try {
slot.worker.postMessage(
{
png: abPng,
pngOffset,
pngLength,
dest: abDest,
destOffset,
destLength,
width,
height,
transfer,
},
[abPng, abDest],
);
} catch (err) {
slot.busy = false;
slot.current = null;
task.reject(err instanceof Error ? err : new Error(String(err)));
}
};
const onWorkerMessage = (slot: WorkerSlot, reply: WorkerReply): void => {
const task = slot.current;
slot.current = null;
slot.busy = false;
if (!task) {
dispatchNext(slot);
return;
}
if (!reply.ok) {
task.reject(new Error(reply.error ?? "png-decode-blit worker failed"));
} else {
// Re-attach the dest ArrayBuffer as a Node Buffer view. We wrap the
// full reply.dest (offset=0, length=byteLength) because the
// dispatch path normalizes to offset-0 ArrayBuffers (either the
// caller's original or a fresh copy we made on the dispatch side).
// The blit work happened over the full ArrayBuffer in the worker;
// returning a view that matches that is the correct semantics.
task.resolve({
dest: Buffer.from(reply.dest, 0, reply.dest.byteLength),
decodeMs: reply.decodeMs ?? 0,
blitMs: reply.blitMs ?? 0,
});
}
dispatchNext(slot);
};
const onWorkerError = (slot: WorkerSlot, err: Error): void => {
const task = slot.current;
slot.current = null;
slot.busy = false;
// Mark dead before rejecting and before draining the queue so this slot is
// excluded from future dispatch; postMessage to its terminated worker would
// be a silent no-op and any task routed here would hang.
slot.dead = true;
if (task) {
task.reject(
new Error(`png-decode-blit worker crashed mid-task: ${err.message}; dest buffer lost`),
);
}
log.warn?.("[pngDecodeBlitWorkerPool] worker errored", { err: err.message });
failQueueIfNoLiveSlots();
};
const onWorkerExit = (slot: WorkerSlot, code: number): void => {
if (terminated) return;
slot.dead = true;
if (slot.current) {
slot.current.reject(new Error(`png-decode-blit worker exited (code=${code}) mid-task`));
slot.current = null;
slot.busy = false;
}
log.warn?.("[pngDecodeBlitWorkerPool] worker exited unexpectedly", { code });
failQueueIfNoLiveSlots();
};
try {
for (let i = 0; i < size; i++) {
const worker = new Worker(entry, { execArgv });
const slot: WorkerSlot = { worker, busy: false, current: null, dead: false };
worker.on("message", (msg: WorkerReply) => onWorkerMessage(slot, msg));
worker.on("error", (err: unknown) =>
onWorkerError(slot, err instanceof Error ? err : new Error(String(err))),
);
worker.on("exit", (code) => onWorkerExit(slot, code));
slots.push(slot);
}
} catch (err) {
terminated = true;
await Promise.all(slots.map((s) => s.worker.terminate().catch(() => undefined)));
throw err;
}
log.info?.("[pngDecodeBlitWorkerPool] spawned", { size, entry });
return {
size,
async run(req: PngDecodeBlitRequest): Promise<PngDecodeBlitResult> {
if (terminated) {
throw new Error("png-decode-blit pool already terminated");
}
return new Promise<PngDecodeBlitResult>((resolve, reject) => {
const task: PendingTask = traceEnabled
? { req, resolve, reject, enqueuedAtMs: Date.now(), traceId: ++nextTaskId }
: { req, resolve, reject };
const idle = slots.find((s) => !s.busy && !s.dead);
if (idle) {
queue.unshift(task);
dispatchNext(idle);
} else if (slots.some((s) => !s.dead)) {
// A live worker is busy; it drains the queue when it completes.
queue.push(task);
} else {
// Every worker has died — don't hang waiting for a dispatch that
// can never happen.
reject(new Error("png-decode-blit pool has no live workers"));
}
});
},
async terminate(): Promise<void> {
if (terminated) return;
terminated = true;
while (queue.length > 0) {
const t = queue.shift();
if (t) t.reject(new Error("png-decode-blit pool terminated before task ran"));
}
for (const slot of slots) {
const t = slot.current;
if (t) {
slot.current = null;
slot.busy = false;
t.reject(new Error("png-decode-blit pool terminated mid-task"));
}
}
await Promise.all(slots.map((s) => s.worker.terminate().catch(() => undefined)));
log.info?.("[pngDecodeBlitWorkerPool] terminated", { size });
},
};
}
@@ -90,6 +90,30 @@ export function addHdrTiming(
perf.timings[key] += Date.now() - startMs; perf.timings[key] += Date.now() - startMs;
} }
export function timeHdrPhase<T>(
perf: HdrPerfCollector | undefined,
key: HdrPerfTimingKey,
fn: () => T,
): T {
if (!perf) return fn();
const start = Date.now();
const result = fn();
addHdrTiming(perf, key, start);
return result;
}
export async function timeHdrPhaseAsync<T>(
perf: HdrPerfCollector | undefined,
key: HdrPerfTimingKey,
fn: () => Promise<T>,
): Promise<T> {
if (!perf) return fn();
const start = Date.now();
const result = await fn();
addHdrTiming(perf, key, start);
return result;
}
function averageTiming(totalMs: number, count: number): number { function averageTiming(totalMs: number, count: number): number {
return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0; return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0;
} }
@@ -4,10 +4,9 @@
*/ */
import { fpsToNumber } from "@hyperframes/core"; import { fpsToNumber } from "@hyperframes/core";
import type { CaptureCalibrationSample, CaptureCostEstimate } from "./captureCost.js";
import type { import type {
CaptureAttemptSummary, CaptureAttemptSummary,
CaptureCalibrationSample,
CaptureCostEstimate,
HdrDiagnostics, HdrDiagnostics,
RenderJob, RenderJob,
RenderPerfSummary, RenderPerfSummary,
@@ -11,6 +11,7 @@
import { rmSync } from "node:fs"; import { rmSync } from "node:fs";
import { import {
type BeforeCaptureHook,
type CaptureSession, type CaptureSession,
type ElementStackingInfo, type ElementStackingInfo,
applyDomLayerMask, applyDomLayerMask,
@@ -23,14 +24,18 @@ import {
import type { ProducerLogger } from "../../../logger.js"; import type { ProducerLogger } from "../../../logger.js";
import { import {
type HdrCompositeContext, type HdrCompositeContext,
type HdrPerfCollector,
type HdrVideoFrameSource, type HdrVideoFrameSource,
type TransitionRange, type TransitionRange,
addHdrTiming,
blitHdrImageLayer, blitHdrImageLayer,
blitHdrVideoLayer, blitHdrVideoLayer,
closeHdrVideoFrameSource, closeHdrVideoFrameSource,
} from "../../renderOrchestrator.js"; } from "../../hdrCompositor.js";
import {
type HdrPerfCollector,
type HdrPerfTimingKey,
timeHdrPhase,
timeHdrPhaseAsync,
} from "../hdrPerf.js";
// ─── Hybrid path gating + partitioning ───────────────────────────────────── // ─── Hybrid path gating + partitioning ─────────────────────────────────────
@@ -102,6 +107,53 @@ export interface LayeredTransitionBuffers {
output: Buffer; output: Buffer;
} }
// ─── Seek + inject + stacking query (shared across all three loop paths) ──
/**
* Seek the page to `time` and run the optional before-capture hook.
* Used by `captureSceneIntoBuffer` which receives stacking info from
* its caller, and by `seekInjectAndQueryStacking` which appends a
* stacking query.
*/
async function seekAndInject(
page: CaptureSession["page"],
time: number,
beforeCaptureHook: BeforeCaptureHook | null,
hdrPerf: HdrPerfCollector | undefined,
seekKey: HdrPerfTimingKey,
injectKey: HdrPerfTimingKey,
): Promise<void> {
await timeHdrPhaseAsync(hdrPerf, seekKey, () =>
page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time),
);
if (beforeCaptureHook) {
await timeHdrPhaseAsync(hdrPerf, injectKey, () => beforeCaptureHook(page, time));
}
}
/**
* Seek the page to `time`, run the optional before-capture hook, then
* query element stacking order. Each phase is individually timed via the
* caller-provided perf keys so the sequential loop, hybrid worker, and
* per-scene transition capture each emit the correct telemetry label
* (`frameSeekMs` vs. `domLayerSeekMs`, etc.).
*/
export async function seekInjectAndQueryStacking(
page: CaptureSession["page"],
time: number,
beforeCaptureHook: BeforeCaptureHook | null,
nativeHdrIds: Set<string>,
hdrPerf: HdrPerfCollector | undefined,
seekKey: HdrPerfTimingKey,
injectKey: HdrPerfTimingKey,
stackingKey: HdrPerfTimingKey,
): Promise<ElementStackingInfo[]> {
await seekAndInject(page, time, beforeCaptureHook, hdrPerf, seekKey, injectKey);
return timeHdrPhaseAsync(hdrPerf, stackingKey, () => queryElementStacking(page, nativeHdrIds));
}
// ─── Per-scene capture (shared by sequential transition + hybrid worker) ── // ─── Per-scene capture (shared by sequential transition + hybrid worker) ──
export interface CaptureSceneArgs { export interface CaptureSceneArgs {
@@ -142,16 +194,14 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void>
log, log,
frameIdx, frameIdx,
} = a; } = a;
let timingStart = Date.now(); await seekAndInject(
await session.page.evaluate((t: number) => { session.page,
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); time,
}, time); beforeCaptureHook,
addHdrTiming(hdrPerf, "domLayerSeekMs", timingStart); hdrPerf,
if (beforeCaptureHook) { "domLayerSeekMs",
timingStart = Date.now(); "domLayerInjectMs",
await beforeCaptureHook(session.page, time); );
addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart);
}
for (const el of stackingInfo) { for (const el of stackingInfo) {
if (!el.isHdr || !sceneIds.has(el.id)) continue; if (!el.isHdr || !sceneIds.has(el.id)) continue;
if (nativeHdrImageIds.has(el.id)) { if (nativeHdrImageIds.has(el.id)) {
@@ -189,22 +239,20 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void>
.map((e) => e.id) .map((e) => e.id)
.filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id)); .filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id));
if (hdrPerf) hdrPerf.domLayerCaptures += 1; if (hdrPerf) hdrPerf.domLayerCaptures += 1;
timingStart = Date.now(); await timeHdrPhaseAsync(hdrPerf, "domMaskApplyMs", () =>
await applyDomLayerMask(session.page, showIds, hideIds); applyDomLayerMask(session.page, showIds, hideIds),
addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart); );
timingStart = Date.now(); const domPng = await timeHdrPhaseAsync(hdrPerf, "domScreenshotMs", () =>
const domPng = await captureAlphaPng(session.page, width, height); captureAlphaPng(session.page, width, height),
addHdrTiming(hdrPerf, "domScreenshotMs", timingStart); );
timingStart = Date.now(); await timeHdrPhaseAsync(hdrPerf, "domMaskRemoveMs", () =>
await removeDomLayerMask(session.page, hideIds); removeDomLayerMask(session.page, hideIds),
addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart); );
try { try {
timingStart = Date.now(); const { data: domRgba } = timeHdrPhase(hdrPerf, "domPngDecodeMs", () => decodePng(domPng));
const { data: domRgba } = decodePng(domPng); timeHdrPhase(hdrPerf, "domBlitMs", () =>
addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart); blitRgba8OverRgb48le(domRgba, sceneBuf, width, height, compositeTransfer),
timingStart = Date.now(); );
blitRgba8OverRgb48le(domRgba, sceneBuf, width, height, compositeTransfer);
addHdrTiming(hdrPerf, "domBlitMs", timingStart);
} catch (err) { } catch (err) {
log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", { log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", {
frameIndex: frameIdx, frameIndex: frameIdx,
@@ -259,30 +307,28 @@ export async function captureTransitionFrameOnWorker(
hdrPerf.frames += 1; hdrPerf.frames += 1;
hdrPerf.transitionFrames += 1; hdrPerf.transitionFrames += 1;
} }
let timingStart = Date.now(); const stackingInfo = await seekInjectAndQueryStacking(
await session.page.evaluate((t: number) => { session.page,
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); time,
}, time); beforeCaptureHook,
addHdrTiming(hdrPerf, "frameSeekMs", timingStart); nativeHdrIds,
if (beforeCaptureHook) { hdrPerf,
timingStart = Date.now(); "frameSeekMs",
await beforeCaptureHook(session.page, time); "frameInjectMs",
addHdrTiming(hdrPerf, "frameInjectMs", timingStart); "stackingQueryMs",
} );
timingStart = Date.now();
const stackingInfo = await queryElementStacking(session.page, nativeHdrIds);
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
const sceneAIds = new Set(sceneElements[transition.fromScene] ?? []); const sceneAIds = new Set(sceneElements[transition.fromScene] ?? []);
const sceneBIds = new Set(sceneElements[transition.toScene] ?? []); const sceneBIds = new Set(sceneElements[transition.toScene] ?? []);
buffers.bufferA.fill(0); buffers.bufferA.fill(0);
buffers.bufferB.fill(0); buffers.bufferB.fill(0);
for (const [sceneBuf, sceneIds] of [ const sceneCaptures: [Buffer, Set<string>][] = [
[buffers.bufferA, sceneAIds], [buffers.bufferA, sceneAIds],
[buffers.bufferB, sceneBIds], [buffers.bufferB, sceneBIds],
] as const) { ];
for (const [sceneBuf, sceneIds] of sceneCaptures) {
await captureSceneIntoBuffer({ await captureSceneIntoBuffer({
session, session,
sceneBuf: sceneBuf as Buffer, sceneBuf,
sceneIds, sceneIds,
stackingInfo, stackingInfo,
time, time,
@@ -32,19 +32,16 @@ import {
crossfade, crossfade,
initTransparentBackground, initTransparentBackground,
initializeSession, initializeSession,
queryElementStacking,
} from "@hyperframes/engine"; } from "@hyperframes/engine";
import type { FileServerHandle } from "../../fileServer.js"; import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js"; import type { ProducerLogger } from "../../../logger.js";
import { import {
type HdrCompositeContext, type HdrCompositeContext,
type HdrPerfCollector,
type ProgressCallback,
type RenderJob,
type TransitionRange, type TransitionRange,
addHdrTiming,
compositeHdrFrame, compositeHdrFrame,
} from "../../renderOrchestrator.js"; } from "../../hdrCompositor.js";
import { type HdrPerfCollector, addHdrTiming, timeHdrPhaseAsync } from "../hdrPerf.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { writeFileExclusiveSync } from "../shared.js"; import { writeFileExclusiveSync } from "../shared.js";
import { import {
type ShaderTransitionWorkerPool, type ShaderTransitionWorkerPool,
@@ -56,6 +53,7 @@ import {
distributeLayeredHybridFrameRanges, distributeLayeredHybridFrameRanges,
ensureFrameWritten, ensureFrameWritten,
partitionTransitionFrames, partitionTransitionFrames,
seekInjectAndQueryStacking,
} from "./captureHdrFrameShared.js"; } from "./captureHdrFrameShared.js";
import { updateJobStatus } from "../shared.js"; import { updateJobStatus } from "../shared.js";
@@ -307,26 +305,22 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
throw err instanceof Error ? err : new Error(String(err)); throw err instanceof Error ? err : new Error(String(err));
}); });
} else { } else {
const beforeCaptureHook = session.onBeforeCapture; const stackingInfo = await seekInjectAndQueryStacking(
let timingStart = Date.now(); session.page,
await session.page.evaluate((t: number) => { time,
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); session.onBeforeCapture,
}, time); nativeHdrIds,
addHdrTiming(hdrPerf, "frameSeekMs", timingStart); hdrPerf,
if (beforeCaptureHook) { "frameSeekMs",
timingStart = Date.now(); "frameInjectMs",
await beforeCaptureHook(session.page, time); "stackingQueryMs",
addHdrTiming(hdrPerf, "frameInjectMs", timingStart); );
}
timingStart = Date.now();
const stackingInfo = await queryElementStacking(session.page, nativeHdrIds);
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
canvas.fill(0); canvas.fill(0);
// Rebind ctx to this worker's session for per-layer captures // Rebind ctx to this worker's session for per-layer captures
const wctx: HdrCompositeContext = { ...hdrCompositeCtx, domSession: session }; const wctx: HdrCompositeContext = { ...hdrCompositeCtx, domSession: session };
timingStart = Date.now(); await timeHdrPhaseAsync(hdrPerf, "normalCompositeMs", () =>
await compositeHdrFrame(wctx, canvas, time, stackingInfo, undefined, i); compositeHdrFrame(wctx, canvas, time, stackingInfo, undefined, i),
addHdrTiming(hdrPerf, "normalCompositeMs", timingStart); );
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) { if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
writeFileExclusiveSync( writeFileExclusiveSync(
join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`), join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
@@ -36,12 +36,8 @@ import {
} from "@hyperframes/engine"; } from "@hyperframes/engine";
import { fpsToFfmpegArg } from "@hyperframes/core"; import { fpsToFfmpegArg } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js"; import type { ProducerLogger } from "../../../logger.js";
import type { import type { HdrImageBuffer, HdrVideoFrameSource } from "../../hdrCompositor.js";
HdrDiagnostics, import type { HdrDiagnostics, RenderJob } from "../../renderOrchestrator.js";
HdrImageBuffer,
HdrVideoFrameSource,
RenderJob,
} from "../../renderOrchestrator.js";
import type { CompositionMetadata } from "../shared.js"; import type { CompositionMetadata } from "../shared.js";
const NO_FOLLOW_FLAG = constants.O_NOFOLLOW ?? 0; const NO_FOLLOW_FLAG = constants.O_NOFOLLOW ?? 0;
@@ -18,24 +18,27 @@ import {
type TransitionFn, type TransitionFn,
TRANSITIONS, TRANSITIONS,
crossfade, crossfade,
queryElementStacking,
} from "@hyperframes/engine"; } from "@hyperframes/engine";
import type { ProducerLogger } from "../../../logger.js"; import type { ProducerLogger } from "../../../logger.js";
import { import {
type HdrCompositeContext, type HdrCompositeContext,
type HdrPerfCollector,
type ProgressCallback,
type RenderJob,
type TransitionRange, type TransitionRange,
addHdrTiming,
compositeHdrFrame, compositeHdrFrame,
} from "../../renderOrchestrator.js"; } from "../../hdrCompositor.js";
import {
type HdrPerfCollector,
addHdrTiming,
timeHdrPhase,
timeHdrPhaseAsync,
} from "../hdrPerf.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { writeFileExclusiveSync } from "../shared.js"; import { writeFileExclusiveSync } from "../shared.js";
import { import {
captureSceneIntoBuffer, captureSceneIntoBuffer,
cleanupEndedHdrVideos, cleanupEndedHdrVideos,
ensureFrameWritten, ensureFrameWritten,
type LayeredTransitionBuffers, type LayeredTransitionBuffers,
seekInjectAndQueryStacking,
} from "./captureHdrFrameShared.js"; } from "./captureHdrFrameShared.js";
import { updateJobStatus } from "../shared.js"; import { updateJobStatus } from "../shared.js";
@@ -57,7 +60,7 @@ export interface SequentialLoopInput {
hdrTargetTransfer: "pq" | "hlg" | undefined; hdrTargetTransfer: "pq" | "hlg" | undefined;
hdrVideoEndTimes: Map<string, number>; hdrVideoEndTimes: Map<string, number>;
cleanedUpVideos: Set<string>; cleanedUpVideos: Set<string>;
hdrVideoFrameSources: Map<string, import("../../renderOrchestrator.js").HdrVideoFrameSource>; hdrVideoFrameSources: Map<string, import("../../hdrCompositor.js").HdrVideoFrameSource>;
debugDumpEnabled: boolean; debugDumpEnabled: boolean;
debugDumpDir: string | null; debugDumpDir: string | null;
assertNotAborted: () => void; assertNotAborted: () => void;
@@ -106,20 +109,16 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
const time = (i * job.config.fps.den) / job.config.fps.num; const time = (i * job.config.fps.den) / job.config.fps.num;
if (hdrPerf) hdrPerf.frames += 1; if (hdrPerf) hdrPerf.frames += 1;
let timingStart = Date.now(); const stackingInfo = await seekInjectAndQueryStacking(
await domSession.page.evaluate((t: number) => { domSession.page,
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); time,
}, time); beforeCaptureHook,
addHdrTiming(hdrPerf, "frameSeekMs", timingStart); nativeHdrIds,
hdrPerf,
if (beforeCaptureHook) { "frameSeekMs",
timingStart = Date.now(); "frameInjectMs",
await beforeCaptureHook(domSession.page, time); "stackingQueryMs",
addHdrTiming(hdrPerf, "frameInjectMs", timingStart); );
}
timingStart = Date.now();
const stackingInfo = await queryElementStacking(domSession.page, nativeHdrIds);
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame); const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) { if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
@@ -143,19 +142,20 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
(activeTransition.endFrame - activeTransition.startFrame); (activeTransition.endFrame - activeTransition.startFrame);
const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []); const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []); const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
timingStart = Date.now(); timeHdrPhase(hdrPerf, "canvasClearMs", () => {
transitionBuffers.bufferA.fill(0); transitionBuffers.bufferA.fill(0);
transitionBuffers.bufferB.fill(0); transitionBuffers.bufferB.fill(0);
addHdrTiming(hdrPerf, "canvasClearMs", timingStart); });
for (const [sceneBuf, sceneIds] of [ const sceneCaptures: [Buffer, Set<string>][] = [
[transitionBuffers.bufferA, sceneAIds], [transitionBuffers.bufferA, sceneAIds],
[transitionBuffers.bufferB, sceneBIds], [transitionBuffers.bufferB, sceneBIds],
] as const) { ];
for (const [sceneBuf, sceneIds] of sceneCaptures) {
assertNotAborted(); assertNotAborted();
await captureSceneIntoBuffer({ await captureSceneIntoBuffer({
session: domSession, session: domSession,
sceneBuf: sceneBuf as Buffer, sceneBuf,
sceneIds, sceneIds,
stackingInfo, stackingInfo,
time, time,
@@ -189,26 +189,24 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
progress, progress,
); );
addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart); addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
timingStart = Date.now(); await timeHdrPhaseAsync(hdrPerf, "encoderWriteMs", async () =>
ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i); ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i),
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart); );
} else { } else {
if (hdrPerf) hdrPerf.normalFrames += 1; if (hdrPerf) hdrPerf.normalFrames += 1;
timingStart = Date.now(); timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
normalCanvas.fill(0); await timeHdrPhaseAsync(hdrPerf, "normalCompositeMs", () =>
addHdrTiming(hdrPerf, "canvasClearMs", timingStart); compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, undefined, i),
timingStart = Date.now(); );
await compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, undefined, i);
addHdrTiming(hdrPerf, "normalCompositeMs", timingStart);
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) { if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
writeFileExclusiveSync( writeFileExclusiveSync(
join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`), join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
normalCanvas, normalCanvas,
); );
} }
timingStart = Date.now(); await timeHdrPhaseAsync(hdrPerf, "encoderWriteMs", async () =>
ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i); ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i),
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart); );
} }
cleanupEndedHdrVideos({ cleanupEndedHdrVideos({
@@ -57,17 +57,14 @@ import type { ProducerLogger } from "../../../logger.js";
import { createHdrImageTransferCache } from "../../hdrImageTransferCache.js"; import { createHdrImageTransferCache } from "../../hdrImageTransferCache.js";
import { import {
type HdrCompositeContext, type HdrCompositeContext,
type HdrDiagnostics,
type HdrPerfCollector,
type HdrTransitionMeta, type HdrTransitionMeta,
type HdrVideoFrameSource, type HdrVideoFrameSource,
type ProgressCallback,
type RenderJob,
type TransitionRange, type TransitionRange,
closeHdrVideoFrameSource, closeHdrVideoFrameSource,
createHdrPerfCollector,
resolveCompositeTransfer, resolveCompositeTransfer,
} from "../../renderOrchestrator.js"; } from "../../hdrCompositor.js";
import { type HdrPerfCollector, createHdrPerfCollector } from "../hdrPerf.js";
import type { HdrDiagnostics, ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import type { CompositionMetadata } from "../shared.js"; import type { CompositionMetadata } from "../shared.js";
import { import {
decodeHdrImageBuffers, decodeHdrImageBuffers,
@@ -42,6 +42,7 @@ import {
} from "@hyperframes/engine"; } from "@hyperframes/engine";
import type { Fps } from "@hyperframes/core"; import type { Fps } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js"; import type { ProducerLogger } from "../../../logger.js";
import { formatExportFrameName } from "../../../utils/paths.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js"; import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { import {
buildGifPalettegenArgs, buildGifPalettegenArgs,
@@ -236,7 +237,7 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
); );
} }
captured.forEach((name, i) => { captured.forEach((name, i) => {
const dst = join(outputPath, `frame_${String(i + 1).padStart(6, "0")}.png`); const dst = join(outputPath, formatExportFrameName(i, "png"));
copyFileSync(join(framesDir, name), dst); copyFileSync(join(framesDir, name), dst);
}); });
if (hasAudio && audioOutputPath && existsSync(audioOutputPath)) { if (hasAudio && audioOutputPath && existsSync(audioOutputPath)) {
@@ -48,10 +48,9 @@ import { fpsToNumber } from "@hyperframes/core";
import { import {
collectVideoMetadataHints, collectVideoMetadataHints,
collectVideoReadinessSkipIds, collectVideoReadinessSkipIds,
materializeExtractedFramesForCompiledDir,
type RenderJob, type RenderJob,
} from "../../renderOrchestrator.js"; } from "../../renderOrchestrator.js";
import { type CompositionMetadata } from "../shared.js"; import { materializeExtractedFramesForCompiledDir, type CompositionMetadata } from "../shared.js";
import type { ProducerLogger } from "../../../logger.js"; import type { ProducerLogger } from "../../../logger.js";
export interface ExtractVideosStageInput { export interface ExtractVideosStageInput {
@@ -13,10 +13,9 @@ import {
findMissingFrameRanges, findMissingFrameRanges,
getNextRetryWorkerCount, getNextRetryWorkerCount,
isRecoverableParallelCaptureError, isRecoverableParallelCaptureError,
resolveCompositeTransfer,
shouldUseLayeredComposite,
shouldUseStreamingEncode, shouldUseStreamingEncode,
} from "./renderOrchestrator.js"; } from "./renderOrchestrator.js";
import { resolveCompositeTransfer, shouldUseLayeredComposite } from "./hdrCompositor.js";
import { import {
createCaptureCalibrationConfig, createCaptureCalibrationConfig,
estimateCaptureCostMultiplier, estimateCaptureCostMultiplier,
@@ -1,4 +1,4 @@
// fallow-ignore-file unused-export unused-type circular-dependency code-duplication complexity // fallow-ignore-file unused-type circular-dependency code-duplication complexity
/** /**
* Render Orchestrator Service * Render Orchestrator Service
* *
@@ -36,8 +36,6 @@ import {
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
readSync,
closeSync,
readdirSync, readdirSync,
rmSync, rmSync,
statSync, statSync,
@@ -52,7 +50,6 @@ import {
resolveConfig, resolveConfig,
type ExtractionResult, type ExtractionResult,
type ExtractionPhaseBreakdown, type ExtractionPhaseBreakdown,
type HdrTransfer,
closeCaptureSession, closeCaptureSession,
type CaptureOptions, type CaptureOptions,
type CaptureVideoMetadataHint, type CaptureVideoMetadataHint,
@@ -65,18 +62,6 @@ import {
mergeWorkerFrames, mergeWorkerFrames,
type ParallelProgress, type ParallelProgress,
type WorkerTask, type WorkerTask,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
decodePng,
blitRgba8OverRgb48le,
blitRgb48leRegion,
groupIntoLayers,
blitRgb48leAffine,
parseTransformMatrix,
convertTransfer,
type ElementStackingInfo,
type HfTransitionMeta,
getSystemTotalMb, getSystemTotalMb,
LOW_MEMORY_TOTAL_MB_THRESHOLD, LOW_MEMORY_TOTAL_MB_THRESHOLD,
assertConfiguredFfmpegBinariesExist, assertConfiguredFfmpegBinariesExist,
@@ -91,16 +76,15 @@ import {
VIRTUAL_TIME_SHIM, VIRTUAL_TIME_SHIM,
} from "./fileServer.js"; } from "./fileServer.js";
import { defaultLogger, type ProducerLogger } from "../logger.js"; import { defaultLogger, type ProducerLogger } from "../logger.js";
import { type HdrImageTransferCache } from "./hdrImageTransferCache.js";
import { import {
createCompiledFrameSrcResolver, createCompiledFrameSrcResolver,
createMemorySampler, createMemorySampler,
type MemorySampler, type MemorySampler,
updateJobStatus, updateJobStatus,
writeFileExclusiveSync,
} from "./render/shared.js"; } from "./render/shared.js";
import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js"; import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { formatCaptureFrameName } from "../utils/paths.js";
import { resolveEffectiveHdrMode } from "./render/hdrMode.js"; import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
import { buildRenderPerfSummary } from "./render/perfSummary.js"; import { buildRenderPerfSummary } from "./render/perfSummary.js";
import { getCaptureStageBrowserConsole } from "./render/captureStageError.js"; import { getCaptureStageBrowserConsole } from "./render/captureStageError.js";
@@ -118,7 +102,7 @@ import {
type RenderExtractionObservability, type RenderExtractionObservability,
type RenderObservabilitySummary, type RenderObservabilitySummary,
} from "./render/observability.js"; } from "./render/observability.js";
import { type HdrPerfCollector, type HdrPerfSummary, addHdrTiming } from "./render/hdrPerf.js"; import { type HdrPerfCollector, type HdrPerfSummary } from "./render/hdrPerf.js";
import { runCompileStage } from "./render/stages/compileStage.js"; import { runCompileStage } from "./render/stages/compileStage.js";
import { runProbeStage } from "./render/stages/probeStage.js"; import { runProbeStage } from "./render/stages/probeStage.js";
import { runExtractVideosStage } from "./render/stages/extractVideosStage.js"; import { runExtractVideosStage } from "./render/stages/extractVideosStage.js";
@@ -128,6 +112,7 @@ import { runCaptureStreamingStage } from "./render/stages/captureStreamingStage.
import { runCaptureHdrStage } from "./render/stages/captureHdrStage.js"; import { runCaptureHdrStage } from "./render/stages/captureHdrStage.js";
import { runEncodeStage } from "./render/stages/encodeStage.js"; import { runEncodeStage } from "./render/stages/encodeStage.js";
import { runAssembleStage } from "./render/stages/assembleStage.js"; import { runAssembleStage } from "./render/stages/assembleStage.js";
import { shouldUseLayeredComposite } from "./hdrCompositor.js";
function sampleDirectoryBytes(dir: string): number { function sampleDirectoryBytes(dir: string): number {
let total = 0; let total = 0;
@@ -182,47 +167,6 @@ function summarizeExtractionObservability(
}; };
} }
// Diagnostic helpers used by the HDR layered compositor when KEEP_TEMP=1
// is set. They are pure (capture no state), so we keep them at module scope
// to avoid re-creating closures per frame and to make them callable from
// any future composite path that needs to log non-zero pixel counts.
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 ||
buf[p + 3] !== 0 ||
buf[p + 4] !== 0 ||
buf[p + 5] !== 0
)
n++;
}
return n;
}
/**
* 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.
*/
export type HdrTransitionMeta = HfTransitionMeta;
/** Pre-computed frame range for an active transition. */
export interface TransitionRange extends HdrTransitionMeta {
startFrame: number;
endFrame: number;
}
export type RenderStatus = export type RenderStatus =
| "queued" | "queued"
| "preprocessing" | "preprocessing"
@@ -379,36 +323,6 @@ export interface HdrDiagnostics {
imageDecodeFailures: number; imageDecodeFailures: number;
} }
// HDR-pipeline perf collector lives in `./render/hdrPerf.ts`. Re-exported
// from this module so the existing `import { ... } from "./renderOrchestrator"`
// callers keep working unchanged.
export {
type HdrPerfCollector,
type HdrPerfSummary,
type HdrPerfTimingKey,
addHdrTiming,
createHdrPerfCollector,
finalizeHdrPerf,
} from "./render/hdrPerf.js";
// Capture-cost calibration helpers and constants live in `./render/captureCost.ts`.
// Re-exported here for backwards compatibility with existing
// `import { ... } from "./renderOrchestrator"` callers.
export {
type CaptureCalibrationOutcome,
type CaptureCalibrationSample,
type CaptureCostEstimate,
createCaptureCalibrationConfig,
createFailedCaptureCalibrationEstimate,
estimateCaptureCostMultiplier,
estimateMeasuredCaptureCostMultiplier,
logCaptureCalibrationResult,
measureCaptureCostFromSession,
resolveRenderWorkerCount,
runCaptureCalibration,
selectCaptureCalibrationFrames,
} from "./render/captureCost.js";
export interface FrameRange { export interface FrameRange {
startFrame: number; startFrame: number;
endFrame: number; endFrame: number;
@@ -501,15 +415,6 @@ function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger
}; };
} }
// Compile-output helpers (`createCompiledFrameSrcResolver`,
// `materializeExtractedFramesForCompiledDir`) live in `./render/shared.ts`.
// Re-exported from this module for backwards compatibility with existing
// `import { ... } from "./renderOrchestrator"` call sites.
export {
createCompiledFrameSrcResolver,
materializeExtractedFramesForCompiledDir,
} from "./render/shared.js";
export function collectVideoReadinessSkipIds( export function collectVideoReadinessSkipIds(
nativeHdrVideoIds: ReadonlySet<string>, nativeHdrVideoIds: ReadonlySet<string>,
extractedVideos: readonly ExtractedVideoReadinessInput[], extractedVideos: readonly ExtractedVideoReadinessInput[],
@@ -563,7 +468,7 @@ export function findMissingFrameRanges(
let rangeStart: number | null = null; let rangeStart: number | null = null;
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`); const framePath = join(framesDir, formatCaptureFrameName(frameIndex, frameExt));
const missing = !existsSync(framePath); const missing = !existsSync(framePath);
if (missing && rangeStart === null) { if (missing && rangeStart === null) {
rangeStart = frameIndex; rangeStart = frameIndex;
@@ -624,12 +529,6 @@ export function isRecoverableParallelCaptureError(error: unknown): boolean {
); );
} }
// `shouldFallbackToScreenshotAfterCalibrationError` lives in
// `./render/captureCost.ts` alongside the calibration runner. Re-exported
// here for backwards compatibility with existing
// `import { ... } from "./renderOrchestrator"` callers.
export { shouldFallbackToScreenshotAfterCalibrationError } from "./render/captureCost.js";
function countCapturedFrames( function countCapturedFrames(
totalFrames: number, totalFrames: number,
framesDir: string, framesDir: string,
@@ -637,7 +536,7 @@ function countCapturedFrames(
): number { ): number {
let captured = 0; let captured = 0;
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`); const framePath = join(framesDir, formatCaptureFrameName(frameIndex, frameExt));
if (existsSync(framePath)) captured++; if (existsSync(framePath)) captured++;
} }
return captured; return captured;
@@ -779,619 +678,6 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
} }
} }
/**
* Crop an rgb48le buffer to a sub-region. Returns a new Buffer containing
* only the cropped pixels.
*/
function cropRgb48le(
src: Buffer,
srcW: number,
srcH: number,
cropX: number,
cropY: number,
cropW: number,
cropH: number,
): Buffer {
const BPP = 6;
const dst = Buffer.alloc(cropW * cropH * BPP);
for (let row = 0; row < cropH; row++) {
const srcRow = cropY + row;
if (srcRow < 0 || srcRow >= srcH) continue;
const srcOff = (srcRow * srcW + cropX) * BPP;
const dstOff = row * cropW * BPP;
const copyLen = Math.min(cropW, srcW - cropX) * BPP;
if (copyLen > 0) src.copy(dst, dstOff, srcOff, srcOff + copyLen);
}
return dst;
}
/**
* 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, raw read, transfer, transform, and blit logic.
*/
export interface HdrVideoFrameSource {
dir: string;
rawPath: string;
fd: number;
width: number;
height: number;
frameSize: number;
frameCount: number;
scratch: Buffer;
}
export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: ProducerLogger): void {
try {
closeSync(source.fd);
} catch (err) {
log?.warn("Failed to close HDR raw frame file", {
rawPath: source.rawPath,
error: err instanceof Error ? err.message : String(err),
});
}
}
export function blitHdrVideoLayer(
canvas: Buffer,
el: ElementStackingInfo,
time: number,
fps: number,
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>,
hdrStartTimes: Map<string, number>,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const frameSource = hdrVideoFrameSources.get(el.id);
const startTime = hdrStartTimes.get(el.id);
if (!frameSource || startTime === undefined || el.opacity <= 0) {
return;
}
// Frame index within the video. Clamp to the extracted raw frame count so
// a composition that outlives the source clip freezes on the last frame,
// matching Chrome's <video> behavior.
const videoFrameIndex = Math.round((time - startTime) * fps) + 1;
if (videoFrameIndex < 1) return;
const effectiveIndex = Math.min(videoFrameIndex, frameSource.frameCount);
if (effectiveIndex < 1) return;
const frameOffset = (effectiveIndex - 1) * frameSource.frameSize;
try {
if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1;
let timingStart = Date.now();
const bytesRead = readSync(
frameSource.fd,
frameSource.scratch,
0,
frameSource.frameSize,
frameOffset,
);
if (bytesRead !== frameSource.frameSize) return;
const hdrRgb = frameSource.scratch;
const srcW = frameSource.width;
const srcH = frameSource.height;
addHdrTiming(hdrPerf, "hdrVideoReadDecodeMs", timingStart);
// Convert between HDR transfer functions if source doesn't match output
if (sourceTransfer && targetTransfer && sourceTransfer !== targetTransfer) {
timingStart = Date.now();
convertTransfer(hdrRgb, sourceTransfer, targetTransfer);
addHdrTiming(hdrPerf, "hdrVideoTransferMs", timingStart);
}
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;
// Apply ancestor overflow:hidden clip rect by constraining the blit
// bounds. For the no-transform (region) path, we crop the source
// image and adjust the destination position. For the affine path,
// clip rect support is not yet implemented (would require per-pixel
// scissor in the affine blit); log a warning and skip clipping.
let blitX = el.x;
let blitY = el.y;
let blitSrcX = 0;
let blitSrcY = 0;
let blitW = srcW;
let blitH = srcH;
let clipped = false;
if (el.clipRect) {
const cr = el.clipRect;
const cx1 = Math.max(blitX, cr.x);
const cy1 = Math.max(blitY, cr.y);
const cx2 = Math.min(blitX + blitW, cr.x + cr.width);
const cy2 = Math.min(blitY + blitH, cr.y + cr.height);
if (cx2 <= cx1 || cy2 <= cy1) return; // fully clipped
blitSrcX = cx1 - blitX;
blitSrcY = cy1 - blitY;
blitW = cx2 - cx1;
blitH = cy2 - cy1;
blitX = cx1;
blitY = cy1;
clipped = true;
}
// Detect translation-only matrix (no scale/rotation) — route through the
// region path which supports clip rects. Chrome reports a viewport matrix
// for all HDR elements, even untransformed ones or those with only layout
// translation (e.g. `left: 960px` → `matrix(1,0,0,1,960,0)`). The region
// blit handles translation via el.x/el.y, so we only need the affine path
// for actual scale/rotation transforms.
// parseTransformMatrix returns a 6-element array or null — length check unnecessary.
const isTranslationOnly = !!(
viewportMatrix &&
Math.abs(viewportMatrix[0]! - 1) < 0.001 &&
Math.abs(viewportMatrix[1]!) < 0.001 &&
Math.abs(viewportMatrix[2]!) < 0.001 &&
Math.abs(viewportMatrix[3]! - 1) < 0.001
);
timingStart = Date.now();
if (viewportMatrix && !isTranslationOnly) {
if (clipped && log) {
log.debug(
`HDR clip rect on affine-transformed element ${el.id} — clip not applied (affine scissor not yet supported)`,
);
}
blitRgb48leAffine(
canvas,
hdrRgb,
viewportMatrix,
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else if (clipped) {
// Crop the source buffer to the clipped region before blitting
const croppedBuf = cropRgb48le(hdrRgb, srcW, srcH, blitSrcX, blitSrcY, blitW, blitH);
blitRgb48leRegion(
canvas,
croppedBuf,
blitX,
blitY,
blitW,
blitH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else {
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
srcW,
srcH,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
}
addHdrTiming(hdrPerf, "hdrVideoBlitMs", timingStart);
} catch (err) {
if (log) {
log.debug(`HDR blit failed for ${el.id}`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Pre-decoded HDR image buffer with its native pixel dimensions.
*
* Static images decode exactly once at setup time and are blitted on every
* visible frame, unlike video frames which are read fresh per timestamp.
*/
export interface HdrImageBuffer {
data: Buffer;
width: number;
height: number;
}
/**
* Blit a single HDR image layer onto an rgb48le canvas.
*
* Image-equivalent of `blitHdrVideoLayer` — the buffer is pre-decoded and
* static, so there's no time-based frame lookup or per-frame PNG read.
*/
export function blitHdrImageLayer(
canvas: Buffer,
el: ElementStackingInfo,
hdrImageBuffers: Map<string, HdrImageBuffer>,
hdrImageTransferCache: HdrImageTransferCache,
width: number,
height: number,
log?: ProducerLogger,
sourceTransfer?: HdrTransfer,
targetTransfer?: HdrTransfer,
hdrPerf?: HdrPerfCollector,
): void {
const buf = hdrImageBuffers.get(el.id);
if (!buf || el.opacity <= 0) {
return;
}
if (el.clipRect && log) {
log.debug(`HDR clip rect on image element ${el.id} — clip not yet supported for images`);
}
try {
if (hdrPerf) hdrPerf.hdrImageLayerBlits += 1;
// The cache returns `buf.data` unchanged when no conversion is needed,
// and otherwise returns a per-(imageId, targetTransfer) buffer that was
// converted exactly once and reused across every subsequent frame.
let timingStart = Date.now();
const hdrRgb =
sourceTransfer && targetTransfer
? hdrImageTransferCache.getConverted(el.id, sourceTransfer, targetTransfer, buf.data)
: buf.data;
addHdrTiming(hdrPerf, "hdrImageTransferMs", timingStart);
const viewportMatrix = parseTransformMatrix(el.transform);
const br = el.borderRadius;
const hasBorderRadius = br[0] > 0 || br[1] > 0 || br[2] > 0 || br[3] > 0;
const borderRadiusParam = hasBorderRadius ? br : undefined;
timingStart = Date.now();
if (viewportMatrix) {
blitRgb48leAffine(
canvas,
hdrRgb,
viewportMatrix,
buf.width,
buf.height,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
} else {
blitRgb48leRegion(
canvas,
hdrRgb,
el.x,
el.y,
buf.width,
buf.height,
width,
height,
el.opacity < 0.999 ? el.opacity : undefined,
borderRadiusParam,
);
}
addHdrTiming(hdrPerf, "hdrImageBlitMs", timingStart);
} catch (err) {
if (log) {
log.debug(`HDR image blit failed for ${el.id}`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Dependencies passed to `compositeHdrFrame`.
*
* Every field except the per-frame arguments is captured once when the HDR
* render path opens its `try { ... }` block and reused across every frame —
* extracting them into an explicit struct lets the helper live at module
* scope (no closure-over-renderJob) and keeps the per-call signature small.
*/
type CompositeTransfer = HdrTransfer | "srgb";
export function shouldUseLayeredComposite(options: {
hasHdrContent: boolean;
hasShaderTransitions: boolean;
isPngSequence: boolean;
}): boolean {
return options.hasHdrContent || (options.hasShaderTransitions && !options.isPngSequence);
}
export function resolveCompositeTransfer(
hasHdrContent: boolean,
effectiveHdr: { transfer: HdrTransfer } | undefined,
): CompositeTransfer {
return hasHdrContent && effectiveHdr ? effectiveHdr.transfer : "srgb";
}
export interface HdrCompositeContext {
log: ProducerLogger;
domSession: CaptureSession;
beforeCaptureHook: BeforeCaptureHook | null;
width: number;
height: number;
fps: number;
compositeTransfer: CompositeTransfer;
nativeHdrImageIds: Set<string>;
hdrImageBuffers: Map<string, HdrImageBuffer>;
hdrImageTransferCache: HdrImageTransferCache;
hdrVideoFrameSources: Map<string, HdrVideoFrameSource>;
hdrVideoStartTimes: Map<string, number>;
imageTransfers: Map<string, HdrTransfer>;
videoTransfers: Map<string, HdrTransfer>;
debugDumpEnabled: boolean;
debugDumpDir: string | null;
hdrPerf?: HdrPerfCollector;
}
/**
* Composite a single HDR frame into a pre-allocated `rgb48le` canvas.
*
* Bottom-to-top z-order: HDR layers are blitted directly from cached image
* buffers / extracted video frames; DOM layers are screenshotted with a
* mass-hide mask (so each layer paints only its own elements) and then
* blended into the canvas via `blitRgba8OverRgb48le` in the active HDR
* transfer space.
*
* The `elementFilter` parameter exists so the transition path can composite
* each scene independently; pass `undefined` for whole-stack rendering.
*
* @param ctx - Long-lived dependencies (logger, browser session, dimensions,
* HDR layer maps). Captured once per render — see
* {@link HdrCompositeContext}.
* @param canvas - Pre-allocated `width * height * 6` byte buffer. Caller must
* zero-fill before every frame (this helper does not).
* @param time - Seek time in seconds.
* @param fullStacking - Stacking info for ALL elements at this time. Even when
* filtering, every other element id is needed to build
* the DOM-layer hide-list.
* @param elementFilter - When set, only elements whose id is in the set are
* composited.
* @param debugFrameIndex - Frame index used to label per-layer diagnostic
* dumps. Pass `-1` to disable per-layer dumps even
* when `KEEP_TEMP=1` (e.g. for warmup frames).
*/
export async function compositeHdrFrame(
ctx: HdrCompositeContext,
canvas: Buffer,
time: number,
fullStacking: ElementStackingInfo[],
elementFilter?: Set<string>,
debugFrameIndex: number = -1,
): Promise<void> {
const {
log,
domSession,
beforeCaptureHook,
width,
height,
fps,
compositeTransfer,
nativeHdrImageIds,
hdrImageBuffers,
hdrImageTransferCache,
hdrVideoFrameSources,
hdrVideoStartTimes,
imageTransfers,
videoTransfers,
debugDumpEnabled,
debugDumpDir,
hdrPerf,
} = ctx;
const filteredStacking = elementFilter
? fullStacking.filter((e) => elementFilter.has(e.id))
: fullStacking;
// Zero-opacity elements stay in the stacking for correct hide-list
// generation (their <img> replacements must be hidden from sibling
// screenshots). The actual blit is skipped in the compositing loop below.
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 },
),
});
}
for (const [layerIdx, layer] of layers.entries()) {
if (layer.type === "hdr") {
// Skip zero-opacity HDR elements — their parent scene may have faded out.
if (layer.element.opacity <= 0) continue;
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const isHdrImage = nativeHdrImageIds.has(layer.element.id);
const hdrTargetTransfer = compositeTransfer === "srgb" ? undefined : compositeTransfer;
if (isHdrImage) {
blitHdrImageLayer(
canvas,
layer.element,
hdrImageBuffers,
hdrImageTransferCache,
width,
height,
log,
imageTransfers.get(layer.element.id),
hdrTargetTransfer,
hdrPerf,
);
} else {
blitHdrVideoLayer(
canvas,
layer.element,
time,
fps,
hdrVideoFrameSources,
hdrVideoStartTimes,
width,
height,
log,
videoTransfers.get(layer.element.id),
hdrTargetTransfer,
hdrPerf,
);
}
if (shouldLog) {
const after = countNonZeroRgb48(canvas);
if (isHdrImage) {
const buf = hdrImageBuffers.get(layer.element.id);
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
id: layer.element.id,
kind: "image",
pixelsAdded: after - before,
totalNonZero: after,
bufferDecoded: !!buf,
bufferDims: buf ? `${buf.width}x${buf.height}` : null,
});
} else {
const frameSource = hdrVideoFrameSources.get(layer.element.id);
const startTime = hdrVideoStartTimes.get(layer.element.id) ?? 0;
const localTime = time - startTime;
const frameNum = Math.floor(localTime * fps) + 1;
log.info("[diag] hdr layer blit", {
frame: debugFrameIndex,
layerIdx,
id: layer.element.id,
kind: "video",
pixelsAdded: after - before,
totalNonZero: after,
startTime,
localTime: localTime.toFixed(3),
hdrFrameNum: frameNum,
rawPath: frameSource?.rawPath ?? null,
frameCount: frameSource?.frameCount ?? null,
});
}
}
} 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));
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
// 1. Seek GSAP to restore all animated properties from clean state
let timingStart = Date.now();
await domSession.page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
}, time);
addHdrTiming(hdrPerf, "domLayerSeekMs", timingStart);
// 2. Run frame injector to set correct SDR video visibility
if (beforeCaptureHook) {
timingStart = Date.now();
await beforeCaptureHook(domSession.page, time);
addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart);
}
// 3. Install the mask (mass-hide stylesheet + inline-hide non-layer ids)
timingStart = Date.now();
await applyDomLayerMask(domSession.page, layer.elementIds, hideIds);
addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart);
// 4. Screenshot
timingStart = Date.now();
const domPng = await captureAlphaPng(domSession.page, width, height);
addHdrTiming(hdrPerf, "domScreenshotMs", timingStart);
// 5. Tear down the mask
timingStart = Date.now();
await removeDomLayerMask(domSession.page, hideIds);
addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart);
try {
timingStart = Date.now();
const { data: domRgba } = decodePng(domPng);
addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart);
const before = shouldLog ? countNonZeroRgb48(canvas) : 0;
const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0;
timingStart = Date.now();
blitRgba8OverRgb48le(domRgba, canvas, width, height, compositeTransfer);
addHdrTiming(hdrPerf, "domBlitMs", timingStart);
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);
writeFileExclusiveSync(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) + "%",
});
}
}
export type RenderConfigInput = Omit<RenderConfig, "fps"> & { fps: FpsInput }; export type RenderConfigInput = Omit<RenderConfig, "fps"> & { fps: FpsInput };
export function createRenderJob(config: RenderConfigInput): RenderJob { export function createRenderJob(config: RenderConfigInput): RenderJob {
@@ -1410,6 +696,8 @@ function normalizeCompositionSrcPath(srcPath: string): string {
} }
function createStandaloneEntryRenderClone(root: Element, host: Element): Element { function createStandaloneEntryRenderClone(root: Element, host: Element): Element {
// linkedom's cloneNode returns `any` (not `Node`), so the Element cast
// is needed to access setAttribute/appendChild without losing type safety.
const hostClone = host.cloneNode(true) as Element; const hostClone = host.cloneNode(true) as Element;
hostClone.setAttribute("data-start", "0"); hostClone.setAttribute("data-start", "0");
@@ -1455,6 +743,9 @@ export function extractStandaloneEntryFromIndex(
const body = document.querySelector("body"); const body = document.querySelector("body");
if (!body) return null; if (!body) return null;
// linkedom's querySelectorAll returns `any` on Document and `NodeList` on
// the ParentNode mixin. Neither types the elements as `Element`, so the
// cast is required to call getAttribute / hasAttribute without `any`.
const hosts = Array.from(document.querySelectorAll("[data-composition-src]")) as Element[]; const hosts = Array.from(document.querySelectorAll("[data-composition-src]")) as Element[];
const host = hosts.find( const host = hosts.find(
(candidate) => (candidate) =>
@@ -1463,6 +754,8 @@ export function extractStandaloneEntryFromIndex(
); );
if (!host) return null; if (!host) return null;
// linkedom's `children` is typed as `NodeList` (not `HTMLCollection<Element>`),
// so the Element[] cast is needed.
const root = const root =
(Array.from(body.children) as Element[]).find((candidate) => (Array.from(body.children) as Element[]).find((candidate) =>
candidate.hasAttribute("data-composition-id"), candidate.hasAttribute("data-composition-id"),
@@ -1521,7 +814,7 @@ export async function executeRenderJob(
log, log,
renderJobId: job.id, renderJobId: job.id,
}); });
const outputFormat = (job.config.format ?? "mp4") as NonNullable<RenderConfig["format"]>; const outputFormat = job.config.format ?? ("mp4" as const);
const isWebm = outputFormat === "webm"; const isWebm = outputFormat === "webm";
const isMov = outputFormat === "mov"; const isMov = outputFormat === "mov";
const isPngSequence = outputFormat === "png-sequence"; const isPngSequence = outputFormat === "png-sequence";
@@ -1,13 +0,0 @@
/**
* Re-exported from @hyperframes/engine.
* @see engine/src/services/screenshotService.ts for implementation.
*/
export {
beginFrameCapture,
pageScreenshotCapture,
getCdpSession,
injectVideoFramesBatch,
syncVideoFrameVisibility,
cdpSessionCache,
type BeginFrameResult,
} from "@hyperframes/engine";
@@ -1,12 +0,0 @@
/**
* Re-exported from @hyperframes/engine.
* @see engine/src/services/streamingEncoder.ts for implementation.
*/
export {
spawnStreamingEncoder,
createFrameReorderBuffer,
type StreamingEncoder,
type StreamingEncoderOptions,
type StreamingEncoderResult,
type FrameReorderBuffer,
} from "@hyperframes/engine";
@@ -1,17 +0,0 @@
/**
* Re-exported from @hyperframes/engine.
* @see engine/src/services/videoFrameExtractor.ts for implementation.
*/
export {
parseVideoElements,
extractVideoFramesRange,
extractAllVideoFrames,
getFrameAtTime,
createFrameLookupTable,
FrameLookupTable,
type VideoElement,
type ExtractedFrames,
type ExtractionOptions,
type ExtractionResult,
type ExtractionPhaseBreakdown,
} from "@hyperframes/engine";
@@ -1,5 +0,0 @@
/**
* Re-exported from @hyperframes/engine.
* @see engine/src/services/videoFrameInjector.ts for implementation.
*/
export { createVideoFrameInjector } from "@hyperframes/engine";
+30 -1
View File
@@ -14,7 +14,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolve, win32 } from "node:path"; import { resolve, win32 } from "node:path";
import { isPathInside, toExternalAssetKey } from "./paths.js"; import {
isPathInside,
toExternalAssetKey,
formatCaptureFrameName,
formatExportFrameName,
} from "./paths.js";
describe("isPathInside", () => { describe("isPathInside", () => {
it("returns true when child is directly inside parent", () => { it("returns true when child is directly inside parent", () => {
@@ -133,3 +138,27 @@ describe("toExternalAssetKey", () => {
expect(/^[A-Za-z]:/.test(key)).toBe(false); expect(/^[A-Za-z]:/.test(key)).toBe(false);
}); });
}); });
describe("formatCaptureFrameName", () => {
it("returns zero-padded filename for zero-based index", () => {
expect(formatCaptureFrameName(0, "jpg")).toBe("frame_000000.jpg");
});
it("pads to 6 digits for large indices", () => {
expect(formatCaptureFrameName(999999, "png")).toBe("frame_999999.png");
});
it("handles mid-range indices", () => {
expect(formatCaptureFrameName(42, "jpg")).toBe("frame_000042.jpg");
});
});
describe("formatExportFrameName", () => {
it("returns one-based filename from zero-based input", () => {
expect(formatExportFrameName(0, "png")).toBe("frame_000001.png");
});
it("increments index by 1", () => {
expect(formatExportFrameName(4, "png")).toBe("frame_000005.png");
});
});
+8
View File
@@ -109,6 +109,14 @@ export function toExternalAssetKey(absPath: string): string {
return "hf-ext/" + normalised; return "hf-ext/" + normalised;
} }
export function formatCaptureFrameName(index: number, ext: string): string {
return `frame_${String(index).padStart(6, "0")}.${ext}`;
}
export function formatExportFrameName(index: number, ext: string): string {
return `frame_${String(index + 1).padStart(6, "0")}.${ext}`;
}
export function resolveRenderPaths( export function resolveRenderPaths(
projectDir: string, projectDir: string,
outputPath: string | null | undefined, outputPath: string | null | undefined,