fix(cli): accept 1–3-frame short WebMs + add direct pixel-probe tests

Address Miguel's R1 blocker + Rames/Miga's testability nit:

- The `-frames:v 3` decode samples AT MOST 3 frames; a legitimate 1- or
  2-frame WebM (256 or 512 bytes) was returned as `undefined` (probe
  failure), silently skipping the advisory even when every available
  pixel was opaque. Accept any positive whole-frame byte count ≤ 768
  (multiples of 256), distinguishing successful short-EOF from partial/
  malformed decode.
- Export `sampledAlphaIsFullyOpaque` and add 11 direct tests covering:
  3/2/1-frame opaque decodes → true; transparent pixel at pos 0 or
  final byte → false (guards the alpha-byte stride); non-frame-multiple
  / over-3-frame / zero byte counts → undefined; execFileSync throw →
  undefined; findFFmpeg missing → undefined; and one args-shape guard
  pinning the load-bearing `-c:v libvpx-vp9` before `-i` (without which
  the default decoder silently discards VP9 alpha and the whole check
  would false-positive on genuinely-transparent WebMs).
- Update advisory wording from "3 sampled decoded frames" to "every
  sampled decoded pixel" so the message is honest for short WebMs.

18/18 tests pass locally under `vitest run`.
This commit is contained in:
Vance Ingalls
2026-07-13 22:03:11 +00:00
parent b8562c91d5
commit 56dcc4e47a
2 changed files with 159 additions and 17 deletions
+126 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { webmAlphaAdvisory } from "./webmAlphaCheck.js";
describe("webmAlphaAdvisory", () => {
@@ -59,3 +59,128 @@ describe("webmAlphaAdvisory", () => {
).toBeUndefined();
});
});
/**
* Direct probe tests. The pixel-level contract (decoder args, byte-count
* gate, alpha-stride walk) is too load-bearing to only cover through
* `webmAlphaAdvisory`. These mock `execFileSync` + `findFFmpeg` so the same
* production dispatch runs with a controlled byte stream.
*/
describe("sampledAlphaIsFullyOpaque", () => {
const FAKE_FFMPEG = "/fake/bin/ffmpeg";
const FAKE_WEBM = "/tmp/fake.webm";
const BYTES_PER_FRAME = 8 * 8 * 4; // 256
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock("node:child_process");
vi.doUnmock("../browser/ffmpeg.js");
});
function opaqueBuffer(frameCount: number): Buffer {
return Buffer.alloc(BYTES_PER_FRAME * frameCount, 0xff);
}
async function importWithMocks(
execImpl: (cmd: string, args: readonly string[], opts?: unknown) => Buffer,
ffmpegPath: string | null = FAKE_FFMPEG,
) {
vi.doMock("node:child_process", () => ({ execFileSync: execImpl }));
vi.doMock("../browser/ffmpeg.js", () => ({
findFFmpeg: () => ffmpegPath,
findFFprobe: () => null,
}));
return await import("./webmAlphaCheck.js");
}
it("returns true for a 3-frame all-opaque decode (768 bytes)", async () => {
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => opaqueBuffer(3));
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBe(true);
});
it("returns true for a valid 2-frame short-EOF decode (512 bytes)", async () => {
// Regression guard for R1 blocker: `-frames:v 3` means AT MOST 3; a
// legitimate 2-frame WebM must not be misclassified as a probe failure.
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => opaqueBuffer(2));
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBe(true);
});
it("returns true for a valid 1-frame single-still decode (256 bytes)", async () => {
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => opaqueBuffer(1));
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBe(true);
});
it("returns false when any sampled pixel has alpha < 255 (transparent WebM)", async () => {
const buf = opaqueBuffer(3);
buf[3] = 200; // pixel 0 alpha byte < 255
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => buf);
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBe(false);
});
it("returns false when a mid-buffer alpha byte < 255", async () => {
// Guards the stride: if the loop mistakenly used `i += 3` or started at
// `i = 0`, this off-position byte wouldn't be checked as alpha.
const buf = opaqueBuffer(3);
buf[buf.length - 1] = 128; // final alpha byte
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => buf);
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBe(false);
});
it("returns undefined for a malformed byte count that is not a whole-frame multiple", async () => {
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => Buffer.alloc(100, 0xff));
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBeUndefined();
});
it("returns undefined for a byte count exceeding 3 frames (over-decode / stray bytes)", async () => {
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => Buffer.alloc(1024, 0xff));
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBeUndefined();
});
it("returns undefined for an empty decode buffer", async () => {
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => Buffer.alloc(0));
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBeUndefined();
});
it("returns undefined when execFileSync throws (spawn / decode failure)", async () => {
const { sampledAlphaIsFullyOpaque } = await importWithMocks(() => {
throw new Error("ffmpeg exited with code 1");
});
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBeUndefined();
});
it("returns undefined when findFFmpeg cannot locate the binary", async () => {
const { sampledAlphaIsFullyOpaque } = await importWithMocks(
() => opaqueBuffer(3), // never called on this path
null,
);
expect(sampledAlphaIsFullyOpaque(FAKE_WEBM)).toBeUndefined();
});
it("passes the load-bearing decoder flags to ffmpeg (canonical VP9 alpha decode)", async () => {
// Regression guard for the "default decoder discards VP9 alpha" trap.
// Without `-c:v libvpx-vp9` BEFORE `-i`, the check would falsely report
// alpha=255 on WebMs whose alpha is genuinely present.
let capturedArgs: readonly string[] | undefined;
const { sampledAlphaIsFullyOpaque } = await importWithMocks((_cmd, args) => {
capturedArgs = args;
return opaqueBuffer(3);
});
sampledAlphaIsFullyOpaque(FAKE_WEBM);
expect(capturedArgs).toBeDefined();
const argList = [...(capturedArgs ?? [])];
const decoderIdx = argList.indexOf("-c:v");
const inputIdx = argList.indexOf("-i");
expect(decoderIdx).toBeGreaterThanOrEqual(0);
expect(argList[decoderIdx + 1]).toBe("libvpx-vp9");
expect(inputIdx).toBeGreaterThan(decoderIdx);
expect(argList).toContain("-pix_fmt");
expect(argList[argList.indexOf("-pix_fmt") + 1]).toBe("rgba");
expect(argList).toContain("-frames:v");
expect(argList[argList.indexOf("-frames:v") + 1]).toBe("3");
expect(argList).toContain("rawvideo");
});
});
+33 -16
View File
@@ -12,11 +12,11 @@ export interface WebmAlphaProbe {
/** True when the VP9 stream declares the alpha sidecar (ALPHA_MODE=1 tag). */
alphaMode: boolean;
/**
* When true, the tag says alpha but 3 sampled decoded frames report every
* pixel at alpha=255 — either the composition has no transparent regions in
* the samples, or libvpx-vp9 wrote the tag without emitting the alpha side
* data (a known Windows-build quirk). Undefined when the pixel-level probe
* couldn't run (no ffmpeg, decode error, unexpected byte count) — an
* When true, the tag says alpha but every decoded sample byte reads
* alpha=255 — either the composition has no transparent regions in the
* samples, or libvpx-vp9 wrote the tag without emitting the alpha side data
* (a known Windows-build quirk). Undefined when the pixel-level probe
* couldn't run (no ffmpeg, decode error, malformed byte count) — an
* inconclusive probe is not a warning trigger.
*/
sampledAlphaFullyOpaque?: boolean;
@@ -52,8 +52,8 @@ export function webmAlphaAdvisory(format: string, probe: WebmAlphaProbe): string
}
if (probe.sampledAlphaFullyOpaque) {
return (
"The WebM declares alpha (ALPHA_MODE=1) but 3 sampled decoded frames read " +
"alpha=255 everywhere. This may be intentional (the composition has no transparent " +
"The WebM declares alpha (ALPHA_MODE=1) but every sampled decoded pixel " +
"reads alpha=255. This may be intentional (the composition has no transparent " +
"regions in the samples) OR your ffmpeg/libvpx-vp9 build wrote the tag without " +
"emitting the alpha side data — a known Windows-build quirk. To rule it out, " +
"re-render with --format mov (ProRes 4444), or with --format png-sequence and " +
@@ -116,13 +116,26 @@ function probeWebmAlpha(filePath: string): WebmAlphaProbe {
}
/**
* Force the libvpx-vp9 decoder (default decoder silently discards VP9 alpha
* — see docs/guides/rendering.mdx) and sample 3 frames at 8x8 rgba. Returns
* `true` iff every alpha byte across all samples is 255, `false` when any
* pixel shows partial/full transparency, `undefined` if the probe couldn't
* run (no ffmpeg, decode error, unexpected byte count).
* Bytes per sampled frame at 8x8 rgba: 8 * 8 * 4 = 256. `-frames:v 3` samples
* AT MOST 3 frames — a legitimate 1-frame WebM (a still) yields 256 bytes and
* a 2-frame yields 512, both valid opaque samples that must be evaluated.
*/
function sampledAlphaIsFullyOpaque(filePath: string): boolean | undefined {
const BYTES_PER_SAMPLE_FRAME = 8 * 8 * 4;
const MAX_SAMPLE_BYTES = BYTES_PER_SAMPLE_FRAME * 3;
/**
* Force the libvpx-vp9 decoder (default decoder silently discards VP9 alpha
* — see docs/guides/rendering.mdx) and sample up to 3 frames at 8x8 rgba.
* Returns `true` iff every alpha byte across all sampled frames is 255,
* `false` when any pixel shows partial/full transparency, `undefined` if the
* probe couldn't run (no ffmpeg, decode error, or the byte count is not a
* positive whole-frame multiple ≤ 768 — anything else is a malformed decode,
* not a signal).
*
* Exported for direct unit testing; the pixel-level contract is too load-
* bearing to only exercise through `webmAlphaAdvisory`.
*/
export function sampledAlphaIsFullyOpaque(filePath: string): boolean | undefined {
const ffmpegPath = findFFmpeg();
if (!ffmpegPath) return undefined;
try {
@@ -147,9 +160,13 @@ function sampledAlphaIsFullyOpaque(filePath: string): boolean | undefined {
],
{ timeout: 30_000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] },
);
// 8*8 rgba * 3 frames = 768 bytes; require full frame count for a
// reliable verdict (silent short-decode is a probe failure, not a signal).
if (buf.length !== 768) return undefined;
if (
buf.length === 0 ||
buf.length > MAX_SAMPLE_BYTES ||
buf.length % BYTES_PER_SAMPLE_FRAME !== 0
) {
return undefined;
}
for (let i = 3; i < buf.length; i += 4) {
if (buf[i] !== 255) return false;
}