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