@anvpx/motion-cli 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.
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 +464 -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 +764 -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 +279 -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 +27 -0
  180. package/dist/voice/run.js +190 -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 +215 -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 +721 -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,764 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { validateMusicBed } from "@anvpx/motion-audio";
4
+ import { AUDIO_DIR_NAME, CUSTOM_SCENE_TYPE, clipsFromManifest, frozenClips, frozenCopyProblems, frozenMusic, isContiguous, mixAtDeliveryLevel, parseAudioManifest, placementsFromTimeline, readRelativeClosure, resolveSceneDir, resolveTimeline, validateStoryboardFull, } from "@anvpx/motion-build";
5
+ import { buildApprovalInputManifest, hashApprovalInput, storyboardHash, } from "../approval/hash.js";
6
+ import { parseFlags, requireStoryboard, textFlag, usageError } from "../args.js";
7
+ import { EXIT } from "../exit-codes.js";
8
+ import { checkStoryboard, rootError } from "../storyboard/check.js";
9
+ import { sceneContainment } from "../storyboard/containment.js";
10
+ import { readJsonFile } from "../storyboard/load.js";
11
+ import { formatFindings } from "../storyboard/report.js";
12
+ import { defaultEnv } from "../template/env.js";
13
+ import { DRAFT_FPS } from "../verify/expectation.js";
14
+ import { platformLookup } from "../verify/platform.js";
15
+ import { makeDraftCommand } from "../workflow/draft.js";
16
+ import { stillCommand } from "../workflow/still.js";
17
+ import { CHECKLIST, defaultOut, PREVIEW_JSON, packDir, } from "./manifest.js";
18
+ import { clearPngs, contactSheet, listPngs, nameStills, } from "./pack.js";
19
+ import { CLIP_MAX_SEC, CLIP_SCENE_SEC, capClip, DARK_COUNT, distinctPicks, sceneInfos, shouldRunClip, stillCandidates, stillPlan, } from "./plan.js";
20
+ import { readProvenance, resolvePair } from "./provenance.js";
21
+ const USAGE = `mk preview <storyboard.json> [--stills 6 | --every] [--budget-sec 90] [--out <dir>] [--assets <dir>] [--ledger <assets.json>] [--audio <manifest>] [--music <bed.wav>] [--music-volume <n>] [--music-license <file>] [--json]
22
+
23
+ Build the proposal pack the user approves, into <out>/<name>.preview, <name> = the storyboard file name without .json (default <out> = <storyboard dir>/out):
24
+ preview.json hash, template, frame, scenes, stills, lint, estimate, approval checklist
25
+ contact.png one image tiling the stills (look at this, show it to the user)
26
+ stills/NN-<scene>-<theme>.png
27
+ clip.mp4 each scene capped at ${CLIP_SCENE_SEC} s, whole clip <= ${CLIP_MAX_SEC} s, 960 px, ${DRAFT_FPS} fps
28
+ Stills: first scene, last scene, each scene type once (an overlay off screen at its scene's still counts as a scene), then an even spread; ${DARK_COUNT} extra stills in the paired theme when
29
+ the template has one. Order of work: stills, contact sheet, clip. If --budget-sec runs out the clip is skipped
30
+ (preview.json says so, exit 0); stills are never skipped. Validation or lint errors exit 2.
31
+ --stills n base stills (1-24, default 6)
32
+ --every one still per base scene at 70% of it (instead of --stills); overlays show where they cover that moment
33
+ --budget-sec n time budget (default 90)
34
+ --audio <manifest>
35
+ voice TimingManifest: mix each scene's wav (the manifest's sibling
36
+ audio/ dir, <id>.wav) under clip.mp4 with the same mix args as
37
+ the final render; absent means a silent clip (audible:false).
38
+ The audiovisual binding (manifest + wavs + mix) enters
39
+ inputManifestSha256.
40
+ --music <bed.wav> opt-in CC0 bed ducked under narration (needs --audio);
41
+ same buildMusicMixArgs as final; volume via --music-volume,
42
+ license receipt via --music-license. Bed + volume + licenseEvidenceSha256
43
+ enter inputManifestSha256.
44
+ --music-volume <n> bed level within (0, 1] (default 0.5)
45
+ --music-license <file> license-evidence file hashed into the receipt
46
+ --json print preview.json instead of the summary line
47
+ Scratch builds live in <out>/<name>.preview-work. Then show the pack and, after an explicit yes: mk approve <storyboard.json>
48
+ `;
49
+ const FLAGS = {
50
+ stills: { type: "string" },
51
+ every: { type: "boolean" },
52
+ "budget-sec": { type: "string" },
53
+ out: { type: "string" },
54
+ assets: { type: "string" },
55
+ ledger: { type: "string" },
56
+ audio: { type: "string" },
57
+ music: { type: "string" },
58
+ "music-volume": { type: "string" },
59
+ "music-license": { type: "string" },
60
+ json: { type: "boolean" },
61
+ };
62
+ const DEFAULT_STILLS = 6;
63
+ const MAX_STILLS = 24;
64
+ const DEFAULT_BUDGET_SEC = 90;
65
+ const MAX_LINT_ITEMS = 10;
66
+ /** The clip is a derived trailer; authored-work lint (reading time, hold) does not apply to it. */
67
+ const clipDraft = makeDraftCommand({ lint: false });
68
+ const ESTIMATE_NOTE = "estimate: measured draft ms per frame x total frames at the storyboard fps; delivery quality is slower";
69
+ const MS = 1000;
70
+ /** A copy of `io` whose stdout/stderr are captured, so wrapped commands stay quiet. */
71
+ const captured = (io) => {
72
+ const out = [];
73
+ const err = [];
74
+ return { io: { ...io, stdout: (t) => out.push(t), stderr: (t) => err.push(t) }, out, err };
75
+ };
76
+ /** `--stills` as a count (1-24); an exit code on a bad value or a clash with `--every`. */
77
+ function stillCount(flags, every, io) {
78
+ const given = textFlag(flags, "stills");
79
+ if (every && given !== undefined) {
80
+ return { code: usageError("preview", USAGE, "--every and --stills conflict", io).code };
81
+ }
82
+ const count = Number(given ?? DEFAULT_STILLS);
83
+ if (Number.isInteger(count) && count >= 1 && count <= MAX_STILLS)
84
+ return { count };
85
+ return {
86
+ code: usageError("preview", USAGE, `--stills must be an integer 1-${MAX_STILLS}`, io).code,
87
+ };
88
+ }
89
+ function readOptions(flags, storyboard, io) {
90
+ const every = flags.every === true;
91
+ const counted = stillCount(flags, every, io);
92
+ if ("code" in counted)
93
+ return counted.code;
94
+ const stills = counted.count;
95
+ const budgetSec = Number(textFlag(flags, "budget-sec") ?? DEFAULT_BUDGET_SEC);
96
+ if (!Number.isFinite(budgetSec) || budgetSec <= 0) {
97
+ return usageError("preview", USAGE, "--budget-sec must be a positive number", io).code;
98
+ }
99
+ const assets = textFlag(flags, "assets");
100
+ const ledger = textFlag(flags, "ledger");
101
+ const audio = textFlag(flags, "audio");
102
+ const music = textFlag(flags, "music");
103
+ const musicVolumeRaw = textFlag(flags, "music-volume");
104
+ const musicLicense = textFlag(flags, "music-license");
105
+ if (musicVolumeRaw !== undefined) {
106
+ const n = Number(musicVolumeRaw);
107
+ if (!Number.isFinite(n) || n <= 0 || n > 1) {
108
+ return usageError("preview", USAGE, "--music-volume must be within (0, 1]", io).code;
109
+ }
110
+ }
111
+ if (music !== undefined && audio === undefined) {
112
+ return usageError("preview", USAGE, "--music needs --audio", io).code;
113
+ }
114
+ if ((musicVolumeRaw !== undefined || musicLicense !== undefined) && music === undefined) {
115
+ return usageError("preview", USAGE, "--music-volume/--music-license need --music", io).code;
116
+ }
117
+ return {
118
+ stills,
119
+ every,
120
+ budgetSec,
121
+ out: resolve(textFlag(flags, "out") ?? defaultOut(storyboard)),
122
+ extra: [
123
+ ...(assets === undefined ? [] : ["--assets", assets]),
124
+ ...(ledger === undefined ? [] : ["--ledger", ledger]),
125
+ ],
126
+ ...(assets === undefined ? {} : { assetsDir: resolve(assets) }),
127
+ ...(ledger === undefined ? {} : { ledgerPath: ledger }),
128
+ ...(audio === undefined ? {} : { audioManifest: audio }),
129
+ ...(music === undefined ? {} : { musicPath: music }),
130
+ ...(musicVolumeRaw === undefined ? {} : { musicVolume: Number(musicVolumeRaw) }),
131
+ ...(musicLicense === undefined ? {} : { musicLicensePath: musicLicense }),
132
+ };
133
+ }
134
+ /** Copies every `<dir>/themes/*.json` of the project into a scratch project. */
135
+ async function copyThemes(host, from, to) {
136
+ const dir = join(from, "themes");
137
+ const names = (await host.listDir(dir).catch(() => [])).filter((n) => n.endsWith(".json"));
138
+ for (const name of names)
139
+ await host.copyFile(join(dir, name), join(to, "themes", name));
140
+ }
141
+ const CUSTOM_FILES = ["scene.js", "fragment.html", "style.css"];
142
+ /**
143
+ * Copies authored scene directories beside a derived storyboard, so a relative
144
+ * `props.dir` still resolves after the storyboard is rewritten into a scratch dir.
145
+ */
146
+ export async function copyCustomScenes(host, board, origin, targetDir) {
147
+ const base = dirname(resolve(origin));
148
+ for (const scene of board.scenes) {
149
+ if (scene.type !== CUSTOM_SCENE_TYPE)
150
+ continue;
151
+ const dir = scene.props.dir;
152
+ if (typeof dir !== "string")
153
+ continue;
154
+ const resolved = resolveSceneDir(base, dir);
155
+ if (!resolved.ok)
156
+ continue;
157
+ // Never copy bytes that resolve outside the project through a link.
158
+ const containment = sceneContainment(base, resolved.dir, CUSTOM_FILES);
159
+ if (!containment.dirInside || containment.escapedFiles.length > 0) {
160
+ throw new Error(`custom scene "${dir}" resolves outside the storyboard directory (symlink)`);
161
+ }
162
+ for (const file of CUSTOM_FILES) {
163
+ const from = join(resolved.dir, file);
164
+ if (await host.exists(from))
165
+ await host.copyFile(from, join(targetDir, dir, file));
166
+ }
167
+ // Relative helpers emitted beside scene.js must resolve in the scratch copy too.
168
+ const jsPath = join(resolved.dir, "scene.js");
169
+ if (await host.exists(jsPath)) {
170
+ const closure = await readRelativeClosure(await host.readText(jsPath), async (helper) => {
171
+ const check = sceneContainment(base, resolved.dir, [helper]);
172
+ if (!check.dirInside || check.escapedFiles.length > 0) {
173
+ throw new Error(`custom scene helper "${helper}" resolves outside the scene (symlink)`);
174
+ }
175
+ const full = join(resolved.dir, helper);
176
+ return (await host.exists(full)) ? host.readText(full) : undefined;
177
+ });
178
+ for (const helper of closure.files) {
179
+ await host.copyFile(join(resolved.dir, helper.path), join(targetDir, dir, helper.path));
180
+ }
181
+ }
182
+ }
183
+ }
184
+ /** Writes a derived storyboard (+ the project's themes, + extra theme files) into its own scratch dir. */
185
+ async function scratch(io, work, name, board, projectDir, extraThemes = [], origin) {
186
+ const dir = join(work, name);
187
+ await io.host.writeTextAtomic(join(dir, "storyboard.json"), `${JSON.stringify(board, null, 2)}\n`);
188
+ if (origin !== undefined)
189
+ await copyCustomScenes(io.host, board, origin, dir);
190
+ await copyThemes(io.host, projectDir, dir);
191
+ for (const theme of extraThemes) {
192
+ await io.host.writeTextAtomic(join(dir, "themes", `${theme.id}.json`), theme.text);
193
+ }
194
+ return { storyboard: join(dir, "storyboard.json"), project: join(dir, "project") };
195
+ }
196
+ /** Runs `mk still` quietly; returns the PNGs it wrote (ordered like the `--at` list) or an exit code. */
197
+ async function capture(io, scratchDir, picks, extra, clock, spent) {
198
+ const stillsDir = join(scratchDir.project, "stills");
199
+ await clearPngs(io.host, stillsDir);
200
+ const quiet = captured(io);
201
+ const began = clock();
202
+ const at = picks.map((p) => p.t).join(",");
203
+ const code = await stillCommand.run([scratchDir.storyboard, "--at", at, "--out", scratchDir.project, ...extra], quiet.io);
204
+ spent.ms += clock() - began;
205
+ if (code !== 0) {
206
+ io.stderr(quiet.err.join(""));
207
+ return code;
208
+ }
209
+ const files = await listPngs(io.host, stillsDir);
210
+ if (files.length !== picks.length) {
211
+ io.stderr(`mk preview: expected ${picks.length} stills, hyperframes wrote ${files.length}\n`);
212
+ return EXIT.ERROR;
213
+ }
214
+ return files;
215
+ }
216
+ const stripNarration = (board) => ({
217
+ ...board,
218
+ scenes: board.scenes.map((entry) => {
219
+ const { narration: _narration, ...scene } = entry;
220
+ return scene;
221
+ }),
222
+ });
223
+ /** Voice total for the full board, or undefined when the manifest cannot resolve yet. */
224
+ async function voiceTotal(io, board, audioManifest) {
225
+ try {
226
+ const parsed = parseAudioManifest(JSON.parse(await io.host.readText(audioManifest)));
227
+ if (!parsed.ok)
228
+ return undefined;
229
+ const timeline = resolveTimeline(board, parsed.manifest);
230
+ return timeline.ok ? timeline.value.total : undefined;
231
+ }
232
+ catch {
233
+ // Unreadable/invalid manifests fail closed in clipDraft below.
234
+ return undefined;
235
+ }
236
+ }
237
+ /** Full-length audible clip: voice-timed build so clip video total equals narration total. */
238
+ async function makeAudibleClip(io, board, projectDir, work, pack, extra, clock, audioManifest, origin) {
239
+ const clip = await scratch(io, work, "clip", stripNarration(board), projectDir, [], origin);
240
+ const quiet = captured(io);
241
+ const began = clock();
242
+ const code = await clipDraft.run([clip.storyboard, "--out", clip.project, ...extra, "--audio", audioManifest], quiet.io);
243
+ const ms = clock() - began;
244
+ if (code !== 0) {
245
+ const why = quiet.err.join("").trim().split("\n")[0] ?? "";
246
+ return {
247
+ result: {
248
+ status: "skipped",
249
+ reason: `draft failed (exit ${code}${why === "" ? "" : `: ${why}`})`,
250
+ },
251
+ msPerFrame: undefined,
252
+ };
253
+ }
254
+ await io.host.copyFile(join(clip.project, "draft.mp4"), join(pack, "clip.mp4"));
255
+ const voiceSec = await voiceTotal(io, board, audioManifest);
256
+ const fallback = resolveTimeline(board);
257
+ const sec = voiceSec ?? (fallback.ok ? fallback.value.total : 0);
258
+ return {
259
+ result: { status: "ok", file: "clip.mp4", sec },
260
+ msPerFrame: ms / Math.max(1, Math.round(sec * DRAFT_FPS)),
261
+ };
262
+ }
263
+ async function makeClip(io, board, projectDir, work, pack, extra, clock, audioManifest, origin) {
264
+ // With --audio the clip is the full audible review (not a capped trailer).
265
+ // Without --audio keep the capped trailer: byte-identical to today.
266
+ if (audioManifest !== undefined) {
267
+ return makeAudibleClip(io, board, projectDir, work, pack, extra, clock, audioManifest, origin);
268
+ }
269
+ const plan = capClip(board);
270
+ const clip = await scratch(io, work, "clip", plan.storyboard, projectDir, [], origin);
271
+ const quiet = captured(io);
272
+ const began = clock();
273
+ const code = await clipDraft.run([clip.storyboard, "--out", clip.project, ...extra], quiet.io);
274
+ const ms = clock() - began;
275
+ if (code !== 0) {
276
+ const why = quiet.err.join("").trim().split("\n")[0] ?? "";
277
+ const reason = `draft failed (exit ${code}${why === "" ? "" : `: ${why}`})`;
278
+ return { result: { status: "skipped", reason }, msPerFrame: undefined };
279
+ }
280
+ await io.host.copyFile(join(clip.project, "draft.mp4"), join(pack, "clip.mp4"));
281
+ const frames = Math.max(1, Math.round(plan.totalSec * DRAFT_FPS));
282
+ return {
283
+ result: { status: "ok", file: "clip.mp4", sec: plan.totalSec },
284
+ msPerFrame: ms / frames,
285
+ };
286
+ }
287
+ const summary = (m, pack, storyboard, overlays) => {
288
+ const paired = m.stills.filter((s) => s.theme !== m.theme).length;
289
+ const from = m.template === null
290
+ ? "free storyboard"
291
+ : `template ${m.template} ${m.blueprint} style ${m.style}`;
292
+ const clip = m.clip.status === "ok" ? `clip ${m.clip.sec}s` : "clip skipped";
293
+ const est = m.estFullRenderSec === null
294
+ ? "est render n/a"
295
+ : `est render ~${Math.max(1, Math.round(m.estFullRenderSec / 60))}m`;
296
+ return (`preview ok | ${from} | ${m.scenes.length} scenes + ${overlays} overlays ${m.totalSec}s ${m.frame.width}x${m.frame.height} | pack ${pack} | ` +
297
+ `contact.png ${m.stills.length - paired} stills (+${paired} paired) ${clip} | ${est} (estimate) | ` +
298
+ `lint ${m.lint.errors} err ${m.lint.warnings} warn | approve: mk approve ${storyboard}\n`);
299
+ };
300
+ const round = (value) => Math.round(value * 100) / 100;
301
+ /** Reads, validates and lints the storyboard; findings go to stderr with exit 2. */
302
+ async function loadBoard(storyboard, io) {
303
+ const loaded = await readJsonFile(storyboard, io.host);
304
+ if (!loaded.ok && loaded.kind === "io") {
305
+ io.stderr(`mk preview: ${loaded.message}\n`);
306
+ return EXIT.USAGE;
307
+ }
308
+ const report = loaded.ok
309
+ ? checkStoryboard(loaded.value, { platforms: await platformLookup(io.host) })
310
+ : { errors: [rootError(loaded.message)], warnings: [] };
311
+ const valid = loaded.ok ? validateStoryboardFull(loaded.value) : undefined;
312
+ if (report.errors.length > 0 || valid === undefined || !valid.ok) {
313
+ io.stderr(formatFindings([...report.errors, ...report.warnings]));
314
+ return EXIT.USAGE;
315
+ }
316
+ const infos = sceneInfos(valid.value);
317
+ if (infos === undefined)
318
+ return EXIT.USAGE;
319
+ const overlays = valid.value.scenes.filter((s) => s.layer === "overlay").length;
320
+ return {
321
+ board: valid.value,
322
+ report,
323
+ infos,
324
+ candidates: stillCandidates(valid.value),
325
+ overlays,
326
+ };
327
+ }
328
+ /** Stills in the storyboard's theme, plus the paired theme for the most distinct scenes. */
329
+ async function shootStills(ctx, pair, spent) {
330
+ const { io, board, projectDir, options, work, clock } = ctx;
331
+ const theme = board.meta.theme;
332
+ const picks = options.every
333
+ ? stillPlan(ctx.infos, ctx.infos.length)
334
+ : stillPlan(ctx.candidates, options.stills);
335
+ const light = await scratch(io, work, "light", board, projectDir, [], ctx.storyboard);
336
+ const files = await capture(io, light, picks, options.extra, clock, spent);
337
+ if (typeof files === "number")
338
+ return files;
339
+ const taken = picks.map((pick, i) => ({ pick, theme, source: files[i] ?? "" }));
340
+ if (pair !== undefined) {
341
+ const extra = distinctPicks(picks, DARK_COUNT);
342
+ const paired = { ...board, meta: { ...board.meta, theme: pair.id } };
343
+ const scratchDir = await scratch(io, work, "paired", paired, projectDir, [pair], ctx.storyboard);
344
+ const darkFiles = await capture(io, scratchDir, extra, options.extra, clock, spent);
345
+ if (typeof darkFiles === "number")
346
+ return darkFiles;
347
+ for (const [i, pick] of extra.entries()) {
348
+ taken.push({ pick, theme: pair.id, source: darkFiles[i] ?? "" });
349
+ }
350
+ }
351
+ return nameStills(taken, theme);
352
+ }
353
+ /** Copies the named stills into the pack and tiles them into contact.png. */
354
+ async function packStills(ctx, named) {
355
+ const { io, pack, work } = ctx;
356
+ const dir = join(pack, "stills");
357
+ await clearPngs(io.host, dir);
358
+ for (const still of named)
359
+ await io.host.copyFile(still.source, join(dir, still.name));
360
+ const files = named.map((s) => join(dir, s.name));
361
+ return contactSheet(io, files, join(work, "contact.txt"), join(pack, "contact.png"));
362
+ }
363
+ /** The clip, unless the stills already used the budget (then a labelled skip). */
364
+ async function clipOrSkip(ctx, stillsMs) {
365
+ const { io, pack, options, clock, began } = ctx;
366
+ await io.host.remove(join(pack, "clip.mp4"));
367
+ const elapsed = clock() - began;
368
+ if (shouldRunClip(elapsed, stillsMs, options.budgetSec * MS)) {
369
+ return makeClip(io, ctx.board, ctx.projectDir, ctx.work, pack, options.extra, clock, options.audioManifest, ctx.storyboard);
370
+ }
371
+ const used = `${Math.round(elapsed / MS)} s used of ${options.budgetSec} s`;
372
+ return {
373
+ result: { status: "skipped", reason: `budget: ${used}; raise --budget-sec` },
374
+ msPerFrame: undefined,
375
+ };
376
+ }
377
+ const stillRow = (s) => ({
378
+ file: `stills/${s.name}`,
379
+ scene: s.pick.id,
380
+ t: s.pick.t,
381
+ theme: s.theme,
382
+ });
383
+ /** The invocation inputs recorded in the pack; absolute so a later `mk approve` matches exactly. */
384
+ const inputsOf = (options) => ({
385
+ assetsDir: options.assetsDir ?? null,
386
+ ledger: options.ledgerPath === undefined ? null : resolve(options.ledgerPath),
387
+ audioManifest: options.audioManifest === undefined ? null : resolve(options.audioManifest),
388
+ music: options.musicPath === undefined ? null : resolve(options.musicPath),
389
+ musicVolume: options.musicVolume ?? null,
390
+ musicLicense: options.musicLicensePath === undefined ? null : resolve(options.musicLicensePath),
391
+ });
392
+ function manifestOf(ctx, facts) {
393
+ const { board, infos, report, options } = ctx;
394
+ const { provenance, clip } = facts;
395
+ const totalSec = round(Math.max(...infos.map((i) => i.start + i.duration)));
396
+ const estimate = clip.msPerFrame === undefined
397
+ ? null
398
+ : Math.round((clip.msPerFrame * totalSec * board.meta.fps) / MS);
399
+ const { width, height, fps } = board.meta;
400
+ const checklist = CHECKLIST.map((item) => item.id === "not-shown" && facts.audioMix.audible
401
+ ? {
402
+ ...item,
403
+ text: facts.audioMix.musicSha === null
404
+ ? "the user knows what is NOT previewed: motion between stills and any scene without a still"
405
+ : "the user knows what is NOT previewed: motion between stills and any scene without a still (music bed included in clip)",
406
+ }
407
+ : { ...item });
408
+ return {
409
+ hash: facts.hash,
410
+ inputManifest: facts.inputManifest,
411
+ inputManifestSha256: facts.inputManifestSha256,
412
+ storyboard: basename(ctx.storyboard),
413
+ inputs: inputsOf(options),
414
+ template: provenance?.template ?? null,
415
+ blueprint: provenance?.blueprint ?? null,
416
+ style: provenance?.style ?? null,
417
+ theme: board.meta.theme,
418
+ pair: facts.pair?.id ?? null,
419
+ frame: { width, height, fps },
420
+ totalSec,
421
+ scenes: infos.map((i) => ({ id: i.id, type: i.type, start: i.start, duration: i.duration })),
422
+ stills: facts.named.map(stillRow),
423
+ contact: "contact.png",
424
+ clip: clip.result,
425
+ estFullRenderSec: estimate,
426
+ estimateNote: ESTIMATE_NOTE,
427
+ lint: {
428
+ errors: report.errors.length,
429
+ warnings: report.warnings.length,
430
+ items: report.warnings.slice(0, MAX_LINT_ITEMS).map((w) => `${w.path || "/"}: ${w.message}`),
431
+ },
432
+ budgetSec: options.budgetSec,
433
+ elapsedSec: round((ctx.clock() - ctx.began) / MS),
434
+ audioMix: facts.audioMix,
435
+ checklist: checklist.map((item) => ({ ...item, done: false })),
436
+ };
437
+ }
438
+ /** Parses flags, loads and validates the storyboard; an exit code on any problem (or `--help`). */
439
+ async function start(args, io, clock) {
440
+ const parsed = parseFlags("preview", USAGE, args, FLAGS, io);
441
+ if (parsed.kind === "exit")
442
+ return parsed.code;
443
+ const storyboard = requireStoryboard("preview", USAGE, parsed.positionals, io);
444
+ if (typeof storyboard !== "string")
445
+ return storyboard.code;
446
+ const options = readOptions(parsed.flags, storyboard, io);
447
+ if (typeof options === "number")
448
+ return options;
449
+ const began = clock();
450
+ const loaded = await loadBoard(storyboard, io);
451
+ if (typeof loaded === "number")
452
+ return loaded;
453
+ const ctx = {
454
+ ...loaded,
455
+ io,
456
+ storyboard,
457
+ options,
458
+ projectDir: dirname(resolve(storyboard)),
459
+ work: `${packDir(options.out, storyboard)}-work`,
460
+ pack: packDir(options.out, storyboard),
461
+ clock,
462
+ began,
463
+ };
464
+ return { ctx, json: parsed.flags.json === true };
465
+ }
466
+ /** `mk preview`; `env` and `clock` are injectable for tests. */
467
+ const resolvePreview = async (io, ctx) => {
468
+ try {
469
+ const manifest = await buildApprovalInputManifest(io.host, ctx.storyboard, {
470
+ ...(ctx.options.assetsDir === undefined ? {} : { assetsDir: ctx.options.assetsDir }),
471
+ ...(ctx.options.ledgerPath === undefined ? {} : { ledgerPath: ctx.options.ledgerPath }),
472
+ ...(ctx.options.audioManifest === undefined
473
+ ? {}
474
+ : { audioManifest: ctx.options.audioManifest }),
475
+ ...(ctx.options.musicPath === undefined ? {} : { music: ctx.options.musicPath }),
476
+ ...(ctx.options.musicVolume === undefined ? {} : { musicVolume: ctx.options.musicVolume }),
477
+ ...(ctx.options.musicLicensePath === undefined
478
+ ? {}
479
+ : { musicLicense: ctx.options.musicLicensePath }),
480
+ });
481
+ return { manifest, sha: hashApprovalInput(manifest) };
482
+ }
483
+ catch (error) {
484
+ const hint = ctx.options.audioManifest === undefined
485
+ ? ""
486
+ : `; rerun \`mk preview ${ctx.storyboard} --audio ${ctx.options.audioManifest}\` after fixing the audio inputs`;
487
+ io.stderr(`mk preview: ${error.message}${hint}\n`);
488
+ return EXIT.USAGE;
489
+ }
490
+ };
491
+ const SILENT_AUDIO_MIX = {
492
+ manifestSha: null,
493
+ wavSha: null,
494
+ audible: false,
495
+ musicSha: null,
496
+ musicVolume: null,
497
+ musicLicenseSha: null,
498
+ };
499
+ /** Mixes the exact final wavs under the preview clip with the same args as the final render. */
500
+ const mixPreviewClip = async (io, pack, work, audioManifest, storyboard, expected, musicOpts = {}) => {
501
+ const rerun = `rerun \`mk preview ${storyboard} --audio ${audioManifest}\` after fixing the audio inputs`;
502
+ let text;
503
+ try {
504
+ text = await io.host.readText(audioManifest);
505
+ }
506
+ catch (error) {
507
+ io.stderr(`mk preview: cannot read --audio manifest: ${error.message}; ${rerun}\n`);
508
+ return EXIT.USAGE;
509
+ }
510
+ let raw;
511
+ try {
512
+ raw = JSON.parse(text);
513
+ }
514
+ catch (error) {
515
+ io.stderr(`mk preview: invalid --audio manifest: ${error.message}; ${rerun}\n`);
516
+ return EXIT.USAGE;
517
+ }
518
+ const parsed = parseAudioManifest(raw);
519
+ if (!parsed.ok) {
520
+ io.stderr(`mk preview: invalid --audio manifest: ${parsed.problems.join("; ")}; ${rerun}\n`);
521
+ return EXIT.USAGE;
522
+ }
523
+ const audioDir = join(dirname(resolve(audioManifest)), AUDIO_DIR_NAME);
524
+ // Place the voice sequence on the film timeline (transitions start scenes
525
+ // early), not on the manifest's cumulative starts.
526
+ const boardLoaded = await readJsonFile(storyboard, io.host);
527
+ const validBoard = boardLoaded.ok ? validateStoryboardFull(boardLoaded.value) : undefined;
528
+ const timeline = validBoard?.ok === true ? resolveTimeline(validBoard.value, parsed.manifest) : undefined;
529
+ const placements = timeline?.ok === true ? placementsFromTimeline(timeline.value) : undefined;
530
+ const clips = clipsFromManifest(parsed.manifest, audioDir, placements);
531
+ if (clips.length === 0) {
532
+ io.stderr(`mk preview: --audio manifest has no scenes; nothing to mix; ${rerun}\n`);
533
+ return EXIT.USAGE;
534
+ }
535
+ for (const clip of clips) {
536
+ const digest = await io.host.sha256File(clip.wav);
537
+ if (digest === undefined) {
538
+ io.stderr(`mk preview: missing narration wav for scene "${clip.id}": ${clip.wav}; ${rerun}\n`);
539
+ return EXIT.USAGE;
540
+ }
541
+ // AU-01 MEDIUM: ungated --audio must reject silent wavs like the manifest
542
+ // path does (EMPTY_SHA256); never mix silence as narration.
543
+ if (digest === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") {
544
+ io.stderr(`mk preview: narration wav is silent/empty for scene "${clip.id}": ${clip.wav}; ${rerun}\n`);
545
+ return EXIT.USAGE;
546
+ }
547
+ }
548
+ // RA-02: never certify audible from hashes alone. The clip must exist and
549
+ // the mix must succeed before audible:true is returned.
550
+ const clipFile = join(pack, "clip.mp4");
551
+ if (!(await io.host.exists(clipFile))) {
552
+ io.stderr(`mk preview: --audio requires an audible clip but ${clipFile} is missing (clip skipped or failed); raise --budget-sec and ${rerun}\n`);
553
+ return EXIT.USAGE;
554
+ }
555
+ const manifestSha = createHash("sha256").update(text).digest("hex");
556
+ const mixedFile = join(pack, "clip-audible.mp4");
557
+ // AU-01: freeze the exact bytes ffmpeg consumes (ABA-proof) and mix with the
558
+ // same build authority as the final render (no second pipeline).
559
+ const frozenDir = join(work, ".frozen-audio");
560
+ let mixBed;
561
+ let musicSha = null;
562
+ let musicVolume = null;
563
+ let musicLicenseSha = null;
564
+ const frozenNarration = frozenClips(clips, frozenDir);
565
+ const checks = [];
566
+ const frozenDigests = [];
567
+ try {
568
+ for (const [index, clip] of frozenNarration.entries()) {
569
+ const source = clips[index];
570
+ if (source === undefined)
571
+ continue;
572
+ await io.host.copyFile(source.wav, clip.wav);
573
+ const frozenSha = await io.host.sha256File(clip.wav);
574
+ frozenDigests.push(frozenSha ?? "");
575
+ checks.push({
576
+ label: `narration "${clip.id}"`,
577
+ actualSha256: frozenSha,
578
+ expectedSha256: expected.mix?.clips[index]?.wavSha,
579
+ });
580
+ }
581
+ }
582
+ catch (error) {
583
+ io.stderr(`mk preview: cannot freeze narration wavs: ${error.message}; ${rerun}\n`);
584
+ return EXIT.ERROR;
585
+ }
586
+ const wavSha = createHash("sha256").update(frozenDigests.join("\n")).digest("hex");
587
+ if (musicOpts.musicPath !== undefined) {
588
+ const validated = validateMusicBed(musicOpts.musicVolume === undefined
589
+ ? { wav: musicOpts.musicPath }
590
+ : { wav: musicOpts.musicPath, volume: musicOpts.musicVolume });
591
+ if (!validated.ok) {
592
+ io.stderr(`mk preview: invalid --music bed: ${validated.hint}; ${rerun}\n`);
593
+ return EXIT.USAGE;
594
+ }
595
+ const bedDigest = await io.host.sha256File(musicOpts.musicPath);
596
+ if (bedDigest === undefined) {
597
+ io.stderr(`mk preview: music bed wav not found: ${musicOpts.musicPath}; ${rerun}\n`);
598
+ return EXIT.USAGE;
599
+ }
600
+ if (bedDigest === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") {
601
+ io.stderr(`mk preview: music bed wav is silent/empty: ${musicOpts.musicPath}; ${rerun}\n`);
602
+ return EXIT.USAGE;
603
+ }
604
+ if (musicOpts.musicLicensePath !== undefined) {
605
+ const licDigest = await io.host.sha256File(musicOpts.musicLicensePath);
606
+ if (licDigest === undefined) {
607
+ io.stderr(`mk preview: cannot read --music-license ${musicOpts.musicLicensePath}: not found; ${rerun}\n`);
608
+ return EXIT.USAGE;
609
+ }
610
+ musicLicenseSha = licDigest;
611
+ }
612
+ const frozenBed = frozenMusic({ wav: musicOpts.musicPath, volume: validated.bed.volume }, frozenDir);
613
+ try {
614
+ await io.host.copyFile(musicOpts.musicPath, frozenBed.wav);
615
+ }
616
+ catch (error) {
617
+ io.stderr(`mk preview: cannot freeze music bed: ${error.message}; ${rerun}\n`);
618
+ return EXIT.ERROR;
619
+ }
620
+ const frozenBedSha = await io.host.sha256File(frozenBed.wav);
621
+ checks.push({
622
+ label: "music bed",
623
+ actualSha256: frozenBedSha,
624
+ expectedSha256: expected.music?.sha256,
625
+ });
626
+ if (musicLicenseSha !== null) {
627
+ checks.push({
628
+ label: "music license evidence",
629
+ actualSha256: musicLicenseSha,
630
+ expectedSha256: expected.music?.licenseEvidenceSha256 ?? undefined,
631
+ });
632
+ }
633
+ musicSha = frozenBedSha ?? null;
634
+ musicVolume = validated.bed.volume;
635
+ mixBed = frozenBed;
636
+ }
637
+ // ADR-0008 D2: the audible clip must mix the bytes the preview manifest
638
+ // describes. An edit during the copy that is restored before the post-media
639
+ // re-resolve is invisible to that re-resolve; the frozen copy is not. The
640
+ // clip list must also still line up with the manifest's mix (count, id,
641
+ // start, mode): the manifest is re-read for the mix, so an extra or reordered
642
+ // clip must not ride along on matching file digests.
643
+ const frozenProblems = frozenCopyProblems(checks);
644
+ if (expected.mix !== undefined) {
645
+ if (expected.mix.clips.length !== clips.length) {
646
+ frozenProblems.push(`the previewed mix has ${expected.mix.clips.length} clips but ${clips.length} were prepared`);
647
+ }
648
+ for (const [index, clip] of clips.entries()) {
649
+ const want = expected.mix.clips[index];
650
+ if (want !== undefined && (want.id !== clip.id || want.start !== clip.start)) {
651
+ frozenProblems.push(`narration ${index + 1} does not match the previewed clip "${want.id}" at ${want.start}s`);
652
+ }
653
+ }
654
+ const mode = clips.length === 1 ? "single" : isContiguous(clips) ? "concat" : "adelay";
655
+ if (expected.mix.mode !== mode) {
656
+ frozenProblems.push(`the previewed mix mode "${expected.mix.mode}" does not match "${mode}"`);
657
+ }
658
+ }
659
+ if (frozenProblems.length > 0) {
660
+ io.stderr(`mk preview: inputs changed while the pack was produced; the pack was not written (${frozenProblems.join("; ")}); ${rerun}\n`);
661
+ return EXIT.USAGE;
662
+ }
663
+ const delivery = await mixAtDeliveryLevel((argv) => io.run("ffmpeg", argv), {
664
+ videoIn: clipFile,
665
+ clips: frozenNarration,
666
+ ...(mixBed === undefined ? {} : { music: mixBed }),
667
+ out: mixedFile,
668
+ swap: async (from, to) => {
669
+ await io.host.copyFile(from, to);
670
+ await io.host.remove(from);
671
+ },
672
+ });
673
+ if (!delivery.ok) {
674
+ const why = delivery.problems[0] ?? "";
675
+ io.stderr(`mk preview: audible clip mix failed${why === "" ? "" : `: ${why}`}; ${rerun}\n`);
676
+ return EXIT.ERROR;
677
+ }
678
+ if (!(await io.host.exists(mixedFile))) {
679
+ io.stderr(`mk preview: audible clip mix produced no file; ${rerun}\n`);
680
+ return EXIT.ERROR;
681
+ }
682
+ await io.host.copyFile(mixedFile, clipFile);
683
+ await io.host.remove(mixedFile);
684
+ return { manifestSha, wavSha, audible: true, musicSha, musicVolume, musicLicenseSha };
685
+ };
686
+ export const makePreviewCommand = (env = defaultEnv, clock = () => performance.now()) => ({
687
+ name: "preview",
688
+ summary: "build the short proposal pack (contact sheet, stills, clip) for approval",
689
+ async run(args, io) {
690
+ const started = await start(args, io, clock);
691
+ if (typeof started === "number")
692
+ return started;
693
+ const { ctx, json } = started;
694
+ const { storyboard } = ctx;
695
+ const provenance = await readProvenance(io.host, storyboard);
696
+ const pair = resolvePair(env(), io, provenance, ctx.board.meta.theme);
697
+ const spent = { ms: 0 };
698
+ // RA-01: freeze the approval identity before any media is produced. The
699
+ // post-media re-resolve below must match, or the pack is unusable: the
700
+ // identity must describe the bytes the reviewer actually saw.
701
+ const snapshot = await resolvePreview(io, ctx);
702
+ if (typeof snapshot === "number")
703
+ return snapshot;
704
+ const frozenHash = await storyboardHash(io.host, storyboard);
705
+ const named = await shootStills(ctx, pair, spent);
706
+ if (typeof named === "number")
707
+ return named;
708
+ const packed = await packStills(ctx, named);
709
+ if (packed !== EXIT.OK)
710
+ return packed;
711
+ const clip = await clipOrSkip(ctx, spent.ms);
712
+ let audioMix;
713
+ if (ctx.options.audioManifest === undefined) {
714
+ audioMix = SILENT_AUDIO_MIX;
715
+ }
716
+ else {
717
+ // RA-02: an audiovisual pack is only audible after a playable mixed
718
+ // clip exists. A budget-skipped clip never certifies from hashes.
719
+ if (clip.result.status !== "ok") {
720
+ const why = clip.result.status === "skipped" ? ` (${clip.result.reason})` : " (missing)";
721
+ io.stderr(`mk preview: --audio requires an audible clip but the clip was skipped${why}; raise --budget-sec and rerun \`mk preview ${storyboard} --audio ${ctx.options.audioManifest}\`\n`);
722
+ return EXIT.USAGE;
723
+ }
724
+ const mixed = await mixPreviewClip(io, ctx.pack, ctx.work, ctx.options.audioManifest, storyboard, snapshot.manifest, {
725
+ ...(ctx.options.musicPath === undefined ? {} : { musicPath: ctx.options.musicPath }),
726
+ ...(ctx.options.musicVolume === undefined
727
+ ? {}
728
+ : { musicVolume: ctx.options.musicVolume }),
729
+ ...(ctx.options.musicLicensePath === undefined
730
+ ? {}
731
+ : { musicLicensePath: ctx.options.musicLicensePath }),
732
+ });
733
+ if (typeof mixed === "number")
734
+ return mixed;
735
+ audioMix = mixed;
736
+ }
737
+ // RA-01: an edit that landed while stills/clip/mix ran must not silently
738
+ // rebind the pack to bytes the reviewer never saw. Fail closed without
739
+ // writing preview.json; `mk approve` also rechecks the binding.
740
+ const reverified = await resolvePreview(io, ctx);
741
+ if (typeof reverified === "number")
742
+ return reverified;
743
+ if (reverified.sha !== snapshot.sha) {
744
+ io.stderr(`mk preview: inputs changed while the pack was produced; the pack was not written. Rerun \`mk preview ${storyboard}\`.\n`);
745
+ return EXIT.USAGE;
746
+ }
747
+ const manifest = manifestOf(ctx, {
748
+ hash: frozenHash,
749
+ inputManifest: snapshot.manifest,
750
+ inputManifestSha256: snapshot.sha,
751
+ provenance,
752
+ pair,
753
+ named,
754
+ clip,
755
+ audioMix,
756
+ });
757
+ const text = `${JSON.stringify(manifest, null, 2)}\n`;
758
+ await io.host.writeTextAtomic(join(ctx.pack, PREVIEW_JSON), text);
759
+ io.stdout(json ? text : summary(manifest, ctx.pack, storyboard, ctx.overlays));
760
+ io.stderr(formatFindings(ctx.report.warnings.slice(0, MAX_LINT_ITEMS)));
761
+ return EXIT.OK;
762
+ },
763
+ });
764
+ export const previewCommand = makePreviewCommand();