From b8562c91d5fea2155822739644404556b1051255 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 13 Jul 2026 21:09:38 +0000 Subject: [PATCH] fix(cli): detect WebM alpha lost when the ALPHA_MODE tag is written without side data Extends webmAlphaCheck.ts (from #2044) with a pixel-level decode probe. After the tag check passes, decodes 3 sampled frames via `ffmpeg -c:v libvpx-vp9 -pix_fmt rgba -f rawvideo` at 8x8 and emits a distinct advisory if every alpha byte reads 255. #2044 detects the "tag absent" failure mode (ffprobe shows no `alpha_mode` in stream tags). It doesn't catch a stricter case reported on CLI 0.7.56 / Windows 11: ALPHA_MODE=1 present but BlockAdditional alpha side data empty. Under current logic webmAlphaAdvisory sees `alphaMode: true` and stays silent, so the render ships as opaque without any signal. The -metadata:s:v:0 alpha_mode=1 push is a muxer directive that some ffmpeg builds write unconditionally, independent of whether libvpx-vp9 emitted the alpha plane. Tag presence is necessary but not sufficient evidence of preserved alpha. Advisory text names both possibilities (opaque composition OR silent alpha drop) plus the concrete workaround (png-sequence + prores repack). Fast path (no tag or missing tag) is unchanged. Probe adds ~1s per WebM render only when the tag says alpha. --- packages/cli/src/utils/webmAlphaCheck.test.ts | 32 +++++++ packages/cli/src/utils/webmAlphaCheck.ts | 93 +++++++++++++++++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/webmAlphaCheck.test.ts b/packages/cli/src/utils/webmAlphaCheck.test.ts index 7fc812ff0..763f9193b 100644 --- a/packages/cli/src/utils/webmAlphaCheck.test.ts +++ b/packages/cli/src/utils/webmAlphaCheck.test.ts @@ -26,4 +26,36 @@ describe("webmAlphaAdvisory", () => { expect(webmAlphaAdvisory("mp4", { probed: true, alphaMode: false })).toBeUndefined(); expect(webmAlphaAdvisory("mov", { probed: true, alphaMode: false })).toBeUndefined(); }); + + it("warns when tag is present but sampled frames all read alpha=255", () => { + const msg = webmAlphaAdvisory("webm", { + probed: true, + alphaMode: true, + sampledAlphaFullyOpaque: true, + }); + expect(msg).toBeDefined(); + expect(msg).toContain("ALPHA_MODE=1"); + expect(msg).toContain("prores_ks"); + }); + + it("stays silent when tag is present and sampled frames are not fully opaque", () => { + expect( + webmAlphaAdvisory("webm", { + probed: true, + alphaMode: true, + sampledAlphaFullyOpaque: false, + }), + ).toBeUndefined(); + }); + + it("stays silent when the pixel-level probe couldn't run (undefined)", () => { + // Preserves #2044 behavior: an inconclusive probe is not a warning trigger. + expect( + webmAlphaAdvisory("webm", { + probed: true, + alphaMode: true, + sampledAlphaFullyOpaque: undefined, + }), + ).toBeUndefined(); + }); }); diff --git a/packages/cli/src/utils/webmAlphaCheck.ts b/packages/cli/src/utils/webmAlphaCheck.ts index af1074c54..038d05ef3 100644 --- a/packages/cli/src/utils/webmAlphaCheck.ts +++ b/packages/cli/src/utils/webmAlphaCheck.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { findFFprobe } from "../browser/ffmpeg.js"; +import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js"; import { c } from "../ui/colors.js"; /** @@ -11,6 +11,15 @@ 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 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 + * inconclusive probe is not a warning trigger. + */ + sampledAlphaFullyOpaque?: boolean; } /** @@ -32,13 +41,27 @@ export interface WebmAlphaProbe { */ export function webmAlphaAdvisory(format: string, probe: WebmAlphaProbe): string | undefined { if (format !== "webm") return undefined; - if (!probe.probed || probe.alphaMode) return undefined; - 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.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 3 sampled decoded frames read " + + "alpha=255 everywhere. 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 -i frame_%06d.png " + + "-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le out.mov" + ); + } + return undefined; } /** @@ -78,12 +101,64 @@ function probeWebmAlpha(filePath: string): WebmAlphaProbe { const alphaMode = Object.entries(tags).some( ([k, v]) => k.toLowerCase() === "alpha_mode" && String(v) === "1", ); - return { probed: true, alphaMode }; + 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 }; } } +/** + * 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). + */ +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"] }, + ); + // 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; + 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