@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 anvpx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # @anvpx/motion-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `mk <command> [flags]` - the motion-kit command line. Workspace dependencies only (`@anvpx/motion-schema`, `@anvpx/motion-build`, `@anvpx/motion-templates` for the built-in template directory); no third-party packages.
4
+
5
+ Every built-in blueprint ships a contact-sheet preview, and `mk template show <template>/<blueprint>` prints its path — e.g. `explain/concept`: ![explain/concept preview](../templates/templates/explain/previews/concept.png)
6
+
7
+ ## Commands
8
+
9
+ | Command | Purpose |
10
+ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
11
+ | `mk doctor [--json]` | Tri-state check per tool (ok, usable, missing or unknown): node >= 22, pnpm, ffmpeg >= 6, ffprobe (required); hyperframes, chrome-headless-shell, gpu, python 3.12, skills (installed `skills/motion-kit` + references) (optional, warn only). `--json`: one line `[{id,status,version?,hint?}]` |
12
+ | `mk validate <sb.json> [--json]` | Schema + scene props + timing + lint. One line per finding: `E /scenes/1/props/items/0: message -> suggestion` (`W` for warnings) |
13
+ | `mk build <sb.json> [--out d] [--assets d] [--ledger f] [--incremental] [--clean-cache] [--shared-cache-dir d]` | Validate, then write the HyperFrames project (default `<storyboard dir>/out/project`); prints file count, duration, manifest sha256. `--incremental` (also on draft/render/still) reuses unchanged scenes from `<storyboard dir>/.mk-cache` (byte-identical output; prints `cache: N hit / M miss`; `--no-incremental` is the default); `--clean-cache` wipes it first; `--shared-cache-dir` is opt-in shared cache across storyboards (requires `--incremental`) |
14
+ | `mk still <sb.json> (--at 1,5,9 \| --review) [--out d]` | Build, then `hyperframes snapshot` per timestamp; PNGs in `<out>/stills`; `--review` captures the upstream review set instead (scene starts, middles, cut seams plus phone-width beats) |
15
+ | `mk compare <a.json> <b.json> [<c.json> [<d.json>]] --at <s> [--out d] [--assets d] [--ledger f] [--json]` | Build 2-4 storyboards, one still of each at the same `--at` second, then tile them in CLI order into `<out>/compare-<s>.png` (default `<out>` = `<first storyboard dir>/out/compare`, each build in `<out>/<name>`; tiles are 640 px wide, so candidates should share an aspect). One output line; `--json`: `{at,png,items:[{storyboard,still,sha256}]}`. Exit 2 for <2 or >4 storyboards or a bad `--at`, 1 when a build, snapshot or tile fails (the failing storyboard is named) |
16
+ | `mk draft <sb.json> [--out d] [--verify]` | Build, then 15 fps draft to `<out>/draft.mp4` (960x540); writes `render-manifest.json`; `--verify` re-probes it (exit 5 on FAIL) |
17
+ | `mk render <sb.json> [--out d] [--fps 30] [--format mp4\|webm\|mov\|gif\|png-sequence] [--resolution 1080p\|4k] [--resume] [--verify] [--gated]` | Build, then final H.264 to `<out>/final.mp4`; non-mp4 formats convert that master with ffmpeg; `--resolution 4k` uses the 4K preset matching the frame; `--verify` re-probes the mp4 master (exit 5 on FAIL); `--gated` renders only when `mk approve` matches the current files, else exit 4 |
18
+ | `mk verify <file.mp4> [--storyboard sb.json] [--platform id] [--draft] [--determinism second.mp4] [--events] [--loudness] [--json]` | Re-probe a rendered file with ffprobe and report findings; see Verify below |
19
+ | `mk state <sb.json> [--out d] [--render d] [--assets d] [--ledger f] [--json]` | Report the highest project state the artifacts prove; read-only, no writes. Pass the same `--assets`/`--ledger`/`--audio`/`--music` flags used with `mk preview`/`mk approve`, else a vendored-asset project is undecidable. `--json`: one line {state, next, missing, problems, evidence}. Exit 1 when a manifest claims more than the evidence; 2 when approval inputs are unreadable |
20
+ | `mk analyze <audio.wav> [--words f] [--tokens text] [--out f] [--python bin]` | Analyze voice audio into AudioAnalysis JSON (words, silences, duration); no `--words`/`--tokens` means UNKNOWN exit 2 |
21
+ | `mk qc <manifest.json> [--audio audio.wav] [--words words.json] [--json]` | Gate a voice TimingManifest plus its audio: PASS/WARN/FAIL/UNKNOWN; no `--audio` means UNKNOWN exit 2 |
22
+ | `mk catalog [<scene>] [--json] [--write [--out f]]` | No argument: index, one line per scene (`type \| layer \| purpose \| required`). `<scene>`: fields, types, defaults, minimal example. `--write` regenerates `CATALOG.md` at the repo root |
23
+ | `mk contact <file.mp4> [--cols 4] [--count 12] [--out f.png]` | ffmpeg contact sheet: `--count` evenly spaced frames, 320 px tiles, `--cols` per row; default `<file>-contact.png`. One output line |
24
+ | `mk template list [--domain d] [--for q] [--tag a,b] [--aspect 16:9] [--sec 45] [--json]` | One line per template: `id@version \| domains \| blueprints \| source` (<= 15 lines); `--for` ranks blueprints (top 8, NOT-FOR sinks); `--aspect`/`--sec` are hard filters; shadowed templates flagged with `!`. Roots: `./templates`, `$MK_TEMPLATES_PATH`, `~/.motion-kit/templates`, built-in, then opt-in npm roots (built-ins win) |
25
+ | `mk template show <t>[/<bp>] [--json]` | Template: styles, themes, blueprints with slot signatures, pacing, copy guide (<= 60 lines). Blueprint: slots (`*` required, `?` optional, limits) and an example |
26
+ | `mk template validate <dir \| id> [--json]` | Contracts, themes (AA contrast), styles, blueprint examples; `E path: message -> fix` per finding; exit 2 on errors |
27
+ | `mk template fork <t>/<bp> --as <name> [--out sb.json] [--json]` | Fork a blueprint example into a user-owned storyboard (built-ins never modified); records `forkedFrom` provenance |
28
+ | `mk new [<t>/<bp>] [--demo] [--with scene[,scene]] [--set k=v ...] [--slots f.json] [--style s] [--length l] [--out sb.json] [--json]` | Instantiate a blueprint into a validated storyboard; vendors the used theme(s) into `<out dir>/themes/`; writes `<out dir>/.mk/<name>/template.json` (provenance). Bare `--demo` uses `explain/architecture`; `--with` appends a base scene whose props come from `slots["$with"]["<type>"]`. `--set k=a\|b\|c` for lists (backslash-pipe is a literal pipe). At most 5 output lines |
29
+ | `mk preview <sb.json> [--stills 6 \| --every] [--budget-sec 90] [--out d] [--assets d] [--ledger f] [--json]` | The proposal pack in `<out>/<name>.preview` (default `<out>` = `<storyboard dir>/out`): `preview.json`, `contact.png`, `stills/NN-<scene>-<theme>.png`, `clip.mp4`; one summary line. Scratch builds in `<out>/<name>.preview-work` |
30
+ | `mk approve <sb.json> [--note t] [--out d] [--assets d] [--ledger f] [--audio m] [--music f]` | Record the user's approval of the CURRENT visual/audiovisual inputs in `<storyboard dir>/.mk/<name>/approval.json`; the previewed inputs are inferred from `preview.json`, and an explicit flag must match the pack; exit 2 when no preview exists |
31
+ | `mk captions [<manifest>] [--storyboard sb] [--out ops.json] [--srt f] [--vtt f] [--from-srt f\|--from-vtt f]` | One caption overlay per narrated scene as RFC 6902 `add` ops for `mk patch --ops`; words are scene-relative and out-of-bound words are reported; SRT/VTT export and import |
32
+ | `mk assets import <file...> --dir d --licence id --source-url url [--attribution t] [--ledger f]` | Copy, hash and append a validated ledger row per file; re-running is a no-op, a hash mismatch at an existing path exits 2, remote paths are refused |
33
+ | `mk patch <sb.json> [--ops f.json] [--op <json> ...]` | RFC 6902 subset (`add`, `replace`, `remove`, `move`, `test`) applied to a copy; paths into a missing parent, `__proto__`/`constructor`/`prototype` and command-shaped keys are refused; the result is validated with the full validator and written atomically, never when invalid (pointer errors on stderr, exit 2). At most 3 output lines. Any edit changes the approval hash |
34
+ | `mk scene add <sb.json> <type> [--id x] [--after id] [--set k=v ...] [--duration s] [--over id] [--offset s]` | Insert a scene built from the catalog's minimal example (`--set` values are JSON when they parse; overlay types need `--over`); one output line |
35
+ | `mk scene remove <sb.json> <id>` / `mk scene move <sb.json> <id> (--after <id> \| --first)` | Remove or reorder a scene; validated and atomic like `patch` |
36
+ | `mk fit <sb.json> --seconds N` | Scale every base scene by one common factor so the total is N, each scene inside its pacing range (smallest to largest seconds of its type in the template's `pacing.json`, never under reading time or the 2 s hold; without template provenance: floor to 1.5x). Deterministic; refuses (writes nothing) when N is out of reach and says which scene's reading time or which limit |
37
+ | `mk generate-3d <image> --out <model.glb> [--python bin]` | Generate a validated GLB from a single image via the TripoSR backend; fails closed with a `mk doctor` hint, never a silent fallback |
38
+ | `mk icon search <q> \| list [--tag group]` | Icon names only (max 20 lines) for scene `icon` props; `--json` for an array |
39
+ | `mk plugin install [--out d]` | Copy the bundled Claude Code plugin (skill, references, commands, manifest, README) to `<dir>`; verifies `skills/motion-kit/SKILL.md` sha256 match; pinned core version (0.0.0 pre-publish, 404 in isolation) |
40
+ | `mk voice <sb.json> --voice <id> [--out f] [--audio-dir d] [--python bin]` | Synthesize narration into a voice TimingManifest; `--voice` required, never hardcoded; no venv/tool fails closed with a `mk doctor` hint |
41
+ | `mk optimize-model <model.glb> [--out f]` | Validate a binary GLB and write an optimized copy (offline passthrough byte validator) |
42
+ | `mk --help` | List every registered command, one line each (<= 40 lines; a test enforces it) |
43
+
44
+ `still`, `draft` and `render` also accept `--assets` and `--ledger`. `<out>` is the project directory.
45
+
46
+ ## Exit codes
47
+
48
+ One module defines them (`src/exit-codes.ts`); every command returns one of these.
49
+
50
+ | Code | Name | Meaning |
51
+ | ---- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | 0 | OK | Success; warnings allowed. `verify`: PASS or WARN. `doctor`: no required tool missing |
53
+ | 1 | ERROR | Tool, build or render failure, hyperframes pin mismatch |
54
+ | 2 | USAGE | Bad flags or arguments, unreadable file, **validation errors** (`validate`, `build`, `still`, `draft`, `render`), unknown command; `verify`/`qc`/`analyze`: UNKNOWN (could not decide); `doctor`: a required check is unknown |
55
+ | 4 | AWAITING_APPROVAL | `render --gated` without a current visual approval (ask the user, then `mk preview` + `mk approve`); not an error |
56
+ | 5 | QA_FAIL | `verify` found a fatal finding (also `draft`/`render --verify`) |
57
+ | 127 | MISSING_TOOL | A required program is missing or cannot run (`doctor`, `verify` without ffprobe, a render step whose program is absent) |
58
+
59
+ Change from Phase 1: validation errors used to exit 1; they now exit 2. Unknown command stays 2 (stderr).
60
+
61
+ ## Templates, preview and the approval gate
62
+
63
+ Templates are data folders discovered in `./templates`, `$MK_TEMPLATES_PATH`, `~/.motion-kit/templates`, the built-in `packages/templates`, then opt-in npm roots (`mk-template-*`, `motion-kit-template-*`, `@*/mk-template-*`, `@anvpx/motion-template-*` in `node_modules`; built-ins win on id clash) (passed in by the CLI; `build` never imports it). A blueprint plus slots becomes a plain storyboard (`mk new`); the storyboard needs no template afterwards (its theme is vendored).
64
+
65
+ `mk preview` runs validate + lint (exit 2 on errors), then stills (first, last, each scene type, even spread, at 70% of the scene; 3 more in the paired theme when the template has one), `contact.png` (ffmpeg tile of the stills, argv only), then `clip.mp4` through `mk draft` on an in-memory copy where every base scene is capped at 2 s, overlays are clamped inside, narration is stripped and the whole clip is <= 30 s (scenes sampled with the same picker). The clip build skips the authored-work lint rules (reading time, minimum hold), because scenes are cut short on purpose. If `--budget-sec` is used up after the stills, the clip is skipped and recorded (exit 0). `preview.json` records the approval hash, scenes, stills, clip status, lint counts, `estFullRenderSec` (an estimate from draft ms per frame) and an approval checklist. With `--audio`, the exact final wavs are mixed under `clip.mp4` with the same mix args as the final render, and `preview.json` records `audioMix` (`manifestSha`, `wavSha`, `audible: true`); silent packs record `audible: false`.
66
+
67
+ Approval gate (visual + audiovisual + music bed; AU-01 extends the same gate with opt-in music, no parallel gate): `.mk/<name>/approval.json` = `{ inputManifest, inputManifestSha256, previewManifestSha, approvedAt, note }`, where `inputManifest` is the single resolver output (storyboard + vendored themes + exact referenced assets + asset-ledger bytes + (for audiovisual) the voice TimingManifest, every sibling wav and the mix + (AU-01, opt-in) the music bed `{path,sha256,volume,licenseEvidenceSha256}` with frozen copied bed bytes, project-relative forward-slash paths, code-unit sorted, `scope` visual or audiovisual) and `inputManifestSha256` is its canonical hash (no timestamp or machine path enters it). `mk preview` uses the same normalized `buildMixArgs`/`buildMusicMixArgs` clip/mix params as the final render. A receipt whose shape or inputs do not match the current manifest reads as `stale`, never silently upgraded — re-preview + re-approve. `mk render --gated` exits 4 (stderr names the cause: no approval, or inputs changed since approval) unless the recomputed hash matches; any edit to storyboard, themes, referenced asset bytes, ledger, audio manifest, wavs, mix, bed, volume or license receipt re-arms it, and inputs are rechecked before publication (mutation during preview/build fails before the renderer; ABA restore still mixes the frozen approved copy). A visual-only receipt never authorizes `--audio`, narration-only never authorizes `--music` (exit 4: re-preview audible + re-approve). `mk preview --audio [--music --music-volume --music-license]`, `mk approve --audio [--music ...]` and `mk render --gated --audio [--music ...]` must all use the identical manifests/bed/volume/license; a mismatch exits 2 at approve time, drift exits 4 at render. Silent/missing/skipped clips never qualify; silent/missing wavs fail closed. Without `--gated`, `render` (and ungated `--audio`) behaves as before; `mk draft` stays explicit and ungated.
68
+
69
+ ## Verify
70
+
71
+ `mk verify <file.mp4>` runs `ffprobe` (found on PATH, as doctor finds ffmpeg) and asserts: a video stream; h264 + yuv420p; frame count = duration x fps within 1 frame. With `--storyboard`: size, fps and duration equal the storyboard's (total = sum of resolved scene durations minus crossfade overlaps; tolerance max(0.1 s, 1 frame)); `--draft` expects 960 px wide at 15 fps. With `--platform <id>` (id or alias from `packages/schema/data/platforms`): aspect, fps cap, duration cap and video codec; a template with `verified: false` adds an info finding `platform-unverified`. The storyboard does not declare audio, so audio is not required (a non-aac audio codec warns). With `--loudness`, audio loudness QC (`verify/audio-loudness.ts`, via `platformAudioReport`) measures integrated LUFS + true peak with installed FFmpeg `loudnorm=print_format=json` (single pass, no gain) and judges only an explicit verified policy (`verified: true`): `|measured-target| > 1.0 LU` is `audio-loudness-mismatch`, peak over ceiling + 0.5 dB is `audio-true-peak-mismatch` (both FAIL); unverified/absent policy is UNKNOWN (`audio-loudness-unverified`/`audio-loudness-no-policy`), unmeasurable audio is UNKNOWN (`audio-loudness-unavailable`/`no-stream`/`unmeasurable`); no universal LUFS is ever applied.
72
+
73
+ Output is terse: `PASS|WARN|FAIL|UNKNOWN <file>` then findings `level code: message` (fatal first, at most 10, then `(+N more)`). `--json` prints one line `{status,file,findings,more?}`. QC measures and reports; it never decides for you: only FAIL exits 5, UNKNOWN exits 2. Pure assertions live in `@anvpx/motion-build` (`verifyMedia`), the ffprobe spawn here. `--determinism` compares a second render byte-for-byte (same machine/GPU/driver only); a mismatch is FAIL.
74
+
75
+ With `--events`, one extra ffmpeg pass (`-v info -i IN -vf freezedetect=n=-60dB:d=2,blackdetect=d=0.1:pix_th=0.10 -an -f null -`; fixed argv, no user-supplied filter string, audio not decoded) reports `media-freeze-detected` and `media-black-detected` as WARN, never fatal: a hold is a judgment call on the clip (`docs/CRAFT-RUBRIC.md` §2 allows 3-4 s settled), not an engine verdict. Black that starts within 0.5 s of either end is `media-black-head-tail` info instead of a warn; nothing found is the info line `media-events-clear`. Detection that cannot run is UNKNOWN: `media-events-unavailable` (ffmpeg, freezedetect or blackdetect missing) and `media-events-unreadable` (decode failed). Both flags are opt-in and default off, so stdout without them is unchanged.
76
+
77
+ Opt-in probes merge through `mergeReports`: FAIL > UNKNOWN > WARN > PASS, findings base first, and the merged status is the one exit-code authority, so WARN still exits 0 and UNKNOWN still exits 2. `--determinism` keeps its own FAIL-only escalation.
78
+
79
+ ## Provenance
80
+
81
+ `manifest.json` (written by `build`) is byte-deterministic: sorted keys, no timestamps or machine paths; it records `storyboardSha256`, `themeHash`, `ledgerSha256`, `scenePlugins` (type -> providing package version), package versions, `hyperframes` pin, `node` version and a sha256 per file. `draft` and `render` also write `<out>/render-manifest.json`: manifest hash, output file name + sha256, render params, tool versions (ffmpeg, hyperframes, node) and verify status (`PASS|WARN|FAIL|UNKNOWN|not-run`). It describes the latest render (`kind`, `output.file`).
82
+
83
+ ## Output size
84
+
85
+ Default output is at most 1 KB per command (`doctor`, `verify`); lists are capped with a total; argument errors are one sentence plus the usage line.
86
+
87
+ ## Rendering facts (hyperframes 0.8.212, checked against its real `--help`)
88
+
89
+ - Run as `pnpm exec hyperframes ...` with cwd = project dir, through the injected runner (Windows: `cmd.exe /d /s /c`, only for the `pnpm`/`npx` .cmd shims; never `shell: true`). cmd.exe re-parses the command line, so on that path any argument containing `& | < > ^ " %`, a line break or NUL is refused (exit 127, message names the argument) rather than escaped. Keep user-provided values (paths, `--at` lists) free of those characters. Refuses to run unless `pnpm exec hyperframes --version` equals `motionKit.pins.hyperframes`.
90
+ - Every build-based command writes `<out>/render-log.txt` (stdout + stderr of each tool); a failure prints the last 15 lines and the log path.
91
+ - `draft`: hyperframes `--resolution` only offers presets from 1080p up, so it renders `--quality draft --fps 15` at the composition size, then ffmpeg downscales (`scale=960:-2:flags=lanczos`, x264 veryfast crf 23) into `draft.mp4` and deletes the intermediate.
92
+ - `render`: `--quality delivery` (x264 preset slow, CRF 15 — the best preset the CLI offers; default `looks` is CRF 16). Size comes from the storyboard `meta`; `--resolution 4k` passes the matching `landscape-4k`/`portrait-4k`/`square-4k` preset. `--format webm|mov|gif|png-sequence` converts the delivered mp4 master with ffmpeg (the mp4 stays the source of truth; `--verify` is mp4-only).
93
+ - `render --resume`: hyperframes supports it only with segmented capture, so `mk` sets `HF_SEGMENTED_CAPTURE=true` and passes `--resume`; finished segments from an interrupted render (`<out>/renders/.hf-segments/`) are reused. Experimental in hyperframes.
94
+ - `<out>` must live inside the workspace so `pnpm exec` finds the pinned binary.
95
+
96
+ ## Pins (`mk doctor` warns when absent or mismatched; render commands refuse)
97
+
98
+ Add to the repo-root `package.json`:
99
+
100
+ ```json
101
+ "motionKit": { "pins": { "hyperframes": "0.8.212", "chromeHeadlessShell": "152.0.7977.30" } }
102
+ ```
103
+
104
+ <!-- DL-01-START: install/doctor qualification (DL-01 owns these lines; AU-01 owns approval sections above) -->
105
+
106
+ ## Install qualification (DL-01)
107
+
108
+ Install is the npm package install plus `mk doctor` hints; `mk plugin install --out <dir>` stays a separate explicit step, never part of npm install. Qualify with `node scripts/qualify-pack.mjs --json`: fresh pack records full inventory/SHA (`tarball.members`, `sha256`, bytes, `node`/`pnpm`/`npm`/`tar` versions, packed `hyperframes` pin vs root pin, `unpublishedInternals`), installs in an empty external consumer with no workspace fallback (`pnpm-workspace.yaml`/`packages/` absent), then runs native `doctor`/`catalog`/`template list`/`template validate explain` plus `new`/`validate`/`build`/`preview --stills 1 --budget-sec 20`/`approve` smoke with missing-target negatives; final `gated-render`/`verify` stay `NOT_RUN` without an explicit yes (do not render). Registry/marketplace stay `NOT_RUN`; isolated install is currently `BLOCKED` (`404` on unpublished `0.0.0` internals, `base: BLOCKED`; see DISTRIBUTION MK-080 choices A/B). Missing guide/index/font/icon/bed, source fallback reachable, wrong pin, broken install, missing venv/WAV all fail closed with actionable nonzero diagnostics (`reinstall @anvpx/motion`, `copy-dist-assets`, `mk doctor`, `py -3.12 -m venv .venv-voice`).
109
+
110
+ ## Voice and music delivery (DL-01)
111
+
112
+ Voice probes (`python`, `python-venv`, `espeak`, `kokoro`, `faster-whisper`) fail closed with manual hints and never auto-install system/pip deps; `mk doctor --fix` leaves voice rows untouched. Supplied/authorized narration needs reviewed pronunciation, intelligibility, pacing, word alignment, no unintended silence plus measured `mk qc` audio QC; a unit test or synthetic silence is not human-quality proof. The 1 s procedural `music/bed.wav` silence is `NOT_RUN` for quality: silence must not pass as music quality. Base workflow `PASS` is distinct from optional voice/music `BLOCKED_ENV`/`NOT_RUN`.
113
+
114
+ <!-- DL-01-END -->
115
+
116
+ ## Run
117
+
118
+ ```
119
+ node packages/cli/src/main.ts doctor # Node 24 type stripping; imports use .ts extensions
120
+ pnpm test # from the repo root (fast, fake runner)
121
+ pnpm test:integration # in packages/cli: real validate/build/still/draft/render and the template flow new, preview, gated render, approve (~60 s); outputs in dist/.out/smoke and dist/.out/template-flow
122
+ ```
@@ -0,0 +1,2 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const analyzeCommand: Command;
@@ -0,0 +1,190 @@
1
+ import { ALIGN_MODEL_ID, alignWords, detectSilence, resolveVenvPython, VOICE_PROVENANCE, } from "@anvpx/motion-audio";
2
+ import { AudioAnalysisSchema } from "@anvpx/motion-schema";
3
+ import { parseFlags, requirePositional, textFlag, usageError } from "../args.js";
4
+ import { EXIT } from "../exit-codes.js";
5
+ import { readJsonFile } from "../storyboard/load.js";
6
+ import { probeMedia } from "../verify/probe.js";
7
+ const USAGE = `mk analyze <audio.wav> [--words <words.json>] [--tokens <text>] [--out <file>] [--python <bin>]
8
+
9
+ Analyze voice audio into AudioAnalysis JSON (words, silences, duration).
10
+ Prints one compact JSON line to stdout, or writes it to --out.
11
+ <audio.wav> audio file to analyze (required)
12
+ --words <file> known word timings JSON [{w,start,end}]; skips the aligner
13
+ --tokens <text> known script text; aligned with the voice venv python
14
+ --out <file> write the JSON to <file> (stdout gets one summary line)
15
+ --python <bin> explicit venv python (skips the probe; only with --tokens)
16
+ Without --words or --tokens there are no word timings: silence intervals and
17
+ duration are still measured and the analysis is UNKNOWN (exit 2), never failed.
18
+ Exit 0 analysis ok; 2 UNKNOWN (could not decide) or bad arguments; 127 ffprobe or python missing.
19
+ `;
20
+ const FLAGS = {
21
+ words: { type: "string" },
22
+ tokens: { type: "string" },
23
+ out: { type: "string" },
24
+ python: { type: "string" },
25
+ };
26
+ const FFMPEG_HINT = "mk: ffprobe not found; install ffmpeg and put it on PATH";
27
+ const DOCTOR_HINT = "Run `mk doctor` for the voice setup steps.";
28
+ const isExit = (value) => typeof value === "object" && value !== null && value.kind === "exit";
29
+ /** One ffprobe duration probe; unreadable audio is UNKNOWN, never failed. */
30
+ async function measureDuration(io, audio) {
31
+ const probed = await probeMedia(io, audio);
32
+ if (!probed.ok && probed.kind === "missing-tool") {
33
+ io.stderr(`${FFMPEG_HINT}\n`);
34
+ return { kind: "exit", code: EXIT.MISSING_TOOL };
35
+ }
36
+ if (!probed.ok) {
37
+ io.stderr(`mk analyze: could not read audio: ${probed.reason}; result is UNKNOWN\n`);
38
+ return { kind: "exit", code: EXIT.USAGE };
39
+ }
40
+ const format = probed.probe.format;
41
+ const duration = Number(format?.duration);
42
+ return Number.isFinite(duration) && duration > 0 ? duration : undefined;
43
+ }
44
+ /** ffprobe silencedetect gate; missing ffprobe is 127, a failed gate is UNKNOWN. */
45
+ async function measureSilence(io, audio) {
46
+ const silence = await detectSilence(io.run, { audioPath: audio });
47
+ if (!silence.ok && silence.status === "missing") {
48
+ io.stderr(`${FFMPEG_HINT}\n`);
49
+ return { kind: "exit", code: EXIT.MISSING_TOOL };
50
+ }
51
+ if (!silence.ok) {
52
+ io.stderr(`mk analyze: silence gate failed: ${silence.hint}; result is UNKNOWN\n`);
53
+ return { kind: "exit", code: EXIT.USAGE };
54
+ }
55
+ return silence.intervals;
56
+ }
57
+ /** Known word timings from a JSON file; invalid content is a usage error. */
58
+ async function wordsFromFile(io, path) {
59
+ const loaded = await readJsonFile(path, io.host);
60
+ if (!loaded.ok) {
61
+ io.stderr(`mk analyze: ${loaded.message}\n`);
62
+ return { kind: "exit", code: EXIT.USAGE };
63
+ }
64
+ if (!Array.isArray(loaded.value)) {
65
+ return usageError("analyze", USAGE, "words file must be a JSON array", io);
66
+ }
67
+ return loaded.value;
68
+ }
69
+ const splitTokens = (text) => text.split(/\s+/).filter((w) => w.length > 0);
70
+ async function pickPython(io, explicit) {
71
+ if (explicit !== undefined)
72
+ return explicit;
73
+ const probe = await resolveVenvPython(io.run);
74
+ if (probe.ok)
75
+ return probe.python;
76
+ io.stderr(`mk analyze: ${probe.hint}\n${DOCTOR_HINT}\n`);
77
+ return { kind: "exit", code: EXIT.MISSING_TOOL };
78
+ }
79
+ /** Align known script text with the voice venv python; failures never fake words. */
80
+ async function wordsFromTokens(io, audio, text, explicitPython) {
81
+ const tokens = splitTokens(text);
82
+ if (tokens.length === 0) {
83
+ return usageError("analyze", USAGE, "--tokens needs at least one token", io);
84
+ }
85
+ const python = await pickPython(io, explicitPython);
86
+ if (isExit(python))
87
+ return python;
88
+ const aligned = await alignWords(io.run, { audioPath: audio, tokens, python });
89
+ if (!aligned.ok && aligned.status === "missing") {
90
+ io.stderr(`mk analyze: ${aligned.hint}\n${DOCTOR_HINT}\n`);
91
+ return { kind: "exit", code: EXIT.MISSING_TOOL };
92
+ }
93
+ if (!aligned.ok) {
94
+ io.stderr(`mk analyze: align failed: ${aligned.hint}; result is UNKNOWN\n`);
95
+ return { kind: "exit", code: EXIT.USAGE };
96
+ }
97
+ return {
98
+ words: aligned.words,
99
+ model: ALIGN_MODEL_ID,
100
+ modelRevision: VOICE_PROVENANCE.alignModelRev,
101
+ };
102
+ }
103
+ /** Words plus optional model provenance from --words or --tokens. */
104
+ async function loadWords(io, audio, wordsPath, tokens, python) {
105
+ if (wordsPath === undefined && tokens === undefined) {
106
+ io.stderr("mk analyze: no --words or --tokens; word timings are UNKNOWN\n");
107
+ return { kind: "exit", code: EXIT.USAGE };
108
+ }
109
+ if (wordsPath !== undefined) {
110
+ const words = await wordsFromFile(io, wordsPath);
111
+ if (isExit(words))
112
+ return words;
113
+ return { words };
114
+ }
115
+ return wordsFromTokens(io, audio, tokens, python);
116
+ }
117
+ /** Validate against the schema and emit (stdout JSON, or --out file plus a summary). */
118
+ async function emitAnalysis(io, input, out) {
119
+ const validated = AudioAnalysisSchema.safeParse(input);
120
+ if (!validated.success) {
121
+ const problems = validated.error.issues
122
+ .map((issue) => `${issue.path.join("/")}: ${issue.message}`)
123
+ .join("; ");
124
+ io.stderr(`mk analyze: refusing invalid analysis: ${problems}\n`);
125
+ return EXIT.USAGE;
126
+ }
127
+ const json = `${JSON.stringify(validated.data)}\n`;
128
+ if (out === undefined) {
129
+ io.stdout(json);
130
+ return EXIT.OK;
131
+ }
132
+ await io.host.writeTextAtomic(out, json);
133
+ io.stdout(`analyze ok | ${validated.data.words.length} words -> ${out}\n`);
134
+ return EXIT.OK;
135
+ }
136
+ const silencesOf = (intervals) => intervals.length === 0 ? undefined : intervals.map(([start, end]) => ({ start, end }));
137
+ const analysisOf = (source, intervals, duration) => {
138
+ const silences = silencesOf(intervals);
139
+ return {
140
+ words: source.words,
141
+ ...(silences === undefined ? {} : { silences }),
142
+ ...(duration === undefined ? {} : { duration }),
143
+ ...(source.model === undefined ? {} : { model: source.model }),
144
+ ...(source.modelRevision === undefined ? {} : { modelRevision: source.modelRevision }),
145
+ };
146
+ };
147
+ /** Flags plus the positional audio path, or the usage failure to exit with. */
148
+ async function resolveInput(args, io) {
149
+ const parsed = parseFlags("analyze", USAGE, args, FLAGS, io);
150
+ if (parsed.kind === "exit")
151
+ return parsed;
152
+ const audio = requirePositional("analyze", USAGE, parsed.positionals, "<audio.wav>", io);
153
+ if (typeof audio !== "string")
154
+ return audio;
155
+ if (!(await io.host.exists(audio))) {
156
+ io.stderr(`mk analyze: cannot read ${audio}; check the path.\n`);
157
+ return { kind: "exit", code: EXIT.USAGE };
158
+ }
159
+ const wordsPath = textFlag(parsed.flags, "words");
160
+ const tokens = textFlag(parsed.flags, "tokens");
161
+ if (wordsPath !== undefined && tokens !== undefined) {
162
+ return usageError("analyze", USAGE, "use only one of --words or --tokens", io);
163
+ }
164
+ return {
165
+ audio,
166
+ wordsPath,
167
+ tokens,
168
+ python: textFlag(parsed.flags, "python"),
169
+ out: textFlag(parsed.flags, "out"),
170
+ };
171
+ }
172
+ export const analyzeCommand = {
173
+ name: "analyze",
174
+ summary: "analyze voice audio into AudioAnalysis JSON (words, silences, duration)",
175
+ async run(args, io) {
176
+ const input = await resolveInput(args, io);
177
+ if (isExit(input))
178
+ return input.code;
179
+ const duration = await measureDuration(io, input.audio);
180
+ if (isExit(duration))
181
+ return duration.code;
182
+ const intervals = await measureSilence(io, input.audio);
183
+ if (isExit(intervals))
184
+ return intervals.code;
185
+ const source = await loadWords(io, input.audio, input.wordsPath, input.tokens, input.python);
186
+ if (isExit(source))
187
+ return source.code;
188
+ return emitAnalysis(io, analysisOf(source, intervals, duration), input.out);
189
+ },
190
+ };
@@ -0,0 +1,32 @@
1
+ import type { Host } from "../types.ts";
2
+ import { type ApprovalBuildOptions, type ApprovalInputManifest } from "./hash.ts";
3
+ export declare const APPROVAL_FILE = "approval.json";
4
+ export declare const approvalPath: (storyboard: string) => string;
5
+ export interface Approval {
6
+ readonly inputManifest: ApprovalInputManifest;
7
+ readonly inputManifestSha256: string;
8
+ readonly previewManifestSha: string;
9
+ readonly approvedAt: string;
10
+ readonly note: string;
11
+ }
12
+ export type ApprovalState = {
13
+ state: "approved";
14
+ hash: string;
15
+ } | {
16
+ state: "missing" | "stale";
17
+ hash: string;
18
+ };
19
+ /** The stored approval, or undefined when absent, malformed or legacy. */
20
+ export declare function readApproval(host: Host, storyboard: string): Promise<Approval | undefined>;
21
+ export declare function writeApproval(host: Host, storyboard: string, approval: Approval): Promise<string>;
22
+ /**
23
+ * Does an approval match the CURRENT inputs (visual, audiovisual or
24
+ * audiovisual+music)? Fails
25
+ * closed: any legacy receipt is `stale` (re-preview + re-approve), missing
26
+ * file is `missing`. A visual-only receipt never authorizes audio: resolving
27
+ * with --audio recomputes the audiovisual hash, which cannot match. A
28
+ * narration-only receipt never authorizes --music: the music
29
+ * {path,sha256,volume,licenseEvidenceSha256} binding changes the hash.
30
+ * Rejects when inputs are unreadable (missing asset, ledger, wav…).
31
+ */
32
+ export declare function approvalState(host: Host, storyboard: string, opts?: ApprovalBuildOptions): Promise<ApprovalState>;
@@ -0,0 +1,141 @@
1
+ import { join } from "node:path";
2
+ import { buildApprovalInputManifest, hashApprovalInput, mkDir, readSidecar, } from "./hash.js";
3
+ export const APPROVAL_FILE = "approval.json";
4
+ export const approvalPath = (storyboard) => join(mkDir(storyboard), APPROVAL_FILE);
5
+ const HEX64 = /^[a-f0-9]{64}$/;
6
+ const isManifestRef = (value) => {
7
+ const row = value;
8
+ return (typeof row === "object" &&
9
+ row !== null &&
10
+ typeof row.path === "string" &&
11
+ typeof row.sha256 === "string" &&
12
+ row.sha256.length === 64);
13
+ };
14
+ const everyManifestRef = (value) => Array.isArray(value) && value.every(isManifestRef);
15
+ const hasLedger = (value) => value === undefined || (typeof value === "string" && HEX64.test(value));
16
+ const isMixClip = (value) => {
17
+ const row = value;
18
+ return (typeof row === "object" &&
19
+ row !== null &&
20
+ typeof row.id === "string" &&
21
+ typeof row.start === "number" &&
22
+ typeof row.wavSha === "string" &&
23
+ row.wavSha.length === 64);
24
+ };
25
+ const isMix = (value) => {
26
+ const row = value;
27
+ if (typeof row !== "object" || row === null)
28
+ return false;
29
+ if (row.mode !== "single" && row.mode !== "concat" && row.mode !== "adelay")
30
+ return false;
31
+ return Array.isArray(row.clips) && row.clips.every(isMixClip);
32
+ };
33
+ const isMusic = (value) => {
34
+ const row = value;
35
+ if (typeof row !== "object" || row === null)
36
+ return false;
37
+ if (typeof row.path !== "string" || row.path.length === 0)
38
+ return false;
39
+ if (typeof row.sha256 !== "string" || !HEX64.test(row.sha256))
40
+ return false;
41
+ if (typeof row.volume !== "number" ||
42
+ !Number.isFinite(row.volume) ||
43
+ row.volume <= 0 ||
44
+ row.volume > 1)
45
+ return false;
46
+ const lic = row.licenseEvidenceSha256;
47
+ return lic === null || (typeof lic === "string" && HEX64.test(lic));
48
+ };
49
+ const hasMusic = (value) => value === undefined || isMusic(value);
50
+ const isApprovalManifest = (value) => {
51
+ const manifest = value;
52
+ if (typeof manifest !== "object" || manifest === null)
53
+ return false;
54
+ if (manifest.scope !== "visual" && manifest.scope !== "audiovisual")
55
+ return false;
56
+ if (!isManifestRef(manifest.storyboard))
57
+ return false;
58
+ if (!everyManifestRef(manifest.themes))
59
+ return false;
60
+ if (!everyManifestRef(manifest.assets))
61
+ return false;
62
+ if (!everyManifestRef(manifest.audio))
63
+ return false;
64
+ if (!hasLedger(manifest.ledgerSha256))
65
+ return false;
66
+ if (manifest.scope === "visual") {
67
+ if ((manifest.audio ?? []).length !== 0)
68
+ return false;
69
+ if (manifest.audioManifest !== undefined || manifest.mix !== undefined)
70
+ return false;
71
+ if (manifest.music !== undefined)
72
+ return false;
73
+ return true;
74
+ }
75
+ if (!isManifestRef(manifest.audioManifest))
76
+ return false;
77
+ if (!isMix(manifest.mix))
78
+ return false;
79
+ if (!hasMusic(manifest.music))
80
+ return false;
81
+ return (manifest.audio ?? []).length > 0;
82
+ };
83
+ const isApprovalReceiptFields = (row) => typeof row.inputManifestSha256 === "string" &&
84
+ HEX64.test(row.inputManifestSha256) &&
85
+ typeof row.previewManifestSha === "string" &&
86
+ typeof row.approvedAt === "string" &&
87
+ typeof row.note === "string";
88
+ const isApproval = (value) => {
89
+ const row = value;
90
+ if (typeof row !== "object" || row === null)
91
+ return false;
92
+ if (!isApprovalManifest(row.inputManifest))
93
+ return false;
94
+ return isApprovalReceiptFields(row);
95
+ };
96
+ /** The stored approval, or undefined when absent, malformed or legacy. */
97
+ export async function readApproval(host, storyboard) {
98
+ try {
99
+ const parsed = JSON.parse(await readSidecar(host, storyboard, APPROVAL_FILE));
100
+ return isApproval(parsed) ? parsed : undefined;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ export async function writeApproval(host, storyboard, approval) {
107
+ const path = approvalPath(storyboard);
108
+ await host.writeText(path, `${JSON.stringify(approval, null, 2)}\n`);
109
+ return path;
110
+ }
111
+ /**
112
+ * Does an approval match the CURRENT inputs (visual, audiovisual or
113
+ * audiovisual+music)? Fails
114
+ * closed: any legacy receipt is `stale` (re-preview + re-approve), missing
115
+ * file is `missing`. A visual-only receipt never authorizes audio: resolving
116
+ * with --audio recomputes the audiovisual hash, which cannot match. A
117
+ * narration-only receipt never authorizes --music: the music
118
+ * {path,sha256,volume,licenseEvidenceSha256} binding changes the hash.
119
+ * Rejects when inputs are unreadable (missing asset, ledger, wav…).
120
+ */
121
+ export async function approvalState(host, storyboard, opts = {}) {
122
+ const manifest = await buildApprovalInputManifest(host, storyboard, opts);
123
+ const hash = hashApprovalInput(manifest);
124
+ let raw;
125
+ try {
126
+ raw = await readSidecar(host, storyboard, APPROVAL_FILE);
127
+ }
128
+ catch {
129
+ return { state: "missing", hash };
130
+ }
131
+ let parsed;
132
+ try {
133
+ parsed = JSON.parse(raw);
134
+ }
135
+ catch {
136
+ return { state: "stale", hash };
137
+ }
138
+ if (!isApproval(parsed))
139
+ return { state: "stale", hash };
140
+ return { state: parsed.inputManifestSha256 === hash ? "approved" : "stale", hash };
141
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const approveCommand: Command;