From 4ee830a53e691dea859aa794e62349aa13053963 Mon Sep 17 00:00:00 2001 From: Rohan Poudel <66029221+rohanpoudel2@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:57:36 -0600 Subject: [PATCH] feat(dry-run): make the plan answer the questions people ask it (#24) --- a/dry-run reported nothing usable summarise() only accumulated bytes for results with status "compressed", and every dry-run result is a skip, so the summary came back all zeros: $ imgvidcompress . --dry-run --json | jq .summary { "totalFiles": 1, "skipped": 1, "inputBytes": 0, "savedBytes": 0, ... } The README recipe built on that printed zeros. Its premise was also wrong: savings cannot be known without encoding. The summary gains additive `planned` and `plannedInputBytes` counts, the existing byte fields keep meaning actual encoded totals, and no savings figure is ever invented for a plan. --- b/it could not tell you the run would fail run() skipped ffmpeg resolution entirely when dryRun was set, so a machine with no ffmpeg, or an ffmpeg missing the requested codec, reported a clean plan and then died on the real run. Dry runs now resolve ffmpeg and go through the codec preflight whenever video is planned. This is an intentional behaviour change: a dry run that cannot be executed now exits 3 instead of 0. A plan that cannot run is not a passing plan. --- c/it misreported what would be skipped executeJob() checked dryRun before the output-exists branch, so every file came back reason "dry-run" even when a real run would have skipped it as "output-exists". Output-exists is now determined first. Both values were already in the documented SkipReason enum. Results also carry the resolved targetFormat, videoCodec and audioCodec, and dry runs now probe each source so the plan reports the same stream drops the real run would emit. Probing makes a video dry run slower; accuracy is the whole point of a plan, and "will this lose my subtitles" is the most valuable thing it can answer before anything is written. Co-authored-by: Claude Opus 5 (1M context) --- README.md | 25 ++++- src/cli/render.ts | 28 +++++- src/core/compress.ts | 227 ++++++++++++++++++++++++++++++------------- src/index.ts | 1 + src/types/results.ts | 19 +++- test/cli.test.ts | 47 ++++++++- test/core.test.ts | 21 ++++ test/streams.test.ts | 33 +++++++ test/video.test.ts | 26 +++++ 9 files changed, 348 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 912c99c..13a365c 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ imgvidcompress video ./clips --to .webm --quality 55 imgvidcompress ./photos --overwrite ``` +A video dry run resolves ffmpeg, checks the selected codecs and probes every +source so the plan can report stream drops such as subtitles or cover art. It +is therefore slower than a file-list preview, but it fails before writing when +the planned conversion cannot run. Dry runs do not encode, so they never +estimate output size or savings. + ## Formats Support is bounded by what your sharp and ffmpeg builds can actually do, not by a list in this README. Both are build-dependent, so ask the tool: @@ -294,7 +300,9 @@ type JobResult = | ({ status: "failed" } & FailedResult); ``` -All three carry `kind`, `inputPath` and `outputPath`. Beyond that: +All three carry `kind`, `inputPath`, `outputPath` and the resolved +`targetFormat`. Video results also carry `videoCodec` and `audioCodec` once the +file reaches planning. Beyond that: | Status | Additional fields | | -------------- | ---------------------------------------------------------------------------------- | @@ -304,6 +312,10 @@ All three carry `kind`, `inputPath` and `outputPath`. Beyond that: `savedBytes` is positive when space was saved and negative if the output grew. `savedRatio` is the fraction of the original removed, 0-1. +The summary's `planned` and `plannedInputBytes` fields count files a dry run +would send to an encoder. Existing `inputBytes`, `outputBytes`, `savedBytes` +and `savedRatio` remain actual encoded totals; dry runs leave them at zero. + `isCompressed`, `isSkipped` and `isFailed` are exported as type guards, so consumers never hand-check `status`: ```ts @@ -397,6 +409,8 @@ imgvidcompress formats --json | jq '.video.curated[].extension' "compressed": 2, "skipped": 0, "failed": 0, + "planned": 0, + "plannedInputBytes": 0, "inputBytes": 64931, "outputBytes": 33106, "savedBytes": 31825, @@ -409,6 +423,7 @@ imgvidcompress formats --json | jq '.video.curated[].extension' "kind": "image", "inputPath": "…/photo-medium.jpg", "outputPath": "…/compressed/photo-medium.webp", + "targetFormat": ".webp", "inputBytes": 44331, "outputBytes": 17902, "savedBytes": 26429, @@ -459,12 +474,16 @@ imgvidcompress ./public/img --dry-run --json \ || { echo "An image exceeds 500 KB"; exit 1; } ``` -**Report the saving without writing anything** +**Inspect the plan without writing anything** ```bash -imgvidcompress ./assets --dry-run --json | jq '.summary' +imgvidcompress ./assets --dry-run --json \ + | jq '{plannedInputBytes: .summary.plannedInputBytes, files: [.results[] | {inputPath, outputPath, targetFormat, videoCodec, audioCodec, reason, warnings}]}' ``` +No savings value is included in the plan because savings cannot be known +without encoding. + **Use it from a build script** ```ts diff --git a/src/cli/render.ts b/src/cli/render.ts index a4e9288..1341ef8 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -129,9 +129,7 @@ export class Renderer { // the list here would just double every line in the log. Skipped files are // included: "1 skipped" with no indication of which file, or why, is the // kind of summary that sends someone hunting through the output directory. - const rows = this.isTty - ? results - : results.filter((r) => r.status === "skipped" && report.dryRun); + const rows = this.isTty ? results : []; if (rows.length > 0) this.write("\n"); for (const result of rows) { @@ -159,8 +157,15 @@ export class Renderer { } if (report.dryRun) { + const skipped = summary.skipped - summary.planned; + const parts = [ + `${summary.planned} file(s) planned (${formatBytes(summary.plannedInputBytes)} input)`, + skipped > 0 ? `${skipped} skipped` : null, + summary.failed > 0 ? pc.red(`${summary.failed} failed`) : null, + "nothing written", + ].filter((part): part is string => part !== null); this.write( - `\n ${pc.bold(pc.yellow("Dry run"))} ${pc.dim(`— ${summary.totalFiles} file(s) planned, nothing written.`)}\n\n`, + `\n ${pc.bold(pc.yellow("Dry run"))} ${pc.dim(`— ${parts.join(" · ")}.`)}\n\n`, ); return; } @@ -237,6 +242,9 @@ function plainLine(result: JobResult): string { } case "skipped": { const notes = (result.warnings ?? []).map((w) => `\n warn ${w}`).join(""); + if (result.reason === "dry-run") { + return ` plan ${basename(result.inputPath)} → ${basename(result.outputPath)} ${formatBytes(result.inputBytes)} · ${settingsLabel(result)}${notes}`; + } return ` skip ${basename(result.inputPath)} (${result.reason})${notes}`; } case "failed": @@ -255,12 +263,24 @@ function detailLine(result: JobResult): string { return `${pc.green("✓")} ${name} ${padStart(arrow, 30)} ${tone(`${grew ? "+" : "−"}${formatPercent(Math.abs(result.savedRatio))}`)}`; } case "skipped": + if (result.reason === "dry-run") { + return `${pc.yellow("○")} ${name} ${pc.dim(`planned → ${basename(result.outputPath)} ${formatBytes(result.inputBytes)} · ${settingsLabel(result)}`)}`; + } return `${pc.yellow("○")} ${name} ${pc.dim(skipLabel(result.reason))}`; case "failed": return `${pc.red("✗")} ${name} ${pc.red(result.error.code)}`; } } +function settingsLabel(result: Extract): string { + const settings = [`format: ${result.targetFormat}`]; + if (result.kind === "video") { + settings.push(`video: ${result.videoCodec ?? "ffmpeg default"}`); + settings.push(`audio: ${result.audioCodec ?? "ffmpeg default"}`); + } + return settings.join(" · "); +} + function skipLabel(reason: string): string { switch (reason) { case "output-larger-than-input": diff --git a/src/core/compress.ts b/src/core/compress.ts index 9b9d687..389994c 100644 --- a/src/core/compress.ts +++ b/src/core/compress.ts @@ -115,8 +115,9 @@ async function run( } const needsVideo = files.some((f) => f.kind === "video"); - const tools: FfmpegTools | null = - needsVideo && !dryRun ? await resolveFfmpeg(options.ffmpegPath) : null; + const tools: FfmpegTools | null = needsVideo + ? await resolveFfmpeg(options.ffmpegPath) + : null; const jobs = await Promise.all( files.map((file) => planJob(file, options, outDir, kind === null)), @@ -452,36 +453,59 @@ async function executeJob( job: CompressionJob, ctx: ExecuteContext, ): Promise { - const { kind, inputPath, outputPath, inputBytes } = job; + const { kind, inputPath, outputPath, inputBytes, targetFormat } = job; try { - if (ctx.dryRun) { + if (!(ctx.options.overwrite ?? false) && (await exists(outputPath))) { return { status: "skipped", kind, inputPath, outputPath, inputBytes, - reason: "dry-run", + targetFormat, + reason: "output-exists", }; } - if (!(ctx.options.overwrite ?? false) && (await exists(outputPath))) { + if (ctx.dryRun) { + const prepared = + kind === "video" ? await prepareVideoJob(job, ctx, job.outputPath) : null; + const warnings = prepared?.warnings ?? []; + return { status: "skipped", kind, inputPath, outputPath, inputBytes, - reason: "output-exists", + targetFormat, + reason: "dry-run", + ...(prepared + ? { + videoCodec: prepared.videoCodec, + audioCodec: prepared.audioCodec, + } + : {}), + ...(warnings.length > 0 ? { warnings } : {}), }; } const startedAt = performance.now(); - const { bytes: outputBytes, warnings } = + const output = kind === "image" ? await runImageJob(job, ctx) : await runVideoJob(job, ctx); + const { bytes: outputBytes, warnings } = output; - const notes = warnings.length > 0 ? { warnings } : {}; + const notes = { + targetFormat, + ...(output.videoCodec !== undefined + ? { + videoCodec: output.videoCodec, + audioCodec: output.audioCodec ?? null, + } + : {}), + ...(warnings.length > 0 ? { warnings } : {}), + }; // `null` signals the encoder declined to write because it grew the file. if (outputBytes === null) { @@ -511,7 +535,14 @@ async function executeJob( }; } catch (err) { if (err instanceof Error && err.name === "AbortError") throw err; - return { status: "failed", kind, inputPath, outputPath, error: toFailure(err) }; + return { + status: "failed", + kind, + inputPath, + outputPath, + targetFormat, + error: toFailure(err), + }; } } @@ -519,6 +550,8 @@ async function executeJob( interface JobOutput { readonly bytes: number | null; readonly warnings: string[]; + readonly videoCodec?: string | null; + readonly audioCodec?: string | null; } async function runImageJob( @@ -555,29 +588,15 @@ async function runVideoJob( ); } - const extension = job.targetFormat; - - // One probe serves the progress percentage, the audio decision, and which - // streams survive. - const probe: MediaProbe = ctx.tools.ffprobe - ? await probeMedia(ctx.tools.ffprobe, job.inputPath) - : EMPTY_PROBE; - - const plan = isVideoContainer(extension) - ? planStreams(extension, probe) - : { plan: openStreamPlan(probe), dropped: [] as string[] }; - return withAtomicOutput(job.outputPath, async (temporaryPath) => { - const args = isVideoContainer(extension) - ? curatedArgsFor(extension, job, ctx, probe, plan.plan, temporaryPath) - : await openArgsFor(extension, job, ctx, probe, plan.plan, temporaryPath); + const prepared = await prepareVideoJob(job, ctx, temporaryPath); const encoded = await encodeVideo({ tools, inputPath: job.inputPath, outputPath: temporaryPath, - args, - durationSeconds: probe.durationSeconds, + args: prepared.args, + durationSeconds: prepared.durationSeconds, ...(ctx.options.onProgress ? { onProgress: (ratio: number) => @@ -587,19 +606,69 @@ async function runVideoJob( ...(ctx.options.signal ? { signal: ctx.options.signal } : {}), }); + const result = { + warnings: prepared.warnings, + videoCodec: prepared.videoCodec, + audioCodec: prepared.audioCodec, + }; + if (ctx.skipLarger && encoded.bytes >= job.inputBytes) { - return { - value: { bytes: null, warnings: plan.dropped }, - replace: false, - }; + return { value: { bytes: null, ...result }, replace: false }; } - return { - value: { bytes: encoded.bytes, warnings: plan.dropped }, - replace: true, - }; + return { value: { bytes: encoded.bytes, ...result }, replace: true }; }); } +interface PreparedVideoJob { + readonly args: string[]; + readonly durationSeconds: number | null; + readonly warnings: string[]; + readonly videoCodec: string | null; + readonly audioCodec: string | null; +} + +/** + * Resolve everything an encode needs without running one. + * + * Split out so a dry run can report the codecs and stream drops a real run + * would produce. `outputPath` is the staging file for a real encode, and the + * final destination for a plan, whose args are never executed. + */ +async function prepareVideoJob( + job: CompressionJob, + ctx: ExecuteContext, + outputPath: string, +): Promise { + if (!ctx.tools) { + throw new CompressorError( + "FFMPEG_NOT_FOUND", + "ffmpeg is required to compress video.", + ); + } + + const extension = job.targetFormat; + + // One probe serves the progress percentage, the audio decision, and which + // streams survive. + const probe: MediaProbe = ctx.tools.ffprobe + ? await probeMedia(ctx.tools.ffprobe, job.inputPath) + : EMPTY_PROBE; + + const plan = isVideoContainer(extension) + ? planStreams(extension, probe) + : { plan: openStreamPlan(probe), dropped: [] as string[] }; + + const prepared = isVideoContainer(extension) + ? prepareCuratedVideo(extension, job, ctx, probe, plan.plan, outputPath) + : await prepareOpenVideo(extension, job, ctx, probe, plan.plan, outputPath); + + return { + ...prepared, + durationSeconds: probe.durationSeconds, + warnings: plan.dropped, + }; +} + const EMPTY_PROBE: MediaProbe = { durationSeconds: null, video: [], @@ -678,15 +747,21 @@ function describeStream(stream: { return parts.length > 0 ? ` (${parts.join(": ")})` : ""; } +interface PreparedVideoArgs { + readonly args: string[]; + readonly videoCodec: string | null; + readonly audioCodec: string | null; +} + /** Curated container: typed codec matrix and tuned per-codec flags. */ -function curatedArgsFor( +function prepareCuratedVideo( container: VideoContainer, job: CompressionJob, ctx: ExecuteContext, probe: MediaProbe, plan: StreamPlan | null, outputPath: string, -): string[] { +): PreparedVideoArgs { const selected = ctx.codecs.get(container); const videoCodec = (selected?.videoCodec ?? resolveVideoCodec(container, ctx.options.videoCodec)) as VideoCodec; @@ -697,20 +772,24 @@ function curatedArgsFor( (selected?.audioCodec as AudioCodec | null | undefined) ?? undefined, ); - return curatedArgs({ - inputPath: job.inputPath, - outputPath, - container, - // Checked against the container's own matrix by the resolvers above. - videoCodec: videoCodec, - audioCodec: audioCodec, - quality: ctx.quality, - speed: ctx.options.preset, - fps: ctx.options.fps, - resize: ctx.options.resize, - sourceAudioBitrates: probe.audio.map((a) => a.bitrate), - streams: plan, - }); + return { + args: curatedArgs({ + inputPath: job.inputPath, + outputPath, + container, + // Checked against the container's own matrix by the resolvers above. + videoCodec, + audioCodec, + quality: ctx.quality, + speed: ctx.options.preset, + fps: ctx.options.fps, + resize: ctx.options.resize, + sourceAudioBitrates: probe.audio.map((a) => a.bitrate), + streams: plan, + }), + videoCodec, + audioCodec, + }; } /** @@ -721,14 +800,14 @@ function curatedArgsFor( * `--codec` is checked against the encoder list first, so a typo fails with a * clear message instead of a wall of ffmpeg stderr. */ -async function openArgsFor( +async function prepareOpenVideo( extension: string, job: CompressionJob, ctx: ExecuteContext, probe: MediaProbe, plan: StreamPlan | null, outputPath: string, -): Promise { +): Promise { const ffmpeg = ctx.tools?.ffmpeg ?? "ffmpeg"; const muxer = extension.slice(1); const detail = await muxerDetail(ffmpeg, muxer); @@ -745,21 +824,27 @@ async function openArgsFor( } const selected = ctx.codecs.get(extension); + const videoCodec = selected?.videoCodec ?? ctx.options.videoCodec ?? null; + const audioCodec = selected?.audioCodec ?? ctx.options.audioCodec ?? null; - return buildOpenVideoArgs({ - inputPath: job.inputPath, - outputPath, - extension, - // null means "let ffmpeg decide", which is always a legal choice. - videoCodec: selected?.videoCodec ?? ctx.options.videoCodec ?? null, - audioCodec: selected?.audioCodec ?? ctx.options.audioCodec ?? null, - quality: ctx.quality, - speed: ctx.options.preset, - fps: ctx.options.fps, - resize: ctx.options.resize, - sourceAudioBitrates: probe.audio.map((a) => a.bitrate), - streams: plan, - }); + return { + args: buildOpenVideoArgs({ + inputPath: job.inputPath, + outputPath, + extension, + // null means "let ffmpeg decide", which is always a legal choice. + videoCodec, + audioCodec, + quality: ctx.quality, + speed: ctx.options.preset, + fps: ctx.options.fps, + resize: ctx.options.resize, + sourceAudioBitrates: probe.audio.map((a) => a.bitrate), + streams: plan, + }), + videoCodec: videoCodec ?? detail?.defaultVideoCodec ?? null, + audioCodec: audioCodec ?? detail?.defaultAudioCodec ?? null, + }; } /** @@ -861,6 +946,8 @@ export function summarise( let compressed = 0; let skipped = 0; let failed = 0; + let planned = 0; + let plannedInputBytes = 0; let inputBytes = 0; let outputBytes = 0; @@ -873,6 +960,10 @@ export function summarise( break; case "skipped": skipped++; + if (result.reason === "dry-run") { + planned++; + plannedInputBytes += result.inputBytes; + } break; case "failed": failed++; @@ -886,6 +977,8 @@ export function summarise( compressed, skipped, failed, + planned, + plannedInputBytes, inputBytes, outputBytes, savedBytes, diff --git a/src/index.ts b/src/index.ts index 9814f55..a30a5eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -152,6 +152,7 @@ export { type CompressionJob, type CompressionReport, type CompressionSummary, + type ResolvedJobSettings, type CommonOptions, type CompressOptions, type ImageOptions, diff --git a/src/types/results.ts b/src/types/results.ts index 320df00..c5ed3e3 100644 --- a/src/types/results.ts +++ b/src/types/results.ts @@ -13,6 +13,15 @@ export interface CompressionJob { readonly targetFormat: ImageOutputFormat | VideoOutputSpec; } +/** Output settings resolved for one file before it is encoded. */ +export interface ResolvedJobSettings { + readonly targetFormat: ImageOutputFormat | VideoOutputSpec; + /** Present for video once the encoder choice has been resolved. */ + readonly videoCodec?: string | null; + /** Present for video once copy-versus-encode has been resolved. */ + readonly audioCodec?: string | null; +} + export type SkipReason = /** The compressed result was bigger than the original, so we kept the original. */ | "output-larger-than-input" @@ -66,7 +75,7 @@ export type JobResult = */ export type Warnings = readonly string[]; -export interface CompressedResult { +export interface CompressedResult extends ResolvedJobSettings { readonly kind: MediaKind; readonly inputPath: string; readonly outputPath: string; @@ -80,7 +89,7 @@ export interface CompressedResult { readonly warnings?: Warnings; } -export interface SkippedResult { +export interface SkippedResult extends ResolvedJobSettings { readonly kind: MediaKind; readonly inputPath: string; readonly outputPath: string; @@ -89,7 +98,7 @@ export interface SkippedResult { readonly warnings?: Warnings; } -export interface FailedResult { +export interface FailedResult extends ResolvedJobSettings { readonly kind: MediaKind; readonly inputPath: string; readonly outputPath: string; @@ -112,6 +121,10 @@ export interface CompressionSummary { readonly compressed: number; readonly skipped: number; readonly failed: number; + /** Files a dry run determined would reach an encoder. */ + readonly planned: number; + /** Source bytes for files counted by `planned`; never an output estimate. */ + readonly plannedInputBytes: number; readonly inputBytes: number; readonly outputBytes: number; readonly savedBytes: number; diff --git a/test/cli.test.ts b/test/cli.test.ts index c080f80..fc9b972 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -132,7 +132,7 @@ describe("CLI", () => { }); describe("dry run", () => { - it("plans without writing anything", async () => { + it("emits one JSON plan without writing anything", async () => { const src = join(dir, "dry"); const out = join(dir, "dry-out"); await makeImage(join(src, "a.png")); @@ -140,15 +140,58 @@ describe("CLI", () => { const result = await runCli([src, "--out", out, "--dry-run", "--json"]); const parsed = JSON.parse(result.stdout) as { dryRun: boolean; - results: { status: string; reason?: string; outputPath: string }[]; + summary: { planned: number; plannedInputBytes: number; savedBytes: number }; + results: { + status: string; + reason?: string; + outputPath: string; + targetFormat: string; + }[]; }; expect(parsed.dryRun).toBe(true); + expect(parsed.summary.planned).toBe(1); + expect(parsed.summary.plannedInputBytes).toBeGreaterThan(0); + expect(parsed.summary.savedBytes).toBe(0); expect(parsed.results[0]?.reason).toBe("dry-run"); // The planned destination is still reported, so a caller can preview it. expect(parsed.results[0]?.outputPath).toContain("a.webp"); + expect(parsed.results[0]?.targetFormat).toBe(".webp"); await expect(access(out)).rejects.toThrow(); }); + + it("fails the plan when its required ffmpeg is unavailable", async () => { + const src = join(dir, "dry-no-ffmpeg"); + await makeCorruptImage(join(src, "clip.mp4")); + + const result = await runCli([ + "video", + src, + "--dry-run", + "--json", + "--ffmpeg-path", + join(dir, "missing-ffmpeg"), + ]); + const parsed = JSON.parse(result.stdout) as { + ok: boolean; + error: { code: string }; + }; + + expect(parsed.ok).toBe(false); + expect(parsed.error.code).toBe("FFMPEG_NOT_FOUND"); + expect(result.exitCode).toBe(3); + }); + + it("shows resolved settings in human-readable plans", async () => { + const src = join(dir, "dry-human"); + await makeImage(join(src, "a.png")); + + const result = await runCli([src, "--dry-run", "--no-color"]); + + expect(result.stderr).toContain("a.webp"); + expect(result.stderr).toContain(".webp"); + expect(result.stderr).toMatch(/planned/i); + }); }); describe("deprecated v1 commands", () => { diff --git a/test/core.test.ts b/test/core.test.ts index 1481f9d..74d7072 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -170,9 +170,30 @@ describe("compression behaviour", () => { expect(report.dryRun).toBe(true); expect(report.summary.skipped).toBe(1); + expect(report.summary.planned).toBe(1); + expect(report.summary.plannedInputBytes).toBeGreaterThan(0); + expect(report.summary.inputBytes).toBe(0); + expect(report.summary.savedBytes).toBe(0); await expect(access(out)).rejects.toThrow(); }); + it("reports an existing output as skipped in a dry run", async () => { + const src = join(dir, "dry-existing"); + const out = join(dir, "dry-existing-out"); + await makeImage(join(src, "a.png")); + await makeImage(join(out, "a.webp"), { format: "webp" }); + + const report = await compressImages([src], { outDir: out, dryRun: true }); + + expect(report.results[0]).toMatchObject({ + status: "skipped", + reason: "output-exists", + targetFormat: ".webp", + }); + expect(report.summary.planned).toBe(0); + expect(report.summary.plannedInputBytes).toBe(0); + }); + it("skips an existing output unless --overwrite", async () => { const src = join(dir, "over"); const out = join(dir, "over-out"); diff --git a/test/streams.test.ts b/test/streams.test.ts index 3ebe8b3..17edee4 100644 --- a/test/streams.test.ts +++ b/test/streams.test.ts @@ -433,5 +433,38 @@ describe.skipIf(!(await hasFfmpeg()))( const streams = await streamsOf(join(out, "input.avi")); expect(streams.match(/audio/g)).toHaveLength(2); }, 120_000); + + it("reports the same stream drops in a dry run as the real run", async () => { + const out = join(dir, "avi-plan-out"); + const dryRun = await compressVideos([input], { + outDir: out, + to: ".avi", + quality: toQuality(50), + dryRun: true, + }); + const realRun = await compressVideos([input], { + outDir: out, + to: ".avi", + quality: toQuality(50), + skipLarger: false, + }); + + const planned = dryRun.results[0]; + const actual = realRun.results[0]; + const plannedWarnings = + planned && "warnings" in planned ? (planned.warnings ?? []) : []; + const actualWarnings = + actual && "warnings" in actual ? (actual.warnings ?? []) : []; + + expect(planned).toMatchObject({ + status: "skipped", + reason: "dry-run", + targetFormat: ".avi", + videoCodec: expect.any(String), + audioCodec: expect.any(String), + }); + expect(plannedWarnings.length).toBeGreaterThan(0); + expect(plannedWarnings).toEqual(actualWarnings); + }, 120_000); }, ); diff --git a/test/video.test.ts b/test/video.test.ts index 37aea83..85e80da 100644 --- a/test/video.test.ts +++ b/test/video.test.ts @@ -301,6 +301,32 @@ describe.skipIf(!(await hasFfmpeg()))("video encoding (requires ffmpeg)", () => expect(starts).toBe(0); }, 120_000); + it("runs the encoder capability preflight for a dry run", async () => { + const caps = await ffmpegCapabilities("ffmpeg"); + const muxer = [...caps.muxers.keys()].find( + (name) => !["mp4", "mkv", "mov", "webm", "avi", "ogv"].includes(name), + ); + expect(muxer).toBeDefined(); + + const src = join(dir, "dry-missing-codec-preflight"); + await makeVideo(join(src, "clip.mp4")); + + let starts = 0; + const error = await compressVideos([src], { + outDir: join(dir, "dry-missing-codec-preflight-out"), + to: `.${muxer!}`, + videoCodec: "definitely-not-an-encoder", + dryRun: true, + onProgress: (event) => { + if (event.type === "job-start") starts++; + }, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(CompressorError); + expect((error as CompressorError).code).toBe("INVALID_OPTION"); + expect(starts).toBe(0); + }, 120_000); + it("does not enlarge a small source given a large resize box", async () => { // The fixture is 320x240; the box is far larger in both dimensions. This // encoded at 4000x3000 before the clamp, so assert against the real file