Files
hyperframes/packages/cli/src/commands/render.ts
T

1787 lines
70 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.
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import {
reportVariableIssues,
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../utils/variables.js";
import {
parseGifLoopArg,
resolveBrowserTimeoutMsArg,
resolveCompositionEntryArg,
resolveDefaultFpsArg,
} from "../utils/renderArgs.js";
export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
["Render a specific composition", "hyperframes render -c compositions/intro.html -o intro.mp4"],
[
"Upsample any composition to 4K (supersamples via Chrome DPR)",
"hyperframes render --resolution 4k --output 4k.mp4",
],
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
[
"Render animated GIF for PRs/docs",
"hyperframes render --format gif --fps 15 --gif-loop 0 --output demo.gif",
],
[
"Render PNG sequence (RGBA frames for AE/Nuke/Fusion)",
"hyperframes render --format png-sequence --output frames/",
],
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
["Opt out of browser GPU render", "hyperframes render --no-browser-gpu --output cpu.mp4"],
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
[
"Override composition variables (parametrized render)",
'hyperframes render --variables \'{"title":"Q4 Report","theme":"dark"}\' --output q4.mp4',
],
[
"Variables from a JSON file",
"hyperframes render --variables-file ./vars.json --output out.mp4",
],
[
"Batch render one output per variables row",
'hyperframes render --batch rows.json --output "renders/{name}.mp4"',
],
];
import { cpus, freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
import { execFileSync, spawn } from "node:child_process";
import { resolveProject } from "../utils/project.js";
import { lintProject, shouldBlockRender } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatRenderSummaryDetail, errorBox } from "../ui/format.js";
import { warnIfWebmAlphaDropped } from "../utils/webmAlphaCheck.js";
import { renderProgress } from "../ui/progress.js";
import {
trackRenderComplete,
trackRenderError,
trackRenderObservation,
trackRenderPreflightRejected,
} from "../telemetry/events.js";
import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js";
import { normalizeSkillSlug } from "../telemetry/skill.js";
import { bytesToMb } from "../telemetry/system.js";
import { VERSION } from "../version.js";
import { isDevMode } from "../utils/env.js";
import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { runEnvironmentChecks } from "../browser/preflight.js";
import { chromeLaunchRemediation } from "../browser/linuxDeps.js";
import type { ProducerLogger, RenderJob } from "@hyperframes/producer";
import {
MAX_VP9_CPU_USED,
MIN_VP9_CPU_USED,
isVideoFrameFormat,
type VideoFrameFormat,
} from "@hyperframes/engine";
import {
normalizeResolutionFlag,
checkOutputResolutionCompatibility,
parseFps,
fpsToNumber,
fpsToFfmpegArg,
type CanvasResolution,
type OutputResolutionIssueKind,
type Fps,
type FpsParseResult,
} from "@hyperframes/core";
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
/**
* Map a {@link FpsParseResult} failure reason to a human-friendly
* error-box message. The empty / undefined / default-fallthrough case
* shouldn't be reachable from the CLI flag (citty supplies a default of
* "30") but the branch exists so this helper can be reused by other
* fps-accepting CLI surfaces in the future.
*/
function formatFpsParseError(
input: string,
reason: Exclude<FpsParseResult, { ok: true }>["reason"],
): string {
switch (reason) {
case "empty":
return "Frame rate must not be empty.";
case "not-a-number":
return `Got "${input}". Frame rate must be an integer (e.g. 30) or a rational (e.g. 30000/1001 for NTSC).`;
case "non-positive":
return `Got "${input}". Frame rate must be greater than zero.`;
case "out-of-range":
return `Got "${input}". Frame rate must be in the range 1240.`;
case "invalid-fraction":
return `Got "${input}". Rational frame rates must be two positive integers separated by '/' (e.g. 30000/1001).`;
case "ambiguous-decimal":
return `Got "${input}". Decimal frame rates are ambiguous — use the exact rational form instead (e.g. 30000/1001 for 29.97).`;
}
}
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
type RenderFormat = (typeof RENDER_FORMATS)[number];
const VALID_FORMAT = new Set<string>(RENDER_FORMATS);
const RENDER_FORMAT_LABEL = "mp4, webm, mov, png-sequence, or gif";
// `png-sequence` writes a directory of frames rather than a single muxed file,
// so its "extension" is empty — the auto-output path becomes a directory name.
const FORMAT_EXT: Record<RenderFormat, string> = {
mp4: ".mp4",
webm: ".webm",
mov: ".mov",
"png-sequence": "",
gif: ".gif",
};
const CPU_CORE_COUNT = cpus().length;
function parseRenderFormat(input: string): RenderFormat | undefined {
if (!VALID_FORMAT.has(input)) return undefined;
return RENDER_FORMATS.find((format) => format === input);
}
export default defineCommand({
meta: {
name: "render",
description: "Render a composition to MP4, WebM, MOV, GIF, or a PNG sequence",
},
args: {
dir: {
type: "positional",
description: "Project directory",
required: false,
},
composition: {
type: "string",
alias: "c",
description:
"Render a specific composition file instead of index.html (e.g. compositions/intro.html). " +
"Sub-compositions using <template> wrappers must be referenced from index.html via data-composition-src. " +
"Pass `.` (or omit the flag) to render the project's index.html.",
},
output: {
type: "string",
alias: "o",
description: "Output path (default: renders/<name>.mp4)",
},
fps: {
type: "string",
alias: "f",
description:
"Frame rate. Accepts integer (24, 25, 30, 50, 60, 120, 240) or " +
"ffmpeg-style rational (30000/1001 for NTSC 29.97, 24000/1001 for " +
"23.976, 60000/1001 for 59.94). Range 1-240. " +
"Defaults to the composition's root data-fps, else 30.",
// No `default` here on purpose: citty would set args.fps="30" on
// omission, which would make explicitFps always non-null and short-
// circuit the data-fps resolution below (resolveDefaultFpsArg). The
// "30" fallback lives at the parseFps(fpsArg ?? "30") call instead.
},
quality: {
type: "string",
alias: "q",
description: "Quality: draft, standard, high",
default: "standard",
},
skill: {
type: "string",
description:
"Authoring workflow skill that initiated this render (e.g. product-launch-video). " +
"Recorded on anonymous render telemetry for per-skill usage breakdowns; ignored unless it is a slug.",
},
format: {
type: "string",
description:
"Output format: mp4, webm, mov, gif, png-sequence " +
"(MOV/WebM render with transparency; png-sequence writes RGBA frames " +
"to a directory for AE/Nuke/Fusion ingest; gif is best at 15fps for PRs/docs)",
default: "mp4",
},
"gif-loop": {
type: "string",
description: "GIF loop count, 0 = infinite. Range: 0-65535. Only used with --format gif.",
},
"video-frame-format": {
type: "string",
description:
"Source video frame extraction format: auto, jpg, png (default: auto). " +
"Use png for UI recordings, screen captures, and color-sensitive source videos; " +
"alpha-capable sources always extract as PNG.",
default: "auto",
},
workers: {
type: "string",
alias: "w",
description:
"Parallel render workers (number or 'auto'). Default: auto. " +
"Each worker launches a separate Chrome process (~256 MB RAM).",
},
docker: {
type: "boolean",
description: "Use Docker for deterministic render",
default: false,
},
hdr: {
type: "boolean",
description: "Force HDR output even if no HDR sources are detected",
default: false,
},
sdr: {
type: "boolean",
description: "Force SDR output even if HDR sources are detected",
default: false,
},
crf: {
type: "string",
description: "Override encoder CRF. Mutually exclusive with --video-bitrate.",
},
"video-bitrate": {
type: "string",
description: "Target video bitrate such as 10M. Mutually exclusive with --crf.",
},
"vp9-cpu-used": {
type: "string",
description:
"libvpx-vp9 -cpu-used value for WebM encodes (-8 to 8). Higher is faster with a larger quality/size tradeoff. Env: PRODUCER_VP9_CPU_USED.",
},
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
"browser-gpu": {
type: "boolean",
description:
"Force host GPU acceleration for Chrome/WebGL capture. Default: auto (probe on first launch; fall back to software if no GPU). Use --no-browser-gpu to force software (SwiftShader).",
},
quiet: {
type: "boolean",
description: "Suppress verbose output",
default: false,
},
debug: {
type: "boolean",
description:
"Write full render diagnostics and keep intermediate artifacts under the producer .debug directory.",
default: false,
},
strict: {
type: "boolean",
description: "Fail render on lint errors",
default: false,
},
"strict-all": {
type: "boolean",
description: "Fail render on lint errors AND warnings",
default: false,
},
"max-concurrent-renders": {
type: "string",
description: "Max concurrent renders when using the producer server (1-10). Default: 2.",
},
variables: {
type: "string",
description:
'JSON object of variable values, merged over the composition\'s data-composition-variables defaults. Example: --variables \'{"title":"Hello"}\'. Read inside the composition via window.__hyperframes.getVariables().',
},
"variables-file": {
type: "string",
description:
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
},
"strict-variables": {
type: "boolean",
description:
"Fail render if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.",
default: false,
},
batch: {
type: "string",
description:
'Path to a JSON array of variable rows (or {"rows":[...]}). Renders one output per row.',
},
"batch-concurrency": {
type: "string",
description:
"Maximum number of batch rows to render at once. Default: 1, because each render already parallelizes across workers.",
},
"batch-fail-fast": {
type: "boolean",
description: "Stop launching new batch rows after the first row failure.",
default: false,
},
json: {
type: "boolean",
description: "With --batch, emit JSON progress events.",
default: false,
},
resolution: {
type: "string",
description:
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
},
"page-side-compositing": {
type: "boolean",
description:
"Run shader transitions on a page-side WebGL canvas inside Chrome " +
"instead of the Node-side layered blend. ~6× faster for SDR " +
"shader-transition renders. HDR/alpha/video content auto-disables. " +
"Use --no-page-side-compositing to force the layered path.",
default: true,
},
"browser-timeout": {
type: "string",
description:
"Puppeteer page-navigation timeout in SECONDS for the entry HTML. " +
"Increase when heavy compositions (many videos / fonts / asset " +
"requests) cannot reach domcontentloaded within the 60s default " +
"(see issue #1199). Accepts 0.001-86400 (24h cap). " +
"Note: this controls page.goto only — very heavy compositions may " +
"also need PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS / " +
"PRODUCER_PLAYER_READY_TIMEOUT_MS bumped (the post-goto window.__hf " +
"readiness poll has its own 45s budget). " +
"Env fallback: PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS (MILLISECONDS).",
},
"protocol-timeout": {
type: "string",
description:
"CDP protocol timeout in ms. Increase on slow/low-memory machines " +
"where Chrome operations time out. Default: 300000 (5 min). " +
"Env: PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS.",
},
"player-ready-timeout": {
type: "string",
description:
"Timeout in ms for the composition player to become ready. " +
"Increase for complex compositions on slow hardware. Default: 45000 (45 s). " +
"Env: PRODUCER_PLAYER_READY_TIMEOUT_MS.",
},
"low-memory-mode": {
type: "boolean",
description:
"Force the low-memory safe render profile on (--low-memory-mode) or " +
"off (--no-low-memory-mode). Safe mode pins to 1 worker, uses " +
"screenshot capture, and skips auto-worker calibration to avoid " +
"memory thrash on constrained machines. Default: auto-detected from " +
"total RAM (<= 8 GB). Env: PRODUCER_LOW_MEMORY_MODE.",
},
"experimental-fast-capture": {
type: "boolean",
description:
"Capture frames via Chrome's drawElementImage API instead of " +
"Page.captureScreenshot — reads DOM paint records directly, ~2x faster. " +
"Default: on where it can engage (macOS + hardware-GPU browser); " +
"incompatible compositions and self-verification failures fall back to " +
"screenshot capture automatically. Pass =false to disable. " +
"Env: PRODUCER_EXPERIMENTAL_FAST_CAPTURE.",
// No `default` — an omitted flag must stay `undefined` so the `!= null`
// guard below leaves PRODUCER_EXPERIMENTAL_FAST_CAPTURE untouched and the
// env fallback survives (matches the --low-memory-mode idiom).
},
},
// `run` is the citty handler for `hyperframes render` — sequential flag
// validation + render dispatch. Inherited CRITICAL on main (CRAP 1290);
// this PR extracted --browser-timeout + --composition validators into
// `utils/renderArgs.ts`, reducing cyclomatic 75→65 and CRAP 1290→978.
// Full decomposition is tracked separately and out of scope for #1199.
// fallow-ignore-next-line complexity
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
const project = resolveProject(args.dir);
// ── Resolve composition entry file ─────────────────────────────────────
// Needed early: fps default below must read the actual render target, not
// always index.html.
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
// ── Validate fps ───────────────────────────────────────────────────────
// Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`).
// The whitelist-based validator was replaced with a sane numeric range so
// legitimate framerates (NTSC trio, PAL, 120/240 slow-mo) work without
// CLI gymnastics. The exact rational survives end-to-end into FFmpeg's
// `-r` / `-framerate` flags via `fpsToFfmpegArg`.
// Precedence: explicit --fps, else the composition's root data-fps, else 30.
// Honoring data-fps matches the runtime — render used to silently force 30
// even when the composition declared e.g. data-fps="24".
const fpsArg = resolveDefaultFpsArg(args.fps, project.dir, project.indexPath, entryFile);
const fpsParse = parseFps(fpsArg ?? "30");
if (!fpsParse.ok) {
errorBox("Invalid fps", formatFpsParseError(fpsArg ?? "30", fpsParse.reason));
process.exit(1);
}
let fps: Fps = fpsParse.value;
// ── Validate quality ───────────────────────────────────────────────────
const qualityRaw = args.quality ?? "standard";
if (!VALID_QUALITY.has(qualityRaw)) {
errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`);
process.exit(1);
}
const quality = qualityRaw as "draft" | "standard" | "high";
// ── Authoring skill (telemetry attribution) ────────────────────────────
// Optional slug naming the workflow skill that drove this render (e.g.
// "product-launch-video"), tagged onto render telemetry for per-skill usage
// breakdowns. Slug-gated (shared with the `events` command) so a caller
// can't push high-cardinality or PII strings into the anonymous event
// stream; a missing/invalid value is omitted.
const authoringSkill = normalizeSkillSlug(args.skill);
if (typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill) {
// Surface a typo (e.g. camelCase) instead of silently losing attribution.
// Warning only — never fails the render.
process.stderr.write(
`hyperframes: ignoring --skill="${args.skill}" — not a valid slug ` +
"(lowercase letters/digits/hyphens, max 64); this render will be unattributed.\n",
);
}
// ── Validate format ─────────────────────────────────────────────────
const formatRaw = args.format ?? "mp4";
const format = parseRenderFormat(formatRaw);
if (!format) {
errorBox("Invalid format", `Got "${formatRaw}". Must be ${RENDER_FORMAT_LABEL}.`);
process.exit(1);
}
let gifFpsCapped = false;
if (format === "gif" && fpsToNumber(fps) > 30) {
fps = { num: 30, den: 1 };
gifFpsCapped = true;
}
const gifLoopParse = parseGifLoopArg(args["gif-loop"]);
if (!gifLoopParse.ok) {
errorBox("Invalid gif-loop", gifLoopParse.message);
process.exit(1);
}
const gifLoop = gifLoopParse.value ?? (format === "gif" ? 0 : undefined);
const videoFrameFormatRaw = args["video-frame-format"] ?? "auto";
if (!isVideoFrameFormat(videoFrameFormatRaw)) {
errorBox(
"Invalid video-frame-format",
`Got "${videoFrameFormatRaw}". Must be auto, jpg, or png.`,
);
process.exit(1);
}
const videoFrameFormat = videoFrameFormatRaw;
// ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined;
if (args.resolution !== undefined) {
outputResolution = normalizeResolutionFlag(args.resolution);
if (!outputResolution) {
errorBox(
"Invalid resolution",
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` +
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
);
process.exit(1);
}
// Reject the --resolution + --hdr combination at the CLI layer so the
// user sees the friendly errorBox before any work directories or
// ffmpeg processes spin up. The orchestrator also enforces this via
// resolveDeviceScaleFactor — defense in depth.
if (args.hdr) {
errorBox(
"Conflicting flags",
"--resolution cannot be combined with --hdr. The HDR pipeline composites at composition dimensions and does not yet support supersampling.",
"Render in two passes: HDR at composition resolution, then upscale separately with ffmpeg.",
);
process.exit(1);
}
}
// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
if (args.workers != null && args.workers !== "auto") {
const parsed = parseInt(args.workers, 10);
if (isNaN(parsed) || parsed < 1) {
errorBox("Invalid workers", `Got "${args.workers}". Must be a positive number or "auto".`);
process.exit(1);
}
workers = parsed;
}
// ── Validate timeout overrides ─────────────────────────────────────
let protocolTimeout: number | undefined;
if (args["protocol-timeout"] != null) {
const parsed = parseInt(args["protocol-timeout"], 10);
if (isNaN(parsed) || parsed < 1000) {
errorBox(
"Invalid protocol-timeout",
`Got "${args["protocol-timeout"]}". Must be a number >= 1000 (ms).`,
);
process.exit(1);
}
protocolTimeout = parsed;
}
let playerReadyTimeout: number | undefined;
if (args["player-ready-timeout"] != null) {
const parsed = parseInt(args["player-ready-timeout"], 10);
if (isNaN(parsed) || parsed < 1000) {
errorBox(
"Invalid player-ready-timeout",
`Got "${args["player-ready-timeout"]}". Must be a number >= 1000 (ms).`,
);
process.exit(1);
}
playerReadyTimeout = parsed;
}
// ── Wire opt-in: page-side compositing ───────────────────────────────
if (args["page-side-compositing"] === false) {
process.env.HF_PAGE_SIDE_COMPOSITING = "false";
}
// ── Override: low-memory safe profile (tri-state) ────────────────────
// Absent → auto-detect from total RAM inside resolveConfig. Explicit
// --low-memory-mode / --no-low-memory-mode forces it on/off via the env
// var the producer's resolveConfig reads.
if (args["low-memory-mode"] != null) {
process.env.PRODUCER_LOW_MEMORY_MODE = args["low-memory-mode"] ? "true" : "false";
}
// ── Override: experimental fast capture (drawElementImage) ───────────
if (args["experimental-fast-capture"] != null) {
process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE = args["experimental-fast-capture"]
? "true"
: "false";
}
// ── Validate max-concurrent-renders ─────────────────────────────────
if (args["max-concurrent-renders"] != null) {
const parsed = parseInt(args["max-concurrent-renders"], 10);
if (isNaN(parsed) || parsed < 1 || parsed > 10) {
errorBox(
"Invalid max-concurrent-renders",
`Got "${args["max-concurrent-renders"]}". Must be a number between 1 and 10.`,
);
process.exit(1);
}
process.env.PRODUCER_MAX_CONCURRENT_RENDERS = String(parsed);
}
// ── Validate batch mode ───────────────────────────────────────────────
const batchPath =
typeof args.batch === "string" && args.batch.trim() !== "" ? args.batch.trim() : undefined;
if (batchPath && (args.variables != null || args["variables-file"] != null)) {
errorBox(
"Conflicting variables flags",
"Use either --batch or --variables/--variables-file, not both.",
);
process.exit(1);
}
if (!batchPath && args["batch-concurrency"] != null) {
errorBox("Invalid batch-concurrency", "--batch-concurrency requires --batch.");
process.exit(1);
}
if (!batchPath && args["batch-fail-fast"]) {
errorBox("Invalid batch-fail-fast", "--batch-fail-fast requires --batch.");
process.exit(1);
}
let batchConcurrency = 1;
if (args["batch-concurrency"] != null) {
const parsed = parseInt(args["batch-concurrency"], 10);
if (isNaN(parsed) || parsed < 1) {
errorBox(
"Invalid batch-concurrency",
`Got "${args["batch-concurrency"]}". Must be a positive integer.`,
);
process.exit(1);
}
batchConcurrency = parsed;
}
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const ext = FORMAT_EXT[format] ?? ".mp4";
// fallow-ignore-next-line code-duplication
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
const batchOutputTemplate = args.output
? args.output
: join(rendersDir, `${project.name}_${datePart}_${timePart}_{index}${ext}`);
const outputPath = args.output
? resolve(args.output)
: join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`);
// Ensure output directory exists
if (!batchPath) mkdirSync(dirname(outputPath), { recursive: true });
const useDocker = args.docker ?? false;
const useGpu = args.gpu ?? false;
const browserGpuArg = args["browser-gpu"];
const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg);
const quiet = args.quiet ?? false;
const debug = args.debug ?? false;
const batchJson = args.json ?? false;
const effectiveQuiet = quiet || (batchPath != null && batchJson);
const strictAll = args["strict-all"] ?? false;
const strictErrors = (args.strict ?? false) || strictAll;
const crfRaw = args.crf;
const videoBitrate = args["video-bitrate"]?.trim();
if (crfRaw != null && videoBitrate) {
errorBox("Conflicting encoder settings", "Use either --crf or --video-bitrate, not both.");
process.exit(1);
}
if (useDocker && browserGpuArg === true) {
errorBox(
"Browser GPU is local-only",
"--browser-gpu uses the host Chrome GPU backend. Docker mode keeps browser rendering deterministic and does not expose a cross-platform Chrome GPU backend.",
"Run without --docker, or use --gpu for Docker GPU encoding where your Docker host supports GPU passthrough.",
);
process.exit(1);
}
let crf: number | undefined;
if (crfRaw != null) {
const parsed = Number(crfRaw);
if (!Number.isInteger(parsed) || parsed < 0) {
errorBox("Invalid crf", `Got "${crfRaw}". Must be a non-negative integer.`);
process.exit(1);
}
crf = parsed;
}
let vp9CpuUsed: number | undefined;
if (args["vp9-cpu-used"] != null) {
const raw = args["vp9-cpu-used"];
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < MIN_VP9_CPU_USED || parsed > MAX_VP9_CPU_USED) {
errorBox(
"Invalid vp9-cpu-used",
`Got "${raw}". Must be an integer between ${MIN_VP9_CPU_USED} and ${MAX_VP9_CPU_USED}.`,
);
process.exit(1);
}
vp9CpuUsed = parsed;
}
if (args["video-bitrate"] != null && !videoBitrate) {
errorBox(
"Invalid video-bitrate",
`Got "${args["video-bitrate"]}". Must be a non-empty bitrate such as "10M".`,
);
process.exit(1);
}
if (!quiet && gifFpsCapped) {
console.log(c.warn(" GIF output is capped at 30fps. Use --fps 15 for smaller files."));
}
// ── Validate browser-timeout (seconds) ───────────────────────────────
// This validator lives in `utils/renderArgs.ts` so the parse/reject
// branches are unit-testable without `process.exit`. See issue #1199
// for the original silent-timeout-0 footgun this guards.
const pageNavigationTimeoutMs = resolveBrowserTimeoutMsArg(args["browser-timeout"]);
// ── Preflight batch rows before browser/lint work ────────────────────
let batchModule: typeof import("./batchRender.js") | undefined;
let preparedBatch: import("./batchRender.js").PreparedBatchRender | undefined;
if (batchPath) {
batchModule = await import("./batchRender.js");
try {
preparedBatch = batchModule.prepareBatchRender({
batchPath,
outputTemplate: batchOutputTemplate,
indexPath: project.indexPath,
strictVariables: args["strict-variables"] ?? false,
quiet: quiet || batchJson,
json: batchJson,
});
} catch (error: unknown) {
batchModule.exitBatchRenderInputError(error);
}
}
// ── Slideshow guard ───────────────────────────────────────────────────
// A slideshow deck is several top-level scene compositions with no master
// root. `render` captures only the FIRST composition, so a deck renders as a
// silently truncated MP4 (e.g. slide 1 of a 40s deck). Warn and point at the
// deck-native path. Best-effort — never block a render on this probe.
if (!quiet) {
try {
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow");
if (slideshowIslandRegex("i").test(readFileSync(renderTarget, "utf8"))) {
console.log(
c.warn("⚠") +
" This composition carries a slideshow island — `render` captures only the first" +
" scene, so the MP4 will be truncated to slide 1. Use " +
c.accent("hyperframes present") +
" for the deck; a linear main-line MP4 export is not yet available.",
);
console.log("");
}
} catch {
/* best-effort — a missing/unreadable target surfaces later in the real flow */
}
}
// ── Print render plan ─────────────────────────────────────────────────
if (!quiet && !batchPath) {
const workerLabel =
workers != null ? `${workers} workers` : `auto workers (${CPU_CORE_COUNT} cores detected)`;
console.log("");
const nameLabel = entryFile ? project.name + "/" + entryFile : project.name;
console.log(
c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + outputPath),
);
console.log(
c.dim(" " + fpsToFfmpegArg(fps) + "fps \u00B7 " + quality + " \u00B7 " + workerLabel),
);
if (outputResolution) {
// Don't claim "supersampled" — when the composition is already at the
// target dimensions, the DPR resolves to 1 and no supersampling
// happens. We don't have the composition's dims at this point in the
// CLI, so describe the intent rather than the mechanism.
console.log(c.dim(" Output resolution: " + outputResolution));
}
if (useGpu || browserGpuMode !== "software") {
const gpuModes = [
useGpu ? "encoder GPU" : null,
browserGpuMode === "hardware"
? "browser GPU (forced)"
: browserGpuMode === "auto"
? "browser GPU (auto-detect)"
: null,
].filter(Boolean);
console.log(c.dim(" GPU: " + gpuModes.join(" + ")));
}
console.log("");
}
// ── Ensure browser for local renders ────────────────────────────────
// Always resolve to our own pinned/managed Chrome, never a
// separately-installed puppeteer-cache binary or system Chrome — render
// behavior (drawElement support included, HF#2060) shouldn't depend on
// whatever arbitrary Chrome version happens to be on the machine.
let browserPath: string | undefined;
if (!useDocker) {
const { ensureBrowser } = await import("../browser/manager.js");
let browserSpinner:
| {
start: (message?: string) => void;
message: (message: string) => void;
stop: (message?: string) => void;
}
| undefined;
try {
if (effectiveQuiet) {
const info = await ensureBrowser({ preferManagedChrome: true });
browserPath = info.executablePath;
} else {
const clack = await import("@clack/prompts");
browserSpinner = clack.spinner();
browserSpinner.start("Checking browser...");
const info = await ensureBrowser({
preferManagedChrome: true,
onProgress: (downloaded, total) => {
if (total <= 0) return;
const pct = Math.floor((downloaded / total) * 100);
browserSpinner?.message(
`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
);
},
});
browserPath = info.executablePath;
browserSpinner.stop(c.dim(`Browser: ${info.source}`));
}
} catch (err: unknown) {
browserSpinner?.stop(c.error("Browser not available"));
errorBox(
"Chrome not found",
normalizeErrorMessage(err),
"Run: npx hyperframes browser ensure",
);
process.exit(1);
}
}
// ── Pre-render lint ──────────────────────────────────────────────────
{
const lintResult = await lintProject(project.dir);
if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) {
console.log("");
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
if (
shouldBlockRender(
strictErrors,
strictAll,
lintResult.totalErrors,
lintResult.totalWarnings,
)
) {
const mode = strictAll ? "--strict-all" : "--strict";
console.log("");
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
console.log("");
process.exit(1);
}
console.log(c.dim(" Continuing render despite lint issues. Use --strict to block."));
console.log("");
}
}
// ── Pre-flight: output-resolution vs composition compatibility ────────
// Catch a preset whose orientation/aspect ratio (or alpha/HDR mode)
// conflicts with the composition BEFORE the browser and ffmpeg spin up —
// otherwise this surfaces cryptically deep inside the render compiler
// (resolveDeviceScaleFactor). Best-effort: a composition we can't read or
// whose dimensions aren't a known preset falls through to the pipeline's
// own defense-in-depth check rather than blocking a render we can't reason
// about. See render-reliability workstream P1-3.
if (outputResolution) {
let resolutionIssue: { message: string; kind: OutputResolutionIssueKind } | undefined;
try {
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
resolutionIssue = await checkRenderResolutionPreflight(
readFileSync(renderTarget, "utf8"),
outputResolution,
{
alphaRequested: format === "webm" || format === "mov" || format === "png-sequence",
hdrRequested: args.hdr ?? false,
},
);
} catch {
// Unreadable file is non-fatal here — the render pipeline will surface
// the real problem with full context.
}
if (resolutionIssue) {
// Count the pre-flight save so dashboard 1783183 can distinguish
// "caught early by pre-flight" from a deep render failure or a user who
// gave up — i.e. measure whether the P1-3 fix is doing its job.
trackRenderPreflightRejected({ kind: resolutionIssue.kind });
errorBox("Output resolution incompatible", resolutionIssue.message);
process.exit(1);
}
}
// ── Validate HDR/SDR mutual exclusion ────────────────────────────────
if (args.hdr && args.sdr) {
console.error("Error: --hdr and --sdr are mutually exclusive.");
process.exit(1);
}
// ── Batch render ──────────────────────────────────────────────────────
if (batchPath && batchModule && preparedBatch) {
const batchQuiet = quiet || batchJson;
const hdrMode: RenderOptions["hdrMode"] = args.sdr
? "force-sdr"
: args.hdr
? "force-hdr"
: "auto";
const renderOptionsBase: RenderOptions = {
fps,
quality,
authoringSkill,
format,
workers,
gpu: useGpu,
browserGpuMode,
hdrMode,
crf,
vp9CpuUsed,
videoBitrate,
quiet: batchQuiet,
browserPath,
entryFile,
outputResolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
debug,
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
};
const manifest = await batchModule.runBatchRender({
prepared: preparedBatch,
concurrency: batchConcurrency,
failFast: args["batch-fail-fast"] ?? false,
quiet: batchQuiet,
json: batchJson,
renderOne: (row) =>
useDocker
? renderDocker(project.dir, row.outputPath, {
...renderOptionsBase,
variables: row.variables,
pageSideCompositing: args["page-side-compositing"] !== false,
})
: renderLocal(project.dir, row.outputPath, {
...renderOptionsBase,
variables: row.variables,
}),
});
if (manifest.failed > 0) process.exitCode = 1;
return;
}
// ── Resolve --variables / --variables-file ──────────────────────────
const variables = resolveVariablesArg(args.variables, args["variables-file"]);
// ── Validate --variables against data-composition-variables ─────────
const strictVariables = args["strict-variables"] ?? false;
if (variables && Object.keys(variables).length > 0) {
const issues = validateVariablesAgainstProject(project.indexPath, variables);
reportVariableIssues(issues, { strict: strictVariables, quiet });
}
// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
fps,
quality,
authoringSkill,
format,
gifLoop,
workers,
gpu: useGpu,
browserGpuMode,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
crf,
vp9CpuUsed,
videoBitrate,
videoFrameFormat,
quiet,
debug,
variables,
entryFile,
outputResolution,
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
exitAfterComplete: true,
});
} else {
await renderLocal(project.dir, outputPath, {
fps,
quality,
authoringSkill,
format,
gifLoop,
workers,
gpu: useGpu,
browserGpuMode,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
crf,
vp9CpuUsed,
videoBitrate,
videoFrameFormat,
quiet,
browserPath,
debug,
variables,
entryFile,
outputResolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
exitAfterComplete: true,
});
}
},
});
export interface SingleRenderResult {
durationMs?: number;
renderTimeMs: number;
}
interface RenderOptions {
fps: Fps;
quality: "draft" | "standard" | "high";
/** Authoring workflow skill that drove this render (telemetry attribution). */
authoringSkill?: string;
format: RenderFormat;
gifLoop?: number;
workers?: number;
gpu: boolean;
/**
* Chrome WebGL backend mode. "auto" probes on first launch and falls back
* to "software" if no usable GPU. Defaults to "software" when omitted to
* stay backwards-compatible with callers that pre-date the tri-state.
*/
browserGpuMode?: "auto" | "hardware" | "software";
hdrMode: "auto" | "force-hdr" | "force-sdr";
crf?: number;
vp9CpuUsed?: number;
videoBitrate?: string;
videoFrameFormat?: VideoFrameFormat;
quiet: boolean;
debug?: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
entryFile?: string;
exitAfterComplete?: boolean;
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture (--experimental-fast-capture). */
experimentalFastCapture?: boolean;
/**
* Puppeteer `page.goto()` timeout for the entry HTML, in milliseconds.
* When omitted, the engine default (60s) applies. Surfaced as
* `--browser-timeout <seconds>` at the CLI and threaded through to the
* producer's EngineConfig override.
*/
pageNavigationTimeoutMs?: number;
/** CDP protocol timeout override (ms). */
protocolTimeout?: number;
/** Player-ready timeout override (ms). */
playerReadyTimeout?: number;
/** Throw render failures to the caller instead of printing and exiting. */
throwOnError?: boolean;
/** Skip the interactive feedback prompt after a successful render. */
skipFeedback?: boolean;
}
/**
* Resolve the browser-GPU mode for a CLI render invocation.
*
* Priority (highest first):
* 1. Docker mode → always "software" (docker has no portable GPU
* passthrough; the engine's render path uses SwiftShader).
* 2. Explicit CLI flag — `--browser-gpu` → "hardware",
* `--no-browser-gpu` → "software".
* 3. Env var `PRODUCER_BROWSER_GPU_MODE` accepts "hardware" / "software" /
* "auto".
* 4. Default = "auto" — engine probes WebGL availability on first launch
* and falls back to software if the host lacks a usable GPU.
*
* Returning "auto" by default lets local renders Just Work whether or not the
* host has a GPU, while preserving the explicit overrides for CI / power
* users who want failure-on-misconfig.
*/
export function resolveBrowserGpuForCli(
useDocker: boolean,
browserGpuArg: boolean | undefined,
envMode = process.env.PRODUCER_BROWSER_GPU_MODE,
): "auto" | "hardware" | "software" {
if (useDocker) return "software";
if (browserGpuArg === true) return "hardware";
if (browserGpuArg === false) return "software";
if (envMode === "hardware" || envMode === "software" || envMode === "auto") return envMode;
return "auto";
}
/**
* Read a composition's dimensions from the SAME source the producer's compiler
* uses — `data-width` / `data-height` on the `[data-composition-id]` root (see
* htmlCompiler.ts). Returns `undefined` when they can't be determined (no root,
* missing/invalid attrs, unparseable HTML). Note the producer *defaults* a
* missing attr to 1080; this pre-flight deliberately defers instead (returns
* `undefined`) rather than guess a dimension the author didn't declare, so it
* never false-aborts — the producer's defense-in-depth still catches that case.
*
* Deriving dims any other way (e.g. `data-resolution` or a `#stage` heuristic)
* risks disagreeing with the actual render: most compositions (all registry
* blocks) carry `data-width/height` and no `data-resolution`, so a parallel
* heuristic could false-abort a valid render. `DOMParser` isn't shipped by
* Node — the CLI polyfills it via linkedom, imported lazily so the heavy DOM
* library stays out of `render.js`'s module-load graph (it cold-imports at
* >5 s already; a static linkedom import tips the render test suite's import
* hook over its timeout — see the note on `renderLocal browser GPU config`).
*/
async function readCompositionDimensions(
compositionHtml: string,
): Promise<{ width: number; height: number } | undefined> {
try {
const { ensureDOMParser } = await import("../utils/dom.js");
ensureDOMParser();
const doc = new DOMParser().parseFromString(compositionHtml, "text/html");
const rootEl = doc.querySelector("[data-composition-id]");
const width = parseInt(rootEl?.getAttribute("data-width") ?? "", 10);
const height = parseInt(rootEl?.getAttribute("data-height") ?? "", 10);
if (width > 0 && height > 0) return { width, height };
} catch {
// Unreadable / unparseable composition — fall through to `undefined`.
}
return undefined;
}
/**
* Render pre-flight: return an actionable message when the chosen
* `outputResolution` preset is incompatible with the composition's
* orientation/aspect ratio, or with the alpha/HDR mode — or `undefined` when
* the combination is fine (or can't be determined statically).
*
* Extracted (and exported) so the CLI wiring around `process.exit` stays a
* thin adapter and the branch logic is unit-testable. See render-reliability
* workstream P1-3.
*/
export async function checkRenderResolutionPreflight(
compositionHtml: string,
outputResolution: CanvasResolution | undefined,
modes: { alphaRequested: boolean; hdrRequested: boolean },
): Promise<{ message: string; kind: OutputResolutionIssueKind } | undefined> {
if (!outputResolution) return undefined;
const dims = await readCompositionDimensions(compositionHtml);
// Couldn't determine the composition's actual dimensions — defer to the
// pipeline's own defense-in-depth check rather than guess.
if (!dims) return undefined;
const compat = checkOutputResolutionCompatibility({
compositionWidth: dims.width,
compositionHeight: dims.height,
outputResolution,
alphaRequested: modes.alphaRequested,
hdrRequested: modes.hdrRequested,
});
// Narrow to the incompatible case; `message`/`kind` are always set there.
if (compat.ok || !compat.message || !compat.kind) return undefined;
return { message: compat.message, kind: compat.kind };
}
const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
function dockerImageTag(version: string): string {
return `${DOCKER_IMAGE_PREFIX}:${version}`;
}
function resolveDockerfilePath(): string {
// Built CLI: dist/docker/Dockerfile.render
const builtPath = resolve(__dirname, "docker", "Dockerfile.render");
// Dev mode: src/docker/Dockerfile.render
const devPath = resolve(__dirname, "..", "src", "docker", "Dockerfile.render");
for (const p of [builtPath, devPath]) {
try {
statSync(p);
return p;
} catch {
continue;
}
}
throw new Error("Dockerfile.render not found — CLI package may be corrupted");
}
function dockerImageExists(tag: string): boolean {
try {
execFileSync("docker", ["image", "inspect", tag], { stdio: "pipe", timeout: 10_000 });
return true;
} catch {
return false;
}
}
function dockerImageTagForPlatform(version: string, platform: string): string {
// Suffix the tag with the arch so amd64 and arm64 images of the same
// hyperframes version coexist in the local cache (a developer who flips
// between hosts shouldn't have to rebuild).
const archSuffix = platform === "linux/arm64" ? "-arm64" : "";
return `${dockerImageTag(version)}${archSuffix}`;
}
function ensureDockerImage(version: string, platform: string, quiet: boolean): string {
const tag = dockerImageTagForPlatform(version, platform);
if (dockerImageExists(tag)) {
if (!quiet) console.log(c.dim(` Docker image: ${tag} (cached)`));
return tag;
}
if (!quiet) console.log(c.dim(` Building Docker image: ${tag} (${platform})...`));
const dockerfilePath = resolveDockerfilePath();
// Copy Dockerfile to a temp build context so docker build has a clean context
const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));
// Platform is now derived from the host arch (see resolveDockerPlatform).
// Apple Silicon and other arm64 hosts get a native linux/arm64 build; the
// Dockerfile installs a pinned arm64 chrome-headless-shell from Playwright
// (chrome-for-testing publishes no linux-arm64 build).
//
// TARGETARCH is passed explicitly rather than relying on BuildKit's
// automatic platform args because the legacy builder (and some BuildKit
// configurations like colima 0.6.x) leaves it unset, which would defeat
// the arch conditional in the Dockerfile.
const targetArch = platform === "linux/arm64" ? "arm64" : "amd64";
try {
execFileSync(
"docker",
[
"build",
"--platform",
platform,
"--build-arg",
`HYPERFRAMES_VERSION=${version}`,
"--build-arg",
`TARGETARCH=${targetArch}`,
"-t",
tag,
tmpDir,
],
{ stdio: quiet ? "pipe" : "inherit", timeout: 600_000 },
);
} catch (error: unknown) {
const message = normalizeErrorMessage(error);
throw new Error(`Failed to build Docker image: ${message}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
if (!quiet) console.log(c.dim(` Docker image: ${tag} (built)`));
return tag;
}
/**
* Resolves the Docker `--platform` for this host and enforces the constraints
* that come with it — keeping that policy out of `renderDocker` so the
* orchestrator stays focused on build/run wiring. May terminate the process
* via errorBox on unrecoverable mismatches (e.g. --gpu on arm64).
*/
function resolveDockerHostPlatform(options: RenderOptions): string {
const platform = resolveDockerPlatform();
// Docker Desktop on Apple Silicon (and colima with VZ) doesn't implement
// the `--gpus` host-passthrough flag, so requesting `--gpu` on a linux/arm64
// container fails at `docker run` with an opaque device-driver error. Catch
// it early with actionable guidance.
if (options.gpu && platform === "linux/arm64") {
errorBox(
"--gpu is not supported with --docker on arm64 hosts",
"Docker Desktop/colima on Apple Silicon doesn't expose --gpus host passthrough to linux/arm64 containers.",
"Drop --gpu, or run a native (non-Docker) render on this host, or set HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 if you need GPU encoding (slow under qemu but works).",
);
process.exit(1);
}
if (!options.quiet && platform === "linux/arm64") {
// The arm64 image uses Playwright's pinned linux-arm64 chrome-headless-shell
// (chrome-for-testing has no arm64 build). It's a different Chromium build
// than amd64's chrome-for-testing binary, so output isn't byte-identical to
// an amd64 golden baseline — fine for end-user output. Set
// HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 to force parity (qemu-emulated,
// slower).
console.log(
c.dim(
" Host is arm64 — using linux/arm64 image with Playwright's " +
"chrome-headless-shell (output won't be byte-identical to amd64 " +
"renders; set HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 to force parity).",
),
);
}
return platform;
}
// Inherited minor finding (CRAP 37.1, cyclomatic 11). This PR only added
// `pageNavigationTimeoutMs` to the options forwarded to `buildDockerRunArgs`.
// fallow-ignore-next-line complexity
async function renderDocker(
projectDir: string,
outputPath: string,
options: RenderOptions,
): Promise<SingleRenderResult> {
const startTime = Date.now();
// Dev mode (tsx/ts-node) uses "latest" since the local version isn't on npm
const dockerVersion = isDevMode() ? "latest" : VERSION;
if (!options.quiet && isDevMode()) {
console.log(c.dim(" Dev mode: using hyperframes@latest in Docker image"));
}
const platform = resolveDockerHostPlatform(options);
let imageTag: string;
try {
imageTag = ensureDockerImage(dockerVersion, platform, options.quiet);
} catch (error: unknown) {
const message = normalizeErrorMessage(error);
const isDockerMissing = /connect|not found|ENOENT/i.test(message);
errorBox(
isDockerMissing ? "Docker not available" : "Docker image build failed",
message,
isDockerMissing
? "Install Docker: https://docs.docker.com/get-docker/"
: "Check Docker is running: docker info",
);
process.exit(1);
}
const outputDir = dirname(outputPath);
const outputFilename = basename(outputPath);
const dockerArgs = buildDockerRunArgs({
imageTag,
projectDir: resolve(projectDir),
outputDir: resolve(outputDir),
outputFilename,
platform,
options: {
fps: options.fps,
quality: options.quality,
format: options.format,
gifLoop: options.gifLoop,
workers: options.workers,
gpu: options.gpu,
browserGpu: options.browserGpuMode === "hardware",
hdrMode: options.hdrMode,
crf: options.crf,
vp9CpuUsed: options.vp9CpuUsed,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
debug: options.debug,
experimentalFastCapture: options.experimentalFastCapture,
pageNavigationTimeoutMs: options.pageNavigationTimeoutMs,
},
});
if (!options.quiet) {
console.log(c.dim(" Running render in Docker container..."));
console.log("");
}
try {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn("docker", dockerArgs, {
// When quiet, still show stderr so container errors surface
stdio: options.quiet ? ["pipe", "pipe", "inherit"] : "inherit",
});
child.on("close", (code) => {
if (code === 0) resolvePromise();
else reject(new Error(`Docker render exited with code ${code}`));
});
child.on("error", (err) => reject(err));
});
} catch (error: unknown) {
handleRenderError(error, options, startTime, true, "Check Docker is running: docker info");
}
const elapsed = Date.now() - startTime;
// Track metrics (no job object available from Docker — use a minimal stub)
trackRenderComplete({
durationMs: elapsed,
fps: fpsToNumber(options.fps),
quality: options.quality,
workers: options.workers,
docker: true,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
...getMemorySnapshot(),
});
// ponytail: Docker runs the producer in a child process, so no perfSummary is
// threaded back here; the summary shows render time only (never a wrong video
// length). Probe the output with ffprobe if a duration figure is wanted here.
printRenderComplete(outputPath, elapsed, options.quiet);
warnIfWebmAlphaDropped(outputPath, options.format, options.quiet);
if (options.exitAfterComplete) scheduleRenderProcessExit();
return { renderTimeMs: elapsed };
}
// fallow-ignore-next-line complexity
export async function renderLocal(
projectDir: string,
outputPath: string,
options: RenderOptions,
): Promise<SingleRenderResult> {
const preflight = await runEnvironmentChecks({
projectDir,
browserPath: options.browserPath,
includeBrowser: true,
includeDisk: true,
includeWindowsUnc: true,
});
const failedChecks = preflight.outcomes.filter((outcome) => !outcome.ok);
if (failedChecks.length > 0) {
for (const check of failedChecks) {
errorBox(check.title ?? `${check.name} check failed`, check.detail, check.hint);
}
process.exit(1);
}
if (!options.quiet) {
for (const outcome of preflight.outcomes) {
if (outcome.level === "warn") {
console.warn(c.warn(` ${outcome.name}: ${outcome.detail}`));
if (outcome.hint) console.warn(c.dim(` ${outcome.hint}`));
}
}
}
if (preflight.ffmpegPath) process.env.HYPERFRAMES_FFMPEG_PATH = preflight.ffmpegPath;
if (preflight.ffprobePath) process.env.HYPERFRAMES_FFPROBE_PATH = preflight.ffprobePath;
if (preflight.browser?.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
process.env.PRODUCER_HEADLESS_SHELL_PATH = preflight.browser.executablePath;
}
const producer = await loadProducer();
const startTime = Date.now();
const logger = createRenderTelemetryLogger(
producer.createConsoleLogger?.(options.debug ? "debug" : "info") ?? createNoopProducerLogger(),
);
const job = producer.createRenderJob({
fps: options.fps,
quality: options.quality,
format: options.format,
gifLoop: options.gifLoop,
workers: options.workers,
useGpu: options.gpu,
logger,
producerConfig: producer.resolveConfig({
browserGpuMode: options.browserGpuMode ?? "software",
...(options.pageNavigationTimeoutMs != null
? { pageNavigationTimeout: options.pageNavigationTimeoutMs }
: {}),
...(options.protocolTimeout != null && { protocolTimeout: options.protocolTimeout }),
...(options.playerReadyTimeout != null && { playerReadyTimeout: options.playerReadyTimeout }),
...(options.vp9CpuUsed != null ? { vp9CpuUsed: options.vp9CpuUsed } : {}),
}),
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
videoFrameFormat: options.videoFrameFormat,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
debug: options.debug,
});
const onProgress = options.quiet
? undefined
: (progressJob: { progress: number }, message: string) => {
renderProgress(progressJob.progress, message);
};
try {
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
} catch (error: unknown) {
handleRenderError(
error,
options,
startTime,
false,
"Try --docker for containerized rendering",
job.failedStage,
job,
);
}
const elapsed = Date.now() - startTime;
trackRenderMetrics(job, elapsed, options, false);
printRenderComplete(
outputPath,
elapsed,
options.quiet,
job.perfSummary?.compositionDurationSeconds,
job.perfSummary?.totalFrames,
);
warnIfWebmAlphaDropped(outputPath, options.format, options.quiet);
if (!options.skipFeedback) {
await maybePromptRenderFeedback({
renderDurationMs: elapsed,
quiet: options.quiet,
});
}
if (options.exitAfterComplete) scheduleRenderProcessExit();
const durationMs = job.perfSummary
? Math.round(job.perfSummary.compositionDurationSeconds * 1000)
: undefined;
return { renderTimeMs: elapsed, durationMs };
}
type UnrefableTimer = {
unref: () => void;
};
function isUnrefableTimer(
timer: ReturnType<typeof setTimeout>,
): timer is ReturnType<typeof setTimeout> & UnrefableTimer {
return (
typeof timer === "object" &&
timer !== null &&
"unref" in timer &&
typeof timer.unref === "function"
);
}
function scheduleRenderProcessExit(): void {
const timer = setTimeout(() => process.exit(0), 100);
if (isUnrefableTimer(timer)) timer.unref();
}
function getMemorySnapshot() {
return {
peakMemoryMb: bytesToMb(process.memoryUsage.rss()),
memoryFreeMb: bytesToMb(freemem()),
};
}
function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
const value = meta?.[key];
return typeof value === "string" ? value : undefined;
}
function metaNumber(meta: Record<string, unknown> | undefined, key: string): number | undefined {
const value = meta?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function metaBoolean(meta: Record<string, unknown> | undefined, key: string): boolean | undefined {
const value = meta?.[key];
return typeof value === "boolean" ? value : undefined;
}
function trackRenderTraceFromLog(message: string, meta: Record<string, unknown> | undefined): void {
if (message !== "[Render:trace]") return;
const status = metaString(meta, "status");
if (status !== "checkpoint" && status !== "error") return;
trackRenderObservation({
source: "cli",
renderJobId: metaString(meta, "renderJobId"),
phase: metaString(meta, "phase"),
status,
compositionHash: metaString(meta, "compositionHash"),
elapsedMs: metaNumber(meta, "elapsedMs"),
durationMs: metaNumber(meta, "durationMs"),
message: metaString(meta, "message"),
workerCount: metaNumber(meta, "workerCount"),
forceScreenshot: metaBoolean(meta, "forceScreenshot"),
useStreamingEncode: metaBoolean(meta, "useStreamingEncode"),
useLayeredComposite: metaBoolean(meta, "useLayeredComposite"),
usePageSideCompositing: metaBoolean(meta, "usePageSideCompositing"),
hasHdrContent: metaBoolean(meta, "hasHdrContent"),
captureMode: metaString(meta, "captureMode"),
videoCount: metaNumber(meta, "videoCount"),
extractedVideoCount: metaNumber(meta, "extractedVideoCount"),
totalFramesExtracted: metaNumber(meta, "totalFramesExtracted"),
maxFramesPerVideo: metaNumber(meta, "maxFramesPerVideo"),
avgFramesPerExtractedVideo: metaNumber(meta, "avgFramesPerExtractedVideo"),
vfrPreflightCount: metaNumber(meta, "vfrPreflightCount"),
vfrPreflightMs: metaNumber(meta, "vfrPreflightMs"),
cacheHits: metaNumber(meta, "cacheHits"),
cacheMisses: metaNumber(meta, "cacheMisses"),
});
}
function createRenderTelemetryLogger(base: ProducerLogger): ProducerLogger {
return {
error(message, meta) {
base.error(message, meta);
trackRenderTraceFromLog(message, meta);
},
warn(message, meta) {
base.warn(message, meta);
trackRenderTraceFromLog(message, meta);
},
info(message, meta) {
base.info(message, meta);
trackRenderTraceFromLog(message, meta);
},
debug(message, meta) {
base.debug(message, meta);
trackRenderTraceFromLog(message, meta);
},
isLevelEnabled(level) {
return base.isLevelEnabled?.(level) ?? true;
},
};
}
function createNoopProducerLogger(): ProducerLogger {
return {
error() {},
warn() {},
info() {},
debug() {},
isLevelEnabled() {
return true;
},
};
}
function handleRenderError(
error: unknown,
options: RenderOptions,
startTime: number,
docker: boolean,
hint: string,
failedStage?: string,
job?: RenderJob,
): never {
const message = normalizeErrorMessage(error);
trackRenderError({
fps: fpsToNumber(options.fps),
quality: options.quality,
docker,
workers: options.workers,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
elapsedMs: Date.now() - startTime,
errorMessage: message,
failedStage,
...renderJobObservabilityTelemetryPayload(job),
...getMemorySnapshot(),
});
if (options.throwOnError) {
throw new Error(message);
}
// A `Failed to launch the browser process` / `libnss3.so cannot open ...`
// failure on Linux/WSL is an environment problem, not a composition bug.
// Replace the generic "Try --docker" hint with the exact per-distro
// remediation and a pointer at `doctor`.
const remediation = chromeLaunchRemediation(message);
if (remediation) {
errorBox("Render failed — Chrome could not launch", message, remediation);
process.exit(1);
}
errorBox("Render failed", message, hint);
process.exit(1);
}
/**
* Extract rich metrics from the completed render job and send to telemetry.
* speed_ratio = composition_duration / render_time — higher is better, >1 means faster than realtime.
*/
// Inherited CRITICAL (CRAP 148.4, cyclomatic 24): exhaustive nullish-fallback
// chain across 30+ telemetry fields. Not touched by this PR.
// fallow-ignore-next-line complexity
function trackRenderMetrics(
job: RenderJob,
elapsedMs: number,
options: RenderOptions,
docker: boolean,
): void {
const perf = job.perfSummary;
const compositionDurationMs = perf
? Math.round(perf.compositionDurationSeconds * 1000)
: undefined;
const speedRatio =
compositionDurationMs && compositionDurationMs > 0 && elapsedMs > 0
? Math.round((compositionDurationMs / elapsedMs) * 100) / 100
: undefined;
const stages = perf?.stages ?? {};
const extract = perf?.videoExtractBreakdown;
trackRenderComplete({
durationMs: elapsedMs,
fps: fpsToNumber(options.fps),
quality: options.quality,
workers: options.workers ?? perf?.workers,
docker,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
staticDedupEnabled: perf?.staticDedup?.enabled,
staticDedupArmed: perf?.staticDedup?.armed,
staticDedupSkipReason: perf?.staticDedup?.skipReason,
staticDedupPredictedFrames: perf?.staticDedup?.predictedFrames,
staticDedupReusedFrames: perf?.staticDedup?.reusedFrames,
beginFrameNoDamageFrames: perf?.beginFrameReuse?.noDamageFrames,
beginFrameHasDamageFrames: perf?.beginFrameReuse?.hasDamageFrames,
deCaptureMode: perf?.drawElement?.mode,
deCompileGate: perf?.drawElement?.compileGate,
deClampReason: perf?.drawElement?.clampReason,
deWorkerInversion: perf?.drawElement?.workerInversion,
dePreInversionWorkers: perf?.drawElement?.preInversionWorkers,
deParallelRouter: perf?.drawElement?.parallelRouter,
dePreRouterWorkers: perf?.drawElement?.preRouterWorkers,
deGateReason: perf?.drawElement?.gateReason,
deWorkerEncode: perf?.drawElement?.workerEncode,
deVerifyArmed: perf?.drawElement?.verifyArmed,
deVerifyChecked: perf?.drawElement?.verifyChecked,
deVerifyMinDb: perf?.drawElement?.verifyMinDb,
deVerifyInitMs: perf?.drawElement?.verifyInitMs,
deSelfVerifyFallback: perf?.drawElement?.selfVerifyFallback,
deFallbackReason: perf?.drawElement?.fallbackReason,
deBlankSuspects: perf?.drawElement?.blankSuspects,
deBlankDeterministicAccepts: perf?.drawElement?.blankDeterministicAccepts,
deBlankRecaptures: perf?.drawElement?.blankRecaptures,
deBoundaryFrames: perf?.drawElement?.boundaryFrames,
deNcprFallbacks: perf?.drawElement?.ncprFallbacks,
compositionDurationMs,
compositionWidth: perf?.resolution.width,
compositionHeight: perf?.resolution.height,
totalFrames: perf?.totalFrames,
speedRatio,
captureAvgMs: perf?.captureAvgMs,
captureP50Ms: perf?.captureP50Ms,
subTimelineWait: perf?.subTimelineWait,
videoCount: perf?.videoCount,
capturePeakMs: perf?.capturePeakMs,
tmpPeakBytes: perf?.tmpPeakBytes,
stageCompileMs: stages.compileMs,
stageVideoExtractMs: stages.videoExtractMs,
stageAudioProcessMs: stages.audioProcessMs,
stageCaptureMs: stages.captureMs,
stageCaptureSetupMs: stages.captureSetupMs,
stageCaptureFrameMs: stages.captureFrameMs,
stageEncodeMs: stages.encodeMs,
stageAssembleMs: stages.assembleMs,
extractResolveMs: extract?.resolveMs,
extractHdrProbeMs: extract?.hdrProbeMs,
extractHdrPreflightMs: extract?.hdrPreflightMs,
extractHdrPreflightCount: extract?.hdrPreflightCount,
extractVfrProbeMs: extract?.vfrProbeMs,
extractVfrPreflightMs: extract?.vfrPreflightMs,
extractVfrPreflightCount: extract?.vfrPreflightCount,
extractPhase3Ms: extract?.extractMs,
extractCacheHits: extract?.cacheHits,
extractCacheMisses: extract?.cacheMisses,
...renderJobObservabilityTelemetryPayload(job),
...getMemorySnapshot(),
});
}
function printRenderComplete(
outputPath: string,
elapsedMs: number,
quiet: boolean,
outputDurationSeconds?: number,
frameCount?: number,
): void {
if (quiet) return;
let fileSize = "unknown";
let isDirectory = false;
try {
const stat = statSync(outputPath);
isDirectory = stat.isDirectory();
if (stat.isDirectory()) {
// png-sequence output is a directory; sum the contained file sizes so
// the user sees the on-disk footprint of the deliverable rather than
// the platform-specific size of the directory inode itself.
let total = 0;
for (const entry of readdirSync(outputPath, { withFileTypes: true })) {
if (!entry.isFile()) continue;
try {
total += statSync(join(outputPath, entry.name)).size;
} catch {
// skip unreadable entries
}
}
fileSize = formatBytes(total);
} else {
fileSize = formatBytes(stat.size);
}
} catch {
// file doesn't exist or is inaccessible
}
const detail = formatRenderSummaryDetail({
elapsedMs,
outputDurationSeconds,
isDirectory,
frameCount,
});
console.log("");
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail));
}