@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,6 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountTimeline } from "../../runtime/mount.js";
|
|
3
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
4
|
+
export function createScene(root, config, ctx) {
|
|
5
|
+
return mountTimeline(root, config, ctx, { baseDelay: 0.2, staggerFrames: 4, rise: 56 });
|
|
6
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountOverlay } from "../../runtime/mount.js";
|
|
3
|
+
import { spanProgress } from "../../runtime/timeline.js";
|
|
4
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
5
|
+
export function createScene(root, config, ctx) {
|
|
6
|
+
const track = /** @type {HTMLElement} */ (root.querySelector("[data-from]"));
|
|
7
|
+
const fill = /** @type {HTMLElement} */ (root.querySelector(".mk-fill"));
|
|
8
|
+
const from = Number(track.dataset.from);
|
|
9
|
+
const to = Number(track.dataset.to);
|
|
10
|
+
const duration = ctx.duration ?? Number(root.dataset.duration);
|
|
11
|
+
return mountOverlay(root, config, ctx, { baseDelay: 0, staggerFrames: 3, rise: 0 }, {
|
|
12
|
+
update: (t) => {
|
|
13
|
+
fill.style.transform = `scaleX(${spanProgress(t, duration, from, to)})`;
|
|
14
|
+
},
|
|
15
|
+
reset: () => fill.style.removeProperty("transform"),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountTimeline } from "../../runtime/mount.js";
|
|
3
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
4
|
+
export function createScene(root, config, ctx) {
|
|
5
|
+
return mountTimeline(root, config, ctx, { baseDelay: 0.3, staggerFrames: 6, rise: 48 });
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountOverlay } from "../../runtime/mount.js";
|
|
3
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
4
|
+
export function createScene(root, config, ctx) {
|
|
5
|
+
return mountOverlay(root, config, ctx, { baseDelay: 0.1, staggerFrames: 4, rise: 0 });
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountTimeline } from "../../runtime/mount.js";
|
|
3
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
4
|
+
export function createScene(root, config, ctx) {
|
|
5
|
+
return mountTimeline(root, config, ctx, { baseDelay: 0.3, staggerFrames: 6, rise: 32 });
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountTimeline } from "../../runtime/mount.js";
|
|
3
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
4
|
+
export function createScene(root, config, ctx) {
|
|
5
|
+
return mountTimeline(root, config, ctx, { baseDelay: 0.2, staggerFrames: 6, rise: 24 });
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountOverlay } from "../../runtime/mount.js";
|
|
3
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
4
|
+
export function createScene(root, config, ctx) {
|
|
5
|
+
return mountOverlay(root, config, ctx, { baseDelay: 0.1, staggerFrames: 5, rise: 40 });
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountTimeline } from "../../runtime/mount.js";
|
|
3
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
4
|
+
export function createScene(root, config, ctx) {
|
|
5
|
+
return mountTimeline(root, config, ctx, { baseDelay: 0.2, staggerFrames: 5, rise: 32 });
|
|
6
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountOverlay } from "../../runtime/mount.js";
|
|
3
|
+
import { countdownState } from "../../runtime/timeline.js";
|
|
4
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
5
|
+
export function createScene(root, config, ctx) {
|
|
6
|
+
const number = /** @type {HTMLElement} */ (root.querySelector("[data-from]"));
|
|
7
|
+
const from = Number(number.dataset.from);
|
|
8
|
+
const to = Number(number.dataset.to);
|
|
9
|
+
const duration = ctx.duration ?? Number(root.dataset.duration);
|
|
10
|
+
return mountOverlay(root, config, ctx, { baseDelay: 0, staggerFrames: 3, rise: 0 }, {
|
|
11
|
+
update: (t) => {
|
|
12
|
+
const state = countdownState(t, duration, from, to);
|
|
13
|
+
number.textContent = String(state.value);
|
|
14
|
+
number.style.transform = `scale(${state.scale})`;
|
|
15
|
+
},
|
|
16
|
+
reset: () => {
|
|
17
|
+
number.textContent = String(from);
|
|
18
|
+
number.style.removeProperty("transform");
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Locale-free, deterministic number formatting for tabular numerals (no Intl, no toLocaleString).
|
|
3
|
+
const MINUS = "−";
|
|
4
|
+
const TIERS = ["", "K", "M", "B", "T"];
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} FmtOpts
|
|
7
|
+
* @property {string} [unit] "$", "€", "£", "¥" prefix; anything else is appended ("%", " km")
|
|
8
|
+
* @property {number} [decimals] fixed decimals (default 0; 1 when compact above 1000)
|
|
9
|
+
* @property {boolean} [sign] show "+" for positive values
|
|
10
|
+
* @property {boolean} [compact] K / M / B / T suffix
|
|
11
|
+
*/
|
|
12
|
+
/** @param {string} digits */
|
|
13
|
+
const group = (digits) => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
14
|
+
/**
|
|
15
|
+
* @param {number} abs
|
|
16
|
+
* @param {number} decimals
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
const fixed = (abs, decimals) => {
|
|
20
|
+
const [int = "0", frac] = abs.toFixed(decimals).split(".");
|
|
21
|
+
return frac === undefined ? group(int) : `${group(int)}.${frac}`;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* @param {number} abs
|
|
25
|
+
* @param {number | undefined} decimals
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
const compactBody = (abs, decimals) => {
|
|
29
|
+
let tier = 0;
|
|
30
|
+
let scaled = abs;
|
|
31
|
+
while (scaled >= 1000 && tier < TIERS.length - 1) {
|
|
32
|
+
scaled /= 1000;
|
|
33
|
+
tier++;
|
|
34
|
+
}
|
|
35
|
+
const d = decimals ?? (tier === 0 ? 0 : 1);
|
|
36
|
+
// rounding can reach 1000 of this tier (999.95K -> 1000.0K): promote once
|
|
37
|
+
if (tier < TIERS.length - 1 && Number(scaled.toFixed(d)) >= 1000) {
|
|
38
|
+
scaled /= 1000;
|
|
39
|
+
tier++;
|
|
40
|
+
}
|
|
41
|
+
return `${fixed(scaled, d)}${TIERS[tier]}`;
|
|
42
|
+
};
|
|
43
|
+
/** @param {string} unit */
|
|
44
|
+
const isPrefix = (unit) => /^[$€£¥]/.test(unit);
|
|
45
|
+
/**
|
|
46
|
+
* @param {number} value
|
|
47
|
+
* @param {FmtOpts} [opts]
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export const fmt = (value, opts = {}) => {
|
|
51
|
+
if (!Number.isFinite(value))
|
|
52
|
+
throw new RangeError(`fmt: value must be finite, got ${value}`);
|
|
53
|
+
const { unit = "", decimals, sign = false, compact = false } = opts;
|
|
54
|
+
const abs = Math.abs(value);
|
|
55
|
+
const body = compact ? compactBody(abs, decimals) : fixed(abs, decimals ?? 0);
|
|
56
|
+
const zero = Number(body.replace(/[^0-9.]/g, "")) === 0;
|
|
57
|
+
const mark = zero ? "" : value < 0 ? MINUS : sign ? "+" : "";
|
|
58
|
+
return isPrefix(unit) ? `${mark}${unit}${body}` : `${mark}${body}${unit}`;
|
|
59
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Pure motion helpers shared by scenes. No DOM, no clock, no state: the same inputs always give
|
|
3
|
+
// the same outputs. One implementation serves emit time (TS) and the browser seek runtime.
|
|
4
|
+
import { clamp01, ease } from "../runtime/easing.js";
|
|
5
|
+
/** Smallest / largest fraction of an item's duration shared with the next item in a stagger. */
|
|
6
|
+
export const MIN_OVERLAP = 0.05;
|
|
7
|
+
export const MAX_OVERLAP = 0.9;
|
|
8
|
+
/**
|
|
9
|
+
* @param {number} v
|
|
10
|
+
* @param {number} lo
|
|
11
|
+
* @param {number} hi
|
|
12
|
+
* @returns {number}
|
|
13
|
+
*/
|
|
14
|
+
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} a
|
|
17
|
+
* @param {number} b
|
|
18
|
+
* @param {number} p
|
|
19
|
+
* @returns {number}
|
|
20
|
+
*/
|
|
21
|
+
export const lerp = (a, b, p) => a + (b - a) * p;
|
|
22
|
+
/** Linear map of `v` from [inMin, inMax] to [outMin, outMax], clamped to the input range.
|
|
23
|
+
* @param {number} v
|
|
24
|
+
* @param {number} inMin
|
|
25
|
+
* @param {number} inMax
|
|
26
|
+
* @param {number} outMin
|
|
27
|
+
* @param {number} outMax
|
|
28
|
+
* @returns {number}
|
|
29
|
+
*/
|
|
30
|
+
export const scale = (v, inMin, inMax, outMin, outMax) => lerp(outMin, outMax, inMax === inMin ? (v >= inMax ? 1 : 0) : clamp01((v - inMin) / (inMax - inMin)));
|
|
31
|
+
/** Monotone 0..1 progress of `t` through [start, end]; a zero-length window is a step.
|
|
32
|
+
* @param {number} t
|
|
33
|
+
* @param {number} start
|
|
34
|
+
* @param {number} end
|
|
35
|
+
* @returns {number}
|
|
36
|
+
*/
|
|
37
|
+
export const progress = (t, start, end) => end > start ? clamp01((t - start) / (end - start)) : t >= start ? 1 : 0;
|
|
38
|
+
/**
|
|
39
|
+
* @param {number} n
|
|
40
|
+
* @param {number} span
|
|
41
|
+
* @returns {void}
|
|
42
|
+
*/
|
|
43
|
+
const assertStagger = (n, span) => {
|
|
44
|
+
if (!Number.isInteger(n) || n < 1)
|
|
45
|
+
throw new RangeError(`stagger: n must be an integer >= 1, got ${n}`);
|
|
46
|
+
if (!(span > 0) || !Number.isFinite(span))
|
|
47
|
+
throw new RangeError(`stagger: span must be > 0, got ${span}`);
|
|
48
|
+
};
|
|
49
|
+
/** @param {number} overlap */
|
|
50
|
+
const overlapOf = (overlap) => clamp(Number.isFinite(overlap) ? overlap : 0, MIN_OVERLAP, MAX_OVERLAP);
|
|
51
|
+
/**
|
|
52
|
+
* Duration of each item when `n` items share `span` seconds and overlap by `overlap` (a fraction of
|
|
53
|
+
* the item duration, clamped to [0.05, 0.9]). The last item ends exactly at `span`.
|
|
54
|
+
* @param {number} n
|
|
55
|
+
* @param {number} span
|
|
56
|
+
* @param {number} overlap
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
export const staggerDuration = (n, span, overlap) => {
|
|
60
|
+
assertStagger(n, span);
|
|
61
|
+
return span / (1 + (n - 1) * (1 - overlapOf(overlap)));
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Start time of item `i` (0-based). Offset between items is always smaller than an item's duration.
|
|
65
|
+
* @param {number} i
|
|
66
|
+
* @param {number} n
|
|
67
|
+
* @param {number} span
|
|
68
|
+
* @param {number} overlap
|
|
69
|
+
* @returns {number}
|
|
70
|
+
*/
|
|
71
|
+
export const staggerStart = (i, n, span, overlap) => i * staggerDuration(n, span, overlap) * (1 - overlapOf(overlap));
|
|
72
|
+
/**
|
|
73
|
+
* Count from `from` (default 0) to `value`, easing out; exact at the end.
|
|
74
|
+
* @param {number} t
|
|
75
|
+
* @param {number} value
|
|
76
|
+
* @param {{ from?: number, start?: number, dur?: number }} [opts]
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
export const countUp = (t, value, opts = {}) => {
|
|
80
|
+
const { from = 0, start = 0, dur = 1 } = opts;
|
|
81
|
+
const p = progress(t, start, start + dur);
|
|
82
|
+
return p >= 1 ? value : lerp(from, value, ease("out", p));
|
|
83
|
+
};
|
|
84
|
+
/** Stroke-dashoffset that draws a path of length `len` on as `p` goes 0..1.
|
|
85
|
+
* @param {number} len
|
|
86
|
+
* @param {number} p
|
|
87
|
+
* @returns {number}
|
|
88
|
+
*/
|
|
89
|
+
export const drawOn = (len, p) => len * (1 - clamp01(p));
|
|
90
|
+
/** Left-to-right clip-path reveal; `p` 0..1.
|
|
91
|
+
* @param {number} p
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export const wipe = (p) => `inset(0 ${Math.round((1 - clamp01(p)) * 100000) / 1000}% 0 0)`;
|
|
95
|
+
/**
|
|
96
|
+
* Word-by-word timings: `n` words spread over `span` seconds with `overlap` between neighbours.
|
|
97
|
+
* @param {number} n
|
|
98
|
+
* @param {number} span
|
|
99
|
+
* @param {number} [overlap]
|
|
100
|
+
* @returns {{ start: number, end: number }[]}
|
|
101
|
+
*/
|
|
102
|
+
export const revealTimings = (n, span, overlap = 0.5) => {
|
|
103
|
+
const dur = staggerDuration(n, span, overlap);
|
|
104
|
+
return Array.from({ length: n }, (_, i) => {
|
|
105
|
+
const start = staggerStart(i, n, span, overlap);
|
|
106
|
+
return { start, end: i === n - 1 ? span : start + dur };
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Eased 0..1 progress of each of `n` words at time `t`.
|
|
111
|
+
* @param {number} n
|
|
112
|
+
* @param {number} t
|
|
113
|
+
* @param {number} span
|
|
114
|
+
* @param {number} [overlap]
|
|
115
|
+
* @returns {number[]}
|
|
116
|
+
*/
|
|
117
|
+
export const revealProgress = (n, t, span, overlap = 0.5) => revealTimings(n, span, overlap).map(({ start, end }) => ease("out", progress(t, start, end)));
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AssetLedger, AssetRow, ValidationError } from "@anvpx/motion-schema";
|
|
2
|
+
export interface CopiedAsset {
|
|
3
|
+
/** Project-relative output path, e.g. `assets/logo.svg`. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly bytes: Uint8Array;
|
|
6
|
+
readonly sha256: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AssetCollector {
|
|
9
|
+
/** The `assetResolver` handed to scene plugins; throws on an invalid ref. */
|
|
10
|
+
readonly resolver: (ref: string) => string;
|
|
11
|
+
/** Distinct refs requested so far, in first-use order. */
|
|
12
|
+
readonly refs: () => string[];
|
|
13
|
+
}
|
|
14
|
+
export declare const sha256Of: (data: Uint8Array | string) => string;
|
|
15
|
+
/**
|
|
16
|
+
* RA-01: snapshot bytes so that a source edit landing after the copy cannot
|
|
17
|
+
* change the media the HyperFrames project embeds. `copyOne` freezes every
|
|
18
|
+
* copied asset through here.
|
|
19
|
+
*/
|
|
20
|
+
export declare const freezeBytes: (data: Uint8Array) => Uint8Array;
|
|
21
|
+
/** Normalises a ref to a forward-slash relative path or throws a fixable error. */
|
|
22
|
+
export declare const normaliseRef: (ref: string) => string;
|
|
23
|
+
export declare const createAssetCollector: (hasAssetsDir: boolean) => AssetCollector;
|
|
24
|
+
/** Content hash of an asset, or a non-hex marker when it is absent or escapes the assets dir. */
|
|
25
|
+
export declare const hashAsset: (assetsDir: string | undefined, ref: string) => string;
|
|
26
|
+
export type CopyResult = {
|
|
27
|
+
readonly ok: true;
|
|
28
|
+
readonly assets: readonly CopiedAsset[];
|
|
29
|
+
/** One licence row per copied asset, keyed by its output path, sorted. */
|
|
30
|
+
readonly licences: readonly AssetRow[];
|
|
31
|
+
} | {
|
|
32
|
+
readonly ok: false;
|
|
33
|
+
readonly errors: readonly ValidationError[];
|
|
34
|
+
};
|
|
35
|
+
/** Reads each ref from assetsDir, hashes it and requires a ledger row whose sha256 matches. */
|
|
36
|
+
export declare const copyAssets: (refs: readonly string[], assetsDir: string | undefined, ledger: AssetLedger | undefined) => CopyResult;
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
const BAD_REF = /^(?:[a-zA-Z]:|\\|\/|[a-zA-Z][a-zA-Z0-9+.-]*:)/;
|
|
5
|
+
export const sha256Of = (data) => createHash("sha256").update(data).digest("hex");
|
|
6
|
+
/**
|
|
7
|
+
* RA-01: snapshot bytes so that a source edit landing after the copy cannot
|
|
8
|
+
* change the media the HyperFrames project embeds. `copyOne` freezes every
|
|
9
|
+
* copied asset through here.
|
|
10
|
+
*/
|
|
11
|
+
export const freezeBytes = (data) => data.slice();
|
|
12
|
+
/** Normalises a ref to a forward-slash relative path or throws a fixable error. */
|
|
13
|
+
export const normaliseRef = (ref) => {
|
|
14
|
+
if (BAD_REF.test(ref) || isAbsolute(ref)) {
|
|
15
|
+
throw new Error(`asset "${ref}" must be a relative path inside the assets dir`);
|
|
16
|
+
}
|
|
17
|
+
const parts = ref.replaceAll("\\", "/").split("/");
|
|
18
|
+
if (parts.some((part) => part === ".." || part === "")) {
|
|
19
|
+
throw new Error(`asset "${ref}" must not contain ".." or empty segments`);
|
|
20
|
+
}
|
|
21
|
+
return parts.filter((part) => part !== ".").join("/");
|
|
22
|
+
};
|
|
23
|
+
export const createAssetCollector = (hasAssetsDir) => {
|
|
24
|
+
const seen = [];
|
|
25
|
+
const resolver = (ref) => {
|
|
26
|
+
if (!hasAssetsDir)
|
|
27
|
+
throw new Error(`asset "${ref}" requested but no assetsDir was given`);
|
|
28
|
+
const clean = normaliseRef(ref);
|
|
29
|
+
if (!seen.includes(clean))
|
|
30
|
+
seen.push(clean);
|
|
31
|
+
return `assets/${clean}`;
|
|
32
|
+
};
|
|
33
|
+
return { resolver, refs: () => [...seen] };
|
|
34
|
+
};
|
|
35
|
+
const insideDir = (root, target) => {
|
|
36
|
+
const rel = relative(root, target);
|
|
37
|
+
return rel !== "" && rel.split(sep)[0] !== ".." && !isAbsolute(rel);
|
|
38
|
+
};
|
|
39
|
+
const failure = (ref, message, suggestion) => ({
|
|
40
|
+
path: `/assets/${ref}`,
|
|
41
|
+
message,
|
|
42
|
+
...(suggestion === undefined ? {} : { suggestion }),
|
|
43
|
+
});
|
|
44
|
+
const readInside = (assetsDir, ref) => {
|
|
45
|
+
try {
|
|
46
|
+
const real = realpathSync(resolve(assetsDir, ref));
|
|
47
|
+
if (!insideDir(realpathSync(assetsDir), real)) {
|
|
48
|
+
return failure(ref, "resolves outside the assets dir", "keep assets inside assetsDir");
|
|
49
|
+
}
|
|
50
|
+
return readFileSync(real);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return failure(ref, "file not found", `add ${ref} under the assets dir`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/** Content hash of an asset, or a non-hex marker when it is absent or escapes the assets dir. */
|
|
57
|
+
export const hashAsset = (assetsDir, ref) => {
|
|
58
|
+
const read = assetsDir === undefined ? undefined : readInside(assetsDir, ref);
|
|
59
|
+
return read instanceof Uint8Array ? sha256Of(read) : "missing";
|
|
60
|
+
};
|
|
61
|
+
const NO_ROW_HINT = (ref) => `add a row for "${ref}" (licence, sourceUrl, sha256; attribution when the licence needs it) to the asset ledger: pass it with --ledger <assets.json> or add it to the storyboard's assets ledger`;
|
|
62
|
+
/** The ledger row for a copied asset, or why there is none; every asset needs one. */
|
|
63
|
+
const ledgerRow = (ref, sha256, ledger) => {
|
|
64
|
+
const row = ledger?.find((candidate) => candidate.path === ref);
|
|
65
|
+
if (!row)
|
|
66
|
+
return failure(ref, "no ledger row", NO_ROW_HINT(ref));
|
|
67
|
+
return row.sha256 === sha256
|
|
68
|
+
? row
|
|
69
|
+
: failure(ref, "sha256 differs from the ledger row", "update the ledger or restore the file");
|
|
70
|
+
};
|
|
71
|
+
const copyOne = (ref, assetsDir, ledger) => {
|
|
72
|
+
const read = assetsDir === undefined ? failure(ref, "no assetsDir given") : readInside(assetsDir, ref);
|
|
73
|
+
if (!(read instanceof Uint8Array))
|
|
74
|
+
return read;
|
|
75
|
+
const sha256 = sha256Of(read);
|
|
76
|
+
const row = ledgerRow(ref, sha256, ledger);
|
|
77
|
+
if (!("sourceUrl" in row))
|
|
78
|
+
return row;
|
|
79
|
+
const path = `assets/${ref}`;
|
|
80
|
+
return { asset: { path, bytes: freezeBytes(read), sha256 }, licence: { ...row, path } };
|
|
81
|
+
};
|
|
82
|
+
const isCopied = (result) => "asset" in result;
|
|
83
|
+
/** Reads each ref from assetsDir, hashes it and requires a ledger row whose sha256 matches. */
|
|
84
|
+
export const copyAssets = (refs, assetsDir, ledger) => {
|
|
85
|
+
const results = refs.map((ref) => copyOne(ref, assetsDir, ledger));
|
|
86
|
+
const errors = results.filter((result) => !isCopied(result));
|
|
87
|
+
if (errors.length > 0)
|
|
88
|
+
return { ok: false, errors };
|
|
89
|
+
const copied = results.filter(isCopied);
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
assets: copied.map(({ asset }) => asset),
|
|
93
|
+
licences: copied.map(({ licence }) => licence).sort((a, b) => (a.path < b.path ? -1 : 1)),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type MusicIdentity } from "./music.ts";
|
|
2
|
+
/**
|
|
3
|
+
* T-AU-01b approval authority (current-only, pure).
|
|
4
|
+
*
|
|
5
|
+
* The frozen copied bed is the single authority preview and render consume:
|
|
6
|
+
* after the entry gate the CLI copies every narration wav plus the bed wav
|
|
7
|
+
* into one frozen directory and mixes only from those copies (ABA-proof).
|
|
8
|
+
* These helpers decide whether that frozen state still matches the approved
|
|
9
|
+
* identity. Any mismatch means re-preview + re-approve (fail closed, never
|
|
10
|
+
* a silent mix). No timestamps or machine paths enter the identity.
|
|
11
|
+
*/
|
|
12
|
+
/** Diff a previewed identity against the current one; non-empty means reapproval. */
|
|
13
|
+
export declare const needsReapproval: (approved: MusicIdentity, current: MusicIdentity) => string[];
|
|
14
|
+
/**
|
|
15
|
+
* Verify frozen bytes against the pinned identity. Non-empty means refuse to
|
|
16
|
+
* mix (fail closed): a mutated bed sha — including an ABA restore of the
|
|
17
|
+
* mutable source after the freeze — still fails unless the frozen copy
|
|
18
|
+
* consumed by ffmpeg matches the identity. Silent (EMPTY_SHA256) and
|
|
19
|
+
* missing (undefined) frozen beds are never audible proof. Machine identity
|
|
20
|
+
* in paths (absolute/traversal/non-wav) is rejected via the identity check.
|
|
21
|
+
*/
|
|
22
|
+
export declare const verifyFrozenApproval: (identity: MusicIdentity, frozenBedSha256: string | undefined, frozenLicenceSha256: string | null | undefined) => string[];
|
|
23
|
+
/** Throw on any frozen-approval problem (mutation/silent/missing/path → fail). */
|
|
24
|
+
export declare const assertFrozenApproval: (identity: MusicIdentity, frozenBedSha256: string | undefined, frozenLicenceSha256: string | null | undefined) => void;
|
|
25
|
+
/** True only when the current identity matches the approval and frozen bytes match. */
|
|
26
|
+
export declare const isApprovalCurrent: (approved: MusicIdentity, current: MusicIdentity, frozenBedSha256: string | undefined, frozenLicenceSha256: string | null | undefined) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* One frozen copy after the CLI wrote it: what actually landed on disk, and -
|
|
29
|
+
* for a gated run - the digest the approval certified. `expectedSha256` is
|
|
30
|
+
* absent on the ungated path, where there is no approval to compare against.
|
|
31
|
+
*/
|
|
32
|
+
export interface FrozenCopyCheck {
|
|
33
|
+
readonly label: string;
|
|
34
|
+
readonly actualSha256: string | undefined;
|
|
35
|
+
readonly expectedSha256?: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* ADR-0008 D2: the bytes ffmpeg consumes must be the bytes a person approved.
|
|
39
|
+
* Hashing the mutable source after the mix cannot prove that (an ABA edit is
|
|
40
|
+
* restored before the recheck), so the copied file is hashed and compared with
|
|
41
|
+
* the approved digest captured before it was copied. Non-empty means refuse
|
|
42
|
+
* before any external tool runs: missing, silent or mismatched copies are never
|
|
43
|
+
* an approved mix.
|
|
44
|
+
*/
|
|
45
|
+
export declare const frozenCopyProblems: (checks: readonly FrozenCopyCheck[]) => string[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { verifyFrozenMusic } from "./mix.js";
|
|
2
|
+
import { EMPTY_SHA256, verifyMusicIdentity } from "./music.js";
|
|
3
|
+
/**
|
|
4
|
+
* T-AU-01b approval authority (current-only, pure).
|
|
5
|
+
*
|
|
6
|
+
* The frozen copied bed is the single authority preview and render consume:
|
|
7
|
+
* after the entry gate the CLI copies every narration wav plus the bed wav
|
|
8
|
+
* into one frozen directory and mixes only from those copies (ABA-proof).
|
|
9
|
+
* These helpers decide whether that frozen state still matches the approved
|
|
10
|
+
* identity. Any mismatch means re-preview + re-approve (fail closed, never
|
|
11
|
+
* a silent mix). No timestamps or machine paths enter the identity.
|
|
12
|
+
*/
|
|
13
|
+
/** Diff a previewed identity against the current one; non-empty means reapproval. */
|
|
14
|
+
export const needsReapproval = (approved, current) => {
|
|
15
|
+
const problems = [];
|
|
16
|
+
if (current.relativePath !== approved.relativePath) {
|
|
17
|
+
problems.push(`music bed path changed: was "${approved.relativePath}", now "${current.relativePath}"`);
|
|
18
|
+
}
|
|
19
|
+
if (current.sha256 !== approved.sha256) {
|
|
20
|
+
problems.push(`music bed sha256 differs for "${current.relativePath}"`);
|
|
21
|
+
}
|
|
22
|
+
if (current.volume !== approved.volume) {
|
|
23
|
+
problems.push(`music bed volume changed for "${current.relativePath}": was ${approved.volume}, now ${current.volume}`);
|
|
24
|
+
}
|
|
25
|
+
if (current.licenceEvidenceSha256 !== approved.licenceEvidenceSha256) {
|
|
26
|
+
problems.push(`music licence evidence sha256 differs for "${current.relativePath}"`);
|
|
27
|
+
}
|
|
28
|
+
return problems;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Verify frozen bytes against the pinned identity. Non-empty means refuse to
|
|
32
|
+
* mix (fail closed): a mutated bed sha — including an ABA restore of the
|
|
33
|
+
* mutable source after the freeze — still fails unless the frozen copy
|
|
34
|
+
* consumed by ffmpeg matches the identity. Silent (EMPTY_SHA256) and
|
|
35
|
+
* missing (undefined) frozen beds are never audible proof. Machine identity
|
|
36
|
+
* in paths (absolute/traversal/non-wav) is rejected via the identity check.
|
|
37
|
+
*/
|
|
38
|
+
export const verifyFrozenApproval = (identity, frozenBedSha256, frozenLicenceSha256) => {
|
|
39
|
+
if (frozenBedSha256 === undefined) {
|
|
40
|
+
return [`music bed wav unreadable: ${identity.relativePath}`];
|
|
41
|
+
}
|
|
42
|
+
if (frozenBedSha256 === EMPTY_SHA256) {
|
|
43
|
+
return [`music bed wav is silent/empty: ${identity.relativePath}`];
|
|
44
|
+
}
|
|
45
|
+
return verifyFrozenMusic(identity, frozenBedSha256, frozenLicenceSha256);
|
|
46
|
+
};
|
|
47
|
+
/** Throw on any frozen-approval problem (mutation/silent/missing/path → fail). */
|
|
48
|
+
export const assertFrozenApproval = (identity, frozenBedSha256, frozenLicenceSha256) => {
|
|
49
|
+
const problems = verifyFrozenApproval(identity, frozenBedSha256, frozenLicenceSha256);
|
|
50
|
+
if (problems.length > 0) {
|
|
51
|
+
throw new Error(`unapproved music bed: ${problems.join("; ")}`);
|
|
52
|
+
}
|
|
53
|
+
// Belt-and-braces: the identity shape itself must be project-local.
|
|
54
|
+
const shape = verifyMusicIdentity(identity, identity.sha256, identity.licenceEvidenceSha256);
|
|
55
|
+
const bad = shape.filter((problem) => !problem.includes("unreadable"));
|
|
56
|
+
if (bad.length > 0) {
|
|
57
|
+
throw new Error(`invalid music identity: ${bad.join("; ")}`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** True only when the current identity matches the approval and frozen bytes match. */
|
|
61
|
+
export const isApprovalCurrent = (approved, current, frozenBedSha256, frozenLicenceSha256) => needsReapproval(approved, current).length === 0 &&
|
|
62
|
+
verifyFrozenApproval(current, frozenBedSha256, frozenLicenceSha256).length === 0;
|
|
63
|
+
/**
|
|
64
|
+
* ADR-0008 D2: the bytes ffmpeg consumes must be the bytes a person approved.
|
|
65
|
+
* Hashing the mutable source after the mix cannot prove that (an ABA edit is
|
|
66
|
+
* restored before the recheck), so the copied file is hashed and compared with
|
|
67
|
+
* the approved digest captured before it was copied. Non-empty means refuse
|
|
68
|
+
* before any external tool runs: missing, silent or mismatched copies are never
|
|
69
|
+
* an approved mix.
|
|
70
|
+
*/
|
|
71
|
+
export const frozenCopyProblems = (checks) => checks.flatMap((check) => {
|
|
72
|
+
if (check.actualSha256 === undefined) {
|
|
73
|
+
return [`${check.label}: frozen copy missing or unreadable`];
|
|
74
|
+
}
|
|
75
|
+
if (check.actualSha256 === EMPTY_SHA256) {
|
|
76
|
+
return [`${check.label}: frozen copy is silent/empty`];
|
|
77
|
+
}
|
|
78
|
+
if (check.expectedSha256 !== undefined && check.actualSha256 !== check.expectedSha256) {
|
|
79
|
+
return [`${check.label}: frozen copy differs from the approved bytes`];
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
});
|