diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 298ef88fa5..372d13bfdf 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -22,34 +22,24 @@ for (const stream of [process.stdout, process.stderr]) { } // ── Worker entry path bootstrap (must run before any producer/engine load) ── -// The hf#677 worker_threads pools (`pngDecodeBlitWorkerPool`, -// `shaderTransitionWorkerPool`) live in the producer package and try to -// resolve their worker entry by probing for sibling `.js` files next to +// The shaderTransitionWorkerPool lives in the producer package and resolves +// its worker entry by probing for a sibling `.js` file next to // `import.meta.url`. When this CLI is bundled by tsup, the producer code is // inlined into `cli.js`, but `import.meta.url` resolves to the producer's // own dist path (NOT cli.js) on some module-graph layouts — so the sibling -// probe lands in a directory that does not contain the bundled workers. -// We emit the worker entries next to cli.js (see tsup.config.ts) and tell -// the pools where to find them via the published env-var overrides. The -// pools have an explicit `workerEntryPath` factory option as the canonical -// API, but setting the env vars here covers every call site without having -// to thread the path through the renderOrchestrator → captureHdrStage → -// captureHdrHybridLoop chain. +// probe lands in a directory that does not contain the bundled worker. +// We emit the worker entry next to cli.js (see tsup.config.ts) and tell +// the pool where to find it via the published env-var override. import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; -// fallow-ignore-next-line complexity (() => { const here = dirname(fileURLToPath(import.meta.url)); const shader = join(here, "shaderTransitionWorker.js"); - const png = join(here, "pngDecodeBlitWorker.js"); if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) { process.env.HF_SHADER_WORKER_ENTRY = shader; } - if (!process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY && existsSync(png)) { - process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY = png; - } })(); // ── Fast-path exits ───────────────────────────────────────────────────────── diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index e220eacbdb..9208dc9e94 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -7,20 +7,8 @@ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), }; export default defineConfig({ - // hf#732 lever-4: emit BOTH the CLI bundle and the PNG decode + alpha-blit - // worker entry. The producer's `pngDecodeBlitWorkerPool` instantiates a - // Node `worker_threads` Worker via `new Worker()`, which is a - // filesystem load — it cannot share the parent module graph. The pool's - // path resolver probes for `pngDecodeBlitWorker.js` next to its own loaded - // module (which lives inside `dist/cli.js` after the producer is - // `noExternal`'d and bundled in). Without this entry the file would not - // exist at runtime and the pool would either crash or silently fall back - // to inline decode/blit, killing the perf gain. entry: { cli: "src/cli.ts", - pngDecodeBlitWorker: "../producer/src/services/pngDecodeBlitWorker.ts", - // hf#677/#732: shader-blend worker. Same `new Worker()` - // bundling rationale as `pngDecodeBlitWorker` above. shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts", }, format: ["esm"], @@ -96,10 +84,6 @@ var __dirname = __hf_dirname(__filename);`, "@hyperframes/aws-lambda/sdk": resolve(__dirname, "../aws-lambda/src/sdk/index.ts"), // Same for the GCP adapter's SDK subpath barrel. "@hyperframes/gcp-cloud-run/sdk": resolve(__dirname, "../gcp-cloud-run/src/sdk/index.ts"), - // hf#732 lever-4: alias for the PNG decode+blit worker's import. - // `alphaBlit.ts` is import-free (only zlib) so the worker survives - // the worker_thread loader boundary directly via this TS source. - "@hyperframes/engine/alpha-blit": resolve(__dirname, "../engine/src/utils/alphaBlit.ts"), // hf#677 follow-up: the shader-blend worker imports from // `@hyperframes/engine/shader-transitions` (subpath export) — a // standalone TS file with zero internal imports that survives the diff --git a/packages/producer/build.mjs b/packages/producer/build.mjs index 4721ef6858..22fceae09f 100644 --- a/packages/producer/build.mjs +++ b/packages/producer/build.mjs @@ -59,11 +59,6 @@ const sharedOpts = { await Promise.all([ build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }), build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/public-server.js" }), - build({ - ...sharedOpts, - entryPoints: ["src/services/pngDecodeBlitWorker.ts"], - outfile: "dist/services/pngDecodeBlitWorker.js", - }), build({ ...sharedOpts, entryPoints: ["src/services/shaderTransitionWorker.ts"], diff --git a/packages/producer/src/index.ts b/packages/producer/src/index.ts index bdf507d613..5db76efbaf 100644 --- a/packages/producer/src/index.ts +++ b/packages/producer/src/index.ts @@ -52,7 +52,7 @@ export { } from "./services/fileServer.js"; // ── Video frame injection (Hyperframes-specific hook) ─────────────────────── -export { createVideoFrameInjector } from "./services/videoFrameInjector.js"; +export { createVideoFrameInjector } from "@hyperframes/engine"; // ── Configuration ─────────────────────────────────────────────────────────── export { resolveConfig, DEFAULT_CONFIG, type ProducerConfig } from "./config.js"; diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 214cfc451c..ec6eb98f1d 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -37,6 +37,7 @@ import { dirname, join } from "node:path"; import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine"; import { fpsToFfmpegArg } from "@hyperframes/core"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; +import { formatExportFrameName } from "../../utils/paths.js"; import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js"; import type { ChunkSliceJson } from "../render/stages/freezePlan.js"; import type { DistributedFormat } from "./shared.js"; @@ -397,7 +398,7 @@ function mergePngFrameDirs( throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`); } for (const frame of frames) { - const dst = join(outputPath, `frame_${String(globalIdx + 1).padStart(6, "0")}.png`); + const dst = join(outputPath, formatExportFrameName(globalIdx, "png")); cpSync(join(chunkDir, frame), dst); globalIdx += 1; } diff --git a/packages/producer/src/services/hdrCompositor.ts b/packages/producer/src/services/hdrCompositor.ts new file mode 100644 index 0000000000..f188bd8cd5 --- /dev/null +++ b/packages/producer/src/services/hdrCompositor.ts @@ -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, + hdrStartTimes: Map, + 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