Files
hyperframes/packages/producer/src/services/render/shared.ts
T
James e221cb8d5c refactor(producer): snapshot cfg.forceScreenshot at compile time, stop mutating mid-pipeline
Resolve the compileStage TODO from PR #720. cfg.forceScreenshot is now
computed exactly once inside compileStage (after applyRenderModeHints)
and returned on CompileStageResult.forceScreenshot. The sequencer stores
it on a local captureForceScreenshot; downstream capture stages take
the value as an explicit parameter and derive their own engine config
rather than reading cfg.forceScreenshot.

Mid-pipeline mutations removed:
- renderOrchestrator.ts: the pre-compile alpha-output mutation moved
  into compileStage so the resolution is one operation in one place.
- captureHdrStage.ts: stopped mutating caller-owned cfg; the layered
  composite path now uses a local hdrCfg derived from cfg plus
  forceScreenshot=true. The stage throws if called with
  forceScreenshot=false to make the contract explicit.
- BeginFrame auto-worker calibration fallback: still flips capture mode
  on a timeout, but flips the local boolean instead of cfg. The
  screenshot-mode retry uses a derived cfg view.

captureStage / captureStreamingStage add a forceScreenshot input and
derive captureCfg (identity-equal to cfg when the values already
agree, so no extra allocation on the common path).

lefthook.yml: grandfather renderOrchestrator.ts and captureHdrStage.ts
in the new 500-line filesize hook (#748). Both pre-date the hook and
are actively being shrunk in the producer stages stack.

Unblocks Phase 3 chunked rendering: LockedRenderConfig.forceScreenshot
in the distributed plan is computed here and survives across processes
without depending on shared mutable state.
2026-05-12 22:19:49 +00:00

244 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, ImageElement, VideoElement } from "@hyperframes/engine";
import type { CompiledComposition } from "../htmlCompiler.js";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { isPathInside } from "../../utils/paths.js";
import type { ProgressCallback, RenderJob, RenderStatus } from "../renderOrchestrator.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 interface RenderModeHintResult {
/** Resolved capture-mode boolean after folding in the hint. */
forceScreenshot: boolean;
/** True iff the hint flipped a `false` input to `true` (warn log fired). */
autoSelected: boolean;
}
/**
* Fold the composition's `renderModeHints.recommendScreenshot` signal
* into the caller's already-resolved `forceScreenshot` value. Pure: the
* caller owns the assignment to its own config. When the hint is the
* deciding factor (caller passed `false`, hint says recommend), fires
* the auto-select warn log with the composition's reason codes.
*/
export function applyRenderModeHints(
alreadyForced: boolean,
compiled: CompiledComposition,
log: ProducerLogger = defaultLogger,
): RenderModeHintResult {
if (alreadyForced || !compiled.renderModeHints.recommendScreenshot) {
return { forceScreenshot: alreadyForced, autoSelected: false };
}
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),
});
return { forceScreenshot: true, autoSelected: true };
}
/**
* Mutate the `RenderJob` view of the pipeline's progress and fire the
* caller's `onProgress` callback. Hoisted here (out of `renderOrchestrator.ts`)
* so the stage modules can call it without forming a runtime cycle.
*
* `completedAt` is stamped on the terminal `"failed"` / `"complete"`
* transitions so callers that poll the job state can tell when the
* pipeline finished.
*/
export function updateJobStatus(
job: RenderJob,
status: RenderStatus,
stage: string,
progress: number,
onProgress?: ProgressCallback,
): void {
job.status = status;
job.currentStage = stage;
job.progress = progress;
if (status === "failed" || status === "complete") job.completedAt = new Date();
if (onProgress) onProgress(job, stage);
}