@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
@@ -0,0 +1,122 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseBlueprintDoc, parsePacingDoc, parseStylePreset, parseTemplateManifest, } from "@anvpx/motion-schema";
4
+ export const MANIFEST_FILE = "template.json";
5
+ /** A theme `file` starting with this prefix is read from the shared themes folder. */
6
+ export const SHARED_PREFIX = "@shared/";
7
+ const fail = (file, message, suggestion) => ({
8
+ ok: false,
9
+ errors: [
10
+ suggestion === undefined ? { path: file, message } : { path: file, message, suggestion },
11
+ ],
12
+ });
13
+ const readRaw = (dir, file) => {
14
+ let text;
15
+ try {
16
+ text = readFileSync(join(dir, file), "utf8");
17
+ }
18
+ catch {
19
+ return fail(file, "file not found", "create it or fix the path in template.json");
20
+ }
21
+ try {
22
+ return { ok: true, value: { text, json: JSON.parse(text) } };
23
+ }
24
+ catch (error) {
25
+ return fail(file, `invalid JSON: ${error.message}`);
26
+ }
27
+ };
28
+ /** Reads one shared file; errors name the `@shared/...` ref the manifest used. */
29
+ const readShared = (sharedDir, ref, name) => {
30
+ const read = readRaw(sharedDir, name);
31
+ return read.ok ? read : fail(ref, "shared theme not found", "add it under shared/themes");
32
+ };
33
+ const inFile = (file, errors) => errors.map((error) => ({ ...error, path: `${file}#${error.path}` }));
34
+ const readParsed = (dir, file, parse) => {
35
+ const raw = readRaw(dir, file);
36
+ if (!raw.ok)
37
+ return raw;
38
+ const parsed = parse(raw.value.json);
39
+ return parsed.ok ? parsed : { ok: false, errors: inFile(file, parsed.errors) };
40
+ };
41
+ /** Reads and parses one referenced document; its own `id` must match the manifest entry. */
42
+ const readDoc = (dir, ref, parse) => {
43
+ const parsed = readParsed(dir, ref.file, parse);
44
+ if (!parsed.ok)
45
+ return parsed;
46
+ if (parsed.value.id !== ref.id) {
47
+ return fail(ref.file, `id "${parsed.value.id}" does not match manifest id "${ref.id}"`);
48
+ }
49
+ return parsed;
50
+ };
51
+ const collect = (results) => {
52
+ const errors = results.flatMap((result) => (result.ok ? [] : result.errors));
53
+ if (errors.length > 0)
54
+ return { ok: false, errors };
55
+ return { ok: true, value: results.flatMap((result) => (result.ok ? [result.value] : [])) };
56
+ };
57
+ const byId = (items) => new Map(items.map((item) => [item.id, item]));
58
+ /** Where a theme ref is read from: the template folder, or (for `@shared/<name>`) the shared folder. */
59
+ export const resolveThemeFile = (file, sharedThemesDir) => {
60
+ if (!file.startsWith(SHARED_PREFIX))
61
+ return { ok: true, value: { shared: false, file } };
62
+ const name = file.slice(SHARED_PREFIX.length);
63
+ if (name === "" || name.includes("/")) {
64
+ return fail(file, "shared theme must be a plain file name", "use @shared/<name>.json");
65
+ }
66
+ if (sharedThemesDir === undefined) {
67
+ return fail(file, "shared themes are not available here", "load through the CLI or pass sharedThemesDir");
68
+ }
69
+ return { ok: true, value: { shared: true, file: name } };
70
+ };
71
+ const readTheme = (dir, ref, options) => {
72
+ const target = resolveThemeFile(ref.file, options.sharedThemesDir);
73
+ if (!target.ok)
74
+ return target;
75
+ const raw = target.value.shared
76
+ ? readShared(options.sharedThemesDir ?? "", ref.file, target.value.file)
77
+ : readRaw(dir, ref.file);
78
+ if (!raw.ok)
79
+ return raw;
80
+ const { id, file, mode, pair } = ref;
81
+ const theme = { id, file, mode, text: raw.value.text, doc: raw.value.json };
82
+ return { ok: true, value: pair === undefined ? theme : { ...theme, pair } };
83
+ };
84
+ const readText = (dir, file) => {
85
+ try {
86
+ return { ok: true, value: readFileSync(join(dir, file), "utf8") };
87
+ }
88
+ catch {
89
+ return fail(file, "file not found", "create it or fix the path in template.json");
90
+ }
91
+ };
92
+ /** Reads a template folder: manifest, themes, styles, blueprints, pacing and copy guide. */
93
+ export const loadTemplate = (dir, options = {}) => {
94
+ const raw = readRaw(dir, MANIFEST_FILE);
95
+ if (!raw.ok)
96
+ return raw;
97
+ const parsed = parseTemplateManifest(raw.value.json);
98
+ if (!parsed.ok)
99
+ return { ok: false, errors: inFile(MANIFEST_FILE, parsed.errors) };
100
+ const manifest = parsed.value;
101
+ const themes = collect(manifest.themes.map((ref) => readTheme(dir, ref, options)));
102
+ const styles = collect(manifest.styles.map((ref) => readDoc(dir, ref, parseStylePreset)));
103
+ const blueprints = collect(manifest.blueprints.map((ref) => readDoc(dir, ref, parseBlueprintDoc)));
104
+ const pacing = readParsed(dir, manifest.pacing, parsePacingDoc);
105
+ const copy = readText(dir, manifest.copy);
106
+ const failures = [themes, styles, blueprints, pacing, copy].flatMap((result) => result.ok ? [] : result.errors);
107
+ if (failures.length > 0 || !themes.ok || !styles.ok || !blueprints.ok || !pacing.ok || !copy.ok) {
108
+ return { ok: false, errors: failures };
109
+ }
110
+ return {
111
+ ok: true,
112
+ value: {
113
+ dir,
114
+ manifest,
115
+ themes: byId(themes.value),
116
+ styles: byId(styles.value),
117
+ blueprints: byId(blueprints.value),
118
+ pacing: pacing.value,
119
+ copy: copy.value,
120
+ },
121
+ };
122
+ };
@@ -0,0 +1,21 @@
1
+ import type { LENGTH_CLASSES, PacingDoc, Result } from "@anvpx/motion-schema";
2
+ import type { LintFinding } from "../lint/index.ts";
3
+ export type LengthClass = (typeof LENGTH_CLASSES)[number];
4
+ interface PacedScene {
5
+ readonly type: string;
6
+ readonly props: Record<string, unknown>;
7
+ readonly id?: string | undefined;
8
+ readonly layer?: string | undefined;
9
+ readonly over?: string | undefined;
10
+ readonly offset?: number | undefined;
11
+ readonly duration?: number | undefined;
12
+ readonly narration?: string | undefined;
13
+ }
14
+ /**
15
+ * Base scenes without a duration get the pacing table's seconds: the table is a floor that is raised to
16
+ * fit the on-screen text and any narration at `wordsPerSecond`.
17
+ */
18
+ export declare const applyPacing: <S extends PacedScene>(scenes: readonly S[], pacing: PacingDoc, length: LengthClass) => Result<S[]>;
19
+ /** Length-class targets are advice: out-of-range scene counts or runtimes are warnings. */
20
+ export declare const pacingWarnings: (pacing: PacingDoc, length: LengthClass, sceneCount: number, totalSec: number) => LintFinding[];
21
+ export {};
@@ -0,0 +1,61 @@
1
+ import { sceneWords } from "@anvpx/motion-scenes";
2
+ const round1 = (value) => Math.round(value * 10) / 10;
3
+ const narrationSeconds = (narration, wordsPerSecond) => round1(narration.split(/\s+/).filter(Boolean).length / wordsPerSecond);
4
+ /** Comfortable reading time of the on-screen text, in half-second steps. */
5
+ const readingSeconds = (props, type, wordsPerSecond) => Math.ceil((sceneWords(props, type) / wordsPerSecond) * 2) / 2;
6
+ /** An overlay without a duration spans its base scene from its offset to the scene end. */
7
+ const spanOverlays = (scenes) => scenes.map((scene) => {
8
+ if (scene.layer !== "overlay" || scene.duration !== undefined)
9
+ return scene;
10
+ const base = scenes.find((other) => other.layer !== "overlay" && other.id === scene.over);
11
+ if (base?.duration === undefined)
12
+ return scene;
13
+ return { ...scene, duration: round1(Math.max(base.duration - (scene.offset ?? 0), 0.1)) };
14
+ });
15
+ /**
16
+ * Base scenes without a duration get the pacing table's seconds: the table is a floor that is raised to
17
+ * fit the on-screen text and any narration at `wordsPerSecond`.
18
+ */
19
+ export const applyPacing = (scenes, pacing, length) => {
20
+ const errors = [];
21
+ const paced = scenes.map((scene, index) => {
22
+ if (scene.duration !== undefined || scene.layer === "overlay")
23
+ return scene;
24
+ const seconds = pacing.sceneSeconds[scene.type]?.[length];
25
+ if (seconds === undefined) {
26
+ errors.push({
27
+ path: `/scenes/${index}/type`,
28
+ message: `pacing has no seconds for scene type "${scene.type}"`,
29
+ suggestion: `add sceneSeconds.${scene.type} to pacing.json or set duration`,
30
+ });
31
+ return scene;
32
+ }
33
+ const spoken = scene.narration === undefined ? 0 : narrationSeconds(scene.narration, pacing.wordsPerSecond);
34
+ const read = readingSeconds(scene.props, scene.type, pacing.wordsPerSecond);
35
+ return { ...scene, duration: Math.max(seconds, spoken, read) };
36
+ });
37
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, value: spanOverlays(paced) };
38
+ };
39
+ const outside = (value, [low, high]) => value < low || value > high;
40
+ /** Length-class targets are advice: out-of-range scene counts or runtimes are warnings. */
41
+ export const pacingWarnings = (pacing, length, sceneCount, totalSec) => {
42
+ const target = pacing.lengths[length];
43
+ const warnings = [];
44
+ if (outside(sceneCount, target.scenes)) {
45
+ warnings.push({
46
+ severity: "warning",
47
+ path: "/scenes",
48
+ message: `${sceneCount} scenes; "${length}" targets ${target.scenes[0]}-${target.scenes[1]}`,
49
+ suggestion: "change the length class or the slot item counts",
50
+ });
51
+ }
52
+ if (outside(totalSec, target.totalSec)) {
53
+ warnings.push({
54
+ severity: "warning",
55
+ path: "/scenes",
56
+ message: `${totalSec}s total; "${length}" targets ${target.totalSec[0]}-${target.totalSec[1]}s`,
57
+ suggestion: "change the length class or trim content",
58
+ });
59
+ }
60
+ return warnings;
61
+ };
@@ -0,0 +1,64 @@
1
+ import type { LoadedTemplate } from "./types.ts";
2
+ /** One blueprint flattened with the template data it inherits; the unit that is filtered and ranked. */
3
+ export interface BlueprintEntry {
4
+ readonly template: string;
5
+ readonly blueprint: string;
6
+ readonly title: string;
7
+ readonly summary: string;
8
+ readonly purpose: string;
9
+ readonly useCases: readonly string[];
10
+ readonly goodFor: string;
11
+ readonly notFor: string;
12
+ readonly audience: readonly string[];
13
+ /** Blueprint tags. */
14
+ readonly tags: readonly string[];
15
+ /** Template-level tags, inherited at a lower weight. */
16
+ readonly templateTags: readonly string[];
17
+ readonly aspects: readonly string[];
18
+ /** Seconds; absent when the blueprint declares none. */
19
+ readonly lengths?: {
20
+ readonly min: number;
21
+ readonly max: number;
22
+ };
23
+ }
24
+ export interface SearchFilters {
25
+ /** Every tag must be present on the blueprint or its template (exact slugs). */
26
+ readonly tags?: readonly string[];
27
+ readonly aspect?: string;
28
+ /** Keeps blueprints whose `lengths` contain this many seconds. */
29
+ readonly sec?: number;
30
+ }
31
+ export interface Hit {
32
+ readonly entry: BlueprintEntry;
33
+ readonly score: number;
34
+ readonly tagScore: number;
35
+ /** A query token appears in `notFor`; the hit sinks below the others. */
36
+ readonly notFor: boolean;
37
+ /** Up to three query tokens that earned the score, strongest first. */
38
+ readonly why: readonly string[];
39
+ }
40
+ export declare const WEIGHTS: {
41
+ readonly tag: 3;
42
+ readonly audience: 2;
43
+ readonly text: 1;
44
+ readonly title: 1;
45
+ readonly templateTag: 1;
46
+ readonly notFor: -3;
47
+ };
48
+ /** Default stopword list; the CLI passes the store stopword file instead (a store test keeps them equal). */
49
+ export declare const DEFAULT_STOPWORDS: readonly string[];
50
+ /** Lowercase, split on non-alphanumerics, drop stopwords, strip one plural suffix; order kept, no duplicates. */
51
+ export declare const tokenize: (text: string, stopwords?: readonly string[]) => string[];
52
+ /** Template lengths are derived (min of mins, max of maxes), never stored. */
53
+ export declare const templateLengths: (template: LoadedTemplate) => {
54
+ min: number;
55
+ max: number;
56
+ } | undefined;
57
+ export declare const entriesOf: (templates: readonly LoadedTemplate[]) => BlueprintEntry[];
58
+ /** Hard filters (AND); result sorted by `template/blueprint` id. */
59
+ export declare const filterEntries: (entries: readonly BlueprintEntry[], filters: SearchFilters) => BlueprintEntry[];
60
+ /**
61
+ * Deterministic ranking of `entries` for a free-text query: hits with a NOT-FOR match sink below the
62
+ * rest, then higher score, higher tag score, then id order. Entries with no positive match are dropped.
63
+ */
64
+ export declare const rankEntries: (entries: readonly BlueprintEntry[], query: string, stopwords?: readonly string[]) => Hit[];
@@ -0,0 +1,129 @@
1
+ export const WEIGHTS = {
2
+ tag: 3,
3
+ audience: 2,
4
+ text: 1,
5
+ title: 1,
6
+ templateTag: 1,
7
+ notFor: -3,
8
+ };
9
+ const MAX_WHY = 3;
10
+ /** Default stopword list; the CLI passes the store stopword file instead (a store test keeps them equal). */
11
+ export const DEFAULT_STOPWORDS = ("a an and are as at be but by for from has have how i in into is it its of on or our so that the " +
12
+ "their them then there these they this to up us use using was we what when where which who why will " +
13
+ "with you your about can do does make need needs want video videos short quick").split(" ");
14
+ const stem = (word) => {
15
+ if (word.length <= 3)
16
+ return word;
17
+ if (word.endsWith("ies"))
18
+ return `${word.slice(0, -3)}y`;
19
+ if (/(?:ss|x|z|ch|sh)es$/.test(word))
20
+ return word.slice(0, -2);
21
+ if (word.endsWith("ss") || !word.endsWith("s"))
22
+ return word;
23
+ return word.slice(0, -1);
24
+ };
25
+ /** Lowercase, split on non-alphanumerics, drop stopwords, strip one plural suffix; order kept, no duplicates. */
26
+ export const tokenize = (text, stopwords = DEFAULT_STOPWORDS) => {
27
+ const dropped = new Set(stopwords);
28
+ const words = text
29
+ .toLowerCase()
30
+ .split(/[^a-z0-9]+/)
31
+ .filter((word) => word !== "" && !dropped.has(word))
32
+ .map(stem);
33
+ return [...new Set(words)];
34
+ };
35
+ const tokenSetOf = (stopwords) => (...texts) => new Set(texts.flatMap((text) => tokenize(text, stopwords)));
36
+ /** Template lengths are derived (min of mins, max of maxes), never stored. */
37
+ export const templateLengths = (template) => {
38
+ const all = [...template.blueprints.values()].flatMap((bp) => bp.discover?.lengths ?? []);
39
+ if (all.length === 0)
40
+ return undefined;
41
+ return { min: Math.min(...all.map((l) => l.min)), max: Math.max(...all.map((l) => l.max)) };
42
+ };
43
+ export const entriesOf = (templates) => templates.flatMap((template) => {
44
+ const { manifest } = template;
45
+ return [...template.blueprints.values()].map((bp) => {
46
+ const own = bp.discover;
47
+ const inherited = manifest.discover;
48
+ return {
49
+ template: manifest.id,
50
+ blueprint: bp.id,
51
+ title: manifest.title,
52
+ summary: manifest.summary,
53
+ purpose: bp.purpose,
54
+ useCases: own?.useCases ?? [],
55
+ goodFor: own?.goodFor ?? "",
56
+ notFor: own?.notFor ?? "",
57
+ audience: own?.audience ?? inherited?.audience ?? [],
58
+ tags: own?.tags ?? [],
59
+ templateTags: inherited?.tags ?? [],
60
+ aspects: own?.aspects ?? inherited?.aspects ?? [],
61
+ ...(own?.lengths === undefined ? {} : { lengths: own.lengths }),
62
+ };
63
+ });
64
+ });
65
+ const idOf = (entry) => `${entry.template}/${entry.blueprint}`;
66
+ const matches = (entry, filters) => {
67
+ const present = new Set([...entry.tags, ...entry.templateTags]);
68
+ if (!(filters.tags ?? []).every((tag) => present.has(tag)))
69
+ return false;
70
+ if (filters.aspect !== undefined && !entry.aspects.includes(filters.aspect))
71
+ return false;
72
+ const { sec } = filters;
73
+ if (sec !== undefined) {
74
+ if (entry.lengths === undefined)
75
+ return false;
76
+ if (sec < entry.lengths.min || sec > entry.lengths.max)
77
+ return false;
78
+ }
79
+ return true;
80
+ };
81
+ /** Hard filters (AND); result sorted by `template/blueprint` id. */
82
+ export const filterEntries = (entries, filters) => entries.filter((entry) => matches(entry, filters)).sort((a, b) => idOf(a).localeCompare(idOf(b)));
83
+ const scoreToken = (token, sets) => {
84
+ const tag = sets.tags.has(token) ? WEIGHTS.tag : 0;
85
+ const gain = tag +
86
+ (sets.audience.has(token) ? WEIGHTS.audience : 0) +
87
+ (sets.text.has(token) ? WEIGHTS.text : 0) +
88
+ (sets.title.has(token) ? WEIGHTS.title : 0) +
89
+ (!sets.tags.has(token) && sets.templateTags.has(token) ? WEIGHTS.templateTag : 0);
90
+ return { token, gain, tag, notFor: sets.notFor.has(token) };
91
+ };
92
+ const scoreEntry = (entry, query, stopwords) => {
93
+ const tokenSet = tokenSetOf(stopwords);
94
+ const sets = {
95
+ tags: tokenSet(...entry.tags),
96
+ templateTags: tokenSet(...entry.templateTags),
97
+ audience: tokenSet(...entry.audience),
98
+ text: tokenSet(...entry.useCases, entry.goodFor, entry.purpose),
99
+ title: tokenSet(entry.title, entry.summary, entry.blueprint),
100
+ notFor: tokenSet(entry.notFor),
101
+ };
102
+ const scored = query.map((token) => scoreToken(token, sets));
103
+ const positive = scored.filter((s) => s.gain > 0);
104
+ const penalty = scored.filter((s) => s.notFor).length * WEIGHTS.notFor;
105
+ return {
106
+ entry,
107
+ score: positive.reduce((sum, s) => sum + s.gain, 0) + penalty,
108
+ tagScore: positive.reduce((sum, s) => sum + s.tag, 0),
109
+ notFor: penalty < 0,
110
+ why: [...positive]
111
+ .sort((a, b) => b.gain - a.gain)
112
+ .slice(0, MAX_WHY)
113
+ .map((s) => s.token),
114
+ };
115
+ };
116
+ /**
117
+ * Deterministic ranking of `entries` for a free-text query: hits with a NOT-FOR match sink below the
118
+ * rest, then higher score, higher tag score, then id order. Entries with no positive match are dropped.
119
+ */
120
+ export const rankEntries = (entries, query, stopwords = DEFAULT_STOPWORDS) => {
121
+ const tokens = tokenize(query, stopwords);
122
+ return entries
123
+ .map((entry) => scoreEntry(entry, tokens, stopwords))
124
+ .filter((hit) => hit.why.length > 0)
125
+ .sort((a, b) => Number(a.notFor) - Number(b.notFor) ||
126
+ b.score - a.score ||
127
+ b.tagScore - a.tagScore ||
128
+ idOf(a.entry).localeCompare(idOf(b.entry)));
129
+ };
@@ -0,0 +1,2 @@
1
+ /** Negative when `a` < `b`, 0 when equal, positive when `a` > `b`; plain `x.y.z` versions only. */
2
+ export declare const compareSemver: (a: string, b: string) => number;
@@ -0,0 +1,12 @@
1
+ const parts = (version) => version.split(".").map(Number);
2
+ /** Negative when `a` < `b`, 0 when equal, positive when `a` > `b`; plain `x.y.z` versions only. */
3
+ export const compareSemver = (a, b) => {
4
+ const left = parts(a);
5
+ const right = parts(b);
6
+ for (let index = 0; index < 3; index++) {
7
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
8
+ if (diff !== 0)
9
+ return diff;
10
+ }
11
+ return 0;
12
+ };
@@ -0,0 +1,8 @@
1
+ import type { Result, SlotDef } from "@anvpx/motion-schema";
2
+ export type SlotValues = Readonly<Record<string, unknown>>;
3
+ type Defs = Readonly<Record<string, SlotDef>>;
4
+ /** A slot counts as set when it holds something: non-empty text/list, any number or object. */
5
+ export declare const isSet: (value: unknown) => boolean;
6
+ /** Checks slot values against the blueprint's slot definitions and fills in defaults. */
7
+ export declare const resolveSlots: (defs: Defs, input: unknown) => Result<SlotValues>;
8
+ export {};
@@ -0,0 +1,116 @@
1
+ import { toJsonPointer } from "@anvpx/motion-schema";
2
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ /** A slot counts as set when it holds something: non-empty text/list, any number or object. */
4
+ export const isSet = (value) => value !== undefined &&
5
+ value !== null &&
6
+ value !== "" &&
7
+ !(Array.isArray(value) && value.length === 0);
8
+ const problem = (path, message, suggestion) => {
9
+ const base = { path: toJsonPointer(["slots", ...path]), message };
10
+ return suggestion === undefined ? base : { ...base, suggestion };
11
+ };
12
+ const textErrors = (value, path, min, max) => {
13
+ if (typeof value !== "string")
14
+ return [problem(path, "must be text")];
15
+ if (max !== undefined && value.length > max) {
16
+ return [problem(path, `${value.length} chars, max ${max}`, `shorten to <= ${max} chars`)];
17
+ }
18
+ if (min !== undefined && value.length < min) {
19
+ return [problem(path, `${value.length} chars, min ${min}`, `use >= ${min} chars`)];
20
+ }
21
+ return [];
22
+ };
23
+ const numberErrors = (value, path, rules) => {
24
+ if (typeof value !== "number" || !Number.isFinite(value)) {
25
+ return [problem(path, "must be a number")];
26
+ }
27
+ if (rules.int === true && !Number.isInteger(value))
28
+ return [problem(path, "must be an integer")];
29
+ if (rules.min !== undefined && value < rules.min) {
30
+ return [problem(path, `${value} is below min ${rules.min}`)];
31
+ }
32
+ if (rules.max !== undefined && value > rules.max) {
33
+ return [problem(path, `${value} is above max ${rules.max}`)];
34
+ }
35
+ return [];
36
+ };
37
+ const countErrors = (list, path, min, max) => {
38
+ if (max !== undefined && list.length > max) {
39
+ return [problem(path, `${list.length} items, max ${max}`, `keep at most ${max}`)];
40
+ }
41
+ if (min !== undefined && list.length < min) {
42
+ return [problem(path, `${list.length} items, min ${min}`, `give at least ${min}`)];
43
+ }
44
+ return [];
45
+ };
46
+ const objectErrors = (value, path, fields) => {
47
+ if (!isRecord(value))
48
+ return [problem(path, "must be an object")];
49
+ const unknown = Object.keys(value).filter((key) => !Object.hasOwn(fields, key));
50
+ const extra = unknown.map((key) => problem([...path, key], "unknown field", `use: ${Object.keys(fields).join("|")}`));
51
+ const own = Object.entries(fields).flatMap(([key, type]) => type === "text"
52
+ ? textErrors(value[key], [...path, key])
53
+ : numberErrors(value[key], [...path, key], {}));
54
+ return [...extra, ...own];
55
+ };
56
+ const slotErrors = (def, value, path) => {
57
+ switch (def.type) {
58
+ case "text":
59
+ return textErrors(value, path, def.min, def.max);
60
+ case "number":
61
+ return numberErrors(value, path, def);
62
+ case "enum":
63
+ return def.values.includes(String(value))
64
+ ? []
65
+ : [
66
+ problem(path, `${JSON.stringify(value)} invalid`, `use one of: ${def.values.join("|")}`),
67
+ ];
68
+ case "text[]": {
69
+ if (!Array.isArray(value))
70
+ return [problem(path, "must be a list of text")];
71
+ return [
72
+ ...countErrors(value, path, def.min, def.max),
73
+ ...value.flatMap((item, index) => textErrors(item, [...path, index], undefined, def.itemMax)),
74
+ ];
75
+ }
76
+ case "object": {
77
+ if (def.list !== true)
78
+ return objectErrors(value, path, def.fields);
79
+ if (!Array.isArray(value))
80
+ return [problem(path, "must be a list of objects")];
81
+ return [
82
+ ...countErrors(value, path, def.min, def.max),
83
+ ...value.flatMap((item, index) => objectErrors(item, [...path, index], def.fields)),
84
+ ];
85
+ }
86
+ }
87
+ };
88
+ const defaultOf = (def) => def.type === "text" || def.type === "number" || def.type === "enum" ? def.default : undefined;
89
+ const isRequired = (def) => (def.type === "text" ||
90
+ def.type === "text[]" ||
91
+ def.type === "number" ||
92
+ def.type === "object") &&
93
+ def.required === true;
94
+ const entryErrors = (name, def, value) => {
95
+ if (isSet(value))
96
+ return slotErrors(def, value, [name]);
97
+ return isRequired(def) ? [problem([name], "required", `set slot "${name}"`)] : [];
98
+ };
99
+ /** Checks slot values against the blueprint's slot definitions and fills in defaults. */
100
+ export const resolveSlots = (defs, input) => {
101
+ if (input !== undefined && !isRecord(input)) {
102
+ return { ok: false, errors: [problem([], "slots must be an object")] };
103
+ }
104
+ const given = input ?? {};
105
+ const errors = Object.keys(given)
106
+ .filter((name) => !Object.hasOwn(defs, name))
107
+ .map((name) => problem([name], "unknown slot", `use: ${Object.keys(defs).join("|")}`));
108
+ const values = {};
109
+ for (const [name, def] of Object.entries(defs)) {
110
+ const value = isSet(given[name]) ? given[name] : defaultOf(def);
111
+ errors.push(...entryErrors(name, def, value));
112
+ if (isSet(value))
113
+ values[name] = value;
114
+ }
115
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, value: values };
116
+ };
@@ -0,0 +1,25 @@
1
+ import type { Result, StylePreset } from "@anvpx/motion-schema";
2
+ import type { PlatformLookup } from "./types.ts";
3
+ export declare const DEFAULT_FRAME: {
4
+ readonly width: 1920;
5
+ readonly height: 1080;
6
+ readonly fps: 30;
7
+ };
8
+ export interface Frame {
9
+ readonly width: number;
10
+ readonly height: number;
11
+ readonly fps: number;
12
+ }
13
+ interface StyledScene {
14
+ readonly type: string;
15
+ readonly layer?: string | undefined;
16
+ readonly props: Record<string, unknown>;
17
+ readonly [key: string]: unknown;
18
+ }
19
+ export interface Styled<S> {
20
+ readonly frame: Frame;
21
+ readonly scenes: readonly S[];
22
+ }
23
+ /** Applies frame, direction defaults and prop defaults; values the blueprint sets always win. */
24
+ export declare const applyStyle: <S extends StyledScene>(scenes: readonly S[], style: StylePreset, platforms: PlatformLookup | undefined, platform?: string) => Result<Styled<S>>;
25
+ export {};
@@ -0,0 +1,56 @@
1
+ export const DEFAULT_FRAME = { width: 1920, height: 1080, fps: 30 };
2
+ const platformFrame = (id, platforms, path) => {
3
+ const found = platforms?.(id);
4
+ if (found === undefined) {
5
+ return {
6
+ ok: false,
7
+ errors: [
8
+ {
9
+ path,
10
+ message: `unknown platform "${id}"`,
11
+ suggestion: "use a platform id printed by mk template list",
12
+ },
13
+ ],
14
+ };
15
+ }
16
+ const { width, height, maxFps } = found;
17
+ return { ok: true, value: { width, height, fps: Math.min(DEFAULT_FRAME.fps, maxFps) } };
18
+ };
19
+ /** A chosen `platform` slot wins over the style frame; otherwise the style frame, else 16:9. */
20
+ const frameOf = (style, platforms, platform) => {
21
+ if (platform !== undefined)
22
+ return platformFrame(platform, platforms, "/slots/platform");
23
+ const frame = style.frame;
24
+ if (frame === undefined)
25
+ return { ok: true, value: DEFAULT_FRAME };
26
+ if (!("platform" in frame))
27
+ return { ok: true, value: frame };
28
+ return platformFrame(frame.platform, platforms, "/frame/platform");
29
+ };
30
+ /** Direction defaults for one scene; overlays take none, and the first base scene has no transition in. */
31
+ const directionOf = (style, overlay, first) => {
32
+ const { shot, move, ease, transition } = style.direction ?? {};
33
+ if (overlay)
34
+ return {};
35
+ return { shot, move, ease, ...(first ? {} : { transition }) };
36
+ };
37
+ const withoutUndefined = (value) => Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
38
+ const styleScene = (scene, style, first) => {
39
+ const direction = directionOf(style, scene.layer === "overlay", first);
40
+ const props = { ...style.props?.[scene.type], ...scene.props };
41
+ return withoutUndefined({ ...direction, ...withoutUndefined(scene), props });
42
+ };
43
+ /** Applies frame, direction defaults and prop defaults; values the blueprint sets always win. */
44
+ export const applyStyle = (scenes, style, platforms, platform) => {
45
+ const frame = frameOf(style, platforms, platform);
46
+ if (!frame.ok)
47
+ return frame;
48
+ const firstBase = scenes.findIndex((scene) => scene.layer !== "overlay");
49
+ return {
50
+ ok: true,
51
+ value: {
52
+ frame: frame.value,
53
+ scenes: scenes.map((scene, index) => styleScene(scene, style, index === firstBase)),
54
+ },
55
+ };
56
+ };
@@ -0,0 +1,29 @@
1
+ import type { BlueprintDoc, PacingDoc, StylePreset, TemplateManifest } from "@anvpx/motion-schema";
2
+ import type { PlatformMargins } from "../lint/index.ts";
3
+ export interface ThemeFile {
4
+ readonly id: string;
5
+ /** Path inside the template folder, e.g. `themes/explain-light.json`. */
6
+ readonly file: string;
7
+ readonly mode: "light" | "dark";
8
+ readonly pair?: string;
9
+ /** Raw file text; vendored byte-for-byte into projects. */
10
+ readonly text: string;
11
+ readonly doc: unknown;
12
+ }
13
+ /** A template folder read and parsed; nothing here touches the filesystem again. */
14
+ export interface LoadedTemplate {
15
+ readonly dir: string;
16
+ readonly manifest: TemplateManifest;
17
+ readonly themes: ReadonlyMap<string, ThemeFile>;
18
+ readonly styles: ReadonlyMap<string, StylePreset>;
19
+ readonly blueprints: ReadonlyMap<string, BlueprintDoc>;
20
+ readonly pacing: PacingDoc;
21
+ readonly copy: string;
22
+ }
23
+ /** Frame size of a delivery platform; supplied by the caller (the platform data lives in schema). */
24
+ export interface PlatformFrame extends PlatformMargins {
25
+ readonly width: number;
26
+ readonly height: number;
27
+ readonly maxFps: number;
28
+ }
29
+ export type PlatformLookup = (id: string) => PlatformFrame | undefined;
@@ -0,0 +1 @@
1
+ export {};