mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(producer,engine): gate drawelement on ancestor background-image + tail verify sample
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user