diff --git a/src/__tests__/ffprobe-command.test.ts b/src/__tests__/ffprobe-command.test.ts index 9b22917..a0f774e 100644 --- a/src/__tests__/ffprobe-command.test.ts +++ b/src/__tests__/ffprobe-command.test.ts @@ -10,8 +10,10 @@ import ffprobeCommand, { extractGlobalFlags, extractProbeArgs, findProbeInput, - formatSummary, + formatSummaryBlock, + resolveOutputText, type ProbeSummary, + type ProbeData, } from "../commands/ffprobe.js"; import { uploadLocalFiles } from "../lib/upload.js"; @@ -232,48 +234,129 @@ describe("buildProbeParams", () => { }); }); -describe("formatSummary", () => { - it("formats a video summary with resolution and fps", () => { +describe("formatSummaryBlock", () => { + it("renders a video summary with codec, resolution, and fps in the Video row", () => { const summary: ProbeSummary = { kind: "video", - container: "mp4", - durationSec: 75, - video: { codec: "h264", width: 1920, height: 1080, fps: 30 }, + container: "mov,mp4,m4a,3gp,3g2,mj2", + durationSec: 5.01, + sizeBytes: 352_256, // 344 KB + video: { + codec: "h264", + profile: "High", + width: 1280, + height: 720, + fps: 24, + bitDepth: 8, + pixelFormat: "yuv420p", + bitrateBps: 449_000, + }, }; - expect(formatSummary(summary)).toBe("Video mp4 1:15 1920x1080 30fps"); - }); - - it("formats an audio summary with codec and channels", () => { + const block = formatSummaryBlock(summary); + expect(block).toContain("Video"); + expect(block).toContain("mov,mp4,m4a,3gp,3g2,mj2"); + expect(block).toContain("5.01s"); + expect(block).toContain("344 KB"); + expect(block).toContain("h264 High"); + expect(block).toContain("1280x720"); + expect(block).toContain("24 fps"); + expect(block).toContain("8-bit yuv420p"); + expect(block).toContain("449 kbps"); + }); + + it("renders an audio-only summary with the Audio row and no Video row", () => { const summary: ProbeSummary = { kind: "audio", container: "mp3", durationSec: 200, - audio: { codec: "mp3", channels: 2 }, + audio: { codec: "mp3", profile: "LC", channelLayout: "stereo", sampleRate: 48_000, bitrateBps: 96_000 }, }; - expect(formatSummary(summary)).toBe("Audio mp3 3:20 mp3 2ch"); + const block = formatSummaryBlock(summary); + expect(block).toContain("Audio"); + expect(block).toContain("mp3 LC"); + expect(block).toContain("stereo"); + expect(block).toContain("48 kHz"); + expect(block).toContain("96 kbps"); + expect(block).not.toContain("Video"); + }); + + it("falls back to a raw channel count when channelLayout is absent", () => { + const summary: ProbeSummary = { kind: "audio", audio: { codec: "aac", channels: 6 } }; + expect(formatSummaryBlock(summary)).toContain("6ch"); }); - it("formats an image summary with dimensions", () => { + it("renders an image summary with dimensions and pixel format", () => { const summary: ProbeSummary = { kind: "image", container: "png", - image: { codec: "png", width: 512, height: 512 }, + sizeBytes: 46_080, + image: { width: 512, height: 512, pixelFormat: "rgba", bitDepth: 8 }, }; - expect(formatSummary(summary)).toBe("Image png 512x512"); + const block = formatSummaryBlock(summary); + expect(block).toContain("Image"); + expect(block).toContain("512x512"); + expect(block).toContain("8-bit rgba"); }); - it("degrades gracefully for the other kind and missing fields", () => { + it("renders stream counts for the other kind, skipping zero counts", () => { + const summary: ProbeSummary = { + kind: "other", + container: "matroska", + streamCounts: { video: 1, audio: 2, subtitle: 1, data: 0 }, + }; + const block = formatSummaryBlock(summary); + expect(block).toContain("1 video"); + expect(block).toContain("2 audio"); + expect(block).toContain("1 subtitle"); + expect(block).not.toContain("0 data"); + }); + + it("degrades gracefully to just the header line when fields are missing", () => { const summary: ProbeSummary = { kind: "other" }; - expect(formatSummary(summary)).toBe("Other unknown"); + const block = formatSummaryBlock(summary); + expect(block).toContain("Other"); + expect(block).not.toContain("\n"); }); - it("formats an hour-plus duration as h:mm:ss", () => { + it("flags HDR and rotation on the video row when present", () => { const summary: ProbeSummary = { kind: "video", - container: "mkv", - durationSec: 3661, - video: {}, + container: "mp4", + video: { codec: "hevc", width: 3840, height: 2160, displayAspectRatio: "16:9", isHdr: true, rotation: 90 }, }; - expect(formatSummary(summary)).toBe("Video mkv 1:01:01"); + const block = formatSummaryBlock(summary); + expect(block).toContain("3840x2160 (16:9)"); + expect(block).toContain("HDR"); + expect(block).toContain("rotated 90°"); + }); + + it("formats a sub-minute duration with fractional seconds, and minute-plus as m:ss", () => { + expect(formatSummaryBlock({ kind: "other", durationSec: 5.01 })).toContain("5.01s"); + expect(formatSummaryBlock({ kind: "other", durationSec: 75 })).toContain("1:15"); + expect(formatSummaryBlock({ kind: "other", durationSec: 3661 })).toContain("1:01:01"); + }); +}); + +describe("resolveOutputText", () => { + it("renders the summary block when `summary` is present", () => { + const data: ProbeData = { + summary: { kind: "audio", container: "mp3", audio: { codec: "mp3" } }, + }; + expect(resolveOutputText(data)).toContain("Audio"); + }); + + it("prints raw stdout verbatim and skips the summary block when `summary` is absent", () => { + // What a `-print_format csv` / `-of default` invocation returns: no + // parsed summary, just ffprobe's own text. + const raw = 'format_name=mov,mp4,m4a,3gp,3g2,mj2\nstreams.stream.0.codec_name="h264"\n'; + const data: ProbeData = { stdout: raw }; + const text = resolveOutputText(data); + expect(text).toBe(raw); + expect(text).not.toContain("Video"); + expect(text).not.toContain("kbps"); + }); + + it("returns null when the response carries neither summary nor stdout", () => { + expect(resolveOutputText({})).toBeNull(); }); }); diff --git a/src/commands/ffprobe.ts b/src/commands/ffprobe.ts index b24cd1a..b022911 100644 --- a/src/commands/ffprobe.ts +++ b/src/commands/ffprobe.ts @@ -33,52 +33,77 @@ const MAX_TIMEOUT_SEC = 900; // (rendobar/rendobar, raw-command redesign). The SDK types `data` as // `unknown` because `jobs.create`/`jobs.wait` are generic over job type -- // these interfaces encode the ffprobe-specific shape so the rest of the -// command works with real fields instead of `unknown`. `summary` is always -// present; every other field is best-effort probe output. +// command works with real fields instead of `unknown`. `summary` is present +// for the normal/JSON-parsed case; `stdout` carries raw ffprobe text when the +// user requested a non-JSON output format instead. Every field beyond that is +// best-effort probe output. interface StreamCounts { video?: number; audio?: number; subtitle?: number; data?: number; + attachment?: number; } interface SummaryCommon { container?: string; - durationSec?: number; + formatLongName?: string; + durationSec?: number | null; sizeBytes?: number; bitrateBps?: number; + startTimeSec?: number; streamCounts?: StreamCounts; tags?: Record; } interface VideoBlock { codec?: string; + profile?: string; width?: number; height?: number; + displayAspectRatio?: string; + pixelFormat?: string; + bitDepth?: number; fps?: number; + isVariableFrameRate?: boolean; + rotation?: number; + isHdr?: boolean; + bitrateBps?: number; + language?: string; } interface AudioBlock { codec?: string; + profile?: string; channels?: number; - sampleRateHz?: number; + channelLayout?: string; + sampleRate?: number; + bitrateBps?: number; + language?: string; } interface ImageBlock { codec?: string; width?: number; height?: number; + pixelFormat?: string; + bitDepth?: number; } export type ProbeSummary = - | (SummaryCommon & { kind: "video"; video: VideoBlock; audio?: AudioBlock }) + | (SummaryCommon & { kind: "video"; video: VideoBlock; audio?: AudioBlock | null }) | (SummaryCommon & { kind: "audio"; audio: AudioBlock }) | (SummaryCommon & { kind: "image"; image: ImageBlock }) | (SummaryCommon & { kind: "other" }); -interface ProbeData { - summary: ProbeSummary; +export interface ProbeData { + // `summary` is present for the normal/JSON-parsed probe case. It's absent + // when the user asked ffprobe for a non-JSON output format (csv/xml, or + // `-of default`) -- there's nothing to summarize, so `stdout` carries the + // raw ffprobe text instead. Exactly one of the two is set. + summary?: ProbeSummary; + stdout?: string; format?: unknown; streams?: unknown; chapters?: unknown; @@ -183,8 +208,15 @@ export function extractProbeArgs(argv: string[]): string[] { return result; } -/** `12` -> `"12"`, `75` -> `"1:15"`, `3661` -> `"1:01:01"`. */ +/** Strips trailing zeros (and a bare trailing dot) so `24.00` -> `24`, `23.50` -> `23.5`. */ +function fmtNum(n: number, decimals = 2): string { + const fixed = n.toFixed(decimals); + return fixed.includes(".") ? fixed.replace(/0+$/, "").replace(/\.$/, "") : fixed; +} + +/** `12` -> `"12s"`, `5.01` -> `"5.01s"` (sub-minute, fractional), `75` -> `"1:15"`, `3661` -> `"1:01:01"`. */ function fmtDuration(sec: number): string { + if (sec < 60) return `${fmtNum(sec)}s`; const total = Math.round(sec); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); @@ -193,23 +225,119 @@ function fmtDuration(sec: number): string { return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } -/** One-line concise summary: kind, container, duration, plus per-kind detail. */ -export function formatSummary(summary: ProbeSummary): string { +/** `449000` -> `"449 kbps"`, `2_500_000` -> `"2.5 Mbps"`. */ +function fmtBitrate(bps: number): string { + if (bps >= 1_000_000) return `${fmtNum(bps / 1_000_000, 1)} Mbps`; + return `${Math.round(bps / 1000)} kbps`; +} + +/** `48000` -> `"48 kHz"`, `44100` -> `"44.1 kHz"`. */ +function fmtHz(hz: number): string { + return `${fmtNum(hz / 1000, 1)} kHz`; +} + +const DOT = pc.dim("·"); + +/** `Video · mov,mp4,m4a,3gp,3g2,mj2 · 5.01s · 344 KB` -- kind, container, duration, size. */ +function formatHeaderLine(summary: ProbeSummary): string { const kindLabel = summary.kind.charAt(0).toUpperCase() + summary.kind.slice(1); - const parts = [kindLabel, summary.container ?? "unknown"]; - if (summary.durationSec !== undefined) parts.push(fmtDuration(summary.durationSec)); + const parts = [pc.bold(kindLabel)]; + const container = summary.container ?? summary.formatLongName; + if (container) parts.push(container); + if (summary.durationSec != null) parts.push(fmtDuration(summary.durationSec)); + if (summary.sizeBytes != null) parts.push(fmtBytes(summary.sizeBytes)); + return parts.join(` ${DOT} `); +} + +const ROW_LABEL_WIDTH = 7; + +function formatRow(label: string, fields: string[]): string { + return ` ${pc.dim(label.padEnd(ROW_LABEL_WIDTH))}${fields.join(" ")}`; +} + +function formatVideoRow(video: VideoBlock): string { + const fields: string[] = []; + if (video.codec) fields.push(video.profile ? `${video.codec} ${video.profile}` : video.codec); + if (video.width && video.height) { + const dar = video.displayAspectRatio ? ` (${video.displayAspectRatio})` : ""; + fields.push(`${video.width}x${video.height}${dar}`); + } + if (video.fps) fields.push(`${fmtNum(video.fps)} fps${video.isVariableFrameRate ? " (VFR)" : ""}`); + const depthFormat = [video.bitDepth ? `${video.bitDepth}-bit` : null, video.pixelFormat].filter(Boolean).join(" "); + if (depthFormat) fields.push(depthFormat); + if (video.bitrateBps) fields.push(fmtBitrate(video.bitrateBps)); + if (video.isHdr) fields.push(pc.yellow("HDR")); + if (video.rotation) fields.push(`rotated ${video.rotation}°`); + if (video.language) fields.push(`[${video.language}]`); + return formatRow("Video", fields); +} + +function formatAudioRow(audio: AudioBlock): string { + const fields: string[] = []; + if (audio.codec) fields.push(audio.profile ? `${audio.codec} ${audio.profile}` : audio.codec); + if (audio.channelLayout) fields.push(audio.channelLayout); + else if (audio.channels) fields.push(`${audio.channels}ch`); + if (audio.sampleRate) fields.push(fmtHz(audio.sampleRate)); + if (audio.bitrateBps) fields.push(fmtBitrate(audio.bitrateBps)); + if (audio.language) fields.push(`[${audio.language}]`); + return formatRow("Audio", fields); +} + +function formatImageRow(image: ImageBlock): string { + const fields: string[] = []; + if (image.width && image.height) fields.push(`${image.width}x${image.height}`); + const depthFormat = [image.bitDepth ? `${image.bitDepth}-bit` : null, image.pixelFormat].filter(Boolean).join(" "); + if (depthFormat) fields.push(depthFormat); + return formatRow("Image", fields); +} + +function formatStreamCountsRow(counts: StreamCounts | undefined): string | null { + if (!counts) return null; + const fields = (["video", "audio", "subtitle", "data", "attachment"] as const) + .filter((key) => counts[key]) + .map((key) => `${counts[key]} ${key}`); + if (fields.length === 0) return null; + return formatRow("Streams", fields); +} + +/** + * Readable multi-line summary block: a header line (kind, container, + * duration, size) plus a detail row per stream kind found on the media. + * Only present fields are rendered -- ffprobe's own data is best-effort, and + * a field this media doesn't carry (e.g. no `displayAspectRatio`) is simply + * skipped rather than printed as `undefined`. + */ +export function formatSummaryBlock(summary: ProbeSummary): string { + const lines = [formatHeaderLine(summary)]; + const rows: string[] = []; if (summary.kind === "video") { - if (summary.video.width && summary.video.height) parts.push(`${summary.video.width}x${summary.video.height}`); - if (summary.video.fps) parts.push(`${summary.video.fps}fps`); + rows.push(formatVideoRow(summary.video)); + if (summary.audio) rows.push(formatAudioRow(summary.audio)); } else if (summary.kind === "audio") { - if (summary.audio.codec) parts.push(summary.audio.codec); - if (summary.audio.channels) parts.push(`${summary.audio.channels}ch`); + rows.push(formatAudioRow(summary.audio)); } else if (summary.kind === "image") { - if (summary.image.width && summary.image.height) parts.push(`${summary.image.width}x${summary.image.height}`); + rows.push(formatImageRow(summary.image)); + } else { + const streamsRow = formatStreamCountsRow(summary.streamCounts); + if (streamsRow) rows.push(streamsRow); } - return parts.join(" "); + if (rows.length > 0) lines.push("", ...rows); + return lines.join("\n"); +} + +/** + * Decides what to print for the non-JSON success path. `summary` renders as + * the readable block; a `summary`-less response means the user asked ffprobe + * for a non-JSON output format (csv/xml/-of default), so its raw `stdout` is + * printed verbatim instead -- exactly what they asked for, nothing summarized. + * Returns null when the response carries neither (unexpected API response). + */ +export function resolveOutputText(data: ProbeData): string | null { + if (data.summary) return formatSummaryBlock(data.summary); + if (data.stdout !== undefined) return data.stdout; + return null; } // ── Help ─────────────────────────────────────────────────────── @@ -359,8 +487,13 @@ export default defineCommand({ // ── Output modes ───────────────────────────────────── // Structured answer goes to stdout so it can be piped (e.g. to jq). - if (flags.json) console.log(JSON.stringify(data, null, 2)); - else console.log(formatSummary(data.summary)); + if (flags.json) { + console.log(JSON.stringify(data, null, 2)); + } else { + const text = resolveOutputText(data); + if (text !== null) console.log(text); + else if (!flags.quiet) process.stderr.write(pc.red(" ✗ Probe completed with no summary or output\n")); + } if (!flags.quiet && isTTY) process.stderr.write(`\n${dashboardLine}`);