@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.
- package/LICENSE +21 -0
- package/README.md +67 -2
- package/dist/.out/demo/boot.js +90 -0
- package/dist/.out/demo/runtime/easing.js +45 -0
- package/dist/.out/demo/runtime/mount.js +153 -0
- package/dist/.out/demo/runtime/timeline.js +199 -0
- package/dist/.out/demo/scenes/count/scene.js +6 -0
- package/dist/.out/demo/scenes/intro/scene.js +6 -0
- package/dist/.out/demo/scenes/outro/scene.js +6 -0
- package/dist/.out/demo/scenes/path/scene.js +6 -0
- package/dist/.out/demo/scenes/signals/scene.js +6 -0
- package/dist/.out/demo/shared/format.js +59 -0
- package/dist/.out/demo/shared/motion.js +117 -0
- package/dist/.out/demo-overlays/boot.js +95 -0
- package/dist/.out/demo-overlays/runtime/easing.js +45 -0
- package/dist/.out/demo-overlays/runtime/mount.js +153 -0
- package/dist/.out/demo-overlays/runtime/timeline.js +199 -0
- package/dist/.out/demo-overlays/scenes/chapter-signals/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/count/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/flow-progress/scene.js +17 -0
- package/dist/.out/demo-overlays/scenes/intro/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/mark/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/outro/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/path/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/presenter/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/signals/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/tick/scene.js +21 -0
- package/dist/.out/demo-overlays/shared/format.js +59 -0
- package/dist/.out/demo-overlays/shared/motion.js +117 -0
- package/dist/assets.d.ts +36 -0
- package/dist/assets.js +95 -0
- package/dist/audio/approval.d.ts +45 -0
- package/dist/audio/approval.js +82 -0
- package/dist/audio/mix.d.ts +231 -0
- package/dist/audio/mix.js +491 -0
- package/dist/audio/music.d.ts +82 -0
- package/dist/audio/music.js +134 -0
- package/dist/audio/preview.d.ts +31 -0
- package/dist/audio/preview.js +31 -0
- package/dist/build.d.ts +65 -0
- package/dist/build.js +260 -0
- package/dist/cache.d.ts +59 -0
- package/dist/cache.js +249 -0
- package/dist/emit/boot.d.ts +16 -0
- package/dist/emit/boot.js +32 -0
- package/dist/emit/boot.template.js +85 -0
- package/dist/emit/fragment-guard.d.ts +5 -0
- package/dist/emit/fragment-guard.js +15 -0
- package/dist/emit/index-html.d.ts +26 -0
- package/dist/emit/index-html.js +63 -0
- package/dist/emit/safe-json.d.ts +2 -0
- package/dist/emit/safe-json.js +7 -0
- package/dist/emit/vendor.d.ts +7 -0
- package/dist/emit/vendor.js +57 -0
- package/dist/fonts.d.ts +39 -0
- package/dist/fonts.js +112 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/integration/render.integration.d.ts +1 -0
- package/dist/integration/render.integration.js +162 -0
- package/dist/lint/custom-source.d.ts +12 -0
- package/dist/lint/custom-source.js +130 -0
- package/dist/lint/fixtures.d.ts +24 -0
- package/dist/lint/fixtures.js +42 -0
- package/dist/lint/flow.d.ts +12 -0
- package/dist/lint/flow.js +38 -0
- package/dist/lint/frame.d.ts +3 -0
- package/dist/lint/frame.js +2 -0
- package/dist/lint/hook-first.d.ts +6 -0
- package/dist/lint/hook-first.js +27 -0
- package/dist/lint/index.d.ts +25 -0
- package/dist/lint/index.js +55 -0
- package/dist/lint/layout-estimate.d.ts +18 -0
- package/dist/lint/layout-estimate.js +165 -0
- package/dist/lint/linear-ease.d.ts +2 -0
- package/dist/lint/linear-ease.js +14 -0
- package/dist/lint/long-hold.d.ts +9 -0
- package/dist/lint/long-hold.js +52 -0
- package/dist/lint/min-hold.d.ts +4 -0
- package/dist/lint/min-hold.js +10 -0
- package/dist/lint/motion-craft.d.ts +18 -0
- package/dist/lint/motion-craft.js +112 -0
- package/dist/lint/narration-pace.d.ts +6 -0
- package/dist/lint/narration-pace.js +35 -0
- package/dist/lint/overlays.d.ts +9 -0
- package/dist/lint/overlays.js +64 -0
- package/dist/lint/reading-time.d.ts +5 -0
- package/dist/lint/reading-time.js +17 -0
- package/dist/lint/safe-zone-9x16.d.ts +11 -0
- package/dist/lint/safe-zone-9x16.js +101 -0
- package/dist/lint/text-floor.d.ts +8 -0
- package/dist/lint/text-floor.js +34 -0
- package/dist/lint/transition-implemented.d.ts +3 -0
- package/dist/lint/transition-implemented.js +15 -0
- package/dist/lint/transition-length.d.ts +4 -0
- package/dist/lint/transition-length.js +10 -0
- package/dist/lint/type-grid.d.ts +30 -0
- package/dist/lint/type-grid.js +147 -0
- package/dist/lint/types.d.ts +41 -0
- package/dist/lint/types.js +1 -0
- package/dist/lint/words-on-screen.d.ts +5 -0
- package/dist/lint/words-on-screen.js +24 -0
- package/dist/manifest.d.ts +53 -0
- package/dist/manifest.js +55 -0
- package/dist/render-manifest.d.ts +21 -0
- package/dist/render-manifest.js +7 -0
- package/dist/review/review.d.ts +15 -0
- package/dist/review/review.js +56 -0
- package/dist/templates/discover-check.d.ts +11 -0
- package/dist/templates/discover-check.js +36 -0
- package/dist/templates/discover.d.ts +37 -0
- package/dist/templates/discover.js +63 -0
- package/dist/templates/expand.d.ts +17 -0
- package/dist/templates/expand.js +184 -0
- package/dist/templates/fixtures.d.ts +4 -0
- package/dist/templates/fixtures.js +63 -0
- package/dist/templates/index.d.ts +8 -0
- package/dist/templates/index.js +6 -0
- package/dist/templates/instantiate.d.ts +43 -0
- package/dist/templates/instantiate.js +160 -0
- package/dist/templates/load.d.ts +16 -0
- package/dist/templates/load.js +122 -0
- package/dist/templates/pacing.d.ts +21 -0
- package/dist/templates/pacing.js +61 -0
- package/dist/templates/search.d.ts +64 -0
- package/dist/templates/search.js +129 -0
- package/dist/templates/semver.d.ts +2 -0
- package/dist/templates/semver.js +12 -0
- package/dist/templates/slots.d.ts +8 -0
- package/dist/templates/slots.js +116 -0
- package/dist/templates/style.d.ts +25 -0
- package/dist/templates/style.js +56 -0
- package/dist/templates/types.d.ts +29 -0
- package/dist/templates/types.js +1 -0
- package/dist/templates/validate.d.ts +17 -0
- package/dist/templates/validate.js +203 -0
- package/dist/timing.d.ts +38 -0
- package/dist/timing.js +88 -0
- package/dist/validate-full.d.ts +3 -0
- package/dist/validate-full.js +132 -0
- package/dist/verify.d.ts +32 -0
- package/dist/verify.js +180 -0
- package/dist/versions.d.ts +26 -0
- package/dist/versions.js +74 -0
- package/package.json +40 -3
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ValidationError } from "@anvpx/motion-schema";
|
|
2
|
+
import type { LintFinding } from "../lint/index.ts";
|
|
3
|
+
import type { LoadedTemplate, PlatformLookup } from "./types.ts";
|
|
4
|
+
export declare const WCAG_AA_TEXT = 4.5;
|
|
5
|
+
/** Rough token estimate: 4 characters per token. */
|
|
6
|
+
export declare const COPY_TOKEN_LIMIT = 300;
|
|
7
|
+
export interface ValidateOptions {
|
|
8
|
+
readonly platforms?: PlatformLookup;
|
|
9
|
+
/** Built-in templates must carry a fully populated `discover`; elsewhere it only warns. */
|
|
10
|
+
readonly builtIn?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface TemplateReport {
|
|
13
|
+
readonly errors: readonly ValidationError[];
|
|
14
|
+
readonly warnings: readonly LintFinding[];
|
|
15
|
+
}
|
|
16
|
+
/** The author/CI gate: contracts, themes (AA contrast), styles, blueprints and their examples. */
|
|
17
|
+
export declare const validateTemplate: (template: LoadedTemplate, options?: ValidateOptions) => TemplateReport;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sceneTypes } from "@anvpx/motion-scenes";
|
|
4
|
+
import { SLOT_TRANSFORMS, STORYBOARD_VERSION, } from "@anvpx/motion-schema";
|
|
5
|
+
import { chartContrastIssues, chartCvdIssues, contrastRatio, isStrictChart, resolveTheme, spacingIssues, THEME_NAMES, } from "@anvpx/motion-themes";
|
|
6
|
+
import { discoverFindings } from "./discover-check.js";
|
|
7
|
+
import { slotReferences } from "./expand.js";
|
|
8
|
+
import { instantiate } from "./instantiate.js";
|
|
9
|
+
export const WCAG_AA_TEXT = 4.5;
|
|
10
|
+
/** Rough token estimate: 4 characters per token. */
|
|
11
|
+
export const COPY_TOKEN_LIMIT = 300;
|
|
12
|
+
const err = (path, message, suggestion) => suggestion === undefined ? { path, message } : { path, message, suggestion };
|
|
13
|
+
const reference = resolveTheme("default");
|
|
14
|
+
const sameKeys = (group, actual, expected) => {
|
|
15
|
+
const missing = Object.keys(expected).filter((key) => !Object.hasOwn(actual, key));
|
|
16
|
+
return missing.length === 0 ? [] : [`${group} missing: ${missing.join(", ")}`];
|
|
17
|
+
};
|
|
18
|
+
const themeErrors = (theme) => {
|
|
19
|
+
if (THEME_NAMES.includes(theme.id)) {
|
|
20
|
+
return [
|
|
21
|
+
err(theme.file, `theme id "${theme.id}" collides with a built-in theme`, "rename it <template>-<name>"),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
let resolved;
|
|
25
|
+
try {
|
|
26
|
+
resolved = resolveTheme(theme.id, { [theme.id]: theme.doc });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return [err(theme.file, error.message)];
|
|
30
|
+
}
|
|
31
|
+
const incomplete = [
|
|
32
|
+
...sameKeys("colors", resolved.colors, reference.colors),
|
|
33
|
+
...sameKeys("fonts", resolved.fonts, reference.fonts),
|
|
34
|
+
...sameKeys("fontSizes", resolved.fontSizes, reference.fontSizes),
|
|
35
|
+
...sameKeys("spacing", resolved.spacing, reference.spacing),
|
|
36
|
+
...sameKeys("radii", resolved.radii, reference.radii),
|
|
37
|
+
...sameKeys("opacities", resolved.opacities, reference.opacities),
|
|
38
|
+
...sameKeys("durations", resolved.durations, reference.durations),
|
|
39
|
+
...(resolved.chart.length < reference.chart.length ? ["chart needs 8 colours"] : []),
|
|
40
|
+
].map((message) => err(theme.file, message));
|
|
41
|
+
const contrast = resolved.textPairs.flatMap(({ fg, bg }) => {
|
|
42
|
+
const foreground = resolved.colors[fg];
|
|
43
|
+
const background = resolved.colors[bg];
|
|
44
|
+
if (foreground === undefined || background === undefined) {
|
|
45
|
+
return [err(theme.file, `text pair ${fg}/${bg} names an unknown colour`)];
|
|
46
|
+
}
|
|
47
|
+
const ratio = contrastRatio(foreground, background);
|
|
48
|
+
return ratio >= WCAG_AA_TEXT
|
|
49
|
+
? []
|
|
50
|
+
: [err(theme.file, `${fg} on ${bg} is ${ratio.toFixed(2)}:1, AA needs ${WCAG_AA_TEXT}:1`)];
|
|
51
|
+
});
|
|
52
|
+
const strict = isStrictChart(theme.doc);
|
|
53
|
+
const chart = strict ? [...chartContrastIssues(resolved), ...chartCvdIssues(resolved)] : [];
|
|
54
|
+
return [
|
|
55
|
+
...incomplete,
|
|
56
|
+
...contrast,
|
|
57
|
+
...chart.map((issue) => err(theme.file, issue.message, "raise the chart foreground/background contrast")),
|
|
58
|
+
];
|
|
59
|
+
};
|
|
60
|
+
/** Theme findings that never block: spacing off the 4 px grid; chart marks under 3:1 unless the theme is strict. */
|
|
61
|
+
const themeWarnings = (theme) => {
|
|
62
|
+
let resolved;
|
|
63
|
+
try {
|
|
64
|
+
resolved = resolveTheme(theme.id, { [theme.id]: theme.doc });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const issues = [
|
|
70
|
+
...spacingIssues(resolved),
|
|
71
|
+
...(isStrictChart(theme.doc) ? [] : chartContrastIssues(resolved)),
|
|
72
|
+
];
|
|
73
|
+
return issues.map((issue) => ({
|
|
74
|
+
severity: "warning",
|
|
75
|
+
path: theme.file,
|
|
76
|
+
message: issue.message,
|
|
77
|
+
}));
|
|
78
|
+
};
|
|
79
|
+
const manifestErrors = (template) => {
|
|
80
|
+
const { requires } = template.manifest;
|
|
81
|
+
const known = new Set(sceneTypes);
|
|
82
|
+
const missing = requires.scenes.filter((type) => !known.has(type));
|
|
83
|
+
return [
|
|
84
|
+
...(requires.storyboard === STORYBOARD_VERSION
|
|
85
|
+
? []
|
|
86
|
+
: [err("template.json#/requires/storyboard", `needs storyboard ${requires.storyboard}`)]),
|
|
87
|
+
...(missing.length === 0
|
|
88
|
+
? []
|
|
89
|
+
: [
|
|
90
|
+
err("template.json#/requires/scenes", `scene type(s) not available: ${missing.join(", ")}`, "update motion-kit or remove them from requires"),
|
|
91
|
+
]),
|
|
92
|
+
];
|
|
93
|
+
};
|
|
94
|
+
const styleErrors = (template, platforms) => [...template.styles.values()].flatMap((style) => {
|
|
95
|
+
const file = `styles/${style.id}.json`;
|
|
96
|
+
const theme = template.themes.has(style.theme)
|
|
97
|
+
? []
|
|
98
|
+
: [
|
|
99
|
+
err(`${file}#/theme`, `unknown theme "${style.theme}"`, `use one of: ${[...template.themes.keys()].join("|")}`),
|
|
100
|
+
];
|
|
101
|
+
const frame = style.frame;
|
|
102
|
+
const platform = frame !== undefined && "platform" in frame && platforms?.(frame.platform) === undefined
|
|
103
|
+
? [err(`${file}#/frame/platform`, `unknown platform "${frame.platform}"`)]
|
|
104
|
+
: [];
|
|
105
|
+
const types = Object.keys(style.props ?? {})
|
|
106
|
+
.filter((type) => !sceneTypes.includes(type))
|
|
107
|
+
.map((type) => err(`${file}#/props/${type}`, `unknown scene type "${type}"`));
|
|
108
|
+
return [...theme, ...platform, ...types];
|
|
109
|
+
});
|
|
110
|
+
const blueprintErrors = (blueprint, template) => {
|
|
111
|
+
const file = `blueprints/${blueprint.id}.json`;
|
|
112
|
+
const refs = slotReferences(blueprint).flatMap((ref) => {
|
|
113
|
+
const def = blueprint.slots[ref.name];
|
|
114
|
+
if (def === undefined)
|
|
115
|
+
return [err(`${file}#${ref.path}`, `unknown slot "${ref.name}"`)];
|
|
116
|
+
const isList = def.type === "text[]" || (def.type === "object" && def.list === true);
|
|
117
|
+
return ref.list && !isList
|
|
118
|
+
? [err(`${file}#${ref.path}`, `$repeat needs a list slot; "${ref.name}" is ${def.type}`)]
|
|
119
|
+
: [];
|
|
120
|
+
});
|
|
121
|
+
const transforms = JSON.stringify(blueprint).matchAll(/"as":"([^"]*)"/g);
|
|
122
|
+
const unknownAs = [...transforms]
|
|
123
|
+
.filter((match) => !SLOT_TRANSFORMS.includes(match[1] ?? ""))
|
|
124
|
+
.map((match) => err(file, `unknown transform "${match[1]}"`, `use one of: ${SLOT_TRANSFORMS.join("|")}`));
|
|
125
|
+
const styleSlot = blueprint.slots.style;
|
|
126
|
+
const badStyles = styleSlot?.type === "enum"
|
|
127
|
+
? styleSlot.values
|
|
128
|
+
.filter((id) => !template.styles.has(id))
|
|
129
|
+
.map((id) => err(`${file}#/slots/style`, `style "${id}" is not in the template`))
|
|
130
|
+
: [];
|
|
131
|
+
const unpaced = blueprint.scenes
|
|
132
|
+
.map((entry) => ("$repeat" in entry ? entry.scene : entry))
|
|
133
|
+
.filter((scene) => scene.duration === undefined && scene.layer !== "overlay")
|
|
134
|
+
.filter((scene) => template.pacing.sceneSeconds[scene.type] === undefined)
|
|
135
|
+
.map((scene) => err(`${file}#/scenes`, `pacing.json has no seconds for "${scene.type}"`));
|
|
136
|
+
return [...refs, ...unknownAs, ...badStyles, ...unpaced];
|
|
137
|
+
};
|
|
138
|
+
const exampleRun = (template, blueprint, variant, platforms) => {
|
|
139
|
+
const { style, platform } = variant;
|
|
140
|
+
const result = instantiate(template, blueprint.id, blueprint.example, {
|
|
141
|
+
style,
|
|
142
|
+
...(platform === undefined ? {} : { platform }),
|
|
143
|
+
...(platforms === undefined ? {} : { platforms }),
|
|
144
|
+
});
|
|
145
|
+
const tag = platform === undefined ? "" : `, ${platform}`;
|
|
146
|
+
const where = `blueprints/${blueprint.id}.json#example (style ${style}${tag})`;
|
|
147
|
+
if (!result.ok) {
|
|
148
|
+
return {
|
|
149
|
+
errors: result.errors.map((e) => ({ ...e, path: `${where}${e.path}` })),
|
|
150
|
+
warnings: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
errors: [],
|
|
155
|
+
warnings: result.value.warnings.map((w) => ({ ...w, path: `${where}${w.path}` })),
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
/** The example must instantiate for every style, and for every platform of a `platform` slot. */
|
|
159
|
+
const exampleErrors = (template, blueprint, platforms) => {
|
|
160
|
+
const slot = blueprint.slots.platform;
|
|
161
|
+
const platformIds = slot?.type === "enum" ? slot.values : [undefined];
|
|
162
|
+
const runs = [...template.styles.keys()].flatMap((style) => platformIds.map((platform) => exampleRun(template, blueprint, platform === undefined ? { style } : { style, platform }, platforms)));
|
|
163
|
+
return {
|
|
164
|
+
errors: runs.flatMap((run) => run.errors),
|
|
165
|
+
warnings: runs.flatMap((run) => run.warnings),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
const COPY_CHARS_PER_TOKEN = 4;
|
|
169
|
+
const copyErrors = (template) => {
|
|
170
|
+
const tokens = Math.ceil(template.copy.length / COPY_CHARS_PER_TOKEN);
|
|
171
|
+
return tokens <= COPY_TOKEN_LIMIT
|
|
172
|
+
? []
|
|
173
|
+
: [
|
|
174
|
+
err(template.manifest.copy, `~${tokens} tokens, max ${COPY_TOKEN_LIMIT}`, "cut the copy guide"),
|
|
175
|
+
];
|
|
176
|
+
};
|
|
177
|
+
const licenceErrors = (template) => existsSync(join(template.dir, "assets")) && !existsSync(join(template.dir, "licences.json"))
|
|
178
|
+
? [
|
|
179
|
+
err("licences.json", "template ships assets but has no licences.json", "add a ledger row per asset"),
|
|
180
|
+
]
|
|
181
|
+
: [];
|
|
182
|
+
/** The author/CI gate: contracts, themes (AA contrast), styles, blueprints and their examples. */
|
|
183
|
+
export const validateTemplate = (template, options = {}) => {
|
|
184
|
+
const { platforms } = options;
|
|
185
|
+
const discover = discoverFindings(template, options.builtIn === true);
|
|
186
|
+
const examples = [...template.blueprints.values()].map((blueprint) => exampleErrors(template, blueprint, platforms));
|
|
187
|
+
const errors = [
|
|
188
|
+
...manifestErrors(template),
|
|
189
|
+
...[...template.themes.values()].flatMap(themeErrors),
|
|
190
|
+
...styleErrors(template, platforms),
|
|
191
|
+
...[...template.blueprints.values()].flatMap((blueprint) => blueprintErrors(blueprint, template)),
|
|
192
|
+
...examples.flatMap((example) => example.errors),
|
|
193
|
+
...copyErrors(template),
|
|
194
|
+
...licenceErrors(template),
|
|
195
|
+
...discover.errors,
|
|
196
|
+
];
|
|
197
|
+
const warnings = [
|
|
198
|
+
...examples.flatMap((example) => example.warnings),
|
|
199
|
+
...[...template.themes.values()].flatMap(themeWarnings),
|
|
200
|
+
...discover.warnings,
|
|
201
|
+
];
|
|
202
|
+
return { errors, warnings };
|
|
203
|
+
};
|
package/dist/timing.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Result, SceneEntry, Storyboard, TimingManifest } from "@anvpx/motion-schema";
|
|
2
|
+
export declare const MIN_AUTO_SECONDS = 3;
|
|
3
|
+
export declare const TRANSITION_SECONDS = 0.5;
|
|
4
|
+
export declare const IMPLEMENTED_TRANSITIONS: readonly string[];
|
|
5
|
+
export type ResolvedTransition = "cut" | "crossfade" | "push" | "wipe" | "zoom-through";
|
|
6
|
+
export interface TimedScene {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
/** Position in the storyboard's `scenes` array (overlays occupy slots too). */
|
|
9
|
+
readonly index: number;
|
|
10
|
+
readonly start: number;
|
|
11
|
+
readonly duration: number;
|
|
12
|
+
/** Transition into this scene (the first scene has none). */
|
|
13
|
+
readonly transition: ResolvedTransition;
|
|
14
|
+
/** Seconds this scene overlaps the previous one. */
|
|
15
|
+
readonly overlap: number;
|
|
16
|
+
}
|
|
17
|
+
/** An overlay placed on the final timeline; it never advances the base timeline. */
|
|
18
|
+
export interface TimedOverlay {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly index: number;
|
|
21
|
+
/** Id of the base scene it covers (may be unknown; lint reports that). */
|
|
22
|
+
readonly over: string;
|
|
23
|
+
/** Absolute start, seconds. */
|
|
24
|
+
readonly start: number;
|
|
25
|
+
readonly duration: number;
|
|
26
|
+
/** Absolute end, seconds. */
|
|
27
|
+
readonly end: number;
|
|
28
|
+
}
|
|
29
|
+
export interface Timeline {
|
|
30
|
+
/** Base scenes in order; the TimingManifest overlap rule applies to these only. */
|
|
31
|
+
readonly scenes: readonly TimedScene[];
|
|
32
|
+
readonly overlays: readonly TimedOverlay[];
|
|
33
|
+
readonly total: number;
|
|
34
|
+
}
|
|
35
|
+
export declare const autoDuration: (words: number) => number;
|
|
36
|
+
export declare const isOverlay: (entry: SceneEntry) => boolean;
|
|
37
|
+
/** Authored durations, optionally overridden per base scene id by a voice TimingManifest. */
|
|
38
|
+
export declare const resolveTimeline: (storyboard: Storyboard, manifest?: TimingManifest) => Result<Timeline>;
|
package/dist/timing.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { sceneWords } from "@anvpx/motion-scenes";
|
|
2
|
+
export const MIN_AUTO_SECONDS = 3;
|
|
3
|
+
export const TRANSITION_SECONDS = 0.5;
|
|
4
|
+
export const IMPLEMENTED_TRANSITIONS = [
|
|
5
|
+
"cut",
|
|
6
|
+
"crossfade",
|
|
7
|
+
"push",
|
|
8
|
+
"wipe",
|
|
9
|
+
"zoom-through",
|
|
10
|
+
];
|
|
11
|
+
const round3 = (value) => Math.round(value * 1000) / 1000;
|
|
12
|
+
export const autoDuration = (words) => Math.max(MIN_AUTO_SECONDS, Math.ceil(words / 3 + 1.5));
|
|
13
|
+
export const isOverlay = (entry) => entry.layer === "overlay";
|
|
14
|
+
const durationOf = (entry, voice) => round3(voice.get(entry.id) ?? entry.duration ?? autoDuration(sceneWords(entry.props, entry.type)));
|
|
15
|
+
const overlapOf = (entry, prev, own) => entry.transition === "crossfade" ||
|
|
16
|
+
entry.transition === "push" ||
|
|
17
|
+
entry.transition === "wipe" ||
|
|
18
|
+
entry.transition === "zoom-through"
|
|
19
|
+
? Math.min(TRANSITION_SECONDS, prev / 2, own / 2)
|
|
20
|
+
: 0;
|
|
21
|
+
const manifestErrors = (storyboard, manifest) => {
|
|
22
|
+
const kinds = new Map(storyboard.scenes.map((scene) => [scene.id, isOverlay(scene)]));
|
|
23
|
+
return manifest.scenes.flatMap((row, index) => {
|
|
24
|
+
const path = `/timing/scenes/${index}/id`;
|
|
25
|
+
const overlay = kinds.get(row.id);
|
|
26
|
+
if (overlay === undefined) {
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
path,
|
|
30
|
+
message: `scene "${row.id}" is not in the storyboard`,
|
|
31
|
+
suggestion: "regenerate timing for this storyboard",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
return overlay
|
|
36
|
+
? [
|
|
37
|
+
{
|
|
38
|
+
path,
|
|
39
|
+
message: `scene "${row.id}" is an overlay; voice timing covers base scenes only`,
|
|
40
|
+
suggestion: "remove it from the timing manifest",
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
: [];
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
const placeBase = (entries, voice) => {
|
|
47
|
+
const scenes = [];
|
|
48
|
+
let cursor = 0;
|
|
49
|
+
entries.forEach(({ entry, index }, position) => {
|
|
50
|
+
const duration = durationOf(entry, voice);
|
|
51
|
+
const prev = scenes[position - 1]?.duration ?? 0;
|
|
52
|
+
const overlap = round3(position === 0 ? 0 : overlapOf(entry, prev, duration));
|
|
53
|
+
const start = round3(cursor - overlap);
|
|
54
|
+
const transition = overlap > 0
|
|
55
|
+
? entry.transition === "push" ||
|
|
56
|
+
entry.transition === "wipe" ||
|
|
57
|
+
entry.transition === "zoom-through"
|
|
58
|
+
? entry.transition
|
|
59
|
+
: "crossfade"
|
|
60
|
+
: "cut";
|
|
61
|
+
scenes.push({ id: entry.id, index, start, duration, transition, overlap });
|
|
62
|
+
cursor = round3(start + duration);
|
|
63
|
+
});
|
|
64
|
+
return { scenes, total: cursor };
|
|
65
|
+
};
|
|
66
|
+
const placeOverlays = (entries, bases) => {
|
|
67
|
+
const starts = new Map(bases.map((scene) => [scene.id, scene.start]));
|
|
68
|
+
return entries.map(({ entry, index }) => {
|
|
69
|
+
const over = entry.over ?? "";
|
|
70
|
+
const duration = round3(entry.duration ?? MIN_AUTO_SECONDS);
|
|
71
|
+
const start = round3((starts.get(over) ?? 0) + (entry.offset ?? 0));
|
|
72
|
+
return { id: entry.id, index, over, start, duration, end: round3(start + duration) };
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const place = (storyboard, voice) => {
|
|
76
|
+
const indexed = storyboard.scenes.map((entry, index) => ({ entry, index }));
|
|
77
|
+
const base = placeBase(indexed.filter(({ entry }) => !isOverlay(entry)), voice);
|
|
78
|
+
const overlays = placeOverlays(indexed.filter(({ entry }) => isOverlay(entry)), base.scenes);
|
|
79
|
+
return { scenes: base.scenes, overlays, total: base.total };
|
|
80
|
+
};
|
|
81
|
+
/** Authored durations, optionally overridden per base scene id by a voice TimingManifest. */
|
|
82
|
+
export const resolveTimeline = (storyboard, manifest) => {
|
|
83
|
+
const errors = manifest ? manifestErrors(storyboard, manifest) : [];
|
|
84
|
+
if (errors.length > 0)
|
|
85
|
+
return { ok: false, errors };
|
|
86
|
+
const voice = new Map((manifest?.scenes ?? []).map((row) => [row.id, row.duration]));
|
|
87
|
+
return { ok: true, value: place(storyboard, voice) };
|
|
88
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { CUSTOM_SCENE_TYPE, getScene, sceneRegistry, sceneTypes } from "@anvpx/motion-scenes";
|
|
2
|
+
import { toJsonPointer, validateStoryboard, } from "@anvpx/motion-schema";
|
|
3
|
+
const WORD_LIMIT = /^scene has \d+ words/;
|
|
4
|
+
const valueAt = (root, path) => path.reduce((node, key) => (typeof node === "object" && node !== null ? Reflect.get(node, key) : undefined), root);
|
|
5
|
+
const withSuggestion = (base, suggestion) => suggestion === undefined ? base : { ...base, suggestion };
|
|
6
|
+
const describeIssue = (issue, props) => {
|
|
7
|
+
if (issue.code === "invalid_value") {
|
|
8
|
+
const received = JSON.stringify(valueAt(props, issue.path));
|
|
9
|
+
return { message: `${received} invalid`, suggestion: `use one of: ${issue.values.join("|")}` };
|
|
10
|
+
}
|
|
11
|
+
if (issue.code === "unrecognized_keys") {
|
|
12
|
+
const keys = issue.keys.join(", ");
|
|
13
|
+
return {
|
|
14
|
+
message: `unknown key(s): ${keys}`,
|
|
15
|
+
suggestion: `remove ${keys} (check spelling against the catalog)`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (WORD_LIMIT.test(issue.message)) {
|
|
19
|
+
return { message: issue.message, suggestion: "shorten the text or split into two scenes" };
|
|
20
|
+
}
|
|
21
|
+
return { message: issue.message };
|
|
22
|
+
};
|
|
23
|
+
const propErrors = (entry, index) => {
|
|
24
|
+
const parsed = getScene(entry.type).configSchema.safeParse(entry.props);
|
|
25
|
+
if (parsed.success)
|
|
26
|
+
return [];
|
|
27
|
+
return parsed.error.issues.map((issue) => {
|
|
28
|
+
const { message, suggestion } = describeIssue(issue, entry.props);
|
|
29
|
+
const path = toJsonPointer(["scenes", index, "props", ...issue.path]);
|
|
30
|
+
return withSuggestion({ path, message }, suggestion);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
const overlayTypes = () => [...sceneRegistry.values()].filter((plugin) => plugin.layer === "overlay").map((p) => p.type);
|
|
34
|
+
/** An overlay entry needs an overlay plugin and the other way round. */
|
|
35
|
+
const layerErrors = (entry, index) => {
|
|
36
|
+
const overlayPlugin = getScene(entry.type).layer === "overlay";
|
|
37
|
+
if (entry.layer === "overlay" && !overlayPlugin) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
path: `/scenes/${index}/type`,
|
|
41
|
+
message: `scene type "${entry.type}" is not an overlay`,
|
|
42
|
+
suggestion: `use one of: ${overlayTypes().join("|")}`,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
if (entry.layer !== "overlay" && overlayPlugin) {
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
path: `/scenes/${index}/layer`,
|
|
50
|
+
message: `scene type "${entry.type}" is overlay-only`,
|
|
51
|
+
suggestion: 'set layer: "overlay", over and duration',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
};
|
|
57
|
+
const ABSOLUTE_DIR = /^(?:[a-zA-Z]:|[\\/]|~)/;
|
|
58
|
+
const ESCAPING_DIR = /(^|[\\/])\.\.([\\/]|$)/;
|
|
59
|
+
/** `custom` takes exactly one prop: a relative directory holding the scene's three files. */
|
|
60
|
+
const customErrors = (entry, index) => {
|
|
61
|
+
const props = entry.props;
|
|
62
|
+
const unknown = Object.keys(props).filter((key) => key !== "dir");
|
|
63
|
+
if (unknown.length > 0) {
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
path: toJsonPointer(["scenes", index, "props"]),
|
|
67
|
+
message: `unknown key(s): ${unknown.join(", ")}`,
|
|
68
|
+
suggestion: "custom scenes take only props.dir",
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
const dir = props.dir;
|
|
73
|
+
if (typeof dir !== "string" || dir.trim() === "") {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
path: toJsonPointer(["scenes", index, "props", "dir"]),
|
|
77
|
+
message: "custom scene needs a directory",
|
|
78
|
+
suggestion: 'set props: { "dir": "scenes/<name>" }',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
if (ABSOLUTE_DIR.test(dir)) {
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
path: toJsonPointer(["scenes", index, "props", "dir"]),
|
|
86
|
+
message: "props.dir must be relative",
|
|
87
|
+
suggestion: "use a path inside the storyboard's directory",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
if (ESCAPING_DIR.test(dir)) {
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
path: toJsonPointer(["scenes", index, "props", "dir"]),
|
|
95
|
+
message: "props.dir escapes the storyboard directory",
|
|
96
|
+
suggestion: "keep the scene beside the storyboard",
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
};
|
|
102
|
+
const sceneErrors = (entry, index) => {
|
|
103
|
+
if (entry.type === CUSTOM_SCENE_TYPE) {
|
|
104
|
+
if (entry.layer === "overlay") {
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
path: `/scenes/${index}/layer`,
|
|
108
|
+
message: `scene type "${entry.type}" is not an overlay`,
|
|
109
|
+
suggestion: `use one of: ${overlayTypes().join("|")}`,
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
return customErrors(entry, index);
|
|
114
|
+
}
|
|
115
|
+
return sceneRegistry.has(entry.type)
|
|
116
|
+
? [...layerErrors(entry, index), ...propErrors(entry, index)]
|
|
117
|
+
: [
|
|
118
|
+
{
|
|
119
|
+
path: `/scenes/${index}/type`,
|
|
120
|
+
message: `unknown scene type "${entry.type}"`,
|
|
121
|
+
suggestion: `use one of: ${sceneTypes.join("|")}|custom (authored: mk scene new)`,
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
};
|
|
125
|
+
/** Schema validation plus every scene's own configSchema; errors are terse and fixable. */
|
|
126
|
+
export const validateStoryboardFull = (input) => {
|
|
127
|
+
const base = validateStoryboard(input);
|
|
128
|
+
if (!base.ok)
|
|
129
|
+
return base;
|
|
130
|
+
const errors = base.value.scenes.flatMap(sceneErrors);
|
|
131
|
+
return errors.length > 0 ? { ok: false, errors } : base;
|
|
132
|
+
};
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PlatformTemplate } from "@anvpx/motion-schema";
|
|
2
|
+
export type FindingLevel = "fatal" | "warn" | "info";
|
|
3
|
+
export type VerifyStatus = "PASS" | "WARN" | "FAIL" | "UNKNOWN";
|
|
4
|
+
export interface VerifyFinding {
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly level: FindingLevel;
|
|
7
|
+
/** At most 25 words. */
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly path?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface VerifyReport {
|
|
12
|
+
readonly status: VerifyStatus;
|
|
13
|
+
readonly findings: readonly VerifyFinding[];
|
|
14
|
+
}
|
|
15
|
+
/** What the rendered file should look like; every field is optional. */
|
|
16
|
+
export interface MediaExpectation {
|
|
17
|
+
readonly width?: number;
|
|
18
|
+
readonly height?: number;
|
|
19
|
+
readonly fps?: number;
|
|
20
|
+
/** Seconds. */
|
|
21
|
+
readonly duration?: number;
|
|
22
|
+
/** true: an audio stream is required; false: none is expected; absent: not checked. */
|
|
23
|
+
readonly audio?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface VerifyInput {
|
|
26
|
+
/** Parsed `ffprobe -show_streams -show_format -of json` output. */
|
|
27
|
+
readonly probe: unknown;
|
|
28
|
+
readonly expect?: MediaExpectation;
|
|
29
|
+
readonly platform?: PlatformTemplate;
|
|
30
|
+
}
|
|
31
|
+
/** Pure QC over probe JSON: measures and reports; the caller decides what to do with the status. */
|
|
32
|
+
export declare function verifyMedia(input: VerifyInput): VerifyReport;
|