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 index 35c2bc9320..4659dde380 100644 --- a/packages/producer/src/services/hdrCompositor.ts +++ b/packages/producer/src/services/hdrCompositor.ts @@ -34,7 +34,7 @@ import { import type { ProducerLogger } from "../logger.js"; import { type HdrImageTransferCache } from "./hdrImageTransferCache.js"; import { writeFileExclusiveSync } from "./render/shared.js"; -import { type HdrPerfCollector, addHdrTiming } from "./render/hdrPerf.js"; +import { type HdrPerfCollector, timeHdrPhase, timeHdrPhaseAsync } from "./render/hdrPerf.js"; // ─── Diagnostic helpers ──────────────────────────────────────────────────── @@ -169,25 +169,19 @@ export function blitHdrVideoLayer( try { if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1; - let timingStart = Date.now(); - const bytesRead = readSync( - frameSource.fd, - frameSource.scratch, - 0, - frameSource.frameSize, - frameOffset, + 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; - 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); + timeHdrPhase(hdrPerf, "hdrVideoTransferMs", () => + convertTransfer(hdrRgb, sourceTransfer, targetTransfer), + ); } const viewportMatrix = parseTransformMatrix(el.transform); @@ -241,54 +235,54 @@ export function blitHdrVideoLayer( 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)`, + timeHdrPhase(hdrPerf, "hdrVideoBlitMs", () => { + 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, ); } - 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}`, { @@ -343,12 +337,11 @@ export function blitHdrImageLayer( // 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 = + const hdrRgb = timeHdrPhase(hdrPerf, "hdrImageTransferMs", () => sourceTransfer && targetTransfer ? hdrImageTransferCache.getConverted(el.id, sourceTransfer, targetTransfer, buf.data) - : buf.data; - addHdrTiming(hdrPerf, "hdrImageTransferMs", timingStart); + : buf.data, + ); const viewportMatrix = parseTransformMatrix(el.transform); @@ -356,34 +349,34 @@ export function blitHdrImageLayer( 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); + timeHdrPhase(hdrPerf, "hdrImageBlitMs", () => { + 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, + ); + } + }); } catch (err) { if (log) { log.debug(`HDR image blit failed for ${el.id}`, { @@ -505,6 +498,7 @@ export async function compositeHdrFrame( // generation (their 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) { @@ -622,49 +616,46 @@ export async function compositeHdrFrame( // (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); + 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) { - timingStart = Date.now(); - await beforeCaptureHook(domSession.page, time); - addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "domLayerInjectMs", () => + beforeCaptureHook(domSession.page, time), + ); } // 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); + await timeHdrPhaseAsync(hdrPerf, "domMaskApplyMs", () => + applyDomLayerMask(domSession.page, layer.elementIds, hideIds), + ); // 4. Screenshot - timingStart = Date.now(); - const domPng = await captureAlphaPng(domSession.page, width, height); - addHdrTiming(hdrPerf, "domScreenshotMs", timingStart); + const domPng = await timeHdrPhaseAsync(hdrPerf, "domScreenshotMs", () => + captureAlphaPng(domSession.page, width, height), + ); // 5. Tear down the mask - timingStart = Date.now(); - await removeDomLayerMask(domSession.page, hideIds); - addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "domMaskRemoveMs", () => + removeDomLayerMask(domSession.page, hideIds), + ); try { - timingStart = Date.now(); - const { data: domRgba } = decodePng(domPng); - addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart); + const { data: domRgba } = timeHdrPhase(hdrPerf, "domPngDecodeMs", () => decodePng(domPng)); 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); + 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`; diff --git a/packages/producer/src/services/render/hdrPerf.ts b/packages/producer/src/services/render/hdrPerf.ts index c033fad5c3..0f23c9bc81 100644 --- a/packages/producer/src/services/render/hdrPerf.ts +++ b/packages/producer/src/services/render/hdrPerf.ts @@ -90,6 +90,30 @@ export function addHdrTiming( perf.timings[key] += Date.now() - startMs; } +export function timeHdrPhase( + 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( + perf: HdrPerfCollector | undefined, + key: HdrPerfTimingKey, + fn: () => Promise, +): Promise { + if (!perf) return fn(); + const start = Date.now(); + const result = await fn(); + addHdrTiming(perf, key, start); + return result; +} + function averageTiming(totalMs: number, count: number): number { return count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0; } diff --git a/packages/producer/src/services/render/stages/captureHdrFrameShared.ts b/packages/producer/src/services/render/stages/captureHdrFrameShared.ts index a6fa7dc38a..a6c0458677 100644 --- a/packages/producer/src/services/render/stages/captureHdrFrameShared.ts +++ b/packages/producer/src/services/render/stages/captureHdrFrameShared.ts @@ -29,7 +29,7 @@ import { blitHdrVideoLayer, closeHdrVideoFrameSource, } from "../../hdrCompositor.js"; -import { type HdrPerfCollector, addHdrTiming } from "../hdrPerf.js"; +import { type HdrPerfCollector, timeHdrPhase, timeHdrPhaseAsync } from "../hdrPerf.js"; // ─── Hybrid path gating + partitioning ───────────────────────────────────── @@ -141,15 +141,15 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise log, frameIdx, } = a; - let timingStart = Date.now(); - await session.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, time); - addHdrTiming(hdrPerf, "domLayerSeekMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "domLayerSeekMs", () => + session.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time), + ); if (beforeCaptureHook) { - timingStart = Date.now(); - await beforeCaptureHook(session.page, time); - addHdrTiming(hdrPerf, "domLayerInjectMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "domLayerInjectMs", () => + beforeCaptureHook(session.page, time), + ); } for (const el of stackingInfo) { if (!el.isHdr || !sceneIds.has(el.id)) continue; @@ -188,22 +188,20 @@ export async function captureSceneIntoBuffer(a: CaptureSceneArgs): Promise .map((e) => e.id) .filter((id) => !sceneIds.has(id) || nativeHdrIds.has(id)); if (hdrPerf) hdrPerf.domLayerCaptures += 1; - timingStart = Date.now(); - await applyDomLayerMask(session.page, showIds, hideIds); - addHdrTiming(hdrPerf, "domMaskApplyMs", timingStart); - timingStart = Date.now(); - const domPng = await captureAlphaPng(session.page, width, height); - addHdrTiming(hdrPerf, "domScreenshotMs", timingStart); - timingStart = Date.now(); - await removeDomLayerMask(session.page, hideIds); - addHdrTiming(hdrPerf, "domMaskRemoveMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "domMaskApplyMs", () => + applyDomLayerMask(session.page, showIds, hideIds), + ); + const domPng = await timeHdrPhaseAsync(hdrPerf, "domScreenshotMs", () => + captureAlphaPng(session.page, width, height), + ); + await timeHdrPhaseAsync(hdrPerf, "domMaskRemoveMs", () => + removeDomLayerMask(session.page, hideIds), + ); try { - timingStart = Date.now(); - const { data: domRgba } = decodePng(domPng); - addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart); - timingStart = Date.now(); - blitRgba8OverRgb48le(domRgba, sceneBuf, width, height, compositeTransfer); - addHdrTiming(hdrPerf, "domBlitMs", timingStart); + const { data: domRgba } = timeHdrPhase(hdrPerf, "domPngDecodeMs", () => decodePng(domPng)); + timeHdrPhase(hdrPerf, "domBlitMs", () => + blitRgba8OverRgb48le(domRgba, sceneBuf, width, height, compositeTransfer), + ); } catch (err) { log.warn("DOM layer decode/blit failed; skipping overlay for transition scene", { frameIndex: frameIdx, @@ -258,19 +256,17 @@ export async function captureTransitionFrameOnWorker( hdrPerf.frames += 1; hdrPerf.transitionFrames += 1; } - let timingStart = Date.now(); - await session.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, time); - addHdrTiming(hdrPerf, "frameSeekMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "frameSeekMs", () => + session.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time), + ); if (beforeCaptureHook) { - timingStart = Date.now(); - await beforeCaptureHook(session.page, time); - addHdrTiming(hdrPerf, "frameInjectMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "frameInjectMs", () => beforeCaptureHook(session.page, time)); } - timingStart = Date.now(); - const stackingInfo = await queryElementStacking(session.page, nativeHdrIds); - addHdrTiming(hdrPerf, "stackingQueryMs", timingStart); + const stackingInfo = await timeHdrPhaseAsync(hdrPerf, "stackingQueryMs", () => + queryElementStacking(session.page, nativeHdrIds), + ); const sceneAIds = new Set(sceneElements[transition.fromScene] ?? []); const sceneBIds = new Set(sceneElements[transition.toScene] ?? []); buffers.bufferA.fill(0); diff --git a/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts b/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts index d78a03d367..5479cd2356 100644 --- a/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts +++ b/packages/producer/src/services/render/stages/captureHdrHybridLoop.ts @@ -41,7 +41,7 @@ import { type TransitionRange, compositeHdrFrame, } from "../../hdrCompositor.js"; -import { type HdrPerfCollector, addHdrTiming } from "../hdrPerf.js"; +import { type HdrPerfCollector, addHdrTiming, timeHdrPhaseAsync } from "../hdrPerf.js"; import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js"; import { writeFileExclusiveSync } from "../shared.js"; import { @@ -306,25 +306,25 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise }); } else { const beforeCaptureHook = session.onBeforeCapture; - let timingStart = Date.now(); - await session.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, time); - addHdrTiming(hdrPerf, "frameSeekMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "frameSeekMs", () => + session.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time), + ); if (beforeCaptureHook) { - timingStart = Date.now(); - await beforeCaptureHook(session.page, time); - addHdrTiming(hdrPerf, "frameInjectMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "frameInjectMs", () => + beforeCaptureHook(session.page, time), + ); } - timingStart = Date.now(); - const stackingInfo = await queryElementStacking(session.page, nativeHdrIds); - addHdrTiming(hdrPerf, "stackingQueryMs", timingStart); + const stackingInfo = await timeHdrPhaseAsync(hdrPerf, "stackingQueryMs", () => + queryElementStacking(session.page, nativeHdrIds), + ); canvas.fill(0); // Rebind ctx to this worker's session for per-layer captures const wctx: HdrCompositeContext = { ...hdrCompositeCtx, domSession: session }; - timingStart = Date.now(); - await compositeHdrFrame(wctx, canvas, time, stackingInfo, undefined, i); - addHdrTiming(hdrPerf, "normalCompositeMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "normalCompositeMs", () => + compositeHdrFrame(wctx, canvas, time, stackingInfo, undefined, i), + ); if (debugDumpEnabled && debugDumpDir && i % 30 === 0) { writeFileExclusiveSync( join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`), diff --git a/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts b/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts index 5360bd980d..11141c2051 100644 --- a/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts +++ b/packages/producer/src/services/render/stages/captureHdrSequentialLoop.ts @@ -26,7 +26,12 @@ import { type TransitionRange, compositeHdrFrame, } from "../../hdrCompositor.js"; -import { type HdrPerfCollector, addHdrTiming } from "../hdrPerf.js"; +import { + type HdrPerfCollector, + addHdrTiming, + timeHdrPhase, + timeHdrPhaseAsync, +} from "../hdrPerf.js"; import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js"; import { writeFileExclusiveSync } from "../shared.js"; import { @@ -104,20 +109,20 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput): const time = (i * job.config.fps.den) / job.config.fps.num; if (hdrPerf) hdrPerf.frames += 1; - let timingStart = Date.now(); - await domSession.page.evaluate((t: number) => { - if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); - }, time); - addHdrTiming(hdrPerf, "frameSeekMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "frameSeekMs", () => + domSession.page.evaluate((t: number) => { + if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t); + }, time), + ); if (beforeCaptureHook) { - timingStart = Date.now(); - await beforeCaptureHook(domSession.page, time); - addHdrTiming(hdrPerf, "frameInjectMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "frameInjectMs", () => + beforeCaptureHook(domSession.page, time), + ); } - timingStart = Date.now(); - const stackingInfo = await queryElementStacking(domSession.page, nativeHdrIds); - addHdrTiming(hdrPerf, "stackingQueryMs", timingStart); + const stackingInfo = await timeHdrPhaseAsync(hdrPerf, "stackingQueryMs", () => + queryElementStacking(domSession.page, nativeHdrIds), + ); const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame); if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) { @@ -141,10 +146,10 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput): (activeTransition.endFrame - activeTransition.startFrame); const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []); const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []); - timingStart = Date.now(); - transitionBuffers.bufferA.fill(0); - transitionBuffers.bufferB.fill(0); - addHdrTiming(hdrPerf, "canvasClearMs", timingStart); + timeHdrPhase(hdrPerf, "canvasClearMs", () => { + transitionBuffers.bufferA.fill(0); + transitionBuffers.bufferB.fill(0); + }); for (const [sceneBuf, sceneIds] of [ [transitionBuffers.bufferA, sceneAIds], @@ -187,26 +192,24 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput): progress, ); addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart); - timingStart = Date.now(); - ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i); - addHdrTiming(hdrPerf, "encoderWriteMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "encoderWriteMs", async () => + ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i), + ); } else { if (hdrPerf) hdrPerf.normalFrames += 1; - timingStart = Date.now(); - normalCanvas.fill(0); - addHdrTiming(hdrPerf, "canvasClearMs", timingStart); - timingStart = Date.now(); - await compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, undefined, i); - addHdrTiming(hdrPerf, "normalCompositeMs", timingStart); + timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0)); + await timeHdrPhaseAsync(hdrPerf, "normalCompositeMs", () => + compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, undefined, i), + ); if (debugDumpEnabled && debugDumpDir && i % 30 === 0) { writeFileExclusiveSync( join(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`), normalCanvas, ); } - timingStart = Date.now(); - ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i); - addHdrTiming(hdrPerf, "encoderWriteMs", timingStart); + await timeHdrPhaseAsync(hdrPerf, "encoderWriteMs", async () => + ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i), + ); } cleanupEndedHdrVideos({ diff --git a/packages/producer/src/services/render/stages/encodeStage.ts b/packages/producer/src/services/render/stages/encodeStage.ts index 0ac575b0db..4c4ae8278f 100644 --- a/packages/producer/src/services/render/stages/encodeStage.ts +++ b/packages/producer/src/services/render/stages/encodeStage.ts @@ -42,6 +42,7 @@ import { } from "@hyperframes/engine"; import type { Fps } from "@hyperframes/core"; import type { ProducerLogger } from "../../../logger.js"; +import { formatExportFrameName } from "../../../utils/paths.js"; import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js"; import { buildGifPalettegenArgs, @@ -236,7 +237,7 @@ export async function runEncodeStage(input: EncodeStageInput): Promise { - const dst = join(outputPath, `frame_${String(i + 1).padStart(6, "0")}.png`); + const dst = join(outputPath, formatExportFrameName(i, "png")); copyFileSync(join(framesDir, name), dst); }); if (hasAudio && audioOutputPath && existsSync(audioOutputPath)) { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 44e8d231e2..e6b99b10f6 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -84,6 +84,7 @@ import { } from "./render/shared.js"; import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; +import { formatCaptureFrameName } from "../utils/paths.js"; import { resolveEffectiveHdrMode } from "./render/hdrMode.js"; import { buildRenderPerfSummary } from "./render/perfSummary.js"; import { getCaptureStageBrowserConsole } from "./render/captureStageError.js"; @@ -467,7 +468,7 @@ export function findMissingFrameRanges( let rangeStart: number | null = null; for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { - const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`); + const framePath = join(framesDir, formatCaptureFrameName(frameIndex, frameExt)); const missing = !existsSync(framePath); if (missing && rangeStart === null) { rangeStart = frameIndex; @@ -535,7 +536,7 @@ function countCapturedFrames( ): number { let captured = 0; for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { - const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`); + const framePath = join(framesDir, formatCaptureFrameName(frameIndex, frameExt)); if (existsSync(framePath)) captured++; } return captured; diff --git a/packages/producer/src/utils/paths.test.ts b/packages/producer/src/utils/paths.test.ts index 336938c6e0..abbffb0001 100644 --- a/packages/producer/src/utils/paths.test.ts +++ b/packages/producer/src/utils/paths.test.ts @@ -14,7 +14,12 @@ import { describe, expect, it } from "vitest"; import { resolve, win32 } from "node:path"; -import { isPathInside, toExternalAssetKey } from "./paths.js"; +import { + isPathInside, + toExternalAssetKey, + formatCaptureFrameName, + formatExportFrameName, +} from "./paths.js"; describe("isPathInside", () => { it("returns true when child is directly inside parent", () => { @@ -133,3 +138,27 @@ describe("toExternalAssetKey", () => { expect(/^[A-Za-z]:/.test(key)).toBe(false); }); }); + +describe("formatCaptureFrameName", () => { + it("returns zero-padded filename for zero-based index", () => { + expect(formatCaptureFrameName(0, "jpg")).toBe("frame_000000.jpg"); + }); + + it("pads to 6 digits for large indices", () => { + expect(formatCaptureFrameName(999999, "png")).toBe("frame_999999.png"); + }); + + it("handles mid-range indices", () => { + expect(formatCaptureFrameName(42, "jpg")).toBe("frame_000042.jpg"); + }); +}); + +describe("formatExportFrameName", () => { + it("returns one-based filename from zero-based input", () => { + expect(formatExportFrameName(0, "png")).toBe("frame_000001.png"); + }); + + it("increments index by 1", () => { + expect(formatExportFrameName(4, "png")).toBe("frame_000005.png"); + }); +}); diff --git a/packages/producer/src/utils/paths.ts b/packages/producer/src/utils/paths.ts index be93fd5ad1..ebf8688ca0 100644 --- a/packages/producer/src/utils/paths.ts +++ b/packages/producer/src/utils/paths.ts @@ -109,6 +109,14 @@ export function toExternalAssetKey(absPath: string): string { return "hf-ext/" + normalised; } +export function formatCaptureFrameName(index: number, ext: string): string { + return `frame_${String(index).padStart(6, "0")}.${ext}`; +} + +export function formatExportFrameName(index: number, ext: string): string { + return `frame_${String(index + 1).padStart(6, "0")}.${ext}`; +} + export function resolveRenderPaths( projectDir: string, outputPath: string | null | undefined,