mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
refactor(cli): decompose render phases
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 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).`;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user