mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: flag visible GSAP transition overlays (#1686)
* fix: flag visible GSAP transition overlays * fix: cover GSAP overlay lint review cases
This commit is contained in:
@@ -26,6 +26,17 @@ describe("core rules", () => {
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("accepts body as the composition root", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="overlay-flash"></div>
|
||||
<script>window.__timelines = window.__timelines || {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when timeline registry is missing", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -144,6 +144,171 @@ describe("GSAP rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("errors when a full-frame transition flash starts visible before GSAP controls it", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
|
||||
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#tr-flash-1");
|
||||
expect(finding?.message).toContain("blank/white video");
|
||||
});
|
||||
|
||||
it("does not error when a full-frame transition flash is initially hidden", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:990"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
|
||||
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not error when GSAP hides a full-frame transition flash at frame zero", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set("#tr-flash-1", { opacity: 0 }, 0);
|
||||
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
|
||||
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports one full-frame transition flash finding when multiple scripts touch it", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
|
||||
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
<script>
|
||||
const tl2 = gsap.timeline({ paused: true });
|
||||
tl2.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 9.92);
|
||||
tl2.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 10.00);
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(
|
||||
result.findings.filter((f) => f.code === "gsap_fullscreen_overlay_starts_visible"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("errors when a full-frame transition flash uses a GSAP from reveal", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#tr-flash-1", { opacity: 0, duration: 0.18 }, 7.92);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe("#tr-flash-1");
|
||||
});
|
||||
|
||||
it("errors when a grouped GSAP selector targets a visible full-frame flash", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
|
||||
<div id="unused"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#unused, #tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
|
||||
tl.to("#unused, #tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe("#tr-flash-1");
|
||||
});
|
||||
|
||||
it("errors when full-frame transition flash styles come from a style block", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.tr-flash { position: fixed; inset: 0; background: #fff; pointer-events: none; z-index: 990; }
|
||||
</style>
|
||||
<div id="tr-flash-1" class="tr-flash"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".tr-flash", { opacity: 1, duration: 0.08 }, 7.92);
|
||||
tl.to(".tr-flash", { opacity: 0, duration: 0.18 }, 8.00);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".tr-flash");
|
||||
});
|
||||
|
||||
it("does NOT error when GSAP animates opacity on a clip element", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -170,6 +170,39 @@ function isHiddenGsapState(values: Record<string, string | number>): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function oneValue(
|
||||
values: Record<string, string | number>,
|
||||
keys: string[],
|
||||
): string | number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = values[key];
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isVisibleGsapState(values: Record<string, string | number>): boolean {
|
||||
const opacity = oneValue(values, ["opacity", "autoAlpha"]);
|
||||
if (typeof opacity === "number") return opacity > 0;
|
||||
if (typeof opacity === "string" && opacity.trim()) {
|
||||
const numeric = Number(opacity);
|
||||
if (Number.isFinite(numeric)) return numeric > 0;
|
||||
}
|
||||
|
||||
const visibility = stringValue(values.visibility)?.toLowerCase();
|
||||
if (visibility === "visible" || visibility === "inherit") return true;
|
||||
|
||||
const display = stringValue(values.display)?.toLowerCase();
|
||||
if (display && display !== "none") return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function makesOverlayVisible(win: GsapWindow): boolean {
|
||||
if (win.method === "from" && isHiddenGsapState(win.propertyValues)) return true;
|
||||
return isVisibleGsapState(win.propertyValues);
|
||||
}
|
||||
|
||||
function isSceneBoundaryExit(win: GsapWindow): boolean {
|
||||
if (win.end <= win.position) return false;
|
||||
if (win.method !== "to" && win.method !== "fromTo") return false;
|
||||
@@ -282,6 +315,81 @@ function getSingleClassSelector(selector: string): string | null {
|
||||
return match?.groups?.name || null;
|
||||
}
|
||||
|
||||
function readStyleProperty(style: string, property: string): string | null {
|
||||
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function cssZero(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
return /^0(?:\.0+)?(?:px|%|vw|vh|rem|em)?$/i.test(value.trim());
|
||||
}
|
||||
|
||||
function styleHasHiddenInitialState(style: string): boolean {
|
||||
const opacity = readStyleProperty(style, "opacity");
|
||||
if (opacity && Number(opacity) === 0) return true;
|
||||
if (readStyleProperty(style, "visibility")?.toLowerCase() === "hidden") return true;
|
||||
if (readStyleProperty(style, "display")?.toLowerCase() === "none") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function styleHasOpaqueBackground(style: string): boolean {
|
||||
const background =
|
||||
readStyleProperty(style, "background") || readStyleProperty(style, "background-color");
|
||||
if (!background) return false;
|
||||
const normalized = background.toLowerCase().replace(/\s+/g, "");
|
||||
if (normalized === "transparent" || normalized === "none") return false;
|
||||
if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
|
||||
if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function styleLooksFullFrameOverlay(style: string): boolean {
|
||||
const position = readStyleProperty(style, "position")?.toLowerCase();
|
||||
if (position !== "fixed" && position !== "absolute") return false;
|
||||
const coversFrame =
|
||||
cssZero(readStyleProperty(style, "inset")) ||
|
||||
(cssZero(readStyleProperty(style, "top")) &&
|
||||
cssZero(readStyleProperty(style, "right")) &&
|
||||
cssZero(readStyleProperty(style, "bottom")) &&
|
||||
cssZero(readStyleProperty(style, "left")));
|
||||
return coversFrame && styleHasOpaqueBackground(style);
|
||||
}
|
||||
|
||||
function collectSimpleStyleRules(styles: LintContext["styles"]): Map<string, string> {
|
||||
const rules = new Map<string, string>();
|
||||
for (const style of styles) {
|
||||
for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
|
||||
if (!selectorList || !body) continue;
|
||||
for (const selector of selectorList.split(",")) {
|
||||
const token = selector.trim();
|
||||
if (!/^[#.][A-Za-z0-9_-]+$/.test(token)) continue;
|
||||
rules.set(token, `${rules.get(token) || ""};${body}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function tagSimpleSelectors(tag: OpenTag): string[] {
|
||||
const selectors: string[] = [];
|
||||
const id = readAttr(tag.raw, "id");
|
||||
if (id) selectors.push(`#${id}`);
|
||||
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
|
||||
for (const className of classes) selectors.push(`.${className}`);
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function combinedTagStyle(tag: OpenTag, styleRules: Map<string, string>): string {
|
||||
const styles = [readAttr(tag.raw, "style") || ""];
|
||||
for (const selector of tagSimpleSelectors(tag)) {
|
||||
const ruleStyle = styleRules.get(selector);
|
||||
if (ruleStyle) styles.push(ruleStyle);
|
||||
}
|
||||
return styles.filter(Boolean).join(";");
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function cssTransformToGsapProps(cssTransform: string): string | null {
|
||||
const parts: string[] = [];
|
||||
@@ -399,7 +507,7 @@ function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[
|
||||
export const gsapRules: LintRule<LintContext>[] = [
|
||||
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
|
||||
// fallow-ignore-next-line complexity
|
||||
async ({ source, tags, scripts, rootCompositionId }) => {
|
||||
async ({ source, tags, scripts, styles, rootCompositionId }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
// Build clip element selector map
|
||||
@@ -424,6 +532,8 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
|
||||
const classUsage = countClassUsage(tags);
|
||||
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
|
||||
const styleRules = collectSimpleStyleRules(styles);
|
||||
const reportedVisibleOverlayKeys = new Set<string>();
|
||||
|
||||
for (const script of scripts) {
|
||||
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
|
||||
@@ -486,6 +596,59 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
}
|
||||
}
|
||||
|
||||
// gsap_fullscreen_overlay_starts_visible
|
||||
for (const tag of tags) {
|
||||
const selectors = tagSimpleSelectors(tag);
|
||||
if (selectors.length === 0) continue;
|
||||
const overlayKey = readAttr(tag.raw, "id") || String(tag.index);
|
||||
if (reportedVisibleOverlayKeys.has(overlayKey)) continue;
|
||||
const authoredStyle = combinedTagStyle(tag, styleRules);
|
||||
if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle)) continue;
|
||||
if (styleHasHiddenInitialState(authoredStyle)) continue;
|
||||
|
||||
const visibilityWindows = gsapWindows
|
||||
.filter((win) => {
|
||||
const tokens = targetedSelectorTokens(win.targetSelector);
|
||||
if (!selectors.some((selector) => tokens.has(selector))) return false;
|
||||
return win.properties.some((prop) =>
|
||||
["opacity", "autoAlpha", "visibility", "display"].includes(prop),
|
||||
);
|
||||
})
|
||||
.sort((a, b) => a.position - b.position);
|
||||
const startsHiddenAtZero = visibilityWindows.some(
|
||||
(win) =>
|
||||
win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues),
|
||||
);
|
||||
if (startsHiddenAtZero) continue;
|
||||
const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win));
|
||||
if (!firstVisible) continue;
|
||||
const selector =
|
||||
selectors.find((candidate) =>
|
||||
targetedSelectorTokens(firstVisible.targetSelector).has(candidate),
|
||||
) ||
|
||||
selectors[0] ||
|
||||
tag.name;
|
||||
const laterHidden = visibilityWindows.some(
|
||||
(win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues),
|
||||
);
|
||||
if (firstVisible.method !== "from" && !laterHidden) continue;
|
||||
|
||||
reportedVisibleOverlayKeys.add(overlayKey);
|
||||
findings.push({
|
||||
code: "gsap_fullscreen_overlay_starts_visible",
|
||||
severity: "error",
|
||||
message:
|
||||
`Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ` +
|
||||
`${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
|
||||
selector,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
`Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add ` +
|
||||
`\`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
|
||||
snippet: truncateSnippet(firstVisible.raw),
|
||||
});
|
||||
}
|
||||
|
||||
// gsap_animates_clip_element — only error when GSAP animates visibility/display
|
||||
for (const win of gsapWindows) {
|
||||
const sel = win.targetSelector;
|
||||
|
||||
@@ -80,8 +80,21 @@ export function findHtmlTag(source: string): OpenTag | null {
|
||||
}
|
||||
|
||||
export function findRootTag(source: string): OpenTag | null {
|
||||
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
|
||||
const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source);
|
||||
const bodyCloseMatch = /<\/body>/i.exec(source);
|
||||
if (
|
||||
bodyOpenMatch &&
|
||||
(readAttr(bodyOpenMatch[0], "data-composition-id") ||
|
||||
readAttr(bodyOpenMatch[0], "data-width") ||
|
||||
readAttr(bodyOpenMatch[0], "data-height"))
|
||||
) {
|
||||
return {
|
||||
raw: bodyOpenMatch[0],
|
||||
name: "body",
|
||||
attrs: bodyOpenMatch[1] ?? "",
|
||||
index: bodyOpenMatch.index,
|
||||
};
|
||||
}
|
||||
const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
||||
const bodyEnd =
|
||||
bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart
|
||||
|
||||
Reference in New Issue
Block a user