diff --git a/packages/engine/src/utils/alphaBlit.test.ts b/packages/engine/src/utils/alphaBlit.test.ts index 4edc6a527..4cc27e253 100644 --- a/packages/engine/src/utils/alphaBlit.test.ts +++ b/packages/engine/src/utils/alphaBlit.test.ts @@ -775,6 +775,25 @@ describe("blitRgb48leRegion", () => { expect(canvas.readUInt16LE(0)).toBe(20000); }); + it("blends opacity over existing destination pixels", () => { + const canvas = makeHdrFrame(2, 1, 10000, 20000, 30000); + const source = makeHdrFrame(2, 1, 50000, 10000, 60000); + blitRgb48leRegion(canvas, source, 0, 0, 2, 1, 2, 1, 0.25); + expect(canvas.readUInt16LE(0)).toBe(20000); + expect(canvas.readUInt16LE(2)).toBe(17500); + expect(canvas.readUInt16LE(4)).toBe(37500); + expect(canvas.readUInt16LE(6)).toBe(20000); + }); + + it("skips exact-zero opacity without mutating the destination", () => { + const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000); + const source = makeHdrFrame(1, 1, 50000, 50000, 50000); + blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 1, 0); + expect(canvas.readUInt16LE(0)).toBe(10000); + expect(canvas.readUInt16LE(2)).toBe(20000); + expect(canvas.readUInt16LE(4)).toBe(30000); + }); + it("no-op for zero-size region", () => { const canvas = Buffer.alloc(4 * 4 * 6); const source = makeHdrFrame(2, 2, 10000, 20000, 30000); diff --git a/packages/engine/src/utils/alphaBlit.ts b/packages/engine/src/utils/alphaBlit.ts index 9b22ced91..8f766e3d1 100644 --- a/packages/engine/src/utils/alphaBlit.ts +++ b/packages/engine/src/utils/alphaBlit.ts @@ -423,6 +423,7 @@ export function blitRgb48leRegion( if (sw <= 0 || sh <= 0) return; const op = opacity ?? 1.0; + if (op <= 0) return; const x0 = Math.max(0, dx); const y0 = Math.max(0, dy); @@ -442,6 +443,33 @@ export function blitRgb48leRegion( const dstRowOff = ((y0 + y) * canvasWidth + x0) * 6; source.copy(canvas, dstRowOff, srcRowOff, srcRowOff + clippedW * 6); } + } else if (!hasMask) { + const invOp = 1 - op; + for (let y = 0; y < y1 - y0; y++) { + let srcOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6; + let dstOff = ((y0 + y) * canvasWidth + x0) * 6; + for (let x = 0; x < clippedW; x++) { + const sr = source[srcOff]! | (source[srcOff + 1]! << 8); + const sg = source[srcOff + 2]! | (source[srcOff + 3]! << 8); + const sb = source[srcOff + 4]! | (source[srcOff + 5]! << 8); + const dr = canvas[dstOff]! | (canvas[dstOff + 1]! << 8); + const dg = canvas[dstOff + 2]! | (canvas[dstOff + 3]! << 8); + const db = canvas[dstOff + 4]! | (canvas[dstOff + 5]! << 8); + + const r = (sr * op + dr * invOp + 0.5) | 0; + const g = (sg * op + dg * invOp + 0.5) | 0; + const b = (sb * op + db * invOp + 0.5) | 0; + canvas[dstOff] = r & 0xff; + canvas[dstOff + 1] = r >>> 8; + canvas[dstOff + 2] = g & 0xff; + canvas[dstOff + 3] = g >>> 8; + canvas[dstOff + 4] = b & 0xff; + canvas[dstOff + 5] = b >>> 8; + + srcOff += 6; + dstOff += 6; + } + } } else { for (let y = 0; y < y1 - y0; y++) { for (let x = 0; x < clippedW; x++) { @@ -528,6 +556,7 @@ export function blitRgb48leAffine( const invTy = -(invB * tx + invD * ty); const op = opacity ?? 1.0; + if (op <= 0) return; const hasMask = borderRadius !== undefined; diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index bee0a2b21..9acdad7cb 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -18,6 +18,9 @@ import { mkdirSync, rmSync, readFileSync, + openSync, + readSync, + closeSync, readdirSync, statSync, writeFileSync, @@ -103,7 +106,6 @@ import { } from "./htmlCompiler.js"; import { defaultLogger, type ProducerLogger } from "../logger.js"; import { isPathInside } from "../utils/paths.js"; -import { clearMaxFrameIndex, getMaxFrameIndex } from "./frameDirCache.js"; import { type HdrImageTransferCache, createHdrImageTransferCache, @@ -310,6 +312,7 @@ export interface RenderPerfSummary { */ peakHeapUsedMb?: number; hdrDiagnostics?: HdrDiagnostics; + hdrPerf?: HdrPerfSummary; } export interface HdrDiagnostics { @@ -317,6 +320,144 @@ export interface HdrDiagnostics { imageDecodeFailures: number; } +export interface HdrPerfSummary { + frames: number; + normalFrames: number; + transitionFrames: number; + domLayerCaptures: number; + hdrVideoLayerBlits: number; + hdrImageLayerBlits: number; + timings: Record; + avgMs: Record; +} + +type HdrPerfTimingKey = + | "frameSeekMs" + | "frameInjectMs" + | "stackingQueryMs" + | "canvasClearMs" + | "normalCompositeMs" + | "transitionCompositeMs" + | "encoderWriteMs" + | "hdrVideoReadDecodeMs" + | "hdrVideoTransferMs" + | "hdrVideoBlitMs" + | "hdrImageTransferMs" + | "hdrImageBlitMs" + | "domLayerSeekMs" + | "domLayerInjectMs" + | "domMaskApplyMs" + | "domScreenshotMs" + | "domMaskRemoveMs" + | "domPngDecodeMs" + | "domBlitMs"; + +interface HdrPerfCollector { + frames: number; + normalFrames: number; + transitionFrames: number; + domLayerCaptures: number; + hdrVideoLayerBlits: number; + hdrImageLayerBlits: number; + timings: Record; +} + +function createHdrPerfCollector(): HdrPerfCollector { + return { + frames: 0, + normalFrames: 0, + transitionFrames: 0, + domLayerCaptures: 0, + hdrVideoLayerBlits: 0, + hdrImageLayerBlits: 0, + timings: { + frameSeekMs: 0, + frameInjectMs: 0, + stackingQueryMs: 0, + canvasClearMs: 0, + normalCompositeMs: 0, + transitionCompositeMs: 0, + encoderWriteMs: 0, + hdrVideoReadDecodeMs: 0, + hdrVideoTransferMs: 0, + hdrVideoBlitMs: 0, + hdrImageTransferMs: 0, + hdrImageBlitMs: 0, + domLayerSeekMs: 0, + domLayerInjectMs: 0, + domMaskApplyMs: 0, + domScreenshotMs: 0, + domMaskRemoveMs: 0, + domPngDecodeMs: 0, + domBlitMs: 0, + }, + }; +} + +function addHdrTiming(perf: HdrPerfCollector | undefined, key: HdrPerfTimingKey, startMs: number) { + if (!perf) return; + perf.timings[key] += Date.now() - startMs; +} + +function averageTiming(totalMs: number, count: number): number { + return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0; +} + +function finalizeHdrPerf(perf: HdrPerfCollector): HdrPerfSummary { + const avgMs: Record = {}; + const perFrameKeys: HdrPerfTimingKey[] = [ + "frameSeekMs", + "frameInjectMs", + "stackingQueryMs", + "canvasClearMs", + "encoderWriteMs", + ]; + for (const key of perFrameKeys) avgMs[key] = averageTiming(perf.timings[key], perf.frames); + avgMs.normalCompositeMs = averageTiming(perf.timings.normalCompositeMs, perf.normalFrames); + avgMs.transitionCompositeMs = averageTiming( + perf.timings.transitionCompositeMs, + perf.transitionFrames, + ); + + const perDomLayerKeys: HdrPerfTimingKey[] = [ + "domLayerSeekMs", + "domLayerInjectMs", + "domMaskApplyMs", + "domScreenshotMs", + "domMaskRemoveMs", + "domPngDecodeMs", + "domBlitMs", + ]; + for (const key of perDomLayerKeys) { + avgMs[key] = averageTiming(perf.timings[key], perf.domLayerCaptures); + } + + const perHdrVideoKeys: HdrPerfTimingKey[] = [ + "hdrVideoReadDecodeMs", + "hdrVideoTransferMs", + "hdrVideoBlitMs", + ]; + for (const key of perHdrVideoKeys) { + avgMs[key] = averageTiming(perf.timings[key], perf.hdrVideoLayerBlits); + } + + const perHdrImageKeys: HdrPerfTimingKey[] = ["hdrImageTransferMs", "hdrImageBlitMs"]; + for (const key of perHdrImageKeys) { + avgMs[key] = averageTiming(perf.timings[key], perf.hdrImageLayerBlits); + } + + return { + frames: perf.frames, + normalFrames: perf.normalFrames, + transitionFrames: perf.transitionFrames, + domLayerCaptures: perf.domLayerCaptures, + hdrVideoLayerBlits: perf.hdrVideoLayerBlits, + hdrImageLayerBlits: perf.hdrImageLayerBlits, + timings: { ...perf.timings }, + avgMs, + }; +} + export interface CaptureCostEstimate { multiplier: number; reasons: string[]; @@ -945,48 +1086,80 @@ function cropRgb48le( * * Shared between the normal-frame compositing path (compositeToBuffer) * and the transition dual-scene compositing loop to avoid duplicating - * the frame lookup, fallback, decode, transform, and blit logic. + * the frame lookup, raw read, transfer, transform, and blit logic. */ +interface HdrVideoFrameSource { + dir: string; + rawPath: string; + fd: number; + width: number; + height: number; + frameSize: number; + frameCount: number; + scratch: Buffer; +} + +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), + }); + } +} + function blitHdrVideoLayer( canvas: Buffer, el: ElementStackingInfo, time: number, fps: number, - hdrFrameDirs: Map, + hdrVideoFrameSources: Map, hdrStartTimes: Map, width: number, height: number, log?: ProducerLogger, sourceTransfer?: HdrTransfer, targetTransfer?: HdrTransfer, + hdrPerf?: HdrPerfCollector, ): void { - const frameDir = hdrFrameDirs.get(el.id); + const frameSource = hdrVideoFrameSources.get(el.id); const startTime = hdrStartTimes.get(el.id); - if (!frameDir || startTime === undefined) { + if (!frameSource || startTime === undefined || el.opacity <= 0) { return; } - // Frame index within the video (1-based for FFmpeg image2 output). - // Clamp against the highest extracted frame in the directory so that when - // the composition outlives the source clip we freeze on the last frame - // (matching Chrome's