diff --git a/packages/cli/src/commands/batchRender.test.ts b/packages/cli/src/commands/batchRender.test.ts index 3953ef6626..f4b5646ffc 100644 --- a/packages/cli/src/commands/batchRender.test.ts +++ b/packages/cli/src/commands/batchRender.test.ts @@ -47,11 +47,6 @@ function expectBatchError(fn: () => unknown, title: string): BatchRenderInputErr throw new Error("Expected BatchRenderInputError"); } -function eventType(value: unknown): string | undefined { - if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; - return "type" in value && typeof value.type === "string" ? value.type : undefined; -} - describe("parseBatchRows", () => { it("parses a JSON array of variable rows", () => { expect(parseBatchRows('[{"name":"Alice"},{"name":"Bob"}]', "rows.json")).toEqual([ @@ -192,7 +187,7 @@ describe("runBatchRender", () => { expect(readFileSync(prepared.manifestPath, "utf8")).toContain('"status": "completed"'); }); - it("emits JSON progress events when json mode is enabled", async () => { + it("emits exactly one final JSON document when json mode is enabled", async () => { const prepared = prepareBatchRender({ batchPath: writeJson("rows.json", '[{"name":"Alice"}]'), outputTemplate: join(tmpDir, "renders/{name}.mp4"), @@ -212,12 +207,14 @@ describe("runBatchRender", () => { renderOne: async () => ({ renderTimeMs: 10 }), }); - const events = log.mock.calls.map((call): unknown => JSON.parse(String(call[0]))); - expect(events.map(eventType)).toEqual([ - "batch-row-start", - "batch-row-complete", - "batch-complete", - ]); + expect(log).toHaveBeenCalledTimes(1); + const result = JSON.parse(String(log.mock.calls[0]?.[0])) as { + type: string; + completed: number; + rows: Array<{ status: string }>; + }; + expect(result).toMatchObject({ type: "batch-complete", completed: 1 }); + expect(result.rows).toEqual([expect.objectContaining({ status: "completed" })]); }); it("continues after row failure by default", async () => { diff --git a/packages/cli/src/commands/batchRender.ts b/packages/cli/src/commands/batchRender.ts index 34ba27f829..12602ee97a 100644 --- a/packages/cli/src/commands/batchRender.ts +++ b/packages/cli/src/commands/batchRender.ts @@ -305,10 +305,6 @@ function writeManifest(manifest: BatchManifest): void { writeFileSync(manifest.manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); } -function emitJsonEvent(event: Record, json: boolean): void { - if (json) console.log(JSON.stringify(event)); -} - async function renderBatchRow( row: PreparedBatchRow, manifest: BatchManifest, @@ -322,11 +318,6 @@ async function renderBatchRow( manifestRow.status = "running"; manifestRow.startedAt = new Date().toISOString(); writeManifest(manifest); - emitJsonEvent( - { type: "batch-row-start", index: row.index, outputPath: row.outputPath }, - options.json, - ); - if (!options.quiet && !options.json) { console.log(c.dim(`Batch row ${row.index}: ${row.outputPath}`)); } @@ -339,31 +330,12 @@ async function renderBatchRow( manifestRow.renderTimeMs = result.renderTimeMs; manifestRow.completedAt = new Date().toISOString(); writeManifest(manifest); - emitJsonEvent( - { - type: "batch-row-complete", - index: row.index, - outputPath: row.outputPath, - durationMs: manifestRow.durationMs, - renderTimeMs: manifestRow.renderTimeMs, - }, - options.json, - ); return true; } catch (error: unknown) { manifestRow.status = "failed"; manifestRow.error = errorMessage(error); manifestRow.completedAt = new Date().toISOString(); writeManifest(manifest); - emitJsonEvent( - { - type: "batch-row-error", - index: row.index, - outputPath: row.outputPath, - error: manifestRow.error, - }, - options.json, - ); if (!options.quiet && !options.json) { console.log(c.error(` Row ${row.index} failed: ${manifestRow.error}`)); } @@ -423,17 +395,19 @@ export async function runBatchRender(options: RunBatchRenderOptions): Promise["reason"], -): string { - switch (reason) { - case "empty": - return "Frame rate must not be empty."; - case "not-a-number": - return `Got "${input}". Frame rate must be an integer (e.g. 30) or a rational (e.g. 30000/1001 for NTSC).`; - case "non-positive": - return `Got "${input}". Frame rate must be greater than zero.`; - case "out-of-range": - return `Got "${input}". Frame rate must be in the range 1–240.`; - case "invalid-fraction": - return `Got "${input}". Rational frame rates must be two positive integers separated by '/' (e.g. 30000/1001).`; - case "ambiguous-decimal": - return `Got "${input}". Decimal frame rates are ambiguous — use the exact rational form instead (e.g. 30000/1001 for 29.97).`; - } -} -const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const; -type RenderFormat = (typeof RENDER_FORMATS)[number]; -const VALID_FORMAT = new Set(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 = { - 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", @@ -321,7 +251,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: { @@ -388,629 +318,15 @@ export default defineCommand({ // env fallback survives (matches the --low-memory-mode idiom). }, }, - // `run` is the citty handler for `hyperframes render` — sequential flag - // validation + render dispatch. Inherited CRITICAL on main (CRAP 1290); - // this PR extracted --browser-timeout + --composition validators into - // `utils/renderArgs.ts`, reducing cyclomatic 75→65 and CRAP 1290→978. - // Full decomposition is tracked separately and out of scope for #1199. - // fallow-ignore-next-line complexity + // 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; - 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(); - } - // 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"; - } - - // ── 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"; - // fallow-ignore-next-line code-duplication - const now = new Date(); - const datePart = now.toISOString().slice(0, 10); - const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-"); - const batchOutputTemplate = args.output - ? args.output - : join(rendersDir, `${project.name}_${datePart}_${timePart}_{index}${ext}`); - const outputPath = args.output - ? resolve(args.output) - : join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`); - - // Ensure output directory exists - if (!batchPath) mkdirSync(dirname(outputPath), { recursive: true }); - - const useDocker = args.docker ?? false; - const useGpu = args.gpu ?? false; - const browserGpuArg = args["browser-gpu"]; - const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg); - const quiet = args.quiet ?? false; - const debug = args.debug ?? false; - const 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, - }, - ); - } 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, - 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, - 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, - 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, + }); }, }); @@ -1021,14 +337,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). */ @@ -1093,35 +402,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 diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts new file mode 100644 index 0000000000..feab99b3e8 --- /dev/null +++ b/packages/cli/src/commands/render/execute.ts @@ -0,0 +1,258 @@ +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; + +type ResolutionPreflight = ( + compositionHtml: string, + outputResolution: CanvasResolution | undefined, + modes: { alphaRequested: boolean; hdrRequested: 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 { + 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, + 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 { + 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 { + // 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; + if (!plan.effectiveQuiet) { + console.log(""); + for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); + } + if ( + shouldBlockRender( + plan.strictErrors, + plan.strictAll, + lintResult.totalErrors, + lintResult.totalWarnings, + ) + ) { + const mode = plan.strictAll ? "--strict-all" : "--strict"; + if (!plan.effectiveQuiet) { + console.log(""); + console.log(c.error(` Aborting render due to lint issues (${mode} mode).`)); + console.log(""); + } + failCommand(); + } + if (!plan.effectiveQuiet) { + console.log(c.dim(renderLintContinuationHint(plan.strictErrors))); + console.log(""); + } +} + +async function runResolutionPreflight( + plan: RenderPlan, + checkResolution: ResolutionPreflight, +): Promise { + 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", + }); + } 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 { + 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, + 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); +} diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts new file mode 100644 index 0000000000..9592bdacf4 --- /dev/null +++ b/packages/cli/src/commands/render/plan.test.ts @@ -0,0 +1,67 @@ +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"), + '
', + ); + 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("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; + } + }); +}); diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts new file mode 100644 index 0000000000..e71fe7bf0d --- /dev/null +++ b/packages/cli/src/commands/render/plan.ts @@ -0,0 +1,450 @@ +import { statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + fpsToNumber, + normalizeResolutionFlag, + parseFps, + type CanvasResolution, + type Fps, + type FpsParseResult, +} from "@hyperframes/core"; +import { + 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(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; + +const FORMAT_EXT: Record = { + 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; +} + +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; + 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>; +} + +function formatFpsParseError( + input: string, + reason: Exclude["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; + 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(); + } + 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 = {}; + 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"; + } + 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 datePart = now.toISOString().slice(0, 10); + const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-"); + const batchOutputTemplate = args.output + ? args.output + : join(rendersDir, `${project.name}_${datePart}_${timePart}_{index}${ext}`); + const outputPath = args.output + ? resolve(args.output) + : join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`); + + 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, + 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"; +} diff --git a/packages/cli/src/commands/render/present.ts b/packages/cli/src/commands/render/present.ts new file mode 100644 index 0000000000..f462c1cf26 --- /dev/null +++ b/packages/cli/src/commands/render/present.ts @@ -0,0 +1,65 @@ +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 { + 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); + if (plan.effectiveQuiet || plan.batchPath) return; + + 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 { + 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. + } +}