@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/fonts.js ADDED
@@ -0,0 +1,112 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { sha256Of } from "./assets.js";
5
+ /** Weights the scenes use for Inter (400 body default; 600/700/800 declared in scene styles). */
6
+ export const FONT_WEIGHTS = [400, 600, 700, 800];
7
+ export const FONTS_STYLESHEET = "fonts/fonts.css";
8
+ const LICENCE = "OFL-1.1";
9
+ /** Latin subset only; order here is the emission order (Inter first keeps default output stable). */
10
+ export const FONT_FAMILIES = {
11
+ inter: {
12
+ name: "Inter",
13
+ weights: FONT_WEIGHTS,
14
+ sourceUrl: "https://github.com/fontsource/font-files/tree/main/fonts/google/inter",
15
+ attribution: "Inter, Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)",
16
+ licencePath: "fonts/LICENSE.txt",
17
+ },
18
+ "space-grotesk": {
19
+ name: "Space Grotesk",
20
+ weights: [500, 700],
21
+ sourceUrl: "https://github.com/fontsource/font-files/tree/main/fonts/google/space-grotesk",
22
+ attribution: "Space Grotesk, Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)",
23
+ licencePath: "fonts/LICENSE-space-grotesk.txt",
24
+ },
25
+ "jetbrains-mono": {
26
+ name: "JetBrains Mono",
27
+ weights: [400, 600],
28
+ sourceUrl: "https://github.com/fontsource/font-files/tree/main/fonts/google/jetbrains-mono",
29
+ attribution: "JetBrains Mono, Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)",
30
+ licencePath: "fonts/LICENSE-jetbrains-mono.txt",
31
+ },
32
+ "source-serif-4": {
33
+ name: "Source Serif 4",
34
+ weights: [400, 600, 700],
35
+ sourceUrl: "https://github.com/fontsource/font-files/tree/main/fonts/google/source-serif-4",
36
+ attribution: "Source Serif 4, Copyright Google Inc. (per the @fontsource/source-serif-4 LICENSE; upstream design: https://github.com/adobe-fonts/source-serif), SIL OFL 1.1",
37
+ licencePath: "fonts/LICENSE-source-serif-4.txt",
38
+ },
39
+ };
40
+ const FAMILY_IDS = Object.keys(FONT_FAMILIES);
41
+ const fontFace = (family, id, weight) => [
42
+ "@font-face {",
43
+ ` font-family: '${family}';`,
44
+ " font-style: normal;",
45
+ ` font-weight: ${weight};`,
46
+ " font-display: block;",
47
+ ` src: url(${id}-latin-${weight}.woff2) format('woff2');`,
48
+ "}",
49
+ "",
50
+ ].join("\n");
51
+ const packageDir = (id) => {
52
+ const specifier = `@fontsource/${id}/LICENSE`;
53
+ try {
54
+ // Resolves from the installed @fontsource tree: when this module runs
55
+ // from @anvpx/motion-build/dist, Node finds build's @fontsource deps;
56
+ // when bundled into @anvpx/motion/dist, the same call finds core's
57
+ // @fontsource deps (declared as runtime deps of core). No relative
58
+ // source-tree path is used, so the lookup survives npm install.
59
+ return dirname(createRequire(import.meta.url).resolve(specifier));
60
+ }
61
+ catch (error) {
62
+ throw new Error(`cannot resolve "${specifier}" from the installed @fontsource tree ` +
63
+ `(is @fontsource/${id} installed alongside ${import.meta.url}?): ${error.message}`);
64
+ }
65
+ };
66
+ const row = (family, path, bytes) => ({
67
+ path,
68
+ sourceUrl: family.sourceUrl,
69
+ licence: LICENCE,
70
+ attribution: family.attribution,
71
+ sha256: sha256Of(bytes),
72
+ });
73
+ /** Families whose CSS name appears in a theme's font stacks; Inter (the base face) is always first. */
74
+ export const fontFamiliesFor = (fonts) => {
75
+ const stacks = Object.values(fonts).join(",").toLowerCase();
76
+ return FAMILY_IDS.filter((id) => id === "inter" || stacks.includes(FONT_FAMILIES[id].name.toLowerCase()));
77
+ };
78
+ /** Per-file bytes and the total, for the build's size report. */
79
+ export const fontSizeReport = (bundle) => {
80
+ const files = [...bundle.files]
81
+ .filter(([path]) => path.endsWith(".woff2"))
82
+ .map(([path, bytes]) => ({ path, bytes: bytes.byteLength }))
83
+ .sort((a, b) => (a.path < b.path ? -1 : 1));
84
+ return { files, totalBytes: files.reduce((sum, file) => sum + file.bytes, 0) };
85
+ };
86
+ /**
87
+ * Reads the pinned `@fontsource/*` latin subsets so renders never fetch fonts over the network.
88
+ * Pass only the families a theme uses (see `fontFamiliesFor`); the default is Inter alone.
89
+ */
90
+ export const bundleFonts = (families = ["inter"]) => {
91
+ const unknown = families.filter((id) => FONT_FAMILIES[id] === undefined);
92
+ if (unknown.length > 0)
93
+ throw new Error(`unknown font family "${unknown.join('", "')}"`);
94
+ const wanted = FAMILY_IDS.filter((id) => families.includes(id));
95
+ const files = new Map();
96
+ const licences = [];
97
+ const faces = [];
98
+ for (const id of wanted) {
99
+ const family = FONT_FAMILIES[id];
100
+ const dir = packageDir(id);
101
+ for (const weight of family.weights) {
102
+ const path = `fonts/${id}-latin-${weight}.woff2`;
103
+ const bytes = readFileSync(join(dir, "files", `${id}-latin-${weight}-normal.woff2`));
104
+ files.set(path, bytes);
105
+ licences.push(row(family, path, bytes));
106
+ faces.push(fontFace(family.name, id, weight));
107
+ }
108
+ files.set(family.licencePath, readFileSync(join(dir, "LICENSE")));
109
+ }
110
+ files.set(FONTS_STYLESHEET, Buffer.from(faces.join("\n")));
111
+ return { files, licences };
112
+ };
@@ -0,0 +1,19 @@
1
+ export { CUSTOM_SCENE_TYPE, type CustomSceneFile, type CustomSceneSource, collectIcons, emitCustomScene, type FieldDoc, HELPER_LIMITS, iconGroups, iconLedgerRows, listIcons, type RelativeClosure, type RelativeImportScan, readRelativeClosure, resolveSceneDir, type SceneDirResult, type SceneDoc, scanRelativeImports, scanVendorImports, sceneDocs, sceneWords, searchIconNames, type VendorScan, } from "@anvpx/motion-scenes";
2
+ export { bundledThemeDocs } from "@anvpx/motion-themes";
3
+ export { type AssetCollector, type CopiedAsset, copyAssets, createAssetCollector, normaliseRef, sha256Of, } from "./assets.ts";
4
+ export { type FrozenCopyCheck, frozenCopyProblems, } from "./audio/approval.ts";
5
+ export { AT_TARGET_TOLERANCE_LU, AUDIO_DIR_NAME, type AudioManifestRef, type AudioManifestResult, buildFilterGraph, buildLoudnessMeasureArgs, buildLoudnessNormalizeArgs, buildLoudnessNormalizeFilter, buildMixArgs, buildMusicFilterGraph, buildMusicMixArgs, clipsFromManifest, DEFAULT_MUSIC_VOLUME, frozenClips, frozenMusic, frozenWavFor, isContiguous, LOUDNORM_MEASURE_FILTER, type LoudnessMeasurement, type MixClip, type MixDelivery, type MixDeliveryPlan, type MixRunner, type MixRunResult, type MusicBed, missingClips, missingMusic, mixAtDeliveryLevel, needsNormalization, parseAudioManifest, parseLoudnessMeasurement, resolveMusicVolume, TARGET_LRA, TARGET_LUFS, TARGET_TRUE_PEAK_DB, toDelayMs, wavPathFor, } from "./audio/mix.ts";
6
+ export { type BuildOptions, type BuildResult, buildProject, tryResolveTheme, } from "./build.ts";
7
+ export { type CacheStats, clearCache } from "./cache.ts";
8
+ export { CSP } from "./emit/index-html.ts";
9
+ export { bundleFonts, FONT_FAMILIES, type FontFamilyId } from "./fonts.ts";
10
+ export { type CustomSourceInput, lintCustomSource } from "./lint/custom-source.ts";
11
+ export { type LintFinding, type LintResult, lintStoryboard, type PlatformMarginLookup, type PlatformMargins, } from "./lint/index.ts";
12
+ export { MIN_HOLD_SECONDS } from "./lint/min-hold.ts";
13
+ export { WORDS_PER_SECOND } from "./lint/reading-time.ts";
14
+ export { type BuildManifest, type CompiledAssetView, compiledAssetMismatches, } from "./manifest.ts";
15
+ export { createRenderManifest, type RenderManifest, type RenderManifestInput, renderRenderManifest, } from "./render-manifest.ts";
16
+ export * from "./templates/index.ts";
17
+ export { autoDuration, resolveTimeline, type TimedScene, type Timeline, } from "./timing.ts";
18
+ export { validateStoryboardFull } from "./validate-full.ts";
19
+ export { type FindingLevel, type MediaExpectation, type VerifyFinding, type VerifyInput, type VerifyReport, type VerifyStatus, verifyMedia, } from "./verify.ts";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export { CUSTOM_SCENE_TYPE, collectIcons, emitCustomScene, HELPER_LIMITS, iconGroups, iconLedgerRows, listIcons, readRelativeClosure, resolveSceneDir, scanRelativeImports, scanVendorImports, sceneDocs, sceneWords, searchIconNames, } from "@anvpx/motion-scenes";
2
+ export { bundledThemeDocs } from "@anvpx/motion-themes";
3
+ export { copyAssets, createAssetCollector, normaliseRef, sha256Of, } from "./assets.js";
4
+ export { frozenCopyProblems, } from "./audio/approval.js";
5
+ export { AT_TARGET_TOLERANCE_LU, AUDIO_DIR_NAME, buildFilterGraph, buildLoudnessMeasureArgs, buildLoudnessNormalizeArgs, buildLoudnessNormalizeFilter, buildMixArgs, buildMusicFilterGraph, buildMusicMixArgs, clipsFromManifest, DEFAULT_MUSIC_VOLUME, frozenClips, frozenMusic, frozenWavFor, isContiguous, LOUDNORM_MEASURE_FILTER, missingClips, missingMusic, mixAtDeliveryLevel, needsNormalization, parseAudioManifest, parseLoudnessMeasurement, resolveMusicVolume, TARGET_LRA, TARGET_LUFS, TARGET_TRUE_PEAK_DB, toDelayMs, wavPathFor, } from "./audio/mix.js";
6
+ export { buildProject, tryResolveTheme, } from "./build.js";
7
+ export { clearCache } from "./cache.js";
8
+ export { CSP } from "./emit/index-html.js";
9
+ export { bundleFonts, FONT_FAMILIES } from "./fonts.js";
10
+ export { lintCustomSource } from "./lint/custom-source.js";
11
+ export { lintStoryboard, } from "./lint/index.js";
12
+ export { MIN_HOLD_SECONDS } from "./lint/min-hold.js";
13
+ export { WORDS_PER_SECOND } from "./lint/reading-time.js";
14
+ export { compiledAssetMismatches, } from "./manifest.js";
15
+ export { createRenderManifest, renderRenderManifest, } from "./render-manifest.js";
16
+ export * from "./templates/index.js";
17
+ export { autoDuration, resolveTimeline, } from "./timing.js";
18
+ export { validateStoryboardFull } from "./validate-full.js";
19
+ export { verifyMedia, } from "./verify.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,162 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { afterAll, describe, expect, it } from "vitest";
6
+ import { buildProject } from "../build.js";
7
+ import { resolveTimeline } from "../timing.js";
8
+ import { validateStoryboardFull } from "../validate-full.js";
9
+ import { verifyMedia } from "../verify.js";
10
+ const path = (relative) => fileURLToPath(new URL(relative, import.meta.url));
11
+ const OUT = path("../../dist/.out");
12
+ const PROJECT = `${OUT}/demo`;
13
+ const SNAPSHOTS = `${OUT}/snap`;
14
+ const CLI = path("../../../../node_modules/hyperframes/dist/cli.js");
15
+ const MIN_PNG_BYTES = 10_000;
16
+ const storyboard = JSON.parse(readFileSync(path("../../fixtures/demo.storyboard.json"), "utf8"));
17
+ const hyperframes = (args, cwd = PROJECT) => spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
18
+ const md5 = (file) => createHash("md5").update(readFileSync(file)).digest("hex");
19
+ const sceneMoments = () => {
20
+ const valid = validateStoryboardFull(storyboard);
21
+ if (!valid.ok)
22
+ throw new Error("fixture invalid");
23
+ const timeline = resolveTimeline(valid.value);
24
+ if (!timeline.ok)
25
+ throw new Error("fixture timeline invalid");
26
+ return timeline.value.scenes.map((scene) => Math.round((scene.start + scene.duration * 0.6) * 100) / 100);
27
+ };
28
+ const timed = (run) => {
29
+ const began = performance.now();
30
+ const value = run();
31
+ return { value, seconds: Math.round((performance.now() - began) / 100) / 10 };
32
+ };
33
+ describe("real HyperFrames end to end", () => {
34
+ // HyperFrames writes JSON reports here; they are not source and must not reach `biome check`.
35
+ afterAll(() => rmSync(`${PROJECT}/.hyperframes`, { recursive: true, force: true }));
36
+ rmSync(PROJECT, { recursive: true, force: true });
37
+ rmSync(SNAPSHOTS, { recursive: true, force: true });
38
+ mkdirSync(OUT, { recursive: true });
39
+ const built = buildProject({ storyboard, outDir: PROJECT });
40
+ it("builds the fixture project", () => {
41
+ expect(built.ok).toBe(true);
42
+ });
43
+ it("hyperframes check exits 0", () => {
44
+ const run = hyperframes(["check", "."]);
45
+ expect(run.status, run.stdout + run.stderr).toBe(0);
46
+ expect(run.stdout).toContain("Check passed");
47
+ });
48
+ it("snapshots one non-blank frame inside each of the 5 scenes", () => {
49
+ const moments = sceneMoments();
50
+ expect(moments).toHaveLength(5);
51
+ const run = hyperframes([
52
+ "snapshot",
53
+ ".",
54
+ "--at",
55
+ moments.join(","),
56
+ "--no-end",
57
+ "-o",
58
+ SNAPSHOTS,
59
+ ]);
60
+ expect(run.status, run.stdout + run.stderr).toBe(0);
61
+ for (const [index, moment] of moments.entries()) {
62
+ const file = `${SNAPSHOTS}/frame-0${index}-at-${moment}s.png`;
63
+ expect(existsSync(file), file).toBe(true);
64
+ expect(statSync(file).size, file).toBeGreaterThan(MIN_PNG_BYTES);
65
+ }
66
+ });
67
+ it("two draft renders (15 fps) are byte-identical", () => {
68
+ const render = (name) => timed(() => hyperframes([
69
+ "render",
70
+ ".",
71
+ "-o",
72
+ `${OUT}/${name}`,
73
+ "--fps",
74
+ "15",
75
+ "--quality",
76
+ "draft",
77
+ "--quiet",
78
+ ]));
79
+ const first = render("draft1.mp4");
80
+ const second = render("draft2.mp4");
81
+ expect(first.value.status, first.value.stderr).toBe(0);
82
+ expect(second.value.status, second.value.stderr).toBe(0);
83
+ const report = {
84
+ renderSeconds: [first.seconds, second.seconds],
85
+ md5: [md5(`${OUT}/draft1.mp4`), md5(`${OUT}/draft2.mp4`)],
86
+ };
87
+ writeFileSync(`${OUT}/integration-report.json`, `${JSON.stringify(report, null, 2)}\n`);
88
+ expect(report.md5[0]).toBe(report.md5[1]);
89
+ });
90
+ it("verifyMedia PASSes the real 15 fps draft against the resolved timeline", () => {
91
+ const valid = validateStoryboardFull(storyboard);
92
+ if (!valid.ok)
93
+ throw new Error("fixture invalid");
94
+ const timeline = resolveTimeline(valid.value);
95
+ if (!timeline.ok)
96
+ throw new Error("fixture timeline invalid");
97
+ const probe = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", `${OUT}/draft1.mp4`], { encoding: "utf8" });
98
+ expect(probe.status, probe.stderr).toBe(0);
99
+ const { width, height } = valid.value.meta;
100
+ const report = verifyMedia({
101
+ probe: JSON.parse(probe.stdout),
102
+ expect: { width, height, fps: 15, duration: timeline.value.total },
103
+ });
104
+ expect(report, JSON.stringify(report)).toMatchObject({ status: "PASS" });
105
+ });
106
+ });
107
+ const OVERLAY_PROJECT = `${OUT}/demo-overlays`;
108
+ const OVERLAY_SNAPSHOTS = `${OUT}/snap-overlays`;
109
+ const BASE_SNAPSHOTS = `${OUT}/snap-base`;
110
+ /** Inside an overlay window of the fixture: lower-third, chapter, bug + countdown, progress. */
111
+ const OVERLAY_MOMENTS = [2.5, 7, 16.5, 24];
112
+ /** Base scene only (signals, no overlay): both builds must match here. */
113
+ const QUIET_MOMENT = 12;
114
+ const overlayStoryboard = JSON.parse(readFileSync(path("../../fixtures/demo-overlays.storyboard.json"), "utf8"));
115
+ const snapshots = (project, outDir, moments) => hyperframes(["snapshot", ".", "--at", moments.join(","), "--no-end", "-o", outDir], project);
116
+ const stillAt = (dir, index, moment) => `${dir}/frame-0${index}-at-${moment}s.png`;
117
+ describe("real HyperFrames end to end with overlays", () => {
118
+ afterAll(() => rmSync(`${OVERLAY_PROJECT}/.hyperframes`, { recursive: true, force: true }));
119
+ rmSync(OVERLAY_PROJECT, { recursive: true, force: true });
120
+ rmSync(OVERLAY_SNAPSHOTS, { recursive: true, force: true });
121
+ rmSync(BASE_SNAPSHOTS, { recursive: true, force: true });
122
+ mkdirSync(OUT, { recursive: true });
123
+ const built = buildProject({ storyboard: overlayStoryboard, outDir: OVERLAY_PROJECT });
124
+ it("builds the overlay fixture and hyperframes check exits 0", () => {
125
+ expect(built.ok).toBe(true);
126
+ const run = hyperframes(["check", "."], OVERLAY_PROJECT);
127
+ expect(run.status, run.stdout + run.stderr).toBe(0);
128
+ expect(run.stdout).toContain("Check passed");
129
+ });
130
+ it("stills inside overlay windows differ from the base-only build; quiet frames match", () => {
131
+ const moments = [...OVERLAY_MOMENTS, QUIET_MOMENT];
132
+ const withOverlays = snapshots(OVERLAY_PROJECT, OVERLAY_SNAPSHOTS, moments);
133
+ expect(withOverlays.status, withOverlays.stdout + withOverlays.stderr).toBe(0);
134
+ const baseOnly = snapshots(PROJECT, BASE_SNAPSHOTS, moments);
135
+ expect(baseOnly.status, baseOnly.stdout + baseOnly.stderr).toBe(0);
136
+ for (const [index, moment] of moments.entries()) {
137
+ const file = stillAt(OVERLAY_SNAPSHOTS, index, moment);
138
+ expect(statSync(file).size, file).toBeGreaterThan(MIN_PNG_BYTES);
139
+ const same = md5(file) === md5(stillAt(BASE_SNAPSHOTS, index, moment));
140
+ expect(same, `frame at ${moment}s`).toBe(moment === QUIET_MOMENT);
141
+ }
142
+ });
143
+ it("verifyMedia PASSes a 15 fps draft: overlays do not change the base duration", () => {
144
+ const out = `${OUT}/draft-overlays.mp4`;
145
+ const run = hyperframes(["render", ".", "-o", out, "--fps", "15", "--quality", "draft", "--quiet"], OVERLAY_PROJECT);
146
+ expect(run.status, run.stderr).toBe(0);
147
+ const valid = validateStoryboardFull(overlayStoryboard);
148
+ if (!valid.ok)
149
+ throw new Error("fixture invalid");
150
+ const timeline = resolveTimeline(valid.value);
151
+ if (!timeline.ok)
152
+ throw new Error("fixture timeline invalid");
153
+ const probe = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", out], { encoding: "utf8" });
154
+ expect(probe.status, probe.stderr).toBe(0);
155
+ const { width, height } = valid.value.meta;
156
+ const report = verifyMedia({
157
+ probe: JSON.parse(probe.stdout),
158
+ expect: { width, height, fps: 15, duration: timeline.value.total },
159
+ });
160
+ expect(report, JSON.stringify(report)).toMatchObject({ status: "PASS" });
161
+ });
162
+ });
@@ -0,0 +1,12 @@
1
+ import type { LintFinding } from "./types.ts";
2
+ /** Files of an authored (`type: "custom"`) scene, as read by the caller. */
3
+ export interface CustomSourceInput {
4
+ readonly js: string;
5
+ readonly css?: string;
6
+ readonly html?: string;
7
+ }
8
+ /**
9
+ * Determinism and safety lint for an authored scene: one finding per rule and file,
10
+ * pointing at the first offending line. Pure, so the CLI can run it before a build.
11
+ */
12
+ export declare const lintCustomSource: (input: CustomSourceInput, dir: string) => LintFinding[];
@@ -0,0 +1,130 @@
1
+ const PROBES = [
2
+ {
3
+ file: "scene.js",
4
+ pattern: /\bDate\.now\s*\(/,
5
+ message: "Date.now is a wall clock",
6
+ suggestion: "derive the value from the t argument",
7
+ },
8
+ {
9
+ file: "scene.js",
10
+ pattern: /\bperformance\.now\s*\(/,
11
+ message: "performance.now is a wall clock",
12
+ suggestion: "derive the value from the t argument",
13
+ },
14
+ {
15
+ file: "scene.js",
16
+ pattern: /\bnew\s+Date\s*\(\s*\)/,
17
+ message: "new Date() reads the wall clock",
18
+ suggestion: "pass an explicit timestamp, or derive from t",
19
+ },
20
+ {
21
+ file: "scene.js",
22
+ pattern: /\brequestAnimationFrame\s*\(/,
23
+ message: "requestAnimationFrame drives its own clock",
24
+ suggestion: "animate from seek(t); the renderer owns the clock",
25
+ },
26
+ {
27
+ file: "scene.js",
28
+ pattern: /\bMath\.random\s*\(/,
29
+ message: "Math.random is unseeded",
30
+ suggestion: "use a seeded generator so every frame repeats",
31
+ },
32
+ {
33
+ file: "scene.js",
34
+ pattern: /\bset(?:Timeout|Interval)\s*\(/,
35
+ message: "a timer is not a frame clock",
36
+ suggestion: "derive the value from t",
37
+ },
38
+ {
39
+ file: "scene.js",
40
+ pattern: /\bfetch\s*\(|\bXMLHttpRequest\b|\bnew\s+WebSocket\b/,
41
+ message: "network access is blocked at render",
42
+ suggestion: "inline the data into props or the scene",
43
+ },
44
+ {
45
+ file: "scene.js",
46
+ pattern: /\bnew\s+Worker\s*\(/,
47
+ message: "workers are blocked at render",
48
+ suggestion: "keep the work on the main thread inside seek(t)",
49
+ },
50
+ {
51
+ file: "scene.js",
52
+ pattern: /\bwindow\./,
53
+ message: "window is reserved by the renderer",
54
+ suggestion: "use the root element and the props you were given",
55
+ },
56
+ {
57
+ file: "style.css",
58
+ pattern: /(^|[\s;{])animation(?:-name)?\s*:/,
59
+ message: "a CSS animation runs on the browser clock",
60
+ suggestion: "set the property from seek(t) instead",
61
+ },
62
+ {
63
+ file: "style.css",
64
+ pattern: /(^|[\s;{])transition\s*:/,
65
+ message: "a CSS transition runs on the browser clock",
66
+ suggestion: "set the property from seek(t) instead",
67
+ },
68
+ {
69
+ file: "style.css",
70
+ pattern: /@keyframes\b/,
71
+ message: "@keyframes runs on the browser clock",
72
+ suggestion: "set the property from seek(t) instead",
73
+ },
74
+ {
75
+ file: "style.css",
76
+ pattern: /url\(\s*['"]?https?:/i,
77
+ message: "external stylesheet URL",
78
+ suggestion: "inline the asset; the render has no network",
79
+ },
80
+ {
81
+ file: "fragment.html",
82
+ pattern: /<script\b/i,
83
+ message: "fragment contains a script tag",
84
+ suggestion: "put code in scene.js",
85
+ },
86
+ {
87
+ file: "fragment.html",
88
+ pattern: /\son[a-z]+\s*=/i,
89
+ message: "fragment contains an inline event handler",
90
+ suggestion: "attach behaviour from scene.js",
91
+ },
92
+ ];
93
+ /** Blanks a span while preserving newlines, so reported line numbers stay true. */
94
+ const blank = (text) => text.replace(/[^\n]/g, " ");
95
+ const stripJs = (source) => source.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/\/\/[^\n]*/g, blank);
96
+ const stripCss = (source) => source.replace(/\/\*[\s\S]*?\*\//g, blank);
97
+ const stripHtml = (source) => source.replace(/<!--[\s\S]*?-->/g, blank);
98
+ const sourceOf = (input, file) => {
99
+ const raw = file === "scene.js" ? input.js : file === "style.css" ? input.css : input.html;
100
+ if (raw === undefined)
101
+ return undefined;
102
+ // Comments describe the rules ("no window.") and must not trip them.
103
+ return file === "scene.js" ? stripJs(raw) : file === "style.css" ? stripCss(raw) : stripHtml(raw);
104
+ };
105
+ /** First offending line of one file, as a finding path of `<dir>/<file>:<line>`. */
106
+ const scan = (input, probe, dir) => {
107
+ const source = sourceOf(input, probe.file);
108
+ if (source === undefined)
109
+ return undefined;
110
+ const lines = source.split(/\r?\n/);
111
+ for (let index = 0; index < lines.length; index += 1) {
112
+ if (!probe.pattern.test(lines[index] ?? ""))
113
+ continue;
114
+ return {
115
+ severity: "error",
116
+ path: `${dir}/${probe.file}:${index + 1}`,
117
+ message: probe.message,
118
+ suggestion: probe.suggestion,
119
+ };
120
+ }
121
+ return undefined;
122
+ };
123
+ /**
124
+ * Determinism and safety lint for an authored scene: one finding per rule and file,
125
+ * pointing at the first offending line. Pure, so the CLI can run it before a build.
126
+ */
127
+ export const lintCustomSource = (input, dir) => PROBES.flatMap((probe) => {
128
+ const finding = scan(input, probe, dir);
129
+ return finding === undefined ? [] : [finding];
130
+ });
@@ -0,0 +1,24 @@
1
+ import type { LintInput } from "./types.ts";
2
+ export interface SceneSpec {
3
+ readonly id?: string;
4
+ readonly type?: string;
5
+ readonly props?: Record<string, unknown>;
6
+ readonly duration?: number;
7
+ readonly transition?: string;
8
+ readonly layer?: "base" | "overlay";
9
+ readonly over?: string;
10
+ readonly offset?: number;
11
+ readonly narration?: string;
12
+ readonly ease?: string;
13
+ readonly move?: string;
14
+ /** Shorthand for `props.caption` (timed speech; craft type rules ignore it). */
15
+ readonly caption?: string;
16
+ /** Shorthand for `props.spacing` (explicit author spacing input; off-grid rule checks it). */
17
+ readonly spacing?: number | string;
18
+ }
19
+ export interface FrameSpec {
20
+ readonly width: number;
21
+ readonly height: number;
22
+ }
23
+ /** Builds a LintInput from loose scene specs (test helper); frame defaults to 1920x1080. */
24
+ export declare const lintInput: (specs: readonly SceneSpec[], frame?: FrameSpec) => LintInput;
@@ -0,0 +1,42 @@
1
+ import { sceneWords } from "@anvpx/motion-scenes";
2
+ import { resolveTimeline } from "../timing.js";
3
+ const OPTIONAL_KEYS = [
4
+ "duration",
5
+ "transition",
6
+ "layer",
7
+ "over",
8
+ "offset",
9
+ "narration",
10
+ "ease",
11
+ "move",
12
+ ];
13
+ const optional = (spec) => Object.fromEntries(OPTIONAL_KEYS.filter((key) => spec[key] !== undefined).map((key) => [key, spec[key]]));
14
+ /** Builds a LintInput from loose scene specs (test helper); frame defaults to 1920x1080. */
15
+ export const lintInput = (specs, frame = { width: 1920, height: 1080 }) => {
16
+ const storyboard = {
17
+ version: 1,
18
+ meta: { title: "T", fps: 30, ...frame, theme: "default" },
19
+ scenes: specs.map((spec, index) => {
20
+ const shorthand = {};
21
+ if (spec.caption !== undefined)
22
+ shorthand.caption = spec.caption;
23
+ if (spec.spacing !== undefined)
24
+ shorthand.spacing = spec.spacing;
25
+ return {
26
+ id: spec.id ?? `s${index + 1}`,
27
+ type: spec.type ?? "title",
28
+ // Explicit props win over the caption/spacing shorthands.
29
+ props: { ...shorthand, ...(spec.props ?? { title: "Hello" }) },
30
+ ...optional(spec),
31
+ };
32
+ }),
33
+ };
34
+ const timeline = resolveTimeline(storyboard);
35
+ if (!timeline.ok)
36
+ throw new Error("fixture timeline must resolve");
37
+ return {
38
+ storyboard,
39
+ timeline: timeline.value,
40
+ words: storyboard.scenes.map((scene) => sceneWords(scene.props, scene.type)),
41
+ };
42
+ };
@@ -0,0 +1,12 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /** Minimum on-screen text size (px) of a node label. */
3
+ export declare const MIN_FLOW_TEXT_PX = 28;
4
+ /** A word wider than its node would be cut mid-word: shorten it or give the nodes room. */
5
+ export declare const flowWordBreakRule: LintRule;
6
+ /**
7
+ * An edge label that still touches something after placement: a card or another label is an
8
+ * error, a crossing edge a warning (the label's halo keeps it readable).
9
+ */
10
+ export declare const flowEdgeLabelRule: LintRule;
11
+ /** Too many or too long nodes force the labels under the legible minimum (or off the card). */
12
+ export declare const flowTextSizeRule: LintRule;
@@ -0,0 +1,38 @@
1
+ import { inspectFlow } from "@anvpx/motion-scenes";
2
+ /** Minimum on-screen text size (px) of a node label. */
3
+ export const MIN_FLOW_TEXT_PX = 28;
4
+ /** Layout report of every base `flow` scene; scenes whose props are not a flow graph are skipped. */
5
+ const flowScenes = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
6
+ if (scene.type !== "flow" || scene.layer === "overlay")
7
+ return [];
8
+ const report = inspectFlow(scene.props, storyboard.meta);
9
+ return report === undefined ? [] : [{ index, report }];
10
+ });
11
+ /** A word wider than its node would be cut mid-word: shorten it or give the nodes room. */
12
+ export const flowWordBreakRule = (input) => flowScenes(input).flatMap(({ index, report }) => report.wordBreaks.map((hit) => ({
13
+ severity: "error",
14
+ path: `/scenes/${index}/props/nodes/${hit.node}/label`,
15
+ message: `word "${hit.word}" is wider than node "${hit.id}"; it would break mid-word`,
16
+ suggestion: 'shorten the word, use fewer nodes or direction "tb"',
17
+ })));
18
+ /**
19
+ * An edge label that still touches something after placement: a card or another label is an
20
+ * error, a crossing edge a warning (the label's halo keeps it readable).
21
+ */
22
+ export const flowEdgeLabelRule = (input) => flowScenes(input).flatMap(({ index, report }) => report.labelOverlaps.map((hit) => ({
23
+ severity: hit.hard ? "error" : "warning",
24
+ path: `/scenes/${index}/props/edges/${hit.edge}/label`,
25
+ message: `edge label overlaps ${hit.with.join(", ")}`,
26
+ suggestion: "shorten the label, drop it, or use fewer nodes",
27
+ })));
28
+ /** Too many or too long nodes force the labels under the legible minimum (or off the card). */
29
+ export const flowTextSizeRule = (input) => flowScenes(input).flatMap(({ index, report }) => report.crowded || report.font < MIN_FLOW_TEXT_PX
30
+ ? [
31
+ {
32
+ severity: "error",
33
+ path: `/scenes/${index}/props/nodes`,
34
+ message: `node text does not fit at ${MIN_FLOW_TEXT_PX}px: too many or too long labels`,
35
+ suggestion: 'use fewer nodes, shorter labels or direction "tb"',
36
+ },
37
+ ]
38
+ : []);
@@ -0,0 +1,3 @@
1
+ import type { Storyboard } from "@anvpx/motion-schema";
2
+ /** True for portrait frames (9:16 and any taller-than-wide format). */
3
+ export declare const isPortrait: (meta: Storyboard["meta"]) => boolean;
@@ -0,0 +1,2 @@
1
+ /** True for portrait frames (9:16 and any taller-than-wide format). */
2
+ export const isPortrait = (meta) => meta.height > meta.width;
@@ -0,0 +1,6 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /**
3
+ * 9:16 only: the first base scene must open with a title or hook; the base timeline starts at
4
+ * 0 s, inside the 2 s window. A "social" style tag does not exist in storyboards yet.
5
+ */
6
+ export declare const hookFirstRule: LintRule;
@@ -0,0 +1,27 @@
1
+ import { isPortrait } from "./frame.js";
2
+ /** Props that carry a hook line in the first scene. */
3
+ const HOOK_KEYS = ["title", "headline", "heading", "text", "value"];
4
+ const hasText = (props) => HOOK_KEYS.some((key) => {
5
+ const value = props[key];
6
+ return (typeof value === "string" && value.trim() !== "") || typeof value === "number";
7
+ });
8
+ /**
9
+ * 9:16 only: the first base scene must open with a title or hook; the base timeline starts at
10
+ * 0 s, inside the 2 s window. A "social" style tag does not exist in storyboards yet.
11
+ */
12
+ export const hookFirstRule = ({ storyboard }) => {
13
+ if (!isPortrait(storyboard.meta))
14
+ return [];
15
+ const index = storyboard.scenes.findIndex((scene) => scene.layer !== "overlay");
16
+ const first = storyboard.scenes[index];
17
+ if (first === undefined || hasText(first.props))
18
+ return [];
19
+ return [
20
+ {
21
+ severity: "warning",
22
+ path: `/scenes/${index}/props`,
23
+ message: `first scene "${first.id}" has no title or hook text within the first 2s`,
24
+ suggestion: "open with a title scene or add a title line",
25
+ },
26
+ ];
27
+ };