diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 6c20afb04..2c2ed2724 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -55,7 +55,7 @@ import { lintProject, shouldBlockRender } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { loadProducer } from "../utils/producer.js"; import { c } from "../ui/colors.js"; -import { formatBytes, formatDuration, errorBox } from "../ui/format.js"; +import { formatBytes, formatRenderSummaryDetail, errorBox } from "../ui/format.js"; import { renderProgress } from "../ui/progress.js"; import { trackRenderComplete, @@ -1227,6 +1227,9 @@ async function renderDocker( ...getMemorySnapshot(), }); + // ponytail: Docker runs the producer in a child process, so no perfSummary is + // threaded back here; the summary shows render time only (never a wrong video + // length). Probe the output with ffprobe if a duration figure is wanted here. printRenderComplete(outputPath, elapsed, options.quiet); if (options.exitAfterComplete) scheduleRenderProcessExit(); return { renderTimeMs: elapsed }; @@ -1323,7 +1326,13 @@ export async function renderLocal( const elapsed = Date.now() - startTime; trackRenderMetrics(job, elapsed, options, false); - printRenderComplete(outputPath, elapsed, options.quiet); + printRenderComplete( + outputPath, + elapsed, + options.quiet, + job.perfSummary?.compositionDurationSeconds, + job.perfSummary?.totalFrames, + ); if (!options.skipFeedback) { await maybePromptRenderFeedback({ renderDurationMs: elapsed, @@ -1546,12 +1555,20 @@ function trackRenderMetrics( }); } -function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boolean): void { +function printRenderComplete( + outputPath: string, + elapsedMs: number, + quiet: boolean, + outputDurationSeconds?: number, + frameCount?: number, +): void { if (quiet) return; let fileSize = "unknown"; + let isDirectory = false; try { const stat = statSync(outputPath); + isDirectory = stat.isDirectory(); if (stat.isDirectory()) { // png-sequence output is a directory; sum the contained file sizes so // the user sees the on-disk footprint of the deliverable rather than @@ -1573,8 +1590,13 @@ function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boole // file doesn't exist or is inaccessible } - const duration = formatDuration(elapsedMs); + const detail = formatRenderSummaryDetail({ + elapsedMs, + outputDurationSeconds, + isDirectory, + frameCount, + }); console.log(""); console.log(c.success("\u25C7") + " " + c.accent(outputPath)); - console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + duration + " \u00B7 completed")); + console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail)); } diff --git a/packages/cli/src/ui/format.test.ts b/packages/cli/src/ui/format.test.ts new file mode 100644 index 000000000..f1d17374b --- /dev/null +++ b/packages/cli/src/ui/format.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { formatRenderSummaryDetail } from "./format.js"; + +describe("formatRenderSummaryDetail", () => { + it("shows the output video length as the primary figure and labels render time", () => { + // Output is 71.7s of video but only took 34.2s of wall-clock to render: + // the two must be distinguishable so users stop comparing render time to ffprobe. + const detail = formatRenderSummaryDetail({ + elapsedMs: 34_200, + outputDurationSeconds: 71.7, + isDirectory: false, + }); + expect(detail).toBe("1m 11.7s video · rendered in 34.2s"); + expect(detail).toContain("video"); + expect(detail).toContain("rendered in"); + }); + + it("omits the video figure gracefully when the duration is unknown", () => { + expect(formatRenderSummaryDetail({ elapsedMs: 5_000, isDirectory: false })).toBe( + "rendered in 5.0s", + ); + }); + + it("shows a frame count for png-sequence directory output instead of a video length", () => { + const detail = formatRenderSummaryDetail({ + elapsedMs: 12_000, + isDirectory: true, + frameCount: 120, + // a stray duration must not leak into directory output + outputDurationSeconds: 4, + }); + expect(detail).toBe("120 frames · rendered in 12.0s"); + expect(detail).not.toContain("video"); + }); + + it("does not crash and shows only render time for a directory with no frame count", () => { + expect(formatRenderSummaryDetail({ elapsedMs: 1_000, isDirectory: true })).toBe( + "rendered in 1.0s", + ); + }); +}); diff --git a/packages/cli/src/ui/format.ts b/packages/cli/src/ui/format.ts index 7fe028439..0ba33827c 100644 --- a/packages/cli/src/ui/format.ts +++ b/packages/cli/src/ui/format.ts @@ -14,6 +14,31 @@ export function formatDuration(ms: number): string { return `${minutes}m ${remaining.toFixed(1)}s`; } +/** + * Build the detail portion of the render-complete summary (everything after the + * file size). The output video length is shown as the primary figure, with the + * wall-clock render time explicitly labeled "rendered in" so the two are never + * confused (users were comparing the render time to ffprobe's media duration). + * Directory (png-sequence) output has no single muxed video, so it shows a frame + * count instead, or just the render time when neither is known. + */ +export function formatRenderSummaryDetail(input: { + elapsedMs: number; + outputDurationSeconds?: number; + isDirectory: boolean; + frameCount?: number; +}): string { + const middle = input.isDirectory + ? input.frameCount != null + ? `${input.frameCount} frames` + : undefined + : input.outputDurationSeconds != null && input.outputDurationSeconds > 0 + ? `${formatDuration(input.outputDurationSeconds * 1000)} video` + : undefined; + const renderTime = `rendered in ${formatDuration(input.elapsedMs)}`; + return [middle, renderTime].filter(Boolean).join(" · "); +} + export function label(name: string, value: string): string { const pad = 14 - name.length; return ` ${c.dim(name)}${" ".repeat(Math.max(1, pad))}${c.bold(value)}`;