Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/core/src/lint/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/lint/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/runtime/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
});
});

Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/runtime/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
112 changes: 112 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
collectExternalAssets,
compileForRender,
detectRenderModeHints,
inlineExternalScripts,
} from "./htmlCompiler.js";
Expand Down Expand Up @@ -343,4 +344,115 @@ describe("detectRenderModeHints", () => {
expect(result.recommendScreenshot).toBe(false);
expect(result.reasons).toEqual([]);
});

it("ignores compiler-generated nested mount wrappers when detecting requestAnimationFrame", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
<script>
(function(){
var __compId = "intro";
var __run = function() {
const label = "safe";
};
if (!__compId) { __run(); return; }
/* __HF_COMPILER_MOUNT_START__ */
var __selector = '[data-composition-id="intro"]';
var __attempt = 0;
var __tryRun = function() {
if (document.querySelector(__selector)) { __run(); return; }
if (++__attempt >= 8) { __run(); return; }
requestAnimationFrame(__tryRun);
};
__tryRun();
/* __HF_COMPILER_MOUNT_END__ */
})();
</script>
</body></html>`;

const result = detectRenderModeHints(html);

expect(result.recommendScreenshot).toBe(false);
expect(result.reasons).toEqual([]);
});

it("still flags user-authored requestAnimationFrame inside nested composition scripts", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
<script>
(function(){
var __compId = "intro";
var __run = function() {
function tick() {
requestAnimationFrame(tick);
}
tick();
};
if (!__compId) { __run(); return; }
/* __HF_COMPILER_MOUNT_START__ */
var __selector = '[data-composition-id="intro"]';
var __attempt = 0;
var __tryRun = function() {
if (document.querySelector(__selector)) { __run(); return; }
if (++__attempt >= 8) { __run(); return; }
requestAnimationFrame(__tryRun);
};
__tryRun();
/* __HF_COMPILER_MOUNT_END__ */
})();
</script>
</body></html>`;

const result = detectRenderModeHints(html);

expect(result.recommendScreenshot).toBe(true);
expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]);
});

it("does not recommend screenshot mode for nested compositions that hoist GSAP from a CDN script", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-mode-"));
const compositionsDir = join(projectDir, "compositions");
mkdirSync(compositionsDir, { recursive: true });

writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080">
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0"></div>
</div>
</body></html>`,
);
writeFileSync(
join(compositionsDir, "intro.html"),
`<template id="intro-template">
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<div data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title">Hello</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["intro"] = gsap.timeline({ paused: true });
</script>
</div>
</template>`,
);

const originalFetch = globalThis.fetch;
globalThis.fetch = mock(async () => {
return new Response(
"window.gsap = { timeline: function() { return { paused: true }; } }; function __ticker(){ requestAnimationFrame(__ticker); }",
{ status: 200 },
);
}) as any;

try {
const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);

expect(result.renderModeHints.recommendScreenshot).toBe(false);
expect(result.renderModeHints.reasons).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
16 changes: 15 additions & 1 deletion packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,23 @@ function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
}

const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
const COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */";
const COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */";

function stripJsComments(source: string): string {
return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
}

function stripCompilerMountBootstrap(source: string): string {
return source.replace(
new RegExp(
`${COMPILER_MOUNT_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${COMPILER_MOUNT_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
"g",
),
"",
);
}

export function detectRenderModeHints(html: string): RenderModeHints {
const reasons: RenderModeHint[] = [];
const { document } = parseHTML(html);
Expand All @@ -96,7 +108,7 @@ export function detectRenderModeHints(html: string): RenderModeHints {
while ((scriptMatch = scriptPattern.exec(html)) !== null) {
const attrs = scriptMatch[1] || "";
if (/\bsrc\s*=/i.test(attrs)) continue;
const content = stripJsComments(scriptMatch[2] || "");
const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
if (!/requestAnimationFrame\s*\(/.test(content)) continue;
reasons.push({
code: "requestAnimationFrame",
Expand Down Expand Up @@ -664,6 +676,7 @@ function inlineSubCompositions(
}
};
if (!__compId) { __run(); return; }
${COMPILER_MOUNT_BLOCK_START}
var __selector = '[data-composition-id="' + (__compId + '').replace(/"/g, '\\\\"') + '"]';
var __attempt = 0;
var __tryRun = function() {
Expand All @@ -672,6 +685,7 @@ function inlineSubCompositions(
requestAnimationFrame(__tryRun);
};
__tryRun();
${COMPILER_MOUNT_BLOCK_END}
})()`);
}
scriptEl.remove();
Expand Down
Loading