Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
});
15 changes: 14 additions & 1 deletion packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ≈ 150–300ms 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,
Expand Down Expand Up @@ -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 = (
Expand Down
60 changes: 60 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
collectExternalAssets,
compileForRender,
injectSdkPositionEditsRenderScript,
detectAncestorBackgroundImage,
detectRenderModeHints,
detectShaderTransitionUsage,
detectThreeDTransformUsage,
Expand Down Expand Up @@ -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>
Expand Down
62 changes: 62 additions & 0 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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*=/;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1881,6 +1942,7 @@ export async function compileForRender(
hasShaderTransitions,
usesThreeDTransforms,
usesMixBlendMode,
hasAncestorBackgroundImage,
};
}

Expand Down
22 changes: 22 additions & 0 deletions packages/producer/src/services/render/stages/compileStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading