fix(producer,engine): gate drawelement on ancestor background-image + tail verify sample

This commit is contained in:
Vance Ingalls
2026-07-11 12:00:25 -07:00
parent d51fa7eba2
commit 0809c98518
5 changed files with 194 additions and 1 deletions
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { computeDeVerifySampleFractions } from "./frameCapture.js";
describe("computeDeVerifySampleFractions", () => {
it("pins the last sample at 95% so late-onset damage is sampled", () => {
const fractions = computeDeVerifySampleFractions(4);
expect(fractions).toEqual([0.25, 0.5, 0.75, 0.95]);
});
it("keeps the requested sample count", () => {
for (const k of [1, 2, 3, 4, 8]) {
expect(computeDeVerifySampleFractions(k)).toHaveLength(k);
}
});
it("uses only the tail sample when k is 1", () => {
expect(computeDeVerifySampleFractions(1)).toEqual([0.95]);
});
it("returns an empty grid when verification is disabled", () => {
expect(computeDeVerifySampleFractions(0)).toEqual([]);
expect(computeDeVerifySampleFractions(-2)).toEqual([]);
});
it("stays strictly increasing within (0, 1) for every supported k", () => {
for (let k = 1; k <= 8; k++) {
const fractions = computeDeVerifySampleFractions(k);
for (let i = 0; i < fractions.length; i++) {
const f = fractions[i] ?? 0;
expect(f).toBeGreaterThan(0);
expect(f).toBeLessThan(1);
if (i > 0) expect(f).toBeGreaterThan(fractions[i - 1] ?? 0);
}
}
});
});
+14 -1
View File
@@ -3107,6 +3107,19 @@ export async function getCompositionDuration(session: CaptureSession): Promise<n
* init-time screenshot → the render falls back to the screenshot path (slower,
* never wrong). Cost when passing: ~K×(seek+screenshot) ≈ 150300ms at init.
*/
/**
* Timeline fractions the self-verify samples. First k-1 evenly spaced, last
* pinned at 95%: late-onset damage (an end-of-comp reveal exposing pixels DE
* paints wrong) was invisible to the old (i+1)/(k+1) grid, whose final sample
* sat at 80% — measured miss: a body-gradient drop starting at ~79% of the
* timeline passed verification while the drained output bottomed at 30.9 dB.
*/
export function computeDeVerifySampleFractions(k: number): number[] {
if (k <= 0) return [];
if (k === 1) return [0.95];
return [...Array.from({ length: k - 1 }, (_, i) => (i + 1) / k), 0.95];
}
async function captureDeVerificationFrames(
session: CaptureSession,
page: Page,
@@ -3153,7 +3166,7 @@ async function captureDeVerificationFrames(
// render (the detectCssEffectRisk lesson), and because DE frames and truth
// would share the corruption, PSNR would pass on the damaged output.
// Seeking 0 → ascending reproduces the render's own seek order.
const fractions = Array.from({ length: k }, (_, i) => (i + 1) / (k + 1));
const fractions = computeDeVerifySampleFractions(k);
const seekTo = async (t: number): Promise<void> => {
await page.evaluate((tt: number) => {
const hf = (
@@ -8,6 +8,7 @@ import {
collectExternalAssets,
compileForRender,
injectSdkPositionEditsRenderScript,
detectAncestorBackgroundImage,
detectRenderModeHints,
detectShaderTransitionUsage,
detectThreeDTransformUsage,
@@ -718,6 +719,65 @@ describe("detectThreeDTransformUsage", () => {
});
});
describe("detectAncestorBackgroundImage", () => {
const wrap = (headCss: string, bodyAttrs = "", inner = "") =>
`<!doctype html><html><head><style>${headCss}</style></head><body${bodyAttrs ? ` ${bodyAttrs}` : ""}>` +
`<div id="root" data-composition-id="main" data-duration="10">${inner}</div></body></html>`;
it("detects a linear-gradient body background from a style rule", () => {
expect(
detectAncestorBackgroundImage(
wrap("body { background: linear-gradient(135deg, #1b2735, #090a0f); }"),
),
).toBe(true);
});
it("detects a url() background-image on html", () => {
expect(detectAncestorBackgroundImage(wrap('html { background-image: url("bg.png"); }'))).toBe(
true,
);
});
it("detects an inline gradient style on body", () => {
expect(
detectAncestorBackgroundImage(
wrap("", 'style="background: radial-gradient(circle, #111, #000)"'),
),
).toBe(true);
});
it("detects a class-selected wrapper between body and the root", () => {
const html =
"<!doctype html><html><head><style>.page-bg { background-image: linear-gradient(#111, #000); }</style></head>" +
'<body><div class="page-bg"><div data-composition-id="main" data-duration="10"></div></div></body></html>';
expect(detectAncestorBackgroundImage(html)).toBe(true);
});
it("ignores background-image on elements inside the composition root", () => {
expect(
detectAncestorBackgroundImage(
wrap(
"#hero { background-image: linear-gradient(#111, #000); }",
"",
'<div id="hero"></div>',
),
),
).toBe(false);
});
it("ignores plain background-color ancestors", () => {
expect(detectAncestorBackgroundImage(wrap("html, body { background: #0d1117; }"))).toBe(false);
});
it("returns false without a composition root", () => {
expect(
detectAncestorBackgroundImage(
"<html><head><style>body { background: linear-gradient(#111, #000); }</style></head><body></body></html>",
),
).toBe(false);
});
});
describe("detectShaderTransitionUsage", () => {
it("detects authored HyperShader initialization", () => {
const html = `<!doctype html>
@@ -78,6 +78,8 @@ export interface CompiledComposition {
usesThreeDTransforms: boolean;
/** Author HTML/CSS use mix-blend-mode (pre-CDN-inline scan). */
usesMixBlendMode: boolean;
/** Ancestors of the composition root carry a background-image (gradient/url). */
hasAncestorBackgroundImage: boolean;
}
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
@@ -324,6 +326,64 @@ function detectMixBlendModeUsage(html: string): boolean {
return MIX_BLEND_MODE_PATTERN.test(html);
}
/** A background declaration whose value paints an image (gradient or url). */
const BACKGROUND_IMAGE_DECL_PATTERN =
/(?:^|;|\{)\s*background(?:-image)?\s*:[^;}]*(?:\bgradient\s*\(|url\s*\()/i;
/**
* Background-image signals on ancestors of the composition root.
* drawElementImage only paints the captured subtree; drawElementService's
* per-frame ancestor fill replicates what lies behind it by walking up the
* DOM for the nearest non-transparent `backgroundColor`. A background-IMAGE
* (linear-gradient, url) on <body>/<html>/a wrapper reads as transparent in
* that scan, so a deeper ancestor's solid color paints instead measured:
* a body `linear-gradient` replaced by the html background color wherever
* the subtree left pixels uncovered (30.9 dB min vs baseline), and the
* damage can set in late enough to slip past the self-verify sample grid.
* Backgrounds on elements INSIDE the root are painted correctly and are
* deliberately not matched this walks only the root's ancestor chain and
* the style rules that select into it.
*/
export function detectAncestorBackgroundImage(html: string): boolean {
const { document } = parseHTML(html);
const root = document.querySelector("[data-composition-id]");
if (!root) return false;
const ancestors: Element[] = [];
for (let el = root.parentElement; el; el = el.parentElement) ancestors.push(el);
if (document.documentElement && !ancestors.includes(document.documentElement)) {
ancestors.push(document.documentElement);
}
// Inline styles on the ancestor chain.
for (const el of ancestors) {
const style = el.getAttribute("style");
if (style && BACKGROUND_IMAGE_DECL_PATTERN.test(`{${style}}`)) return true;
}
// <style> rules: any rule carrying an image-painting background declaration
// whose selector resolves to an ancestor of the root. Selector matching goes
// through querySelectorAll so class/id/compound selectors on wrappers are
// covered, not just literal `body`/`html`.
for (const styleEl of document.querySelectorAll("style")) {
const css = styleEl.textContent ?? "";
for (const rule of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const [, selectorList = "", declarations = ""] = rule;
if (!BACKGROUND_IMAGE_DECL_PATTERN.test(`{${declarations}}`)) continue;
for (const selector of selectorList.split(",")) {
const sel = selector.trim();
if (!sel || sel.startsWith("@")) continue;
if (/^(?:html|:root)$/i.test(sel)) return true;
try {
for (const matched of document.querySelectorAll(sel)) {
if (ancestors.includes(matched)) return true;
}
} catch {
// Selector syntax linkedom can't parse (e.g. vendor pseudo) — skip.
}
}
}
}
return false;
}
const SHADER_TRANSITION_USAGE_PATTERN =
/\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
@@ -1706,6 +1766,7 @@ export async function compileForRender(
// composition that loads GSAP from a CDN.
const usesThreeDTransforms = detectThreeDTransformUsage(sanitizedHtml);
const usesMixBlendMode = detectMixBlendModeUsage(sanitizedHtml);
const hasAncestorBackgroundImage = detectAncestorBackgroundImage(sanitizedHtml);
const normalizedFontHtml = normalizeSystemFontPrimaryFamilies(
injectTextRenderingRule(
@@ -1881,6 +1942,7 @@ export async function compileForRender(
hasShaderTransitions,
usesThreeDTransforms,
usesMixBlendMode,
hasAncestorBackgroundImage,
};
}
@@ -200,6 +200,28 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
"for this render. Capture uses the platform's baseline route.",
);
}
// Fast-capture ancestor-background-image gate, same shape as the gates above.
// drawElementService's per-frame ancestor fill replicates only the nearest
// non-transparent ancestor background-COLOR behind the captured subtree; a
// background-image (linear-gradient, url) on <body>/<html>/a wrapper reads
// as transparent there, so a deeper ancestor's solid color paints instead
// wherever the subtree leaves pixels uncovered (measured: body gradient
// replaced by html color, 30.9 dB min vs baseline — and late-onset, so the
// self-verify grid can miss it). HF_FAST_CAPTURE_ANCESTOR_BG=true bypasses
// for R&D.
if (
cfg.useDrawElement &&
process.env.HF_FAST_CAPTURE_ANCESTOR_BG !== "true" &&
compiled.hasAncestorBackgroundImage
) {
cfg.useDrawElement = false;
deCompileGate = "ancestor_background_image";
log.info(
"[Render] Fast capture: composition root's ancestors (body/html/wrapper) carry a " +
"background-image — disabling drawElementImage for this render. Capture uses the " +
"platform's baseline route.",
);
}
// Shader-transition comps: page-side compositing is the faster, purpose-built
// path for them, and resolveConfig force-disables it whenever drawElement is
// on. With drawElement default-on that trade is backwards — prefer page-side