@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,705 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { validateMusicBed } from "@anvpx/motion-audio";
3
+ import { AUDIO_DIR_NAME, clipsFromManifest, compiledAssetMismatches, frozenClips, frozenCopyProblems, frozenMusic, isContiguous, missingMusic, mixAtDeliveryLevel, parseAudioManifest, } from "@anvpx/motion-build";
4
+ import { approvalState, readApproval } from "../approval/approval.js";
5
+ import { buildApprovalInputManifest, hashApprovalInput, } from "../approval/hash.js";
6
+ import { textFlag, usageError } from "../args.js";
7
+ import { EXIT } from "../exit-codes.js";
8
+ import { readJsonFile } from "../storyboard/load.js";
9
+ import { runInProject } from "../tooling/session.js";
10
+ import { finalExpectation } from "../verify/expectation.js";
11
+ import { finishRender } from "./finish.js";
12
+ import { parseBuildArgs, prepareProject } from "./project.js";
13
+ const USAGE = `mk render <storyboard.json> [--out <dir>] [--fps 30] [--format mp4|webm|mov|gif|png-sequence] [--resolution 1080p|4k] [--resume] [--assets <dir>] [--ledger <assets.json>] [--verify] [--gated] [--audio <manifest>] [--music <wav>]
14
+
15
+ Build, then render the final H.264 mp4 to <out>/final.mp4 with \`hyperframes render --quality delivery\`
16
+ (x264 preset slow, CRF 15; size and colour come from the storyboard meta).
17
+ --fps <n> integer frame rate 1-240 (default 30)
18
+ --format f delivery container: mp4 (default), webm, mov (ProRes 4444), gif or png-sequence.
19
+ Non-mp4 formats are converted from the rendered master with ffmpeg; \`--verify\` stays mp4-only.
20
+ --resolution r 1080p (default) or 4k (landscape/portrait/square chosen from the storyboard frame)
21
+ --resume continue an interrupted render: uses hyperframes' segmented capture
22
+ (HF_SEGMENTED_CAPTURE=true + --resume) and reuses finished segments
23
+ --verify re-probe final.mp4 (see \`mk verify\`); exit 5 on FAIL
24
+ --audio <manifest>
25
+ voice TimingManifest: mix each scene's wav (the manifest's sibling
26
+ audio/ dir, <id>.wav) under the render at its absolute start.
27
+ Absent means silent video; a missing/silent wav fails instead of silence.
28
+ With gated, the identical approved audio manifest is required:
29
+ any wav, manifest, mix, bed, volume or license drift exits 4;
30
+ a visual-only receipt never authorizes audio, narration-only
31
+ never authorizes music.
32
+ --music <wav>
33
+ user CC0 music bed: loops to the narration length, ducks under
34
+ narration via sidechaincompress (same buildMusicMixArgs as
35
+ preview), then joins it. Requires --audio; a missing/silent bed
36
+ wav fails instead of silence. With --gated the identical approved
37
+ bed, volume and license receipt are required: any drift exits 4.
38
+ Music is covered by the same gate, not a parallel one.
39
+ --music-volume <n> bed level within (0, 1] (default 0.5)
40
+ --music-license <file> license-evidence file hashed into the receipt (hash only)
41
+ --gated require a current approval (storyboard + themes + exact
42
+ referenced assets + ledger + (for audiovisual) audio manifest, no timestamp/machine path in hash);
43
+ any mismatch exits 4 with one fix line; inputs are rechecked
44
+ before publication so a mid-render edit cannot slip through.
45
+ Output of hyperframes: <out>/render-log.txt; provenance: <out>/render-manifest.json.
46
+ `;
47
+ const FINAL = "final.mp4";
48
+ /** Silent intermediate when --audio mixes narration underneath. */
49
+ const SILENT = "final-silent.mp4";
50
+ /** This attempt's mixed output; published over FINAL only when delivery succeeded. */
51
+ const MIX_TEMP = "final-mix.mp4";
52
+ /** This attempt's direct video (visual-only gated run); published only after the last gate. */
53
+ const VIDEO_TEMP = "final-video.mp4";
54
+ /** Frozen approved audio: the only bytes ffmpeg is allowed to consume. */
55
+ const FROZEN_DIR = ".frozen-audio";
56
+ const DEFAULT_FPS = "30";
57
+ const MAX_FPS = 240;
58
+ /** hyperframes only honours --resume when segmented capture is switched on. */
59
+ const SEGMENTED_CAPTURE = { HF_SEGMENTED_CAPTURE: "true" };
60
+ /** Delivery containers the command can write (the master is always final.mp4). */
61
+ export const DELIVERY_FORMATS = ["mp4", "webm", "mov", "gif", "png-sequence"];
62
+ export const RESOLUTIONS = ["1080p", "4k"];
63
+ const isDeliveryFormat = (value) => DELIVERY_FORMATS.includes(value);
64
+ /** `4k` maps to the hyperframes preset that matches the storyboard's aspect. */
65
+ export const resolutionPreset = (width, height) => {
66
+ const ratio = width / height;
67
+ if (ratio > 1.2)
68
+ return "landscape-4k";
69
+ if (ratio < 0.8)
70
+ return "portrait-4k";
71
+ return "square-4k";
72
+ };
73
+ /** Output of a non-mp4 format: `final.<ext>`, or the `final.frames` directory for a PNG sequence. */
74
+ export const exportFile = (outDir, format) => join(outDir, format === "png-sequence" ? "final.frames" : `final.${format}`);
75
+ /** ffmpeg argv for the delivery conversion; the master mp4 is the source of truth. */
76
+ export const exportArgs = (master, format, outDir) => {
77
+ const file = exportFile(outDir, format);
78
+ const target = format === "png-sequence" ? join(file, "%04d.png") : file;
79
+ const head = ["-y", "-hide_banner", "-loglevel", "error", "-i", master];
80
+ switch (format) {
81
+ case "webm":
82
+ return {
83
+ file,
84
+ args: [
85
+ ...head,
86
+ "-c:v",
87
+ "libvpx-vp9",
88
+ "-b:v",
89
+ "0",
90
+ "-crf",
91
+ "32",
92
+ "-c:a",
93
+ "libopus",
94
+ "-b:a",
95
+ "128k",
96
+ target,
97
+ ],
98
+ };
99
+ case "mov":
100
+ return {
101
+ file,
102
+ args: [...head, "-c:v", "prores_ks", "-profile:v", "4444", "-c:a", "pcm_s16le", target],
103
+ };
104
+ case "gif":
105
+ return {
106
+ file,
107
+ args: [
108
+ ...head,
109
+ "-vf",
110
+ "fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
111
+ "-loop",
112
+ "0",
113
+ target,
114
+ ],
115
+ };
116
+ case "png-sequence":
117
+ return { file, args: [...head, "-vsync", "0", target] };
118
+ }
119
+ };
120
+ /** Converts the published master into the requested delivery format. */
121
+ export async function exportArtifact(io, outDir, format) {
122
+ const master = join(outDir, FINAL);
123
+ const { args, file } = exportArgs(master, format, outDir);
124
+ if (format === "png-sequence")
125
+ await io.host.mkdir?.(file);
126
+ const result = await io.run("ffmpeg", args);
127
+ if (result.code !== 0) {
128
+ io.stderr(`mk render: converting to ${format} failed: ${result.stderr.slice(-500) || `exit ${result.code}`}\n`);
129
+ return { ok: false, code: EXIT.ERROR };
130
+ }
131
+ return { ok: true, file };
132
+ }
133
+ /** sha256 of zero bytes: a silent/empty wav can never qualify as audible proof. */
134
+ const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
135
+ const isFps = (text) => /^\d+$/.test(text) && Number(text) >= 1 && Number(text) <= MAX_FPS;
136
+ /** Loads a voice TimingManifest plus its sibling wavs; fails closed, never silent. */
137
+ async function loadNarration(name, audioPath, io) {
138
+ const loaded = await readJsonFile(audioPath, io.host);
139
+ if (!loaded.ok) {
140
+ io.stderr(`mk ${name}: cannot read --audio manifest: ${loaded.message}\n`);
141
+ return { ok: false, code: EXIT.USAGE };
142
+ }
143
+ const parsed = parseAudioManifest(loaded.value);
144
+ if (!parsed.ok) {
145
+ io.stderr(`mk ${name}: invalid --audio manifest: ${parsed.problems.join("; ")}\n`);
146
+ return { ok: false, code: EXIT.USAGE };
147
+ }
148
+ const audioDir = join(dirname(resolve(audioPath)), AUDIO_DIR_NAME);
149
+ const clips = clipsFromManifest(parsed.manifest, audioDir);
150
+ if (clips.length === 0) {
151
+ io.stderr(`mk ${name}: --audio manifest has no scenes; nothing to mix\n`);
152
+ return { ok: false, code: EXIT.USAGE };
153
+ }
154
+ for (const clip of clips) {
155
+ if (!(await io.host.exists(clip.wav))) {
156
+ io.stderr(`mk ${name}: missing narration wav for scene "${clip.id}": ${clip.wav}\n`);
157
+ return { ok: false, code: EXIT.ERROR };
158
+ }
159
+ // AU-01 MEDIUM: ungated --audio must reject silent wavs like the manifest
160
+ // path does (hash.ts EMPTY_SHA256); never mix silence as narration.
161
+ const digest = await io.host.sha256File(clip.wav);
162
+ if (digest === EMPTY_SHA256) {
163
+ io.stderr(`mk ${name}: narration wav is silent/empty for scene "${clip.id}": ${clip.wav}\n`);
164
+ return { ok: false, code: EXIT.USAGE };
165
+ }
166
+ }
167
+ return { ok: true, clips, manifest: parsed.manifest };
168
+ }
169
+ /** Validates the explicit user bed through the audio guard; fails closed, never silent. */
170
+ async function loadMusic(name, musicPath, io, musicVolume) {
171
+ const validated = validateMusicBed(musicVolume === undefined ? { wav: musicPath } : { wav: musicPath, volume: musicVolume });
172
+ if (!validated.ok) {
173
+ io.stderr(`mk ${name}: invalid --music bed: ${validated.hint}\n`);
174
+ return { ok: false, code: EXIT.USAGE };
175
+ }
176
+ const present = await io.host.exists(validated.bed.wav);
177
+ if (missingMusic(validated.bed, () => present).length > 0) {
178
+ io.stderr(`mk ${name}: missing music bed wav: ${validated.bed.wav}\n`);
179
+ return { ok: false, code: EXIT.ERROR };
180
+ }
181
+ const digest = await io.host.sha256File(validated.bed.wav);
182
+ if (digest === undefined) {
183
+ io.stderr(`mk ${name}: missing music bed wav: ${validated.bed.wav}\n`);
184
+ return { ok: false, code: EXIT.ERROR };
185
+ }
186
+ if (digest === EMPTY_SHA256) {
187
+ io.stderr(`mk ${name}: music bed wav is silent/empty: ${validated.bed.wav}\n`);
188
+ return { ok: false, code: EXIT.USAGE };
189
+ }
190
+ return { ok: true, music: validated.bed };
191
+ }
192
+ /** Request with the voice timing override, or the base request without audio. */
193
+ const withVoiceTiming = (request, manifest) => (manifest === undefined ? request : { ...request, timing: manifest });
194
+ /** Parses flags and fails fast on bad input or a missing voice wav. */
195
+ async function resolvePlan(args, io) {
196
+ const flags = {
197
+ fps: { type: "string" },
198
+ format: { type: "string" },
199
+ resolution: { type: "string" },
200
+ resume: { type: "boolean" },
201
+ verify: { type: "boolean" },
202
+ gated: { type: "boolean" },
203
+ audio: { type: "string" },
204
+ music: { type: "string" },
205
+ "music-volume": { type: "string" },
206
+ "music-license": { type: "string" },
207
+ };
208
+ const parsed = parseBuildArgs("render", USAGE, args, flags, io);
209
+ if (parsed.kind === "exit")
210
+ return { code: parsed.code };
211
+ const fps = textFlag(parsed.flags, "fps") ?? DEFAULT_FPS;
212
+ if (!isFps(fps))
213
+ return { code: usageError("render", USAGE, `--fps must be an integer 1-${MAX_FPS}`, io).code };
214
+ const formatRaw = textFlag(parsed.flags, "format") ?? "mp4";
215
+ if (!isDeliveryFormat(formatRaw)) {
216
+ return {
217
+ code: usageError("render", USAGE, `--format must be ${DELIVERY_FORMATS.join("|")}`, io).code,
218
+ };
219
+ }
220
+ const resolutionRaw = textFlag(parsed.flags, "resolution") ?? "1080p";
221
+ if (resolutionRaw !== "1080p" && resolutionRaw !== "4k") {
222
+ return {
223
+ code: usageError("render", USAGE, `--resolution must be ${RESOLUTIONS.join("|")}`, io).code,
224
+ };
225
+ }
226
+ if (formatRaw !== "mp4" && parsed.flags.verify === true) {
227
+ return {
228
+ code: usageError("render", USAGE, "--verify supports the mp4 master only", io).code,
229
+ };
230
+ }
231
+ const audioPath = textFlag(parsed.flags, "audio");
232
+ const musicPath = textFlag(parsed.flags, "music");
233
+ const musicVolumeRaw = textFlag(parsed.flags, "music-volume");
234
+ const musicLicensePath = textFlag(parsed.flags, "music-license");
235
+ let musicVolume;
236
+ if (musicVolumeRaw !== undefined) {
237
+ musicVolume = Number(musicVolumeRaw);
238
+ if (!Number.isFinite(musicVolume) || musicVolume <= 0 || musicVolume > 1) {
239
+ return { code: usageError("render", USAGE, "--music-volume must be within (0, 1]", io).code };
240
+ }
241
+ }
242
+ if ((musicVolumeRaw !== undefined || musicLicensePath !== undefined) && musicPath === undefined) {
243
+ return {
244
+ code: usageError("render", USAGE, "--music-volume/--music-license need --music", io).code,
245
+ };
246
+ }
247
+ if (musicPath !== undefined && audioPath === undefined) {
248
+ return {
249
+ code: usageError("render", USAGE, "--music needs --audio: the bed loops to the narration length", io).code,
250
+ };
251
+ }
252
+ const narration = audioPath === undefined ? undefined : await loadNarration("render", audioPath, io);
253
+ if (narration !== undefined && !narration.ok)
254
+ return { code: narration.code };
255
+ const bedded = musicPath === undefined ? undefined : await loadMusic("render", musicPath, io, musicVolume);
256
+ if (bedded !== undefined && !bedded.ok)
257
+ return { code: bedded.code };
258
+ if (musicLicensePath !== undefined) {
259
+ const licDigest = await io.host.sha256File(musicLicensePath);
260
+ if (licDigest === undefined) {
261
+ io.stderr(`mk render: cannot read --music-license ${musicLicensePath}: not found\n`);
262
+ return { code: EXIT.USAGE };
263
+ }
264
+ }
265
+ let resolutionArg;
266
+ if (resolutionRaw === "4k") {
267
+ const loaded = await readJsonFile(parsed.request.storyboard, io.host);
268
+ if (!loaded.ok) {
269
+ io.stderr(`mk render: cannot read the storyboard for --resolution 4k: ${loaded.message}\n`);
270
+ return { code: EXIT.USAGE };
271
+ }
272
+ const meta = loaded.value.meta;
273
+ const width = typeof meta?.width === "number" ? meta.width : 1920;
274
+ const height = typeof meta?.height === "number" ? meta.height : 1080;
275
+ resolutionArg = resolutionPreset(width, height);
276
+ }
277
+ return {
278
+ request: withVoiceTiming(parsed.request, narration?.manifest),
279
+ fps,
280
+ format: formatRaw,
281
+ resolution: resolutionRaw,
282
+ resolutionArg,
283
+ resume: parsed.flags.resume === true,
284
+ verify: parsed.flags.verify === true,
285
+ gated: parsed.flags.gated === true,
286
+ narration: narration?.clips,
287
+ audioManifest: audioPath,
288
+ music: bedded?.music,
289
+ musicPath,
290
+ musicVolume,
291
+ musicLicensePath,
292
+ };
293
+ }
294
+ /**
295
+ * `--gated`: undefined when the CURRENT inputs (visual or audiovisual) are
296
+ * approved, else EXIT.AWAITING_APPROVAL with one fix line. A visual-only
297
+ * receipt never authorizes audio: resolving with --audio recomputes the
298
+ * audiovisual hash, which cannot match. Fails closed on unreadable
299
+ * inputs (missing asset, ledger, wav…): exit 2. An unreadable storyboard is
300
+ * left to the normal build path to report.
301
+ */
302
+ async function gatedGate(io, request, audioManifest, musicOpts = {}) {
303
+ let state;
304
+ try {
305
+ state = await approvalState(io.host, request.storyboard, {
306
+ ...(request.assetsDir === undefined ? {} : { assetsDir: request.assetsDir }),
307
+ ...(request.ledger === undefined ? {} : { ledgerPath: request.ledger }),
308
+ ...(audioManifest === undefined ? {} : { audioManifest }),
309
+ ...(musicOpts.musicPath === undefined ? {} : { music: musicOpts.musicPath }),
310
+ ...(musicOpts.musicVolume === undefined ? {} : { musicVolume: musicOpts.musicVolume }),
311
+ ...(musicOpts.musicLicensePath === undefined
312
+ ? {}
313
+ : { musicLicense: musicOpts.musicLicensePath }),
314
+ });
315
+ }
316
+ catch (error) {
317
+ io.stderr(`mk render: cannot resolve approval inputs for gated render: ${error.message}\n`);
318
+ return EXIT.USAGE;
319
+ }
320
+ if (state.state === "approved")
321
+ return undefined;
322
+ const scope = audioManifest === undefined ? "visual" : "audiovisual";
323
+ const why = state.state === "stale"
324
+ ? `the ${scope} inputs (storyboard, themes, referenced assets, ledger${audioManifest === undefined ? "" : ", audio manifest, wavs, mix, bed, volume or license"}) changed since approval`
325
+ : "no approval recorded";
326
+ return awaitingApproval(io, request, audioManifest, state.hash, why, musicOpts);
327
+ }
328
+ /** One AWAITING_APPROVAL line with the fix: preview, show the user, explicit yes, approve. */
329
+ function awaitingApproval(io, request, audioManifest, hash, why, musicOpts = {}) {
330
+ const audioFlag = audioManifest === undefined ? "" : ` --audio ${audioManifest}`;
331
+ const musicFlag = musicOpts.musicPath === undefined ? "" : ` --music ${musicOpts.musicPath}`;
332
+ const musicVolumeFlag = musicOpts.musicVolume === undefined ? "" : ` --music-volume ${musicOpts.musicVolume}`;
333
+ const musicLicenseFlag = musicOpts.musicLicensePath === undefined
334
+ ? ""
335
+ : ` --music-license ${musicOpts.musicLicensePath}`;
336
+ const audioMusicFlags = `${audioFlag}${musicFlag}${musicVolumeFlag}${musicLicenseFlag}`;
337
+ io.stderr(`mk: AWAITING_APPROVAL (${hash.slice(0, 12)}): ${why}. Run \`mk preview ${request.storyboard}${audioMusicFlags}\`, show the user the pack, and only after an explicit yes run \`mk approve ${request.storyboard}${audioMusicFlags}\`.\n`);
338
+ return EXIT.AWAITING_APPROVAL;
339
+ }
340
+ async function checkPrePublish(io, request, audioManifest, outDir, musicOpts = {}) {
341
+ let manifest;
342
+ try {
343
+ manifest = await buildApprovalInputManifest(io.host, request.storyboard, {
344
+ ...(request.assetsDir === undefined ? {} : { assetsDir: request.assetsDir }),
345
+ ...(request.ledger === undefined ? {} : { ledgerPath: request.ledger }),
346
+ ...(audioManifest === undefined ? {} : { audioManifest }),
347
+ ...(musicOpts.musicPath === undefined ? {} : { music: musicOpts.musicPath }),
348
+ ...(musicOpts.musicVolume === undefined ? {} : { musicVolume: musicOpts.musicVolume }),
349
+ ...(musicOpts.musicLicensePath === undefined
350
+ ? {}
351
+ : { musicLicense: musicOpts.musicLicensePath }),
352
+ });
353
+ }
354
+ catch (error) {
355
+ io.stderr(`mk render: cannot resolve approval inputs for gated render: ${error.message}\n`);
356
+ return { ok: false, code: EXIT.USAGE };
357
+ }
358
+ const sha = hashApprovalInput(manifest);
359
+ const stored = await readApproval(io.host, request.storyboard);
360
+ if (stored === undefined || stored.inputManifestSha256 !== sha) {
361
+ const scope = audioManifest === undefined ? "visual" : "audiovisual";
362
+ const why = stored === undefined
363
+ ? "no approval recorded"
364
+ : `the ${scope} inputs (storyboard, themes, referenced assets, ledger${audioManifest === undefined ? "" : ", audio manifest, wavs, mix, bed, volume or license"}) changed since approval`;
365
+ return {
366
+ ok: false,
367
+ code: awaitingApproval(io, request, audioManifest, sha, why, musicOpts),
368
+ };
369
+ }
370
+ let files;
371
+ try {
372
+ const parsed = JSON.parse(await io.host.readText(join(outDir, "manifest.json")));
373
+ if (typeof parsed !== "object" || parsed === null || !("files" in parsed)) {
374
+ throw new Error("compiled manifest has no files map");
375
+ }
376
+ const candidate = parsed.files;
377
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
378
+ throw new Error("compiled manifest has no files map");
379
+ }
380
+ files = candidate;
381
+ }
382
+ catch (error) {
383
+ io.stderr(`mk render: cannot verify the compiled snapshot before publishing: ${error.message}\n`);
384
+ return { ok: false, code: EXIT.ERROR };
385
+ }
386
+ const mismatches = compiledAssetMismatches(files, manifest.assets);
387
+ if (mismatches.length > 0) {
388
+ return {
389
+ ok: false,
390
+ code: awaitingApproval(io, request, audioManifest, sha, `the built media changed since approval (${mismatches.join("; ")})`, musicOpts),
391
+ };
392
+ }
393
+ return { ok: true, manifest, sha };
394
+ }
395
+ /**
396
+ * ADR-0008 D1/D2: copies the approved narration and bed into the frozen layout
397
+ * before any external tool runs, and checks the copies against the approved
398
+ * digests twice - right after the copy (catches a file that changes only while
399
+ * its bytes are read and is restored before the post-run recheck) and again
400
+ * immediately before ffmpeg opens them (the render can take minutes; the post
401
+ * run gate re-resolves the mutable sources, not the frozen copies). Both checks
402
+ * refuse through the existing approval path (exit 4); missing or empty copies
403
+ * fail even without an approval.
404
+ */
405
+ async function copyFrozenInputs(io, outDir, narration, music) {
406
+ const frozenDir = join(outDir, FROZEN_DIR);
407
+ const frozenNarration = frozenClips(narration, frozenDir);
408
+ const frozenBed = music === undefined ? undefined : frozenMusic(music, frozenDir);
409
+ for (const [index, clip] of frozenNarration.entries()) {
410
+ const source = narration[index];
411
+ if (source === undefined)
412
+ continue;
413
+ try {
414
+ await io.host.copyFile(source.wav, clip.wav);
415
+ }
416
+ catch (error) {
417
+ io.stderr(`mk render: cannot freeze narration wavs: ${error.message}\n`);
418
+ return { ok: false, code: EXIT.ERROR };
419
+ }
420
+ }
421
+ if (frozenBed !== undefined && music !== undefined) {
422
+ try {
423
+ await io.host.copyFile(music.wav, frozenBed.wav);
424
+ }
425
+ catch (error) {
426
+ io.stderr(`mk render: cannot freeze music bed: ${error.message}\n`);
427
+ return { ok: false, code: EXIT.ERROR };
428
+ }
429
+ }
430
+ return {
431
+ ok: true,
432
+ narration: frozenNarration,
433
+ ...(frozenBed === undefined ? {} : { music: frozenBed }),
434
+ };
435
+ }
436
+ /**
437
+ * Every reason the frozen state is not the approved state: missing/empty/
438
+ * mismatched bytes, and a clip list that no longer lines up with the approved
439
+ * `mix.clips` (count, id, start, mix mode). Alignment matters because the
440
+ * narration used for the build is loaded before the approval is re-resolved; an
441
+ * extra or reordered clip must not ride along on a matching file digest.
442
+ */
443
+ async function frozenApprovalProblems(io, frozen, narration, plan, approved) {
444
+ const checks = [];
445
+ for (const [index, clip] of frozen.narration.entries()) {
446
+ checks.push({
447
+ label: `narration "${clip.id}" (${clip.wav})`,
448
+ actualSha256: await io.host.sha256File(clip.wav),
449
+ expectedSha256: approved?.manifest.mix?.clips[index]?.wavSha,
450
+ });
451
+ }
452
+ if (frozen.music !== undefined) {
453
+ checks.push({
454
+ label: `music bed (${frozen.music.wav})`,
455
+ actualSha256: await io.host.sha256File(frozen.music.wav),
456
+ expectedSha256: approved?.manifest.music?.sha256,
457
+ });
458
+ if (plan.musicLicensePath !== undefined && approved?.manifest.music !== undefined) {
459
+ checks.push({
460
+ label: `music license evidence (${plan.musicLicensePath})`,
461
+ actualSha256: await io.host.sha256File(plan.musicLicensePath),
462
+ expectedSha256: approved.manifest.music.licenseEvidenceSha256 ?? undefined,
463
+ });
464
+ }
465
+ }
466
+ const problems = frozenCopyProblems(checks);
467
+ if (approved === undefined)
468
+ return problems;
469
+ const expected = approved.manifest.mix?.clips ?? [];
470
+ if (expected.length !== narration.length) {
471
+ problems.push(`the approved mix has ${expected.length} clips but ${narration.length} were prepared`);
472
+ }
473
+ for (const [index, clip] of narration.entries()) {
474
+ const want = expected[index];
475
+ if (want === undefined)
476
+ continue;
477
+ if (want.id !== clip.id || want.start !== clip.start) {
478
+ problems.push(`narration ${index + 1} does not match the approved clip "${want.id}" at ${want.start}s`);
479
+ }
480
+ }
481
+ if (approved.manifest.mix !== undefined) {
482
+ const mode = narration.length === 1 ? "single" : isContiguous(narration) ? "concat" : "adelay";
483
+ if (approved.manifest.mix.mode !== mode) {
484
+ problems.push(`the approved mix mode "${approved.manifest.mix.mode}" does not match "${mode}"`);
485
+ }
486
+ }
487
+ return problems;
488
+ }
489
+ /** Prints the refusal and returns the code: re-approve when approved, else a plain failure. */
490
+ function refuseFrozen(io, problems, plan, approved, request) {
491
+ io.stderr(`mk render: the frozen audio is not the approved audio: ${problems.join("; ")}. ` +
492
+ "If you did not change it, another process wrote it while this render ran; re-preview, " +
493
+ "show the user, then re-approve.\n");
494
+ if (approved === undefined)
495
+ return EXIT.ERROR;
496
+ return awaitingApproval(io, request, plan.audioManifest, approved.sha, `the frozen audio differs from the approved bytes (${problems.join("; ")})`, {
497
+ ...(plan.musicPath === undefined ? {} : { musicPath: plan.musicPath }),
498
+ ...(plan.musicVolume === undefined ? {} : { musicVolume: plan.musicVolume }),
499
+ ...(plan.musicLicensePath === undefined ? {} : { musicLicensePath: plan.musicLicensePath }),
500
+ });
501
+ }
502
+ /**
503
+ * ADR-0008 D3: the problems to report for a typed failed delivery. A failure
504
+ * with an empty list is still a failure, never a silent success.
505
+ */
506
+ export const deliveryFailureProblems = (delivery) => delivery.problems.length > 0 ? [...delivery.problems] : ["mix delivery failed"];
507
+ /** Renders silent video, then mixes narration (plus the ducked bed) underneath. */
508
+ async function runRender(io, outDir, plan, approved, request) {
509
+ const silent = join(outDir, SILENT);
510
+ const mixedTemp = join(outDir, MIX_TEMP);
511
+ // ADR-0008 D1/D2: freeze the approved bytes before any external tool runs and
512
+ // refuse when the copies are not the approved bytes. Preview and final share
513
+ // this layout and the same buildMusicMixArgs graph (no second pipeline).
514
+ let frozenNarration = [];
515
+ let music;
516
+ if (plan.narration !== undefined) {
517
+ const copied = await copyFrozenInputs(io, outDir, plan.narration, plan.music);
518
+ if (!copied.ok)
519
+ return { code: copied.code, pending: undefined };
520
+ frozenNarration = copied.narration;
521
+ music = copied.music;
522
+ const early = await frozenApprovalProblems(io, copied, plan.narration, plan, approved);
523
+ if (early.length > 0) {
524
+ return { code: refuseFrozen(io, early, plan, approved, request), pending: undefined };
525
+ }
526
+ }
527
+ // Visual-only gated runs render to a temp too: the post-run gate must pass before
528
+ // this attempt's bytes can replace a previous final.mp4 (ADR-0008 D3).
529
+ const videoOut = plan.narration === undefined ? (plan.gated ? VIDEO_TEMP : FINAL) : SILENT;
530
+ const renderArgs = [
531
+ "render",
532
+ ".",
533
+ "-o",
534
+ videoOut,
535
+ "--fps",
536
+ plan.fps,
537
+ ...(plan.resolutionArg === undefined ? [] : ["--resolution", plan.resolutionArg]),
538
+ "--quality",
539
+ "delivery",
540
+ "--quiet",
541
+ ];
542
+ let deliveryFailed = false;
543
+ let refusal;
544
+ let pending;
545
+ const code = await runInProject(io, outDir, async (session) => {
546
+ const rendered = await session.hf(plan.resume ? [...renderArgs, "--resume"] : renderArgs, plan.resume ? { env: SEGMENTED_CAPTURE } : undefined);
547
+ if (!rendered || plan.narration === undefined)
548
+ return;
549
+ // ADR-0008 D2: check again at consumption time. A render takes minutes, and
550
+ // the frozen files are what ffmpeg opens; the post-run gate re-resolves the
551
+ // mutable sources, not these copies.
552
+ const late = await frozenApprovalProblems(io, { narration: frozenNarration, ...(music === undefined ? {} : { music }) }, plan.narration, plan, approved);
553
+ if (late.length > 0) {
554
+ refusal = refuseFrozen(io, late, plan, approved, request);
555
+ await discardAttempt(io, outDir);
556
+ return;
557
+ }
558
+ // ADR-0008 D3: mix into this attempt's temp file, never into final.mp4. A
559
+ // typed ok:false (including an empty problem list) is a failed command: the
560
+ // temps are cleaned and publication never happens.
561
+ const delivery = await mixAtDeliveryLevel((argv) => session.probe("ffmpeg", argv), {
562
+ videoIn: silent,
563
+ clips: frozenNarration,
564
+ ...(music === undefined ? {} : { music }),
565
+ out: mixedTemp,
566
+ swap: async (from, to) => {
567
+ await io.host.copyFile(from, to);
568
+ await io.host.remove(from);
569
+ },
570
+ });
571
+ if (!delivery.ok) {
572
+ const problems = deliveryFailureProblems(delivery);
573
+ io.stderr(`mk render: ${problems.join("; ")}\n`);
574
+ await discardAttempt(io, outDir);
575
+ deliveryFailed = true;
576
+ return;
577
+ }
578
+ if (delivery.problems.length > 0) {
579
+ io.stderr(`mk render: ${delivery.problems.join("; ")}\n`);
580
+ }
581
+ pending = "mix";
582
+ });
583
+ if (refusal !== undefined)
584
+ return { code: refusal, pending: undefined };
585
+ if (deliveryFailed)
586
+ return { code: EXIT.ERROR, pending: undefined };
587
+ // A visual-only gated attempt leaves its video temp for publication after the gate.
588
+ if (code === 0 && plan.narration === undefined && plan.gated)
589
+ pending = "video";
590
+ return { code, pending };
591
+ }
592
+ /** Removes this attempt's intermediates; a previous final.mp4 and the frozen bytes stay. */
593
+ async function discardAttempt(io, outDir) {
594
+ for (const name of [MIX_TEMP, `${MIX_TEMP}.loudnorm.mp4`, SILENT, VIDEO_TEMP]) {
595
+ await io.host.remove(join(outDir, name)).catch(() => undefined);
596
+ }
597
+ }
598
+ /**
599
+ * ADR-0008 D3: publishes this attempt's temp over final.mp4 only after the last
600
+ * gate passed. It covers both shapes: a mixed temp, and the direct video of a
601
+ * visual-only gated run (which must not touch final.mp4 before the gate either).
602
+ * `renameFile` replaces the file in one step, so a previous final is either fully
603
+ * replaced or untouched; the published bytes are hashed before success is reported.
604
+ */
605
+ async function publishAttempt(io, outDir, pending) {
606
+ const temp = join(outDir, pending === "mix" ? MIX_TEMP : VIDEO_TEMP);
607
+ const final = join(outDir, FINAL);
608
+ const expected = await io.host.sha256File(temp);
609
+ if (expected === undefined) {
610
+ io.stderr("mk render: the rendered file disappeared before it could be published\n");
611
+ await discardAttempt(io, outDir);
612
+ return EXIT.ERROR;
613
+ }
614
+ try {
615
+ await io.host.renameFile(temp, final);
616
+ }
617
+ catch (error) {
618
+ io.stderr(`mk render: cannot publish the final: ${error.message}\n`);
619
+ await discardAttempt(io, outDir);
620
+ return EXIT.ERROR;
621
+ }
622
+ const published = await io.host.sha256File(final);
623
+ if (published !== expected) {
624
+ io.stderr("mk render: the published final does not match the rendered bytes; do not deliver it\n");
625
+ await io.host.remove(final).catch(() => undefined);
626
+ await io.host.remove(join(outDir, "render-manifest.json")).catch(() => undefined);
627
+ await discardAttempt(io, outDir);
628
+ return EXIT.ERROR;
629
+ }
630
+ if (pending === "mix")
631
+ await io.host.remove(join(outDir, SILENT));
632
+ return undefined;
633
+ }
634
+ const checkGatedEntry = async (io, request, audioManifest, musicOpts = {}) => gatedGate(io, request, audioManifest, musicOpts);
635
+ export const renderCommand = {
636
+ name: "render",
637
+ summary: "build, then render the final video (mp4 by default; webm/mov/gif/png-sequence)",
638
+ async run(args, io) {
639
+ const plan = await resolvePlan(args, io);
640
+ if ("code" in plan)
641
+ return plan.code;
642
+ const musicOpts = {
643
+ ...(plan.musicPath === undefined ? {} : { musicPath: plan.musicPath }),
644
+ ...(plan.musicVolume === undefined ? {} : { musicVolume: plan.musicVolume }),
645
+ ...(plan.musicLicensePath === undefined ? {} : { musicLicensePath: plan.musicLicensePath }),
646
+ };
647
+ if (plan.gated) {
648
+ const blocked = await checkGatedEntry(io, plan.request, plan.audioManifest, musicOpts);
649
+ if (blocked !== undefined)
650
+ return blocked;
651
+ }
652
+ const prepared = await prepareProject(plan.request, io);
653
+ if (!prepared.ok)
654
+ return prepared.code;
655
+ let approved;
656
+ if (plan.gated) {
657
+ const snapshot = await checkPrePublish(io, plan.request, plan.audioManifest, prepared.outDir, musicOpts);
658
+ if (!snapshot.ok)
659
+ return snapshot.code;
660
+ approved = { manifest: snapshot.manifest, sha: snapshot.sha };
661
+ }
662
+ const run = await runRender(io, prepared.outDir, plan, approved, plan.request);
663
+ if (run.code !== 0)
664
+ return run.code;
665
+ // The last gate runs before publication, so a refusal never replaces a
666
+ // previous final.mp4 with this attempt's bytes (ADR-0008 D3).
667
+ if (plan.gated) {
668
+ const recheck = await gatedGate(io, plan.request, plan.audioManifest, musicOpts);
669
+ if (recheck !== undefined) {
670
+ await discardAttempt(io, prepared.outDir);
671
+ return recheck;
672
+ }
673
+ }
674
+ if (run.pending !== undefined) {
675
+ const published = await publishAttempt(io, prepared.outDir, run.pending);
676
+ if (published !== undefined)
677
+ return published;
678
+ }
679
+ const { total, meta } = prepared;
680
+ // Delivery formats are conversions of the published master; the mp4 stays the source of truth.
681
+ let outputFile = join(prepared.outDir, FINAL);
682
+ if (plan.format !== "mp4") {
683
+ const exported = await exportArtifact(io, prepared.outDir, plan.format);
684
+ if (!exported.ok)
685
+ return exported.code;
686
+ outputFile = exported.file;
687
+ }
688
+ const renderedMeta = plan.resolution === "4k" ? { ...meta, width: meta.width * 2, height: meta.height * 2 } : meta;
689
+ return finishRender(io, {
690
+ record: {
691
+ kind: "final",
692
+ outDir: prepared.outDir,
693
+ file: outputFile,
694
+ params: {
695
+ fps: Number(plan.fps),
696
+ quality: "delivery",
697
+ width: renderedMeta.width,
698
+ height: renderedMeta.height,
699
+ resume: plan.resume,
700
+ },
701
+ },
702
+ ...(plan.verify ? { verify: finalExpectation(renderedMeta, total, Number(plan.fps)) } : {}),
703
+ });
704
+ },
705
+ };