@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,134 @@
|
|
|
1
|
+
import { DEFAULT_MUSIC_VOLUME as SCHEMA_DEFAULT_MUSIC_VOLUME } from "@anvpx/motion-schema";
|
|
2
|
+
/**
|
|
3
|
+
* Pinned music-bed mix constants (T-AU-01a, current-only).
|
|
4
|
+
*
|
|
5
|
+
* Single measurement source: the qc-lufs pack proof.
|
|
6
|
+
* LUFS target comes from the verified `youtube` platform policy
|
|
7
|
+
* (`lufsTarget: -14`, `truePeakDb: -1`, `verified: true`); the ducked bed
|
|
8
|
+
* depth is the static schema level `DEFAULT_MUSIC_VOLUME = 0.5`, measured
|
|
9
|
+
* on this machine as `20*log10(0.5) = -6.0206 dB`, before the frozen
|
|
10
|
+
* sidechain stage. Tolerances mirror the `qc-lufs` measurement seam
|
|
11
|
+
* (`LOUDNESS_TOLERANCE_LU = 1`, `TRUE_PEAK_TOLERANCE_DB = 0.5`).
|
|
12
|
+
* Nothing here runs ffmpeg; measurement stays in the verify chain (LC-04).
|
|
13
|
+
*/
|
|
14
|
+
/** Ducked bed level, re-exported from the schema single source. */
|
|
15
|
+
export const DEFAULT_MUSIC_VOLUME = SCHEMA_DEFAULT_MUSIC_VOLUME;
|
|
16
|
+
/** Pinned integrated-loudness target for the mixed output (qc-lufs). */
|
|
17
|
+
export const MIX_LUFS_TARGET = -14;
|
|
18
|
+
/** Pinned true-peak ceiling for the mixed output (qc-lufs). */
|
|
19
|
+
export const MIX_TRUE_PEAK_DBTP = -1;
|
|
20
|
+
/** Accepted drift for integrated loudness vs the pinned target. */
|
|
21
|
+
export const MIX_LOUDNESS_TOLERANCE_LU = 1;
|
|
22
|
+
/** Accepted excess for true peak above the pinned ceiling. */
|
|
23
|
+
export const MIX_TRUE_PEAK_TOLERANCE_DB = 0.5;
|
|
24
|
+
/** Static bed gain of the default ducked level, dB (measured receipt). */
|
|
25
|
+
export const DUCKING_DB = -6.0206;
|
|
26
|
+
/** Frozen sidechain stage (pinned by mix.test.ts, qc-lufs §Ducking dB). */
|
|
27
|
+
export const SIDECHAIN_THRESHOLD = 0.03;
|
|
28
|
+
export const SIDECHAIN_RATIO = 4;
|
|
29
|
+
export const SIDECHAIN_ATTACK_MS = 200;
|
|
30
|
+
export const SIDECHAIN_RELEASE_MS = 500;
|
|
31
|
+
/** Only CC0 beds are supported (licence gate, current-only). */
|
|
32
|
+
export const MUSIC_LICENCE = "CC0-1.0";
|
|
33
|
+
/** sha256 of zero bytes: a silent/empty wav can never qualify as a bed. */
|
|
34
|
+
export const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
35
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/i;
|
|
36
|
+
/** Static gain of a bed level in dB; refuses nonsense (fail closed). */
|
|
37
|
+
export const musicGainDb = (volume) => {
|
|
38
|
+
if (!Number.isFinite(volume) || volume <= 0) {
|
|
39
|
+
throw new Error(`invalid music volume ${volume}: must be finite and > 0`);
|
|
40
|
+
}
|
|
41
|
+
return 20 * Math.log10(volume);
|
|
42
|
+
};
|
|
43
|
+
/** True when a level sits at the pinned ducking depth within 0.001 dB. */
|
|
44
|
+
export const isPinnedDuckingDepth = (volume) => {
|
|
45
|
+
if (!Number.isFinite(volume) || volume <= 0 || volume > 1)
|
|
46
|
+
return false;
|
|
47
|
+
return Math.abs(musicGainDb(volume) - DUCKING_DB) < 0.001;
|
|
48
|
+
};
|
|
49
|
+
const PHASE_EPSILON = 1e-9;
|
|
50
|
+
/**
|
|
51
|
+
* Seamless-loop phase math: how many bed repeats cover `totalDuration`
|
|
52
|
+
* seconds and where the trim lands in the bed cycle. `phaseError` is the
|
|
53
|
+
* fractional cycle cut by the trim (0 = trim lands on the loop boundary,
|
|
54
|
+
* seamless); `loops` is `ceil(total / bed)`. Refuses nonsense (fail closed).
|
|
55
|
+
*/
|
|
56
|
+
export const seamlessLoopFor = (bedDuration, totalDuration) => {
|
|
57
|
+
if (!Number.isFinite(bedDuration) || bedDuration <= 0) {
|
|
58
|
+
throw new Error(`invalid bed duration ${bedDuration}: must be finite and > 0`);
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isFinite(totalDuration) || totalDuration <= 0) {
|
|
61
|
+
throw new Error(`invalid total duration ${totalDuration}: must be finite and > 0`);
|
|
62
|
+
}
|
|
63
|
+
const loops = Math.ceil(totalDuration / bedDuration);
|
|
64
|
+
const remainder = totalDuration % bedDuration;
|
|
65
|
+
const phaseError = remainder < PHASE_EPSILON || bedDuration - remainder < PHASE_EPSILON
|
|
66
|
+
? 0
|
|
67
|
+
: remainder / bedDuration;
|
|
68
|
+
return {
|
|
69
|
+
loops,
|
|
70
|
+
trimDuration: totalDuration,
|
|
71
|
+
phaseError,
|
|
72
|
+
isSeamless: phaseError === 0,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const isLocalWavRef = (ref) => ref.length > 0 &&
|
|
76
|
+
!ref.includes("\\") &&
|
|
77
|
+
!ref.startsWith("/") &&
|
|
78
|
+
!ref.startsWith("../") &&
|
|
79
|
+
!ref.includes("/../") &&
|
|
80
|
+
!/^[a-zA-Z]:[\\/]/.test(ref) &&
|
|
81
|
+
ref.endsWith(".wav") &&
|
|
82
|
+
!ref.includes("..");
|
|
83
|
+
/**
|
|
84
|
+
* Fail-closed identity check (current-only): malformed identity fields,
|
|
85
|
+
* a missing/unreadable bed digest, a mutated bed sha, or a drifted licence
|
|
86
|
+
* receipt each yield a problem. Empty means the frozen bytes match the
|
|
87
|
+
* identity. An ABA restore (mutate then restore the source) still passes
|
|
88
|
+
* only when the frozen copy consumed by the mix matches — the check reads
|
|
89
|
+
* the frozen digest, never the mutable source.
|
|
90
|
+
*/
|
|
91
|
+
export const verifyMusicIdentity = (identity, actualBedSha256, actualLicenceSha256) => {
|
|
92
|
+
const problems = [];
|
|
93
|
+
if (!isLocalWavRef(identity.relativePath)) {
|
|
94
|
+
problems.push(`music bed "${identity.relativePath}" must be a project-local .wav`);
|
|
95
|
+
}
|
|
96
|
+
if (!SHA256_HEX.test(identity.sha256)) {
|
|
97
|
+
problems.push("music bed sha256 must be 64 hex characters");
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isFinite(identity.volume) || identity.volume <= 0 || identity.volume > 1) {
|
|
100
|
+
problems.push("music bed volume must be within (0, 1]");
|
|
101
|
+
}
|
|
102
|
+
if (identity.licenceEvidenceSha256 !== null && !SHA256_HEX.test(identity.licenceEvidenceSha256)) {
|
|
103
|
+
problems.push("music licence evidence sha256 must be 64 hex characters or null");
|
|
104
|
+
}
|
|
105
|
+
if (actualBedSha256 === undefined) {
|
|
106
|
+
problems.push(`music bed wav unreadable: ${identity.relativePath}`);
|
|
107
|
+
}
|
|
108
|
+
else if (actualBedSha256 !== identity.sha256) {
|
|
109
|
+
problems.push(`music bed sha256 differs for "${identity.relativePath}"`);
|
|
110
|
+
}
|
|
111
|
+
if (identity.licenceEvidenceSha256 !== null &&
|
|
112
|
+
actualLicenceSha256 !== identity.licenceEvidenceSha256) {
|
|
113
|
+
problems.push(`music licence evidence sha256 differs for "${identity.relativePath}"`);
|
|
114
|
+
}
|
|
115
|
+
return problems;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Judges one integrated-loudness + true-peak measurement against the pinned
|
|
119
|
+
* qc-lufs constants. Mirrors the verify-chain tolerances; measurement itself
|
|
120
|
+
* stays in the CLI verify seam (LC-04).
|
|
121
|
+
*/
|
|
122
|
+
export const assessMixLoudness = (integratedLufs, truePeakDb) => {
|
|
123
|
+
const problems = [];
|
|
124
|
+
if (!Number.isFinite(integratedLufs) || !Number.isFinite(truePeakDb)) {
|
|
125
|
+
return { ok: false, problems: ["loudness measurement is non-finite"] };
|
|
126
|
+
}
|
|
127
|
+
if (Math.abs(integratedLufs - MIX_LUFS_TARGET) > MIX_LOUDNESS_TOLERANCE_LU) {
|
|
128
|
+
problems.push(`integrated ${integratedLufs} LUFS differs from pinned target ${MIX_LUFS_TARGET} LUFS by more than ${MIX_LOUDNESS_TOLERANCE_LU} LU`);
|
|
129
|
+
}
|
|
130
|
+
if (truePeakDb - MIX_TRUE_PEAK_DBTP > MIX_TRUE_PEAK_TOLERANCE_DB) {
|
|
131
|
+
problems.push(`true peak ${truePeakDb} dBTP exceeds pinned ceiling ${MIX_TRUE_PEAK_DBTP} dBTP`);
|
|
132
|
+
}
|
|
133
|
+
return { ok: problems.length === 0, problems };
|
|
134
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type MixClip, type MusicBed } from "./mix.ts";
|
|
2
|
+
import type { MusicIdentity } from "./music.ts";
|
|
3
|
+
/**
|
|
4
|
+
* T-AU-01b preview authority (current-only, pure).
|
|
5
|
+
*
|
|
6
|
+
* The audible preview mixes the exact final bytes with the exact final
|
|
7
|
+
* params: the same frozen layout (`frozenClips`/`frozenMusic`) and the same
|
|
8
|
+
* ducked `buildMusicMixArgs` graph as the final render (no second pipeline).
|
|
9
|
+
* Any bed/volume/receipt change after preview makes the preview stale
|
|
10
|
+
* (`isPreviewCurrent` false → re-preview + re-approve). Mutated, silent, or
|
|
11
|
+
* missing frozen bytes fail closed; no machine identity enters paths.
|
|
12
|
+
*/
|
|
13
|
+
/** Frozen preview inputs in the final frozen layout (role- and ordinal-scoped names). */
|
|
14
|
+
export declare const freezePreviewInputs: (clips: readonly MixClip[], music: MusicBed | undefined, frozenDir: string) => {
|
|
15
|
+
clips: MixClip[];
|
|
16
|
+
music: MusicBed | undefined;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Preview mix argv with the same normalized params as the final render.
|
|
20
|
+
* Same wrapper, same graph: preview and final diverge only in the out path.
|
|
21
|
+
*/
|
|
22
|
+
export declare const buildPreviewMixArgs: (videoIn: string, clips: readonly MixClip[], music: MusicBed, out: string) => string[];
|
|
23
|
+
/** Narration-only preview argv, same as the final narration mix. */
|
|
24
|
+
export declare const buildPreviewNarrationArgs: (videoIn: string, clips: readonly MixClip[], out: string) => string[];
|
|
25
|
+
/** True only when the previewed bed identity still matches the current one. */
|
|
26
|
+
export declare const isPreviewCurrent: (previewed: MusicIdentity, current: MusicIdentity) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Fail closed on preview inputs: mutated/silent/missing frozen bytes or
|
|
29
|
+
* machine-identity paths yield problems (never an audible approval).
|
|
30
|
+
*/
|
|
31
|
+
export declare const verifyPreviewInputs: (identity: MusicIdentity, frozenBedSha256: string | undefined, frozenLicenceSha256: string | null | undefined) => string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { needsReapproval, verifyFrozenApproval } from "./approval.js";
|
|
2
|
+
import { buildMixArgs, buildMusicMixArgs, frozenClips, frozenMusic, } from "./mix.js";
|
|
3
|
+
/**
|
|
4
|
+
* T-AU-01b preview authority (current-only, pure).
|
|
5
|
+
*
|
|
6
|
+
* The audible preview mixes the exact final bytes with the exact final
|
|
7
|
+
* params: the same frozen layout (`frozenClips`/`frozenMusic`) and the same
|
|
8
|
+
* ducked `buildMusicMixArgs` graph as the final render (no second pipeline).
|
|
9
|
+
* Any bed/volume/receipt change after preview makes the preview stale
|
|
10
|
+
* (`isPreviewCurrent` false → re-preview + re-approve). Mutated, silent, or
|
|
11
|
+
* missing frozen bytes fail closed; no machine identity enters paths.
|
|
12
|
+
*/
|
|
13
|
+
/** Frozen preview inputs in the final frozen layout (role- and ordinal-scoped names). */
|
|
14
|
+
export const freezePreviewInputs = (clips, music, frozenDir) => ({
|
|
15
|
+
clips: frozenClips(clips, frozenDir),
|
|
16
|
+
music: music === undefined ? undefined : frozenMusic(music, frozenDir),
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* Preview mix argv with the same normalized params as the final render.
|
|
20
|
+
* Same wrapper, same graph: preview and final diverge only in the out path.
|
|
21
|
+
*/
|
|
22
|
+
export const buildPreviewMixArgs = (videoIn, clips, music, out) => buildMusicMixArgs(videoIn, clips, music, out);
|
|
23
|
+
/** Narration-only preview argv, same as the final narration mix. */
|
|
24
|
+
export const buildPreviewNarrationArgs = (videoIn, clips, out) => buildMixArgs(videoIn, clips, out);
|
|
25
|
+
/** True only when the previewed bed identity still matches the current one. */
|
|
26
|
+
export const isPreviewCurrent = (previewed, current) => needsReapproval(previewed, current).length === 0;
|
|
27
|
+
/**
|
|
28
|
+
* Fail closed on preview inputs: mutated/silent/missing frozen bytes or
|
|
29
|
+
* machine-identity paths yield problems (never an audible approval).
|
|
30
|
+
*/
|
|
31
|
+
export const verifyPreviewInputs = (identity, frozenBedSha256, frozenLicenceSha256) => verifyFrozenApproval(identity, frozenBedSha256, frozenLicenceSha256);
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type CustomSceneSource } from "@anvpx/motion-scenes";
|
|
2
|
+
import type { AssetLedger, AssetRow, ResolvedTheme, TimingManifest, ValidationError } from "@anvpx/motion-schema";
|
|
3
|
+
import { type AudioManifestRef } from "./audio/mix.ts";
|
|
4
|
+
import { type CacheStats } from "./cache.ts";
|
|
5
|
+
import { type LintFinding, type PlatformMarginLookup } from "./lint/index.ts";
|
|
6
|
+
import { type BuildManifest } from "./manifest.ts";
|
|
7
|
+
export interface BuildOptions {
|
|
8
|
+
/** Parsed storyboard JSON (validated here). */
|
|
9
|
+
readonly storyboard: unknown;
|
|
10
|
+
readonly outDir: string;
|
|
11
|
+
readonly assetsDir?: string;
|
|
12
|
+
readonly ledger?: AssetLedger;
|
|
13
|
+
/** Voice timing; overrides scene durations by id. */
|
|
14
|
+
readonly timing?: TimingManifest;
|
|
15
|
+
/**
|
|
16
|
+
* Opt-in narration mixdown input: the voice TimingManifest plus the directory
|
|
17
|
+
* holding its `<scene-id>.wav` files. Absent means a silent build (no behavior
|
|
18
|
+
* change). Present but unmixable (no scenes, missing wav) fails the build
|
|
19
|
+
* closed — before the slow emit — instead of rendering silent video.
|
|
20
|
+
*/
|
|
21
|
+
readonly audioManifest?: AudioManifestRef;
|
|
22
|
+
/** Project theme documents by name (vendored DTCG files), resolved beside the built-in themes. */
|
|
23
|
+
readonly themes?: Readonly<Record<string, unknown>>;
|
|
24
|
+
/** Enables the per-scene incremental cache stored in this directory. */
|
|
25
|
+
readonly cacheDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Opt-in workspace-level sharing for the disk cache (see `DiskCacheOptions.sharedDir`).
|
|
28
|
+
* Absent means per-storyboard only; never enabled by default.
|
|
29
|
+
*/
|
|
30
|
+
readonly sharedCacheDir?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Run the lint rules (default true). Only for derived storyboards that are not authored work,
|
|
33
|
+
* such as preview clips whose scenes are cut short; schema and timing validation always run.
|
|
34
|
+
*/
|
|
35
|
+
readonly lint?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Platform margins by id (`meta.platform`): scene padding is raised to the platform UI bands and the
|
|
38
|
+
* `safe-zone-9x16` lint runs. Without it a platform tag changes nothing.
|
|
39
|
+
*/
|
|
40
|
+
readonly platforms?: PlatformMarginLookup;
|
|
41
|
+
/**
|
|
42
|
+
* Authored scene sources, keyed by scene id, already read from disk by the caller.
|
|
43
|
+
* `build` stays pure: it never resolves `props.dir` itself. A `custom` entry without
|
|
44
|
+
* a source here fails the build instead of emitting an empty scene.
|
|
45
|
+
*/
|
|
46
|
+
readonly customScenes?: Readonly<Record<string, CustomSceneSource>>;
|
|
47
|
+
}
|
|
48
|
+
export type BuildResult = {
|
|
49
|
+
readonly ok: true;
|
|
50
|
+
readonly outDir: string;
|
|
51
|
+
readonly files: readonly string[];
|
|
52
|
+
readonly manifest: BuildManifest;
|
|
53
|
+
/** Licence rows for every font, inlined icon and copied image; also written to `licences.json`. */
|
|
54
|
+
readonly licences: readonly AssetRow[];
|
|
55
|
+
readonly warnings: readonly LintFinding[];
|
|
56
|
+
/** Present only for cached builds; never written to the manifest. */
|
|
57
|
+
readonly cache?: CacheStats;
|
|
58
|
+
} | {
|
|
59
|
+
readonly ok: false;
|
|
60
|
+
readonly errors: readonly ValidationError[];
|
|
61
|
+
};
|
|
62
|
+
/** A theme by name, or undefined when it is unknown (callers that only lint skip theme rules). */
|
|
63
|
+
export declare const tryResolveTheme: (name: string, docs?: BuildOptions["themes"]) => ResolvedTheme | undefined;
|
|
64
|
+
/** storyboard -> validate -> theme -> timing -> lint -> assets -> emit -> HyperFrames project. */
|
|
65
|
+
export declare const buildProject: (options: BuildOptions) => BuildResult;
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CUSTOM_SCENE_TYPE, collectIcons, emitCustomScene, getScene, iconLedgerRows, LUCIDE_LICENCE_TEXT, runtimeFiles, sceneWords, } from "@anvpx/motion-scenes";
|
|
4
|
+
import { canonicalJson } from "@anvpx/motion-schema";
|
|
5
|
+
import { bundledThemeDocs, resolveGrades, resolveTheme, toCssVars, toGradeCssVars, } from "@anvpx/motion-themes";
|
|
6
|
+
import { copyAssets, createAssetCollector, sha256Of } from "./assets.js";
|
|
7
|
+
import { clipsFromManifest, missingClips } from "./audio/mix.js";
|
|
8
|
+
import { createCachePlan, emitCached } from "./cache.js";
|
|
9
|
+
import { renderBoot } from "./emit/boot.js";
|
|
10
|
+
import { assertInertFragment } from "./emit/fragment-guard.js";
|
|
11
|
+
import { renderIndexHtml } from "./emit/index-html.js";
|
|
12
|
+
import { renderImportMap, vendorFiles, vendorLibs } from "./emit/vendor.js";
|
|
13
|
+
import { bundleFonts, fontFamiliesFor } from "./fonts.js";
|
|
14
|
+
import { lintStoryboard, } from "./lint/index.js";
|
|
15
|
+
import { createManifest, renderManifest, } from "./manifest.js";
|
|
16
|
+
import { GRADE_REVIEW_PATH, renderGradeReview } from "./review/review.js";
|
|
17
|
+
import { resolveTimeline } from "./timing.js";
|
|
18
|
+
import { validateStoryboardFull } from "./validate-full.js";
|
|
19
|
+
import { readVersions } from "./versions.js";
|
|
20
|
+
const fail = (path, message) => ({
|
|
21
|
+
ok: false,
|
|
22
|
+
errors: [{ path, message }],
|
|
23
|
+
});
|
|
24
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
25
|
+
const themeOf = (name, docs) => {
|
|
26
|
+
try {
|
|
27
|
+
return { ok: true, value: resolveTheme(name, { ...bundledThemeDocs(), ...docs }) };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return fail("/meta/theme", messageOf(error));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** A theme by name, or undefined when it is unknown (callers that only lint skip theme rules). */
|
|
34
|
+
export const tryResolveTheme = (name, docs) => {
|
|
35
|
+
const found = themeOf(name, docs);
|
|
36
|
+
return found.ok ? found.value : undefined;
|
|
37
|
+
};
|
|
38
|
+
/** Base scenes in order, then overlays in declared order (later in the page = higher z-order). */
|
|
39
|
+
const placementsOf = (timeline) => [
|
|
40
|
+
...timeline.scenes.map((placed, position) => ({
|
|
41
|
+
placed,
|
|
42
|
+
final: position === timeline.scenes.length - 1,
|
|
43
|
+
})),
|
|
44
|
+
...timeline.overlays.map((overlay) => ({
|
|
45
|
+
placed: { ...overlay, overlap: 0 },
|
|
46
|
+
final: overlay.end >= timeline.total,
|
|
47
|
+
})),
|
|
48
|
+
];
|
|
49
|
+
const insetsOf = (board, platforms) => {
|
|
50
|
+
const { platform } = board.meta;
|
|
51
|
+
const insets = platform === undefined ? undefined : platforms?.(platform)?.safeZones;
|
|
52
|
+
return insets === undefined ? {} : { insets };
|
|
53
|
+
};
|
|
54
|
+
const contextOf = (board, placed, total, theme, assets, platforms) => ({
|
|
55
|
+
theme,
|
|
56
|
+
...insetsOf(board, platforms),
|
|
57
|
+
fps: board.meta.fps,
|
|
58
|
+
width: board.meta.width,
|
|
59
|
+
height: board.meta.height,
|
|
60
|
+
duration: placed.duration,
|
|
61
|
+
timeline: { start: placed.start, total },
|
|
62
|
+
assetResolver: assets.resolver,
|
|
63
|
+
});
|
|
64
|
+
/** Runs an emit and appends the icon names it drew to `icons`. */
|
|
65
|
+
const drawn = (icons, run) => {
|
|
66
|
+
const result = collectIcons(run);
|
|
67
|
+
icons.push(...result.icons);
|
|
68
|
+
return result.value;
|
|
69
|
+
};
|
|
70
|
+
const emitScene = (entry, { placed, final }, ctx, plan, customScenes) => {
|
|
71
|
+
const source = entry.type === CUSTOM_SCENE_TYPE ? customScenes?.[entry.id] : undefined;
|
|
72
|
+
if (entry.type === CUSTOM_SCENE_TYPE && source === undefined) {
|
|
73
|
+
return fail(`/scenes/${placed.index}`, `custom scene "${entry.id}" has no source; keep its directory beside the storyboard and set props.dir`);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const icons = [];
|
|
77
|
+
const emit = (context) => source === undefined
|
|
78
|
+
? getScene(entry.type).emit(entry, context)
|
|
79
|
+
: emitCustomScene(entry, context, source);
|
|
80
|
+
// Authored bytes are not in the cache key, so a cached hit could serve a stale scene.
|
|
81
|
+
const scenePlan = source === undefined ? plan : undefined;
|
|
82
|
+
const files = scenePlan
|
|
83
|
+
? emitCached(scenePlan, entry, ctx, emit, (names) => icons.push(...names))
|
|
84
|
+
: drawn(icons, () => emit(ctx));
|
|
85
|
+
const config = {
|
|
86
|
+
...(entry.ease === undefined ? {} : { ease: entry.ease }),
|
|
87
|
+
...(entry.move === undefined ? {} : { move: entry.move }),
|
|
88
|
+
...(entry.focus === undefined ? {} : { focus: entry.focus }),
|
|
89
|
+
};
|
|
90
|
+
const fragment = files.get(`scenes/${entry.id}/fragment.html`) ?? "";
|
|
91
|
+
assertInertFragment(entry.id, fragment);
|
|
92
|
+
const { id, start, duration, index } = placed;
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
value: {
|
|
96
|
+
page: { id, start, duration, index, fragment },
|
|
97
|
+
boot: { timed: placed, final, config },
|
|
98
|
+
files,
|
|
99
|
+
icons,
|
|
100
|
+
vendor: source === undefined ? (getScene(entry.type).vendor ?? []) : (source.vendor ?? []),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return fail(`/scenes/${placed.index}`, messageOf(error));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const emitAll = (board, timeline, theme, assets, plan, platforms, customScenes) => {
|
|
109
|
+
const emitted = [];
|
|
110
|
+
const errors = [];
|
|
111
|
+
for (const placement of placementsOf(timeline)) {
|
|
112
|
+
const entry = board.scenes[placement.placed.index];
|
|
113
|
+
if (!entry)
|
|
114
|
+
continue;
|
|
115
|
+
const ctx = contextOf(board, placement.placed, timeline.total, theme, assets, platforms);
|
|
116
|
+
const result = emitScene(entry, placement, ctx, plan, customScenes);
|
|
117
|
+
if (result.ok)
|
|
118
|
+
emitted.push(result.value);
|
|
119
|
+
else
|
|
120
|
+
errors.push(...result.errors);
|
|
121
|
+
}
|
|
122
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true, value: emitted };
|
|
123
|
+
};
|
|
124
|
+
const assemble = (board, timeline, theme, emitted, assetFiles, fonts) => {
|
|
125
|
+
const files = new Map();
|
|
126
|
+
const themeCss = toCssVars(theme);
|
|
127
|
+
const grades = resolveGrades();
|
|
128
|
+
const gradeCss = toGradeCssVars(grades);
|
|
129
|
+
const libs = vendorLibs(emitted.flatMap((scene) => scene.vendor));
|
|
130
|
+
files.set("index.html", renderIndexHtml({
|
|
131
|
+
meta: board.meta,
|
|
132
|
+
total: timeline.total,
|
|
133
|
+
themeCss,
|
|
134
|
+
scenes: emitted.map((scene) => scene.page),
|
|
135
|
+
importMap: renderImportMap(libs),
|
|
136
|
+
}));
|
|
137
|
+
files.set("boot.js", renderBoot(emitted.map((scene) => scene.boot), board.meta.fps));
|
|
138
|
+
for (const [path, text] of runtimeFiles())
|
|
139
|
+
files.set(path, text);
|
|
140
|
+
for (const [path, bytes] of vendorFiles(libs))
|
|
141
|
+
files.set(path, bytes);
|
|
142
|
+
for (const scene of emitted)
|
|
143
|
+
for (const [path, text] of scene.files)
|
|
144
|
+
files.set(path, text);
|
|
145
|
+
for (const [path, bytes] of assetFiles)
|
|
146
|
+
files.set(path, bytes);
|
|
147
|
+
for (const [path, bytes] of fonts)
|
|
148
|
+
files.set(path, bytes);
|
|
149
|
+
files.set(GRADE_REVIEW_PATH, renderGradeReview({ themeName: theme.name, themeCss, gradeCss, grades }));
|
|
150
|
+
return files;
|
|
151
|
+
};
|
|
152
|
+
const writeFiles = (outDir, files) => {
|
|
153
|
+
for (const [path, data] of files) {
|
|
154
|
+
const target = join(outDir, path);
|
|
155
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
156
|
+
writeFileSync(target, data);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const toErrors = (findings) => findings.map(({ path, message, suggestion }) => suggestion === undefined ? { path, message } : { path, message, suggestion });
|
|
160
|
+
const runLint = (options, board, timeline, theme) => {
|
|
161
|
+
if (options.lint === false)
|
|
162
|
+
return { errors: [], warnings: [] };
|
|
163
|
+
const words = board.scenes.map((scene) => sceneWords(scene.props, scene.type));
|
|
164
|
+
return lintStoryboard({
|
|
165
|
+
storyboard: board,
|
|
166
|
+
timeline,
|
|
167
|
+
words,
|
|
168
|
+
theme,
|
|
169
|
+
...(options.platforms === undefined ? {} : { platforms: options.platforms }),
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
const cachePlanOf = (cacheDir, theme, assetsDir, sharedCacheDir) => {
|
|
173
|
+
if (cacheDir === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
return createCachePlan(cacheDir, theme, assetsDir, sharedCacheDir === undefined ? undefined : { sharedDir: sharedCacheDir });
|
|
176
|
+
};
|
|
177
|
+
/** Fail-closed gate for the opt-in narration mixdown; absent means silent. */
|
|
178
|
+
const checkAudioManifest = (ref) => {
|
|
179
|
+
if (ref === undefined)
|
|
180
|
+
return { ok: true, value: undefined };
|
|
181
|
+
const clips = clipsFromManifest(ref.manifest, ref.audioDir);
|
|
182
|
+
if (clips.length === 0)
|
|
183
|
+
return fail("/audio", "audio manifest has no scenes; nothing to mix");
|
|
184
|
+
const missing = missingClips(clips, (path) => existsSync(path));
|
|
185
|
+
const first = missing[0];
|
|
186
|
+
if (first !== undefined) {
|
|
187
|
+
return fail(`/audio/${first.id}`, `missing narration wav for scene "${first.id}": ${first.wav}`);
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, value: undefined };
|
|
190
|
+
};
|
|
191
|
+
const compile = (options) => {
|
|
192
|
+
const valid = validateStoryboardFull(options.storyboard);
|
|
193
|
+
if (!valid.ok)
|
|
194
|
+
return valid;
|
|
195
|
+
const board = valid.value;
|
|
196
|
+
const theme = themeOf(board.meta.theme, options.themes);
|
|
197
|
+
if (!theme.ok)
|
|
198
|
+
return theme;
|
|
199
|
+
const timeline = resolveTimeline(board, options.timing);
|
|
200
|
+
if (!timeline.ok)
|
|
201
|
+
return timeline;
|
|
202
|
+
const audio = checkAudioManifest(options.audioManifest);
|
|
203
|
+
if (!audio.ok)
|
|
204
|
+
return audio;
|
|
205
|
+
const lint = runLint(options, board, timeline.value, theme.value);
|
|
206
|
+
if (lint.errors.length > 0)
|
|
207
|
+
return { ok: false, errors: toErrors(lint.errors) };
|
|
208
|
+
const assets = createAssetCollector(options.assetsDir !== undefined);
|
|
209
|
+
const plan = cachePlanOf(options.cacheDir, theme.value, options.assetsDir, options.sharedCacheDir);
|
|
210
|
+
const emitted = emitAll(board, timeline.value, theme.value, assets, plan, options.platforms, options.customScenes);
|
|
211
|
+
if (!emitted.ok)
|
|
212
|
+
return emitted;
|
|
213
|
+
const copied = copyAssets(assets.refs(), options.assetsDir, options.ledger);
|
|
214
|
+
if (!copied.ok)
|
|
215
|
+
return copied;
|
|
216
|
+
const assetFiles = new Map(copied.assets.map((asset) => [asset.path, asset.bytes]));
|
|
217
|
+
const fonts = bundleFonts(fontFamiliesFor(theme.value.fonts));
|
|
218
|
+
const icons = [...new Set(emitted.value.flatMap((scene) => scene.icons))].sort();
|
|
219
|
+
const licences = [...fonts.licences, ...iconLedgerRows(icons), ...copied.licences];
|
|
220
|
+
const files = assemble(board, timeline.value, theme.value, emitted.value, assetFiles, fonts.files);
|
|
221
|
+
if (icons.length > 0)
|
|
222
|
+
files.set("LICENSE-lucide.txt", LUCIDE_LICENCE_TEXT);
|
|
223
|
+
files.set("licences.json", `${JSON.stringify(licences, null, 2)}
|
|
224
|
+
`);
|
|
225
|
+
const manifest = createManifest(JSON.stringify(board), files, readVersions(), {
|
|
226
|
+
themeHash: sha256Of(canonicalJson(theme.value)),
|
|
227
|
+
ledgerSha256: options.ledger === undefined ? null : sha256Of(canonicalJson(options.ledger)),
|
|
228
|
+
sceneTypes: board.scenes.map((scene) => scene.type),
|
|
229
|
+
icons,
|
|
230
|
+
overlays: timeline.value.overlays.map(({ id, over, start, end }) => ({ id, over, start, end })),
|
|
231
|
+
});
|
|
232
|
+
files.set("manifest.json", renderManifest(manifest));
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
value: {
|
|
236
|
+
files,
|
|
237
|
+
manifest,
|
|
238
|
+
licences,
|
|
239
|
+
warnings: lint.warnings,
|
|
240
|
+
...(plan ? { cache: plan.stats } : {}),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
/** storyboard -> validate -> theme -> timing -> lint -> assets -> emit -> HyperFrames project. */
|
|
245
|
+
export const buildProject = (options) => {
|
|
246
|
+
const compiled = compile(options);
|
|
247
|
+
if (!compiled.ok)
|
|
248
|
+
return compiled;
|
|
249
|
+
const { files, manifest, licences, warnings, cache } = compiled.value;
|
|
250
|
+
writeFiles(options.outDir, files);
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
outDir: options.outDir,
|
|
254
|
+
files: [...files.keys()].sort(),
|
|
255
|
+
manifest,
|
|
256
|
+
licences,
|
|
257
|
+
warnings,
|
|
258
|
+
...(cache ? { cache } : {}),
|
|
259
|
+
};
|
|
260
|
+
};
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EmitContext, ResolvedTheme, SceneEntry } from "@anvpx/motion-schema";
|
|
2
|
+
/** Everything outside the scene itself that can change what a plugin emits. */
|
|
3
|
+
export interface CacheSalt {
|
|
4
|
+
readonly themeHash: string;
|
|
5
|
+
readonly packages: Readonly<Record<string, string>>;
|
|
6
|
+
readonly hyperframes: string;
|
|
7
|
+
readonly nodeMajor: string;
|
|
8
|
+
/** Hash of the scenes package source: editing a plugin without a version bump invalidates. */
|
|
9
|
+
readonly scenesSource: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CacheStats {
|
|
12
|
+
hits: number;
|
|
13
|
+
misses: number;
|
|
14
|
+
}
|
|
15
|
+
type Placement = Pick<EmitContext, "fps" | "width" | "height" | "duration" | "timeline" | "insets">;
|
|
16
|
+
/** Emitted per-scene files plus the asset refs and icon names the plugin requested. */
|
|
17
|
+
export interface CachedScene {
|
|
18
|
+
readonly files: Map<string, string>;
|
|
19
|
+
readonly refs: readonly string[];
|
|
20
|
+
readonly icons: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
type HashAsset = (ref: string) => string;
|
|
23
|
+
export interface SceneCache {
|
|
24
|
+
load(base: string, hash: HashAsset, prefix: string): CachedScene | undefined;
|
|
25
|
+
save(base: string, refs: readonly string[], hash: HashAsset, files: ReadonlyMap<string, string>, icons: readonly string[]): void;
|
|
26
|
+
}
|
|
27
|
+
export interface CachePlan {
|
|
28
|
+
readonly cache: SceneCache;
|
|
29
|
+
readonly salt: CacheSalt;
|
|
30
|
+
readonly hashAsset: HashAsset;
|
|
31
|
+
readonly stats: CacheStats;
|
|
32
|
+
}
|
|
33
|
+
/** Key of a scene before its assets are known: type, config (incl. overlay `over`/window), placement, salt. */
|
|
34
|
+
export declare const baseKey: (entry: SceneEntry, ctx: Placement, salt: CacheSalt) => string;
|
|
35
|
+
/** Final key: the base key plus the content hash of every asset the scene references. */
|
|
36
|
+
export declare const fullKey: (base: string, assets: Readonly<Record<string, string>>) => string;
|
|
37
|
+
/** Opt-in workspace-level sharing for the disk cache. Absent means per-storyboard only. */
|
|
38
|
+
export interface DiskCacheOptions {
|
|
39
|
+
/** Directory of content-addressed entries shared across storyboards; best-effort on failure. */
|
|
40
|
+
readonly sharedDir?: string;
|
|
41
|
+
/** Largest single shared entry file accepted on read; a larger file is a miss. */
|
|
42
|
+
readonly maxSharedEntryBytes?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Disk-backed cache; every failure degrades to a miss or a skipped store, never an error.
|
|
46
|
+
* With `sharedDir` set, entries are content-addressed (same salt plus config plus asset
|
|
47
|
+
* hashes yields the same keys), so two storyboards sharing a scene share its entries: load
|
|
48
|
+
* falls back to the shared dir and promotes hits locally, save writes through to both.
|
|
49
|
+
*/
|
|
50
|
+
export declare const createDiskCache: (dir: string, opts?: DiskCacheOptions) => SceneCache;
|
|
51
|
+
/** Deletes cache files only (hex-named `.json`/`.refs` and their tmp files); returns how many. */
|
|
52
|
+
export declare const clearCache: (dir: string) => number;
|
|
53
|
+
export declare const createCachePlan: (dir: string, theme: ResolvedTheme, assetsDir: string | undefined, opts?: DiskCacheOptions) => CachePlan;
|
|
54
|
+
/**
|
|
55
|
+
* Replays a cached scene (re-registering its asset refs) or emits and stores it. The icon names the
|
|
56
|
+
* scene drew go to `onIcons` either way, so a cache hit still reaches the licence ledger.
|
|
57
|
+
*/
|
|
58
|
+
export declare const emitCached: (plan: CachePlan, entry: SceneEntry, ctx: EmitContext, emit: (ctx: EmitContext) => Map<string, string>, onIcons?: (icons: readonly string[]) => void) => Map<string, string>;
|
|
59
|
+
export {};
|