mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(lint): flag high overlay-element counts (heavy-capture risk)
Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition with ~40 heavy overlay DOM elements — `filter:blur`, oversized `radial-gradient`, and `clip-path` animations — captures solid-black for the first ~half of the render, recovering near the end. Reproduces identically via drawElement AND forced --no-browser-gpu screenshot capture AND `snapshot`, so the capture layer itself is the offender, not encoder/mux. Independent of duration (padding the timeline grows the bad zone proportionally, doesn't shift it). Presence alone matters — even opacity:0 / visibility:hidden / unused overlays contribute. Reporter's workaround was splitting into per-transition mini-compositions + FFmpeg concat. Add compositionCheck rule `composition_heavy_overlay_count_high` (warning). Counts DOM elements that carry any of: inline `style` filter:blur / clip-path (non-none) / radial-gradient, or a class/id whose top-level CSS rule body sets one of those. `display:none` elements are counted-out (removed from render tree); opacity:0 / visibility:hidden overlays are counted-in per the field-signal repro shape. Warns at 25 to give lead time before the observed 40-element bad zone. Skips registry source and installed-block files, mirroring `composition_file_too_large`. Includes a `ts=1784040753` reference in fixHint so authors can trace the risk shape. Stack: PR #5 of 9 (base via/parity-telemetry-gate). Signed-off-by: Via Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1688,4 +1688,211 @@ describe("composition rules", () => {
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// composition_heavy_overlay_count_high — field signal ts=1784040753.
|
||||
// See rule comments in ../rules/composition.ts for the black-frame repro
|
||||
// story. Threshold: WARN at 25+ elements with filter:blur / clip-path
|
||||
// (non-none) / radial-gradient. Presence-based: opacity:0 and
|
||||
// visibility:hidden are counted-in, display:none is counted-out.
|
||||
describe("composition_heavy_overlay_count_high", () => {
|
||||
const wrap = (bodyInner: string, headInner = ""): string =>
|
||||
`<!DOCTYPE html><html><head>${headInner}</head><body>
|
||||
<div data-composition-id="main" data-start="0" data-duration="10" data-width="1920" data-height="1080">
|
||||
${bodyInner}
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
const repeat = (n: number, template: (i: number) => string): string =>
|
||||
Array.from({ length: n }, (_, i) => template(i)).join("\n");
|
||||
|
||||
it("warns when a composition has 40 blur-filtered overlays", async () => {
|
||||
const overlays = repeat(
|
||||
40,
|
||||
(i) => `<div id="ov-${i}" style="filter: blur(6px); opacity: 0.6"></div>`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.message).toMatch(/40 elements/);
|
||||
expect(finding?.message).toMatch(/filter:blur/);
|
||||
expect(finding?.fixHint).toMatch(/ts=1784040753/);
|
||||
});
|
||||
|
||||
it("warns when 30 elements share a clip-path class defined in a <style> block", async () => {
|
||||
const head = `<style>.clipped { clip-path: circle(50%); }</style>`;
|
||||
const overlays = repeat(30, (i) => `<div id="c-${i}" class="clipped"></div>`);
|
||||
const result = await lintHyperframeHtml(wrap(overlays, head));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toMatch(/30 elements/);
|
||||
});
|
||||
|
||||
it("warns when 25 mixed heavy overlays are present (blur + clip-path + radial-gradient)", async () => {
|
||||
const head = `<style>.clipped { clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); }</style>`;
|
||||
const blur = repeat(9, (i) => `<div id="b-${i}" style="filter: blur(4px)"></div>`);
|
||||
const clip = repeat(8, (i) => `<div id="c-${i}" class="clipped"></div>`);
|
||||
const radial = repeat(
|
||||
8,
|
||||
(i) => `<div id="r-${i}" style="background: radial-gradient(circle, red, blue)"></div>`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(wrap(blur + clip + radial, head));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toMatch(/25 elements/);
|
||||
});
|
||||
|
||||
it("does not warn when only 5 blur overlays are present (well below threshold)", async () => {
|
||||
const overlays = repeat(5, (i) => `<div id="ov-${i}" style="filter: blur(6px)"></div>`);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn on 40 plain non-overlay divs (no heavy CSS anywhere)", async () => {
|
||||
const overlays = repeat(40, (i) => `<div id="p-${i}">plain ${i}</div>`);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts opacity:0 blur overlays IN (presence alone matters per field signal)", async () => {
|
||||
const overlays = repeat(
|
||||
30,
|
||||
(i) => `<div id="hidden-${i}" style="filter: blur(6px); opacity: 0"></div>`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toMatch(/30 elements/);
|
||||
});
|
||||
|
||||
it("counts visibility:hidden blur overlays IN (presence alone matters)", async () => {
|
||||
const overlays = repeat(
|
||||
30,
|
||||
(i) => `<div id="hidden-${i}" style="filter: blur(6px); visibility: hidden"></div>`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("counts display:none blur overlays OUT (element removed from render tree)", async () => {
|
||||
const overlays = repeat(
|
||||
40,
|
||||
(i) => `<div id="gone-${i}" style="filter: blur(6px); display: none"></div>`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn on registry source files (block library authoring surface)", async () => {
|
||||
const overlays = repeat(40, (i) => `<div id="ov-${i}" style="filter: blur(6px)"></div>`);
|
||||
const result = await lintHyperframeHtml(wrap(overlays), {
|
||||
filePath: "/project/registry/blocks/blur-hero/blur-hero.html",
|
||||
});
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn on registry-installed block files (`hyperframes-registry-item` marker)", async () => {
|
||||
const overlays = repeat(40, (i) => `<div id="ov-${i}" style="filter: blur(6px)"></div>`);
|
||||
const html =
|
||||
"<!-- hyperframes-registry-item: blur-hero -->\n" +
|
||||
`<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
|
||||
${overlays}
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/blur-hero.html",
|
||||
});
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when 24 heavy overlays are present (just below threshold)", async () => {
|
||||
const overlays = repeat(24, (i) => `<div id="ov-${i}" style="filter: blur(6px)"></div>`);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores `clip-path: none` (does not count as a heavy overlay)", async () => {
|
||||
const overlays = repeat(40, (i) => `<div id="none-${i}" style="clip-path: none"></div>`);
|
||||
const result = await lintHyperframeHtml(wrap(overlays));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches heavy selectors by leftmost id (e.g. `#hero { clip-path: ... }`)", async () => {
|
||||
const head = `<style>#hero-${0} { clip-path: circle(30%); }</style>`;
|
||||
// Single id selector wouldn't match 30 elements meaningfully, so use a
|
||||
// class-based repro plus one id-hit to prove the id lookup runs.
|
||||
const clipHead = `<style>.clipped { clip-path: circle(50%); }</style>${head}`;
|
||||
const clipped = repeat(29, (i) => `<div id="c-${i}" class="clipped"></div>`);
|
||||
const idHit = `<div id="hero-0"></div>`;
|
||||
const result = await lintHyperframeHtml(wrap(clipped + idHit, clipHead));
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toMatch(/30 elements/);
|
||||
});
|
||||
|
||||
it("uses sub-composition-flavored fix hint when isSubComposition is set", async () => {
|
||||
const overlays = repeat(30, (i) => `<div id="ov-${i}" style="filter: blur(6px)"></div>`);
|
||||
const result = await lintHyperframeHtml(wrap(overlays), {
|
||||
isSubComposition: true,
|
||||
});
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.fixHint).toMatch(/sub-composition further/);
|
||||
});
|
||||
|
||||
it("does not double-count the composition root itself (only overlay children)", async () => {
|
||||
// 25 blur overlays live inside a root that itself has `filter: blur(...)`.
|
||||
// If we counted the root too, the count would be 26 (still fires); the
|
||||
// message must report 25 to prove the root skip is working.
|
||||
const overlays = repeat(25, (i) => `<div id="ov-${i}" style="filter: blur(6px)"></div>`);
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div data-composition-id="main" data-start="0" data-duration="10" data-width="1920" data-height="1080" style="filter: blur(8px)">
|
||||
${overlays}
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "composition_heavy_overlay_count_high",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toMatch(/25 elements/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,46 @@ const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"])
|
||||
const CAPTION_CUE_TOKEN =
|
||||
/^(?:caption(?:[-_](?:group|word|line|block|cue|text))?|subtitle(?:[-_](?:group|line|cue|text))?|cg-.+)$/i;
|
||||
|
||||
// composition_heavy_overlay_count_high — warn when a composition carries this
|
||||
// many or more elements whose CSS uses filter:blur, clip-path (non-none), or
|
||||
// radial-gradient. Field signal ts=1784040753 (#hyperframes-cli-feedback):
|
||||
// a composition with ~40 such elements captures solid-black for the first
|
||||
// ~half of the render, recovering near the end. Presence alone matters —
|
||||
// opacity:0 and visibility:hidden overlays still contribute — so the rule
|
||||
// counts every one that isn't display:none-hidden. Threshold sits below the
|
||||
// observed 40-element repro (25) so authors get lead time; adjust here if
|
||||
// noise/signal shifts, since a per-rule config option would also require
|
||||
// plumbing through HyperframeLinterOptions across every embedder.
|
||||
const HEAVY_OVERLAY_ELEMENT_COUNT_WARN = 25;
|
||||
const HEAVY_OVERLAY_EXEMPT_TAGS = new Set([
|
||||
"audio",
|
||||
"body",
|
||||
"br",
|
||||
"defs",
|
||||
"head",
|
||||
"hr",
|
||||
"html",
|
||||
"link",
|
||||
"meta",
|
||||
"script",
|
||||
"source",
|
||||
"style",
|
||||
"template",
|
||||
"title",
|
||||
"use",
|
||||
"video",
|
||||
]);
|
||||
// Matches any of: `filter: <...>blur(...)`, `clip-path: <non-none-value>`,
|
||||
// or `radial-gradient(...)`. Property terminator is `;` or `}`; value class
|
||||
// excludes both so we don't over-match into the next declaration. `clip-path`
|
||||
// escapes when its value starts with a CSS-wide keyword that leaves the render
|
||||
// tree unaffected (none / inherit / initial / unset) — the whitespace-eating
|
||||
// `\s*` lives *inside* the negative lookahead so the engine can't backtrack
|
||||
// `\s*` from outside to 0-width and slip past the keyword guard.
|
||||
const HEAVY_OVERLAY_CSS_PATTERN =
|
||||
/(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
|
||||
const INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
|
||||
|
||||
// `parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004`. Sub-second
|
||||
// authored adjacencies survive parse + add as a value a few ulps above the
|
||||
// next clip's start; a strict `>` fires the overlap rule on adjacencies that
|
||||
@@ -106,6 +146,46 @@ function leftmostCompoundClasses(selector: string): string[] {
|
||||
return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
|
||||
}
|
||||
|
||||
// Id token in a selector's leftmost compound. `#hero .title` → "hero";
|
||||
// `.a#b > .c` → "b"; `.a .b` → null. Companion to leftmostCompoundClasses;
|
||||
// splits on the same combinator set so the two agree on where "leftmost" ends.
|
||||
function leftmostCompoundId(selector: string): string | null {
|
||||
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
|
||||
return leftmost.match(/#([\w-]+)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
// Class tokens + ids whose rule body sets a "heavy overlay" property
|
||||
// (filter:blur, clip-path non-none, or radial-gradient). Only top-level rules
|
||||
// are scanned — the flat `[^{}]*` body class naturally skips @keyframes
|
||||
// bodies (which contain nested `{...}` stops) and other @-rules, so keyframe
|
||||
// selectors like `0%`/`100%` don't leak in.
|
||||
function collectHeavyOverlayHooks(styles: ExtractedBlock[]): {
|
||||
classes: Set<string>;
|
||||
ids: Set<string>;
|
||||
} {
|
||||
const classes = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
for (const style of styles) {
|
||||
const noComments = style.content.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ruleWithBody.exec(noComments)) !== null) {
|
||||
const header = (m[1] ?? "").trim();
|
||||
const body = m[2] ?? "";
|
||||
if (!header || header.startsWith("@")) continue;
|
||||
if (!HEAVY_OVERLAY_CSS_PATTERN.test(body)) continue;
|
||||
for (const sel of header.split(",")) {
|
||||
const trimmed = sel.trim();
|
||||
if (!trimmed) continue;
|
||||
for (const cls of leftmostCompoundClasses(trimmed)) classes.add(cls);
|
||||
const idToken = leftmostCompoundId(trimmed);
|
||||
if (idToken) ids.add(idToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { classes, ids };
|
||||
}
|
||||
|
||||
// Distinct selectors across all <style> blocks whose leftmost compound keys off one
|
||||
// of the root element's own classes — the ones that break under id-scoping.
|
||||
function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[]): string[] {
|
||||
@@ -1079,4 +1159,87 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// in runtime/init.ts). Not an error; data-duration is optional here.
|
||||
return [];
|
||||
},
|
||||
|
||||
// composition_heavy_overlay_count_high
|
||||
// Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition
|
||||
// with ~40 heavy overlay DOM elements — `filter:blur`, oversized
|
||||
// `radial-gradient`, and `clip-path` animations — captures solid-black for
|
||||
// the first ~half of the render, recovering near the end. Reproduces
|
||||
// identically via drawElement AND forced --no-browser-gpu screenshot
|
||||
// capture AND `snapshot`, so the offender is the capture layer itself, not
|
||||
// encoder/mux. Independent of duration (padding the timeline grows the bad
|
||||
// zone proportionally, doesn't shift it). Reporter's workaround was to
|
||||
// split into per-transition mini compositions + FFmpeg concat.
|
||||
//
|
||||
// Presence alone matters: opacity:0 and visibility:hidden overlays still
|
||||
// contribute to the capture-layer regression, so they're counted-in. The
|
||||
// only escape hatch is `display: none` — an element removed from the render
|
||||
// tree can't feed the compositor. Warn at 25, well below the observed
|
||||
// 40-element repro, to give authors lead time before hitting the bug.
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags, styles, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
|
||||
const { classes: heavyClassTokens, ids: heavyIds } = collectHeavyOverlayHooks(styles);
|
||||
|
||||
let heavyCount = 0;
|
||||
for (const tag of tags) {
|
||||
if (HEAVY_OVERLAY_EXEMPT_TAGS.has(tag.name)) continue;
|
||||
// Structural containers (root + mounted sub-compositions) aren't overlay
|
||||
// content — the heavy children live inside them, and each such child is
|
||||
// its own tag entry that we score directly. Counting the container too
|
||||
// would double-attribute the risk to one authoring surface.
|
||||
if (isCompositionRootOrMount(tag.raw)) continue;
|
||||
|
||||
// readJsonAttr lets a `style` value carry the opposite quote character
|
||||
// (inline `background: url("x.png")` etc.), which readAttr would truncate.
|
||||
const styleAttr = readJsonAttr(tag.raw, "style") ?? "";
|
||||
// display:none removes the element from the render tree, so the capture
|
||||
// layer never sees it — the only reliable way to keep an "unused" heavy
|
||||
// overlay in the source without paying the compositor cost.
|
||||
if (styleAttr && INLINE_STYLE_DISPLAY_NONE_PATTERN.test(styleAttr)) continue;
|
||||
|
||||
let heavy = false;
|
||||
if (styleAttr && HEAVY_OVERLAY_CSS_PATTERN.test(styleAttr)) heavy = true;
|
||||
|
||||
if (!heavy && (heavyClassTokens.size > 0 || heavyIds.size > 0)) {
|
||||
const classList = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
||||
if (classList.some((cls) => heavyClassTokens.has(cls))) heavy = true;
|
||||
if (!heavy) {
|
||||
const idValue = readAttr(tag.raw, "id");
|
||||
if (idValue && heavyIds.has(idValue)) heavy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (heavy) heavyCount += 1;
|
||||
}
|
||||
|
||||
if (heavyCount < HEAVY_OVERLAY_ELEMENT_COUNT_WARN) return [];
|
||||
|
||||
const splitTarget = options.isSubComposition
|
||||
? "Split this sub-composition further into per-transition mini-compositions"
|
||||
: "Split coherent scenes / transitions into separate .html files under compositions/";
|
||||
|
||||
return [
|
||||
{
|
||||
code: "composition_heavy_overlay_count_high",
|
||||
severity: "warning",
|
||||
message:
|
||||
`This composition has ${heavyCount} elements carrying "heavy overlay" CSS ` +
|
||||
`(filter:blur, radial-gradient, or clip-path). Field signal: a composition with ` +
|
||||
`~40 such elements — including opacity:0 / visibility:hidden ones — captures ` +
|
||||
`solid-black for the first ~half of the render, recovering near the end. Reproduces ` +
|
||||
`identically via drawElement, forced screenshot capture, and snapshot, so the capture ` +
|
||||
`layer itself is the offender (not encoder/mux). Independent of duration. Presence ` +
|
||||
`alone matters; only display:none elements are excluded here.`,
|
||||
fixHint:
|
||||
`${splitTarget} and concat the pieces (FFmpeg or the runtime's slideshow) so each ` +
|
||||
`capture only sees a small subset of heavy overlays at once. Even hidden overlays ` +
|
||||
`(opacity:0 / visibility:hidden) contribute — either remove truly unused ones from ` +
|
||||
`the source or scope them into their own per-transition sub-composition. If an ` +
|
||||
`overlay is genuinely inert for the whole clip, use display:none so it never enters ` +
|
||||
`the render tree. Field ref ts=1784040753 (#hyperframes-cli-feedback).`,
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user