From ebe74b59d53bf61912d6f3545d2684813af8444a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 22:14:18 -0400 Subject: [PATCH 1/2] fix(cli): warn when a WebM render loses its requested alpha channel HyperFrames always encodes WebM with an alpha-capable pixel format (yuva420p), but some ffmpeg/libvpx builds silently emit opaque yuv420p even when handed alpha input and -pix_fmt yuva420p. The render succeeds and plays back fine, so the lost transparency is only discovered after compositing (users report shipping a solid-black clip and colorkeying it out by hand). After a WebM render, best-effort ffprobe the output's pix_fmt; if it lacks alpha, print a non-blocking warning that names the concrete remedy (--format mov / ProRes 4444). Only WebM is checked (mp4 is intentionally opaque; mov/png carry alpha through paths that don't hit libvpx-vp9), and a failed probe stays silent rather than warning speculatively. Pure decision (pixelFormatHasAlpha / webmAlphaAdvisory) unit-tested; verified end-to-end that a transparent WebM render now surfaces the warning while an MP4 render stays silent. --- packages/cli/src/commands/render.ts | 3 + packages/cli/src/utils/webmAlphaCheck.test.ts | 54 +++++++++++ packages/cli/src/utils/webmAlphaCheck.ts | 90 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 packages/cli/src/utils/webmAlphaCheck.test.ts create mode 100644 packages/cli/src/utils/webmAlphaCheck.ts diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index a07e1c988..d2049eb8b 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -57,6 +57,7 @@ import { formatLintFindings } from "../utils/lintFormat.js"; import { loadProducer } from "../utils/producer.js"; import { c } from "../ui/colors.js"; import { formatBytes, formatRenderSummaryDetail, errorBox } from "../ui/format.js"; +import { warnIfWebmAlphaDropped } from "../utils/webmAlphaCheck.js"; import { renderProgress } from "../ui/progress.js"; import { trackRenderComplete, @@ -1373,6 +1374,7 @@ async function renderDocker( // threaded back here; the summary shows render time only (never a wrong video // length). Probe the output with ffprobe if a duration figure is wanted here. printRenderComplete(outputPath, elapsed, options.quiet); + warnIfWebmAlphaDropped(outputPath, options.format, options.quiet); if (options.exitAfterComplete) scheduleRenderProcessExit(); return { renderTimeMs: elapsed }; } @@ -1475,6 +1477,7 @@ export async function renderLocal( job.perfSummary?.compositionDurationSeconds, job.perfSummary?.totalFrames, ); + warnIfWebmAlphaDropped(outputPath, options.format, options.quiet); if (!options.skipFeedback) { await maybePromptRenderFeedback({ renderDurationMs: elapsed, diff --git a/packages/cli/src/utils/webmAlphaCheck.test.ts b/packages/cli/src/utils/webmAlphaCheck.test.ts new file mode 100644 index 000000000..d5dd1de3d --- /dev/null +++ b/packages/cli/src/utils/webmAlphaCheck.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { pixelFormatHasAlpha, webmAlphaAdvisory } from "./webmAlphaCheck.js"; + +describe("pixelFormatHasAlpha", () => { + it("recognizes alpha-capable pixel formats", () => { + for (const f of [ + "yuva420p", + "yuva444p10le", + "gbrap", + "gbrap10le", + "rgba", + "bgra", + "argb", + "abgr", + "ya8", + "ya16le", + ]) { + expect(pixelFormatHasAlpha(f), f).toBe(true); + } + }); + + it("rejects opaque pixel formats", () => { + for (const f of ["yuv420p", "yuv444p", "yuv420p10le", "gbrp", "rgb24", "bgr0", ""]) { + expect(pixelFormatHasAlpha(f), f).toBe(false); + } + }); + + it("is case- and whitespace-insensitive", () => { + expect(pixelFormatHasAlpha(" YUVA420P \n")).toBe(true); + expect(pixelFormatHasAlpha(" YUV420P ")).toBe(false); + }); +}); + +describe("webmAlphaAdvisory", () => { + it("warns when a webm output lost its requested alpha", () => { + const msg = webmAlphaAdvisory("webm", "yuv420p"); + expect(msg).toBeDefined(); + expect(msg).toContain("yuv420p"); + expect(msg).toContain("--format mov"); + }); + + it("stays silent when the webm output kept alpha", () => { + expect(webmAlphaAdvisory("webm", "yuva420p")).toBeUndefined(); + }); + + it("stays silent for non-webm formats (mp4 is intentionally opaque)", () => { + expect(webmAlphaAdvisory("mp4", "yuv420p")).toBeUndefined(); + expect(webmAlphaAdvisory("mov", "yuva444p10le")).toBeUndefined(); + }); + + it("stays silent when the pixel format could not be probed", () => { + expect(webmAlphaAdvisory("webm", undefined)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/webmAlphaCheck.ts b/packages/cli/src/utils/webmAlphaCheck.ts new file mode 100644 index 000000000..cbd8df103 --- /dev/null +++ b/packages/cli/src/utils/webmAlphaCheck.ts @@ -0,0 +1,90 @@ +import { execFileSync } from "node:child_process"; +import { findFFprobe } from "../browser/ffmpeg.js"; +import { c } from "../ui/colors.js"; + +/** + * True when an ffprobe `pix_fmt` string carries an alpha channel. + * + * HyperFrames always requests an alpha-capable pixel format for WebM + * (`yuva420p`), so a probed WebM output that lacks alpha proves the local + * ffmpeg/libvpx build silently dropped the alpha plane during encode. + * + * Covers the alpha families ffmpeg reports: planar YUV+A (`yuva*`), planar + * RGB+A (`gbrap*`), grayscale+A (`ya8`/`ya16le`), and packed RGBA variants. + */ +export function pixelFormatHasAlpha(pixFmt: string): boolean { + const f = pixFmt.trim().toLowerCase(); + if (!f) return false; + return ( + f.startsWith("yuva") || + f.startsWith("gbrap") || + f.startsWith("ya") || + /rgba|argb|abgr|bgra/.test(f) + ); +} + +/** + * Advisory message when a WebM render lost its requested alpha channel, or + * `undefined` when nothing is wrong / can't be determined. + * + * Pure over (format, probed pix_fmt) so the decision is unit-testable without + * spawning ffprobe. Only WebM is checked — it's the format HyperFrames encodes + * with `yuva420p`; MP4 is intentionally opaque and MOV/PNG-sequence carry alpha + * through paths that don't hit libvpx-vp9. A missing `pixFmt` (probe failed) + * stays silent rather than warning speculatively. + */ +export function webmAlphaAdvisory(format: string, pixFmt: string | undefined): string | undefined { + if (format !== "webm") return undefined; + if (!pixFmt || pixelFormatHasAlpha(pixFmt)) return undefined; + return ( + `The WebM output is ${pixFmt} (opaque). Your ffmpeg build's VP9 encoder did not ` + + `preserve the alpha channel HyperFrames requested (yuva420p), so any transparency ` + + `was flattened. For guaranteed transparency, re-render with --format mov (ProRes 4444).` + ); +} + +/** + * Best-effort ffprobe of a file's first video stream `pix_fmt`. Returns + * `undefined` on any failure (no ffprobe, spawn error, unreadable file) — this + * is a diagnostic, never a reason to fail a completed render. + */ +function probeVideoPixelFormat(filePath: string): string | undefined { + try { + const ffprobePath = findFFprobe(); + if (!ffprobePath) return undefined; + const raw = execFileSync( + ffprobePath, + [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=pix_fmt", + "-of", + "default=nw=1:nk=1", + filePath, + ], + { encoding: "utf-8", timeout: 15_000 }, + ); + return raw.trim() || undefined; + } catch { + return undefined; + } +} + +/** + * After a completed WebM render, verify the output actually carries the alpha + * channel HyperFrames requested. Some ffmpeg/libvpx builds silently encode + * VP9 as opaque `yuv420p` even when handed alpha input and `-pix_fmt yuva420p` + * — 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. + */ +export function warnIfWebmAlphaDropped(outputPath: string, format: string, quiet: boolean): void { + if (quiet || format !== "webm") return; + const advisory = webmAlphaAdvisory(format, probeVideoPixelFormat(outputPath)); + if (!advisory) return; + console.warn(`\n${c.warn("⚠")} ${c.bold("Transparency not preserved")}`); + console.warn(` ${c.dim(advisory)}\n`); +} From 4e6a709d647c801f84596572eeeaa0e63fcf3ba4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 15:13:11 -0400 Subject: [PATCH 2/2] fix(cli): key WebM alpha check on ALPHA_MODE tag, not pix_fmt (R1 blocker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 (Rames/Via) correctly flagged the detection as ~100% false-positive on working builds. libvpx-vp9 stores the alpha plane in a Matroska BlockAdditional sidecar, so ffprobe ALWAYS reports pix_fmt=yuv420p for a correct transparent WebM (per docs/guides/rendering.mdx #1823 and the webm-concat-copy smoke test). The real signal is the stream-level ALPHA_MODE=1 tag: a working encode writes it; a build that can't emit the sidecar omits it and produces genuinely opaque output. Re-cut the probe to read stream_tags=alpha_mode (JSON, case-insensitive) and warn only when a probed WebM lacks ALPHA_MODE=1. Tests inverted accordingly (alphaMode:true → silent; alphaMode:false → warn). Verified end-to-end: a transparent webm render on an alpha-preserving build (ALPHA_MODE=1) now emits 0 warnings; previously it warned on every webm. --- packages/cli/src/utils/webmAlphaCheck.test.ts | 57 +++-------- packages/cli/src/utils/webmAlphaCheck.ts | 97 +++++++++++-------- 2 files changed, 70 insertions(+), 84 deletions(-) diff --git a/packages/cli/src/utils/webmAlphaCheck.test.ts b/packages/cli/src/utils/webmAlphaCheck.test.ts index d5dd1de3d..7fc812ff0 100644 --- a/packages/cli/src/utils/webmAlphaCheck.test.ts +++ b/packages/cli/src/utils/webmAlphaCheck.test.ts @@ -1,54 +1,29 @@ import { describe, expect, it } from "vitest"; -import { pixelFormatHasAlpha, webmAlphaAdvisory } from "./webmAlphaCheck.js"; - -describe("pixelFormatHasAlpha", () => { - it("recognizes alpha-capable pixel formats", () => { - for (const f of [ - "yuva420p", - "yuva444p10le", - "gbrap", - "gbrap10le", - "rgba", - "bgra", - "argb", - "abgr", - "ya8", - "ya16le", - ]) { - expect(pixelFormatHasAlpha(f), f).toBe(true); - } - }); - - it("rejects opaque pixel formats", () => { - for (const f of ["yuv420p", "yuv444p", "yuv420p10le", "gbrp", "rgb24", "bgr0", ""]) { - expect(pixelFormatHasAlpha(f), f).toBe(false); - } - }); - - it("is case- and whitespace-insensitive", () => { - expect(pixelFormatHasAlpha(" YUVA420P \n")).toBe(true); - expect(pixelFormatHasAlpha(" YUV420P ")).toBe(false); - }); -}); +import { webmAlphaAdvisory } from "./webmAlphaCheck.js"; describe("webmAlphaAdvisory", () => { - it("warns when a webm output lost its requested alpha", () => { - const msg = webmAlphaAdvisory("webm", "yuv420p"); + it("warns when a probed webm lacks the ALPHA_MODE sidecar tag", () => { + // A build that dropped the alpha sidecar: ffprobe reported a stream but no + // ALPHA_MODE=1 tag. (pix_fmt is irrelevant — libvpx-vp9 always reports + // yuv420p; the sidecar tag is the real signal.) + const msg = webmAlphaAdvisory("webm", { probed: true, alphaMode: false }); expect(msg).toBeDefined(); - expect(msg).toContain("yuv420p"); + expect(msg).toContain("ALPHA_MODE"); expect(msg).toContain("--format mov"); }); - it("stays silent when the webm output kept alpha", () => { - expect(webmAlphaAdvisory("webm", "yuva420p")).toBeUndefined(); + it("stays SILENT when the webm carries ALPHA_MODE=1 (working transparent WebM)", () => { + // Regression guard for the #2044 R1 blocker: a correct transparent WebM + // reports pix_fmt=yuv420p BUT ALPHA_MODE=1 — it must NOT warn. + expect(webmAlphaAdvisory("webm", { probed: true, alphaMode: true })).toBeUndefined(); }); - it("stays silent for non-webm formats (mp4 is intentionally opaque)", () => { - expect(webmAlphaAdvisory("mp4", "yuv420p")).toBeUndefined(); - expect(webmAlphaAdvisory("mov", "yuva444p10le")).toBeUndefined(); + it("stays silent when the output could not be probed", () => { + expect(webmAlphaAdvisory("webm", { probed: false, alphaMode: false })).toBeUndefined(); }); - it("stays silent when the pixel format could not be probed", () => { - expect(webmAlphaAdvisory("webm", undefined)).toBeUndefined(); + it("stays silent for non-webm formats (mp4 opaque; mov carries alpha natively)", () => { + expect(webmAlphaAdvisory("mp4", { probed: true, alphaMode: false })).toBeUndefined(); + expect(webmAlphaAdvisory("mov", { probed: true, alphaMode: false })).toBeUndefined(); }); }); diff --git a/packages/cli/src/utils/webmAlphaCheck.ts b/packages/cli/src/utils/webmAlphaCheck.ts index cbd8df103..af1074c54 100644 --- a/packages/cli/src/utils/webmAlphaCheck.ts +++ b/packages/cli/src/utils/webmAlphaCheck.ts @@ -3,55 +3,55 @@ import { findFFprobe } from "../browser/ffmpeg.js"; import { c } from "../ui/colors.js"; /** - * True when an ffprobe `pix_fmt` string carries an alpha channel. - * - * HyperFrames always requests an alpha-capable pixel format for WebM - * (`yuva420p`), so a probed WebM output that lacks alpha proves the local - * ffmpeg/libvpx build silently dropped the alpha plane during encode. - * - * Covers the alpha families ffmpeg reports: planar YUV+A (`yuva*`), planar - * RGB+A (`gbrap*`), grayscale+A (`ya8`/`ya16le`), and packed RGBA variants. + * 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 function pixelFormatHasAlpha(pixFmt: string): boolean { - const f = pixFmt.trim().toLowerCase(); - if (!f) return false; - return ( - f.startsWith("yuva") || - f.startsWith("gbrap") || - f.startsWith("ya") || - /rgba|argb|abgr|bgra/.test(f) - ); +export interface WebmAlphaProbe { + probed: boolean; + /** True when the VP9 stream declares the alpha sidecar (ALPHA_MODE=1 tag). */ + alphaMode: boolean; } /** - * Advisory message when a WebM render lost its requested alpha channel, or + * Decide whether to warn that a WebM render lost its transparency, or * `undefined` when nothing is wrong / can't be determined. * - * Pure over (format, probed pix_fmt) so the decision is unit-testable without - * spawning ffprobe. Only WebM is checked — it's the format HyperFrames encodes - * with `yuva420p`; MP4 is intentionally opaque and MOV/PNG-sequence carry alpha - * through paths that don't hit libvpx-vp9. A missing `pixFmt` (probe failed) - * stays silent rather than warning speculatively. + * 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, pixFmt: string | undefined): string | undefined { +export function webmAlphaAdvisory(format: string, probe: WebmAlphaProbe): string | undefined { if (format !== "webm") return undefined; - if (!pixFmt || pixelFormatHasAlpha(pixFmt)) return undefined; + if (!probe.probed || probe.alphaMode) return undefined; return ( - `The WebM output is ${pixFmt} (opaque). Your ffmpeg build's VP9 encoder did not ` + - `preserve the alpha channel HyperFrames requested (yuva420p), so any transparency ` + - `was flattened. For guaranteed transparency, re-render with --format mov (ProRes 4444).` + "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)." ); } /** - * Best-effort ffprobe of a file's first video stream `pix_fmt`. Returns - * `undefined` on any failure (no ffprobe, spawn error, unreadable file) — this - * is a diagnostic, never a reason to fail a completed render. + * 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 probeVideoPixelFormat(filePath: string): string | undefined { +function probeWebmAlpha(filePath: string): WebmAlphaProbe { try { const ffprobePath = findFFprobe(); - if (!ffprobePath) return undefined; + if (!ffprobePath) return { probed: false, alphaMode: false }; const raw = execFileSync( ffprobePath, [ @@ -60,30 +60,41 @@ function probeVideoPixelFormat(filePath: string): string | undefined { "-select_streams", "v:0", "-show_entries", - "stream=pix_fmt", + "stream=codec_name:stream_tags=alpha_mode", "-of", - "default=nw=1:nk=1", + "json", filePath, ], { encoding: "utf-8", timeout: 15_000 }, ); - return raw.trim() || undefined; + const parsed = JSON.parse(raw) as { + streams?: Array<{ codec_name?: string; tags?: Record }>; + }; + 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", + ); + return { probed: true, alphaMode }; } catch { - return undefined; + return { probed: false, alphaMode: false }; } } /** * After a completed WebM render, verify the output actually carries the alpha - * channel HyperFrames requested. Some ffmpeg/libvpx builds silently encode - * VP9 as opaque `yuv420p` even when handed alpha input and `-pix_fmt yuva420p` - * — 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. + * 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, probeVideoPixelFormat(outputPath)); + 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`);