diff --git a/packages/engine/src/services/frameCapture-verifySampleFractions.test.ts b/packages/engine/src/services/frameCapture-verifySampleFractions.test.ts new file mode 100644 index 0000000000..a7da475e9b --- /dev/null +++ b/packages/engine/src/services/frameCapture-verifySampleFractions.test.ts @@ -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); + } + } + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 08055386c9..d6c20d6ba1 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -3107,6 +3107,19 @@ export async function getCompositionDuration(session: CaptureSession): Promise (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 => { await page.evaluate((tt: number) => { const hf = ( diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 2fa6573ddc..55dcb47512 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -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 = "") => + `` + + `
${inner}
`; + + 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 = + "" + + '
'; + 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); }", + "", + '
', + ), + ), + ).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( + "", + ), + ).toBe(false); + }); +}); + describe("detectShaderTransitionUsage", () => { it("detects authored HyperShader initialization", () => { const html = ` diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 37dd29cf81..65d20b1457 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -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 //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; + } + //