mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
Merge d7c743f1c4 into 0e558d5916
This commit is contained in:
@@ -3071,3 +3071,117 @@ describe("SVG draw-on rules", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("gsap_fromto_flash_before_start", () => {
|
||||
const comp = (script: string, body = "", style = "") => `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">${body}</div>
|
||||
${style ? `<style>${style}</style>` : ""}
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
${script}
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
it("errors when a late fromTo jumps from a non-identity from-var on a visible element", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, ease: "steps(12)" }, 2);`,
|
||||
`<div id="line">code</div>`,
|
||||
),
|
||||
);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding!.severity).toBe("error");
|
||||
expect(finding!.selector).toBe("#line");
|
||||
});
|
||||
|
||||
it("errors when a late fromTo fades in an element that was never hidden", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 1.5);`,
|
||||
`<div id="hero">Hi</div>`,
|
||||
),
|
||||
);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("exempts a target hidden by authored CSS opacity:0", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", opacity: 1, duration: 0.8 }, 2);`,
|
||||
`<div id="line">code</div>`,
|
||||
`#line { opacity: 0; }`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exempts a target pre-hidden by a timeline set at the timeline start", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`tl.set("#line", { opacity: 0 }, 0);
|
||||
tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
|
||||
`<div id="line">code</div>`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exempts a target pre-hidden by a standalone gsap.set", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`gsap.set("#line", { opacity: 0 });
|
||||
tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
|
||||
`<div id="line">code</div>`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exempts identity from-vars (no jump)", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`tl.fromTo("#drift", { x: 0 }, { x: 100, duration: 3 }, 5);`,
|
||||
`<div id="drift">Hi</div>`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exempts immediateRender: true (from-vars hold from load)", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, immediateRender: true }, 2);`,
|
||||
`<div id="line">code</div>`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exempts tweens at the timeline start (zero flash window)", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(
|
||||
`tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 0);`,
|
||||
`<div id="line">code</div>`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1745,6 +1745,94 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_fromto_flash_before_start — a fromTo() positioned after the timeline's start
|
||||
// renders with GSAP's default immediateRender: false. The target therefore sits in
|
||||
// its AUTHORED state from t=0, then jumps to the tween's from-vars when it begins:
|
||||
// a visible flash/jump on every playback and every cold seek. (Real-world casualty:
|
||||
// a "typewriter" whose lines were fully readable before each line "typed".)
|
||||
// Exemptions: the authored state is already hidden (CSS opacity:0, standalone
|
||||
// gsap.set, or a timeline set-to-hidden at the timeline start) — nothing to flash;
|
||||
// the from-vars are the transform/opacity identity — no jump; immediateRender: true —
|
||||
// GSAP applies the from-vars at build time and holds them; and tweens starting at
|
||||
// the timeline start, whose flash window is zero.
|
||||
async ({ scripts, styles, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
|
||||
const TRANSFORM_IDENTITY: Record<string, number> = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
for (const script of scripts) {
|
||||
if (!/gsap\.timeline/.test(script.content)) continue;
|
||||
const windows = await cachedExtractGsapWindows(script.content);
|
||||
const preHidden = new Set([
|
||||
...cssHiddenSelectors,
|
||||
...extractStandaloneHiddenSelectors(script.content),
|
||||
]);
|
||||
// A timeline set-to-hidden at the timeline start pre-hides the target for every
|
||||
// seek; sets at later positions leave [0, position) visible, so they do not.
|
||||
const initialHiddenSets = new Set(
|
||||
windows
|
||||
.filter(
|
||||
(w) =>
|
||||
w.method === "set" &&
|
||||
w.position <= SCENE_BOUNDARY_EPSILON_SECONDS &&
|
||||
isHiddenGsapState(w.propertyValues),
|
||||
)
|
||||
.map((w) => w.targetSelector),
|
||||
);
|
||||
for (const win of windows) {
|
||||
if (win.method !== "fromTo") continue;
|
||||
if (win.immediateRender) continue;
|
||||
if (win.position <= SCENE_BOUNDARY_EPSILON_SECONDS) continue;
|
||||
if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
|
||||
const sel = win.targetSelector;
|
||||
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
|
||||
if (preHidden.has(cssKey) || initialHiddenSets.has(sel)) continue;
|
||||
const from = win.fromPropertyValues;
|
||||
if (!from) continue;
|
||||
const jumps = Object.entries(from).some(([prop, value]) => {
|
||||
const identity = TRANSFORM_IDENTITY[prop];
|
||||
if (identity !== undefined) {
|
||||
const n = numberValue(value);
|
||||
return n === null ? true : n !== identity;
|
||||
}
|
||||
if (prop === "opacity" || prop === "autoAlpha") {
|
||||
const n = numberValue(value);
|
||||
return n === null ? true : n !== 1;
|
||||
}
|
||||
return prop === "visibility" || prop === "display";
|
||||
});
|
||||
if (!jumps) continue;
|
||||
findings.push({
|
||||
code: "gsap_fromto_flash_before_start",
|
||||
severity: "error",
|
||||
message:
|
||||
`"${sel}" is visible from t=0 in its authored state, then jumps to this ` +
|
||||
`tween's from-vars at ${win.position.toFixed(2)}s (timeline fromTo defaults ` +
|
||||
"to immediateRender: false) — a visible flash on every playback and cold seek.",
|
||||
selector: sel,
|
||||
fixHint:
|
||||
`Hide the authored state until the tween starts (CSS \`opacity: 0\` on "${sel}" ` +
|
||||
'with `opacity: 1` in the destination vars, or `tl.set("' +
|
||||
sel +
|
||||
'", { opacity: 0 }, 0)`), or pass `immediateRender: true` so the ' +
|
||||
"from-vars hold from load.",
|
||||
snippet: truncateSnippet(win.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
|
||||
// or using roundProps snaps motion to integer device pixels. On the seek-by-frame
|
||||
// capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly
|
||||
|
||||
Reference in New Issue
Block a user