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
+275
View File
@@ -0,0 +1,275 @@
import { mkdirSync, readFileSync } from "node:fs";
import type { CanvasResolution, OutputResolutionIssueKind } from "@hyperframes/core";
import { c } from "../../ui/colors.js";
import { errorBox, formatBytes } from "../../ui/format.js";
import { formatLintFindings } from "../../utils/lintFormat.js";
import { lintProject, shouldBlockRender } from "../../utils/lintProject.js";
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
import {
reportVariableIssues,
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../../utils/variables.js";
import { trackRenderPreflightRejected } from "../../telemetry/events.js";
import { applyRenderEnvironment, renderOutputDirectory, type RenderPlan } from "./plan.js";
import type { RenderOptions, SingleRenderResult } from "../render.js";
type RenderExecutor = (
projectDir: string,
outputPath: string,
options: RenderOptions,
) => Promise<SingleRenderResult>;
type ResolutionPreflight = (
compositionHtml: string,
outputResolution: CanvasResolution | undefined,
modes: { alphaRequested: boolean; hdrRequested: boolean; aspectAgnostic?: boolean },
) => Promise<{ message: string; kind: OutputResolutionIssueKind } | undefined>;
export interface RenderExecutionDependencies {
renderDocker: RenderExecutor;
renderLocal: RenderExecutor;
checkResolution: ResolutionPreflight;
}
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.";
}
/** Execute a validated plan. Output and process lifecycle stay outside parsing. */
export async function executeRenderPlan(
plan: RenderPlan,
dependencies: RenderExecutionDependencies,
): Promise<void> {
applyRenderEnvironment(plan);
if (!plan.batchPath) mkdirSync(renderOutputDirectory(plan), { recursive: true });
let batchModule: typeof import("../batchRender.js") | undefined;
let preparedBatch: import("../batchRender.js").PreparedBatchRender | undefined;
if (plan.batchPath) {
batchModule = await import("../batchRender.js");
try {
preparedBatch = batchModule.prepareBatchRender({
batchPath: plan.batchPath,
outputTemplate: plan.batchOutputTemplate,
indexPath: plan.renderTarget,
strictVariables: plan.strictVariables,
quiet: plan.quiet || plan.batchJson,
json: plan.batchJson,
});
} catch (error: unknown) {
batchModule.exitBatchRenderInputError(error);
}
}
const browserPath = plan.useDocker ? undefined : await ensureRenderBrowser(plan);
await runRenderLint(plan);
await runResolutionPreflight(plan, dependencies.checkResolution);
if (plan.batchPath && batchModule && preparedBatch) {
await executeBatchRender(plan, browserPath, batchModule, preparedBatch, dependencies);
return;
}
const variables = resolveVariablesArg(plan.variablesArg, plan.variablesFileArg);
if (variables && Object.keys(variables).length > 0) {
const issues = validateVariablesAgainstProject(plan.renderTarget, variables);
reportVariableIssues(issues, { strict: plan.strictVariables, quiet: plan.quiet });
}
const options: RenderOptions = {
fps: plan.fps,
quality: plan.quality,
authoringSkill: plan.authoringSkill,
format: plan.format,
gifLoop: plan.gifLoop,
workers: plan.workers,
gpu: plan.useGpu,
browserGpuMode: plan.browserGpuMode,
hdrMode: plan.hdrMode,
crf: plan.crf,
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
videoFrameFormat: plan.videoFrameFormat,
quiet: plan.quiet,
browserPath,
debug: plan.debug,
bestEffort: plan.bestEffort,
variables,
entryFile: plan.entryFile,
outputResolution: plan.outputResolution,
outputResolutionAspectAgnostic: plan.outputResolutionAspectAgnostic,
outputResolutionRaw: plan.outputResolutionRaw,
pageNavigationTimeoutMs: plan.pageNavigationTimeoutMs,
protocolTimeout: plan.protocolTimeout,
playerReadyTimeout: plan.playerReadyTimeout,
exitAfterComplete: true,
enableDeParallelRouterTrial: true,
};
if (plan.useDocker) {
options.pageSideCompositing = plan.pageSideCompositing;
options.experimentalFastCapture = plan.experimentalFastCapture;
}
const execute = plan.useDocker ? dependencies.renderDocker : dependencies.renderLocal;
await execute(plan.project.dir, plan.outputPath, options);
}
async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
const { ensureBrowser } = await import("../../browser/manager.js");
let browserSpinner:
| {
start: (message?: string) => void;
message: (message: string) => void;
stop: (message?: string) => void;
}
| undefined;
try {
if (plan.effectiveQuiet) {
return (await ensureBrowser({ preferManagedChrome: true })).executablePath;
}
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) + ")")}`,
);
},
});
browserSpinner.stop(c.dim(`Browser: ${info.source}`));
return info.executablePath;
} catch (error: unknown) {
browserSpinner?.stop(c.error("Browser not available"));
errorBox(
"Chrome not found",
normalizeErrorMessage(error),
"Run: npx hyperframes browser ensure",
);
failCommand();
}
}
async function runRenderLint(plan: RenderPlan): Promise<void> {
// lintProject's explicit-entry contract is an absolute source path;
// entryFile remains project-relative for the producer.
const explicitEntry = plan.entryFile ? plan.renderTarget : undefined;
const lintResult = await lintProject(plan.project.dir, explicitEntry);
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return;
presentRenderLintFindings(lintResult, plan.effectiveQuiet);
if (
shouldBlockRender(
plan.strictErrors,
plan.strictAll,
lintResult.totalErrors,
lintResult.totalWarnings,
)
) {
presentRenderLintAbort(plan);
failCommand();
}
presentRenderLintContinuation(plan);
}
function presentRenderLintFindings(
lintResult: Awaited<ReturnType<typeof lintProject>>,
quiet: boolean,
): void {
if (quiet) return;
console.log("");
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
}
function presentRenderLintAbort(plan: RenderPlan): void {
if (plan.effectiveQuiet) return;
const mode = plan.strictAll ? "--strict-all" : "--strict";
console.log("");
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
console.log("");
}
function presentRenderLintContinuation(plan: RenderPlan): void {
if (plan.effectiveQuiet) return;
console.log(c.dim(renderLintContinuationHint(plan.strictErrors)));
console.log("");
}
async function runResolutionPreflight(
plan: RenderPlan,
checkResolution: ResolutionPreflight,
): Promise<void> {
if (!plan.outputResolution) return;
let issue: { message: string; kind: OutputResolutionIssueKind } | undefined;
try {
issue = await checkResolution(readFileSync(plan.renderTarget, "utf8"), plan.outputResolution, {
alphaRequested:
plan.format === "webm" || plan.format === "mov" || plan.format === "png-sequence",
hdrRequested: plan.hdrMode === "force-hdr",
aspectAgnostic: plan.outputResolutionAspectAgnostic,
});
} catch {
// Unreadable input is surfaced by the render pipeline with full context.
}
if (!issue) return;
trackRenderPreflightRejected({ kind: issue.kind });
errorBox("Output resolution incompatible", issue.message);
failCommand();
}
async function executeBatchRender(
plan: RenderPlan,
browserPath: string | undefined,
batchModule: typeof import("../batchRender.js"),
preparedBatch: import("../batchRender.js").PreparedBatchRender,
dependencies: RenderExecutionDependencies,
): Promise<void> {
const batchQuiet = plan.quiet || plan.batchJson;
const renderOptionsBase: RenderOptions = {
fps: plan.fps,
quality: plan.quality,
authoringSkill: plan.authoringSkill,
format: plan.format,
workers: plan.workers,
gpu: plan.useGpu,
browserGpuMode: plan.browserGpuMode,
hdrMode: plan.hdrMode,
crf: plan.crf,
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
quiet: batchQuiet,
browserPath,
entryFile: plan.entryFile,
outputResolution: plan.outputResolution,
outputResolutionAspectAgnostic: plan.outputResolutionAspectAgnostic,
outputResolutionRaw: plan.outputResolutionRaw,
pageNavigationTimeoutMs: plan.pageNavigationTimeoutMs,
protocolTimeout: plan.protocolTimeout,
playerReadyTimeout: plan.playerReadyTimeout,
debug: plan.debug,
bestEffort: plan.bestEffort,
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
enableDeParallelRouterTrial: plan.batchConcurrency <= 1,
};
const manifest = await batchModule.runBatchRender({
prepared: preparedBatch,
concurrency: plan.batchConcurrency,
failFast: plan.batchFailFast,
quiet: batchQuiet,
json: plan.batchJson,
renderOne: (row) => {
const options: RenderOptions = { ...renderOptionsBase, variables: row.variables };
if (plan.useDocker) options.pageSideCompositing = plan.pageSideCompositing;
const execute = plan.useDocker ? dependencies.renderDocker : dependencies.renderLocal;
return execute(plan.project.dir, row.outputPath, options);
},
});
if (manifest.failed > 0) setCommandExitCode(1);
}
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { CliUsageError } from "../../utils/commandResult.js";
import { createRenderPlan } from "./plan.js";
describe("createRenderPlan", () => {
let projectDir: string;
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "hf-render-plan-"));
writeFileSync(
join(projectDir, "index.html"),
'<main data-composition-id="main" data-width="1920" data-height="1080" data-fps="24"></main>',
);
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
rmSync(projectDir, { recursive: true, force: true });
});
it("resolves defaults once into a frozen execution plan", () => {
const plan = createRenderPlan(
{ dir: projectDir, output: "result.mp4" },
new Date("2026-07-10T12:34:56Z"),
);
expect(plan.fps).toEqual({ num: 24, den: 1 });
expect(plan.outputPath).toBe(resolve("result.mp4"));
expect(plan.hdrMode).toBe("auto");
expect(plan.bestEffort).toBe(true);
expect(plan.batchConcurrency).toBe(1);
expect(Object.isFrozen(plan)).toBe(true);
expect(Object.isFrozen(plan.environment)).toBe(true);
});
it("preserves an explicit strict-readiness opt-in", () => {
const plan = createRenderPlan({ dir: projectDir, "best-effort": false });
expect(plan.bestEffort).toBe(false);
});
it("preserves an aspect-agnostic resolution alias through the execution plan", () => {
const plan = createRenderPlan({ dir: projectDir, resolution: "1080p" });
expect(plan.outputResolution).toBe("landscape");
expect(plan.outputResolutionAspectAgnostic).toBe(true);
expect(plan.outputResolutionRaw).toBe("1080p");
});
it("classifies malformed command input as a usage error", () => {
expect(() => createRenderPlan({ dir: projectDir, quality: "maximum" })).toThrow(CliUsageError);
});
it("rejects batch and single-render variables before execution", () => {
expect(() =>
createRenderPlan({ dir: projectDir, batch: "rows.json", variables: '{"name":"Ada"}' }),
).toThrow(CliUsageError);
});
it("keeps environment changes declarative until execution", () => {
const previous = process.env.PRODUCER_LOW_MEMORY_MODE;
delete process.env.PRODUCER_LOW_MEMORY_MODE;
try {
const plan = createRenderPlan({ dir: projectDir, "low-memory-mode": true });
expect(plan.environment).toEqual({ PRODUCER_LOW_MEMORY_MODE: "true" });
expect(process.env.PRODUCER_LOW_MEMORY_MODE).toBeUndefined();
} finally {
if (previous === undefined) delete process.env.PRODUCER_LOW_MEMORY_MODE;
else process.env.PRODUCER_LOW_MEMORY_MODE = previous;
}
});
it("resolves a relative frame-cache directory into the execution environment", () => {
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "./frame-cache" });
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe(resolve("./frame-cache"));
});
it("preserves frame-cache disable aliases for engine normalization", () => {
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "OFF" });
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe("OFF");
});
});
+467
View File
@@ -0,0 +1,467 @@
import { statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import {
formatRenderOutputTimestamp,
fpsToNumber,
isAspectAgnosticResolutionAlias,
normalizeResolutionFlag,
parseFps,
type CanvasResolution,
type Fps,
type FpsParseResult,
} from "@hyperframes/core";
import {
EXTRACT_CACHE_DIR_DISABLED_ALIASES,
MAX_VP9_CPU_USED,
MIN_VP9_CPU_USED,
isVideoFrameFormat,
type VideoFrameFormat,
} from "@hyperframes/engine";
import { errorBox } from "../../ui/format.js";
import { failUsage } from "../../utils/commandResult.js";
import { resolveProject } from "../../utils/project.js";
import {
hasExplicitCompositionArg,
parseGifLoopArg,
resolveBrowserTimeoutMsArg,
resolveCompositionEntryArg,
resolveDefaultFpsArg,
} from "../../utils/renderArgs.js";
import { normalizeSkillSlug } from "../../telemetry/skill.js";
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
const VALID_FORMAT = new Set<string>(RENDER_FORMATS);
const RENDER_FORMAT_LABEL = "mp4, webm, mov, png-sequence, or gif";
export type RenderFormat = (typeof RENDER_FORMATS)[number];
export type RenderQuality = "draft" | "standard" | "high";
export type BrowserGpuMode = "auto" | "hardware" | "software";
export type HdrMode = "auto" | "force-hdr" | "force-sdr";
export type RenderProject = ReturnType<typeof resolveProject>;
const FORMAT_EXT: Record<RenderFormat, string> = {
mp4: ".mp4",
webm: ".webm",
mov: ".mov",
"png-sequence": "",
gif: ".gif",
};
export interface RenderCommandArgs {
dir?: string;
composition?: string;
output?: string;
fps?: string;
quality?: string;
skill?: string;
format?: string;
"gif-loop"?: string;
"video-frame-format"?: string;
workers?: string;
docker?: boolean;
hdr?: boolean;
sdr?: boolean;
crf?: string;
"video-bitrate"?: string;
"vp9-cpu-used"?: string;
gpu?: boolean;
"browser-gpu"?: boolean;
quiet?: boolean;
debug?: boolean;
"best-effort"?: boolean;
strict?: boolean;
"strict-all"?: boolean;
"max-concurrent-renders"?: string;
variables?: string;
"variables-file"?: string;
"strict-variables"?: boolean;
batch?: string;
"batch-concurrency"?: string;
"batch-fail-fast"?: boolean;
json?: boolean;
resolution?: string;
"page-side-compositing"?: boolean;
"browser-timeout"?: string;
"protocol-timeout"?: string;
"player-ready-timeout"?: string;
"low-memory-mode"?: boolean;
"experimental-fast-capture"?: boolean;
"frames-cache-dir"?: string;
}
export interface RenderPlan {
project: RenderProject;
entryFile?: string;
renderTarget: string;
fps: Fps;
quality: RenderQuality;
authoringSkill?: string;
invalidAuthoringSkill?: string;
format: RenderFormat;
gifLoop?: number;
gifFpsCapped: boolean;
videoFrameFormat: VideoFrameFormat;
outputResolution?: CanvasResolution;
outputResolutionAspectAgnostic: boolean;
outputResolutionRaw?: string;
workers?: number;
protocolTimeout?: number;
playerReadyTimeout?: number;
pageNavigationTimeoutMs?: number;
batchPath?: string;
batchConcurrency: number;
batchFailFast: boolean;
batchOutputTemplate: string;
outputPath: string;
useDocker: boolean;
useGpu: boolean;
browserGpuMode: BrowserGpuMode;
quiet: boolean;
debug: boolean;
bestEffort: boolean;
batchJson: boolean;
effectiveQuiet: boolean;
strictAll: boolean;
strictErrors: boolean;
crf?: number;
vp9CpuUsed?: number;
videoBitrate?: string;
hdrMode: HdrMode;
pageSideCompositing: boolean;
experimentalFastCapture: boolean;
variablesArg?: string;
variablesFileArg?: string;
strictVariables: boolean;
environment: Readonly<Record<string, string>>;
}
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).`;
}
}
function parseRenderFormat(input: string): RenderFormat | undefined {
if (!VALID_FORMAT.has(input)) return undefined;
return RENDER_FORMATS.find((format) => format === input);
}
function positiveInteger(raw: string, title: string, message: string, min = 1): number {
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < min) {
errorBox(title, message);
failUsage();
}
return parsed;
}
/** Parse and validate command input into an immutable execution plan. */
// fallow-ignore-next-line complexity
export function createRenderPlan(args: RenderCommandArgs, now = new Date()): RenderPlan {
const hasExplicitComposition = hasExplicitCompositionArg(args.composition);
const project = resolveProject(args.dir, { requireIndex: !hasExplicitComposition });
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
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));
failUsage();
}
let fps = fpsParse.value;
const qualityRaw = args.quality ?? "standard";
if (!VALID_QUALITY.has(qualityRaw)) {
errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`);
failUsage();
}
const quality = qualityRaw as RenderQuality;
const authoringSkill = normalizeSkillSlug(args.skill);
const invalidAuthoringSkill =
typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill
? args.skill
: undefined;
const formatRaw = args.format ?? "mp4";
const format = parseRenderFormat(formatRaw);
if (!format) {
errorBox("Invalid format", `Got "${formatRaw}". Must be ${RENDER_FORMAT_LABEL}.`);
failUsage();
}
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);
failUsage();
}
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.`,
);
failUsage();
}
let outputResolution: CanvasResolution | undefined;
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).`,
);
failUsage();
}
outputResolutionAspectAgnostic = isAspectAgnosticResolutionAlias(args.resolution);
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.",
);
failUsage();
}
}
if (args.hdr && args.sdr) {
errorBox("Conflicting flags", "--hdr and --sdr are mutually exclusive.");
failUsage();
}
const workers =
args.workers != null && args.workers !== "auto"
? positiveInteger(
args.workers,
"Invalid workers",
`Got "${args.workers}". Must be a positive number or "auto".`,
)
: undefined;
const protocolTimeout =
args["protocol-timeout"] != null
? positiveInteger(
args["protocol-timeout"],
"Invalid protocol-timeout",
`Got "${args["protocol-timeout"]}". Must be a number >= 1000 (ms).`,
1000,
)
: undefined;
const playerReadyTimeout =
args["player-ready-timeout"] != null
? positiveInteger(
args["player-ready-timeout"],
"Invalid player-ready-timeout",
`Got "${args["player-ready-timeout"]}". Must be a number >= 1000 (ms).`,
1000,
)
: undefined;
const environment: Record<string, string> = {};
if (args["page-side-compositing"] === false) environment.HF_PAGE_SIDE_COMPOSITING = "false";
if (args["low-memory-mode"] != null) {
environment.PRODUCER_LOW_MEMORY_MODE = args["low-memory-mode"] ? "true" : "false";
}
if (args["experimental-fast-capture"] != null) {
environment.PRODUCER_EXPERIMENTAL_FAST_CAPTURE = args["experimental-fast-capture"]
? "true"
: "false";
}
// Sugar for HYPERFRAMES_EXTRACT_CACHE_DIR. Disabling aliases pass through
// verbatim for the engine helper; positive paths become CWD-stable here.
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);
environment.HYPERFRAMES_EXTRACT_CACHE_DIR = isDisableAlias ? raw : resolve(raw);
}
if (args["max-concurrent-renders"] != null) {
const parsed = positiveInteger(
args["max-concurrent-renders"],
"Invalid max-concurrent-renders",
`Got "${args["max-concurrent-renders"]}". Must be a number between 1 and 10.`,
);
if (parsed > 10) {
errorBox(
"Invalid max-concurrent-renders",
`Got "${args["max-concurrent-renders"]}". Must be a number between 1 and 10.`,
);
failUsage();
}
environment.PRODUCER_MAX_CONCURRENT_RENDERS = String(parsed);
}
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.",
);
failUsage();
}
if (!batchPath && args["batch-concurrency"] != null) {
errorBox("Invalid batch-concurrency", "--batch-concurrency requires --batch.");
failUsage();
}
if (!batchPath && args["batch-fail-fast"]) {
errorBox("Invalid batch-fail-fast", "--batch-fail-fast requires --batch.");
failUsage();
}
const batchConcurrency =
args["batch-concurrency"] != null
? positiveInteger(
args["batch-concurrency"],
"Invalid batch-concurrency",
`Got "${args["batch-concurrency"]}". Must be a positive integer.`,
)
: 1;
const rendersDir = resolve("renders");
const ext = FORMAT_EXT[format];
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}`);
const useDocker = args.docker ?? false;
const useGpu = args.gpu ?? false;
const browserGpuMode = resolveBrowserGpuForCli(useDocker, args["browser-gpu"]);
if (useDocker && args["browser-gpu"] === 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.",
);
failUsage();
}
const videoBitrate = args["video-bitrate"]?.trim();
if (args.crf != null && videoBitrate) {
errorBox("Conflicting encoder settings", "Use either --crf or --video-bitrate, not both.");
failUsage();
}
const crf =
args.crf != null
? positiveInteger(
args.crf,
"Invalid crf",
`Got "${args.crf}". Must be a non-negative integer.`,
0,
)
: undefined;
let vp9CpuUsed: number | undefined;
if (args["vp9-cpu-used"] != null) {
const parsed = Number(args["vp9-cpu-used"]);
if (!Number.isInteger(parsed) || parsed < MIN_VP9_CPU_USED || parsed > MAX_VP9_CPU_USED) {
errorBox(
"Invalid vp9-cpu-used",
`Got "${args["vp9-cpu-used"]}". Must be an integer between ${MIN_VP9_CPU_USED} and ${MAX_VP9_CPU_USED}.`,
);
failUsage();
}
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".`,
);
failUsage();
}
const quiet = args.quiet ?? false;
const batchJson = args.json ?? false;
return Object.freeze({
project,
entryFile,
renderTarget,
fps,
quality,
authoringSkill,
invalidAuthoringSkill,
format,
gifLoop,
gifFpsCapped,
videoFrameFormat: videoFrameFormatRaw,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
workers,
protocolTimeout,
playerReadyTimeout,
pageNavigationTimeoutMs: resolveBrowserTimeoutMsArg(args["browser-timeout"]),
batchPath,
batchConcurrency,
batchFailFast: args["batch-fail-fast"] ?? false,
batchOutputTemplate,
outputPath,
useDocker,
useGpu,
browserGpuMode,
quiet,
debug: args.debug ?? false,
bestEffort: args["best-effort"] ?? true,
batchJson,
effectiveQuiet: quiet || (batchPath != null && batchJson),
strictAll: args["strict-all"] ?? false,
strictErrors: (args.strict ?? false) || (args["strict-all"] ?? false),
crf,
vp9CpuUsed,
videoBitrate,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
variablesArg: args.variables,
variablesFileArg: args["variables-file"],
strictVariables: args["strict-variables"] ?? false,
environment: Object.freeze(environment),
});
}
export function applyRenderEnvironment(plan: RenderPlan): void {
for (const [key, value] of Object.entries(plan.environment)) process.env[key] = value;
}
export function renderOutputDirectory(plan: RenderPlan): string {
return dirname(plan.outputPath);
}
/** Resolve browser GPU mode from Docker, CLI, env, then the auto default. */
export function resolveBrowserGpuForCli(
useDocker: boolean,
browserGpuArg: boolean | undefined,
envMode = process.env.PRODUCER_BROWSER_GPU_MODE,
): BrowserGpuMode {
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";
}
@@ -0,0 +1,72 @@
import { cpus } from "node:os";
import { readFileSync } from "node:fs";
import { fpsToFfmpegArg } from "@hyperframes/core";
import { c } from "../../ui/colors.js";
import type { RenderPlan } from "./plan.js";
/** Present warnings and the human render plan. JSON batch output stays silent. */
export async function presentRenderPlan(plan: RenderPlan): Promise<void> {
await presentRenderWarnings(plan);
if (plan.effectiveQuiet || plan.batchPath) return;
presentRenderSummary(plan);
}
async function presentRenderWarnings(plan: RenderPlan): Promise<void> {
if (plan.invalidAuthoringSkill) {
process.stderr.write(
`hyperframes: ignoring --skill="${plan.invalidAuthoringSkill}" — not a valid slug ` +
"(lowercase letters/digits/hyphens, max 64); this render will be unattributed.\n",
);
}
if (!plan.effectiveQuiet && plan.gifFpsCapped) {
console.log(c.warn(" GIF output is capped at 30fps. Use --fps 15 for smaller files."));
}
if (!plan.effectiveQuiet) await warnForSlideshow(plan);
}
function presentRenderSummary(plan: RenderPlan): void {
const workerLabel =
plan.workers != null
? `${plan.workers} workers`
: `auto workers (${cpus().length} cores detected)`;
const nameLabel = plan.entryFile ? `${plan.project.name}/${plan.entryFile}` : plan.project.name;
console.log("");
console.log(
c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + plan.outputPath),
);
console.log(
c.dim(` ${fpsToFfmpegArg(plan.fps)}fps \u00B7 ${plan.quality} \u00B7 ${workerLabel}`),
);
if (plan.outputResolution) {
console.log(c.dim(" Output resolution: " + plan.outputResolution));
}
if (plan.useGpu || plan.browserGpuMode !== "software") {
const gpuModes = [
plan.useGpu ? "encoder GPU" : null,
plan.browserGpuMode === "hardware"
? "browser GPU (forced)"
: plan.browserGpuMode === "auto"
? "browser GPU (auto-detect)"
: null,
].filter(Boolean);
console.log(c.dim(" GPU: " + gpuModes.join(" + ")));
}
console.log("");
}
async function warnForSlideshow(plan: RenderPlan): Promise<void> {
try {
const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow");
if (!slideshowIslandRegex("i").test(readFileSync(plan.renderTarget, "utf8"))) return;
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 only; the execution pipeline owns real file failures.
}
}