mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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:
+5
-15
@@ -22,34 +22,24 @@ for (const stream of [process.stdout, process.stderr]) {
|
||||
}
|
||||
|
||||
// ── Worker entry path bootstrap (must run before any producer/engine load) ──
|
||||
// The hf#677 worker_threads pools (`pngDecodeBlitWorkerPool`,
|
||||
// `shaderTransitionWorkerPool`) live in the producer package and try to
|
||||
// resolve their worker entry by probing for sibling `.js` files next to
|
||||
// The shaderTransitionWorkerPool lives in the producer package and resolves
|
||||
// its worker entry by probing for a sibling `.js` file next to
|
||||
// `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
|
||||
// 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.
|
||||
// We emit the worker entries next to cli.js (see tsup.config.ts) and tell
|
||||
// the pools where to find them via the published env-var overrides. The
|
||||
// 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.
|
||||
// probe lands in a directory that does not contain the bundled worker.
|
||||
// We emit the worker entry next to cli.js (see tsup.config.ts) and tell
|
||||
// the pool where to find it via the published env-var override.
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
(() => {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const shader = join(here, "shaderTransitionWorker.js");
|
||||
const png = join(here, "pngDecodeBlitWorker.js");
|
||||
if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,20 +7,8 @@ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url),
|
||||
};
|
||||
|
||||
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: {
|
||||
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",
|
||||
},
|
||||
format: ["esm"],
|
||||
@@ -96,10 +84,6 @@ var __dirname = __hf_dirname(__filename);`,
|
||||
"@hyperframes/aws-lambda/sdk": resolve(__dirname, "../aws-lambda/src/sdk/index.ts"),
|
||||
// Same for the GCP adapter's SDK subpath barrel.
|
||||
"@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
|
||||
// `@hyperframes/engine/shader-transitions` (subpath export) — a
|
||||
// standalone TS file with zero internal imports that survives the
|
||||
|
||||
@@ -59,11 +59,6 @@ const sharedOpts = {
|
||||
await Promise.all([
|
||||
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/services/pngDecodeBlitWorker.ts"],
|
||||
outfile: "dist/services/pngDecodeBlitWorker.js",
|
||||
}),
|
||||
build({
|
||||
...sharedOpts,
|
||||
entryPoints: ["src/services/shaderTransitionWorker.ts"],
|
||||
|
||||
@@ -52,7 +52,7 @@ export {
|
||||
} from "./services/fileServer.js";
|
||||
|
||||
// ── Video frame injection (Hyperframes-specific hook) ───────────────────────
|
||||
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
||||
export { createVideoFrameInjector } from "@hyperframes/engine";
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────────────
|
||||
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 { fpsToFfmpegArg } from "@hyperframes/core";
|
||||
import { defaultLogger, type ProducerLogger } from "../../logger.js";
|
||||
import { formatExportFrameName } from "../../utils/paths.js";
|
||||
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
|
||||
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
|
||||
import type { DistributedFormat } from "./shared.js";
|
||||
@@ -397,7 +398,7 @@ function mergePngFrameDirs(
|
||||
throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`);
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
*/
|
||||
|
||||
import { fpsToNumber } from "@hyperframes/core";
|
||||
import type { CaptureCalibrationSample, CaptureCostEstimate } from "./captureCost.js";
|
||||
import type {
|
||||
CaptureAttemptSummary,
|
||||
CaptureCalibrationSample,
|
||||
CaptureCostEstimate,
|
||||
HdrDiagnostics,
|
||||
RenderJob,
|
||||
RenderPerfSummary,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { rmSync } from "node:fs";
|
||||
import {
|
||||
type BeforeCaptureHook,
|
||||
type CaptureSession,
|
||||
type ElementStackingInfo,
|
||||
applyDomLayerMask,
|
||||
@@ -23,14 +24,18 @@ import {
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
import {
|
||||
type HdrCompositeContext,
|
||||
type HdrPerfCollector,
|
||||
type HdrVideoFrameSource,
|
||||
type TransitionRange,
|
||||
addHdrTiming,
|
||||
blitHdrImageLayer,
|
||||
blitHdrVideoLayer,
|
||||
closeHdrVideoFrameSource,
|
||||
} from "../../renderOrchestrator.js";
|
||||
} from "../../hdrCompositor.js";
|
||||
import {
|
||||
type HdrPerfCollector,
|
||||
type HdrPerfTimingKey,
|
||||
timeHdrPhase,
|
||||
timeHdrPhaseAsync,
|
||||
} from "../hdrPerf.js";
|
||||
|
||||
// ─── Hybrid path gating + partitioning ─────────────────────────────────────
|
||||
|
||||
@@ -102,6 +107,53 @@ export interface LayeredTransitionBuffers {
|
||||
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) ──
|
||||
|
||||
export interface CaptureSceneArgs {
|
||||
@@ -142,16 +194,14 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void>
|
||||
log,
|
||||
frameIdx,
|
||||
} = a;
|
||||
let timingStart = Date.now();
|
||||
await session.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
addHdrTiming(hdrPerf, "domLayerSeekMs", timingStart);
|
||||
if (beforeCaptureHook) {
|
||||
timingStart = Date.now();
|
||||
await beforeCaptureHook(session.page, time);
|
||||
addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart);
|
||||
}
|
||||
await seekAndInject(
|
||||
session.page,
|
||||
time,
|
||||
beforeCaptureHook,
|
||||
hdrPerf,
|
||||
"domLayerSeekMs",
|
||||
"domLayerInjectMs",
|
||||
);
|
||||
for (const el of stackingInfo) {
|
||||
if (!el.isHdr || !sceneIds.has(el.id)) continue;
|
||||
if (nativeHdrImageIds.has(el.id)) {
|
||||
@@ -189,22 +239,20 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise<void>
|
||||
.map((e) => e.id)
|
||||
.filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id));
|
||||
if (hdrPerf) hdrPerf.domLayerCaptures += 1;
|
||||
timingStart = Date.now();
|
||||
await applyDomLayerMask(session.page, showIds, hideIds);
|
||||
addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart);
|
||||
timingStart = Date.now();
|
||||
const domPng = await captureAlphaPng(session.page, width, height);
|
||||
addHdrTiming(hdrPerf, "domScreenshotMs", timingStart);
|
||||
timingStart = Date.now();
|
||||
await removeDomLayerMask(session.page, hideIds);
|
||||
addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart);
|
||||
await timeHdrPhaseAsync(hdrPerf, "domMaskApplyMs", () =>
|
||||
applyDomLayerMask(session.page, showIds, hideIds),
|
||||
);
|
||||
const domPng = await timeHdrPhaseAsync(hdrPerf, "domScreenshotMs", () =>
|
||||
captureAlphaPng(session.page, width, height),
|
||||
);
|
||||
await timeHdrPhaseAsync(hdrPerf, "domMaskRemoveMs", () =>
|
||||
removeDomLayerMask(session.page, hideIds),
|
||||
);
|
||||
try {
|
||||
timingStart = Date.now();
|
||||
const { data: domRgba } = decodePng(domPng);
|
||||
addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart);
|
||||
timingStart = Date.now();
|
||||
blitRgba8OverRgb48le(domRgba, sceneBuf, width, height, compositeTransfer);
|
||||
addHdrTiming(hdrPerf, "domBlitMs", timingStart);
|
||||
const { data: domRgba } = timeHdrPhase(hdrPerf, "domPngDecodeMs", () => decodePng(domPng));
|
||||
timeHdrPhase(hdrPerf, "domBlitMs", () =>
|
||||
blitRgba8OverRgb48le(domRgba, sceneBuf, width, height, compositeTransfer),
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", {
|
||||
frameIndex: frameIdx,
|
||||
@@ -259,30 +307,28 @@ export async function captureTransitionFrameOnWorker(
|
||||
hdrPerf.frames += 1;
|
||||
hdrPerf.transitionFrames += 1;
|
||||
}
|
||||
let timingStart = Date.now();
|
||||
await session.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
addHdrTiming(hdrPerf, "frameSeekMs", timingStart);
|
||||
if (beforeCaptureHook) {
|
||||
timingStart = Date.now();
|
||||
await beforeCaptureHook(session.page, time);
|
||||
addHdrTiming(hdrPerf, "frameInjectMs", timingStart);
|
||||
}
|
||||
timingStart = Date.now();
|
||||
const stackingInfo = await queryElementStacking(session.page, nativeHdrIds);
|
||||
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
|
||||
const stackingInfo = await seekInjectAndQueryStacking(
|
||||
session.page,
|
||||
time,
|
||||
beforeCaptureHook,
|
||||
nativeHdrIds,
|
||||
hdrPerf,
|
||||
"frameSeekMs",
|
||||
"frameInjectMs",
|
||||
"stackingQueryMs",
|
||||
);
|
||||
const sceneAIds = new Set(sceneElements[transition.fromScene] ?? []);
|
||||
const sceneBIds = new Set(sceneElements[transition.toScene] ?? []);
|
||||
buffers.bufferA.fill(0);
|
||||
buffers.bufferB.fill(0);
|
||||
for (const [sceneBuf, sceneIds] of [
|
||||
const sceneCaptures: [Buffer, Set<string>][] = [
|
||||
[buffers.bufferA, sceneAIds],
|
||||
[buffers.bufferB, sceneBIds],
|
||||
] as const) {
|
||||
];
|
||||
for (const [sceneBuf, sceneIds] of sceneCaptures) {
|
||||
await captureSceneIntoBuffer({
|
||||
session,
|
||||
sceneBuf: sceneBuf as Buffer,
|
||||
sceneBuf,
|
||||
sceneIds,
|
||||
stackingInfo,
|
||||
time,
|
||||
|
||||
@@ -32,19 +32,16 @@ import {
|
||||
crossfade,
|
||||
initTransparentBackground,
|
||||
initializeSession,
|
||||
queryElementStacking,
|
||||
} from "@hyperframes/engine";
|
||||
import type { FileServerHandle } from "../../fileServer.js";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
import {
|
||||
type HdrCompositeContext,
|
||||
type HdrPerfCollector,
|
||||
type ProgressCallback,
|
||||
type RenderJob,
|
||||
type TransitionRange,
|
||||
addHdrTiming,
|
||||
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 {
|
||||
type ShaderTransitionWorkerPool,
|
||||
@@ -56,6 +53,7 @@ import {
|
||||
distributeLayeredHybridFrameRanges,
|
||||
ensureFrameWritten,
|
||||
partitionTransitionFrames,
|
||||
seekInjectAndQueryStacking,
|
||||
} from "./captureHdrFrameShared.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));
|
||||
});
|
||||
} else {
|
||||
const beforeCaptureHook = session.onBeforeCapture;
|
||||
let timingStart = Date.now();
|
||||
await session.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
addHdrTiming(hdrPerf, "frameSeekMs", timingStart);
|
||||
if (beforeCaptureHook) {
|
||||
timingStart = Date.now();
|
||||
await beforeCaptureHook(session.page, time);
|
||||
addHdrTiming(hdrPerf, "frameInjectMs", timingStart);
|
||||
}
|
||||
timingStart = Date.now();
|
||||
const stackingInfo = await queryElementStacking(session.page, nativeHdrIds);
|
||||
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
|
||||
const stackingInfo = await seekInjectAndQueryStacking(
|
||||
session.page,
|
||||
time,
|
||||
session.onBeforeCapture,
|
||||
nativeHdrIds,
|
||||
hdrPerf,
|
||||
"frameSeekMs",
|
||||
"frameInjectMs",
|
||||
"stackingQueryMs",
|
||||
);
|
||||
canvas.fill(0);
|
||||
// Rebind ctx to this worker's session for per-layer captures
|
||||
const wctx: HdrCompositeContext = { ...hdrCompositeCtx, domSession: session };
|
||||
timingStart = Date.now();
|
||||
await compositeHdrFrame(wctx, canvas, time, stackingInfo, undefined, i);
|
||||
addHdrTiming(hdrPerf, "normalCompositeMs", timingStart);
|
||||
await timeHdrPhaseAsync(hdrPerf, "normalCompositeMs", () =>
|
||||
compositeHdrFrame(wctx, canvas, time, stackingInfo, undefined, i),
|
||||
);
|
||||
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
|
||||
writeFileExclusiveSync(
|
||||
join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
|
||||
|
||||
@@ -36,12 +36,8 @@ import {
|
||||
} from "@hyperframes/engine";
|
||||
import { fpsToFfmpegArg } from "@hyperframes/core";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
import type {
|
||||
HdrDiagnostics,
|
||||
HdrImageBuffer,
|
||||
HdrVideoFrameSource,
|
||||
RenderJob,
|
||||
} from "../../renderOrchestrator.js";
|
||||
import type { HdrImageBuffer, HdrVideoFrameSource } from "../../hdrCompositor.js";
|
||||
import type { HdrDiagnostics, RenderJob } from "../../renderOrchestrator.js";
|
||||
import type { CompositionMetadata } from "../shared.js";
|
||||
|
||||
const NO_FOLLOW_FLAG = constants.O_NOFOLLOW ?? 0;
|
||||
|
||||
@@ -18,24 +18,27 @@ import {
|
||||
type TransitionFn,
|
||||
TRANSITIONS,
|
||||
crossfade,
|
||||
queryElementStacking,
|
||||
} from "@hyperframes/engine";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
import {
|
||||
type HdrCompositeContext,
|
||||
type HdrPerfCollector,
|
||||
type ProgressCallback,
|
||||
type RenderJob,
|
||||
type TransitionRange,
|
||||
addHdrTiming,
|
||||
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 {
|
||||
captureSceneIntoBuffer,
|
||||
cleanupEndedHdrVideos,
|
||||
ensureFrameWritten,
|
||||
type LayeredTransitionBuffers,
|
||||
seekInjectAndQueryStacking,
|
||||
} from "./captureHdrFrameShared.js";
|
||||
import { updateJobStatus } from "../shared.js";
|
||||
|
||||
@@ -57,7 +60,7 @@ export interface SequentialLoopInput {
|
||||
hdrTargetTransfer: "pq" | "hlg" | undefined;
|
||||
hdrVideoEndTimes: Map<string, number>;
|
||||
cleanedUpVideos: Set<string>;
|
||||
hdrVideoFrameSources: Map<string, import("../../renderOrchestrator.js").HdrVideoFrameSource>;
|
||||
hdrVideoFrameSources: Map<string, import("../../hdrCompositor.js").HdrVideoFrameSource>;
|
||||
debugDumpEnabled: boolean;
|
||||
debugDumpDir: string | null;
|
||||
assertNotAborted: () => void;
|
||||
@@ -106,20 +109,16 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
|
||||
const time = (i * job.config.fps.den) / job.config.fps.num;
|
||||
if (hdrPerf) hdrPerf.frames += 1;
|
||||
|
||||
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, "frameSeekMs", timingStart);
|
||||
|
||||
if (beforeCaptureHook) {
|
||||
timingStart = Date.now();
|
||||
await beforeCaptureHook(domSession.page, time);
|
||||
addHdrTiming(hdrPerf, "frameInjectMs", timingStart);
|
||||
}
|
||||
timingStart = Date.now();
|
||||
const stackingInfo = await queryElementStacking(domSession.page, nativeHdrIds);
|
||||
addHdrTiming(hdrPerf, "stackingQueryMs", timingStart);
|
||||
const stackingInfo = await seekInjectAndQueryStacking(
|
||||
domSession.page,
|
||||
time,
|
||||
beforeCaptureHook,
|
||||
nativeHdrIds,
|
||||
hdrPerf,
|
||||
"frameSeekMs",
|
||||
"frameInjectMs",
|
||||
"stackingQueryMs",
|
||||
);
|
||||
const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
|
||||
|
||||
if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
|
||||
@@ -143,19 +142,20 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
|
||||
(activeTransition.endFrame - activeTransition.startFrame);
|
||||
const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
|
||||
const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
|
||||
timingStart = Date.now();
|
||||
transitionBuffers.bufferA.fill(0);
|
||||
transitionBuffers.bufferB.fill(0);
|
||||
addHdrTiming(hdrPerf, "canvasClearMs", timingStart);
|
||||
timeHdrPhase(hdrPerf, "canvasClearMs", () => {
|
||||
transitionBuffers.bufferA.fill(0);
|
||||
transitionBuffers.bufferB.fill(0);
|
||||
});
|
||||
|
||||
for (const [sceneBuf, sceneIds] of [
|
||||
const sceneCaptures: [Buffer, Set<string>][] = [
|
||||
[transitionBuffers.bufferA, sceneAIds],
|
||||
[transitionBuffers.bufferB, sceneBIds],
|
||||
] as const) {
|
||||
];
|
||||
for (const [sceneBuf, sceneIds] of sceneCaptures) {
|
||||
assertNotAborted();
|
||||
await captureSceneIntoBuffer({
|
||||
session: domSession,
|
||||
sceneBuf: sceneBuf as Buffer,
|
||||
sceneBuf,
|
||||
sceneIds,
|
||||
stackingInfo,
|
||||
time,
|
||||
@@ -189,26 +189,24 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
|
||||
progress,
|
||||
);
|
||||
addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
|
||||
timingStart = Date.now();
|
||||
ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i);
|
||||
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart);
|
||||
await timeHdrPhaseAsync(hdrPerf, "encoderWriteMs", async () =>
|
||||
ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i),
|
||||
);
|
||||
} else {
|
||||
if (hdrPerf) hdrPerf.normalFrames += 1;
|
||||
timingStart = Date.now();
|
||||
normalCanvas.fill(0);
|
||||
addHdrTiming(hdrPerf, "canvasClearMs", timingStart);
|
||||
timingStart = Date.now();
|
||||
await compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, undefined, i);
|
||||
addHdrTiming(hdrPerf, "normalCompositeMs", timingStart);
|
||||
timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
|
||||
await timeHdrPhaseAsync(hdrPerf, "normalCompositeMs", () =>
|
||||
compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, undefined, i),
|
||||
);
|
||||
if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
|
||||
writeFileExclusiveSync(
|
||||
join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
|
||||
normalCanvas,
|
||||
);
|
||||
}
|
||||
timingStart = Date.now();
|
||||
ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i);
|
||||
addHdrTiming(hdrPerf, "encoderWriteMs", timingStart);
|
||||
await timeHdrPhaseAsync(hdrPerf, "encoderWriteMs", async () =>
|
||||
ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i),
|
||||
);
|
||||
}
|
||||
|
||||
cleanupEndedHdrVideos({
|
||||
|
||||
@@ -57,17 +57,14 @@ import type { ProducerLogger } from "../../../logger.js";
|
||||
import { createHdrImageTransferCache } from "../../hdrImageTransferCache.js";
|
||||
import {
|
||||
type HdrCompositeContext,
|
||||
type HdrDiagnostics,
|
||||
type HdrPerfCollector,
|
||||
type HdrTransitionMeta,
|
||||
type HdrVideoFrameSource,
|
||||
type ProgressCallback,
|
||||
type RenderJob,
|
||||
type TransitionRange,
|
||||
closeHdrVideoFrameSource,
|
||||
createHdrPerfCollector,
|
||||
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 {
|
||||
decodeHdrImageBuffers,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "@hyperframes/engine";
|
||||
import type { Fps } from "@hyperframes/core";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
import { formatExportFrameName } from "../../../utils/paths.js";
|
||||
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
|
||||
import {
|
||||
buildGifPalettegenArgs,
|
||||
@@ -236,7 +237,7 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
|
||||
);
|
||||
}
|
||||
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);
|
||||
});
|
||||
if (hasAudio && audioOutputPath && existsSync(audioOutputPath)) {
|
||||
|
||||
@@ -48,10 +48,9 @@ import { fpsToNumber } from "@hyperframes/core";
|
||||
import {
|
||||
collectVideoMetadataHints,
|
||||
collectVideoReadinessSkipIds,
|
||||
materializeExtractedFramesForCompiledDir,
|
||||
type RenderJob,
|
||||
} from "../../renderOrchestrator.js";
|
||||
import { type CompositionMetadata } from "../shared.js";
|
||||
import { materializeExtractedFramesForCompiledDir, type CompositionMetadata } from "../shared.js";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
|
||||
export interface ExtractVideosStageInput {
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
findMissingFrameRanges,
|
||||
getNextRetryWorkerCount,
|
||||
isRecoverableParallelCaptureError,
|
||||
resolveCompositeTransfer,
|
||||
shouldUseLayeredComposite,
|
||||
shouldUseStreamingEncode,
|
||||
} from "./renderOrchestrator.js";
|
||||
import { resolveCompositeTransfer, shouldUseLayeredComposite } from "./hdrCompositor.js";
|
||||
import {
|
||||
createCaptureCalibrationConfig,
|
||||
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
|
||||
*
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
closeSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
@@ -52,7 +50,6 @@ import {
|
||||
resolveConfig,
|
||||
type ExtractionResult,
|
||||
type ExtractionPhaseBreakdown,
|
||||
type HdrTransfer,
|
||||
closeCaptureSession,
|
||||
type CaptureOptions,
|
||||
type CaptureVideoMetadataHint,
|
||||
@@ -65,18 +62,6 @@ import {
|
||||
mergeWorkerFrames,
|
||||
type ParallelProgress,
|
||||
type WorkerTask,
|
||||
captureAlphaPng,
|
||||
applyDomLayerMask,
|
||||
removeDomLayerMask,
|
||||
decodePng,
|
||||
blitRgba8OverRgb48le,
|
||||
blitRgb48leRegion,
|
||||
groupIntoLayers,
|
||||
blitRgb48leAffine,
|
||||
parseTransformMatrix,
|
||||
convertTransfer,
|
||||
type ElementStackingInfo,
|
||||
type HfTransitionMeta,
|
||||
getSystemTotalMb,
|
||||
LOW_MEMORY_TOTAL_MB_THRESHOLD,
|
||||
assertConfiguredFfmpegBinariesExist,
|
||||
@@ -91,16 +76,15 @@ import {
|
||||
VIRTUAL_TIME_SHIM,
|
||||
} from "./fileServer.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
import { type HdrImageTransferCache } from "./hdrImageTransferCache.js";
|
||||
import {
|
||||
createCompiledFrameSrcResolver,
|
||||
createMemorySampler,
|
||||
type MemorySampler,
|
||||
updateJobStatus,
|
||||
writeFileExclusiveSync,
|
||||
} from "./render/shared.js";
|
||||
import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { formatCaptureFrameName } from "../utils/paths.js";
|
||||
import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
|
||||
import { buildRenderPerfSummary } from "./render/perfSummary.js";
|
||||
import { getCaptureStageBrowserConsole } from "./render/captureStageError.js";
|
||||
@@ -118,7 +102,7 @@ import {
|
||||
type RenderExtractionObservability,
|
||||
type RenderObservabilitySummary,
|
||||
} 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 { runProbeStage } from "./render/stages/probeStage.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 { runEncodeStage } from "./render/stages/encodeStage.js";
|
||||
import { runAssembleStage } from "./render/stages/assembleStage.js";
|
||||
import { shouldUseLayeredComposite } from "./hdrCompositor.js";
|
||||
|
||||
function sampleDirectoryBytes(dir: string): number {
|
||||
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 =
|
||||
| "queued"
|
||||
| "preprocessing"
|
||||
@@ -379,36 +323,6 @@ export interface HdrDiagnostics {
|
||||
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 {
|
||||
startFrame: 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(
|
||||
nativeHdrVideoIds: ReadonlySet<string>,
|
||||
extractedVideos: readonly ExtractedVideoReadinessInput[],
|
||||
@@ -563,7 +468,7 @@ export function findMissingFrameRanges(
|
||||
let rangeStart: number | null = null;
|
||||
|
||||
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);
|
||||
if (missing && rangeStart === null) {
|
||||
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(
|
||||
totalFrames: number,
|
||||
framesDir: string,
|
||||
@@ -637,7 +536,7 @@ function countCapturedFrames(
|
||||
): number {
|
||||
let captured = 0;
|
||||
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++;
|
||||
}
|
||||
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 function createRenderJob(config: RenderConfigInput): RenderJob {
|
||||
@@ -1410,6 +696,8 @@ function normalizeCompositionSrcPath(srcPath: string): string {
|
||||
}
|
||||
|
||||
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;
|
||||
hostClone.setAttribute("data-start", "0");
|
||||
|
||||
@@ -1455,6 +743,9 @@ export function extractStandaloneEntryFromIndex(
|
||||
const body = document.querySelector("body");
|
||||
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 host = hosts.find(
|
||||
(candidate) =>
|
||||
@@ -1463,6 +754,8 @@ export function extractStandaloneEntryFromIndex(
|
||||
);
|
||||
if (!host) return null;
|
||||
|
||||
// linkedom's `children` is typed as `NodeList` (not `HTMLCollection<Element>`),
|
||||
// so the Element[] cast is needed.
|
||||
const root =
|
||||
(Array.from(body.children) as Element[]).find((candidate) =>
|
||||
candidate.hasAttribute("data-composition-id"),
|
||||
@@ -1521,7 +814,7 @@ export async function executeRenderJob(
|
||||
log,
|
||||
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 isMov = outputFormat === "mov";
|
||||
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";
|
||||
@@ -14,7 +14,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolve, win32 } from "node:path";
|
||||
|
||||
import { isPathInside, toExternalAssetKey } from "./paths.js";
|
||||
import {
|
||||
isPathInside,
|
||||
toExternalAssetKey,
|
||||
formatCaptureFrameName,
|
||||
formatExportFrameName,
|
||||
} from "./paths.js";
|
||||
|
||||
describe("isPathInside", () => {
|
||||
it("returns true when child is directly inside parent", () => {
|
||||
@@ -133,3 +138,27 @@ describe("toExternalAssetKey", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,14 @@ export function toExternalAssetKey(absPath: string): string {
|
||||
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(
|
||||
projectDir: string,
|
||||
outputPath: string | null | undefined,
|
||||
|
||||
Reference in New Issue
Block a user