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