diff --git a/packages/producer/src/services/render/shared.ts b/packages/producer/src/services/render/shared.ts new file mode 100644 index 000000000..f9f4ac487 --- /dev/null +++ b/packages/producer/src/services/render/shared.ts @@ -0,0 +1,204 @@ +/** + * Shared types and pure helpers used by the staged render pipeline. + * + * Lives in its own module so the stage files in `./stages/` can import the + * helpers they need without reaching back into `renderOrchestrator.ts` — + * the orchestrator imports the stage functions, so a runtime cycle would + * otherwise form (and grow as more stages are extracted). + * + * `renderOrchestrator.ts` re-exports everything declared here for + * backwards compatibility with existing test files and external callers. + */ + +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core"; +import type { AudioElement, EngineConfig, ImageElement, VideoElement } from "@hyperframes/engine"; +import type { CompiledComposition } from "../htmlCompiler.js"; +import { defaultLogger, type ProducerLogger } from "../../logger.js"; +import { isPathInside } from "../../utils/paths.js"; + +export interface CompositionMetadata { + duration: number; + videos: VideoElement[]; + audios: AudioElement[]; + images: ImageElement[]; + width: number; + height: number; +} + +/** + * Floating-point tolerance for reconciling browser-discovered media timing + * against statically-parsed metadata. Used when the browser reports a + * slightly different `end` / `mediaStart` / `volume` than the compiled + * HTML and we want to ignore sub-millisecond float noise. + */ +export const BROWSER_MEDIA_EPSILON = 0.0001; + +/** + * Browser-discovered media inside inlined sub-compositions can still report + * scene-local timing from the merged DOM (e.g. start=0, end=85.52) while the + * compiled metadata is already offset into the parent host timeline + * (e.g. start=4.417, end=89.937). Reproject browser end-time into the + * compiled element's time origin before reconciling it back into the render + * metadata. + */ +export function projectBrowserEndToCompositionTimeline( + existingStart: number, + browserStart: number, + browserEnd: number, +): number { + return browserEnd + (existingStart - browserStart); +} + +/** + * Translate the user-facing `--resolution` flag into a Chrome + * `deviceScaleFactor`. The composition's intrinsic dimensions stay the + * page-layout viewport; the screenshot lands at output dims via DPR. + * + * The scale must be a positive integer ≥ 1 — fractional DPRs introduce + * visible aliasing and we'd rather fail loudly than produce a blurry + * 4K render. Downsampling (output < composition) is rejected because + * the user is unlikely to have intended it; if the use case appears + * we can plumb a separate flag. + * + * Throws on: + * - HDR + outputResolution (HDR compositor processes raw pixel buffers + * at composition dimensions and would need parallel scaling). + * - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k). + * - Non-integer scale ratio. + * - Downsampling (output dimensions smaller than composition). + */ +export function resolveDeviceScaleFactor(input: { + compositionWidth: number; + compositionHeight: number; + outputResolution: CanvasResolution | undefined; + hdrRequested: boolean; + alphaRequested: boolean; +}): number { + if (!input.outputResolution) return 1; + if (input.hdrRequested) { + throw new Error( + "outputResolution cannot be combined with hdrMode='force-hdr'. " + + "HDR rendering composites at composition dimensions and does not yet " + + "support supersampling. Pick one or render in two passes.", + ); + } + if (input.alphaRequested) { + throw new Error( + "outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). " + + "The alpha screenshot path does not yet apply deviceScaleFactor and would silently " + + "produce composition-resolution frames. Render alpha at composition resolution and " + + "upscale separately, or use --format mp4.", + ); + } + const target = CANVAS_DIMENSIONS[input.outputResolution]; + // Aspect-ratio compare via cross-multiplication so the equality is integer- + // safe. Float division (`target.width / compositionWidth`) loses precision + // for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a + // future preset could trip a false-mismatch on otherwise valid input. + if (target.width * input.compositionHeight !== target.height * input.compositionWidth) { + throw new Error( + `outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` + + `does not match the aspect ratio of the composition ` + + `(${input.compositionWidth}×${input.compositionHeight}). ` + + `Pick a preset whose orientation matches.`, + ); + } + // Aspect ratios match → widthRatio === heightRatio. Compute once. + const widthRatio = target.width / input.compositionWidth; + if (widthRatio < 1) { + throw new Error( + `outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` + + `is smaller than the composition (${input.compositionWidth}×${input.compositionHeight}). ` + + `Downsampling via --resolution is not supported.`, + ); + } + if (!Number.isInteger(widthRatio)) { + throw new Error( + `outputResolution ${input.outputResolution} requires a non-integer ` + + `device scale factor (${widthRatio}×) to upsample from ` + + `${input.compositionWidth}×${input.compositionHeight}. ` + + `Pick a preset that's an integer multiple, or rescale the composition.`, + ); + } + return widthRatio; +} + +/** + * Write compiled HTML and sub-compositions to the work directory. + * + * Exported for integration tests. Not part of the stable public API — + * callers outside this package should use `executeRenderJob` instead. + */ +export function writeCompiledArtifacts( + compiled: CompiledComposition, + workDir: string, + includeSummary: boolean, +): void { + const compileDir = join(workDir, "compiled"); + mkdirSync(compileDir, { recursive: true }); + + writeFileSync(join(compileDir, "index.html"), compiled.html, "utf-8"); + + for (const [srcPath, html] of compiled.subCompositions) { + const outPath = join(compileDir, srcPath); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, html, "utf-8"); + } + + // Copy external assets (files outside projectDir) into the compiled directory + // so the file server can serve them. The safe-path check uses + // `isPathInside()` rather than a hardcoded separator — on Windows, + // `compileDir + "/"` never matches because paths use `\\`, which caused + // every external asset to be wrongly rejected as "unsafe" (see GH #321). + for (const [relativePath, absolutePath] of compiled.externalAssets) { + const outPath = resolve(join(compileDir, relativePath)); + if (!isPathInside(outPath, compileDir)) { + console.warn(`[Render] Skipping external asset with unsafe path: ${relativePath}`); + continue; + } + mkdirSync(dirname(outPath), { recursive: true }); + copyFileSync(absolutePath, outPath); + } + + if (includeSummary) { + const summary = { + width: compiled.width, + height: compiled.height, + staticDuration: compiled.staticDuration, + videos: compiled.videos.map((v) => ({ + id: v.id, + src: v.src, + start: v.start, + end: v.end, + mediaStart: v.mediaStart, + })), + audios: compiled.audios.map((a) => ({ + id: a.id, + src: a.src, + start: a.start, + end: a.end, + mediaStart: a.mediaStart, + })), + subCompositions: Array.from(compiled.subCompositions.keys()), + renderModeHints: compiled.renderModeHints, + hasShaderTransitions: compiled.hasShaderTransitions, + }; + writeFileSync(join(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8"); + } +} + +export function applyRenderModeHints( + cfg: EngineConfig, + compiled: CompiledComposition, + log: ProducerLogger = defaultLogger, +): void { + if (cfg.forceScreenshot || !compiled.renderModeHints.recommendScreenshot) return; + + cfg.forceScreenshot = true; + log.warn("Auto-selected screenshot capture mode for render compatibility", { + reasonCodes: compiled.renderModeHints.reasons.map((reason) => reason.code), + reasons: compiled.renderModeHints.reasons.map((reason) => reason.message), + }); +} diff --git a/packages/producer/src/services/render/stages/compileStage.ts b/packages/producer/src/services/render/stages/compileStage.ts index a00724800..5f2417800 100644 --- a/packages/producer/src/services/render/stages/compileStage.ts +++ b/packages/producer/src/services/render/stages/compileStage.ts @@ -32,8 +32,8 @@ import { resolveDeviceScaleFactor, writeCompiledArtifacts, type CompositionMetadata, - type RenderJob, -} from "../../renderOrchestrator.js"; +} from "../shared.js"; +import type { RenderJob } from "../../renderOrchestrator.js"; export interface CompileStageInput { projectDir: string; diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts index f3ba06adc..22b3df73d 100644 --- a/packages/producer/src/services/render/stages/probeStage.ts +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -44,13 +44,12 @@ import { import { createFileServer, type FileServerHandle, VIRTUAL_TIME_SHIM } from "../../fileServer.js"; import type { ProducerLogger } from "../../../logger.js"; import { + BROWSER_MEDIA_EPSILON, projectBrowserEndToCompositionTimeline, writeCompiledArtifacts, type CompositionMetadata, - type RenderJob, -} from "../../renderOrchestrator.js"; - -const BROWSER_MEDIA_EPSILON = 0.0001; +} from "../shared.js"; +import type { RenderJob } from "../../renderOrchestrator.js"; export interface ProbeStageInput { projectDir: string; diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 344e55c03..b5abc3bd8 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -6,7 +6,6 @@ import type { EngineConfig, ExtractedFrames } from "@hyperframes/engine"; import type { CompiledComposition } from "./htmlCompiler.js"; import { - applyRenderModeHints, buildMissingFrameRetryBatches, collectVideoMetadataHints, collectVideoReadinessSkipIds, @@ -19,16 +18,19 @@ import { getNextRetryWorkerCount, isRecoverableParallelCaptureError, materializeExtractedFramesForCompiledDir, - projectBrowserEndToCompositionTimeline, - resolveDeviceScaleFactor, resolveRenderWorkerCount, resolveCompositeTransfer, selectCaptureCalibrationFrames, shouldFallbackToScreenshotAfterCalibrationError, shouldUseLayeredComposite, shouldUseStreamingEncode, - writeCompiledArtifacts, } from "./renderOrchestrator.js"; +import { + applyRenderModeHints, + projectBrowserEndToCompositionTimeline, + resolveDeviceScaleFactor, + writeCompiledArtifacts, +} from "./render/shared.js"; import { toExternalAssetKey } from "../utils/paths.js"; describe("extractStandaloneEntryFromIndex", () => { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index fbed2b3f1..6f96ac4ab 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -29,13 +29,7 @@ import { symlinkSync, } from "fs"; import { parseHTML } from "linkedom"; -import { - CANVAS_DIMENSIONS, - type CanvasResolution, - type Fps, - fpsToNumber, - fpsToFfmpegArg, -} from "@hyperframes/core"; +import { type CanvasResolution, type Fps, fpsToNumber, fpsToFfmpegArg } from "@hyperframes/core"; import { type EngineConfig, resolveConfig, @@ -44,7 +38,6 @@ import { type ExtractedFrames, type ExtractionPhaseBreakdown, createFrameLookupTable, - type VideoElement, FrameLookupTable, type HdrTransfer, detectTransfer, @@ -65,8 +58,6 @@ import { applyFaststart, getEncoderPreset, processCompositionAudio, - type AudioElement, - type ImageElement, calculateOptimalWorkers, distributeFrames, executeParallelCapture, @@ -555,105 +546,6 @@ export class RenderCancelledError extends Error { } } -export interface CompositionMetadata { - duration: number; - videos: VideoElement[]; - audios: AudioElement[]; - images: ImageElement[]; - width: number; - height: number; -} - -/** - * Browser-discovered media inside inlined sub-compositions can still report - * scene-local timing from the merged DOM (e.g. start=0, end=85.52) while the - * compiled metadata is already offset into the parent host timeline - * (e.g. start=4.417, end=89.937). Reproject browser end-time into the - * compiled element's time origin before reconciling it back into the render - * metadata. - */ -export function projectBrowserEndToCompositionTimeline( - existingStart: number, - browserStart: number, - browserEnd: number, -): number { - return browserEnd + (existingStart - browserStart); -} - -/** - * Translate the user-facing `--resolution` flag into a Chrome - * `deviceScaleFactor`. The composition's intrinsic dimensions stay the - * page-layout viewport; the screenshot lands at output dims via DPR. - * - * The scale must be a positive integer ≥ 1 — fractional DPRs introduce - * visible aliasing and we'd rather fail loudly than produce a blurry - * 4K render. Downsampling (output < composition) is rejected because - * the user is unlikely to have intended it; if the use case appears - * we can plumb a separate flag. - * - * Throws on: - * - HDR + outputResolution (HDR compositor processes raw pixel buffers - * at composition dimensions and would need parallel scaling). - * - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k). - * - Non-integer scale ratio. - * - Downsampling (output dimensions smaller than composition). - */ -export function resolveDeviceScaleFactor(input: { - compositionWidth: number; - compositionHeight: number; - outputResolution: CanvasResolution | undefined; - hdrRequested: boolean; - alphaRequested: boolean; -}): number { - if (!input.outputResolution) return 1; - if (input.hdrRequested) { - throw new Error( - "outputResolution cannot be combined with hdrMode='force-hdr'. " + - "HDR rendering composites at composition dimensions and does not yet " + - "support supersampling. Pick one or render in two passes.", - ); - } - if (input.alphaRequested) { - throw new Error( - "outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). " + - "The alpha screenshot path does not yet apply deviceScaleFactor and would silently " + - "produce composition-resolution frames. Render alpha at composition resolution and " + - "upscale separately, or use --format mp4.", - ); - } - const target = CANVAS_DIMENSIONS[input.outputResolution]; - // Aspect-ratio compare via cross-multiplication so the equality is integer- - // safe. Float division (`target.width / compositionWidth`) loses precision - // for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a - // future preset could trip a false-mismatch on otherwise valid input. - if (target.width * input.compositionHeight !== target.height * input.compositionWidth) { - throw new Error( - `outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` + - `does not match the aspect ratio of the composition ` + - `(${input.compositionWidth}×${input.compositionHeight}). ` + - `Pick a preset whose orientation matches.`, - ); - } - // Aspect ratios match → widthRatio === heightRatio. Compute once. - const widthRatio = target.width / input.compositionWidth; - if (widthRatio < 1) { - throw new Error( - `outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` + - `is smaller than the composition (${input.compositionWidth}×${input.compositionHeight}). ` + - `Downsampling via --resolution is not supported.`, - ); - } - if (!Number.isInteger(widthRatio)) { - throw new Error( - `outputResolution ${input.outputResolution} requires a non-integer ` + - `device scale factor (${widthRatio}×) to upsample from ` + - `${input.compositionWidth}×${input.compositionHeight}. ` + - `Pick a preset that's an integer multiple, or rescale the composition.`, - ); - } - return widthRatio; -} - function updateJobStatus( job: RenderJob, status: RenderStatus, @@ -706,69 +598,6 @@ function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger }; } -/** - * Write compiled HTML and sub-compositions to the work directory. - */ -// Exported for integration tests. Not part of the stable public API — -// callers outside this package should use `executeRenderJob` instead. -export function writeCompiledArtifacts( - compiled: CompiledComposition, - workDir: string, - includeSummary: boolean, -): void { - const compileDir = join(workDir, "compiled"); - mkdirSync(compileDir, { recursive: true }); - - writeFileSync(join(compileDir, "index.html"), compiled.html, "utf-8"); - - for (const [srcPath, html] of compiled.subCompositions) { - const outPath = join(compileDir, srcPath); - mkdirSync(dirname(outPath), { recursive: true }); - writeFileSync(outPath, html, "utf-8"); - } - - // Copy external assets (files outside projectDir) into the compiled directory - // so the file server can serve them. The safe-path check uses - // `isPathInside()` rather than a hardcoded separator — on Windows, - // `compileDir + "/"` never matches because paths use `\\`, which caused - // every external asset to be wrongly rejected as "unsafe" (see GH #321). - for (const [relativePath, absolutePath] of compiled.externalAssets) { - const outPath = resolve(join(compileDir, relativePath)); - if (!isPathInside(outPath, compileDir)) { - console.warn(`[Render] Skipping external asset with unsafe path: ${relativePath}`); - continue; - } - mkdirSync(dirname(outPath), { recursive: true }); - copyFileSync(absolutePath, outPath); - } - - if (includeSummary) { - const summary = { - width: compiled.width, - height: compiled.height, - staticDuration: compiled.staticDuration, - videos: compiled.videos.map((v) => ({ - id: v.id, - src: v.src, - start: v.start, - end: v.end, - mediaStart: v.mediaStart, - })), - audios: compiled.audios.map((a) => ({ - id: a.id, - src: a.src, - start: a.start, - end: a.end, - mediaStart: a.mediaStart, - })), - subCompositions: Array.from(compiled.subCompositions.keys()), - renderModeHints: compiled.renderModeHints, - hasShaderTransitions: compiled.hasShaderTransitions, - }; - writeFileSync(join(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8"); - } -} - export function createCompiledFrameSrcResolver( compiledDir: string, ): (framePath: string) => string | null { @@ -855,20 +684,6 @@ export function materializeExtractedFramesForCompiledDir( } } -export function applyRenderModeHints( - cfg: EngineConfig, - compiled: CompiledComposition, - log: ProducerLogger = defaultLogger, -): void { - if (cfg.forceScreenshot || !compiled.renderModeHints.recommendScreenshot) return; - - cfg.forceScreenshot = true; - log.warn("Auto-selected screenshot capture mode for render compatibility", { - reasonCodes: compiled.renderModeHints.reasons.map((reason) => reason.code), - reasons: compiled.renderModeHints.reasons.map((reason) => reason.message), - }); -} - export function collectVideoReadinessSkipIds( nativeHdrVideoIds: ReadonlySet, extractedVideos: readonly ExtractedVideoReadinessInput[],