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:
Miguel Ángel
2026-04-22 16:10:38 +02:00
committed by GitHub
parent 29b6274ebc
commit d740f5ce42
6 changed files with 188 additions and 10 deletions
+36
View File
@@ -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>
+4 -2
View File
@@ -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",
+5 -1
View File
@@ -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);
});
});
+16 -6
View File
@@ -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);