mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 13:07:11 +00:00
* fix(studio): text in Studio reads without em or en dashes * fix(studio): the dash check also catches HTML entities, and peak reads Unavailable * test(studio): the dash check test covers every entity form * fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too * fix(producer): the gate check stops early on the engine's new fallback wording
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
/**
|
|
* 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 a 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}, an unexpected inverse ` +
|
|
`pattern, worth investigating`,
|
|
diff,
|
|
};
|
|
}
|
|
return { ok: true, reason: "", diff };
|
|
}
|