mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
1452 lines
54 KiB
TypeScript
1452 lines
54 KiB
TypeScript
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,
|
||
} 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, formatDuration, errorBox } from "../ui/format.js";
|
||
import { renderProgress } from "../ui/progress.js";
|
||
import {
|
||
trackRenderComplete,
|
||
trackRenderError,
|
||
trackRenderObservation,
|
||
} from "../telemetry/events.js";
|
||
import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
|
||
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.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 type { ProducerLogger, RenderJob } from "@hyperframes/producer";
|
||
import {
|
||
normalizeResolutionFlag,
|
||
parseFps,
|
||
fpsToNumber,
|
||
fpsToFfmpegArg,
|
||
type CanvasResolution,
|
||
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 1–240.`;
|
||
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.",
|
||
default: "30",
|
||
},
|
||
quality: {
|
||
type: "string",
|
||
alias: "q",
|
||
description: "Quality: draft, standard, high",
|
||
default: "standard",
|
||
},
|
||
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.",
|
||
},
|
||
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.",
|
||
},
|
||
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,
|
||
},
|
||
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.",
|
||
},
|
||
},
|
||
// `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);
|
||
|
||
// ── 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`.
|
||
const fpsParse = parseFps(args.fps ?? "30");
|
||
if (!fpsParse.ok) {
|
||
errorBox("Invalid fps", formatFpsParseError(args.fps ?? "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";
|
||
|
||
// ── 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);
|
||
|
||
// ── 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";
|
||
}
|
||
|
||
// ── 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 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;
|
||
}
|
||
|
||
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) and composition entry file ────
|
||
// Both validators live in `utils/renderArgs.ts` so the parse/reject
|
||
// branches are unit-testable without `process.exit`. See issue #1199
|
||
// for the original EISDIR / silent-timeout-0 footguns this guards.
|
||
const pageNavigationTimeoutMs = resolveBrowserTimeoutMsArg(args["browser-timeout"]);
|
||
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
|
||
|
||
// ── 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);
|
||
}
|
||
}
|
||
|
||
// ── 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 ────────────────────────────────
|
||
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();
|
||
browserPath = info.executablePath;
|
||
} else {
|
||
const clack = await import("@clack/prompts");
|
||
browserSpinner = clack.spinner();
|
||
browserSpinner.start("Checking browser...");
|
||
const info = await ensureBrowser({
|
||
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",
|
||
err instanceof Error ? err.message : String(err),
|
||
"Run: npx hyperframes browser ensure",
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// ── Pre-render lint ──────────────────────────────────────────────────
|
||
{
|
||
const lintResult = await lintProject(project);
|
||
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("");
|
||
}
|
||
}
|
||
|
||
// ── 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,
|
||
format,
|
||
workers,
|
||
gpu: useGpu,
|
||
browserGpuMode,
|
||
hdrMode,
|
||
crf,
|
||
videoBitrate,
|
||
quiet: batchQuiet,
|
||
browserPath,
|
||
entryFile,
|
||
outputResolution,
|
||
pageNavigationTimeoutMs,
|
||
protocolTimeout,
|
||
playerReadyTimeout,
|
||
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,
|
||
format,
|
||
gifLoop,
|
||
workers,
|
||
gpu: useGpu,
|
||
browserGpuMode,
|
||
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
|
||
crf,
|
||
videoBitrate,
|
||
quiet,
|
||
variables,
|
||
entryFile,
|
||
outputResolution,
|
||
pageSideCompositing: args["page-side-compositing"] !== false,
|
||
pageNavigationTimeoutMs,
|
||
protocolTimeout,
|
||
playerReadyTimeout,
|
||
exitAfterComplete: true,
|
||
});
|
||
} else {
|
||
await renderLocal(project.dir, outputPath, {
|
||
fps,
|
||
quality,
|
||
format,
|
||
gifLoop,
|
||
workers,
|
||
gpu: useGpu,
|
||
browserGpuMode,
|
||
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
|
||
crf,
|
||
videoBitrate,
|
||
quiet,
|
||
browserPath,
|
||
variables,
|
||
entryFile,
|
||
outputResolution,
|
||
pageNavigationTimeoutMs,
|
||
protocolTimeout,
|
||
playerReadyTimeout,
|
||
exitAfterComplete: true,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
export interface SingleRenderResult {
|
||
durationMs?: number;
|
||
renderTimeMs: number;
|
||
}
|
||
|
||
interface RenderOptions {
|
||
fps: Fps;
|
||
quality: "draft" | "standard" | "high";
|
||
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;
|
||
videoBitrate?: string;
|
||
quiet: boolean;
|
||
browserPath?: string;
|
||
variables?: Record<string, unknown>;
|
||
entryFile?: string;
|
||
exitAfterComplete?: boolean;
|
||
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
|
||
outputResolution?: CanvasResolution;
|
||
pageSideCompositing?: 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";
|
||
}
|
||
|
||
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 skips chrome-headless-shell on arm64 and falls back to system
|
||
// chromium because chrome-headless-shell ships linux64 only.
|
||
//
|
||
// 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 = error instanceof Error ? error.message : String(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") {
|
||
// chrome-headless-shell doesn't publish a linux-arm64 build, so the arm64
|
||
// image falls back to system chromium. That loses byte-for-byte parity
|
||
// with amd64 renders — fine for end-user output, not fine if you're
|
||
// comparing against an amd64 golden baseline. Set
|
||
// HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 to keep parity (qemu-emulated,
|
||
// slower).
|
||
console.log(
|
||
c.dim(
|
||
" Host is arm64 — using linux/arm64 image with system chromium " +
|
||
"(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 = error instanceof Error ? error.message : String(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,
|
||
videoBitrate: options.videoBitrate,
|
||
quiet: options.quiet,
|
||
variables: options.variables,
|
||
entryFile: options.entryFile,
|
||
outputResolution: options.outputResolution,
|
||
pageSideCompositing: options.pageSideCompositing,
|
||
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,
|
||
...getMemorySnapshot(),
|
||
});
|
||
|
||
printRenderComplete(outputPath, elapsed, 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?.("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 }),
|
||
}),
|
||
hdrMode: options.hdrMode,
|
||
crf: options.crf,
|
||
videoBitrate: options.videoBitrate,
|
||
variables: options.variables,
|
||
entryFile: options.entryFile,
|
||
outputResolution: options.outputResolution,
|
||
});
|
||
|
||
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);
|
||
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,
|
||
elapsedMs: Date.now() - startTime,
|
||
errorMessage: message,
|
||
failedStage,
|
||
...renderJobObservabilityTelemetryPayload(job),
|
||
...getMemorySnapshot(),
|
||
});
|
||
if (options.throwOnError) {
|
||
throw new Error(message);
|
||
}
|
||
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,
|
||
compositionDurationMs,
|
||
compositionWidth: perf?.resolution.width,
|
||
compositionHeight: perf?.resolution.height,
|
||
totalFrames: perf?.totalFrames,
|
||
speedRatio,
|
||
captureAvgMs: perf?.captureAvgMs,
|
||
capturePeakMs: perf?.capturePeakMs,
|
||
tmpPeakBytes: perf?.tmpPeakBytes,
|
||
stageCompileMs: stages.compileMs,
|
||
stageVideoExtractMs: stages.videoExtractMs,
|
||
stageAudioProcessMs: stages.audioProcessMs,
|
||
stageCaptureMs: stages.captureMs,
|
||
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): void {
|
||
if (quiet) return;
|
||
|
||
let fileSize = "unknown";
|
||
try {
|
||
const stat = statSync(outputPath);
|
||
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 duration = formatDuration(elapsedMs);
|
||
console.log("");
|
||
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
|
||
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + duration + " \u00B7 completed"));
|
||
}
|