@anvpx/motion-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -2
  3. package/dist/analyze/command.d.ts +2 -0
  4. package/dist/analyze/command.js +190 -0
  5. package/dist/approval/approval.d.ts +32 -0
  6. package/dist/approval/approval.js +141 -0
  7. package/dist/approval/command.d.ts +2 -0
  8. package/dist/approval/command.js +255 -0
  9. package/dist/approval/gate.d.ts +7 -0
  10. package/dist/approval/gate.js +23 -0
  11. package/dist/approval/hash.d.ts +71 -0
  12. package/dist/approval/hash.js +451 -0
  13. package/dist/args.d.ts +30 -0
  14. package/dist/args.js +53 -0
  15. package/dist/assets/command.d.ts +2 -0
  16. package/dist/assets/command.js +165 -0
  17. package/dist/captions/command.d.ts +2 -0
  18. package/dist/captions/command.js +341 -0
  19. package/dist/captions/cues.d.ts +10 -0
  20. package/dist/captions/cues.js +61 -0
  21. package/dist/catalog/command.d.ts +2 -0
  22. package/dist/catalog/command.js +60 -0
  23. package/dist/catalog/format.d.ts +13 -0
  24. package/dist/catalog/format.js +54 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +35 -0
  27. package/dist/commands.d.ts +3 -0
  28. package/dist/commands.js +59 -0
  29. package/dist/compare/command.d.ts +4 -0
  30. package/dist/compare/command.js +144 -0
  31. package/dist/contact/argv.d.ts +24 -0
  32. package/dist/contact/argv.js +42 -0
  33. package/dist/contact/command.d.ts +2 -0
  34. package/dist/contact/command.js +79 -0
  35. package/dist/doctor/checks.d.ts +25 -0
  36. package/dist/doctor/checks.js +571 -0
  37. package/dist/doctor/command.d.ts +4 -0
  38. package/dist/doctor/command.js +88 -0
  39. package/dist/doctor/format.d.ts +7 -0
  40. package/dist/doctor/format.js +41 -0
  41. package/dist/doctor/heal.d.ts +14 -0
  42. package/dist/doctor/heal.js +71 -0
  43. package/dist/doctor/pins.d.ts +8 -0
  44. package/dist/doctor/pins.js +28 -0
  45. package/dist/doctor/skills.d.ts +15 -0
  46. package/dist/doctor/skills.js +58 -0
  47. package/dist/doctor/types.d.ts +36 -0
  48. package/dist/doctor/types.js +1 -0
  49. package/dist/doctor/vendored.d.ts +15 -0
  50. package/dist/doctor/vendored.js +90 -0
  51. package/dist/doctor/version.d.ts +8 -0
  52. package/dist/doctor/version.js +11 -0
  53. package/dist/exit-codes.d.ts +16 -0
  54. package/dist/exit-codes.js +15 -0
  55. package/dist/fit/command.d.ts +5 -0
  56. package/dist/fit/command.js +154 -0
  57. package/dist/fit/solve.d.ts +21 -0
  58. package/dist/fit/solve.js +80 -0
  59. package/dist/fit/target.d.ts +18 -0
  60. package/dist/fit/target.js +47 -0
  61. package/dist/generate3d/command.d.ts +3 -0
  62. package/dist/generate3d/command.js +38 -0
  63. package/dist/generate3d/run.d.ts +39 -0
  64. package/dist/generate3d/run.js +77 -0
  65. package/dist/icon/command.d.ts +2 -0
  66. package/dist/icon/command.js +113 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +16 -0
  69. package/dist/install-mk.d.ts +31 -0
  70. package/dist/install-mk.js +168 -0
  71. package/dist/integration/preview.integration.d.ts +1 -0
  72. package/dist/integration/preview.integration.js +53 -0
  73. package/dist/integration/smoke.integration.d.ts +1 -0
  74. package/dist/integration/smoke.integration.js +91 -0
  75. package/dist/main.d.ts +2 -0
  76. package/dist/main.js +4 -0
  77. package/dist/node-io.d.ts +14 -0
  78. package/dist/node-io.js +152 -0
  79. package/dist/optimize/command.d.ts +12 -0
  80. package/dist/optimize/command.js +110 -0
  81. package/dist/packs/command.d.ts +2 -0
  82. package/dist/packs/command.js +109 -0
  83. package/dist/patch/apply.d.ts +36 -0
  84. package/dist/patch/apply.js +186 -0
  85. package/dist/patch/command.d.ts +2 -0
  86. package/dist/patch/command.js +92 -0
  87. package/dist/plugin/install.d.ts +40 -0
  88. package/dist/plugin/install.js +170 -0
  89. package/dist/preview/command.d.ts +10 -0
  90. package/dist/preview/command.js +758 -0
  91. package/dist/preview/manifest.d.ts +94 -0
  92. package/dist/preview/manifest.js +19 -0
  93. package/dist/preview/pack.d.ts +24 -0
  94. package/dist/preview/pack.js +53 -0
  95. package/dist/preview/plan.d.ts +58 -0
  96. package/dist/preview/plan.js +160 -0
  97. package/dist/preview/provenance.d.ts +23 -0
  98. package/dist/preview/provenance.js +38 -0
  99. package/dist/publish/command.d.ts +2 -0
  100. package/dist/publish/command.js +70 -0
  101. package/dist/publish/player.d.ts +25 -0
  102. package/dist/publish/player.js +108 -0
  103. package/dist/publish/static.d.ts +16 -0
  104. package/dist/publish/static.js +78 -0
  105. package/dist/qc.d.ts +2 -0
  106. package/dist/qc.js +192 -0
  107. package/dist/scene/author.d.ts +16 -0
  108. package/dist/scene/author.js +260 -0
  109. package/dist/scene/command.d.ts +2 -0
  110. package/dist/scene/command.js +110 -0
  111. package/dist/scene/ops.d.ts +23 -0
  112. package/dist/scene/ops.js +151 -0
  113. package/dist/state/command.d.ts +2 -0
  114. package/dist/state/command.js +128 -0
  115. package/dist/state/evidence.d.ts +71 -0
  116. package/dist/state/evidence.js +243 -0
  117. package/dist/storyboard/check.d.ts +21 -0
  118. package/dist/storyboard/check.js +39 -0
  119. package/dist/storyboard/containment.d.ts +24 -0
  120. package/dist/storyboard/containment.js +43 -0
  121. package/dist/storyboard/custom.d.ts +15 -0
  122. package/dist/storyboard/custom.js +133 -0
  123. package/dist/storyboard/edit.d.ts +19 -0
  124. package/dist/storyboard/edit.js +39 -0
  125. package/dist/storyboard/load.d.ts +11 -0
  126. package/dist/storyboard/load.js +52 -0
  127. package/dist/storyboard/report.d.ts +5 -0
  128. package/dist/storyboard/report.js +8 -0
  129. package/dist/template/coerce.d.ts +6 -0
  130. package/dist/template/coerce.js +44 -0
  131. package/dist/template/command.d.ts +5 -0
  132. package/dist/template/command.js +163 -0
  133. package/dist/template/env.d.ts +34 -0
  134. package/dist/template/env.js +38 -0
  135. package/dist/template/fork.d.ts +8 -0
  136. package/dist/template/fork.js +156 -0
  137. package/dist/template/format.d.ts +21 -0
  138. package/dist/template/format.js +161 -0
  139. package/dist/template/list.d.ts +15 -0
  140. package/dist/template/list.js +116 -0
  141. package/dist/template/new.d.ts +6 -0
  142. package/dist/template/new.js +302 -0
  143. package/dist/testing/env.d.ts +8 -0
  144. package/dist/testing/env.js +19 -0
  145. package/dist/testing/fakes.d.ts +33 -0
  146. package/dist/testing/fakes.js +95 -0
  147. package/dist/testing/fixtures.d.ts +8 -0
  148. package/dist/testing/fixtures.js +6 -0
  149. package/dist/testing/probe.d.ts +15 -0
  150. package/dist/testing/probe.js +20 -0
  151. package/dist/testing/project.d.ts +22 -0
  152. package/dist/testing/project.js +26 -0
  153. package/dist/tooling/session.d.ts +40 -0
  154. package/dist/tooling/session.js +106 -0
  155. package/dist/types.d.ts +59 -0
  156. package/dist/types.js +1 -0
  157. package/dist/verify/audio-loudness.d.ts +46 -0
  158. package/dist/verify/audio-loudness.js +186 -0
  159. package/dist/verify/command.d.ts +2 -0
  160. package/dist/verify/command.js +161 -0
  161. package/dist/verify/determinism.d.ts +30 -0
  162. package/dist/verify/determinism.js +59 -0
  163. package/dist/verify/expectation.d.ts +15 -0
  164. package/dist/verify/expectation.js +19 -0
  165. package/dist/verify/media-events.d.ts +61 -0
  166. package/dist/verify/media-events.js +174 -0
  167. package/dist/verify/platform.d.ts +33 -0
  168. package/dist/verify/platform.js +59 -0
  169. package/dist/verify/probe.d.ts +14 -0
  170. package/dist/verify/probe.js +23 -0
  171. package/dist/verify/report.d.ts +15 -0
  172. package/dist/verify/report.js +45 -0
  173. package/dist/verify/run.d.ts +27 -0
  174. package/dist/verify/run.js +53 -0
  175. package/dist/voice/command.d.ts +3 -0
  176. package/dist/voice/command.js +49 -0
  177. package/dist/voice/manifest.d.ts +33 -0
  178. package/dist/voice/manifest.js +69 -0
  179. package/dist/voice/run.d.ts +21 -0
  180. package/dist/voice/run.js +181 -0
  181. package/dist/workflow/build.d.ts +2 -0
  182. package/dist/workflow/build.js +25 -0
  183. package/dist/workflow/draft.d.ts +9 -0
  184. package/dist/workflow/draft.js +201 -0
  185. package/dist/workflow/finish.d.ts +23 -0
  186. package/dist/workflow/finish.js +28 -0
  187. package/dist/workflow/project.d.ts +73 -0
  188. package/dist/workflow/project.js +198 -0
  189. package/dist/workflow/provenance.d.ts +18 -0
  190. package/dist/workflow/provenance.js +36 -0
  191. package/dist/workflow/render.d.ts +31 -0
  192. package/dist/workflow/render.js +705 -0
  193. package/dist/workflow/still.d.ts +5 -0
  194. package/dist/workflow/still.js +62 -0
  195. package/dist/workflow/validate.d.ts +2 -0
  196. package/dist/workflow/validate.js +49 -0
  197. package/package.json +38 -3
@@ -0,0 +1,451 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { AUDIO_DIR_NAME, normaliseRef as buildNormaliseRef, clipsFromManifest, isContiguous, parseAudioManifest, readRelativeClosure, resolveSceneDir, } from "@anvpx/motion-build";
5
+ import { parseMusicBed } from "@anvpx/motion-schema";
6
+ import { sceneContainment } from "../storyboard/containment.js";
7
+ export const MK_DIR = ".mk";
8
+ const THEMES_DIR = "themes";
9
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
10
+ /** sha256 of zero bytes: a silent/empty wav can never qualify as audible proof. */
11
+ const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
12
+ /** File name without its extension: the namespace of everything a storyboard owns next to itself. */
13
+ export const storyboardStem = (storyboard) => basename(storyboard, extname(storyboard));
14
+ /** `<storyboard dir>/.mk/<stem>`: provenance and approval, so two storyboards in one folder never share state. */
15
+ export const mkDir = (storyboard) => join(dirname(resolve(storyboard)), MK_DIR, storyboardStem(storyboard));
16
+ /**
17
+ * Reads a sidecar file of `storyboard`. Projects made before the per-storyboard namespace kept it
18
+ * in `<dir>/.mk/`; that location is still read, and only for a storyboard named `storyboard.json`.
19
+ */
20
+ export async function readSidecar(host, storyboard, file) {
21
+ try {
22
+ return await host.readText(join(mkDir(storyboard), file));
23
+ }
24
+ catch (error) {
25
+ if (storyboardStem(storyboard) !== "storyboard")
26
+ throw error;
27
+ return host.readText(join(dirname(resolve(storyboard)), MK_DIR, file));
28
+ }
29
+ }
30
+ /**
31
+ * The approval hash: sha256 over the storyboard bytes, every vendored theme (name + bytes,
32
+ * sorted by name) and — when the storyboard has authored scenes — every file of those scene
33
+ * directories. Any edit to any of them changes the hash, which re-arms the gate. Authored
34
+ * scenes are media-producing input: leaving them out would let `scene.js` change under an
35
+ * approval and still render.
36
+ */
37
+ export function combineHash(storyboardText, themes, scenes = []) {
38
+ const rows = [...themes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
39
+ const sceneRows = [...scenes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
40
+ return sha256(JSON.stringify({
41
+ storyboard: sha256(storyboardText),
42
+ themes: rows.map(([name, text]) => [name, sha256(text)]),
43
+ ...(sceneRows.length === 0
44
+ ? {}
45
+ : { scenes: sceneRows.map(([name, text]) => [name, sha256(text)]) }),
46
+ }));
47
+ }
48
+ const SCENE_FILES = ["scene.js", "fragment.html", "style.css"];
49
+ /** Every file of every authored scene directory, as `<props.dir>/<file>` + bytes. */
50
+ async function authoredSceneFiles(host, storyboard, text) {
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(text);
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ const scenes = parsed.scenes;
59
+ if (!Array.isArray(scenes))
60
+ return [];
61
+ const base = dirname(resolve(storyboard));
62
+ const files = [];
63
+ for (const entry of scenes) {
64
+ const scene = entry;
65
+ if (scene.type !== "custom")
66
+ continue;
67
+ const requested = scene.props?.dir;
68
+ if (typeof requested !== "string")
69
+ continue;
70
+ const resolved = resolveSceneDir(base, requested);
71
+ if (!resolved.ok)
72
+ continue;
73
+ const dirName = requested.replaceAll("\\", "/");
74
+ // A symlink or junction can point outside while looking project-local; the
75
+ // loader refuses it, and the approval manifest must not record outside bytes
76
+ // as local inputs either.
77
+ const containment = sceneContainment(base, resolved.dir, SCENE_FILES);
78
+ if (!containment.dirInside) {
79
+ throw new Error(`custom scene "${requested}" resolves outside the storyboard directory (symlink/junction)`);
80
+ }
81
+ if (containment.escapedFiles.length > 0) {
82
+ throw new Error(`custom scene file "${containment.escapedFiles[0] ?? ""}" resolves outside the scene directory (symlink/junction)`);
83
+ }
84
+ for (const file of SCENE_FILES) {
85
+ const path = join(resolved.dir, file);
86
+ if (!(await host.exists(path)))
87
+ continue;
88
+ files.push([`${dirName}/${file}`, await host.readText(path)]);
89
+ }
90
+ // Relative helpers are emitted into the project, so they are approval inputs
91
+ // too: a helper-only edit must rebuild and re-arm the gate.
92
+ const jsPath = join(resolved.dir, "scene.js");
93
+ if (await host.exists(jsPath)) {
94
+ const closure = await readRelativeClosure(await host.readText(jsPath), async (helper) => {
95
+ const check = sceneContainment(base, resolved.dir, [helper]);
96
+ if (!check.dirInside || check.escapedFiles.length > 0) {
97
+ throw new Error(`custom scene helper "${helper}" resolves outside the scene (symlink)`);
98
+ }
99
+ const full = join(resolved.dir, helper);
100
+ return (await host.exists(full)) ? host.readText(full) : undefined;
101
+ });
102
+ for (const helper of closure.files) {
103
+ files.push([`${dirName}/${helper.path}`, helper.text]);
104
+ }
105
+ }
106
+ }
107
+ return files;
108
+ }
109
+ /** Reads the storyboard, `<dir>/themes/*.json` and any authored scene files; rejects when the storyboard is unreadable. */
110
+ export async function storyboardHash(host, storyboard) {
111
+ const text = await host.readText(storyboard);
112
+ const dir = join(dirname(resolve(storyboard)), THEMES_DIR);
113
+ const names = (await host.listDir(dir).catch(() => []))
114
+ .filter((name) => name.endsWith(".json"))
115
+ .sort();
116
+ const themes = await Promise.all(names.map(async (name) => [name, await host.readText(join(dir, name))]));
117
+ const scenes = await authoredSceneFiles(host, storyboard, text);
118
+ return combineHash(text, themes, scenes);
119
+ }
120
+ /**
121
+ * RA-01: the build resolver is the single path authority; the CLI keeps its
122
+ * own escape taxonomy on top so existing fail-closed messages are unchanged.
123
+ * The trailing clean check preserves the historical rejection of ".".
124
+ */
125
+ const normaliseRef = (ref) => {
126
+ let clean;
127
+ try {
128
+ clean = buildNormaliseRef(ref);
129
+ }
130
+ catch (error) {
131
+ throw new Error(`${error.message} (escape)`);
132
+ }
133
+ if (clean === "" || clean === "." || clean.startsWith("../") || clean === "..") {
134
+ throw new Error(`asset "${ref}" escapes the project (escape)`);
135
+ }
136
+ return clean;
137
+ };
138
+ const toForwardSlash = (p) => p.split(sep).join("/");
139
+ /** Every storyboard string key that resolves through the asset ledger.
140
+ * Census of the build surface today: only `image` is emitted by scenes
141
+ * (bug, media-frame, quote via assetResolver); video/poster/font/lut/src
142
+ * (+ aliases) are hashed too so a future scene cannot bypass the gate by
143
+ * renaming the key. Timing/platforms/cache need no extra file hash: timing
144
+ * and meta.platform are inside the hashed storyboard bytes, platforms ship
145
+ * pinned with @anvpx/motion-schema, and .mk-cache output is never an input.
146
+ */
147
+ const ASSET_REF_KEYS = new Set(["image", "video", "poster", "font", "lut"]);
148
+ /** Collects every asset-ref string in the storyboard JSON (see ASSET_REF_KEYS). */
149
+ const collectFromObject = (record, out) => {
150
+ for (const [key, entry] of Object.entries(record)) {
151
+ if (typeof entry === "string") {
152
+ if (ASSET_REF_KEYS.has(key))
153
+ out.push(entry);
154
+ }
155
+ else
156
+ collectAssetRefs(entry, out);
157
+ }
158
+ };
159
+ const collectAssetRefs = (value, out) => {
160
+ if (typeof value === "string")
161
+ return;
162
+ if (Array.isArray(value)) {
163
+ for (const item of value)
164
+ collectAssetRefs(item, out);
165
+ return;
166
+ }
167
+ if (typeof value === "object" && value !== null) {
168
+ collectFromObject(value, out);
169
+ }
170
+ };
171
+ const codeUnitSort = (a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
172
+ const insideDir = (root, target) => {
173
+ const rel = relative(root, target);
174
+ return rel !== "" && rel.split(sep)[0] !== ".." && !isAbsolute(rel);
175
+ };
176
+ /** Rejects symlinks (or any realpath) escaping the assets dir; skips when off-disk (fake hosts). */
177
+ const assertInsideAssetsDir = (assetsDir, full, ref) => {
178
+ let realAssets;
179
+ let realTarget;
180
+ try {
181
+ realAssets = realpathSync(resolve(assetsDir));
182
+ realTarget = realpathSync(full);
183
+ }
184
+ catch {
185
+ return;
186
+ }
187
+ if (!insideDir(realAssets, realTarget)) {
188
+ throw new Error(`asset "${ref}" resolves outside the assets dir (symlink escape)`);
189
+ }
190
+ };
191
+ /** Rejects symlinks (or any realpath) escaping the project dir; skips when off-disk (fake hosts). */
192
+ const assertInsideProjectDir = (projectDir, full, label) => {
193
+ let realRoot;
194
+ let realTarget;
195
+ try {
196
+ realRoot = realpathSync(resolve(projectDir));
197
+ realTarget = realpathSync(full);
198
+ }
199
+ catch {
200
+ return;
201
+ }
202
+ if (realTarget !== realRoot && !insideDir(realRoot, realTarget)) {
203
+ throw new Error(`theme "${label}" resolves outside the project (symlink escape)`);
204
+ }
205
+ };
206
+ /**
207
+ * Builds the visual-only input manifest: storyboard + vendored themes +
208
+ * exact referenced assets + ledger bytes. Project-relative forward-slash
209
+ * paths, code-unit sorted. Fails closed on missing assets, escapes,
210
+ * symlinks, duplicate normalized paths and unreadable ledgers.
211
+ */
212
+ const readThemeRefs = async (host, projectDir) => {
213
+ const themesDir = join(projectDir, THEMES_DIR);
214
+ const themeNames = (await host.listDir(themesDir).catch(() => []))
215
+ .filter((name) => name.endsWith(".json"))
216
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
217
+ const themes = [];
218
+ for (const name of themeNames) {
219
+ const full = join(themesDir, name);
220
+ assertInsideProjectDir(projectDir, full, name);
221
+ const text = await host.readText(full);
222
+ const rel = toForwardSlash(relative(projectDir, resolve(full)));
223
+ if (rel === "" || rel.startsWith("../") || isAbsolute(rel)) {
224
+ throw new Error(`theme "${name}" escapes the project (escape)`);
225
+ }
226
+ themes.push({ path: rel, sha256: sha256(text) });
227
+ }
228
+ themes.sort(codeUnitSort);
229
+ return themes;
230
+ };
231
+ const dedupeAssetRefs = (rawRefs) => {
232
+ const seen = new Map();
233
+ for (const raw of rawRefs) {
234
+ const clean = normaliseRef(raw);
235
+ const first = seen.get(clean);
236
+ if (first !== undefined && first !== raw) {
237
+ throw new Error(`asset "${raw}" duplicates normalized path "${clean}" (duplicate)`);
238
+ }
239
+ seen.set(clean, raw);
240
+ }
241
+ return [...seen.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
242
+ };
243
+ const readAssetRefs = async (host, projectDir, assetsDir, cleans) => {
244
+ const assets = [];
245
+ for (const clean of cleans) {
246
+ const full = resolve(assetsDir, clean);
247
+ const rel = toForwardSlash(relative(projectDir, full));
248
+ if (rel === "" || rel.startsWith("../") || isAbsolute(rel)) {
249
+ throw new Error(`asset "${clean}" escapes the project (escape)`);
250
+ }
251
+ assertInsideAssetsDir(assetsDir, full, clean);
252
+ const digest = await host.sha256File(full);
253
+ if (digest === undefined) {
254
+ throw new Error(`asset "${clean}" not found under the assets dir`);
255
+ }
256
+ assets.push({ path: rel, sha256: digest });
257
+ }
258
+ assets.sort(codeUnitSort);
259
+ return assets;
260
+ };
261
+ const readLedgerSha = async (host, ledgerPath) => {
262
+ try {
263
+ return sha256(await host.readText(ledgerPath));
264
+ }
265
+ catch (error) {
266
+ throw new Error(`cannot read asset ledger ${ledgerPath}: ${error.message}`);
267
+ }
268
+ };
269
+ /** Resolves --audio TimingManifest plus every sibling wav into refs + mix. Fails closed. */
270
+ const readAudioRefs = async (host, projectDir, audioManifestPath) => {
271
+ let text;
272
+ try {
273
+ text = await host.readText(audioManifestPath);
274
+ }
275
+ catch (error) {
276
+ throw new Error(`cannot read --audio manifest ${audioManifestPath}: ${error.message}`);
277
+ }
278
+ let raw;
279
+ try {
280
+ raw = JSON.parse(text);
281
+ }
282
+ catch (error) {
283
+ throw new Error(`invalid --audio manifest ${audioManifestPath}: ${error.message}`);
284
+ }
285
+ const parsed = parseAudioManifest(raw);
286
+ if (!parsed.ok) {
287
+ throw new Error(`invalid --audio manifest ${audioManifestPath}: ${parsed.problems.join("; ")}`);
288
+ }
289
+ const audioDir = join(dirname(resolve(audioManifestPath)), AUDIO_DIR_NAME);
290
+ const clips = clipsFromManifest(parsed.manifest, audioDir);
291
+ if (clips.length === 0) {
292
+ throw new Error(`--audio manifest ${audioManifestPath} has no scenes; nothing to mix`);
293
+ }
294
+ const audio = [];
295
+ const mixClips = [];
296
+ for (const clip of clips) {
297
+ const digest = await host.sha256File(clip.wav);
298
+ if (digest === undefined) {
299
+ throw new Error(`audio wav for scene "${clip.id}" not found: ${clip.wav}`);
300
+ }
301
+ if (digest === EMPTY_SHA256) {
302
+ throw new Error(`audio wav for scene "${clip.id}" is silent/empty: ${clip.wav}`);
303
+ }
304
+ const rel = toForwardSlash(relative(projectDir, resolve(clip.wav)));
305
+ if (rel === "" || rel.startsWith("../") || isAbsolute(rel)) {
306
+ throw new Error(`audio wav for scene "${clip.id}" escapes the project (escape)`);
307
+ }
308
+ audio.push({ path: rel, sha256: digest });
309
+ mixClips.push({ id: clip.id, start: clip.start, wavSha: digest });
310
+ }
311
+ audio.sort(codeUnitSort);
312
+ const manifestRel = toForwardSlash(relative(projectDir, resolve(audioManifestPath)));
313
+ if (manifestRel === "" || manifestRel.startsWith("../") || isAbsolute(manifestRel)) {
314
+ throw new Error(`--audio manifest ${audioManifestPath} escapes the project (escape)`);
315
+ }
316
+ const audioManifest = { path: manifestRel, sha256: sha256(text) };
317
+ const mode = clips.length === 1 ? "single" : isContiguous(parsed.manifest.scenes) ? "concat" : "adelay";
318
+ return { audio, audioManifest, mix: { mode, clips: mixClips } };
319
+ };
320
+ /** Resolves --music bed wav plus volume and license-evidence receipt. Fails closed. */
321
+ const readMusicRefs = async (host, projectDir, musicPath, musicVolume, musicLicensePath) => {
322
+ const parsedBed = parseMusicBed(musicVolume === undefined ? { wav: musicPath } : { wav: musicPath, volume: musicVolume });
323
+ if (!parsedBed.ok) {
324
+ throw new Error(`invalid --music bed: ${parsedBed.hint}`);
325
+ }
326
+ const full = resolve(musicPath);
327
+ const digest = await host.sha256File(full);
328
+ if (digest === undefined) {
329
+ throw new Error(`music bed wav not found: ${musicPath}`);
330
+ }
331
+ if (digest === EMPTY_SHA256) {
332
+ throw new Error(`music bed wav is silent/empty: ${musicPath}`);
333
+ }
334
+ if (digest.length !== 64) {
335
+ throw new Error(`music bed wav unreadable: ${musicPath}`);
336
+ }
337
+ const rel = toForwardSlash(relative(projectDir, full));
338
+ if (rel === "" || rel.startsWith("../") || isAbsolute(rel)) {
339
+ throw new Error(`music bed "${musicPath}" escapes the project (escape)`);
340
+ }
341
+ assertInsideProjectDir(projectDir, full, `music bed "${musicPath}"`);
342
+ let licenseEvidenceSha256 = null;
343
+ if (musicLicensePath !== undefined) {
344
+ const licenseFull = resolve(musicLicensePath);
345
+ const licenseDigest = await host.sha256File(licenseFull);
346
+ if (licenseDigest === undefined) {
347
+ throw new Error(`cannot read --music-license ${musicLicensePath}: not found`);
348
+ }
349
+ licenseEvidenceSha256 = licenseDigest;
350
+ }
351
+ return { path: rel, sha256: digest, volume: parsedBed.bed.volume, licenseEvidenceSha256 };
352
+ };
353
+ export async function buildApprovalInputManifest(host, storyboard, opts = {}) {
354
+ const projectDir = dirname(resolve(storyboard));
355
+ const storyboardText = await host.readText(storyboard);
356
+ const storyboardRel = toForwardSlash(relative(projectDir, resolve(storyboard)));
357
+ const storyboardRef = { path: storyboardRel, sha256: sha256(storyboardText) };
358
+ const themes = await readThemeRefs(host, projectDir);
359
+ const sceneFiles = await authoredSceneFiles(host, storyboard, storyboardText);
360
+ const scenes = sceneFiles.map(([path, text]) => ({
361
+ path,
362
+ sha256: sha256(text),
363
+ }));
364
+ let parsed = null;
365
+ try {
366
+ parsed = JSON.parse(storyboardText);
367
+ }
368
+ catch {
369
+ parsed = null;
370
+ }
371
+ const rawRefs = [];
372
+ if (parsed !== null)
373
+ collectAssetRefs(parsed, rawRefs);
374
+ const cleans = dedupeAssetRefs(rawRefs);
375
+ const assetsDir = opts.assetsDir === undefined ? undefined : resolve(opts.assetsDir);
376
+ if (rawRefs.length > 0 && assetsDir === undefined) {
377
+ throw new Error(`asset "${rawRefs[0] ?? ""}" requested but no assetsDir was given`);
378
+ }
379
+ const assets = assetsDir === undefined ? [] : await readAssetRefs(host, projectDir, assetsDir, cleans);
380
+ const ledgerSha256 = opts.ledgerPath === undefined ? undefined : await readLedgerSha(host, opts.ledgerPath);
381
+ if (opts.audioManifest === undefined) {
382
+ if (opts.music !== undefined) {
383
+ throw new Error(`--music needs --audio: pass the same --audio manifest used for preview`);
384
+ }
385
+ return {
386
+ scope: "visual",
387
+ storyboard: storyboardRef,
388
+ themes,
389
+ assets,
390
+ ...(ledgerSha256 === undefined ? {} : { ledgerSha256 }),
391
+ ...(scenes.length === 0 ? {} : { scenes }),
392
+ audio: [],
393
+ };
394
+ }
395
+ const { audio, audioManifest, mix } = await readAudioRefs(host, projectDir, opts.audioManifest);
396
+ const music = opts.music === undefined
397
+ ? undefined
398
+ : await readMusicRefs(host, projectDir, opts.music, opts.musicVolume, opts.musicLicense);
399
+ return {
400
+ scope: "audiovisual",
401
+ storyboard: storyboardRef,
402
+ themes,
403
+ assets,
404
+ ...(ledgerSha256 === undefined ? {} : { ledgerSha256 }),
405
+ ...(scenes.length === 0 ? {} : { scenes }),
406
+ audio,
407
+ audioManifest,
408
+ mix,
409
+ ...(music === undefined ? {} : { music }),
410
+ };
411
+ }
412
+ /**
413
+ * Canonical hash of the manifest. Sorts themes/assets/audio by UTF-16
414
+ * code units so enumeration order never changes the digest. No timestamp
415
+ * or machine path enters the hash. Audiovisual fields (audioManifest + mix)
416
+ * are covered so wav/manifest/mix changes re-arm the gate. AU-01 music
417
+ * {path,sha256,volume,licenseEvidenceSha256} is covered so bed/volume/license
418
+ * changes re-arm the gate.
419
+ */
420
+ export function hashApprovalInput(manifest) {
421
+ const themes = [...manifest.themes].sort(codeUnitSort);
422
+ const assets = [...manifest.assets].sort(codeUnitSort);
423
+ const audio = [...(manifest.audio ?? [])].sort(codeUnitSort);
424
+ const scenes = [...(manifest.scenes ?? [])].sort(codeUnitSort);
425
+ return sha256(JSON.stringify({
426
+ scope: manifest.scope,
427
+ storyboard: { path: manifest.storyboard.path, sha256: manifest.storyboard.sha256 },
428
+ themes: themes.map((t) => [t.path, t.sha256]),
429
+ assets: assets.map((a) => [a.path, a.sha256]),
430
+ ledgerSha256: manifest.ledgerSha256 ?? null,
431
+ audio: audio.map((a) => [a.path, a.sha256]),
432
+ ...(scenes.length === 0 ? {} : { scenes: scenes.map((s) => [s.path, s.sha256]) }),
433
+ audioManifest: manifest.audioManifest === undefined
434
+ ? null
435
+ : { path: manifest.audioManifest.path, sha256: manifest.audioManifest.sha256 },
436
+ mix: manifest.mix === undefined
437
+ ? null
438
+ : {
439
+ mode: manifest.mix.mode,
440
+ clips: manifest.mix.clips.map((c) => [c.id, c.start, c.wavSha]),
441
+ },
442
+ music: manifest.music === undefined
443
+ ? null
444
+ : {
445
+ path: manifest.music.path,
446
+ sha256: manifest.music.sha256,
447
+ volume: manifest.music.volume,
448
+ licenseEvidenceSha256: manifest.music.licenseEvidenceSha256,
449
+ },
450
+ }));
451
+ }
package/dist/args.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { Io } from "./types.ts";
2
+ export type FlagSpec = Readonly<Record<string, {
3
+ type: "boolean" | "string";
4
+ short?: string;
5
+ multiple?: boolean;
6
+ }>>;
7
+ export type Flags = Readonly<Record<string, string | boolean | string[] | undefined>>;
8
+ export interface Exit {
9
+ kind: "exit";
10
+ code: number;
11
+ }
12
+ export interface Parsed {
13
+ kind: "run";
14
+ flags: Flags;
15
+ positionals: string[];
16
+ }
17
+ /** Reports a usage problem on stderr with the one-line usage; always EXIT.USAGE. */
18
+ export declare function usageError(name: string, usage: string, message: string, io: Io): Exit;
19
+ /** Strict flag parsing plus `--help`; unknown flags and missing values exit 2. */
20
+ export declare function parseFlags(name: string, usage: string, args: readonly string[], spec: FlagSpec, io: Io): Parsed | Exit;
21
+ /** A path: exactly one positional argument; `label` names it in the error. */
22
+ export declare function requirePositional(name: string, usage: string, positionals: readonly string[], label: string, io: Io): string | Exit;
23
+ /** A string flag's value, or undefined when absent. */
24
+ export declare function textFlag(flags: Flags, name: string): string | undefined;
25
+ /** Every value of a repeatable string flag (`multiple: true`), in order. */
26
+ export declare function listFlag(flags: Flags, name: string): string[];
27
+ /** The storyboard path: exactly one positional argument. */
28
+ export declare const requireStoryboard: (name: string, usage: string, positionals: readonly string[], io: Io) => string | Exit;
29
+ /** Node's argument errors append long advice; keep only the first sentence. */
30
+ export declare const firstSentence: (message: string) => string;
package/dist/args.js ADDED
@@ -0,0 +1,53 @@
1
+ import { parseArgs } from "node:util";
2
+ import { EXIT } from "./exit-codes.js";
3
+ const usageLine = (usage) => usage.split("\n")[0] ?? "";
4
+ /** Reports a usage problem on stderr with the one-line usage; always EXIT.USAGE. */
5
+ export function usageError(name, usage, message, io) {
6
+ io.stderr(`mk ${name}: ${message}\nusage: ${usageLine(usage)}\n`);
7
+ return { kind: "exit", code: EXIT.USAGE };
8
+ }
9
+ /** Strict flag parsing plus `--help`; unknown flags and missing values exit 2. */
10
+ export function parseFlags(name, usage, args, spec, io) {
11
+ try {
12
+ const { values, positionals } = parseArgs({
13
+ args: [...args],
14
+ options: { ...spec, help: { type: "boolean", short: "h" } },
15
+ allowPositionals: true,
16
+ });
17
+ const { help, ...flags } = values;
18
+ if (!help)
19
+ return { kind: "run", flags, positionals };
20
+ io.stdout(usage);
21
+ return { kind: "exit", code: EXIT.OK };
22
+ }
23
+ catch (error) {
24
+ return usageError(name, usage, firstSentence(error.message), io);
25
+ }
26
+ }
27
+ /** A path: exactly one positional argument; `label` names it in the error. */
28
+ export function requirePositional(name, usage, positionals, label, io) {
29
+ const [path, ...extra] = positionals;
30
+ if (path === undefined)
31
+ return usageError(name, usage, `missing ${label}`, io);
32
+ if (extra.length > 0) {
33
+ return usageError(name, usage, `unexpected argument "${extra[0]}"`, io);
34
+ }
35
+ return path;
36
+ }
37
+ /** A string flag's value, or undefined when absent. */
38
+ export function textFlag(flags, name) {
39
+ const value = flags[name];
40
+ return typeof value === "string" ? value : undefined;
41
+ }
42
+ /** Every value of a repeatable string flag (`multiple: true`), in order. */
43
+ export function listFlag(flags, name) {
44
+ const value = flags[name];
45
+ return Array.isArray(value) ? value : [];
46
+ }
47
+ /** The storyboard path: exactly one positional argument. */
48
+ export const requireStoryboard = (name, usage, positionals, io) => requirePositional(name, usage, positionals, "<storyboard.json>", io);
49
+ /** Node's argument errors append long advice; keep only the first sentence. */
50
+ export const firstSentence = (message) => {
51
+ const end = message.search(/\.\s/);
52
+ return end === -1 ? message : message.slice(0, end + 1);
53
+ };
@@ -0,0 +1,2 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const assetsCommand: Command;