@anvpx/motion-build 0.0.0-stage → 0.1.1
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 +238 -0
- package/dist/audio/mix.js +497 -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 +45 -0
- package/dist/timing.js +98 -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 +185 -0
- package/dist/versions.d.ts +26 -0
- package/dist/versions.js +74 -0
- package/package.json +40 -3
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { estimateContentHeight, progressHeight } from "./layout-estimate.js";
|
|
2
|
+
/** Frames at least this tall relative to their width count as 9:16 (16/9 = 1.78; 4:5 = 1.25 is excluded). */
|
|
3
|
+
const TALL_RATIO = 1.5;
|
|
4
|
+
/** The scenes' own margin (define-scene SAFE_MARGIN): padding is never smaller than this. */
|
|
5
|
+
const BASE_MARGIN = 0.05;
|
|
6
|
+
const SLACK_PX = 2;
|
|
7
|
+
const px = (value) => Math.round(value);
|
|
8
|
+
const geometryOf = (meta, margins, theme) => {
|
|
9
|
+
const safe = margins?.safe;
|
|
10
|
+
if (meta.platform === undefined || safe === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const zones = margins?.safeZones;
|
|
13
|
+
const frac = (zone) => Math.max(BASE_MARGIN, zone ?? 0);
|
|
14
|
+
const pad = {
|
|
15
|
+
top: frac(zones?.top) * meta.height,
|
|
16
|
+
bottom: frac(zones?.bottom) * meta.height,
|
|
17
|
+
left: frac(zones?.left) * meta.width,
|
|
18
|
+
right: frac(zones?.right) * meta.width,
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
platform: meta.platform,
|
|
22
|
+
height: meta.height,
|
|
23
|
+
pad,
|
|
24
|
+
limits: {
|
|
25
|
+
top: safe.top * meta.height,
|
|
26
|
+
bottom: meta.height * (1 - safe.bottom),
|
|
27
|
+
side: safe.side * meta.width,
|
|
28
|
+
},
|
|
29
|
+
metrics: { width: meta.width - pad.left - pad.right, theme },
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const sideFinding = (g) => {
|
|
33
|
+
const side = Math.min(g.pad.left, g.pad.right);
|
|
34
|
+
if (side >= g.limits.side - SLACK_PX)
|
|
35
|
+
return [];
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
severity: "warning",
|
|
39
|
+
path: "/meta/platform",
|
|
40
|
+
message: `side padding ${px(side)}px is under the ${px(g.limits.side)}px side margin of ${g.platform}`,
|
|
41
|
+
suggestion: "widen the side margin of the scenes",
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
};
|
|
45
|
+
/** The progress bar sits at the bottom edge of the padded frame. */
|
|
46
|
+
const overlayFinding = (g, scene, index) => {
|
|
47
|
+
if (scene.type !== "progress")
|
|
48
|
+
return [];
|
|
49
|
+
const bottom = g.height - g.pad.bottom;
|
|
50
|
+
if (bottom <= g.limits.bottom + SLACK_PX)
|
|
51
|
+
return [];
|
|
52
|
+
const top = bottom - progressHeight(scene.props, g.metrics);
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
severity: "warning",
|
|
56
|
+
path: `/scenes/${index}/props`,
|
|
57
|
+
message: `progress bar spans y=${px(top)}-${px(bottom)}px, below the ${g.platform} bottom safe line at y=${px(g.limits.bottom)}px`,
|
|
58
|
+
suggestion: "raise the bottom margin of the platform or drop the overlay",
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
};
|
|
62
|
+
/** Base content is centred vertically in the padded frame. */
|
|
63
|
+
const baseFinding = (g, scene, index) => {
|
|
64
|
+
const height = estimateContentHeight(scene.type, scene.props, g.metrics);
|
|
65
|
+
if (height === undefined)
|
|
66
|
+
return [];
|
|
67
|
+
const area = g.height - g.pad.top - g.pad.bottom;
|
|
68
|
+
const top = g.pad.top + (area - height) / 2;
|
|
69
|
+
const bottom = top + height;
|
|
70
|
+
if (top >= g.limits.top - SLACK_PX && bottom <= g.limits.bottom + SLACK_PX)
|
|
71
|
+
return [];
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
severity: "warning",
|
|
75
|
+
path: `/scenes/${index}/props`,
|
|
76
|
+
message: `${scene.type} content (est. ${px(height)}px) spans y=${px(top)}-${px(bottom)}px; ${g.platform} keeps y=${px(g.limits.top)}-${px(g.limits.bottom)}px clear`,
|
|
77
|
+
suggestion: "shorten the text or use fewer items",
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Platform-tagged 9:16 frames: warns when the estimated content block of a base scene (or the progress
|
|
83
|
+
* overlay) reaches into the platform's `safe{top,bottom,side}` margins.
|
|
84
|
+
*
|
|
85
|
+
* Estimate, not a measurement: heights come from `layout-estimate.ts` (scene CSS + a generous glyph-width
|
|
86
|
+
* model, roughly +-20 %), content is assumed vertically centred in the padded frame as the scenes lay it
|
|
87
|
+
* out, and the `bug` overlay and scene types without an estimator are not checked. Always a warning: the
|
|
88
|
+
* numbers in the platform data are unverified approximations.
|
|
89
|
+
*/
|
|
90
|
+
export const safeZone9x16Rule = ({ storyboard, theme, platforms }) => {
|
|
91
|
+
const { meta } = storyboard;
|
|
92
|
+
if (theme === undefined || platforms === undefined || meta.platform === undefined)
|
|
93
|
+
return [];
|
|
94
|
+
if (meta.height / meta.width < TALL_RATIO)
|
|
95
|
+
return [];
|
|
96
|
+
const g = geometryOf(meta, platforms(meta.platform), theme);
|
|
97
|
+
if (g === undefined)
|
|
98
|
+
return [];
|
|
99
|
+
const scenes = storyboard.scenes.flatMap((scene, index) => scene.layer === "overlay" ? overlayFinding(g, scene, index) : baseFinding(g, scene, index));
|
|
100
|
+
return [...sideFinding(g), ...scenes];
|
|
101
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LintRule } from "./types.ts";
|
|
2
|
+
/** Body text floor as a fraction of frame width: 24 px @1920 (16:9), 32 px @1080 (9:16). */
|
|
3
|
+
export declare const BODY_FLOOR_LANDSCAPE = 0.0125;
|
|
4
|
+
export declare const BODY_FLOOR_PORTRAIT = 0.0296;
|
|
5
|
+
/** Captions and labels may be this share of the body floor. */
|
|
6
|
+
export declare const LABEL_FLOOR_RATIO = 0.8;
|
|
7
|
+
/** Theme text sizes stay above the readable floor of the frame; inert without `input.theme`. */
|
|
8
|
+
export declare const textFloorRule: LintRule;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isPortrait } from "./frame.js";
|
|
2
|
+
/** Body text floor as a fraction of frame width: 24 px @1920 (16:9), 32 px @1080 (9:16). */
|
|
3
|
+
export const BODY_FLOOR_LANDSCAPE = 0.0125;
|
|
4
|
+
export const BODY_FLOOR_PORTRAIT = 0.0296;
|
|
5
|
+
/** Captions and labels may be this share of the body floor. */
|
|
6
|
+
export const LABEL_FLOOR_RATIO = 0.8;
|
|
7
|
+
/** Theme type-scale steps checked as body text and as labels. */
|
|
8
|
+
const BODY_STEPS = ["md", "lg"];
|
|
9
|
+
const LABEL_STEPS = ["sm"];
|
|
10
|
+
/** Theme text sizes stay above the readable floor of the frame; inert without `input.theme`. */
|
|
11
|
+
export const textFloorRule = ({ storyboard, theme }) => {
|
|
12
|
+
if (theme === undefined)
|
|
13
|
+
return [];
|
|
14
|
+
const { meta } = storyboard;
|
|
15
|
+
const body = meta.width * (isPortrait(meta) ? BODY_FLOOR_PORTRAIT : BODY_FLOOR_LANDSCAPE);
|
|
16
|
+
const check = (steps, floor, severity, role) => steps.flatMap((step) => {
|
|
17
|
+
const px = theme.fontSizes[step];
|
|
18
|
+
if (px === undefined || px >= floor)
|
|
19
|
+
return [];
|
|
20
|
+
const need = Math.ceil(floor);
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
severity,
|
|
24
|
+
path: `/meta/theme/fontSize/${step}`,
|
|
25
|
+
message: `${role} text ${px}px is under ${need}px for a ${meta.width}px-wide frame`,
|
|
26
|
+
suggestion: `raise theme fontSize.${step} to >= ${need}px`,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
});
|
|
30
|
+
return [
|
|
31
|
+
...check(BODY_STEPS, body, "error", "body"),
|
|
32
|
+
...check(LABEL_STEPS, body * LABEL_FLOOR_RATIO, "warning", "label"),
|
|
33
|
+
];
|
|
34
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { IMPLEMENTED_TRANSITIONS } from "../timing.js";
|
|
2
|
+
/** Cut, crossfade, push, wipe and zoom-through render as themselves; anything else falls back to cut. */
|
|
3
|
+
export const transitionImplementedRule = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
|
|
4
|
+
const name = scene.transition;
|
|
5
|
+
if (index === 0 || name === undefined || IMPLEMENTED_TRANSITIONS.includes(name))
|
|
6
|
+
return [];
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
severity: "warning",
|
|
10
|
+
path: `/scenes/${index}/transition`,
|
|
11
|
+
message: `transition ${name} not implemented, using cut`,
|
|
12
|
+
suggestion: `use one of: ${IMPLEMENTED_TRANSITIONS.join("|")}`,
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const MAX_TRANSITION_SECONDS = 0.8;
|
|
2
|
+
/** A transition (scene overlap) is at most 0.8 s. */
|
|
3
|
+
export const transitionLengthRule = ({ timeline }) => timeline.scenes
|
|
4
|
+
.filter((scene) => scene.overlap > MAX_TRANSITION_SECONDS)
|
|
5
|
+
.map((scene) => ({
|
|
6
|
+
severity: "error",
|
|
7
|
+
path: `/scenes/${scene.index}/transition`,
|
|
8
|
+
message: `transition ${scene.overlap}s exceeds ${MAX_TRANSITION_SECONDS}s`,
|
|
9
|
+
suggestion: `keep transitions <= ${MAX_TRANSITION_SECONDS}s or use cut`,
|
|
10
|
+
}));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LintRule } from "./types.ts";
|
|
2
|
+
/** More than five items in one list crowds the grid. */
|
|
3
|
+
export declare const MAX_LIST_ITEMS = 5;
|
|
4
|
+
/** A single typeset line stays at or under 60 characters. */
|
|
5
|
+
export declare const MAX_LINE_CHARS = 60;
|
|
6
|
+
/** Type must sit on the 4pt grid. */
|
|
7
|
+
export declare const SPACING_GRID = 4;
|
|
8
|
+
/**
|
|
9
|
+
* Six or more items in one list crowd the frame. Empty lists and scenes
|
|
10
|
+
* without an items array belong to other rules; data scenes are unmeasurable
|
|
11
|
+
* prose and stay silent.
|
|
12
|
+
*/
|
|
13
|
+
export declare const typeDensityRule: LintRule;
|
|
14
|
+
/**
|
|
15
|
+
* A title line past 60 characters overflows the grid estimate. Captions are
|
|
16
|
+
* timed speech, not grid type; overlays and data scenes stay silent. The rule
|
|
17
|
+
* is frame-agnostic on purpose (no safe-zone coupling).
|
|
18
|
+
*/
|
|
19
|
+
export declare const lineLengthRule: LintRule;
|
|
20
|
+
/**
|
|
21
|
+
* Numeric spacing must sit on the 4pt grid. Token spacing (xs, sm, md, ...)
|
|
22
|
+
* resolves in the theme and stays silent; overlays never advance the grid.
|
|
23
|
+
*/
|
|
24
|
+
export declare const offGridSpacingRule: LintRule;
|
|
25
|
+
/**
|
|
26
|
+
* One accent per idea: the same `[x]` twice in a scene, or the same accent in
|
|
27
|
+
* two consecutive base scenes, reads as shouting. Captions are timed speech
|
|
28
|
+
* and never count; overlays are skipped without breaking the chain.
|
|
29
|
+
*/
|
|
30
|
+
export declare const duplicatedEmphasisRule: LintRule;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Scenes whose text is data, not prose: density and line length do not apply. */
|
|
2
|
+
const DATA_EXEMPT_TYPES = new Set(["chart", "code", "table", "kpi-grid"]);
|
|
3
|
+
/** More than five items in one list crowds the grid. */
|
|
4
|
+
export const MAX_LIST_ITEMS = 5;
|
|
5
|
+
/** A single typeset line stays at or under 60 characters. */
|
|
6
|
+
export const MAX_LINE_CHARS = 60;
|
|
7
|
+
/** Type must sit on the 4pt grid. */
|
|
8
|
+
export const SPACING_GRID = 4;
|
|
9
|
+
/** Display-text keys checked for line length and emphasis (caption has its own timing). */
|
|
10
|
+
const TEXT_KEYS = ["title", "headline", "heading", "text", "value", "label"];
|
|
11
|
+
const ACCENT_PATTERN = /\[([^[\]]+)\]/g;
|
|
12
|
+
const accentsInString = (value) => {
|
|
13
|
+
const found = [];
|
|
14
|
+
for (const match of value.matchAll(ACCENT_PATTERN)) {
|
|
15
|
+
if (match[1] !== undefined)
|
|
16
|
+
found.push(match[1]);
|
|
17
|
+
}
|
|
18
|
+
return found;
|
|
19
|
+
};
|
|
20
|
+
const accentsInValue = (value) => {
|
|
21
|
+
if (typeof value === "string")
|
|
22
|
+
return accentsInString(value);
|
|
23
|
+
if (!Array.isArray(value))
|
|
24
|
+
return [];
|
|
25
|
+
return value.filter((item) => typeof item === "string").flatMap(accentsInString);
|
|
26
|
+
};
|
|
27
|
+
const accentsOf = (props) => Object.entries(props)
|
|
28
|
+
.filter(([key]) => key !== "caption")
|
|
29
|
+
.flatMap(([, value]) => accentsInValue(value));
|
|
30
|
+
/**
|
|
31
|
+
* Six or more items in one list crowd the frame. Empty lists and scenes
|
|
32
|
+
* without an items array belong to other rules; data scenes are unmeasurable
|
|
33
|
+
* prose and stay silent.
|
|
34
|
+
*/
|
|
35
|
+
export const typeDensityRule = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
|
|
36
|
+
if (scene.layer === "overlay" || DATA_EXEMPT_TYPES.has(scene.type))
|
|
37
|
+
return [];
|
|
38
|
+
const items = scene.props.items;
|
|
39
|
+
if (!Array.isArray(items) || items.length <= MAX_LIST_ITEMS)
|
|
40
|
+
return [];
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
severity: "warning",
|
|
44
|
+
path: `/scenes/${index}/props/items`,
|
|
45
|
+
message: `scene "${scene.id}" lists ${items.length} items; dense lists crowd the grid (max ${MAX_LIST_ITEMS})`,
|
|
46
|
+
suggestion: "split into two scenes or use fewer items",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
});
|
|
50
|
+
const longestLine = (value) => value.split("\n").reduce((max, line) => Math.max(max, line.length), 0);
|
|
51
|
+
const longLineKey = (props) => {
|
|
52
|
+
for (const key of TEXT_KEYS) {
|
|
53
|
+
const value = props[key];
|
|
54
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
55
|
+
continue;
|
|
56
|
+
const width = longestLine(value);
|
|
57
|
+
if (width > MAX_LINE_CHARS)
|
|
58
|
+
return { key, width };
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* A title line past 60 characters overflows the grid estimate. Captions are
|
|
64
|
+
* timed speech, not grid type; overlays and data scenes stay silent. The rule
|
|
65
|
+
* is frame-agnostic on purpose (no safe-zone coupling).
|
|
66
|
+
*/
|
|
67
|
+
export const lineLengthRule = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
|
|
68
|
+
if (scene.layer === "overlay" || DATA_EXEMPT_TYPES.has(scene.type))
|
|
69
|
+
return [];
|
|
70
|
+
const hit = longLineKey(scene.props);
|
|
71
|
+
if (hit === undefined)
|
|
72
|
+
return [];
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
severity: "warning",
|
|
76
|
+
path: `/scenes/${index}/props/${hit.key}`,
|
|
77
|
+
message: `${hit.key} line has ${hit.width} chars; keep lines <= ${MAX_LINE_CHARS}`,
|
|
78
|
+
suggestion: "split the line or shorten the text",
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* Numeric spacing must sit on the 4pt grid. Token spacing (xs, sm, md, ...)
|
|
84
|
+
* resolves in the theme and stays silent; overlays never advance the grid.
|
|
85
|
+
*/
|
|
86
|
+
export const offGridSpacingRule = ({ storyboard }) => storyboard.scenes.flatMap((scene, index) => {
|
|
87
|
+
if (scene.layer === "overlay")
|
|
88
|
+
return [];
|
|
89
|
+
const spacing = scene.props.spacing;
|
|
90
|
+
if (typeof spacing !== "number" || spacing % SPACING_GRID === 0)
|
|
91
|
+
return [];
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
severity: "warning",
|
|
95
|
+
path: `/scenes/${index}/props/spacing`,
|
|
96
|
+
message: `spacing ${spacing} is off the ${SPACING_GRID}pt grid`,
|
|
97
|
+
suggestion: `use a multiple of ${SPACING_GRID} for grid spacing`,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
});
|
|
101
|
+
const duplicateWithin = (accents) => {
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
for (const accent of accents) {
|
|
104
|
+
if (seen.has(accent))
|
|
105
|
+
return accent;
|
|
106
|
+
seen.add(accent);
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* One accent per idea: the same `[x]` twice in a scene, or the same accent in
|
|
112
|
+
* two consecutive base scenes, reads as shouting. Captions are timed speech
|
|
113
|
+
* and never count; overlays are skipped without breaking the chain.
|
|
114
|
+
*/
|
|
115
|
+
export const duplicatedEmphasisRule = ({ storyboard }) => {
|
|
116
|
+
const findings = [];
|
|
117
|
+
let previous = new Set();
|
|
118
|
+
let hasPrevious = false;
|
|
119
|
+
storyboard.scenes.forEach((scene, index) => {
|
|
120
|
+
if (scene.layer === "overlay")
|
|
121
|
+
return;
|
|
122
|
+
const accents = accentsOf(scene.props);
|
|
123
|
+
const within = duplicateWithin(accents);
|
|
124
|
+
if (within !== undefined) {
|
|
125
|
+
findings.push({
|
|
126
|
+
severity: "warning",
|
|
127
|
+
path: `/scenes/${index}/props`,
|
|
128
|
+
message: `accent "${within}" appears twice; duplicated emphasis crowds the beat`,
|
|
129
|
+
suggestion: "keep one accent and drop the repeat",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else if (hasPrevious) {
|
|
133
|
+
const repeat = accents.find((accent) => previous.has(accent));
|
|
134
|
+
if (repeat !== undefined) {
|
|
135
|
+
findings.push({
|
|
136
|
+
severity: "warning",
|
|
137
|
+
path: `/scenes/${index}/props`,
|
|
138
|
+
message: `accent "${repeat}" repeats the previous scene; duplicated emphasis`,
|
|
139
|
+
suggestion: "keep one accent per idea and drop the repeat",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
previous = new Set(accents);
|
|
144
|
+
hasPrevious = true;
|
|
145
|
+
});
|
|
146
|
+
return findings;
|
|
147
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ResolvedTheme, Storyboard } from "@anvpx/motion-schema";
|
|
2
|
+
import type { Timeline } from "../timing.ts";
|
|
3
|
+
export interface LintFinding {
|
|
4
|
+
readonly severity: "error" | "warning";
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
readonly suggestion?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Platform margins as fractions of the frame; supplied by the caller (the data lives in schema). */
|
|
10
|
+
export interface PlatformMargins {
|
|
11
|
+
/** Conservative margins that content must stay outside; `side` applies left and right. */
|
|
12
|
+
readonly safe?: {
|
|
13
|
+
readonly top: number;
|
|
14
|
+
readonly bottom: number;
|
|
15
|
+
readonly side: number;
|
|
16
|
+
};
|
|
17
|
+
/** Platform UI bands; scene padding is raised to at least these. */
|
|
18
|
+
readonly safeZones?: {
|
|
19
|
+
readonly top: number;
|
|
20
|
+
readonly right: number;
|
|
21
|
+
readonly bottom: number;
|
|
22
|
+
readonly left: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export type PlatformMarginLookup = (id: string) => PlatformMargins | undefined;
|
|
26
|
+
export interface LintInput {
|
|
27
|
+
readonly storyboard: Storyboard;
|
|
28
|
+
readonly timeline: Timeline;
|
|
29
|
+
/** On-screen words per scene, same order as `storyboard.scenes`. */
|
|
30
|
+
readonly words: readonly number[];
|
|
31
|
+
/** Resolved theme; theme-dependent rules (text-floor) are inert without it. */
|
|
32
|
+
readonly theme?: ResolvedTheme;
|
|
33
|
+
/** Platform margins by id; `safe-zone-9x16` is inert without it. */
|
|
34
|
+
readonly platforms?: PlatformMarginLookup;
|
|
35
|
+
}
|
|
36
|
+
/** A rule is a pure function from the resolved storyboard to findings. */
|
|
37
|
+
export type LintRule = (input: LintInput) => LintFinding[];
|
|
38
|
+
export interface LintResult {
|
|
39
|
+
readonly errors: readonly LintFinding[];
|
|
40
|
+
readonly warnings: readonly LintFinding[];
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isPortrait } from "./frame.js";
|
|
2
|
+
/** Approved by the user until measured (research says 15/8, UNVERIFIED). */
|
|
3
|
+
export const MAX_WORDS_LANDSCAPE = 25;
|
|
4
|
+
export const MAX_WORDS_PORTRAIT = 14;
|
|
5
|
+
/** Scenes whose text is data, not prose. */
|
|
6
|
+
const EXEMPT_TYPES = new Set(["chart", "table", "kpi-grid", "code"]);
|
|
7
|
+
export const wordsOnScreenRule = ({ storyboard, words }) => {
|
|
8
|
+
const max = isPortrait(storyboard.meta) ? MAX_WORDS_PORTRAIT : MAX_WORDS_LANDSCAPE;
|
|
9
|
+
return storyboard.scenes.flatMap((scene, index) => {
|
|
10
|
+
if (scene.layer === "overlay" || EXEMPT_TYPES.has(scene.type))
|
|
11
|
+
return [];
|
|
12
|
+
const count = words[index] ?? 0;
|
|
13
|
+
if (count <= max)
|
|
14
|
+
return [];
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
severity: "warning",
|
|
18
|
+
path: `/scenes/${index}/props`,
|
|
19
|
+
message: `scene "${scene.id}" shows ${count} words; keep ${max} or fewer`,
|
|
20
|
+
suggestion: `cut to ${max} words or split the scene`,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
});
|
|
24
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { VersionInfo } from "./versions.ts";
|
|
2
|
+
export interface BuildManifest {
|
|
3
|
+
readonly storyboardSha256: string;
|
|
4
|
+
readonly themeHash: string;
|
|
5
|
+
/** Hash of the asset ledger the build was given; null when none was supplied. */
|
|
6
|
+
readonly ledgerSha256: string | null;
|
|
7
|
+
/** Scene types used -> version of the package that provides the plugin. */
|
|
8
|
+
readonly scenePlugins: Readonly<Record<string, string>>;
|
|
9
|
+
readonly motionKit: Readonly<Record<string, string>>;
|
|
10
|
+
readonly hyperframes: string;
|
|
11
|
+
readonly node: string;
|
|
12
|
+
readonly files: Readonly<Record<string, string>>;
|
|
13
|
+
/** Distinct Lucide icons inlined into the scenes, sorted; omitted when none. */
|
|
14
|
+
readonly icons?: readonly string[];
|
|
15
|
+
/** Absolute windows of overlays, in declared order; omitted when the storyboard has none. */
|
|
16
|
+
readonly overlays?: readonly OverlayWindow[];
|
|
17
|
+
}
|
|
18
|
+
export interface OverlayWindow {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly over: string;
|
|
21
|
+
readonly start: number;
|
|
22
|
+
readonly end: number;
|
|
23
|
+
}
|
|
24
|
+
export type ProjectFiles = ReadonlyMap<string, string | Uint8Array>;
|
|
25
|
+
/** Build inputs that are not files of the project but still determine its bytes. */
|
|
26
|
+
export interface Provenance {
|
|
27
|
+
readonly themeHash: string;
|
|
28
|
+
readonly ledgerSha256: string | null;
|
|
29
|
+
/** Scene types used by the storyboard (duplicates allowed). */
|
|
30
|
+
readonly sceneTypes: readonly string[];
|
|
31
|
+
/** Icon names the scenes drew (duplicates allowed). */
|
|
32
|
+
readonly icons?: readonly string[];
|
|
33
|
+
readonly overlays?: readonly OverlayWindow[];
|
|
34
|
+
}
|
|
35
|
+
/** Reproducibility manifest; no timestamps or machine paths, so identical input gives identical bytes. */
|
|
36
|
+
export declare const createManifest: (storyboardJson: string, files: ProjectFiles, versions: VersionInfo, provenance: Provenance) => BuildManifest;
|
|
37
|
+
/** Sorted keys at every depth, two-space indent, trailing newline. */
|
|
38
|
+
export declare const renderManifest: (manifest: BuildManifest) => string;
|
|
39
|
+
/** One approval-bound media byte: project-relative output path plus content hash. */
|
|
40
|
+
export interface CompiledAssetView {
|
|
41
|
+
readonly path: string;
|
|
42
|
+
readonly sha256: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* RA-01: compare the media the build actually copied (`assets/*` entries of
|
|
46
|
+
* the compiled manifest) with the approval. Every copied byte must be
|
|
47
|
+
* approval-bound with the identical hash, and every approved byte must be
|
|
48
|
+
* present in the build, or the media must not publish. Non-media outputs
|
|
49
|
+
* (html, fonts, licences) are out of scope and ignored; storyboard, theme
|
|
50
|
+
* and ledger drift is caught by the source re-resolve at the same boundary.
|
|
51
|
+
* Sorted for deterministic diagnostics.
|
|
52
|
+
*/
|
|
53
|
+
export declare const compiledAssetMismatches: (files: Readonly<Record<string, string>>, approved: readonly CompiledAssetView[]) => readonly string[];
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { canonicalJson } from "@anvpx/motion-schema";
|
|
2
|
+
import { sha256Of } from "./assets.js";
|
|
3
|
+
const SCENES_PACKAGE = "@anvpx/motion-scenes";
|
|
4
|
+
/** Reproducibility manifest; no timestamps or machine paths, so identical input gives identical bytes. */
|
|
5
|
+
export const createManifest = (storyboardJson, files, versions, provenance) => {
|
|
6
|
+
const iconNames = [...new Set(provenance.icons ?? [])].sort();
|
|
7
|
+
return {
|
|
8
|
+
storyboardSha256: sha256Of(storyboardJson),
|
|
9
|
+
themeHash: provenance.themeHash,
|
|
10
|
+
ledgerSha256: provenance.ledgerSha256,
|
|
11
|
+
scenePlugins: Object.fromEntries([...new Set(provenance.sceneTypes)]
|
|
12
|
+
.sort()
|
|
13
|
+
.map((type) => [type, versions.packages[SCENES_PACKAGE] ?? "unknown"])),
|
|
14
|
+
motionKit: versions.packages,
|
|
15
|
+
hyperframes: versions.hyperframes,
|
|
16
|
+
node: versions.node,
|
|
17
|
+
files: Object.fromEntries([...files].sort(([a], [b]) => (a < b ? -1 : 1)).map(([path, data]) => [path, sha256Of(data)])),
|
|
18
|
+
...(iconNames.length > 0 ? { icons: iconNames } : {}),
|
|
19
|
+
...(provenance.overlays && provenance.overlays.length > 0
|
|
20
|
+
? { overlays: provenance.overlays }
|
|
21
|
+
: {}),
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** Sorted keys at every depth, two-space indent, trailing newline. */
|
|
25
|
+
export const renderManifest = (manifest) => canonicalJson(manifest);
|
|
26
|
+
/**
|
|
27
|
+
* RA-01: compare the media the build actually copied (`assets/*` entries of
|
|
28
|
+
* the compiled manifest) with the approval. Every copied byte must be
|
|
29
|
+
* approval-bound with the identical hash, and every approved byte must be
|
|
30
|
+
* present in the build, or the media must not publish. Non-media outputs
|
|
31
|
+
* (html, fonts, licences) are out of scope and ignored; storyboard, theme
|
|
32
|
+
* and ledger drift is caught by the source re-resolve at the same boundary.
|
|
33
|
+
* Sorted for deterministic diagnostics.
|
|
34
|
+
*/
|
|
35
|
+
export const compiledAssetMismatches = (files, approved) => {
|
|
36
|
+
const want = new Map(approved.map((entry) => [entry.path, entry.sha256]));
|
|
37
|
+
const mismatches = [];
|
|
38
|
+
for (const [path, sha] of Object.entries(files)) {
|
|
39
|
+
if (!path.startsWith("assets/"))
|
|
40
|
+
continue;
|
|
41
|
+
const approvedSha = want.get(path);
|
|
42
|
+
if (approvedSha === undefined) {
|
|
43
|
+
mismatches.push(`"${path}" was copied into the build but is not in the approval`);
|
|
44
|
+
}
|
|
45
|
+
else if (approvedSha !== sha) {
|
|
46
|
+
mismatches.push(`"${path}" sha differs from the approval`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const entry of approved) {
|
|
50
|
+
if (files[entry.path] === undefined) {
|
|
51
|
+
mismatches.push(`approved "${entry.path}" is missing from the build`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return mismatches.sort();
|
|
55
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { VerifyStatus } from "./verify.ts";
|
|
2
|
+
export interface RenderManifestInput {
|
|
3
|
+
readonly kind: "draft" | "final";
|
|
4
|
+
/** sha256 of the deterministic `manifest.json` of the project that was rendered. */
|
|
5
|
+
readonly manifestSha256: string;
|
|
6
|
+
/** Basename and hash of the rendered file; no directory, so the record is machine independent. */
|
|
7
|
+
readonly output: {
|
|
8
|
+
readonly file: string;
|
|
9
|
+
readonly sha256: string;
|
|
10
|
+
};
|
|
11
|
+
readonly params: Readonly<Record<string, string | number | boolean>>;
|
|
12
|
+
/** Versions of the tools that ran; "unknown" when they could not be detected. */
|
|
13
|
+
readonly tools: Readonly<Record<string, string>>;
|
|
14
|
+
readonly verify: VerifyStatus | "not-run";
|
|
15
|
+
}
|
|
16
|
+
export interface RenderManifest extends RenderManifestInput {
|
|
17
|
+
readonly schema: 1;
|
|
18
|
+
}
|
|
19
|
+
/** Render-time provenance, written next to the mp4; kept apart from the deterministic build manifest. */
|
|
20
|
+
export declare const createRenderManifest: (input: RenderManifestInput) => RenderManifest;
|
|
21
|
+
export declare const renderRenderManifest: (manifest: RenderManifest) => string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { canonicalJson } from "@anvpx/motion-schema";
|
|
2
|
+
/** Render-time provenance, written next to the mp4; kept apart from the deterministic build manifest. */
|
|
3
|
+
export const createRenderManifest = (input) => ({
|
|
4
|
+
schema: 1,
|
|
5
|
+
...input,
|
|
6
|
+
});
|
|
7
|
+
export const renderRenderManifest = (manifest) => canonicalJson(manifest);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type GradeName } from "@anvpx/motion-themes";
|
|
2
|
+
/** Output path of the auto-emitted grade review sheet (no new CLI command). */
|
|
3
|
+
export declare const GRADE_REVIEW_PATH = "review/grade.html";
|
|
4
|
+
export interface GradeReviewInput {
|
|
5
|
+
readonly themeName: string;
|
|
6
|
+
readonly themeCss: string;
|
|
7
|
+
readonly gradeCss: string;
|
|
8
|
+
readonly grades: Readonly<Record<GradeName, string>>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Per-grade stills grid (CSS-gradient stills with the grade preview filter).
|
|
12
|
+
* Pure and deterministic: no timestamps, no network, no inline script.
|
|
13
|
+
* SDR only: tone maps are HDR-prepare selections, never an HDR output claim.
|
|
14
|
+
*/
|
|
15
|
+
export declare const renderGradeReview: (input: GradeReviewInput) => string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { esc } from "@anvpx/motion-scenes";
|
|
2
|
+
import { GRADE_NAMES, gradeFilter, gradeLutFile, gradeToneMap, } from "@anvpx/motion-themes";
|
|
3
|
+
/** Output path of the auto-emitted grade review sheet (no new CLI command). */
|
|
4
|
+
export const GRADE_REVIEW_PATH = "review/grade.html";
|
|
5
|
+
const cardSwatch = (grade, colour) => {
|
|
6
|
+
const filter = gradeFilter(grade);
|
|
7
|
+
const style = `background: linear-gradient(135deg, ${colour} 0%, #808080 55%, #101010 100%);` +
|
|
8
|
+
(filter === "none" ? "" : ` filter: ${filter};`);
|
|
9
|
+
return `<div class="mk-grade-swatch" style="${esc(style)}" aria-hidden="true"></div>`;
|
|
10
|
+
};
|
|
11
|
+
const cardOf = (grade, colour) => {
|
|
12
|
+
const lut = gradeLutFile(grade) ?? "none";
|
|
13
|
+
const tone = gradeToneMap(grade);
|
|
14
|
+
const filter = gradeFilter(grade);
|
|
15
|
+
return [
|
|
16
|
+
`<article class="mk-grade-card" data-grade="${esc(grade)}">`,
|
|
17
|
+
` <h2>${esc(grade)}</h2>`,
|
|
18
|
+
` ${cardSwatch(grade, colour)}`,
|
|
19
|
+
` <dl>`,
|
|
20
|
+
` <dt>colour</dt><dd><code>${esc(colour)}</code></dd>`,
|
|
21
|
+
` <dt>lut</dt><dd><code>${esc(lut)}</code></dd>`,
|
|
22
|
+
` <dt>tone map (HDR prepare)</dt><dd><code>${esc(tone)}</code></dd>`,
|
|
23
|
+
` <dt>preview filter</dt><dd><code>${esc(filter)}</code></dd>`,
|
|
24
|
+
` </dl>`,
|
|
25
|
+
`</article>`,
|
|
26
|
+
].join("\n");
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Per-grade stills grid (CSS-gradient stills with the grade preview filter).
|
|
30
|
+
* Pure and deterministic: no timestamps, no network, no inline script.
|
|
31
|
+
* SDR only: tone maps are HDR-prepare selections, never an HDR output claim.
|
|
32
|
+
*/
|
|
33
|
+
export const renderGradeReview = (input) => {
|
|
34
|
+
const cards = GRADE_NAMES.map((grade) => cardOf(grade, input.grades[grade])).join("\n");
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="UTF-8" />
|
|
39
|
+
<meta name="viewport" content="width=1280" />
|
|
40
|
+
<title>${esc(`Grade review — ${input.themeName}`)}</title>
|
|
41
|
+
<style>
|
|
42
|
+
${input.themeCss}${input.gradeCss} .mk-grade-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; padding: 24px; }
|
|
43
|
+
.mk-grade-card { border: 1px solid var(--mk-color-divider, #ccc); border-radius: 12px; padding: 16px; }
|
|
44
|
+
.mk-grade-swatch { width: 100%; height: 180px; border-radius: 8px; }
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<main>
|
|
49
|
+
<h1>Grade review — ${esc(input.themeName)} (SDR)</h1>
|
|
50
|
+
<p>Curated grades: ${esc(GRADE_NAMES.join(", "))}. LUTs ship as CC0-1.0 data; tone maps are HDR-prepare selections only.</p>
|
|
51
|
+
${cards}
|
|
52
|
+
</main>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
55
|
+
`;
|
|
56
|
+
};
|