@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,25 @@
1
+ import { flowEdgeLabelRule, flowTextSizeRule, flowWordBreakRule } from "./flow.ts";
2
+ import { hookFirstRule } from "./hook-first.ts";
3
+ import { linearEaseRule } from "./linear-ease.ts";
4
+ import { longHoldRule } from "./long-hold.ts";
5
+ import { minHoldRule } from "./min-hold.ts";
6
+ import { flatMotionRule, holdAfterAccentRule, transitionEaseMismatchRule, unsupportedMotionRule } from "./motion-craft.ts";
7
+ import { narrationPaceRule } from "./narration-pace.ts";
8
+ import { overlayFitRule, overlayStackRule, overlayTargetRule } from "./overlays.ts";
9
+ import { readingTimeRule } from "./reading-time.ts";
10
+ import { safeZone9x16Rule } from "./safe-zone-9x16.ts";
11
+ import { textFloorRule } from "./text-floor.ts";
12
+ import { transitionImplementedRule } from "./transition-implemented.ts";
13
+ import { transitionLengthRule } from "./transition-length.ts";
14
+ import { duplicatedEmphasisRule, lineLengthRule, offGridSpacingRule, typeDensityRule } from "./type-grid.ts";
15
+ import type { LintInput, LintResult, LintRule } from "./types.ts";
16
+ import { wordsOnScreenRule } from "./words-on-screen.ts";
17
+ export type { LintFinding, LintInput, LintResult, LintRule, PlatformMarginLookup, PlatformMargins, } from "./types.ts";
18
+ export { duplicatedEmphasisRule, flatMotionRule, flowEdgeLabelRule, flowTextSizeRule, flowWordBreakRule, holdAfterAccentRule, hookFirstRule, linearEaseRule, lineLengthRule, longHoldRule, minHoldRule, narrationPaceRule, offGridSpacingRule, overlayFitRule, overlayStackRule, overlayTargetRule, readingTimeRule, safeZone9x16Rule, textFloorRule, transitionEaseMismatchRule, transitionImplementedRule, transitionLengthRule, typeDensityRule, unsupportedMotionRule, wordsOnScreenRule, };
19
+ /**
20
+ * All rules, in report order. Contrast lives in theme tokens.
21
+ * Craft rules (motion-craft, type-grid) run after the timing and layout
22
+ * gates so their pointers stay stable when earlier rules change.
23
+ */
24
+ export declare const LINT_RULES: readonly LintRule[];
25
+ export declare const lintStoryboard: (input: LintInput) => LintResult;
@@ -0,0 +1,55 @@
1
+ import { flowEdgeLabelRule, flowTextSizeRule, flowWordBreakRule } from "./flow.js";
2
+ import { hookFirstRule } from "./hook-first.js";
3
+ import { linearEaseRule } from "./linear-ease.js";
4
+ import { longHoldRule } from "./long-hold.js";
5
+ import { minHoldRule } from "./min-hold.js";
6
+ import { flatMotionRule, holdAfterAccentRule, transitionEaseMismatchRule, unsupportedMotionRule, } from "./motion-craft.js";
7
+ import { narrationPaceRule } from "./narration-pace.js";
8
+ import { overlayFitRule, overlayStackRule, overlayTargetRule } from "./overlays.js";
9
+ import { readingTimeRule } from "./reading-time.js";
10
+ import { safeZone9x16Rule } from "./safe-zone-9x16.js";
11
+ import { textFloorRule } from "./text-floor.js";
12
+ import { transitionImplementedRule } from "./transition-implemented.js";
13
+ import { transitionLengthRule } from "./transition-length.js";
14
+ import { duplicatedEmphasisRule, lineLengthRule, offGridSpacingRule, typeDensityRule, } from "./type-grid.js";
15
+ import { wordsOnScreenRule } from "./words-on-screen.js";
16
+ export { duplicatedEmphasisRule, flatMotionRule, flowEdgeLabelRule, flowTextSizeRule, flowWordBreakRule, holdAfterAccentRule, hookFirstRule, linearEaseRule, lineLengthRule, longHoldRule, minHoldRule, narrationPaceRule, offGridSpacingRule, overlayFitRule, overlayStackRule, overlayTargetRule, readingTimeRule, safeZone9x16Rule, textFloorRule, transitionEaseMismatchRule, transitionImplementedRule, transitionLengthRule, typeDensityRule, unsupportedMotionRule, wordsOnScreenRule, };
17
+ /**
18
+ * All rules, in report order. Contrast lives in theme tokens.
19
+ * Craft rules (motion-craft, type-grid) run after the timing and layout
20
+ * gates so their pointers stay stable when earlier rules change.
21
+ */
22
+ export const LINT_RULES = [
23
+ minHoldRule,
24
+ readingTimeRule,
25
+ transitionLengthRule,
26
+ transitionImplementedRule,
27
+ overlayTargetRule,
28
+ overlayFitRule,
29
+ overlayStackRule,
30
+ flowWordBreakRule,
31
+ flowEdgeLabelRule,
32
+ flowTextSizeRule,
33
+ textFloorRule,
34
+ wordsOnScreenRule,
35
+ longHoldRule,
36
+ linearEaseRule,
37
+ narrationPaceRule,
38
+ hookFirstRule,
39
+ safeZone9x16Rule,
40
+ flatMotionRule,
41
+ unsupportedMotionRule,
42
+ transitionEaseMismatchRule,
43
+ holdAfterAccentRule,
44
+ typeDensityRule,
45
+ lineLengthRule,
46
+ offGridSpacingRule,
47
+ duplicatedEmphasisRule,
48
+ ];
49
+ export const lintStoryboard = (input) => {
50
+ const findings = LINT_RULES.flatMap((rule) => rule(input));
51
+ return {
52
+ errors: findings.filter((finding) => finding.severity === "error"),
53
+ warnings: findings.filter((finding) => finding.severity === "warning"),
54
+ };
55
+ };
@@ -0,0 +1,18 @@
1
+ import type { ResolvedTheme } from "@anvpx/motion-schema";
2
+ /**
3
+ * Deterministic height estimates of portrait scene content, in px. No browser: the numbers mirror each
4
+ * scene's CSS (padding, gaps, line-heights, size classes) and wrap text with the shared glyph-width
5
+ * estimate (generous, so lines wrap early). Expect roughly +-20 % against a real render; the lint rule
6
+ * built on it only warns.
7
+ */
8
+ export interface Metrics {
9
+ /** Width of the scene content box in px (frame minus side padding). */
10
+ readonly width: number;
11
+ readonly theme: ResolvedTheme;
12
+ }
13
+ type Props = Readonly<Record<string, unknown>>;
14
+ /** Content height in px of a base scene, or undefined for a type with no estimate. */
15
+ export declare const estimateContentHeight: (type: string, props: Props, m: Metrics) => number | undefined;
16
+ /** Height of the bottom-pinned progress overlay (label above a thin track). */
17
+ export declare const progressHeight: (props: Props, m: Metrics) => number;
18
+ export {};
@@ -0,0 +1,165 @@
1
+ import { textWidth } from "@anvpx/motion-scenes";
2
+ const BORDER = 4;
3
+ const RULE = 8;
4
+ const text = (value) => (typeof value === "string" ? value : "");
5
+ const list = (value) => (Array.isArray(value) ? value : []);
6
+ const size = (m, step) => m.theme.fontSizes[step] ?? 0;
7
+ const space = (m, step) => m.theme.spacing[step] ?? 0;
8
+ const largestWithin = (m, steps, maxPx) => steps.find((step) => size(m, step) <= maxPx) ?? steps[steps.length - 1] ?? "md";
9
+ /** Greedy wrap: number of lines of `value` at `px` inside `boxWidth` (a word is never split). */
10
+ const lineCount = (value, px, boxWidth) => {
11
+ const words = value.replace(/[[\]]/g, "").split(/\s+/).filter(Boolean);
12
+ if (words.length === 0)
13
+ return 0;
14
+ let lines = 1;
15
+ let current = "";
16
+ for (const word of words) {
17
+ const next = current === "" ? word : `${current} ${word}`;
18
+ if (current !== "" && textWidth(next, px) > boxWidth) {
19
+ lines += 1;
20
+ current = word;
21
+ }
22
+ else
23
+ current = next;
24
+ }
25
+ return lines;
26
+ };
27
+ const block = (value, px, boxWidth, lineHeight) => lineCount(value, px, boxWidth) * px * lineHeight;
28
+ const heading = (m, title, gap) => title === ""
29
+ ? 0
30
+ : block(title, size(m, "title"), m.width, 1.04) + RULE + space(m, gap) + space(m, "xl");
31
+ const callout = (props, m) => {
32
+ const plain = text(props.text).replace(/[[\]]/g, "");
33
+ const kind = text(props.kind) || "statement";
34
+ const [big, mid] = kind === "formula" ? [16, 40] : [28, 70];
35
+ const step = plain.length <= big ? "display" : plain.length <= mid ? "title" : "xl";
36
+ const inner = m.width - 2 * space(m, "xxl") - BORDER;
37
+ const note = text(props.note);
38
+ const noteH = note === "" ? 0 : space(m, "lg") + block(note, size(m, "md"), inner * 0.9, 1.3);
39
+ const head = Math.max(80, size(m, "md"));
40
+ return (2 * space(m, "xl") +
41
+ BORDER +
42
+ head +
43
+ space(m, "lg") +
44
+ block(plain, size(m, step), inner, 1.12) +
45
+ space(m, "lg") +
46
+ RULE +
47
+ noteH);
48
+ };
49
+ const BIG_MAX_ITEMS = 3;
50
+ const BIG_MAX_CHARS = 84;
51
+ const MANY_ITEMS = 5;
52
+ const densityOf = (items) => {
53
+ if (items.length >= MANY_ITEMS)
54
+ return "many";
55
+ const chars = items.reduce((sum, item) => sum + item.length, 0);
56
+ return items.length <= BIG_MAX_ITEMS && chars <= BIG_MAX_CHARS ? "big" : "regular";
57
+ };
58
+ /** Row metrics per list density, mirroring the bullets CSS. */
59
+ const rowSpec = (m, density) => {
60
+ if (density === "many") {
61
+ const step = largestWithin(m, ["lg", "md"], 52);
62
+ return {
63
+ px: size(m, step),
64
+ mark: 64,
65
+ padV: space(m, "md"),
66
+ padL: space(m, "md"),
67
+ padR: space(m, "lg"),
68
+ lh: 1.15,
69
+ gap: space(m, "md"),
70
+ };
71
+ }
72
+ if (density === "big") {
73
+ const step = largestWithin(m, ["xl", "lg", "md"], 80);
74
+ return {
75
+ px: size(m, step),
76
+ mark: 88,
77
+ padV: space(m, "xl"),
78
+ padL: space(m, "lg"),
79
+ padR: space(m, "xl"),
80
+ lh: 1.15,
81
+ gap: space(m, "xl"),
82
+ };
83
+ }
84
+ return {
85
+ px: size(m, "lg"),
86
+ mark: 88,
87
+ padV: space(m, "lg"),
88
+ padL: space(m, "lg"),
89
+ padR: space(m, "xl"),
90
+ lh: 1.25,
91
+ gap: space(m, "lg"),
92
+ };
93
+ };
94
+ const bullets = (props, m) => {
95
+ const items = list(props.items).map(text);
96
+ const spec = rowSpec(m, densityOf(items));
97
+ const textBox = m.width - spec.mark - space(m, "lg") - spec.padL - spec.padR - BORDER;
98
+ const row = (item) => Math.max(spec.mark, block(item, spec.px, textBox, spec.lh)) + 2 * spec.padV + BORDER;
99
+ const rows = items.reduce((sum, item) => sum + row(item), 0) + spec.gap * Math.max(items.length - 1, 0);
100
+ return heading(m, text(props.title), "lg") + rows;
101
+ };
102
+ const statHeroStep = (value) => value.length <= 4 ? "hero" : value.length <= 7 ? "heroMd" : "heroSm";
103
+ const stat = (props, m) => {
104
+ const value = String(props.value ?? "");
105
+ const hero = size(m, statHeroStep(value)) * 0.9;
106
+ const label = text(props.label);
107
+ const labelH = label === "" ? 0 : space(m, "lg") + block(label, size(m, "lg"), m.width, 1.25);
108
+ const delta = text(props.delta) === "" ? 0 : space(m, "lg") + size(m, "md") * 1.2 + 2 * space(m, "sm");
109
+ const context = text(props.context);
110
+ const contextH = context === ""
111
+ ? 0
112
+ : 2 * space(m, "lg") + 2 + block(context, size(m, "md"), Math.min(m.width, 1000), 1.2);
113
+ return hero + labelH + delta + contextH;
114
+ };
115
+ const iconGrid = (props, m) => {
116
+ const items = list(props.items).map((item) => (item ?? {}));
117
+ const count = items.length;
118
+ const given = typeof props.cols === "number" ? props.cols : undefined;
119
+ const cols = given ?? (count <= 4 ? Math.max(2, count) : count <= 6 ? 3 : 4);
120
+ const gap = space(m, "xl");
121
+ const cardW = (m.width - (cols - 1) * gap) / cols;
122
+ const labelPx = size(m, cols === 4 && count > 4 ? "md" : "lg");
123
+ const inner = cardW - 2 * space(m, "xl") - BORDER;
124
+ const cardH = (item) => {
125
+ const detail = text(item.detail);
126
+ const detailH = detail === "" ? 0 : space(m, "md") + block(detail, size(m, "sm"), inner, 1.3);
127
+ return (2 * space(m, "xl") +
128
+ BORDER +
129
+ 112 +
130
+ space(m, "sm") +
131
+ space(m, "md") +
132
+ block(text(item.label), labelPx, inner, 1.12) +
133
+ detailH);
134
+ };
135
+ const rows = Math.ceil(count / cols);
136
+ let grid = 0;
137
+ for (let row = 0; row < rows; row++) {
138
+ grid += Math.max(...items.slice(row * cols, (row + 1) * cols).map(cardH));
139
+ }
140
+ const head = text(props.title) === "" ? 0 : heading(m, text(props.title), "md") - space(m, "xl");
141
+ return head + (head === 0 ? 0 : space(m, "xl")) + grid + gap * Math.max(rows - 1, 0);
142
+ };
143
+ const outro = (props, m) => {
144
+ const inner = Math.min(m.width, 1400);
145
+ const lines = list(props.lines).map(text);
146
+ const credits = list(props.credits).length;
147
+ const gap = space(m, "lg");
148
+ const main = block(text(props.title), size(m, "title"), inner, 1.08) +
149
+ gap +
150
+ RULE +
151
+ lines.reduce((sum, line) => sum + gap + block(line, size(m, "lg"), inner, 1.3), 0);
152
+ const creditRows = credits === 0 ? 0 : space(m, "lg") + 2 + size(m, "sm") * 1.2;
153
+ return main + creditRows;
154
+ };
155
+ const ESTIMATORS = {
156
+ callout,
157
+ bullets,
158
+ stat,
159
+ "icon-grid": iconGrid,
160
+ outro,
161
+ };
162
+ /** Content height in px of a base scene, or undefined for a type with no estimate. */
163
+ export const estimateContentHeight = (type, props, m) => ESTIMATORS[type]?.(props, m);
164
+ /** Height of the bottom-pinned progress overlay (label above a thin track). */
165
+ export const progressHeight = (props, m) => space(m, "md") + (text(props.label) === "" ? 0 : size(m, "sm") * 1.2 + space(m, "sm"));
@@ -0,0 +1,2 @@
1
+ import type { LintRule } from "./types.ts";
2
+ export declare const linearEaseRule: LintRule;
@@ -0,0 +1,14 @@
1
+ /** Scenes where linear motion is the point (progress, time, draw-on). */
2
+ const EXEMPT_TYPES = new Set(["progress", "countdown", "chart"]);
3
+ export const linearEaseRule = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
4
+ if (scene.ease !== "linear" || EXEMPT_TYPES.has(scene.type))
5
+ return [];
6
+ return [
7
+ {
8
+ severity: "warning",
9
+ path: `/scenes/${index}/ease`,
10
+ message: `scene "${scene.id}" uses linear ease; entrances feel robotic`,
11
+ suggestion: 'use ease "out" or "spring-soft"',
12
+ },
13
+ ];
14
+ });
@@ -0,0 +1,9 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /** A scene at or under this length never reads as frozen. */
3
+ export declare const MAX_STATIC_HOLD_SECONDS = 5;
4
+ /** Still time allowed after the last element has finished entering. */
5
+ export declare const MAX_SETTLED_SECONDS = 4;
6
+ /** Seconds from scene start until its last animated element has settled. */
7
+ export declare const settleSeconds: (type: string, props: Record<string, unknown>, fps: number, words?: number) => number;
8
+ /** Dead air: a long, un-narrated, camera-still scene whose content settled long before it ends. */
9
+ export declare const longHoldRule: LintRule;
@@ -0,0 +1,52 @@
1
+ /** A scene at or under this length never reads as frozen. */
2
+ export const MAX_STATIC_HOLD_SECONDS = 5;
3
+ /** Still time allowed after the last element has finished entering. */
4
+ export const MAX_SETTLED_SECONDS = 4;
5
+ /**
6
+ * Per-type estimate of the reveal span, mirroring the scene runtime (its entrance timeline):
7
+ * the first entrance starts at `baseDelay`, each further element follows after `staggerFrames`,
8
+ * the last one takes `ENTRANCE_SECONDS` to land; `stat` also counts up for `COUNT_SECONDS`.
9
+ * Types not listed reveal as one element. Staged elements are the array props below.
10
+ * Reading counts as change too: the viewer is busy until the words are read at a calm pace.
11
+ */
12
+ const READING_WORDS_PER_SECOND = 2.5;
13
+ const BASE_DELAY = 0.2;
14
+ const ENTRANCE_SECONDS = 0.6;
15
+ const COUNT_SECONDS = 1.4;
16
+ const DEFAULT_STAGGER_FRAMES = 5;
17
+ const STAGGER_FRAMES = { flow: 6 };
18
+ const STAGED_KEYS = ["items", "nodes", "edges", "steps"];
19
+ const stagedCount = (props) => STAGED_KEYS.reduce((sum, key) => {
20
+ const value = props[key];
21
+ return sum + (Array.isArray(value) ? value.length : 0);
22
+ }, 0);
23
+ /** Seconds from scene start until its last animated element has settled. */
24
+ export const settleSeconds = (type, props, fps, words = 0) => {
25
+ const reading = words / READING_WORDS_PER_SECOND;
26
+ if (type === "stat")
27
+ return Math.max(reading, BASE_DELAY + COUNT_SECONDS);
28
+ const stagger = (STAGGER_FRAMES[type] ?? DEFAULT_STAGGER_FRAMES) / fps;
29
+ const gaps = Math.max(stagedCount(props) - 1, 0);
30
+ return Math.max(reading, BASE_DELAY + gaps * stagger + ENTRANCE_SECONDS);
31
+ };
32
+ /** Dead air: a long, un-narrated, camera-still scene whose content settled long before it ends. */
33
+ export const longHoldRule = ({ storyboard, timeline, words }) => timeline.scenes.flatMap((timed) => {
34
+ const scene = storyboard.scenes[timed.index];
35
+ if (scene === undefined)
36
+ return [];
37
+ const moving = scene.move !== undefined && scene.move !== "static";
38
+ const narrated = (scene.narration ?? "").trim() !== "";
39
+ if (moving || narrated || timed.duration <= MAX_STATIC_HOLD_SECONDS)
40
+ return [];
41
+ const settled = settleSeconds(scene.type, scene.props, storyboard.meta.fps, words[timed.index] ?? 0);
42
+ if (timed.duration - settled <= MAX_SETTLED_SECONDS)
43
+ return [];
44
+ return [
45
+ {
46
+ severity: "warning",
47
+ path: `/scenes/${timed.index}/duration`,
48
+ message: `scene "${scene.id}" holds ${timed.duration}s with no change; still for ${(timed.duration - settled).toFixed(1)}s after its content settles (max ${MAX_SETTLED_SECONDS}s)`,
49
+ suggestion: "split the scene, add a move, add narration, or shorten the duration",
50
+ },
51
+ ];
52
+ });
@@ -0,0 +1,4 @@
1
+ import type { LintRule } from "./types.ts";
2
+ export declare const MIN_HOLD_SECONDS = 2;
3
+ /** Every scene stays on screen for at least 2 s (PLAN 5b timing). */
4
+ export declare const minHoldRule: LintRule;
@@ -0,0 +1,10 @@
1
+ export const MIN_HOLD_SECONDS = 2;
2
+ /** Every scene stays on screen for at least 2 s (PLAN 5b timing). */
3
+ export const minHoldRule = ({ timeline }) => timeline.scenes
4
+ .filter((scene) => scene.duration < MIN_HOLD_SECONDS)
5
+ .map((scene) => ({
6
+ severity: "error",
7
+ path: `/scenes/${scene.index}/duration`,
8
+ message: `hold ${scene.duration}s is under ${MIN_HOLD_SECONDS}s`,
9
+ suggestion: `use duration >= ${MIN_HOLD_SECONDS}`,
10
+ }));
@@ -0,0 +1,18 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /**
3
+ * Three static base scenes in a row read as flat. Overlays never advance the
4
+ * timeline so they reset the run instead of extending it.
5
+ */
6
+ export declare const flatMotionRule: LintRule;
7
+ /** A move outside the schema enum has no renderer and must fail closed. */
8
+ export declare const unsupportedMotionRule: LintRule;
9
+ /**
10
+ * A hard cut with a spring ease snaps: the picture jumps while the motion
11
+ * eases. Blended transitions (crossfade, push, wipe, zoom-through) own their
12
+ * ease and stay silent, as does the first scene (no incoming transition).
13
+ */
14
+ export declare const transitionEaseMismatchRule: LintRule;
15
+ /** Accented copy needs room to land: 2 s holds under-read the emphasis. */
16
+ export declare const MIN_ACCENT_HOLD_SECONDS = 3;
17
+ export declare const MIN_HOLD_FLOOR_SECONDS = 2;
18
+ export declare const holdAfterAccentRule: LintRule;
@@ -0,0 +1,112 @@
1
+ import { MOVES } from "@anvpx/motion-schema";
2
+ /** Moves the renderer understands (schema enum); anything else falls back and must error. */
3
+ const ALLOWED_MOVES = new Set(MOVES);
4
+ /** Ease values that imply a soft landing; pairing them with a hard cut reads as a snap. */
5
+ const SPRING_EASES = new Set(["spring-soft", "spring-snappy"]);
6
+ /** `[...]` accent markup; unmatched or empty brackets stay literal (mirrors scenes markup). */
7
+ const ACCENT_RE = /\[([^[\]]+)\]/;
8
+ const isAccentValue = (value) => {
9
+ if (typeof value === "string")
10
+ return ACCENT_RE.test(value);
11
+ if (!Array.isArray(value))
12
+ return false;
13
+ return value.some((item) => typeof item === "string" && ACCENT_RE.test(item));
14
+ };
15
+ const hasAccentText = (props) => Object.entries(props).some(([key, value]) => key !== "caption" && isAccentValue(value));
16
+ const isCopyValue = (value) => {
17
+ if (typeof value === "string")
18
+ return value.trim() !== "";
19
+ if (typeof value === "number")
20
+ return true;
21
+ if (!Array.isArray(value))
22
+ return false;
23
+ return value.some((item) => isCopyValue(item));
24
+ };
25
+ const hasCopyText = (props) => Object.entries(props).some(([key, value]) => key !== "caption" && isCopyValue(value));
26
+ const isStaticMove = (move) => move === undefined || move === "static";
27
+ /**
28
+ * Three static base scenes in a row read as flat. Overlays never advance the
29
+ * timeline so they reset the run instead of extending it.
30
+ */
31
+ export const flatMotionRule = ({ storyboard }) => {
32
+ const findings = [];
33
+ let run = 0;
34
+ storyboard.scenes.forEach((scene, index) => {
35
+ if (scene.layer === "overlay" || !isStaticMove(scene.move)) {
36
+ run = 0;
37
+ return;
38
+ }
39
+ run += 1;
40
+ if (run >= 3) {
41
+ findings.push({
42
+ severity: "warning",
43
+ path: `/scenes/${index}/move`,
44
+ message: `scene "${scene.id}" is the third flat static scene in a row`,
45
+ suggestion: "add a move (push-in, pan-l, orbit) or split the beat",
46
+ });
47
+ }
48
+ });
49
+ return findings;
50
+ };
51
+ /** A move outside the schema enum has no renderer and must fail closed. */
52
+ export const unsupportedMotionRule = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
53
+ if (scene.layer === "overlay")
54
+ return [];
55
+ const move = scene.move;
56
+ if (move === undefined || ALLOWED_MOVES.has(move))
57
+ return [];
58
+ return [
59
+ {
60
+ severity: "error",
61
+ path: `/scenes/${index}/move`,
62
+ message: `move "${move}" is not implemented`,
63
+ suggestion: `use one of: ${MOVES.join("|")}`,
64
+ },
65
+ ];
66
+ });
67
+ /**
68
+ * A hard cut with a spring ease snaps: the picture jumps while the motion
69
+ * eases. Blended transitions (crossfade, push, wipe, zoom-through) own their
70
+ * ease and stay silent, as does the first scene (no incoming transition).
71
+ */
72
+ export const transitionEaseMismatchRule = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
73
+ if (index === 0 || scene.layer === "overlay")
74
+ return [];
75
+ if (scene.transition !== "cut")
76
+ return [];
77
+ const ease = scene.ease;
78
+ if (ease === undefined || !SPRING_EASES.has(ease))
79
+ return [];
80
+ return [
81
+ {
82
+ severity: "warning",
83
+ path: `/scenes/${index}/ease`,
84
+ message: `cut paired with spring ease "${ease}" snaps on the cut`,
85
+ suggestion: 'use ease "out" or "in-out" for cuts',
86
+ },
87
+ ];
88
+ });
89
+ /** Accented copy needs room to land: 2 s holds under-read the emphasis. */
90
+ export const MIN_ACCENT_HOLD_SECONDS = 3;
91
+ export const MIN_HOLD_FLOOR_SECONDS = 2;
92
+ const accentHoldFinding = (id, index, duration, props) => {
93
+ if (!hasCopyText(props) || !hasAccentText(props))
94
+ return [];
95
+ return [
96
+ {
97
+ severity: "warning",
98
+ path: `/scenes/${index}/duration`,
99
+ message: `scene "${id}" has accent markup but holds ${duration}s; accents need >= ${MIN_ACCENT_HOLD_SECONDS}s`,
100
+ suggestion: `use duration >= ${MIN_ACCENT_HOLD_SECONDS} or drop the accent`,
101
+ },
102
+ ];
103
+ };
104
+ export const holdAfterAccentRule = ({ storyboard, timeline }) => timeline.scenes.flatMap((timed) => {
105
+ const scene = storyboard.scenes[timed.index];
106
+ if (scene === undefined || scene.layer === "overlay")
107
+ return [];
108
+ // min-hold owns durations under 2 s; longer holds already breathe.
109
+ if (timed.duration < MIN_HOLD_FLOOR_SECONDS || timed.duration >= MIN_ACCENT_HOLD_SECONDS)
110
+ return [];
111
+ return accentHoldFinding(scene.id, timed.index, timed.duration, scene.props);
112
+ });
@@ -0,0 +1,6 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /** Words per second bounds (target ~2.2, 130 wpm). */
3
+ export declare const MAX_NARRATION_WPS = 3;
4
+ export declare const MIN_NARRATION_WPS = 1.2;
5
+ /** Only scenes with narration are checked. */
6
+ export declare const narrationPaceRule: LintRule;
@@ -0,0 +1,35 @@
1
+ /** Words per second bounds (target ~2.2, 130 wpm). */
2
+ export const MAX_NARRATION_WPS = 3;
3
+ export const MIN_NARRATION_WPS = 1.2;
4
+ const wordCount = (text) => text.split(/\s+/).filter(Boolean).length;
5
+ /** Only scenes with narration are checked. */
6
+ export const narrationPaceRule = ({ storyboard, timeline }) => timeline.scenes.flatMap((timed) => {
7
+ const narration = storyboard.scenes[timed.index]?.narration;
8
+ const count = narration === undefined ? 0 : wordCount(narration);
9
+ if (count === 0 || timed.duration <= 0)
10
+ return [];
11
+ const rate = count / timed.duration;
12
+ const path = `/scenes/${timed.index}/narration`;
13
+ const said = `narration ${count} words in ${timed.duration}s is ${rate.toFixed(1)} words/s`;
14
+ if (rate > MAX_NARRATION_WPS) {
15
+ return [
16
+ {
17
+ severity: "warning",
18
+ path,
19
+ message: `${said}; max ${MAX_NARRATION_WPS}`,
20
+ suggestion: `set duration >= ${Math.ceil(count / MAX_NARRATION_WPS)} or cut narration`,
21
+ },
22
+ ];
23
+ }
24
+ if (rate < MIN_NARRATION_WPS) {
25
+ return [
26
+ {
27
+ severity: "warning",
28
+ path,
29
+ message: `${said}; min ${MIN_NARRATION_WPS}`,
30
+ suggestion: `set duration <= ${Math.floor(count / MIN_NARRATION_WPS)} or add narration`,
31
+ },
32
+ ];
33
+ }
34
+ return [];
35
+ });
@@ -0,0 +1,9 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /** More simultaneous overlays than this cover too much of the picture. */
3
+ export declare const MAX_SIMULTANEOUS_OVERLAYS = 2;
4
+ /** `over` must name an existing base scene (not an overlay, not itself). */
5
+ export declare const overlayTargetRule: LintRule;
6
+ /** An overlay must end no later than the base scene it covers. */
7
+ export declare const overlayFitRule: LintRule;
8
+ /** Warns when 3+ overlays are visible at once. */
9
+ export declare const overlayStackRule: LintRule;
@@ -0,0 +1,64 @@
1
+ /** Float slack when comparing absolute seconds. */
2
+ const EPSILON = 1e-6;
3
+ /** More simultaneous overlays than this cover too much of the picture. */
4
+ export const MAX_SIMULTANEOUS_OVERLAYS = 2;
5
+ const baseIds = (storyboardScenes) => new Set(storyboardScenes.filter((scene) => scene.layer !== "overlay").map((scene) => scene.id));
6
+ /** `over` must name an existing base scene (not an overlay, not itself). */
7
+ export const overlayTargetRule = ({ storyboard, timeline }) => {
8
+ const bases = baseIds(storyboard.scenes);
9
+ const ids = new Set(storyboard.scenes.map((scene) => scene.id));
10
+ return timeline.overlays.flatMap((overlay) => {
11
+ if (bases.has(overlay.over))
12
+ return [];
13
+ const reason = ids.has(overlay.over)
14
+ ? `"${overlay.over}" is an overlay, not a base scene`
15
+ : `"${overlay.over}" is not a scene`;
16
+ return [
17
+ {
18
+ severity: "error",
19
+ path: `/scenes/${overlay.index}/over`,
20
+ message: `over ${reason}`,
21
+ suggestion: `use one of: ${[...bases].join("|")}`,
22
+ },
23
+ ];
24
+ });
25
+ };
26
+ /** An overlay must end no later than the base scene it covers. */
27
+ export const overlayFitRule = ({ timeline }) => timeline.overlays.flatMap((overlay) => {
28
+ const base = timeline.scenes.find((scene) => scene.id === overlay.over);
29
+ if (!base)
30
+ return [];
31
+ const baseEnd = Math.round((base.start + base.duration) * 1000) / 1000;
32
+ if (overlay.end <= baseEnd + EPSILON)
33
+ return [];
34
+ const room = Math.round((baseEnd - overlay.start) * 1000) / 1000;
35
+ return [
36
+ {
37
+ severity: "error",
38
+ path: `/scenes/${overlay.index}/duration`,
39
+ message: `ends at ${overlay.end}s, after "${base.id}" ends at ${baseEnd}s`,
40
+ suggestion: room > 0
41
+ ? `use duration <= ${room} or a smaller offset`
42
+ : `use an offset < ${Math.round((baseEnd - base.start) * 1000) / 1000}`,
43
+ },
44
+ ];
45
+ });
46
+ const crowded = (overlays) => {
47
+ const byStart = [...overlays].sort((a, b) => a.start - b.start || a.index - b.index);
48
+ const flagged = [];
49
+ let active = [];
50
+ for (const overlay of byStart) {
51
+ active = active.filter((other) => other.end > overlay.start + EPSILON);
52
+ active.push(overlay);
53
+ if (active.length > MAX_SIMULTANEOUS_OVERLAYS)
54
+ flagged.push(overlay);
55
+ }
56
+ return flagged.sort((a, b) => a.index - b.index);
57
+ };
58
+ /** Warns when 3+ overlays are visible at once. */
59
+ export const overlayStackRule = ({ timeline }) => crowded(timeline.overlays).map((overlay) => ({
60
+ severity: "warning",
61
+ path: `/scenes/${overlay.index}/offset`,
62
+ message: `more than ${MAX_SIMULTANEOUS_OVERLAYS} overlays visible at ${overlay.start}s`,
63
+ suggestion: "stagger the overlays or drop one",
64
+ }));
@@ -0,0 +1,5 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /** 180 words per minute maximum. */
3
+ export declare const WORDS_PER_SECOND = 3;
4
+ /** At least one reading-second per 3 on-screen words. */
5
+ export declare const readingTimeRule: LintRule;
@@ -0,0 +1,17 @@
1
+ /** 180 words per minute maximum. */
2
+ export const WORDS_PER_SECOND = 3;
3
+ /** At least one reading-second per 3 on-screen words. */
4
+ export const readingTimeRule = ({ timeline, words }) => timeline.scenes.flatMap((scene) => {
5
+ const count = words[scene.index] ?? 0;
6
+ const needed = Math.ceil(count / WORDS_PER_SECOND);
7
+ if (scene.duration >= needed)
8
+ return [];
9
+ return [
10
+ {
11
+ severity: "error",
12
+ path: `/scenes/${scene.index}/duration`,
13
+ message: `${count} words need >= ${needed}s to read (max 180 wpm); duration is ${scene.duration}s`,
14
+ suggestion: `set duration >= ${needed} or cut text`,
15
+ },
16
+ ];
17
+ });
@@ -0,0 +1,11 @@
1
+ import type { LintRule } from "./types.ts";
2
+ /**
3
+ * Platform-tagged 9:16 frames: warns when the estimated content block of a base scene (or the progress
4
+ * overlay) reaches into the platform's `safe{top,bottom,side}` margins.
5
+ *
6
+ * Estimate, not a measurement: heights come from `layout-estimate.ts` (scene CSS + a generous glyph-width
7
+ * model, roughly +-20 %), content is assumed vertically centred in the padded frame as the scenes lay it
8
+ * out, and the `bug` overlay and scene types without an estimator are not checked. Always a warning: the
9
+ * numbers in the platform data are unverified approximations.
10
+ */
11
+ export declare const safeZone9x16Rule: LintRule;