diff --git a/docs/docs.json b/docs/docs.json index f2744d4f09..ed4deaafbf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -832,6 +832,8 @@ "group": "Rendering paths", "pages": [ "guides/rendering", + "reference/cli-render", + "guides/agents", "deploy/overview", "deploy/cloud", "guides/deploy" diff --git a/docs/guides/agents.mdx b/docs/guides/agents.mdx new file mode 100644 index 0000000000..08c8a66661 --- /dev/null +++ b/docs/guides/agents.mdx @@ -0,0 +1,94 @@ +--- +title: "Rendering for agents" +sidebarTitle: "Rendering for agents" +description: "Drive hyperframes render from an AI agent or CI pipeline: stream machine-readable NDJSON progress, detect the failed stage without scraping logs, and keep stdout clean for pipes." +--- + +An agent (or a CI job) driving `hyperframes render` needs to know three things +while the render runs: *is it making progress*, *what stage is it in*, and — +when it dies — *which stage failed and why*, in a form a program can branch on. +The TTY progress bar answers none of those, and `--quiet` removes even the bar. + +`--progress-format ndjson` turns the render into an event stream: one JSON +object per line on stdout, human logs suppressed on stdout so the pipe stays +clean, diagnostics on stderr. The full event schema lives in the +[CLI render progress reference](/reference/cli-render). + +## The recipe + +```bash +hyperframes render --progress-format ndjson --output out.mp4 | while read -r line; do + type=$(jq -r '.type' <<<"$line") + case "$type" in + render.progress) + jq -r '"[\(.progress * 100 | floor)%] \(.stage) (\(.framesRendered)/\(.totalFrames) frames)"' <<<"$line" ;; + render.completed) + echo "done" ;; + render.failed) + jq -r '"failed in stage \(.failedStage): \(.error)"' <<<"$line" >&2 + exit 1 ;; + esac +done +``` + +What the stream guarantees: + +- **Ordering** — events arrive in pipeline order; `progress` is monotonic + within a render. +- **Exactly one terminal event** — every render ends in `render.completed` or + `render.failed` (or EOF if the process was killed). No polling, no timeout + heuristics. +- **Structured failure** — `render.failed` carries `failedStage` plus the + producer's `errorDetails` (message, elapsed time, free memory, browser + console tail, per-stage timings), so an agent can branch on the failing + stage rather than regex-matching stderr. + +## Branching on the failed stage + +The producer names the stage that failed (`Compiling composition`, +`Capturing frames`, `Encoding video`, …), and `errorDetails.browserConsoleTail` +contains the composition's own console output — usually the fastest route to a +self-repair loop: + +```bash +hyperframes render --progress-format ndjson -o out.mp4 > events.ndjson || true +jq -r 'select(.type == "render.failed") + | {failedStage, error, consoleTail: .errorDetails.browserConsoleTail}' events.ndjson +``` + +A composition error (broken script, missing timeline) shows up in the console +tail; an environment error (Chrome could not launch, disk full) shows up in +`error` with `failedStage` at an early stage. Different failure, different fix +— and neither requires parsing human log text. + +## Keeping stdout for something else + +When stdout is already spoken for — a `--batch --json` result document, or a +wrapper that wants the human logs — route the stream to a file descriptor: + +```bash +# Events on fd 3, human logs stay on stdout +hyperframes render --progress-format ndjson --progress-fd 3 -o out.mp4 3>events.ndjson + +# Live tail of the same stream from another process +tail -f events.ndjson | jq -r '.stage' +``` + +## Batch renders + +Batch rows multiplex into one stream, each event stamped with its `row` index +and each row closing with its own terminal event: + +```bash +hyperframes render --batch rows.json --progress-format ndjson -o "renders/{name}.mp4" \ + | jq -r 'select(.type != "render.progress") | "row \(.row): \(.type)"' +``` + +## Notes + +- `--quiet` does **not** silence the NDJSON stream — quiet governs human + output. Use `--progress-format none` if you want no progress at all. +- `--progress-format ndjson` is for local renders; with `--docker` run the CLI + inside the container with the flag instead. +- The render never fails because a consumer disappeared: on EPIPE the stream + stops and the render finishes normally. diff --git a/docs/reference/cli-render.mdx b/docs/reference/cli-render.mdx new file mode 100644 index 0000000000..82591970ef --- /dev/null +++ b/docs/reference/cli-render.mdx @@ -0,0 +1,117 @@ +--- +title: "hyperframes render — progress output" +sidebarTitle: "CLI render progress" +description: "The render command's progress modes: the interactive TTY bar, the machine-readable NDJSON event stream for agents and CI, and how they interact with --quiet, --batch, and --json." +--- + +`hyperframes render` reports progress in one of three formats, selected with +`--progress-format`: + +```bash +hyperframes render --progress-format --output out.mp4 +``` + +| Format | What you get | stdout contract | +| --- | --- | --- | +| `tty` (default) | The interactive progress bar (line-per-tick when stdout is not a TTY) | Human output | +| `ndjson` | One JSON object per progress tick — machine-readable, for agents and CI | The event stream owns stdout (see below) | +| `none` | No progress output at all; the rest of the human output is untouched | Human output | + +`--quiet` keeps its existing meaning: it silences *human* output. Under +`--progress-format ndjson` the event stream keeps flowing even with `--quiet`, +because the stream is the machine contract a consumer is parsing, not +presentation. + +## The NDJSON event stream + +With `--progress-format ndjson`, every producer progress tick becomes one +newline-terminated JSON object on stdout: + +```json +{"type":"render.progress","ts":"2026-09-08T00:00:00.000Z","progress":0.42,"status":"rendering","stage":"Capturing frames","message":"Capturing frames","framesRendered":120,"totalFrames":300,"failedStage":null} +``` + +So the stream stays parseable, human logs move out of the way: plan summaries, +lint findings, and completion prints are suppressed on stdout (exactly the rule +`--batch --json` already applies), while warnings and error boxes continue to +go to stderr. A pipe such as `hyperframes render --progress-format ndjson | jq` +therefore sees only JSON. + +### Event types + +Every event carries `type`, `ts` (ISO-8601), `progress` (fraction, `0`–`1`), +`status` (the producer job status: `queued`, `preprocessing`, `rendering`, +`encoding`, `assembling`, `complete`, `failed`, `cancelled`), `stage` (the +human stage label), `message`, `framesRendered`, `totalFrames`, and +`failedStage` (`null` until a failure). + +- **`render.progress`** — a tick while the pipeline runs. +- **`render.completed`** — terminal success. `progress` is `1`. +- **`render.failed`** — terminal failure. Additionally carries `error` (the + failure message), `failedStage` (which pipeline stage failed), and + `errorDetails` — the producer's structured failure report (message, elapsed + time, free memory, browser console tail, per-stage timings, observability + summary) or `null` when the render failed before a job existed. + +Exactly one terminal event (`render.completed` or `render.failed`) closes each +render's stream. A consumer can treat "terminal event or EOF" as the end of +the render. + +### Batch renders + +With `--batch`, every row's events are stamped with the row index, so one +stream can multiplex concurrent rows: + +```json +{"type":"render.progress","ts":"...","progress":0.8,"status":"encoding","stage":"Encoding video","message":"Encoding video","framesRendered":300,"totalFrames":300,"failedStage":null,"row":2} +``` + +Each row emits its own terminal event. + +### `--progress-fd`: keep stdout for something else + +`--progress-fd N` writes the event stream to an inherited file descriptor +instead of stdout: + +```bash +# Human output on stdout, events into a file via fd 3 +hyperframes render --progress-format ndjson --progress-fd 3 -o out.mp4 3>events.ndjson + +# Combine with --batch --json: one final JSON document on stdout, live events on fd 3 +hyperframes render --batch rows.json --json --progress-format ndjson --progress-fd 3 3>events.ndjson +``` + +With `--progress-fd`, human stdout output is *not* suppressed — the stream no +longer owns stdout. + +### Flag interactions + +- `--progress-format ndjson` + `--docker` is rejected: the containerized + render's output is opaque to the host CLI. Run the CLI inside the container + with the flag instead. +- `--progress-format ndjson` + `--json` (batch) is rejected unless the stream + is redirected with `--progress-fd`, because `--json` promises exactly one + JSON document on stdout. +- `--progress-fd` requires `--progress-format ndjson`. +- If the consumer of the stream goes away mid-render (EPIPE), the render keeps + going; the stream just stops. + +## Consuming the stream + +Live stage/percentage feed: + +```bash +hyperframes render --progress-format ndjson -o out.mp4 \ + | jq -r '"\(.type) \(.progress * 100 | floor)% \(.stage) \(.framesRendered)/\(.totalFrames)"' +``` + +Wait for the terminal event and fail the script on `render.failed`: + +```bash +hyperframes render --progress-format ndjson -o out.mp4 \ + | jq -e 'select(.type == "render.failed" or .type == "render.completed") + | if .type == "render.failed" then ("\(.failedStage): \(.error)\n" | halt_error(1)) else . end' +``` + +For the agent-oriented walkthrough (polling from a wrapper process, extracting +`errorDetails` for self-repair), see [Rendering for agents](/guides/agents). diff --git a/packages/cli/src/commands/render.progressNdjson.test.ts b/packages/cli/src/commands/render.progressNdjson.test.ts new file mode 100644 index 0000000000..a19d8433db --- /dev/null +++ b/packages/cli/src/commands/render.progressNdjson.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CliUsageError } from "../utils/commandResult.js"; +import { ProgressNdjsonWriter, type NdjsonRenderJobView } from "../ui/progressNdjson.js"; +import { createRenderPlan } from "./render/plan.js"; +import { resolveRenderProgressCallback } from "./render.js"; + +function job(overrides: Partial = {}): NdjsonRenderJobView { + return { + status: "rendering", + progress: 40, + currentStage: "Capturing frames", + framesRendered: 80, + totalFrames: 200, + ...overrides, + }; +} + +describe("resolveRenderProgressCallback", () => { + it("streams NDJSON events through the writer when one is active", () => { + const lines: string[] = []; + const writer = new ProgressNdjsonWriter({ + sink: (line) => { + lines.push(line); + }, + now: () => new Date(0), + }); + + const onProgress = resolveRenderProgressCallback({ quiet: false, progressNdjson: writer }); + expect(onProgress).toBeDefined(); + onProgress?.(job(), "Capturing frames"); + + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? "")).toMatchObject({ + type: "render.progress", + progress: 0.4, + stage: "Capturing frames", + }); + }); + + it("keeps the NDJSON stream alive under --quiet (quiet silences human output only)", () => { + const lines: string[] = []; + const writer = new ProgressNdjsonWriter({ + sink: (line) => { + lines.push(line); + }, + }); + + const onProgress = resolveRenderProgressCallback({ quiet: true, progressNdjson: writer }); + expect(onProgress).toBeDefined(); + onProgress?.(job(), "Capturing frames"); + + expect(lines).toHaveLength(1); + }); + + it("disables progress entirely for --progress-format none", () => { + expect(resolveRenderProgressCallback({ quiet: false, progressFormat: "none" })).toBeUndefined(); + }); + + it("keeps the legacy behavior: --quiet disables the tty bar", () => { + expect(resolveRenderProgressCallback({ quiet: true })).toBeUndefined(); + }); + + it("falls back to the tty progress bar by default", () => { + const originalWrite = process.stdout.write.bind(process.stdout); + const originalIsTTY = process.stdout.isTTY; + let output = ""; + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + process.stdout.write = ((chunk: string | Uint8Array) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write; + try { + const onProgress = resolveRenderProgressCallback({ quiet: false }); + expect(onProgress).toBeDefined(); + onProgress?.(job(), "Capturing frames"); + } finally { + process.stdout.write = originalWrite; + Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true }); + } + expect(output).toContain("Capturing frames"); + expect(() => JSON.parse(output)).toThrow(); + }); + + it("emits an ordered stream ending in exactly one terminal event", () => { + const lines: string[] = []; + const writer = new ProgressNdjsonWriter({ + sink: (line) => { + lines.push(line); + }, + }); + const onProgress = resolveRenderProgressCallback({ quiet: false, progressNdjson: writer }); + + // Replay the tick sequence a producer render drives through its + // ProgressCallback, including a stray tick after the terminal failure. + onProgress?.(job({ status: "preprocessing", progress: 5 }), "Compiling composition"); + onProgress?.(job({ progress: 55 }), "Capturing frames"); + onProgress?.( + job({ + status: "failed", + progress: 55, + error: "Chrome crashed", + failedStage: "Capturing frames", + errorDetails: { message: "Chrome crashed", elapsedMs: 5000, freeMemoryMB: 1024 }, + }), + "Failed: Chrome crashed", + ); + onProgress?.(job({ progress: 60 }), "stray teardown tick"); + + const events = lines.map((line) => JSON.parse(line) as Record); + expect(events.map((event) => event.type)).toEqual([ + "render.progress", + "render.progress", + "render.failed", + ]); + expect(events[2]).toMatchObject({ + failedStage: "Capturing frames", + errorDetails: { message: "Chrome crashed", elapsedMs: 5000, freeMemoryMB: 1024 }, + }); + }); +}); + +describe("createRenderPlan progress flags", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "hf-render-ndjson-plan-")); + writeFileSync( + join(projectDir, "index.html"), + '
', + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(projectDir, { recursive: true, force: true }); + }); + + it("defaults to tty with no fd", () => { + const plan = createRenderPlan({ dir: projectDir }); + expect(plan.progressFormat).toBe("tty"); + expect(plan.progressFd).toBeUndefined(); + expect(plan.effectiveQuiet).toBe(false); + }); + + it("resolves ndjson with an explicit fd", () => { + const plan = createRenderPlan({ + dir: projectDir, + "progress-format": "ndjson", + "progress-fd": "3", + }); + expect(plan.progressFormat).toBe("ndjson"); + expect(plan.progressFd).toBe(3); + }); + + it("suppresses human stdout output when the NDJSON stream owns stdout", () => { + const plan = createRenderPlan({ dir: projectDir, "progress-format": "ndjson" }); + expect(plan.effectiveQuiet).toBe(true); + // The user's own --quiet remains unset; only presentation is muted. + expect(plan.quiet).toBe(false); + }); + + it("keeps human stdout output when the stream is redirected to an fd", () => { + const plan = createRenderPlan({ + dir: projectDir, + "progress-format": "ndjson", + "progress-fd": "3", + }); + expect(plan.effectiveQuiet).toBe(false); + }); + + it("rejects unknown progress formats as usage errors", () => { + expect(() => createRenderPlan({ dir: projectDir, "progress-format": "json" })).toThrow( + CliUsageError, + ); + }); + + it("rejects --progress-fd without --progress-format ndjson", () => { + expect(() => createRenderPlan({ dir: projectDir, "progress-fd": "3" })).toThrow(CliUsageError); + }); + + it("rejects a non-integer --progress-fd", () => { + expect(() => + createRenderPlan({ dir: projectDir, "progress-format": "ndjson", "progress-fd": "three" }), + ).toThrow(CliUsageError); + }); + + it("rejects ndjson with --docker", () => { + expect(() => + createRenderPlan({ dir: projectDir, "progress-format": "ndjson", docker: true }), + ).toThrow(CliUsageError); + }); + + it("rejects ndjson on stdout combined with --batch --json", () => { + expect(() => + createRenderPlan({ + dir: projectDir, + batch: "rows.json", + json: true, + "progress-format": "ndjson", + }), + ).toThrow(CliUsageError); + }); + + it("allows ndjson alongside --batch --json when redirected to an fd", () => { + const plan = createRenderPlan({ + dir: projectDir, + batch: "rows.json", + json: true, + "progress-format": "ndjson", + "progress-fd": "3", + }); + expect(plan.progressFormat).toBe("ndjson"); + expect(plan.progressFd).toBe(3); + }); +}); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index f53af70355..5e18e2a648 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -29,6 +29,10 @@ export const examples: Example[] = [ ], ["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"], ["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"], + [ + "Stream machine-readable progress for agents/CI (NDJSON on stdout)", + "hyperframes render --progress-format ndjson --output out.mp4 | jq -r '.stage'", + ], ["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"], ["Opt out of browser GPU render", "hyperframes render --no-browser-gpu --output cpu.mp4"], [ @@ -57,6 +61,11 @@ import { c } from "../ui/colors.js"; import { formatBytes, formatRenderSummaryDetail, errorBox } from "../ui/format.js"; import { warnIfWebmAlphaDropped } from "../utils/webmAlphaCheck.js"; import { renderProgress } from "../ui/progress.js"; +import type { + NdjsonRenderJobView, + ProgressFormat, + ProgressNdjsonWriter, +} from "../ui/progressNdjson.js"; import { trackRenderComplete, trackRenderError, @@ -350,6 +359,23 @@ export default defineCommand({ "/hyperframes-extract-cache-. " + "Env: HYPERFRAMES_EXTRACT_CACHE_DIR.", }, + "progress-format": { + type: "string", + description: + "Progress output: tty (interactive bar), ndjson (one JSON event per " + + "line on stdout for agents/CI — render.progress ticks plus a terminal " + + "render.completed / render.failed; human stdout logs are suppressed " + + "and diagnostics stay on stderr), none (no progress output, like " + + "--quiet but for progress only). Default: tty.", + default: "tty", + }, + "progress-fd": { + type: "string", + description: + "Write --progress-format ndjson events to this inherited file " + + "descriptor instead of stdout (e.g. 3 with a `3>events.ndjson` shell " + + "redirect), keeping stdout free for human logs or --json.", + }, }, // Keep the transport adapter thin: each phase has one ownership boundary. async run({ args }) { @@ -451,6 +477,19 @@ export interface RenderOptions { * with process-wide state. */ manageDeParallelRouterBreaker?: boolean; + /** + * Progress presentation (`--progress-format`). Omitted means the legacy + * behavior: TTY bar unless `quiet`. `"none"` suppresses progress without + * touching the rest of the human output; `"ndjson"` requires + * `progressNdjson` to carry the stream. + */ + progressFormat?: ProgressFormat; + /** + * Active NDJSON event writer when `progressFormat === "ndjson"`. One writer + * per render (batch rows each get their own, stamped with the row index) so + * terminal-event dedupe is scoped to the row. + */ + progressNdjson?: ProgressNdjsonWriter; } /** @@ -900,11 +939,7 @@ export async function renderLocal( }); const job = producer.createRenderJob(producer.renderConfigFromRequest(request, { logger })); - const onProgress = options.quiet - ? undefined - : (progressJob: { progress: number }, message: string) => { - renderProgress(progressJob.progress, message); - }; + const onProgress = resolveRenderProgressCallback(options); try { await producer.executeRenderJob(job, projectDir, outputPath, onProgress); @@ -931,6 +966,10 @@ export async function renderLocal( // (win32/x64, CLI 0.7.58): valid MP4 on disk, exited 1 with no error print. markRenderSucceeded(); + // Guarantee the stream's terminal event even if the producer's own + // "complete" tick never reached the callback; the writer dedupes when it did. + options.progressNdjson?.completed(job); + maybeConsumeDeParallelRouterTrial(deParallelRouterActive, job, options.quiet); const elapsed = Date.now() - startTime; if (job.outcome === "completed_with_warnings") { @@ -973,6 +1012,28 @@ export async function renderLocal( }; } +/** + * Resolve the producer progress sink for one render. Precedence: an active + * NDJSON writer always streams (even under --quiet — quiet silences HUMAN + * output, not the machine contract an agent is parsing); otherwise --quiet + * and --progress-format none disable progress; otherwise the TTY bar runs. + * Exported for the command-level wiring tests (no browser required). + */ +export function resolveRenderProgressCallback( + options: Pick, +): ((job: NdjsonRenderJobView, message: string) => void) | undefined { + const ndjson = options.progressNdjson; + if (ndjson) { + return (job, message) => { + ndjson.publish(job, message); + }; + } + if (options.quiet || options.progressFormat === "none") return undefined; + return (job, message) => { + renderProgress(job.progress, message); + }; +} + type UnrefableTimer = { unref: () => void; }; @@ -1418,6 +1479,10 @@ function handleRenderError( job?: RenderJob, ): never { const message = normalizeErrorMessage(error); + // Terminal failure event first, so the stream carries failedStage / + // errorDetails even when throwOnError short-circuits the human reporting + // below. Deduped when the producer already published its failed tick. + options.progressNdjson?.failed(message, job); trackRenderError({ fps: fpsToNumber(options.fps), quality: options.quality, diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index 83fa15942c..54fb629b2b 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -17,7 +17,18 @@ import { validateVariablesAgainstProject, } from "../../utils/variables.js"; import { trackRenderPreflightRejected } from "../../telemetry/events.js"; -import { applyRenderEnvironment, renderOutputDirectory, type RenderPlan } from "./plan.js"; +import { + ProgressNdjsonWriter, + createNdjsonSink, + redirectConsoleStdoutToStderr, + type NdjsonSink, +} from "../../ui/progressNdjson.js"; +import { + applyRenderEnvironment, + progressNdjsonOwnsStdout, + renderOutputDirectory, + type RenderPlan, +} from "./plan.js"; import type { RenderOptions, SingleRenderResult } from "../render.js"; type RenderExecutor = ( @@ -86,8 +97,25 @@ export async function executeRenderPlan( await runRenderLint(plan); await runResolutionPreflight(plan, dependencies.checkResolution); + // One shared sink per command; each render (batch row) gets its own writer + // so terminal-event dedupe stays scoped to the row it reports on. + const ndjsonSink = + plan.progressFormat === "ndjson" ? createNdjsonSink(plan.progressFd) : undefined; + // The event stream owning stdout implies quiet HUMAN output on stdout — + // same contract --batch --json already enforces via effectiveQuiet. + const humanQuiet = plan.quiet || (ndjsonSink !== undefined && progressNdjsonOwnsStdout(plan)); + // Quiet only mutes the CLI's own prints; engine/producer diagnostics that + // write via console.log directly still land on stdout. Reroute them to + // stderr so the pipe carries nothing but NDJSON. + if (ndjsonSink !== undefined && progressNdjsonOwnsStdout(plan)) { + redirectConsoleStdoutToStderr(); + } + if (plan.batchPath && batchModule && preparedBatch) { - await executeBatchRender(plan, browserPath, batchModule, preparedBatch, dependencies); + await executeBatchRender(plan, browserPath, batchModule, preparedBatch, dependencies, { + ndjsonSink, + humanQuiet, + }); return; } @@ -112,7 +140,7 @@ export async function executeRenderPlan( vp9CpuUsed: plan.vp9CpuUsed, videoBitrate: plan.videoBitrate, videoFrameFormat: plan.videoFrameFormat, - quiet: plan.quiet, + quiet: humanQuiet, browserPath, debug: plan.debug, bestEffort: plan.bestEffort, @@ -126,6 +154,8 @@ export async function executeRenderPlan( playerReadyTimeout: plan.playerReadyTimeout, exitAfterComplete: true, manageDeParallelRouterBreaker: true, + progressFormat: plan.progressFormat, + progressNdjson: ndjsonSink ? new ProgressNdjsonWriter({ sink: ndjsonSink }) : undefined, }; if (plan.useDocker) { options.pageSideCompositing = plan.pageSideCompositing; @@ -249,8 +279,9 @@ async function executeBatchRender( batchModule: typeof import("../batchRender.js"), preparedBatch: import("../batchRender.js").PreparedBatchRender, dependencies: RenderExecutionDependencies, + progress: { ndjsonSink: NdjsonSink | undefined; humanQuiet: boolean }, ): Promise { - const batchQuiet = plan.quiet || plan.batchJson; + const batchQuiet = progress.humanQuiet || plan.batchJson; const renderOptionsBase: RenderOptions = { fps: plan.fps, quality: plan.quality, @@ -281,6 +312,7 @@ async function executeBatchRender( throwOnError: true, skipFeedback: true, manageDeParallelRouterBreaker: plan.batchConcurrency <= 1, + progressFormat: plan.progressFormat, }; const manifest = await batchModule.runBatchRender({ prepared: preparedBatch, @@ -289,7 +321,13 @@ async function executeBatchRender( quiet: batchQuiet, json: plan.batchJson, renderOne: (row) => { - const options: RenderOptions = { ...renderOptionsBase, variables: row.variables }; + const options: RenderOptions = { + ...renderOptionsBase, + variables: row.variables, + progressNdjson: progress.ndjsonSink + ? new ProgressNdjsonWriter({ sink: progress.ndjsonSink, row: row.index }) + : undefined, + }; if (plan.useDocker) options.pageSideCompositing = plan.pageSideCompositing; const execute = plan.useDocker ? dependencies.renderDocker : dependencies.renderLocal; return execute(plan.project.dir, row.outputPath, options); diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 600a267760..960baba5ef 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -18,6 +18,11 @@ import { type VideoFrameFormat, } from "@hyperframes/engine"; import { errorBox } from "../../ui/format.js"; +import { + PROGRESS_FORMAT_LABEL, + parseProgressFormat, + type ProgressFormat, +} from "../../ui/progressNdjson.js"; import { failUsage } from "../../utils/commandResult.js"; import { resolveProject } from "../../utils/project.js"; import { @@ -90,6 +95,8 @@ export interface RenderCommandArgs { "low-memory-mode"?: boolean; "experimental-fast-capture"?: boolean; "frames-cache-dir"?: string; + "progress-format"?: string; + "progress-fd"?: string; } export interface RenderPlan { @@ -137,6 +144,8 @@ export interface RenderPlan { variablesArg?: string; variablesFileArg?: string; strictVariables: boolean; + progressFormat: ProgressFormat; + progressFd?: number; environment: Readonly>; } @@ -411,6 +420,12 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren const quiet = args.quiet ?? false; const batchJson = args.json ?? false; + const { progressFormat, progressFd } = resolveProgressPlan(args, useDocker, batchJson); + // NDJSON owning stdout implies quiet human output: the event stream is the + // machine contract, so plan summaries / lint findings / completion prints + // must not interleave with it (same rule --batch --json already applies). + // console.warn/console.error diagnostics still reach stderr. + const ndjsonOwnsStdout = progressFormat === "ndjson" && progressFd === undefined; return Object.freeze({ project, entryFile, @@ -443,7 +458,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren debug: args.debug ?? false, bestEffort: args["best-effort"] ?? true, batchJson, - effectiveQuiet: quiet || (batchPath != null && batchJson), + effectiveQuiet: quiet || (batchPath != null && batchJson) || ndjsonOwnsStdout, strictAll: args["strict-all"] ?? false, strictErrors: (args.strict ?? false) || (args["strict-all"] ?? false), crf, @@ -455,10 +470,66 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren variablesArg: args.variables, variablesFileArg: args["variables-file"], strictVariables: args["strict-variables"] ?? false, + progressFormat, + progressFd, environment: Object.freeze(environment), }); } +/** True when the NDJSON event stream owns stdout (no `--progress-fd` redirect). */ +export function progressNdjsonOwnsStdout(plan: RenderPlan): boolean { + return plan.progressFormat === "ndjson" && plan.progressFd === undefined; +} + +interface ResolvedProgressPlan { + progressFormat: ProgressFormat; + progressFd?: number; +} + +/** Validate the machine-progress flags and their interactions with other modes. */ +function resolveProgressPlan( + args: RenderCommandArgs, + useDocker: boolean, + batchJson: boolean, +): ResolvedProgressPlan { + const raw = args["progress-format"] ?? "tty"; + const progressFormat = parseProgressFormat(raw); + if (!progressFormat) { + errorBox("Invalid progress-format", `Got "${raw}". Must be ${PROGRESS_FORMAT_LABEL}.`); + failUsage(); + } + let progressFd: number | undefined; + if (args["progress-fd"] != null) { + if (progressFormat !== "ndjson") { + errorBox("Invalid progress-fd", "--progress-fd requires --progress-format ndjson."); + failUsage(); + } + progressFd = positiveInteger( + args["progress-fd"], + "Invalid progress-fd", + `Got "${args["progress-fd"]}". Must be a positive file descriptor number (e.g. 3).`, + ); + } + if (progressFormat !== "ndjson") return { progressFormat }; + if (useDocker) { + errorBox( + "NDJSON progress is local-only", + "--progress-format ndjson streams the local render pipeline's progress events; --docker runs the render inside a container whose output is opaque to the host CLI.", + "Drop --docker, or run the containerized CLI directly with --progress-format ndjson.", + ); + failUsage(); + } + if (batchJson && progressFd === undefined) { + errorBox( + "Conflicting stdout formats", + "--json promises exactly one final JSON document on stdout; NDJSON progress on stdout would interleave with it.", + "Route the event stream to another descriptor: --progress-fd 3 (e.g. `hyperframes render ... 3>progress.ndjson`).", + ); + failUsage(); + } + return { progressFormat, progressFd }; +} + export function applyRenderEnvironment(plan: RenderPlan): void { for (const [key, value] of Object.entries(plan.environment)) process.env[key] = value; } diff --git a/packages/cli/src/ui/progressNdjson.test.ts b/packages/cli/src/ui/progressNdjson.test.ts new file mode 100644 index 0000000000..38903c99c0 --- /dev/null +++ b/packages/cli/src/ui/progressNdjson.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; + +import { + ProgressNdjsonWriter, + createNdjsonSink, + parseProgressFormat, + redirectConsoleStdoutToStderr, + type NdjsonRenderJobView, +} from "./progressNdjson.js"; + +const FIXED_NOW = () => new Date("2026-09-08T00:00:00.000Z"); + +function collectingWriter(row?: number): { writer: ProgressNdjsonWriter; lines: string[] } { + const lines: string[] = []; + const writer = new ProgressNdjsonWriter({ + sink: (line) => { + lines.push(line); + }, + row, + now: FIXED_NOW, + }); + return { writer, lines }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseEvents(lines: string[]): Record[] { + return lines.map((line) => { + expect(line.endsWith("\n")).toBe(true); + const parsed: unknown = JSON.parse(line); + if (!isRecord(parsed)) throw new Error(`NDJSON line is not an object: ${line}`); + return parsed; + }); +} + +function firstEvent(lines: string[]): Record { + const [event] = parseEvents(lines); + if (!event) throw new Error("expected at least one NDJSON event"); + return event; +} + +function job(overrides: Partial = {}): NdjsonRenderJobView { + return { + status: "rendering", + progress: 42, + currentStage: "Capturing frames", + framesRendered: 120, + totalFrames: 300, + ...overrides, + }; +} + +describe("parseProgressFormat", () => { + it("accepts the three documented formats", () => { + expect(parseProgressFormat("tty")).toBe("tty"); + expect(parseProgressFormat("ndjson")).toBe("ndjson"); + expect(parseProgressFormat("none")).toBe("none"); + }); + + it("rejects anything else", () => { + expect(parseProgressFormat("json")).toBeUndefined(); + expect(parseProgressFormat("")).toBeUndefined(); + expect(parseProgressFormat("NDJSON")).toBeUndefined(); + }); +}); + +describe("ProgressNdjsonWriter", () => { + it("emits one newline-terminated render.progress object per tick", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job(), "Capturing frames"); + + const event = firstEvent(lines); + expect(event).toEqual({ + type: "render.progress", + ts: "2026-09-08T00:00:00.000Z", + progress: 0.42, + status: "rendering", + stage: "Capturing frames", + message: "Capturing frames", + framesRendered: 120, + totalFrames: 300, + failedStage: null, + }); + }); + + it("preserves publish order across ticks", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job({ status: "preprocessing", progress: 5 }), "Compiling"); + writer.publish(job({ progress: 50 }), "Capturing frames"); + writer.publish(job({ status: "encoding", progress: 90 }), "Encoding"); + + const events = parseEvents(lines); + expect(events.map((event) => event.progress)).toEqual([0.05, 0.5, 0.9]); + expect(events.map((event) => event.status)).toEqual(["preprocessing", "rendering", "encoding"]); + }); + + it("maps a terminal complete tick to render.completed", () => { + const { writer, lines } = collectingWriter(); + + writer.publish( + job({ status: "complete", progress: 100, framesRendered: 300 }), + "Render complete", + ); + + const event = firstEvent(lines); + expect(event.type).toBe("render.completed"); + expect(event.progress).toBe(1); + expect(event.framesRendered).toBe(300); + }); + + it("maps a terminal failed tick to render.failed with the producer failure contract", () => { + const { writer, lines } = collectingWriter(); + + writer.publish( + job({ + status: "failed", + progress: 61, + error: "Chrome crashed", + failedStage: "Capturing frames", + errorDetails: { message: "Chrome crashed", elapsedMs: 1234, freeMemoryMB: 900 }, + }), + "Failed: Chrome crashed", + ); + + const event = firstEvent(lines); + expect(event).toMatchObject({ + type: "render.failed", + status: "failed", + progress: 0.61, + error: "Chrome crashed", + failedStage: "Capturing frames", + errorDetails: { message: "Chrome crashed", elapsedMs: 1234, freeMemoryMB: 900 }, + }); + }); + + it("emits render.failed even when no job ever existed", () => { + const { writer, lines } = collectingWriter(); + + writer.failed("Chrome not found"); + + const event = firstEvent(lines); + expect(event).toMatchObject({ + type: "render.failed", + progress: 0, + stage: "pipeline", + error: "Chrome not found", + failedStage: null, + errorDetails: null, + }); + }); + + it("emits exactly one terminal event per writer", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job({ status: "failed", error: "boom", failedStage: "Encoding" }), "Failed"); + writer.completed(job()); + writer.failed("boom again"); + writer.publish(job(), "late tick"); + + const events = parseEvents(lines); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("render.failed"); + }); + + it("does not emit events after the completed terminal event", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job(), "Capturing frames"); + writer.completed(job({ framesRendered: 300 })); + writer.publish(job(), "stray teardown tick"); + writer.completed(job()); + + const events = parseEvents(lines); + expect(events.map((event) => event.type)).toEqual(["render.progress", "render.completed"]); + }); + + it("latches after a cancelled tick", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job({ status: "cancelled" }), "Cancelled"); + writer.publish(job(), "stray tick"); + + const events = parseEvents(lines); + expect(events).toHaveLength(1); + expect(events[0]?.status).toBe("cancelled"); + }); + + it("stamps the batch row index on every event", () => { + const { writer, lines } = collectingWriter(7); + + writer.publish(job(), "Capturing frames"); + writer.completed(job()); + + const events = parseEvents(lines); + expect(events.map((event) => event.row)).toEqual([7, 7]); + }); + + it("omits the row field outside batch renders", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job(), "Capturing frames"); + + const event = firstEvent(lines); + expect("row" in event).toBe(false); + }); + + it("clamps out-of-range progress into the 0-1 fraction", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job({ progress: -5 }), "warming up"); + writer.publish(job({ progress: 250 }), "overshoot"); + + const events = parseEvents(lines); + expect(events.map((event) => event.progress)).toEqual([0, 1]); + }); + + it("defaults missing frame counters to zero", () => { + const { writer, lines } = collectingWriter(); + + writer.publish(job({ framesRendered: undefined, totalFrames: undefined }), "starting"); + + const event = firstEvent(lines); + expect(event.framesRendered).toBe(0); + expect(event.totalFrames).toBe(0); + }); + + it("disables the stream instead of throwing when the sink breaks", () => { + const lines: string[] = []; + let calls = 0; + const writer = new ProgressNdjsonWriter({ + sink: (line) => { + calls++; + if (calls > 1) throw new Error("EPIPE"); + lines.push(line); + }, + now: FIXED_NOW, + }); + + writer.publish(job(), "tick 1"); + expect(() => writer.publish(job(), "tick 2")).not.toThrow(); + writer.publish(job(), "tick 3"); + + expect(lines).toHaveLength(1); + expect(calls).toBe(2); + }); +}); + +describe("redirectConsoleStdoutToStderr", () => { + it("reroutes the stdout-bound console channels to console.error", () => { + const calls: unknown[][] = []; + const fake = { + log: (..._args: unknown[]) => {}, + info: (..._args: unknown[]) => {}, + debug: (..._args: unknown[]) => {}, + error: (...args: unknown[]) => { + calls.push(args); + }, + }; + + redirectConsoleStdoutToStderr(fake); + fake.log("[BrowserManager] Browser launched"); + fake.info("info line"); + fake.debug("debug line"); + + expect(calls).toEqual([["[BrowserManager] Browser launched"], ["info line"], ["debug line"]]); + }); +}); + +describe("createNdjsonSink", () => { + it("writes to stdout when no fd is given", () => { + const originalWrite = process.stdout.write.bind(process.stdout); + let output = ""; + process.stdout.write = ((chunk: string | Uint8Array) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write; + try { + createNdjsonSink()('{"type":"render.progress"}\n'); + } finally { + process.stdout.write = originalWrite; + } + expect(output).toBe('{"type":"render.progress"}\n'); + }); +}); diff --git a/packages/cli/src/ui/progressNdjson.ts b/packages/cli/src/ui/progressNdjson.ts new file mode 100644 index 0000000000..c78d2d842f --- /dev/null +++ b/packages/cli/src/ui/progressNdjson.ts @@ -0,0 +1,202 @@ +import { writeSync } from "node:fs"; + +export const PROGRESS_FORMATS = ["tty", "ndjson", "none"] as const; +export type ProgressFormat = (typeof PROGRESS_FORMATS)[number]; +export const PROGRESS_FORMAT_LABEL = "tty, ndjson, or none"; + +export function parseProgressFormat(input: string): ProgressFormat | undefined { + return PROGRESS_FORMATS.find((format) => format === input); +} + +/** + * Structural view of the producer's `RenderJob` limited to the fields the + * NDJSON stream serializes. Kept structural (rather than importing the + * producer type) so the writer stays unit-testable with plain objects and + * `@hyperframes/producer` stays out of this module's import graph. + */ +export interface NdjsonRenderJobView { + status: string; + /** Pipeline progress in percent (0–100), as maintained by the producer. */ + progress: number; + currentStage: string; + framesRendered?: number; + totalFrames?: number; + failedStage?: string; + error?: string; + errorDetails?: unknown; +} + +/** One line-oriented output the writer appends `\n`-terminated JSON to. */ +export type NdjsonSink = (line: string) => void; + +/** + * Sink for `--progress-format ndjson`: stdout by default, or an inherited + * file descriptor via `--progress-fd N` (so human stdout stays usable). + * A sink failure (e.g. EPIPE when the consumer exits early) disables the + * stream instead of crashing the render — mirroring the producer's + * OrderedRenderEventPublisher, which contains sink failures at the boundary. + */ +export function createNdjsonSink(fd?: number): NdjsonSink { + if (fd === undefined) { + return (line: string) => { + process.stdout.write(line); + }; + } + return (line: string) => { + writeSync(fd, line); + }; +} + +/** + * When the NDJSON stream owns stdout, every other stdout writer in the + * process has to move to stderr or the pipe stops being parseable. The CLI's + * own prints are already quiet-suppressed, but library diagnostics (e.g. the + * engine's `[BrowserManager] Browser launched` line) write via `console.log` + * directly — reroute the stdout-bound console channels to stderr for the + * remainder of the process. The stream itself is unaffected: it writes + * through `process.stdout.write`, not the console. + */ +interface StdoutBoundConsole { + log: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +export function redirectConsoleStdoutToStderr(target: StdoutBoundConsole = console): void { + // console.error targets stderr regardless of the original channel. + const toStderr = (...args: unknown[]): void => { + target.error(...args); + }; + target.log = toStderr; + target.info = toStderr; + target.debug = toStderr; +} + +export interface ProgressNdjsonWriterOptions { + sink: NdjsonSink; + /** Batch row index stamped onto every event of this render. */ + row?: number; + /** Injectable clock for deterministic tests. */ + now?: () => Date; +} + +/** Convert the producer's 0–100 percentage into a 0–1 fraction. */ +function progressFraction(progressPercent: number): number { + const bounded = Math.max(0, Math.min(100, progressPercent)); + return Math.round(bounded * 100) / 10000; +} + +function frameCount(value: number | undefined): number { + return value ?? 0; +} + +/** + * Streams machine-readable render progress as NDJSON: one JSON object per + * line, one line per progress tick. Three event types: + * + * - `render.progress` — a progress tick while the pipeline runs. + * - `render.completed` — terminal success. + * - `render.failed` — terminal failure, carrying `failedStage` and the + * producer's structured `errorDetails`. + * + * Exactly one terminal event is emitted per writer (= per render / batch + * row); anything published after it is dropped, so the double-report paths — + * the producer's own terminal status tick plus the CLI's failure handler — + * cannot duplicate the terminal line. + */ +export class ProgressNdjsonWriter { + private readonly sink: NdjsonSink; + private readonly row: number | undefined; + private readonly now: () => Date; + private terminal = false; + private sinkBroken = false; + + constructor(options: ProgressNdjsonWriterOptions) { + this.sink = options.sink; + this.row = options.row; + this.now = options.now ?? (() => new Date()); + } + + /** ProgressCallback-compatible entry: routes ticks to the right event type. */ + publish(job: NdjsonRenderJobView, message: string): void { + if (job.status === "failed") { + this.failed(job.error ?? message, job); + return; + } + if (job.status === "complete") { + this.completed(job, message); + return; + } + if (this.terminal) return; + this.write({ + type: "render.progress", + ts: this.now().toISOString(), + progress: progressFraction(job.progress), + status: job.status, + stage: job.currentStage, + message, + framesRendered: frameCount(job.framesRendered), + totalFrames: frameCount(job.totalFrames), + failedStage: null, + ...(this.row !== undefined ? { row: this.row } : {}), + }); + // A cancelled render never reaches a completed/failed terminal event; + // latch after reporting the cancellation tick so teardown noise can't + // trail it on the stream. + if (job.status === "cancelled") this.terminal = true; + } + + /** Terminal success event. Safe to call after the producer's own tick. */ + completed(job: NdjsonRenderJobView, message = "Render complete"): void { + if (this.terminal) return; + this.terminal = true; + this.write({ + type: "render.completed", + ts: this.now().toISOString(), + progress: 1, + status: "complete", + stage: job.currentStage, + message, + framesRendered: frameCount(job.framesRendered), + totalFrames: frameCount(job.totalFrames), + failedStage: null, + ...(this.row !== undefined ? { row: this.row } : {}), + }); + } + + /** + * Terminal failure event including the producer's failure contract + * (`failedStage` + `errorDetails`). `job` is optional so failures that + * never produced a job (preflight, environment) still emit a terminal line. + */ + failed(error: string, job?: NdjsonRenderJobView): void { + if (this.terminal) return; + this.terminal = true; + this.write({ + type: "render.failed", + ts: this.now().toISOString(), + progress: progressFraction(job?.progress ?? 0), + status: "failed", + stage: job?.currentStage ?? "pipeline", + message: error, + error, + framesRendered: frameCount(job?.framesRendered), + totalFrames: frameCount(job?.totalFrames), + failedStage: job?.failedStage ?? job?.currentStage ?? null, + errorDetails: job?.errorDetails ?? null, + ...(this.row !== undefined ? { row: this.row } : {}), + }); + } + + private write(event: Record): void { + if (this.sinkBroken) return; + try { + this.sink(JSON.stringify(event) + "\n"); + } catch { + // The consumer went away (EPIPE) or the fd is invalid. The render + // itself must keep going; stop emitting rather than crash. + this.sinkBroken = true; + } + } +}