refactor(cli): decompose render phases

This commit is contained in:
James
2026-07-20 10:34:06 -07:00
parent 954eeec2d0
commit 3222f1f31d
7 changed files with 959 additions and 876 deletions
+19 -815
View File
@@ -1,19 +1,11 @@
import { failCommand, setCommandExitCode, requestCliExit } from "../utils/commandResult.js";
import { failCommand, requestCliExit } from "../utils/commandResult.js";
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,
hasExplicitCompositionArg,
resolveBrowserTimeoutMsArg,
resolveCompositionEntryArg,
resolveDefaultFpsArg,
} from "../utils/renderArgs.js";
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
import { presentRenderPlan } from "./render/present.js";
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
export { resolveBrowserGpuForCli, renderLintContinuationHint };
export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
@@ -54,12 +46,9 @@ export const examples: Example[] = [
'hyperframes render --batch rows.json --output "renders/{name}.mp4"',
],
];
import { cpus, freemem, tmpdir } from "node:os";
import { 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";
@@ -69,19 +58,16 @@ import {
trackRenderComplete,
trackRenderError,
trackRenderObservation,
trackRenderPreflightRejected,
} from "../telemetry/events.js";
import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
import { readConfigFresh, writeConfig, type HyperframesConfig } from "../telemetry/config.js";
import { shouldTrack } from "../telemetry/client.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 { formatRenderOutputTimestamp } from "@hyperframes/core";
import { runEnvironmentChecks } from "../browser/preflight.js";
import { detectH264EncoderMode } from "../browser/ffmpeg.js";
import { chromeLaunchRemediation } from "../browser/linuxDeps.js";
@@ -93,76 +79,16 @@ import {
runPostRenderStepAsync,
} from "../utils/render-success-state.js";
import type { ProducerLogger, RenderJob } from "@hyperframes/producer";
import { EXTRACT_CACHE_DIR_DISABLED_ALIASES, type VideoFrameFormat } from "@hyperframes/engine";
import {
EXTRACT_CACHE_DIR_DISABLED_ALIASES,
MAX_VP9_CPU_USED,
MIN_VP9_CPU_USED,
isVideoFrameFormat,
type VideoFrameFormat,
} from "@hyperframes/engine";
import {
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
checkOutputResolutionCompatibility,
suggestMatchingPreset,
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",
@@ -337,7 +263,7 @@ export default defineCommand({
},
json: {
type: "boolean",
description: "With --batch, emit JSON progress events.",
description: "With --batch, emit exactly one final JSON result document.",
default: false,
},
resolution: {
@@ -416,657 +342,15 @@ export default defineCommand({
"Env: HYPERFRAMES_EXTRACT_CACHE_DIR.",
},
},
// `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
// Keep the transport adapter thin: each phase has one ownership boundary.
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
const hasExplicitComposition = hasExplicitCompositionArg(args.composition);
const project = resolveProject(args.dir, { requireIndex: !hasExplicitComposition });
// ── 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);
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
// ── 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));
failCommand();
}
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.`);
failCommand();
}
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}.`);
failCommand();
}
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);
failCommand();
}
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.`,
);
failCommand();
}
const videoFrameFormat = videoFrameFormatRaw;
// ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined;
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) name
// a resolution *tier* without pinning an orientation. Historically they
// all normalize to a `landscape` preset, which rejects portrait/square
// compositions at `resolveDeviceScaleFactor` time. Track the raw-input
// shape so the compile stage can re-map the preset to the composition's
// orientation (see `outputResolutionAspectAgnostic` on RenderConfig).
// Explicit orientation-bearing aliases (`1080p-portrait`, `4k-square`, …)
// and canonical presets (`landscape`, `portrait`, …) stay strict.
let outputResolutionAspectAgnostic = false;
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).`,
);
failCommand();
}
outputResolutionAspectAgnostic = isAspectAgnosticResolutionAlias(args.resolution);
// 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.",
);
failCommand();
}
}
// ── 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".`);
failCommand();
}
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).`,
);
failCommand();
}
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).`,
);
failCommand();
}
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";
}
// ── Override: extracted-frame cache directory ────────────────────────
// Sugar for HYPERFRAMES_EXTRACT_CACHE_DIR. Set BEFORE resolveConfig() so
// the env resolver picks up the CLI-supplied value. Disabling aliases
// (off/none/false/0) pass through verbatim — the engine helper canonicalizes.
// Positive paths are resolved to absolute so CWD changes downstream can't
// stale them.
if (typeof args["frames-cache-dir"] === "string" && args["frames-cache-dir"].trim() !== "") {
const raw = args["frames-cache-dir"].trim();
const normalized = raw.toLowerCase();
const isDisableAlias = EXTRACT_CACHE_DIR_DISABLED_ALIASES.includes(normalized);
process.env.HYPERFRAMES_EXTRACT_CACHE_DIR = isDisableAlias ? raw : resolve(raw);
}
// ── 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.`,
);
failCommand();
}
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.",
);
failCommand();
}
if (!batchPath && args["batch-concurrency"] != null) {
errorBox("Invalid batch-concurrency", "--batch-concurrency requires --batch.");
failCommand();
}
if (!batchPath && args["batch-fail-fast"]) {
errorBox("Invalid batch-fail-fast", "--batch-fail-fast requires --batch.");
failCommand();
}
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.`,
);
failCommand();
}
batchConcurrency = parsed;
}
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const ext = FORMAT_EXT[format] ?? ".mp4";
const now = new Date();
const timestamp = formatRenderOutputTimestamp(now);
const batchOutputTemplate = args.output
? args.output
: join(rendersDir, `${project.name}_${timestamp}_{index}${ext}`);
const outputPath = args.output
? resolve(args.output)
: join(rendersDir, `${project.name}_${timestamp}${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 bestEffort = args["best-effort"] ?? true;
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.");
failCommand();
}
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.",
);
failCommand();
}
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.`);
failCommand();
}
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}.`,
);
failCommand();
}
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".`,
);
failCommand();
}
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: renderTarget,
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 { 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",
);
failCommand();
}
}
// ── Pre-render lint ──────────────────────────────────────────────────
{
// lintProject's explicit-entry contract is an absolute source path;
// entryFile is project-relative for the producer.
const explicitEntry = entryFile ? renderTarget : undefined;
const lintResult = await lintProject(project.dir, explicitEntry);
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("");
failCommand();
}
console.log(c.dim(renderLintContinuationHint(strictErrors)));
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 {
resolutionIssue = await checkRenderResolutionPreflight(
readFileSync(renderTarget, "utf8"),
outputResolution,
{
alphaRequested: format === "webm" || format === "mov" || format === "png-sequence",
hdrRequested: args.hdr ?? false,
aspectAgnostic: outputResolutionAspectAgnostic,
},
);
} 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);
failCommand();
}
}
// ── Validate HDR/SDR mutual exclusion ────────────────────────────────
if (args.hdr && args.sdr) {
console.error("Error: --hdr and --sdr are mutually exclusive.");
failCommand();
}
// ── 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,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
debug,
bestEffort,
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
// Sequential batch rows may trial; real concurrent workers
// (batchConcurrency > 1) can't safely share the trial's process-wide
// env var/flags — see enableDeParallelRouterTrial's own doc comment.
enableDeParallelRouterTrial: batchConcurrency <= 1,
};
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) setCommandExitCode(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(renderTarget, 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,
bestEffort,
variables,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
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,
bestEffort,
variables,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
exitAfterComplete: true,
// The single top-level CLI render is sequential by construction — the
// one place the trial's process-wide state is unconditionally safe.
enableDeParallelRouterTrial: true,
});
}
const plan = createRenderPlan(args);
await presentRenderPlan(plan);
await executeRenderPlan(plan, {
renderDocker,
renderLocal,
checkResolution: checkRenderResolutionPreflight,
});
},
});
@@ -1077,14 +361,7 @@ export interface SingleRenderResult {
warnings?: Array<{ code: string; message: string }>;
}
// fallow-ignore-next-line unused-export
export function renderLintContinuationHint(strictErrors: boolean): string {
return strictErrors
? " Continuing render despite lint warnings. Use --strict-all to block warnings."
: " Continuing render despite lint issues. Use --strict to block errors.";
}
interface RenderOptions {
export interface RenderOptions {
fps: Fps;
quality: "draft" | "standard" | "high";
/** Authoring workflow skill that drove this render (telemetry attribution). */
@@ -1113,19 +390,9 @@ interface RenderOptions {
exitAfterComplete?: boolean;
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` came from an aspect-agnostic alias
* (`--resolution 1080p` / `hd` / `4k` / `uhd`). The compile stage adapts
* the preset to the composition's orientation instead of rejecting
* portrait/square comps as an aspect-ratio mismatch.
*/
/** Whether the resolution names a tier without fixing an orientation. */
outputResolutionAspectAgnostic?: boolean;
/**
* Raw `--resolution` string as typed by the user. Preserved so Docker mode
* can forward the pre-normalized flag to the in-container CLI, which
* re-runs the aspect-agnostic detection on its own side — otherwise we'd
* lose the "1080p was ambiguous" signal at the process boundary.
*/
/** Raw resolution flag retained for the in-container CLI. */
outputResolutionRaw?: string;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture (--experimental-fast-capture). */
@@ -1163,35 +430,6 @@ interface RenderOptions {
enableDeParallelRouterTrial?: 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
@@ -1236,14 +474,6 @@ async function readCompositionDimensions(
* 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.
*
* `aspectAgnostic` reflects whether `outputResolution` was normalized from an
* aspect-agnostic alias like `--resolution 1080p` / `hd` / `4k` / `uhd`.
* When true, an aspect-ratio mismatch is *not* an error at the CLI layer:
* the compile stage will re-map the preset to the composition's orientation
* (a portrait 1080×1920 composition with `--resolution 1080p` renders at
* 1080×1920, not 1920×1080). Alpha / HDR / downsampling / non-integer-scale
* checks still block, because those failures are not orientation-fixable.
*/
export async function checkRenderResolutionPreflight(
compositionHtml: string,
@@ -1255,24 +485,6 @@ export async function checkRenderResolutionPreflight(
// 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;
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`)
// don't nail an orientation — the compile stage remaps them to the
// composition's orientation via `adaptAspectAgnosticResolution` +
// `suggestMatchingPreset`. Mirror that remap here BEFORE running the
// compatibility check so the early rejection reflects the *effective*
// preset the pipeline will actually use.
//
// Doing this pre-check (rather than post-hoc "downgrade aspect-mismatch")
// is what makes the following cases fail early with an aspect-aware
// message instead of throwing deep in the compile stage after Chrome +
// ffmpeg have already spun up (Rames Δ2 on PR #2529):
//
// - Non-preset aspect (e.g. IG 4:5 1080×1350): no sibling preset
// matches → `suggestMatchingPreset` returns `undefined` → we keep the
// original preset and surface the aspect-mismatch normally.
// - Orientation-flip + tier-too-small (portrait-4K comp 2160×3840 +
// `--resolution 1080p`): remaps `landscape` → `portrait` (1080×1920),
// re-check catches the downsample early with a clear message.
const effective =
modes.aspectAgnostic === true
? (suggestMatchingPreset(dims.width, dims.height, outputResolution) ?? outputResolution)
@@ -1482,14 +694,6 @@ async function renderDocker(
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
// Forward the RAW `--resolution` flag (falling back to the canonical
// preset name when raw wasn't captured, e.g. programmatic callers).
// The in-container CLI re-runs `normalizeResolutionFlag` +
// `isAspectAgnosticResolutionAlias`, so aspect-agnostic aliases
// (`1080p`, `hd`, `4k`, `uhd`) retain their orientation-adaptive
// behavior inside Docker; passing the normalized `landscape` preset
// would silently lose that signal at the process boundary and
// reject portrait/square comps.
outputResolution: options.outputResolutionRaw ?? options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
debug: options.debug,