Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
da78933
perf(core): skip rendering preview scenes far from the playhead
miguel-heygen Oct 24, 2026
285606c
Merge origin/main
miguel-heygen Oct 24, 2026
8439142
refactor(core): split the far-scene pass into small helpers
miguel-heygen Oct 24, 2026
44f7742
perf(core): a first render reveals only the clips it reaches
miguel-heygen Oct 24, 2026
6044331
fix(core): reveal the clips holding first-rendered tweens, wherever t…
miguel-heygen Oct 24, 2026
4359fb8
feat(core): a scene may load a project helper and wait for fonts and …
miguel-heygen Oct 24, 2026
edb0b51
perf(core): the preview mounts only the scenes near the playhead
miguel-heygen Oct 24, 2026
5b2021c
perf(core): a seek waits only for the scene it lands in; neighbours m…
miguel-heygen Oct 24, 2026
5a1ad71
feat(studio): selecting a clip in an unmounted scene mounts that scen…
miguel-heygen Oct 24, 2026
b8314c5
Merge origin/main
miguel-heygen Oct 24, 2026
ef14523
fix(core): a source a play and a seek both need is fetched and decode…
miguel-heygen Oct 24, 2026
f15fa8c
fix(core): a scene mounting during play joins the mix without restart…
miguel-heygen Oct 24, 2026
8db1b12
fix(studio): selecting a clip waits on a scene mount only in a previe…
miguel-heygen Oct 24, 2026
b79cf69
fix(core): far scenes still mount after an edit, and an edit to a par…
miguel-heygen Oct 24, 2026
ee62eb2
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 24, 2026
09e87ec
perf(core): web audio schedules a clip only as the playhead comes nea…
miguel-heygen Oct 24, 2026
fe1dfee
chore(studio): trim the scene hold helper's comments to its package's…
miguel-heygen Oct 24, 2026
89db7a1
fix(core): a scene the preview unmounts is freed, not kept by audio, …
miguel-heygen Oct 24, 2026
4283542
fix(studio): an unmounted scene's rows and their state come from the …
miguel-heygen Oct 24, 2026
66cdebe
perf(core): the preview mounts scenes ahead of play and keeps recent …
miguel-heygen Oct 24, 2026
9fe2377
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 24, 2026
3bcb4b3
refactor(core): keep the far-scene neighbour rule private to its module
miguel-heygen Oct 24, 2026
993c830
style(core): format the far-scene neighbour rule
miguel-heygen Oct 24, 2026
17a68b1
perf(core): the preview unmounts scenes the playhead leaves, as kept …
miguel-heygen Oct 24, 2026
0a28854
fix(core): scenes that measure text, call their own animate or wait o…
miguel-heygen Oct 24, 2026
695c74f
fix(core): each preview scene carries the helpers it loads, so it mou…
miguel-heygen Oct 24, 2026
b80688c
fix(core): a mounted scene shows though its stand-in was hidden by di…
miguel-heygen Oct 24, 2026
72a6589
fix(core): a helper scenes share runs once for the film, and a remoun…
miguel-heygen Oct 24, 2026
7f6030a
fix(core): a scene that also publishes its timeline under another id …
miguel-heygen Oct 25, 2026
9883d96
feat(core): scenes that draw in 2D or chain the timeline they registe…
miguel-heygen Oct 25, 2026
aea086b
perf(core): a play frame that draws nothing new skips the hidden-clip…
miguel-heygen Oct 25, 2026
8351fe3
fix(core): a GSAP file copied into a project counts as the library fo…
miguel-heygen Oct 25, 2026
e2bbe2d
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
555eee5
style(core): drop an unneeded escape in the registered-chain pattern
miguel-heygen Oct 25, 2026
4e90fd4
chore: core and producer size budgets make room for the preview scene…
miguel-heygen Oct 25, 2026
4f4f2fb
fix(core): a scene that draws on a 2D canvas stays mounted
miguel-heygen Oct 25, 2026
8621ceb
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
94576f0
fix(core): a scene the preview unmounts lets go of timelines it built…
miguel-heygen Oct 25, 2026
1da8643
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
f91ddc7
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
73f344f
perf(core): a scene leaving the playhead keeps its last rendered size…
miguel-heygen Oct 25, 2026
7752323
refactor(core): the far-scene skip keeps last-render boxes in their o…
miguel-heygen Oct 25, 2026
673ef10
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
defa68e
fix(core): a mounting scene never records the runtime's root timeline…
miguel-heygen Oct 25, 2026
be4864a
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
be9a987
chore(producer): raise the size budget to the measured size with the …
miguel-heygen Oct 25, 2026
c17c3df
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
e1ab276
docs(core): keep the far-scene and scene-memory comments within the c…
miguel-heygen Oct 25, 2026
d4ff3fc
perf(core): style each hidden clip visible once ahead of its cut, so …
miguel-heygen Oct 25, 2026
25f5ae3
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
5a54583
fix(core): a seek into a scene still mounting as a neighbour waits fo…
miguel-heygen Oct 25, 2026
2a9fe05
chore(core): keep comment share and size budgets to main plus the mea…
miguel-heygen Oct 25, 2026
80e934a
Merge remote-tracking branch 'origin/main' into dslivers/far-scenes-s…
miguel-heygen Oct 25, 2026
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
178 changes: 175 additions & 3 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3559,9 +3559,8 @@ describe("bundleToSingleHtml sceneParts", () => {
"its script uses addEventListener",
);
expect(host("b")?.hasAttribute("data-hf-scene-no-swap")).toBe(false);
expect(host("c")?.getAttribute("data-hf-scene-no-swap")).toBe(
"it runs a script that is not a known library",
);
// A project file is read and held to the rules of an inline script.
expect(host("c")?.getAttribute("data-hf-scene-no-swap")).toBe("its script uses .animate(");
// The root loading the same URL does not make a scene's own initializer a library.
expect(host("d")?.getAttribute("data-hf-scene-no-swap")).toBe(
"it runs a script that is not a known library",
Expand All @@ -3577,6 +3576,179 @@ describe("bundleToSingleHtml sceneParts", () => {
expect(parseHTML(rendered).document.querySelector("[data-hf-scene-no-swap]")).toBeNull();
});

it("lets a scene load a project helper and wait for fonts, and refuses anything else either leaves pending", async () => {
const scenes: Record<string, string> = {
helper: `<script src="../lib/helper.js"></script><script>window.__timelines.helper = window.build("helper");</script>`,
fontsThen: `<script>document.fonts.ready.then(() => window.fit("fontsThen"));</script>`,
fontsAwait: `<script>(async () => { await document.fonts.ready; window.fit("fontsAwait"); })();</script>`,
timerHelper: `<script src="../lib/ticker.js"></script>`,
missingHelper: `<script src="../lib/gone.js"></script>`,
fetchThen: `<script>fetch("data.json").then((r) => r.json());</script>`,
otherAwait: `<script>(async () => { await document.fonts.ready; await window.load(); })();</script>`,
promise: `<script>new Promise((done) => done()).then(() => {});</script>`,
};
const names = Object.keys(scenes);
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
${names.map((n) => `<div data-composition-id="${n}" data-composition-src="compositions/${n}.html" data-start="0" data-duration="2"></div>`).join("\n")}
</div></body></html>`,
"lib/helper.js": `window.build = function (id) { var tl = gsap.timeline({ paused: true }); tl.to(document.querySelector('[data-composition-id="' + id + '"] p'), { x: 1 }); return tl; };`,
"lib/ticker.js": `setInterval(function () {}, 100);`,
...Object.fromEntries(
names.map((n) => [
`compositions/${n}.html`,
`<template id="${n}-template"><div data-composition-id="${n}"><p>${n}</p>${scenes[n]}</div></template>`,
]),
),
});
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
const refusals = Object.fromEntries(
names.map((n) => [
n,
doc.querySelector(`div[data-hf-scene="${n}"]`)?.getAttribute("data-hf-scene-no-swap") ??
null,
]),
);
expect(refusals).toEqual({
helper: null,
fontsThen: null,
fontsAwait: null,
timerHelper: "its script uses setInterval",
missingHelper: "it runs a script that is not a known library",
fetchThen: "its script uses fetch",
otherAwait: "its script uses async",
promise: "its script uses Promise",
});
});

it("runs a helper scenes share once for the film, so a scene mounts without the one that loaded it first", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="4">
<div data-composition-id="late" data-composition-src="compositions/late.html" data-start="2" data-duration="2"></div>
<div data-composition-id="early" data-composition-src="compositions/early.html" data-start="0" data-duration="2"></div>
</div></body></html>`,
"lib/kit.js": `window.Kit = { build: function (id) { return id; } };`,
"lib/own.js": `window.Own = 1;`,
"compositions/late.html": `<template id="late-template"><div data-composition-id="late"><p>late</p><script src="../lib/kit.js"></script><script src="../lib/own.js"></script><script>window.Kit.build("late");</script></div></template>`,
"compositions/early.html": `<template id="early-template"><div data-composition-id="early"><p>early</p><script src="../lib/kit.js"></script><script>window.Kit.build("early");</script></div></template>`,
});
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
const texts = (selector: string) =>
[...doc.querySelectorAll(selector)].map((el) => el.textContent ?? "").join("\n");
const film = texts("script:not([data-hf-scene])");
expect(film.match(/window\.Kit =/g)).toHaveLength(1);
expect(texts('script[data-hf-scene="late"]')).toContain("window.Own = 1");
expect(texts("script[data-hf-scene]")).not.toContain("window.Kit =");
const rendered = parseHTML(await bundleToSingleHtml(dir)).document.documentElement.outerHTML;
expect(rendered.match(/window\.Kit =/g)).toHaveLength(1);
});

it("treats a GSAP file copied into the project as the library, at the root and in a scene, and no other file", async () => {
const gsapCopy = `/*!\n * GSAP 3.14.2\n * https://gsap.com\n */\nwindow.gsap = { all: "*" }; requestAnimationFrame(() => {});`;
const project = (file: string) =>
makeTempProject({
"index.html": `<!doctype html><html><head><script src="vendor/${file}"></script></head><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="1"></div>
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="1" data-duration="1"></div>
</div></body></html>`,
[`vendor/${file}`]:
file === "gsap.min.js" ? gsapCopy : gsapCopy.replace("GSAP 3.14.2", "Helpers 1.0.0"),
"compositions/a.html": `<template id="a-template"><div data-composition-id="a"><p>A</p><script src="../vendor/${file}"></script></div></template>`,
"compositions/b.html": `<template id="b-template"><div data-composition-id="b"><p>B</p></div></template>`,
});
const marks = async (dir: string) => {
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
return ["a", "b"].map(
(id) =>
doc.querySelector(`div[data-hf-scene="${id}"]`)?.getAttribute("data-hf-scene-no-swap") ??
null,
);
};
expect(await marks(project("gsap.min.js"))).toEqual([null, null]);
expect(await marks(project("helpers.js"))).toEqual([
"its script uses requestAnimationFrame",
"a script outside the scene selects *",
]);
});

it("lets a scene measure text on an unattached canvas, chain a timeline into the registry, call its own animate and wait on font loads, and nothing more", async () => {
const measure = `let ctx = null; window.measure = (s) => { if (!ctx) { ctx = document.createElement("canvas").getContext("2d"); } ctx.font = "800 46px Outfit"; return ctx.measureText(s).width; };`;
const scenes: Record<string, string> = {
measureCanvas: `<script>${measure}</script>`,
ownAnimate: `<script>const built = { animate(t) { return t; } }; window.step = (t) => built.animate(t);</script>`,
fontLoads: `<script>Promise.all([document.fonts.load("800 46px Outfit"), document.fonts.load('112px "Gochi Hand"')]).then(window.build, window.build);</script>`,
fontLoadThen: `<script>document.fonts.load("700 30px Outfit").then(() => window.fit("fontLoadThen"));</script>`,
fontLoadAwait: `<script>(async () => { await document.fonts.load("700 30px Outfit"); window.fit("fontLoadAwait"); })();</script>`,
namedCanvas: `<script>const c = document.createElement("canvas"); c.getContext("2d");</script>`,
canvasBack: `<script>const x = document.createElement("canvas").getContext("2d"); window.keep = x.canvas;</script>`,
drawOnStage: `<script>const g = document.querySelector("canvas").getContext('2d'); g.fillRect(0, 0, 4, 4);</script>`,
registeredChain: `<script>const clock = { t: 0 }; window.__timelines["registeredChain"] = gsap.timeline({ paused: true, onUpdate: () => draw(clock.t) }).to(clock, { t: 2, duration: 2, ease: "none" });</script>`,
registeredChainByKey: `<script>const clock = { t: 0 }; __timelines[doc.scene_id] = gsap.timeline({ paused: true }).to(clock, { t: 2 });</script>`,
unregisteredChain: `<script>gsap.timeline().to(window.__sharedState, { x: 1 });</script>`,
chainKeptAside: `<script>const tl = gsap.timeline({ paused: true }).to({ t: 0 }, { t: 1 }); window.__timelines["chainKeptAside"] = tl;</script>`,
registeredChainAndTween: `<script>window.__timelines["registeredChainAndTween"] = gsap.timeline().to({}, { x: 1 }); gsap.to(window.__shared, { x: 1 });</script>`,
webglCanvas: `<script>document.createElement("canvas").getContext("webgl");</script>`,
webgl2Canvas: `<script>document.querySelector("canvas").getContext("webgl2");</script>`,
experimentalWebgl: `<script>document.querySelector("canvas").getContext("experimental-webgl");</script>`,
webgpuCanvas: `<script>document.querySelector("canvas").getContext("webgpu");</script>`,
bitmapCanvas: `<script>document.querySelector("canvas").getContext("bitmaprenderer");</script>`,
dynamicContext: `<script>document.querySelector("canvas").getContext(window.kind);</script>`,
browserAnimate: `<script>document.querySelector("p").animate([{ opacity: 0 }], { duration: 1000 });</script>`,
spreadAnimate: `<script>document.querySelector("p").animate(...window.frames);</script>`,
otherPromise: `<script>Promise.all([fetch("a.json")]).then(() => {});</script>`,
dynamicFontLoad: `<script>document.fonts.load(window.face).then(() => {});</script>`,
};
const names = Object.keys(scenes);
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
${names.map((n) => `<div data-composition-id="${n}" data-composition-src="compositions/${n}.html" data-start="0" data-duration="2"></div>`).join("\n")}
</div></body></html>`,
...Object.fromEntries(
names.map((n) => [
`compositions/${n}.html`,
`<template id="${n}-template"><div data-composition-id="${n}"><p>${n}</p><canvas></canvas>${scenes[n]}</div></template>`,
]),
),
});
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
const refusals = Object.fromEntries(
names.map((n) => [
n,
doc.querySelector(`div[data-hf-scene="${n}"]`)?.getAttribute("data-hf-scene-no-swap") ??
null,
]),
);
expect(refusals).toEqual({
measureCanvas: null,
ownAnimate: null,
fontLoads: null,
fontLoadThen: null,
fontLoadAwait: null,
namedCanvas: "its script uses getContext",
canvasBack: "its script uses getContext",
drawOnStage: "its script uses getContext",
registeredChain: null,
registeredChainByKey: null,
unregisteredChain: "its script uses gsap.timeline().to(",
chainKeptAside: "its script uses gsap.timeline({ paused: true }).to(",
registeredChainAndTween: "its script uses gsap.to(",
webglCanvas: "its script uses getContext",
webgl2Canvas: "its script uses getContext",
experimentalWebgl: "its script uses getContext",
webgpuCanvas: "its script uses getContext",
bitmapCanvas: "its script uses getContext",
dynamicContext: "its script uses getContext",
browserAnimate: "its script uses .animate(",
spreadAnimate: "its script uses .animate(",
otherPromise: "its script uses Promise",
dynamicFontLoad: "its script uses .then(",
});
});

const rootProject = (root: string, extra: Record<string, string> = {}) =>
makeTempProject({
"index.html": `<!doctype html><html><head></head><body>
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { readDeclaredDefaults } from "../runtime/getVariables";
import {
ensureExternalLinkTag,
inlineSubCompositions,
isLibraryCopy,
refuseSwapsReachedByRootScripts,
} from "./inlineSubCompositions";
import { isSafePath, resolveWithinProject } from "../safePath.js";
Expand Down Expand Up @@ -1179,7 +1180,8 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
].map((el) => {
const src = el.getAttribute("src");
const path = src && isRelativeUrl(src) ? resolveEntryUrl(src) : null;
return src ? (path && safeReadFile(path)) || "" : el.textContent || "";
const text = src ? (path && safeReadFile(path)) || "" : el.textContent || "";
return isLibraryCopy(text) ? "" : text;
})
: [];

Expand Down Expand Up @@ -1218,6 +1220,10 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
},
flattenInnerRoot: prepareFlattenedInnerRoot,
tagScenes: options?.sceneParts === true,
readProjectScript: (path: string) => {
const resolved = isRelativeUrl(path) ? resolveEntryUrl(path) : null;
return resolved ? safeReadFile(resolved) : null;
},
readVariableDefaults: readDeclaredDefaults,
parseHostVariables: parseHostVariableValues,
buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId),
Expand All @@ -1241,6 +1247,10 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
...subCompResult.variablesByComp,
};
const seenCompScriptSrcs = new Set<string>();
const scenesBySrc = new Map<string, Set<string | undefined>>();
for (const item of subCompResult.scriptItems)
if (item.kind !== "inline")
scenesBySrc.set(item.src, (scenesBySrc.get(item.src) ?? new Set()).add(item.scene));
for (const scriptItem of subCompResult.scriptItems) {
if (scriptItem.kind === "inline") {
pushRun(scriptRuns, scriptItem.scene, scriptItem.content);
Expand All @@ -1254,13 +1264,15 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
}
if (seenCompScriptSrcs.has(extSrc)) continue;
seenCompScriptSrcs.add(extSrc);
// Run once for the film when scenes share it: a scene mounts without the one that loaded it first.
const scene = (scenesBySrc.get(extSrc)?.size ?? 0) > 1 ? undefined : scriptItem.scene;
if (isRelativeUrl(extSrc) && scriptItem.type !== "module") {
const jsPath = resolveEntryUrl(extSrc);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
const chunk = () =>
preserveLocalScriptIntegrity(document, extSrc, resolveEntryUrl) ? "" : js;
pushRun(scriptRuns, scriptItem.scene, chunk);
pushRun(scriptRuns, scene, chunk);
continue;
}
}
Expand Down
Loading
Loading