Files
hyperframes/packages/cli/src/utils/webmAlphaCheck.ts
T
Vance Ingalls 56dcc4e47a 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`.
2026-07-13 22:03:11 +00:00

194 lines
7.5 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js";
import { c } from "../ui/colors.js";
/**
* Result of probing a WebM's first video stream for its alpha sidecar.
* `probed` distinguishes "ffprobe ran and reported a video stream" from a
* failed/absent probe (so a probe failure stays silent, not a false warning).
*/
export interface WebmAlphaProbe {
probed: boolean;
/** True when the VP9 stream declares the alpha sidecar (ALPHA_MODE=1 tag). */
alphaMode: boolean;
/**
* 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;
}
/**
* Decide whether to warn that a WebM render lost its transparency, or
* `undefined` when nothing is wrong / can't be determined.
*
* IMPORTANT — the signal is the `ALPHA_MODE=1` stream tag, NOT `pix_fmt`.
* libvpx-vp9 stores the alpha plane in a Matroska BlockAdditional sidecar and
* ALWAYS reports `pix_fmt=yuv420p` even for a correct transparent WebM (see
* docs/guides/rendering.mdx and the webm-concat-copy smoke test). A working
* encode writes `ALPHA_MODE=1`; an ffmpeg/libvpx build that can't emit the
* sidecar omits the tag and produces genuinely opaque output. Keying on the
* tag means builds that preserve alpha stay silent (no false positive) and
* only builds that actually drop it get the warning.
*
* Pure over (format, probe) so the decision is unit-testable without spawning
* ffprobe. Only WebM is checked; MP4 is intentionally opaque and MOV/PNG-seq
* carry alpha through non-libvpx paths.
*/
export function webmAlphaAdvisory(format: string, probe: WebmAlphaProbe): string | undefined {
if (format !== "webm") return undefined;
if (!probe.probed) return undefined;
if (!probe.alphaMode) {
return (
"The WebM output has no VP9 alpha sidecar (the ALPHA_MODE stream tag is absent), " +
"so transparency was flattened to opaque. Your ffmpeg/libvpx-vp9 build cannot emit " +
"the alpha plane on this platform. For guaranteed transparency, re-render with " +
"--format mov (ProRes 4444)."
);
}
if (probe.sampledAlphaFullyOpaque) {
return (
"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 " +
"encode the frames yourself: ffmpeg -framerate <fps> -i frame_%06d.png " +
"-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le out.mov"
);
}
return undefined;
}
/**
* Best-effort ffprobe of a file's first video stream for the ALPHA_MODE tag.
* Returns `{ probed: false }` on any failure (no ffprobe, spawn error,
* unreadable file, no video stream) — this is a diagnostic, never a reason to
* fail a completed render. The tag key is matched case-insensitively (ffprobe
* surfaces it as `ALPHA_MODE`; some builds lower-case it).
*/
function probeWebmAlpha(filePath: string): WebmAlphaProbe {
try {
const ffprobePath = findFFprobe();
if (!ffprobePath) return { probed: false, alphaMode: false };
const raw = execFileSync(
ffprobePath,
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name:stream_tags=alpha_mode",
"-of",
"json",
filePath,
],
{ encoding: "utf-8", timeout: 15_000 },
);
const parsed = JSON.parse(raw) as {
streams?: Array<{ codec_name?: string; tags?: Record<string, string> }>;
};
const stream = parsed.streams?.[0];
if (!stream || typeof stream.codec_name !== "string") {
return { probed: false, alphaMode: false };
}
const tags = stream.tags ?? {};
const alphaMode = Object.entries(tags).some(
([k, v]) => k.toLowerCase() === "alpha_mode" && String(v) === "1",
);
const probe: WebmAlphaProbe = { probed: true, alphaMode };
if (alphaMode) {
const opaque = sampledAlphaIsFullyOpaque(filePath);
// Only surface `true`; leave undefined otherwise so #2044's "silent on
// working alpha" fast path is preserved when the pixel probe can't run
// OR when the sample has any partial/transparent pixel.
if (opaque === true) probe.sampledAlphaFullyOpaque = true;
}
return probe;
} catch {
return { probed: false, alphaMode: false };
}
}
/**
* 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.
*/
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 {
const buf = execFileSync(
ffmpegPath,
[
"-v",
"error",
"-c:v",
"libvpx-vp9",
"-i",
filePath,
"-frames:v",
"3",
"-vf",
"scale=8:8",
"-pix_fmt",
"rgba",
"-f",
"rawvideo",
"-",
],
{ timeout: 30_000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] },
);
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;
}
return true;
} catch {
return undefined;
}
}
/**
* After a completed WebM render, verify the output actually carries the alpha
* sidecar. Some ffmpeg/libvpx-vp9 builds silently produce opaque output — the
* render succeeds and looks fine in a player, but transparency is gone, which
* the user only discovers after compositing. Surface it loudly here with the
* concrete `--format mov` remedy. Best-effort and non-blocking; a build that
* DOES preserve alpha (ALPHA_MODE=1) stays silent.
*/
export function warnIfWebmAlphaDropped(outputPath: string, format: string, quiet: boolean): void {
if (quiet || format !== "webm") return;
const advisory = webmAlphaAdvisory(format, probeWebmAlpha(outputPath));
if (!advisory) return;
console.warn(`\n${c.warn("⚠")} ${c.bold("Transparency not preserved")}`);
console.warn(` ${c.dim(advisory)}\n`);
}