@anvpx/motion-build 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -2
  3. package/dist/.out/demo/boot.js +90 -0
  4. package/dist/.out/demo/runtime/easing.js +45 -0
  5. package/dist/.out/demo/runtime/mount.js +153 -0
  6. package/dist/.out/demo/runtime/timeline.js +199 -0
  7. package/dist/.out/demo/scenes/count/scene.js +6 -0
  8. package/dist/.out/demo/scenes/intro/scene.js +6 -0
  9. package/dist/.out/demo/scenes/outro/scene.js +6 -0
  10. package/dist/.out/demo/scenes/path/scene.js +6 -0
  11. package/dist/.out/demo/scenes/signals/scene.js +6 -0
  12. package/dist/.out/demo/shared/format.js +59 -0
  13. package/dist/.out/demo/shared/motion.js +117 -0
  14. package/dist/.out/demo-overlays/boot.js +95 -0
  15. package/dist/.out/demo-overlays/runtime/easing.js +45 -0
  16. package/dist/.out/demo-overlays/runtime/mount.js +153 -0
  17. package/dist/.out/demo-overlays/runtime/timeline.js +199 -0
  18. package/dist/.out/demo-overlays/scenes/chapter-signals/scene.js +6 -0
  19. package/dist/.out/demo-overlays/scenes/count/scene.js +6 -0
  20. package/dist/.out/demo-overlays/scenes/flow-progress/scene.js +17 -0
  21. package/dist/.out/demo-overlays/scenes/intro/scene.js +6 -0
  22. package/dist/.out/demo-overlays/scenes/mark/scene.js +6 -0
  23. package/dist/.out/demo-overlays/scenes/outro/scene.js +6 -0
  24. package/dist/.out/demo-overlays/scenes/path/scene.js +6 -0
  25. package/dist/.out/demo-overlays/scenes/presenter/scene.js +6 -0
  26. package/dist/.out/demo-overlays/scenes/signals/scene.js +6 -0
  27. package/dist/.out/demo-overlays/scenes/tick/scene.js +21 -0
  28. package/dist/.out/demo-overlays/shared/format.js +59 -0
  29. package/dist/.out/demo-overlays/shared/motion.js +117 -0
  30. package/dist/assets.d.ts +36 -0
  31. package/dist/assets.js +95 -0
  32. package/dist/audio/approval.d.ts +45 -0
  33. package/dist/audio/approval.js +82 -0
  34. package/dist/audio/mix.d.ts +231 -0
  35. package/dist/audio/mix.js +491 -0
  36. package/dist/audio/music.d.ts +82 -0
  37. package/dist/audio/music.js +134 -0
  38. package/dist/audio/preview.d.ts +31 -0
  39. package/dist/audio/preview.js +31 -0
  40. package/dist/build.d.ts +65 -0
  41. package/dist/build.js +260 -0
  42. package/dist/cache.d.ts +59 -0
  43. package/dist/cache.js +249 -0
  44. package/dist/emit/boot.d.ts +16 -0
  45. package/dist/emit/boot.js +32 -0
  46. package/dist/emit/boot.template.js +85 -0
  47. package/dist/emit/fragment-guard.d.ts +5 -0
  48. package/dist/emit/fragment-guard.js +15 -0
  49. package/dist/emit/index-html.d.ts +26 -0
  50. package/dist/emit/index-html.js +63 -0
  51. package/dist/emit/safe-json.d.ts +2 -0
  52. package/dist/emit/safe-json.js +7 -0
  53. package/dist/emit/vendor.d.ts +7 -0
  54. package/dist/emit/vendor.js +57 -0
  55. package/dist/fonts.d.ts +39 -0
  56. package/dist/fonts.js +112 -0
  57. package/dist/index.d.ts +19 -0
  58. package/dist/index.js +19 -0
  59. package/dist/integration/render.integration.d.ts +1 -0
  60. package/dist/integration/render.integration.js +162 -0
  61. package/dist/lint/custom-source.d.ts +12 -0
  62. package/dist/lint/custom-source.js +130 -0
  63. package/dist/lint/fixtures.d.ts +24 -0
  64. package/dist/lint/fixtures.js +42 -0
  65. package/dist/lint/flow.d.ts +12 -0
  66. package/dist/lint/flow.js +38 -0
  67. package/dist/lint/frame.d.ts +3 -0
  68. package/dist/lint/frame.js +2 -0
  69. package/dist/lint/hook-first.d.ts +6 -0
  70. package/dist/lint/hook-first.js +27 -0
  71. package/dist/lint/index.d.ts +25 -0
  72. package/dist/lint/index.js +55 -0
  73. package/dist/lint/layout-estimate.d.ts +18 -0
  74. package/dist/lint/layout-estimate.js +165 -0
  75. package/dist/lint/linear-ease.d.ts +2 -0
  76. package/dist/lint/linear-ease.js +14 -0
  77. package/dist/lint/long-hold.d.ts +9 -0
  78. package/dist/lint/long-hold.js +52 -0
  79. package/dist/lint/min-hold.d.ts +4 -0
  80. package/dist/lint/min-hold.js +10 -0
  81. package/dist/lint/motion-craft.d.ts +18 -0
  82. package/dist/lint/motion-craft.js +112 -0
  83. package/dist/lint/narration-pace.d.ts +6 -0
  84. package/dist/lint/narration-pace.js +35 -0
  85. package/dist/lint/overlays.d.ts +9 -0
  86. package/dist/lint/overlays.js +64 -0
  87. package/dist/lint/reading-time.d.ts +5 -0
  88. package/dist/lint/reading-time.js +17 -0
  89. package/dist/lint/safe-zone-9x16.d.ts +11 -0
  90. package/dist/lint/safe-zone-9x16.js +101 -0
  91. package/dist/lint/text-floor.d.ts +8 -0
  92. package/dist/lint/text-floor.js +34 -0
  93. package/dist/lint/transition-implemented.d.ts +3 -0
  94. package/dist/lint/transition-implemented.js +15 -0
  95. package/dist/lint/transition-length.d.ts +4 -0
  96. package/dist/lint/transition-length.js +10 -0
  97. package/dist/lint/type-grid.d.ts +30 -0
  98. package/dist/lint/type-grid.js +147 -0
  99. package/dist/lint/types.d.ts +41 -0
  100. package/dist/lint/types.js +1 -0
  101. package/dist/lint/words-on-screen.d.ts +5 -0
  102. package/dist/lint/words-on-screen.js +24 -0
  103. package/dist/manifest.d.ts +53 -0
  104. package/dist/manifest.js +55 -0
  105. package/dist/render-manifest.d.ts +21 -0
  106. package/dist/render-manifest.js +7 -0
  107. package/dist/review/review.d.ts +15 -0
  108. package/dist/review/review.js +56 -0
  109. package/dist/templates/discover-check.d.ts +11 -0
  110. package/dist/templates/discover-check.js +36 -0
  111. package/dist/templates/discover.d.ts +37 -0
  112. package/dist/templates/discover.js +63 -0
  113. package/dist/templates/expand.d.ts +17 -0
  114. package/dist/templates/expand.js +184 -0
  115. package/dist/templates/fixtures.d.ts +4 -0
  116. package/dist/templates/fixtures.js +63 -0
  117. package/dist/templates/index.d.ts +8 -0
  118. package/dist/templates/index.js +6 -0
  119. package/dist/templates/instantiate.d.ts +43 -0
  120. package/dist/templates/instantiate.js +160 -0
  121. package/dist/templates/load.d.ts +16 -0
  122. package/dist/templates/load.js +122 -0
  123. package/dist/templates/pacing.d.ts +21 -0
  124. package/dist/templates/pacing.js +61 -0
  125. package/dist/templates/search.d.ts +64 -0
  126. package/dist/templates/search.js +129 -0
  127. package/dist/templates/semver.d.ts +2 -0
  128. package/dist/templates/semver.js +12 -0
  129. package/dist/templates/slots.d.ts +8 -0
  130. package/dist/templates/slots.js +116 -0
  131. package/dist/templates/style.d.ts +25 -0
  132. package/dist/templates/style.js +56 -0
  133. package/dist/templates/types.d.ts +29 -0
  134. package/dist/templates/types.js +1 -0
  135. package/dist/templates/validate.d.ts +17 -0
  136. package/dist/templates/validate.js +203 -0
  137. package/dist/timing.d.ts +38 -0
  138. package/dist/timing.js +88 -0
  139. package/dist/validate-full.d.ts +3 -0
  140. package/dist/validate-full.js +132 -0
  141. package/dist/verify.d.ts +32 -0
  142. package/dist/verify.js +180 -0
  143. package/dist/versions.d.ts +26 -0
  144. package/dist/versions.js +74 -0
  145. package/package.json +40 -3
package/dist/cache.js ADDED
@@ -0,0 +1,249 @@
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { collectIcons, sceneSourceHash } from "@anvpx/motion-scenes";
4
+ import { canonicalJson } from "@anvpx/motion-schema";
5
+ import { hashAsset, normaliseRef, sha256Of } from "./assets.js";
6
+ import { readVersions } from "./versions.js";
7
+ const VERSION = 2;
8
+ const FILE_NAME = /^[0-9a-f]{64}\.(?:json|refs)(?:\.\d+\.tmp)?$/;
9
+ /** Key of a scene before its assets are known: type, config (incl. overlay `over`/window), placement, salt. */
10
+ export const baseKey = (entry, ctx, salt) => sha256Of(canonicalJson({
11
+ kind: "scene-base",
12
+ version: VERSION,
13
+ sceneType: entry.type,
14
+ config: entry,
15
+ placement: {
16
+ fps: ctx.fps,
17
+ width: ctx.width,
18
+ height: ctx.height,
19
+ duration: ctx.duration,
20
+ timeline: ctx.timeline ?? null,
21
+ ...(ctx.insets === undefined ? {} : { insets: ctx.insets }),
22
+ },
23
+ salt,
24
+ }));
25
+ /** Final key: the base key plus the content hash of every asset the scene references. */
26
+ export const fullKey = (base, assets) => sha256Of(canonicalJson({ kind: "scene-full", version: VERSION, base, assets }));
27
+ const assetHashes = (refs, hash) => Object.fromEntries(refs.map((ref) => [ref, hash(ref)]));
28
+ const readJson = (path) => {
29
+ try {
30
+ return JSON.parse(readFileSync(path, "utf8"));
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ };
36
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
37
+ const isCleanRef = (ref) => {
38
+ if (typeof ref !== "string")
39
+ return false;
40
+ try {
41
+ return normaliseRef(ref) === ref;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ };
47
+ const isIconName = (name) => typeof name === "string" && /^[a-z0-9-]+$/.test(name);
48
+ const parseRegistered = (raw) => {
49
+ if (!isRecord(raw) || raw.version !== VERSION)
50
+ return undefined;
51
+ const { refs, icons } = raw;
52
+ if (!Array.isArray(refs) || !Array.isArray(icons))
53
+ return undefined;
54
+ return refs.every(isCleanRef) && icons.every(isIconName)
55
+ ? { refs: [...refs], icons: [...icons] }
56
+ : undefined;
57
+ };
58
+ const isSafePath = (path, prefix) => path.startsWith(prefix) && !path.includes("\\") && !path.split("/").includes("..");
59
+ /** A corrupt, tampered or foreign entry yields undefined, i.e. a miss. */
60
+ const parseFiles = (raw, prefix) => {
61
+ if (!isRecord(raw) || raw.version !== VERSION || !isRecord(raw.files))
62
+ return undefined;
63
+ const entries = Object.entries(raw.files);
64
+ const valid = entries.every(([path, text]) => typeof text === "string" && isSafePath(path, prefix));
65
+ if (!valid || raw.sha256 !== sha256Of(canonicalJson(raw.files)))
66
+ return undefined;
67
+ return new Map(entries);
68
+ };
69
+ /** Scene fragments are kilobytes; anything above this in the shared dir is never trusted. */
70
+ const MAX_SHARED_ENTRY_BYTES = 8 * 1024 * 1024;
71
+ /** Shared entries dated further than this in the future are treated as tampered. */
72
+ const SHARED_MTIME_SKEW_MS = 60_000;
73
+ const sharedBudgetOf = (opts) => opts?.maxSharedEntryBytes ?? MAX_SHARED_ENTRY_BYTES;
74
+ const fileSizeOf = (path) => {
75
+ try {
76
+ return statSync(path).size;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ };
82
+ /** Missing or future-dated files in the shared dir are never trusted: a miss, never an error. */
83
+ const isPlausibleSharedFile = (path) => {
84
+ try {
85
+ return statSync(path).mtimeMs <= Date.now() + SHARED_MTIME_SKEW_MS;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ };
91
+ const readSharedJson = (path, maxBytes) => {
92
+ const size = fileSizeOf(path);
93
+ if (size === undefined || size > maxBytes)
94
+ return undefined;
95
+ return readJson(path);
96
+ };
97
+ /** Reads one entry; shared reads add mtime and size guards, local reads stay as they were. */
98
+ const loadFromDir = (dir, base, hash, prefix, maxBytes) => {
99
+ const refsPath = join(dir, `${base}.refs`);
100
+ if (maxBytes !== undefined && !isPlausibleSharedFile(refsPath))
101
+ return undefined;
102
+ const registered = parseRegistered(maxBytes === undefined ? readJson(refsPath) : readSharedJson(refsPath, maxBytes));
103
+ if (!registered)
104
+ return undefined;
105
+ const { refs, icons } = registered;
106
+ const key = fullKey(base, assetHashes(refs, hash));
107
+ const jsonPath = join(dir, `${key}.json`);
108
+ if (maxBytes !== undefined && !isPlausibleSharedFile(jsonPath))
109
+ return undefined;
110
+ const files = parseFiles(maxBytes === undefined ? readJson(jsonPath) : readSharedJson(jsonPath, maxBytes), prefix);
111
+ return files ? { files, refs, icons } : undefined;
112
+ };
113
+ /** Stores one entry; callers swallow failures so a failed store only costs a future miss. */
114
+ const storeInDir = (dir, base, refs, hash, files, icons) => {
115
+ mkdirSync(dir, { recursive: true });
116
+ const object = Object.fromEntries(files);
117
+ const key = fullKey(base, assetHashes(refs, hash));
118
+ writeAtomic(dir, key, "json", {
119
+ version: VERSION,
120
+ files: object,
121
+ sha256: sha256Of(canonicalJson(object)),
122
+ });
123
+ writeAtomic(dir, base, "refs", { version: VERSION, refs, icons });
124
+ };
125
+ const writeAtomic = (dir, key, ext, data) => {
126
+ const target = join(dir, `${key}.${ext}`);
127
+ const tmp = `${target}.${process.pid}.tmp`;
128
+ try {
129
+ writeFileSync(tmp, JSON.stringify(data));
130
+ renameSync(tmp, target);
131
+ }
132
+ catch (error) {
133
+ rmSync(tmp, { force: true });
134
+ throw error;
135
+ }
136
+ };
137
+ /**
138
+ * Disk-backed cache; every failure degrades to a miss or a skipped store, never an error.
139
+ * With `sharedDir` set, entries are content-addressed (same salt plus config plus asset
140
+ * hashes yields the same keys), so two storyboards sharing a scene share its entries: load
141
+ * falls back to the shared dir and promotes hits locally, save writes through to both.
142
+ */
143
+ export const createDiskCache = (dir, opts) => {
144
+ const sharedDir = opts?.sharedDir;
145
+ const shared = sharedDir === undefined || resolve(sharedDir) === resolve(dir) ? undefined : sharedDir;
146
+ const maxBytes = shared === undefined ? 0 : sharedBudgetOf(opts);
147
+ return {
148
+ load(base, hash, prefix) {
149
+ const local = loadFromDir(dir, base, hash, prefix, undefined);
150
+ if (local !== undefined || shared === undefined)
151
+ return local;
152
+ const hit = loadFromDir(shared, base, hash, prefix, maxBytes);
153
+ if (!hit)
154
+ return undefined;
155
+ try {
156
+ storeInDir(dir, base, hit.refs, hash, hit.files, hit.icons);
157
+ }
158
+ catch {
159
+ // promotion is best-effort; the shared hit still counts
160
+ }
161
+ return hit;
162
+ },
163
+ save(base, refs, hash, files, icons) {
164
+ try {
165
+ storeInDir(dir, base, refs, hash, files, icons);
166
+ }
167
+ catch {
168
+ // a failed store only costs a future miss
169
+ }
170
+ if (shared !== undefined) {
171
+ try {
172
+ storeInDir(shared, base, refs, hash, files, icons);
173
+ }
174
+ catch {
175
+ // a failed shared store only costs a future shared miss
176
+ }
177
+ }
178
+ },
179
+ };
180
+ };
181
+ /** Deletes cache files only (hex-named `.json`/`.refs` and their tmp files); returns how many. */
182
+ export const clearCache = (dir) => {
183
+ let names;
184
+ try {
185
+ names = readdirSync(dir).filter((name) => FILE_NAME.test(name));
186
+ }
187
+ catch {
188
+ return 0;
189
+ }
190
+ for (const name of names)
191
+ rmSync(join(dir, name), { force: true });
192
+ try {
193
+ rmdirSync(dir);
194
+ }
195
+ catch {
196
+ // not empty (foreign files): leave the directory alone
197
+ }
198
+ return names.length;
199
+ };
200
+ export const createCachePlan = (dir, theme, assetsDir, opts) => {
201
+ const { packages, hyperframes, node } = readVersions();
202
+ const memo = new Map();
203
+ return {
204
+ cache: createDiskCache(dir, opts),
205
+ salt: {
206
+ themeHash: sha256Of(canonicalJson(theme)),
207
+ packages,
208
+ hyperframes,
209
+ nodeMajor: node.split(".")[0] ?? node,
210
+ scenesSource: sceneSourceHash(),
211
+ },
212
+ hashAsset: (ref) => {
213
+ const known = memo.get(ref) ?? hashAsset(assetsDir, ref);
214
+ memo.set(ref, known);
215
+ return known;
216
+ },
217
+ stats: { hits: 0, misses: 0 },
218
+ };
219
+ };
220
+ /**
221
+ * Replays a cached scene (re-registering its asset refs) or emits and stores it. The icon names the
222
+ * scene drew go to `onIcons` either way, so a cache hit still reaches the licence ledger.
223
+ */
224
+ export const emitCached = (plan, entry, ctx, emit, onIcons = () => undefined) => {
225
+ const base = baseKey(entry, ctx, plan.salt);
226
+ const hit = plan.cache.load(base, plan.hashAsset, `scenes/${entry.id}/`);
227
+ if (hit) {
228
+ for (const ref of hit.refs)
229
+ ctx.assetResolver(ref);
230
+ onIcons(hit.icons);
231
+ plan.stats.hits += 1;
232
+ return hit.files;
233
+ }
234
+ plan.stats.misses += 1;
235
+ const refs = [];
236
+ const { value: files, icons } = collectIcons(() => emit({
237
+ ...ctx,
238
+ assetResolver: (ref) => {
239
+ const resolved = ctx.assetResolver(ref);
240
+ const clean = normaliseRef(ref);
241
+ if (!refs.includes(clean))
242
+ refs.push(clean);
243
+ return resolved;
244
+ },
245
+ }));
246
+ plan.cache.save(base, refs, plan.hashAsset, files, icons);
247
+ onIcons(icons);
248
+ return files;
249
+ };
@@ -0,0 +1,16 @@
1
+ import type { TimedScene } from "../timing.ts";
2
+ export interface BootScene {
3
+ readonly timed: Pick<TimedScene, "id" | "start" | "duration" | "overlap"> & {
4
+ readonly transition?: TimedScene["transition"];
5
+ };
6
+ /** Visible through its last frame (the closing base scene, or an overlay ending with it). */
7
+ readonly final: boolean;
8
+ readonly config: {
9
+ readonly ease?: string;
10
+ readonly move?: string;
11
+ readonly focus?: string;
12
+ };
13
+ }
14
+ export declare const clipId: (sceneId: string) => string;
15
+ /** The single boot.js: mounts every scene and wires one `hf-seek` listener. */
16
+ export declare const renderBoot: (scenes: readonly BootScene[], fps: number) => string;
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { safeJson } from "./safe-json.js";
3
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4
+ const template = () => readFileSync(new URL("./boot.template.js", import.meta.url), "utf8").replaceAll("\r\n", "\n");
5
+ export const clipId = (sceneId) => `clip-${sceneId}`;
6
+ const importLines = (scenes) => {
7
+ const names = scenes.map((_, index) => `scene${index}`);
8
+ const imports = scenes.map(({ timed }, index) => {
9
+ if (!SLUG.test(timed.id))
10
+ throw new Error(`scene id "${timed.id}" is not a slug`);
11
+ return `import { createScene as ${names[index]} } from "./scenes/${timed.id}/scene.js";`;
12
+ });
13
+ return [...imports, `const FACTORIES = [${names.join(", ")}];`].join("\n");
14
+ };
15
+ const configJson = (scenes, fps) => safeJson({
16
+ fps,
17
+ scenes: scenes.map(({ timed, config, final }) => ({
18
+ clipId: clipId(timed.id),
19
+ start: timed.start,
20
+ duration: timed.duration,
21
+ overlap: timed.overlap,
22
+ ...(timed.transition === "push" ? { push: true } : {}),
23
+ ...(timed.transition === "wipe" ? { wipe: true } : {}),
24
+ ...(timed.transition === "zoom-through" ? { zoomThrough: true } : {}),
25
+ final,
26
+ config,
27
+ })),
28
+ });
29
+ /** The single boot.js: mounts every scene and wires one `hf-seek` listener. */
30
+ export const renderBoot = (scenes, fps) => template()
31
+ .replace("/*@imports*/", () => importLines(scenes))
32
+ .replace("/*@config*/ null", () => configJson(scenes, fps));
@@ -0,0 +1,85 @@
1
+ // Emitted boot glue: the only file that knows the renderer's `hf-seek` event.
2
+ /*@imports*/
3
+ window.__timelines = window.__timelines || {};
4
+ const CONFIG = /*@config*/ null;
5
+
6
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
7
+
8
+ const ease = (p) => 1 - (1 - p) ** 3;
9
+
10
+ /** Eased progress 0..1 of a scene entering over its predecessor (1 when no overlap transition). */
11
+ const transOf = (t, scene) =>
12
+ scene?.overlap > 0 && (scene.push || scene.wipe || scene.zoomThrough)
13
+ ? ease(clamp((t - scene.start) / scene.overlap, 0, 1))
14
+ : 1;
15
+
16
+ /** Push progress 0..1 of a scene entering over its predecessor (1 when it is not a push). */
17
+ const pushOf = (t, scene) => (scene?.push && scene.overlap > 0 ? transOf(t, scene) : 1);
18
+
19
+ /** Pure function of global time `t`: local scene time, visibility and fade opacity. */
20
+ const stateAt = (t, scene) => {
21
+ const end = scene.start + scene.duration;
22
+ const live = t >= scene.start && (t < end || (scene.final && t <= end));
23
+ const fade = scene.overlap > 0 ? clamp((t - scene.start) / scene.overlap, 0, 1) : 1;
24
+ return {
25
+ local: clamp(t - scene.start, 0, scene.duration),
26
+ visible: live,
27
+ opacity: live ? (scene.push || scene.wipe ? 1 : fade) : 0,
28
+ };
29
+ };
30
+
31
+ const mounts = CONFIG.scenes.map((scene, index) => {
32
+ const clip = document.getElementById(scene.clipId);
33
+ const ctx = { fps: CONFIG.fps, duration: scene.duration };
34
+ const runtime = FACTORIES[index](clip.firstElementChild, scene.config, ctx);
35
+ return { scene, clip, runtime };
36
+ });
37
+
38
+ const ready = Promise.all([document.fonts.ready, ...mounts.map((mount) => mount.runtime.ready)]);
39
+
40
+ /** Horizontal offset in percent: an entering push scene starts one frame to the right, the one it replaces leaves to the left. */
41
+ const offsetOf = (t, scene, next) => {
42
+ if (scene.push) return (1 - pushOf(t, scene)) * 100;
43
+ return next?.push ? -pushOf(t, next) * 100 : null;
44
+ };
45
+
46
+ /** Scale punch of a zoom-through: incoming shrinks 1.25 -> 1, outgoing grows 1 -> 1.12. */
47
+ const ZOOM_IN_FROM = 1.25;
48
+ const ZOOM_OUT_TO = 1.12;
49
+
50
+ const scaleOf = (t, scene, next) => {
51
+ if (scene.zoomThrough) return ZOOM_IN_FROM - (ZOOM_IN_FROM - 1) * transOf(t, scene);
52
+ if (next?.zoomThrough) return 1 + (ZOOM_OUT_TO - 1) * transOf(t, next);
53
+ return null;
54
+ };
55
+
56
+ /** Directional reveal of a wipe: incoming clip opens left -> right. */
57
+ const clipOf = (t, scene) => {
58
+ if (!scene.wipe) return null;
59
+ const p = transOf(t, scene);
60
+ if (p >= 1) return "";
61
+ return `inset(0 ${(1 - p) * 100}% 0 0)`;
62
+ };
63
+
64
+ const apply = (t) =>
65
+ mounts.map(({ scene, clip, runtime }, index) => {
66
+ const state = stateAt(t, scene);
67
+ clip.style.visibility = state.visible ? "visible" : "hidden";
68
+ clip.style.opacity = String(state.opacity);
69
+ const next = CONFIG.scenes[index + 1];
70
+ const offset = offsetOf(t, scene, next);
71
+ if (offset !== null) clip.style.transform = `translateX(${offset}%)`;
72
+ else {
73
+ const scale = scaleOf(t, scene, next);
74
+ if (scale !== null) clip.style.transform = `scale(${scale})`;
75
+ }
76
+ const clipPath = clipOf(t, scene);
77
+ if (clipPath !== null) clip.style.clipPath = clipPath;
78
+ return runtime.seek(state.local);
79
+ });
80
+
81
+ window.addEventListener("hf-seek", (event) => {
82
+ event.detail.waitUntil(Promise.all([ready, ...apply(event.detail.time)]));
83
+ });
84
+
85
+ apply(0);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The CSP forbids inline script, so a scene fragment may not carry any.
3
+ * Throws a message naming the scene so the build fails with a fixable error.
4
+ */
5
+ export declare const assertInertFragment: (sceneId: string, fragment: string) => void;
@@ -0,0 +1,15 @@
1
+ const SCRIPT_TAG = /<script/i;
2
+ /** An `on*` attribute inside a tag (`<img onerror=...>`, `<a/onclick=...>`). */
3
+ const EVENT_HANDLER = /<[^>]*[\s/"']on[a-z]+\s*=/i;
4
+ /**
5
+ * The CSP forbids inline script, so a scene fragment may not carry any.
6
+ * Throws a message naming the scene so the build fails with a fixable error.
7
+ */
8
+ export const assertInertFragment = (sceneId, fragment) => {
9
+ if (SCRIPT_TAG.test(fragment)) {
10
+ throw new Error(`scene "${sceneId}" fragment contains <script>; scripts live in scene.js`);
11
+ }
12
+ if (EVENT_HANDLER.test(fragment)) {
13
+ throw new Error(`scene "${sceneId}" fragment contains an inline event handler (on*=)`);
14
+ }
15
+ };
@@ -0,0 +1,26 @@
1
+ import type { StoryboardMeta } from "@anvpx/motion-schema";
2
+ /**
3
+ * Defence in depth (PLAN 3b); `script-src` and `style-src` allow 'self' for emitted modules and sheets.
4
+ * `script-src 'unsafe-inline'` stays only because HyperFrames injects its own runtime as inline
5
+ * scripts (check/snapshot/render fail without it, verified 0.8.212). The page itself emits no inline
6
+ * script and `assertInertFragment` rejects `<script` and `on*=` in scene fragments.
7
+ */
8
+ export declare const CSP: string;
9
+ export interface PageScene {
10
+ readonly id: string;
11
+ readonly start: number;
12
+ readonly duration: number;
13
+ readonly index: number;
14
+ /** The scene fragment (`<section id="scene-…">…</section>`). */
15
+ readonly fragment: string;
16
+ }
17
+ export interface PageInput {
18
+ readonly meta: StoryboardMeta;
19
+ readonly total: number;
20
+ readonly themeCss: string;
21
+ readonly scenes: readonly PageScene[];
22
+ /** Import-map tag for vendored libraries; "" when no scene imports one. */
23
+ readonly importMap?: string;
24
+ }
25
+ /** The root composition: scenes inline, scene stylesheets linked, boot.js as the only module. */
26
+ export declare const renderIndexHtml: (input: PageInput) => string;
@@ -0,0 +1,63 @@
1
+ import { esc } from "@anvpx/motion-scenes";
2
+ import { FONTS_STYLESHEET } from "../fonts.js";
3
+ import { clipId } from "./boot.js";
4
+ /**
5
+ * Defence in depth (PLAN 3b); `script-src` and `style-src` allow 'self' for emitted modules and sheets.
6
+ * `script-src 'unsafe-inline'` stays only because HyperFrames injects its own runtime as inline
7
+ * scripts (check/snapshot/render fail without it, verified 0.8.212). The page itself emits no inline
8
+ * script and `assertInertFragment` rejects `<script` and `on*=` in scene fragments.
9
+ */
10
+ export const CSP = [
11
+ "default-src 'none'",
12
+ "script-src 'self' 'unsafe-inline'",
13
+ "style-src 'self' 'unsafe-inline'",
14
+ "img-src 'self' data:",
15
+ "font-src 'self' data:",
16
+ "media-src 'self' data:",
17
+ "connect-src 'none'",
18
+ "worker-src 'none'",
19
+ "frame-src 'none'",
20
+ ].join("; ");
21
+ const baseCss = (meta) => `html, body {
22
+ margin: 0;
23
+ width: ${meta.width}px;
24
+ height: ${meta.height}px;
25
+ overflow: hidden;
26
+ background: var(--mk-color-bg);
27
+ }
28
+ #root { position: relative; width: ${meta.width}px; height: ${meta.height}px; overflow: hidden; }
29
+ .clip { position: absolute; inset: 0; }
30
+ `;
31
+ const clipHtml = (scene) => [
32
+ `<div id="${clipId(scene.id)}" class="clip" data-start="${scene.start}"`,
33
+ ` data-duration="${scene.duration}" data-track-index="${scene.index}">\n`,
34
+ scene.fragment.trimEnd(),
35
+ "\n</div>",
36
+ ].join("");
37
+ /** The root composition: scenes inline, scene stylesheets linked, boot.js as the only module. */
38
+ export const renderIndexHtml = (input) => {
39
+ const { meta } = input;
40
+ const links = [
41
+ FONTS_STYLESHEET,
42
+ ...input.scenes.map((scene) => `scenes/${scene.id}/style.css`),
43
+ ].map((href) => ` <link rel="stylesheet" href="${href}" />`);
44
+ return `<!doctype html>
45
+ <html lang="${esc(meta.lang ?? "en")}">
46
+ <head>
47
+ <meta charset="UTF-8" />
48
+ <meta http-equiv="Content-Security-Policy" content="${CSP}" />
49
+ <meta name="viewport" content="width=${meta.width}, height=${meta.height}" />
50
+ <title>${esc(meta.title)}</title>
51
+ <style>
52
+ ${input.themeCss}${baseCss(meta)} </style>
53
+ ${links.join("\n")}
54
+ ${input.importMap ?? ""} </head>
55
+ <body>
56
+ <div id="root" data-composition-id="main" data-no-timeline data-start="0" data-duration="${input.total}" data-width="${meta.width}" data-height="${meta.height}">
57
+ ${input.scenes.map(clipHtml).join("\n")}
58
+ </div>
59
+ <script type="module" src="boot.js"></script>
60
+ </body>
61
+ </html>
62
+ `;
63
+ };
@@ -0,0 +1,2 @@
1
+ /** JSON that is safe inside `<script>` and in JS source (no `</script>`, no U+2028/2029). */
2
+ export declare const safeJson: (value: unknown) => string;
@@ -0,0 +1,7 @@
1
+ const BACKSLASH = String.fromCharCode(0x5c);
2
+ const LINE_SEPARATOR = String.fromCharCode(0x2028);
3
+ const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);
4
+ const UNSAFE = new RegExp(`[<>&${LINE_SEPARATOR}${PARAGRAPH_SEPARATOR}]`, "g");
5
+ const unicodeEscape = (char) => `${BACKSLASH}u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
6
+ /** JSON that is safe inside `<script>` and in JS source (no `</script>`, no U+2028/2029). */
7
+ export const safeJson = (value) => JSON.stringify(value).replace(UNSAFE, unicodeEscape);
@@ -0,0 +1,7 @@
1
+ import type { VendorLib } from "@anvpx/motion-schema";
2
+ /** Vendor libraries named by scenes, deduplicated and in a stable order. */
3
+ export declare const vendorLibs: (used: readonly VendorLib[]) => readonly VendorLib[];
4
+ /** `vendor/…` path -> bytes, for every library in `libs`. Empty when nothing is vendored. */
5
+ export declare const vendorFiles: (libs: readonly VendorLib[]) => Map<string, Uint8Array>;
6
+ /** The `importmap` script tag, or "" when no scene imports a vendored library. */
7
+ export declare const renderImportMap: (libs: readonly VendorLib[]) => string;
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const SPECS = [
5
+ {
6
+ specifier: "three",
7
+ pkg: "three",
8
+ // three.module.js re-exports the core build, so both files are required.
9
+ files: [
10
+ ["three.module.js", "three.module.js"],
11
+ ["three.core.js", "three.core.js"],
12
+ ],
13
+ target: "vendor/three.module.js",
14
+ },
15
+ {
16
+ specifier: "postprocessing",
17
+ pkg: "postprocessing",
18
+ // Imports `three` by bare specifier; the import map resolves it to the copy above.
19
+ files: [["index.js", "postprocessing.js"]],
20
+ target: "vendor/postprocessing.js",
21
+ },
22
+ ];
23
+ const specOf = (lib) => {
24
+ const spec = SPECS.find((candidate) => candidate.specifier === lib);
25
+ if (spec === undefined)
26
+ throw new Error(`unknown vendor library "${lib}"`);
27
+ return spec;
28
+ };
29
+ /** Directory holding a package's ESM entry, resolved from this module. */
30
+ const entryDir = (pkg) => {
31
+ const resolved = import.meta.resolve(pkg);
32
+ if (!resolved.startsWith("file:"))
33
+ throw new Error(`vendor package "${pkg}" is not a local file`);
34
+ return dirname(fileURLToPath(resolved));
35
+ };
36
+ /** Vendor libraries named by scenes, deduplicated and in a stable order. */
37
+ export const vendorLibs = (used) => [...new Set(used)].sort();
38
+ /** `vendor/…` path -> bytes, for every library in `libs`. Empty when nothing is vendored. */
39
+ export const vendorFiles = (libs) => {
40
+ const files = new Map();
41
+ for (const lib of vendorLibs(libs)) {
42
+ const spec = specOf(lib);
43
+ const dir = entryDir(spec.pkg);
44
+ for (const [source, dest] of spec.files) {
45
+ files.set(`vendor/${dest}`, readFileSync(join(dir, source)));
46
+ }
47
+ }
48
+ return files;
49
+ };
50
+ /** The `importmap` script tag, or "" when no scene imports a vendored library. */
51
+ export const renderImportMap = (libs) => {
52
+ const sorted = vendorLibs(libs);
53
+ if (sorted.length === 0)
54
+ return "";
55
+ const imports = Object.fromEntries(sorted.map((lib) => [specOf(lib).specifier, `./${specOf(lib).target}`]));
56
+ return ` <script type="importmap">${JSON.stringify({ imports })}</script>\n`;
57
+ };
@@ -0,0 +1,39 @@
1
+ import type { AssetRow } from "@anvpx/motion-schema";
2
+ /** Weights the scenes use for Inter (400 body default; 600/700/800 declared in scene styles). */
3
+ export declare const FONT_WEIGHTS: readonly [400, 600, 700, 800];
4
+ export declare const FONTS_STYLESHEET = "fonts/fonts.css";
5
+ export type FontFamilyId = "inter" | "space-grotesk" | "jetbrains-mono" | "source-serif-4";
6
+ interface FontFamily {
7
+ /** CSS family name as it appears in theme font stacks. */
8
+ readonly name: string;
9
+ readonly weights: readonly number[];
10
+ readonly sourceUrl: string;
11
+ readonly attribution: string;
12
+ /** Output path of this family's OFL text. */
13
+ readonly licencePath: string;
14
+ }
15
+ /** Latin subset only; order here is the emission order (Inter first keeps default output stable). */
16
+ export declare const FONT_FAMILIES: Readonly<Record<FontFamilyId, FontFamily>>;
17
+ export interface FontBundle {
18
+ /** Project-relative output path -> bytes. */
19
+ readonly files: ReadonlyMap<string, Uint8Array>;
20
+ /** One licence row per shipped font file, ready for the credits / assets ledger. */
21
+ readonly licences: readonly AssetRow[];
22
+ }
23
+ export interface FontSizeReport {
24
+ readonly files: readonly {
25
+ readonly path: string;
26
+ readonly bytes: number;
27
+ }[];
28
+ readonly totalBytes: number;
29
+ }
30
+ /** Families whose CSS name appears in a theme's font stacks; Inter (the base face) is always first. */
31
+ export declare const fontFamiliesFor: (fonts: Readonly<Record<string, string>>) => FontFamilyId[];
32
+ /** Per-file bytes and the total, for the build's size report. */
33
+ export declare const fontSizeReport: (bundle: FontBundle) => FontSizeReport;
34
+ /**
35
+ * Reads the pinned `@fontsource/*` latin subsets so renders never fetch fonts over the network.
36
+ * Pass only the families a theme uses (see `fontFamiliesFor`); the default is Inter alone.
37
+ */
38
+ export declare const bundleFonts: (families?: readonly FontFamilyId[]) => FontBundle;
39
+ export {};