mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #2360 from heygen-com/via/issue-1-webm-alpha-probe
fix(cli): detect WebM alpha lost when ALPHA_MODE tag is written without side data
This commit is contained in:
@@ -181,6 +181,13 @@
|
||||
"file": "packages/producer/src/services/fileServer.ts",
|
||||
"exports": ["isPathInside"],
|
||||
},
|
||||
// `sampledAlphaIsFullyOpaque` is exported for direct unit testing of the
|
||||
// 1/2/3-frame byte-count gate and per-frame stride logic; the pixel-level
|
||||
// contract is too load-bearing to only exercise through `webmAlphaAdvisory`.
|
||||
{
|
||||
"file": "packages/cli/src/utils/webmAlphaCheck.ts",
|
||||
"exports": ["sampledAlphaIsFullyOpaque"],
|
||||
},
|
||||
// Studio telemetry: consumed by useRenderQueue.ts / StudioFeedbackBar.tsx
|
||||
// (deep relative imports) but fallow's static analyzer doesn't trace
|
||||
// them. Same path-resolution quirk — trackStudioSessionStart from the
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -26,4 +26,161 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,12 +101,81 @@ 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user