mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(engine): software-GPU parity diff helper for solid-black capture-shape bugs
Field signals ts=1784049136 (hardware-GPU intermittent black rectangles → resolved with --no-browser-gpu --low-memory-mode --workers 1) and ts=1784032286 (clip-path animated image → intermittent black rectangles → resolved with deterministic precompose). Pattern: hardware-GPU writes solid-black on some composition shapes; software-GPU / screenshot bypass restores correctness. Raw per-pixel diff alone false-positives on every compositor jitter frame; the diagnostic-grade signal is asymmetric black-only-in-A pixels (solid-black where B has content). Adds `packages/engine/src/utils/gpuParityDiff.ts`: pure helpers (`diffGpuParityFrames`, `diffGpuParityPngs`, `verifyGpuParity`) that compare two RGBA frames captured via different GPU paths, count per-pixel diffs above a tolerance, and isolate black-only-in-A / black-only-in-B pixel counts + bounding boxes. Symmetric black regions (real black content present in both captures) are NOT flagged. PNG wrapper preserves the underlying decode error as Error.cause on either side. All exposed via `@hyperframes/engine`'s package index for downstream wiring. 19 unit tests cover identity, per-pixel tolerance, the field-bug shape, the shared-black no-op case, bounding-box tightness across multiple regions, the inverse pattern, dimension mismatch, data-length mismatch, overlapping threshold rejection, custom tolerance, verdict output, PNG end-to-end, and cause-preservation on both A and B decode failures. Reduced-scope first pass. Wiring a `hyperframes verify-gpu-parity` CLI command, dual-mode capture orchestration, and integration coverage against a known-bad composition is intentionally deferred to a follow-up so the diagnostic primitive can land and be exercised in isolation. The exported surface is stable — a follow-up need only add the capture-and-diff driver. Stack: PR #7 of 9 (base via/parallel-capture-observability). Signed-off-by: Via
This commit is contained in:
@@ -262,6 +262,17 @@ export {
|
||||
|
||||
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
|
||||
|
||||
export {
|
||||
diffGpuParityFrames,
|
||||
diffGpuParityPngs,
|
||||
verifyGpuParity,
|
||||
type RgbaFrame,
|
||||
type GpuParityDiffOptions,
|
||||
type GpuParityDiffResult,
|
||||
type BlackOnlyInARegion,
|
||||
type VerifyGpuParityResult,
|
||||
} from "./utils/gpuParityDiff.js";
|
||||
|
||||
// ── Shader transitions ────────────────────────────────────────────────────────
|
||||
export {
|
||||
type TransitionFn,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflateSync } from "zlib";
|
||||
import {
|
||||
diffGpuParityFrames,
|
||||
diffGpuParityPngs,
|
||||
verifyGpuParity,
|
||||
type RgbaFrame,
|
||||
} from "./gpuParityDiff.js";
|
||||
|
||||
// ── Fixture helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Build an RGBA frame from a per-pixel fill function. */
|
||||
function makeFrame(
|
||||
width: number,
|
||||
height: number,
|
||||
fill: (x: number, y: number) => number[],
|
||||
): RgbaFrame {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const rgba = fill(x, y);
|
||||
const i = (y * width + x) * 4;
|
||||
data[i] = rgba[0] ?? 0;
|
||||
data[i + 1] = rgba[1] ?? 0;
|
||||
data[i + 2] = rgba[2] ?? 0;
|
||||
data[i + 3] = rgba[3] ?? 255;
|
||||
}
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
/** Fill the given rectangle of an existing frame in-place. */
|
||||
function paintRect(
|
||||
frame: RgbaFrame,
|
||||
rect: { x: number; y: number; w: number; h: number },
|
||||
rgba: [number, number, number, number],
|
||||
): RgbaFrame {
|
||||
for (let y = rect.y; y < rect.y + rect.h; y++) {
|
||||
for (let x = rect.x; x < rect.x + rect.w; x++) {
|
||||
const i = (y * frame.width + x) * 4;
|
||||
frame.data[i] = rgba[0];
|
||||
frame.data[i + 1] = rgba[1];
|
||||
frame.data[i + 2] = rgba[2];
|
||||
frame.data[i + 3] = rgba[3];
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
// ── PNG construction (only for the decodePng integration test) ──────────────
|
||||
|
||||
function uint32BE(n: number): Buffer {
|
||||
const b = Buffer.allocUnsafe(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
let _crcTable: Uint32Array | undefined;
|
||||
function crc32Table(): Uint32Array {
|
||||
if (_crcTable) return _crcTable;
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c;
|
||||
}
|
||||
_crcTable = t;
|
||||
return t;
|
||||
}
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
const table = crc32Table();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = (table[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function makeChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const crcBuf = uint32BE(crc32(Buffer.concat([typeBuffer, data])));
|
||||
return Buffer.concat([uint32BE(data.length), typeBuffer, data, crcBuf]);
|
||||
}
|
||||
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
function makePng(width: number, height: number, pixels: number[]): Buffer {
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
const scanlines: number[] = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
scanlines.push(0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
scanlines.push(pixels[i] ?? 0, pixels[i + 1] ?? 0, pixels[i + 2] ?? 0, pixels[i + 3] ?? 0);
|
||||
}
|
||||
}
|
||||
const idat = deflateSync(Buffer.from(scanlines));
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── diffGpuParityFrames ─────────────────────────────────────────────────────
|
||||
|
||||
describe("diffGpuParityFrames", () => {
|
||||
it("reports zero diff on identical frames", () => {
|
||||
const a = makeFrame(8, 8, () => [100, 150, 200, 255]);
|
||||
const b = makeFrame(8, 8, () => [100, 150, 200, 255]);
|
||||
const result = diffGpuParityFrames(a, b);
|
||||
expect(result.diffPixels).toBe(0);
|
||||
expect(result.diffFraction).toBe(0);
|
||||
expect(result.blackOnlyInA.pixels).toBe(0);
|
||||
expect(result.blackOnlyInA.boundingBox).toBeNull();
|
||||
expect(result.blackOnlyInB.pixels).toBe(0);
|
||||
});
|
||||
|
||||
it("flags every pixel when frames differ everywhere beyond tolerance", () => {
|
||||
const a = makeFrame(4, 4, () => [10, 20, 30, 255]);
|
||||
const b = makeFrame(4, 4, () => [200, 200, 200, 255]);
|
||||
const result = diffGpuParityFrames(a, b);
|
||||
expect(result.diffPixels).toBe(16);
|
||||
expect(result.diffFraction).toBe(1);
|
||||
});
|
||||
|
||||
it("tolerates sub-threshold channel jitter", () => {
|
||||
// ±7 on any channel — under default tolerance of 8 — should not count.
|
||||
const a = makeFrame(4, 4, () => [128, 128, 128, 255]);
|
||||
const b = makeFrame(4, 4, () => [135, 121, 135, 255]);
|
||||
const result = diffGpuParityFrames(a, b);
|
||||
expect(result.diffPixels).toBe(0);
|
||||
});
|
||||
|
||||
it("detects a solid-black-in-A region absent from B (the field-bug pattern)", () => {
|
||||
// B has a red rectangle where A has solid black — hardware-GPU dropped
|
||||
// pixels in the shape's region.
|
||||
const b = makeFrame(20, 20, () => [200, 40, 40, 255]);
|
||||
const a = makeFrame(20, 20, () => [200, 40, 40, 255]);
|
||||
paintRect(a, { x: 5, y: 5, w: 8, h: 6 }, [0, 0, 0, 255]);
|
||||
|
||||
const result = diffGpuParityFrames(a, b);
|
||||
expect(result.blackOnlyInA.pixels).toBe(48); // 8 * 6
|
||||
expect(result.blackOnlyInA.boundingBox).toEqual({ x: 5, y: 5, width: 8, height: 6 });
|
||||
expect(result.blackOnlyInB.pixels).toBe(0);
|
||||
expect(result.blackOnlyInB.boundingBox).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT flag pixels that are legitimately black in both frames", () => {
|
||||
// Both frames share a solid-black region — this is real content, not a
|
||||
// capture bug. Must NOT count toward blackOnlyIn{A,B}.
|
||||
const fill = () => [180, 180, 180, 255];
|
||||
const a = makeFrame(20, 20, fill);
|
||||
const b = makeFrame(20, 20, fill);
|
||||
paintRect(a, { x: 4, y: 4, w: 10, h: 10 }, [0, 0, 0, 255]);
|
||||
paintRect(b, { x: 4, y: 4, w: 10, h: 10 }, [0, 0, 0, 255]);
|
||||
|
||||
const result = diffGpuParityFrames(a, b);
|
||||
expect(result.blackOnlyInA.pixels).toBe(0);
|
||||
expect(result.blackOnlyInB.pixels).toBe(0);
|
||||
expect(result.blackOnlyInA.boundingBox).toBeNull();
|
||||
expect(result.blackOnlyInB.boundingBox).toBeNull();
|
||||
// Per-pixel diff should also be zero — frames are identical.
|
||||
expect(result.diffPixels).toBe(0);
|
||||
});
|
||||
|
||||
it("computes a tight bounding box around the black-only-in-A region", () => {
|
||||
const b = makeFrame(32, 32, () => [255, 255, 255, 255]);
|
||||
const a = makeFrame(32, 32, () => [255, 255, 255, 255]);
|
||||
// Two rects — bbox must span both.
|
||||
paintRect(a, { x: 2, y: 3, w: 3, h: 3 }, [0, 0, 0, 255]);
|
||||
paintRect(a, { x: 20, y: 25, w: 5, h: 4 }, [0, 0, 0, 255]);
|
||||
|
||||
const result = diffGpuParityFrames(a, b);
|
||||
expect(result.blackOnlyInA.pixels).toBe(3 * 3 + 5 * 4);
|
||||
expect(result.blackOnlyInA.boundingBox).toEqual({
|
||||
x: 2,
|
||||
y: 3,
|
||||
// From x=2 (inclusive) to x=24 (inclusive, last col of second rect)
|
||||
width: 24 - 2 + 1,
|
||||
// From y=3 (inclusive) to y=28 (inclusive, last row of second rect,
|
||||
// y=25 through y=28 for h=4)
|
||||
height: 28 - 3 + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags the symmetric case (black-only-in-B) separately", () => {
|
||||
const a = makeFrame(10, 10, () => [200, 200, 200, 255]);
|
||||
const b = makeFrame(10, 10, () => [200, 200, 200, 255]);
|
||||
paintRect(b, { x: 1, y: 1, w: 2, h: 2 }, [0, 0, 0, 255]);
|
||||
|
||||
const result = diffGpuParityFrames(a, b);
|
||||
expect(result.blackOnlyInA.pixels).toBe(0);
|
||||
expect(result.blackOnlyInB.pixels).toBe(4);
|
||||
expect(result.blackOnlyInB.boundingBox).toEqual({ x: 1, y: 1, width: 2, height: 2 });
|
||||
});
|
||||
|
||||
it("throws with both sizes surfaced when dimensions mismatch", () => {
|
||||
const a = makeFrame(4, 4, () => [0, 0, 0, 255]);
|
||||
const b = makeFrame(5, 4, () => [0, 0, 0, 255]);
|
||||
expect(() => diffGpuParityFrames(a, b)).toThrow(/4x4.*5x4/);
|
||||
});
|
||||
|
||||
it("throws when data length disagrees with declared dimensions", () => {
|
||||
const bad: RgbaFrame = { width: 4, height: 4, data: new Uint8Array(4 * 4 * 4 - 1) };
|
||||
const good = makeFrame(4, 4, () => [0, 0, 0, 255]);
|
||||
expect(() => diffGpuParityFrames(bad, good)).toThrow(/frame A data length/);
|
||||
expect(() => diffGpuParityFrames(good, bad)).toThrow(/frame B data length/);
|
||||
});
|
||||
|
||||
it("throws when contentSumThreshold does not exceed blackSumThreshold", () => {
|
||||
const a = makeFrame(2, 2, () => [0, 0, 0, 255]);
|
||||
const b = makeFrame(2, 2, () => [0, 0, 0, 255]);
|
||||
expect(() =>
|
||||
diffGpuParityFrames(a, b, { blackSumThreshold: 30, contentSumThreshold: 20 }),
|
||||
).toThrow(/must be strictly greater/);
|
||||
});
|
||||
|
||||
it("respects a custom pixelChannelTolerance", () => {
|
||||
const a = makeFrame(4, 4, () => [100, 100, 100, 255]);
|
||||
const b = makeFrame(4, 4, () => [120, 100, 100, 255]);
|
||||
// Default tolerance 8 → this counts as differing (|120-100|=20 > 8).
|
||||
expect(diffGpuParityFrames(a, b).diffPixels).toBe(16);
|
||||
// Loosen tolerance past the delta → no pixels differ.
|
||||
expect(diffGpuParityFrames(a, b, { pixelChannelTolerance: 25 }).diffPixels).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── verifyGpuParity ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("verifyGpuParity", () => {
|
||||
it("returns ok=true when both frames render the same content", () => {
|
||||
const a = makeFrame(16, 16, () => [50, 60, 70, 255]);
|
||||
const b = makeFrame(16, 16, () => [50, 60, 70, 255]);
|
||||
const result = verifyGpuParity(a, b);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.reason).toBe("");
|
||||
expect(result.diff.blackOnlyInA.pixels).toBe(0);
|
||||
});
|
||||
|
||||
it("returns ok=false with a diagnostic reason when hardware-GPU frame drops a shape", () => {
|
||||
const b = makeFrame(50, 50, () => [180, 100, 100, 255]);
|
||||
const a = makeFrame(50, 50, () => [180, 100, 100, 255]);
|
||||
// 10x10 black rectangle in A → 100 pixels / 2500 = 4% ≫ 0.1% default.
|
||||
paintRect(a, { x: 10, y: 10, w: 10, h: 10 }, [0, 0, 0, 255]);
|
||||
|
||||
const result = verifyGpuParity(a, b);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/hardware-GPU frame has 100 pixel/);
|
||||
expect(result.reason).toMatch(/bbox 10,10 10x10/);
|
||||
});
|
||||
|
||||
it("returns ok=true for shared black content — must not false-positive on real black shapes", () => {
|
||||
const fill = () => [220, 220, 220, 255];
|
||||
const a = makeFrame(30, 30, fill);
|
||||
const b = makeFrame(30, 30, fill);
|
||||
paintRect(a, { x: 5, y: 5, w: 8, h: 8 }, [0, 0, 0, 255]);
|
||||
paintRect(b, { x: 5, y: 5, w: 8, h: 8 }, [0, 0, 0, 255]);
|
||||
const result = verifyGpuParity(a, b);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("flags the inverse (black-only-in-B) with a distinct reason", () => {
|
||||
const a = makeFrame(50, 50, () => [180, 100, 100, 255]);
|
||||
const b = makeFrame(50, 50, () => [180, 100, 100, 255]);
|
||||
paintRect(b, { x: 5, y: 5, w: 10, h: 10 }, [0, 0, 0, 255]);
|
||||
const result = verifyGpuParity(a, b);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/software-GPU frame has 100 pixel/);
|
||||
expect(result.reason).toMatch(/inverse pattern/);
|
||||
});
|
||||
|
||||
it("honors a stricter blackOnlyFractionThreshold", () => {
|
||||
const b = makeFrame(100, 100, () => [180, 100, 100, 255]);
|
||||
const a = makeFrame(100, 100, () => [180, 100, 100, 255]);
|
||||
// 5 pixels = 0.05% — below default 0.1% but above 0.01%.
|
||||
paintRect(a, { x: 0, y: 0, w: 5, h: 1 }, [0, 0, 0, 255]);
|
||||
expect(verifyGpuParity(a, b).ok).toBe(true);
|
||||
expect(verifyGpuParity(a, b, { blackOnlyFractionThreshold: 0.0001 }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── diffGpuParityPngs (PNG-buffer wrapper) ──────────────────────────────────
|
||||
|
||||
describe("diffGpuParityPngs", () => {
|
||||
it("decodes and diffs PNG buffers end-to-end", () => {
|
||||
// 2x2 identical checkerboards.
|
||||
const pixels = [
|
||||
// TL red
|
||||
255, 0, 0, 255,
|
||||
// TR white
|
||||
255, 255, 255, 255,
|
||||
// BL white
|
||||
255, 255, 255, 255,
|
||||
// BR red
|
||||
255, 0, 0, 255,
|
||||
];
|
||||
const pngA = makePng(2, 2, pixels);
|
||||
const pngB = makePng(2, 2, pixels);
|
||||
const result = diffGpuParityPngs(pngA, pngB);
|
||||
expect(result.width).toBe(2);
|
||||
expect(result.height).toBe(2);
|
||||
expect(result.diffPixels).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves the underlying decode error as `cause` when frame A is malformed", () => {
|
||||
const validPng = makePng(1, 1, [0, 0, 0, 255]);
|
||||
const badPng = Buffer.from("not a png at all");
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
diffGpuParityPngs(badPng, validPng);
|
||||
} catch (err) {
|
||||
caught = err as Error;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect(caught?.message).toMatch(/failed to decode frame A/);
|
||||
expect(caught?.cause).toBeDefined();
|
||||
const causeA = caught?.cause as Error | undefined;
|
||||
expect(causeA?.message).toMatch(/not a PNG file/);
|
||||
});
|
||||
|
||||
it("preserves the underlying decode error as `cause` when frame B is malformed", () => {
|
||||
const validPng = makePng(1, 1, [0, 0, 0, 255]);
|
||||
const badPng = Buffer.from("also not a png");
|
||||
let caught: Error | undefined;
|
||||
try {
|
||||
diffGpuParityPngs(validPng, badPng);
|
||||
} catch (err) {
|
||||
caught = err as Error;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect(caught?.message).toMatch(/failed to decode frame B/);
|
||||
const causeB = caught?.cause as Error | undefined;
|
||||
expect(causeB?.message).toMatch(/not a PNG file/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* GPU Parity Diff — diagnostic helpers for detecting shape-dependent
|
||||
* hardware-GPU capture bugs by comparing frames captured via the two
|
||||
* capture paths (hardware-GPU vs software-GPU / screenshot bypass).
|
||||
*
|
||||
* Field pattern this exists to catch:
|
||||
*
|
||||
* • ts=1784049136 · hardware-GPU capture emitted intermittent black
|
||||
* rectangles in one scene; `--no-browser-gpu --low-memory-mode
|
||||
* --workers 1` (software-GPU screenshot mode) resolved it.
|
||||
* • ts=1784032286 · animated `clip-path` on a large image → intermittent
|
||||
* black rectangles from Chrome capture; deterministic precomposited
|
||||
* swipe states removed the artifact.
|
||||
* • Baseline · JetBrains Mono glyph-drop on parallel/BeginFrame text
|
||||
* capture.
|
||||
*
|
||||
* Common shape: hardware-GPU writes solid-black regions where content
|
||||
* should exist; software-GPU / screenshot fallback renders the same
|
||||
* region correctly. A raw per-pixel diff is a coarse signal for this —
|
||||
* on its own it fires on every animation frame's compositor jitter, so
|
||||
* this module also isolates the *asymmetric* black-in-A-only pattern
|
||||
* that is the diagnostic fingerprint of the bug.
|
||||
*
|
||||
* This module is the reduced-scope first pass: a pure helper + unit
|
||||
* tests. Wiring a `hyperframes verify-gpu-parity` CLI surface, capture
|
||||
* orchestration, and integration coverage is intentionally deferred to
|
||||
* a follow-up so the diagnostic primitive can land and be exercised
|
||||
* against real captured frames in isolation.
|
||||
*/
|
||||
|
||||
import { decodePng } from "./alphaBlit.js";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A decoded RGBA frame. Matches the shape returned by `decodePng`. */
|
||||
export interface RgbaFrame {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface GpuParityDiffOptions {
|
||||
/**
|
||||
* Per-channel absolute difference at which a pixel is considered "differing".
|
||||
* A pixel counts as differing if any of R/G/B channels differ by more than
|
||||
* this value. Default 8 (tolerates minor compositor jitter / dithering).
|
||||
*/
|
||||
pixelChannelTolerance?: number;
|
||||
/**
|
||||
* A pixel qualifies as "solid black" when R + G + B is ≤ this value.
|
||||
* Default 12 (covers hardware capture bugs that write pure #000 as well
|
||||
* as slightly off-black values from Chrome's compositor).
|
||||
*/
|
||||
blackSumThreshold?: number;
|
||||
/**
|
||||
* A pixel qualifies as "has content" (non-black) when R + G + B is ≥ this
|
||||
* value. Default 32 — deliberately higher than `blackSumThreshold` so the
|
||||
* two categories cannot overlap on borderline near-black pixels.
|
||||
*/
|
||||
contentSumThreshold?: number;
|
||||
/**
|
||||
* A frame is flagged as failing parity when the black-only-in-A fraction
|
||||
* of pixels exceeds this value. Default 0.001 (~0.1% of the frame).
|
||||
* Lowering trades sensitivity for false-positive risk on legitimate
|
||||
* animation frames that momentarily contain a small black shape only on
|
||||
* one capture path.
|
||||
*/
|
||||
blackOnlyFractionThreshold?: number;
|
||||
}
|
||||
|
||||
export interface BlackOnlyInARegion {
|
||||
/** Count of pixels that are black in A but have content in B. */
|
||||
pixels: number;
|
||||
/** `pixels / (width * height)`. */
|
||||
fraction: number;
|
||||
/**
|
||||
* Axis-aligned bounding box of all black-only-in-A pixels. `null` when
|
||||
* `pixels === 0`. This is intentionally coarse — a single bbox is
|
||||
* cheaper than connected-component labeling and is sufficient to
|
||||
* localize the "one large black rectangle" pattern the field bugs
|
||||
* produce. Callers wanting per-region detail can extend this later.
|
||||
*/
|
||||
boundingBox: { x: number; y: number; width: number; height: number } | null;
|
||||
}
|
||||
|
||||
export interface GpuParityDiffResult {
|
||||
/** Frame dimensions (both inputs must match). */
|
||||
width: number;
|
||||
height: number;
|
||||
/** Total pixels compared. */
|
||||
totalPixels: number;
|
||||
/** Pixels differing beyond `pixelChannelTolerance`. */
|
||||
diffPixels: number;
|
||||
/** `diffPixels / totalPixels`. */
|
||||
diffFraction: number;
|
||||
/**
|
||||
* Pixels that are solid-black in A (the hardware-GPU frame by
|
||||
* convention) *and* have content in B (the software-GPU frame). This
|
||||
* is the diagnostic-grade signal for shape-dependent hardware-GPU
|
||||
* capture bugs.
|
||||
*/
|
||||
blackOnlyInA: BlackOnlyInARegion;
|
||||
/**
|
||||
* Symmetric counterpart — black in B but content in A. Useful for
|
||||
* ruling out the inverse (extremely rare in the wild, but if it
|
||||
* shows up it means the software path is the buggy one).
|
||||
*/
|
||||
blackOnlyInB: BlackOnlyInARegion;
|
||||
}
|
||||
|
||||
export interface VerifyGpuParityResult {
|
||||
ok: boolean;
|
||||
/** Human-readable failure summary; empty when `ok === true`. */
|
||||
reason: string;
|
||||
diff: GpuParityDiffResult;
|
||||
}
|
||||
|
||||
// ── Defaults ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PIXEL_CHANNEL_TOLERANCE = 8;
|
||||
const DEFAULT_BLACK_SUM_THRESHOLD = 12;
|
||||
const DEFAULT_CONTENT_SUM_THRESHOLD = 32;
|
||||
const DEFAULT_BLACK_ONLY_FRACTION_THRESHOLD = 0.001;
|
||||
|
||||
// ── Core diff ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compare two RGBA frames captured via different GPU paths.
|
||||
*
|
||||
* The two inputs must have identical dimensions; on mismatch this throws
|
||||
* (with the mismatched sizes surfaced in the message) so callers cannot
|
||||
* silently compare misaligned frames.
|
||||
*
|
||||
* Alpha channel is *not* considered for the black-only detection — Chrome's
|
||||
* capture output is opaque along the code paths this diagnostic targets,
|
||||
* and considering alpha would false-positive on legitimately transparent
|
||||
* pixels. `pixelChannelTolerance` also compares only R/G/B for the same
|
||||
* reason.
|
||||
*/
|
||||
export function diffGpuParityFrames(
|
||||
a: RgbaFrame,
|
||||
b: RgbaFrame,
|
||||
options: GpuParityDiffOptions = {},
|
||||
): GpuParityDiffResult {
|
||||
if (a.width !== b.width || a.height !== b.height) {
|
||||
throw new Error(
|
||||
`diffGpuParityFrames: frame size mismatch — A is ${a.width}x${a.height}, ` +
|
||||
`B is ${b.width}x${b.height}`,
|
||||
);
|
||||
}
|
||||
const expectedLen = a.width * a.height * 4;
|
||||
if (a.data.length !== expectedLen) {
|
||||
throw new Error(
|
||||
`diffGpuParityFrames: frame A data length ${a.data.length} does not match ` +
|
||||
`expected ${expectedLen} (${a.width}x${a.height} * 4)`,
|
||||
);
|
||||
}
|
||||
if (b.data.length !== expectedLen) {
|
||||
throw new Error(
|
||||
`diffGpuParityFrames: frame B data length ${b.data.length} does not match ` +
|
||||
`expected ${expectedLen} (${b.width}x${b.height} * 4)`,
|
||||
);
|
||||
}
|
||||
|
||||
const tol = options.pixelChannelTolerance ?? DEFAULT_PIXEL_CHANNEL_TOLERANCE;
|
||||
const blackSum = options.blackSumThreshold ?? DEFAULT_BLACK_SUM_THRESHOLD;
|
||||
const contentSum = options.contentSumThreshold ?? DEFAULT_CONTENT_SUM_THRESHOLD;
|
||||
|
||||
if (contentSum <= blackSum) {
|
||||
throw new Error(
|
||||
`diffGpuParityFrames: contentSumThreshold (${contentSum}) must be strictly greater ` +
|
||||
`than blackSumThreshold (${blackSum}) — overlapping thresholds would double-classify ` +
|
||||
`borderline pixels`,
|
||||
);
|
||||
}
|
||||
|
||||
const total = a.width * a.height;
|
||||
const aData = a.data;
|
||||
const bData = b.data;
|
||||
|
||||
let diffPixels = 0;
|
||||
let blackOnlyAPixels = 0;
|
||||
let blackOnlyBPixels = 0;
|
||||
|
||||
let aMinX = a.width;
|
||||
let aMinY = a.height;
|
||||
let aMaxX = -1;
|
||||
let aMaxY = -1;
|
||||
let bMinX = a.width;
|
||||
let bMinY = a.height;
|
||||
let bMaxX = -1;
|
||||
let bMaxY = -1;
|
||||
|
||||
for (let y = 0; y < a.height; y++) {
|
||||
for (let x = 0; x < a.width; x++) {
|
||||
const i = (y * a.width + x) * 4;
|
||||
const ar = aData[i] ?? 0;
|
||||
const ag = aData[i + 1] ?? 0;
|
||||
const ab = aData[i + 2] ?? 0;
|
||||
const br = bData[i] ?? 0;
|
||||
const bg = bData[i + 1] ?? 0;
|
||||
const bb = bData[i + 2] ?? 0;
|
||||
|
||||
if (Math.abs(ar - br) > tol || Math.abs(ag - bg) > tol || Math.abs(ab - bb) > tol) {
|
||||
diffPixels++;
|
||||
}
|
||||
|
||||
const aSum = ar + ag + ab;
|
||||
const bSum = br + bg + bb;
|
||||
|
||||
if (aSum <= blackSum && bSum >= contentSum) {
|
||||
blackOnlyAPixels++;
|
||||
if (x < aMinX) aMinX = x;
|
||||
if (y < aMinY) aMinY = y;
|
||||
if (x > aMaxX) aMaxX = x;
|
||||
if (y > aMaxY) aMaxY = y;
|
||||
} else if (bSum <= blackSum && aSum >= contentSum) {
|
||||
blackOnlyBPixels++;
|
||||
if (x < bMinX) bMinX = x;
|
||||
if (y < bMinY) bMinY = y;
|
||||
if (x > bMaxX) bMaxX = x;
|
||||
if (y > bMaxY) bMaxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
totalPixels: total,
|
||||
diffPixels,
|
||||
diffFraction: diffPixels / total,
|
||||
blackOnlyInA: {
|
||||
pixels: blackOnlyAPixels,
|
||||
fraction: blackOnlyAPixels / total,
|
||||
boundingBox:
|
||||
blackOnlyAPixels === 0
|
||||
? null
|
||||
: { x: aMinX, y: aMinY, width: aMaxX - aMinX + 1, height: aMaxY - aMinY + 1 },
|
||||
},
|
||||
blackOnlyInB: {
|
||||
pixels: blackOnlyBPixels,
|
||||
fraction: blackOnlyBPixels / total,
|
||||
boundingBox:
|
||||
blackOnlyBPixels === 0
|
||||
? null
|
||||
: { x: bMinX, y: bMinY, width: bMaxX - bMinX + 1, height: bMaxY - bMinY + 1 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PNG-buffer convenience wrapper around `diffGpuParityFrames`. Decodes
|
||||
* both inputs via `decodePng` and delegates. Errors from the decoder are
|
||||
* re-thrown with `cause` preserved so the failing side (A or B) is
|
||||
* obvious in the stack.
|
||||
*/
|
||||
export function diffGpuParityPngs(
|
||||
pngA: Buffer,
|
||||
pngB: Buffer,
|
||||
options: GpuParityDiffOptions = {},
|
||||
): GpuParityDiffResult {
|
||||
let a: RgbaFrame;
|
||||
let b: RgbaFrame;
|
||||
try {
|
||||
a = decodePng(pngA);
|
||||
} catch (err) {
|
||||
throw new Error(`diffGpuParityPngs: failed to decode frame A`, { cause: err });
|
||||
}
|
||||
try {
|
||||
b = decodePng(pngB);
|
||||
} catch (err) {
|
||||
throw new Error(`diffGpuParityPngs: failed to decode frame B`, { cause: err });
|
||||
}
|
||||
return diffGpuParityFrames(a, b, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verdict wrapper — returns `{ ok, reason }` suitable for a follow-up
|
||||
* CLI or gate to emit. `ok === false` when either black-only fraction
|
||||
* exceeds `blackOnlyFractionThreshold`. Raw diff numbers are always
|
||||
* included on both branches so callers can log or gate on their own
|
||||
* metrics without a second pass.
|
||||
*/
|
||||
export function verifyGpuParity(
|
||||
a: RgbaFrame,
|
||||
b: RgbaFrame,
|
||||
options: GpuParityDiffOptions = {},
|
||||
): VerifyGpuParityResult {
|
||||
const diff = diffGpuParityFrames(a, b, options);
|
||||
const threshold = options.blackOnlyFractionThreshold ?? DEFAULT_BLACK_ONLY_FRACTION_THRESHOLD;
|
||||
|
||||
if (diff.blackOnlyInA.fraction > threshold) {
|
||||
const bb = diff.blackOnlyInA.boundingBox;
|
||||
const region = bb ? ` (bbox ${bb.x},${bb.y} ${bb.width}x${bb.height})` : "";
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`hardware-GPU frame has ${diff.blackOnlyInA.pixels} pixel(s) ` +
|
||||
`(${(diff.blackOnlyInA.fraction * 100).toFixed(3)}%) that are solid-black ` +
|
||||
`while software-GPU frame has content there${region} — likely shape-dependent ` +
|
||||
`hardware-GPU capture bug`,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
if (diff.blackOnlyInB.fraction > threshold) {
|
||||
const bb = diff.blackOnlyInB.boundingBox;
|
||||
const region = bb ? ` (bbox ${bb.x},${bb.y} ${bb.width}x${bb.height})` : "";
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`software-GPU frame has ${diff.blackOnlyInB.pixels} pixel(s) ` +
|
||||
`(${(diff.blackOnlyInB.fraction * 100).toFixed(3)}%) that are solid-black ` +
|
||||
`while hardware-GPU frame has content there${region} — unexpected inverse ` +
|
||||
`pattern, worth investigating`,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
return { ok: true, reason: "", diff };
|
||||
}
|
||||
Reference in New Issue
Block a user