Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/codecs/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,11 +290,23 @@ export function buildScaleFilter(resize?: ResizeOptions): string | null {
const h = resize?.maxHeight;
if (w === undefined && h === undefined) return null;

// Matches the image path's default; an explicit false opts into upscaling.
const noEnlarge = resize?.withoutEnlargement ?? true;

if (w !== undefined && h !== undefined) {
return `scale=w=${w}:h=${h}:force_original_aspect_ratio=decrease:force_divisible_by=2`;
// Bound the box by the source itself. `force_original_aspect_ratio=decrease`
// fits the frame *inside* the box but will scale up to reach it, so a
// 320x240 clip asked to fit 4000x4000 came out at 4000x3000 — a 12x
// enlargement from options documented as "never enlarge". The
// single-dimension branches below already clamped; this one did not.
const boundW = noEnlarge ? `min(iw\\,${w})` : `${w}`;
const boundH = noEnlarge ? `min(ih\\,${h})` : `${h}`;
return `scale=w=${boundW}:h=${boundH}:force_original_aspect_ratio=decrease:force_divisible_by=2`;
}
if (w !== undefined) {
return noEnlarge ? `scale=w=min(iw\\,${w}):h=-2` : `scale=w=${w}:h=-2`;
}
if (w !== undefined) return `scale=w=min(iw\\,${w}):h=-2`;
return `scale=w=-2:h=min(ih\\,${h})`;
return noEnlarge ? `scale=w=-2:h=min(ih\\,${h})` : `scale=w=-2:h=${h}`;
}

/** Curated path: validates the speed knob and maps quality onto the codec's CRF. */
Expand Down
67 changes: 63 additions & 4 deletions test/video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,21 @@ import { toQuality, toPixels } from "../src/types/brand.js";
import { resolveFfmpeg, resetFfmpegCache } from "../src/codecs/ffmpeg.js";
import { CompressorError } from "../src/core/errors.js";

/** Read a stream's codec name back out of the encoded file. */
function probeCodec(file: string, stream: "v" | "a"): Promise<string> {
/** Read arbitrary stream fields back out of the encoded file. */
function probeStream(
file: string,
entries: string,
stream: "v" | "a" = "v",
): Promise<string> {
const args = [
"-v",
"error",
"-select_streams",
`${stream}:0`,
"-show_entries",
"stream=codec_name",
`stream=${entries}`,
"-of",
"default=noprint_wrappers=1:nokey=1",
"csv=p=0",
file,
];

Expand All @@ -33,6 +37,11 @@ function probeCodec(file: string, stream: "v" | "a"): Promise<string> {
});
}

/** Read a stream's codec name back out of the encoded file. */
function probeCodec(file: string, stream: "v" | "a"): Promise<string> {
return probeStream(file, "codec_name", stream);
}

describe("video argument construction", () => {
it("escapes the comma inside a scale expression", () => {
// An unescaped comma is read by ffmpeg as an option separator.
Expand All @@ -51,6 +60,31 @@ describe("video argument construction", () => {
expect(buildScaleFilter({})).toBeNull();
});

it("clamps both dimensions to the source, so a small input is never enlarged", () => {
// `force_original_aspect_ratio=decrease` fits the frame inside the box but
// scales *up* to reach it, so the box has to be bounded by the source.
// Without the clamp a 320x240 clip given a 4000x4000 box encoded at
// 4000x3000 — from options documented as "never enlarge".
const filter = buildScaleFilter({
maxWidth: toPixels(4000),
maxHeight: toPixels(4000),
});

expect(filter).toContain("min(iw\\,4000)");
expect(filter).toContain("min(ih\\,4000)");
});

it("allows upscaling only when withoutEnlargement is explicitly false", () => {
const filter = buildScaleFilter({
maxWidth: toPixels(4000),
maxHeight: toPixels(4000),
withoutEnlargement: false,
});

expect(filter).not.toContain("min(");
expect(filter).toContain("w=4000");
});

it("adds faststart for MP4 so playback can begin before download finishes", () => {
const args = buildVideoArgs({
inputPath: "in.mov",
Expand Down Expand Up @@ -134,6 +168,31 @@ describe.skipIf(!(await hasFfmpeg()))("video encoding (requires ffmpeg)", () =>
expect(await probeCodec(join(dir, "mp4-out", "clip.mp4"), "v")).toBe("h264");
}, 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
// rather than the filter string.
const src = join(dir, "no-enlarge");
await makeVideo(join(src, "clip.mp4"));

const report = await compressVideos([src], {
outDir: join(dir, "no-enlarge-out"),
resize: {
maxWidth: toPixels(4000),
maxHeight: toPixels(4000),
withoutEnlargement: true,
},
skipLarger: false,
});

expect(report.summary.failed).toBe(0);
const size = await probeStream(
join(dir, "no-enlarge-out", "clip.mp4"),
"width,height",
);
expect(size).toBe("320,240");
}, 120_000);

it("produces a playable WebM, which v1 could not", async () => {
// v1 muxed H.264 into WebM; ffmpeg refuses, so the run always failed.
const src = join(dir, "webm");
Expand Down
Loading