mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: nested GSAP sub-composition lint and render handling (#405)
## Summary - allow nested sub-composition files to inherit GSAP from their host without tripping `missing_gsap_script` - keep nested render seeks stable for sub-compositions without regressing producer baselines - stop producer render-hint detection from treating the compiler's own nested mount retry wrapper as user-authored `requestAnimationFrame()` usage ## Root Cause - the core linter treated template-based nested compositions like standalone root compositions, so it incorrectly required a local GSAP loader even when the host composition already provided GSAP - producer `detectRenderModeHints()` runs before CDN scripts are inlined, so nested GSAP exports were never failing because of the GSAP payload itself - the nested-only false positive came from the compiler-generated mount bootstrap that waits for the inlined sub-composition root with `requestAnimationFrame()` before running the hoisted inline script - preview and export seek paths also needed to stay split so the nested timeline re-arm behavior that stabilizes scrubbing does not collapse render baselines ## What Changed - lint: keep the nested GSAP false-positive fix and regression coverage for template sub-compositions - runtime: keep the render-seek behavior that preserves nested child offsets during export without changing preview scrubbing behavior - producer: mark compiler-owned mount bootstrap blocks and strip only those blocks before scanning inline scripts for raw `requestAnimationFrame()` - producer tests now cover both cases: compiler-generated wrappers are ignored, but real user-authored nested `requestAnimationFrame()` still opts into screenshot mode ## Validation - `bun test packages/core/src/lint/rules/gsap.test.ts` - `bun test packages/producer/src/services/htmlCompiler.test.ts` - `bunx oxfmt packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts` - `bunx oxlint packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts` - `bun run --filter @hyperframes/producer test --sequential chat style-11-prod` - `style-11-prod` passed locally - `chat` still shows local-only visual drift on this macOS/ARM workstation, but the render metadata now reports `renderModeHints.recommendScreenshot=false`, which is the concrete acceptance condition for `#402` - Docker CI-image repro is blocked locally by OrbStack x86/arm64 loader mismatch, so final regression confirmation is deferred to GitHub Actions Closes #392 Closes #402
This commit is contained in:
@@ -142,6 +142,42 @@ describe("GSAP rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT require a local GSAP script for sub-compositions", () => {
|
||||
const html = `<template id="intro-template">
|
||||
<div data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title">Hello</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from(".title", { opacity: 0, duration: 1 });
|
||||
window.__timelines["intro"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
const result = lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT require a local GSAP script when a template composition is linted in isolation", () => {
|
||||
const html = `<template id="intro-template">
|
||||
<div data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title">Hello</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from(".title", { opacity: 0, duration: 1 });
|
||||
window.__timelines["intro"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
const result = lintHyperframeHtml(html, { filePath: "compositions/intro.html" });
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ERRORS when GSAP animates visibility on a clip element", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -432,11 +432,13 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// missing_gsap_script
|
||||
({ scripts }) => {
|
||||
({ scripts, rawSource, options }) => {
|
||||
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
||||
const allScriptSrcs = scripts
|
||||
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
|
||||
.filter(Boolean);
|
||||
const canInheritGsapFromHost =
|
||||
options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
|
||||
|
||||
const usesGsap = allScriptTexts.some((t) =>
|
||||
/gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t),
|
||||
@@ -455,7 +457,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
(t.length > 5000 && /\bgsap\b/i.test(t)),
|
||||
);
|
||||
|
||||
if (!usesGsap || hasGsapScript || hasInlineGsap) return [];
|
||||
if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_gsap_script",
|
||||
|
||||
@@ -381,7 +381,7 @@ describe("createRuntimePlayer", () => {
|
||||
expect(deps.onRenderFrameSeek).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renderSeek rearms paused siblings before seeking the master timeline", () => {
|
||||
it("renderSeek rearms paused siblings and keeps them active for export frames", () => {
|
||||
const { master, scene1, scene2, scene5 } = createNestedTimelineHarness();
|
||||
const deps = createMockDeps(master);
|
||||
const player = createRuntimePlayer({
|
||||
@@ -390,12 +390,16 @@ describe("createRuntimePlayer", () => {
|
||||
});
|
||||
player.pause();
|
||||
player.renderSeek(5);
|
||||
expect(master.totalTime).toHaveBeenCalledWith(5, false);
|
||||
expect(scene1.time()).toBe(1.5);
|
||||
expect(scene2.time()).toBe(3.5);
|
||||
expect(scene5.time()).toBe(0);
|
||||
expect(scene1.play).toHaveBeenCalledTimes(1);
|
||||
expect(scene2.play).toHaveBeenCalledTimes(1);
|
||||
expect(scene5.play).toHaveBeenCalledTimes(1);
|
||||
expect(scene1.pause).toHaveBeenCalledTimes(1);
|
||||
expect(scene2.pause).toHaveBeenCalledTimes(1);
|
||||
expect(scene5.pause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -83,6 +83,15 @@ function seekMasterAndSiblingTimelinesDeterministically(
|
||||
}
|
||||
}
|
||||
|
||||
function activateSiblingTimelines(
|
||||
registry: Record<string, RuntimeTimelineLike | undefined> | undefined | null,
|
||||
master: RuntimeTimelineLike,
|
||||
): void {
|
||||
forEachSiblingTimeline(registry, master, (tl) => {
|
||||
tl.play();
|
||||
});
|
||||
}
|
||||
|
||||
export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
return {
|
||||
_timeline: null,
|
||||
@@ -156,12 +165,13 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
// their animations advance. Without this, non-GSAP compositions freeze
|
||||
// on their initial frame.
|
||||
const quantized = timeline
|
||||
? seekMasterAndSiblingTimelinesDeterministically(
|
||||
deps.getTimelineRegistry?.(),
|
||||
timeline,
|
||||
timeSeconds,
|
||||
canonicalFps,
|
||||
)
|
||||
? (() => {
|
||||
// Export seeks run frame-by-frame through the resolved root timeline.
|
||||
// If nested siblings stay paused, GSAP collapses the root back to the
|
||||
// authored master duration and later frames clamp incorrectly.
|
||||
activateSiblingTimelines(deps.getTimelineRegistry?.(), timeline);
|
||||
return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps);
|
||||
})()
|
||||
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
|
||||
deps.onDeterministicSeek(quantized);
|
||||
deps.setIsPlaying(false);
|
||||
|
||||
Reference in New Issue
Block a user