@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,59 @@
1
+ /**
2
+ * Same-machine determinism bar from the scene3d spike: two renders reached
3
+ * SSIM All=1.0 and byte-identical md5. The gate passes on byte-identical
4
+ * sha256; anything else fails closed and names the SSIM bar for the
5
+ * frame-level re-check.
6
+ */
7
+ export const SSIM_GATE = 0.9999;
8
+ /**
9
+ * Scope of the claim: same machine, GPU and driver only. Cross-machine and
10
+ * cross-GPU output is NOT claimed (GPU float differs); deterministic scenes
11
+ * only — seeded grain, pure function of t, no rAF/Clock/random (scene3d and
12
+ * caption qualify).
13
+ */
14
+ export const DETERMINISM_NOTE = "same-machine only: same GPU/driver; cross-machine output is not claimed " +
15
+ "(GPU float may differ). Deterministic scenes only: seeded grain, no rAF.";
16
+ /** sha256 of both renders; a missing file hashes to undefined (fails closed downstream). */
17
+ export async function hashPair(host, pair) {
18
+ const [first, second] = await Promise.all([
19
+ host.sha256File(pair.first),
20
+ host.sha256File(pair.second),
21
+ ]);
22
+ return { first, second };
23
+ }
24
+ /** Pure hash comparison: identical passes, anything else is a fatal finding. */
25
+ export function determinismFindings(first, second) {
26
+ if (first === undefined || second === undefined) {
27
+ return [
28
+ {
29
+ code: "determinism-unreadable",
30
+ level: "fatal",
31
+ message: "could not hash both renders; the gate fails closed, re-render the pair",
32
+ },
33
+ ];
34
+ }
35
+ if (first === second) {
36
+ return [
37
+ {
38
+ code: "determinism-match",
39
+ level: "info",
40
+ message: "two renders are byte-identical; same-machine determinism holds",
41
+ },
42
+ ];
43
+ }
44
+ return [
45
+ {
46
+ code: "determinism-mismatch",
47
+ level: "fatal",
48
+ message: `renders differ byte-for-byte; re-check frames at SSIM >= ${SSIM_GATE}, same machine only`,
49
+ },
50
+ ];
51
+ }
52
+ /** Merges gate findings into a verify report; any fatal escalates the status to FAIL. */
53
+ export function withDeterminism(report, findings) {
54
+ const merged = [...report.findings, ...findings];
55
+ const fatal = findings.some((f) => f.level === "fatal");
56
+ if (fatal && report.status !== "FAIL")
57
+ return { status: "FAIL", findings: merged };
58
+ return { ...report, findings: merged };
59
+ }
@@ -0,0 +1,15 @@
1
+ import type { MediaExpectation } from "@anvpx/motion-build";
2
+ export interface FrameSpec {
3
+ width: number;
4
+ height: number;
5
+ fps: number;
6
+ }
7
+ /** Draft renders are downscaled to this width at this rate (see `mk draft`). */
8
+ export declare const DRAFT_WIDTH = 960;
9
+ export declare const DRAFT_FPS = 15;
10
+ /** ffmpeg `scale=960:-2` keeps the aspect ratio and rounds the height to an even number. */
11
+ export declare const draftHeight: (meta: FrameSpec) => number;
12
+ /** What a final render of a storyboard must look like. */
13
+ export declare const finalExpectation: (meta: FrameSpec, total: number, fps: number) => MediaExpectation;
14
+ /** What a draft of a storyboard must look like. */
15
+ export declare const draftExpectation: (meta: FrameSpec, total: number) => MediaExpectation;
@@ -0,0 +1,19 @@
1
+ /** Draft renders are downscaled to this width at this rate (see `mk draft`). */
2
+ export const DRAFT_WIDTH = 960;
3
+ export const DRAFT_FPS = 15;
4
+ /** ffmpeg `scale=960:-2` keeps the aspect ratio and rounds the height to an even number. */
5
+ export const draftHeight = (meta) => Math.round((meta.height * DRAFT_WIDTH) / meta.width / 2) * 2;
6
+ /** What a final render of a storyboard must look like. */
7
+ export const finalExpectation = (meta, total, fps) => ({
8
+ width: meta.width,
9
+ height: meta.height,
10
+ fps,
11
+ duration: total,
12
+ });
13
+ /** What a draft of a storyboard must look like. */
14
+ export const draftExpectation = (meta, total) => ({
15
+ width: DRAFT_WIDTH,
16
+ height: draftHeight(meta),
17
+ fps: DRAFT_FPS,
18
+ duration: total,
19
+ });
@@ -0,0 +1,61 @@
1
+ import type { VerifyReport } from "@anvpx/motion-build";
2
+ import type { Io } from "../types.ts";
3
+ import { mergeReports } from "./report.ts";
4
+ /** Freeze noise floor in dB; a lower floor treats quieter changes as motion. */
5
+ export declare const FREEZE_NOISE_DB = -60;
6
+ /** Shortest still span reported as frozen, seconds. */
7
+ export declare const FREEZE_MIN_SECONDS = 2;
8
+ /** Shortest black span reported, seconds. */
9
+ export declare const BLACK_MIN_SECONDS = 0.1;
10
+ /** Fraction of pixels dark enough to count as black. */
11
+ export declare const BLACK_PIXEL_THRESHOLD = 0.1;
12
+ /** Black this close to either end is treated as an intentional fade. */
13
+ export declare const HEAD_TAIL_SECONDS = 0.5;
14
+ /** Fixed filter chain; never user-supplied (mirrors LOUDNORM_FILTER, no second decode). */
15
+ export declare const MEDIA_EVENTS_FILTER: string;
16
+ /** Fixed ffmpeg argv template; `IN` is replaced with the file under test. */
17
+ export declare const MEDIA_EVENTS_ARGS: readonly string[];
18
+ export interface MediaEvent {
19
+ readonly start: number;
20
+ readonly duration: number;
21
+ }
22
+ export interface MediaEvents {
23
+ readonly frozen: readonly MediaEvent[];
24
+ readonly black: readonly MediaEvent[];
25
+ }
26
+ export type MediaEventsUnavailableKind = "missing-tool" | "missing-filter" | "unreadable";
27
+ export type MediaEventsOutcome = {
28
+ ok: true;
29
+ events: MediaEvents;
30
+ } | {
31
+ ok: false;
32
+ kind: MediaEventsUnavailableKind;
33
+ reason?: string;
34
+ };
35
+ /**
36
+ * Reads the freezedetect/blackdetect metadata lines out of combined stdout+stderr.
37
+ * Real ffmpeg prints freezedetect as `lavfi.freezedetect.freeze_start|_duration` and
38
+ * blackdetect as `black_start/black_end/black_duration` on one line, so both that
39
+ * spelling and the documented `lavfi.blackdetect.black_*` form are accepted.
40
+ * A span still open at EOF (ffmpeg prints no duration for it) is closed at the probed
41
+ * clip duration when one is known, and dropped otherwise.
42
+ * Malformed numbers and unpaired fields are ignored: a missing event is evidence,
43
+ * a thrown parse error is not.
44
+ */
45
+ export declare function parseMediaEvents(text: string, durationSec?: number): MediaEvents;
46
+ /** Builds the fixed ffmpeg argv for one file; the filter string never varies. */
47
+ export declare const mediaEventsArgsFor: (file: string) => string[];
48
+ /**
49
+ * Measures frozen and black spans with one decode pass and two fixed filters.
50
+ * `durationSec` (from the existing ffprobe result, never a second probe) closes a
51
+ * span that reaches EOF. Measurement only: audio is dropped (`-an`), nothing is written.
52
+ */
53
+ export declare function measureMediaEvents(io: Io, file: string, durationSec?: number): Promise<MediaEventsOutcome>;
54
+ /**
55
+ * Turns measured events into findings. A freeze or a mid-clip black run is a WARN,
56
+ * never fatal: both are judgment calls on the clip (CRAFT-RUBRIC §2 allows holds).
57
+ * Black at the head or tail is reported as info and leaves the status alone.
58
+ */
59
+ export declare function assessMediaEvents(outcome: MediaEventsOutcome, durationSec?: number): VerifyReport;
60
+ /** The spec's seam name: merges this probe's report into the base report. */
61
+ export declare const withMediaEvents: typeof mergeReports;
@@ -0,0 +1,174 @@
1
+ import { EXIT } from "../exit-codes.js";
2
+ import { mergeReports } from "./report.js";
3
+ /** Freeze noise floor in dB; a lower floor treats quieter changes as motion. */
4
+ export const FREEZE_NOISE_DB = -60;
5
+ /** Shortest still span reported as frozen, seconds. */
6
+ export const FREEZE_MIN_SECONDS = 2;
7
+ /** Shortest black span reported, seconds. */
8
+ export const BLACK_MIN_SECONDS = 0.1;
9
+ /** Fraction of pixels dark enough to count as black. */
10
+ export const BLACK_PIXEL_THRESHOLD = 0.1;
11
+ /** Black this close to either end is treated as an intentional fade. */
12
+ export const HEAD_TAIL_SECONDS = 0.5;
13
+ /** Fixed filter chain; never user-supplied (mirrors LOUDNORM_FILTER, no second decode). */
14
+ export const MEDIA_EVENTS_FILTER = `freezedetect=n=${FREEZE_NOISE_DB}dB:d=${FREEZE_MIN_SECONDS},` +
15
+ `blackdetect=d=${BLACK_MIN_SECONDS}:pix_th=${BLACK_PIXEL_THRESHOLD.toFixed(2)}`;
16
+ /** Fixed ffmpeg argv template; `IN` is replaced with the file under test. */
17
+ export const MEDIA_EVENTS_ARGS = [
18
+ "-v",
19
+ "info",
20
+ "-i",
21
+ "IN",
22
+ "-vf",
23
+ MEDIA_EVENTS_FILTER,
24
+ "-an",
25
+ "-f",
26
+ "null",
27
+ "-",
28
+ ];
29
+ /** `[blackdetect @ …] black_start:1.5`, `lavfi.freezedetect.freeze_duration: 2`. */
30
+ const EVENT_META = /(freezedetect\.freeze|blackdetect\.black|black)_(start|duration|end)\s*:\s*(-?\d+(?:\.\d+)?)/g;
31
+ const sorted = (events) => [...events].sort((a, b) => a.start - b.start);
32
+ /** Applies one metadata field; unpaired, negative or malformed values are dropped. */
33
+ function applyField(state, pair, field, value) {
34
+ if (field === "start") {
35
+ if (value >= 0)
36
+ state.open[pair] = value;
37
+ return;
38
+ }
39
+ const start = state.open[pair];
40
+ state.open[pair] = undefined;
41
+ if (start === undefined)
42
+ return;
43
+ const duration = field === "duration" ? value : value - start;
44
+ if (duration >= 0)
45
+ (pair === "frozen" ? state.frozen : state.black).push({ start, duration });
46
+ }
47
+ /**
48
+ * Reads the freezedetect/blackdetect metadata lines out of combined stdout+stderr.
49
+ * Real ffmpeg prints freezedetect as `lavfi.freezedetect.freeze_start|_duration` and
50
+ * blackdetect as `black_start/black_end/black_duration` on one line, so both that
51
+ * spelling and the documented `lavfi.blackdetect.black_*` form are accepted.
52
+ * A span still open at EOF (ffmpeg prints no duration for it) is closed at the probed
53
+ * clip duration when one is known, and dropped otherwise.
54
+ * Malformed numbers and unpaired fields are ignored: a missing event is evidence,
55
+ * a thrown parse error is not.
56
+ */
57
+ export function parseMediaEvents(text, durationSec) {
58
+ const state = {
59
+ frozen: [],
60
+ black: [],
61
+ open: { frozen: undefined, black: undefined },
62
+ };
63
+ for (const match of text.matchAll(EVENT_META)) {
64
+ const value = Number(match[3]);
65
+ if (!Number.isFinite(value))
66
+ continue;
67
+ const pair = (match[1] ?? "").startsWith("freeze") ? "frozen" : "black";
68
+ applyField(state, pair, match[2] ?? "", value);
69
+ }
70
+ closeOpen(state, "frozen", durationSec);
71
+ closeOpen(state, "black", durationSec);
72
+ return { frozen: sorted(state.frozen), black: sorted(state.black) };
73
+ }
74
+ /** Closes a span that never printed its end: a freeze or black run reaching EOF. */
75
+ function closeOpen(state, pair, durationSec) {
76
+ const start = state.open[pair];
77
+ state.open[pair] = undefined;
78
+ if (start === undefined)
79
+ return;
80
+ const duration = durationSec === undefined ? -1 : durationSec - start;
81
+ if (duration >= 0)
82
+ (pair === "frozen" ? state.frozen : state.black).push({ start, duration });
83
+ }
84
+ /** Builds the fixed ffmpeg argv for one file; the filter string never varies. */
85
+ export const mediaEventsArgsFor = (file) => MEDIA_EVENTS_ARGS.map((arg) => (arg === "IN" ? file : arg));
86
+ const shortReason = (stderr) => (stderr.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "no details")
87
+ .trim()
88
+ .split(/\s+/)
89
+ .slice(-12)
90
+ .join(" ");
91
+ /**
92
+ * Measures frozen and black spans with one decode pass and two fixed filters.
93
+ * `durationSec` (from the existing ffprobe result, never a second probe) closes a
94
+ * span that reaches EOF. Measurement only: audio is dropped (`-an`), nothing is written.
95
+ */
96
+ export async function measureMediaEvents(io, file, durationSec) {
97
+ const result = await io.run("ffmpeg", mediaEventsArgsFor(file));
98
+ if (result.code === EXIT.MISSING_TOOL)
99
+ return { ok: false, kind: "missing-tool" };
100
+ if (result.code !== 0) {
101
+ const stderr = result.stderr ?? "";
102
+ if (/no such filter|unknown filter|not recognized/i.test(stderr)) {
103
+ return { ok: false, kind: "missing-filter", reason: shortReason(stderr) };
104
+ }
105
+ return { ok: false, kind: "unreadable", reason: shortReason(stderr) };
106
+ }
107
+ const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
108
+ return { ok: true, events: parseMediaEvents(text, durationSec) };
109
+ }
110
+ const finding = (code, level, message) => ({
111
+ code,
112
+ level,
113
+ message,
114
+ });
115
+ const round1 = (n) => (Math.round(n * 10) / 10).toFixed(1);
116
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
117
+ /** Longest span, earliest on a tie. */
118
+ const longest = (spans) => spans.reduce((best, span) => (best === undefined || span.duration > best.duration ? span : best), undefined);
119
+ /** True when the span starts or ends within HEAD_TAIL_SECONDS of either end of the clip. */
120
+ const nearEnd = (span, durationSec) => span.start <= HEAD_TAIL_SECONDS ||
121
+ (durationSec !== undefined && span.start + span.duration >= durationSec - HEAD_TAIL_SECONDS);
122
+ function freezeFinding(frozen) {
123
+ const span = longest(frozen) ?? { start: 0, duration: 0 };
124
+ const message = `${plural(frozen.length, "frozen span")}, longest ${round1(span.duration)} s at ` +
125
+ `${round1(span.start)} s; review each against an intentional hold.`;
126
+ return finding("media-freeze-detected", "warn", message);
127
+ }
128
+ function strayBlackFinding(stray) {
129
+ const first = stray[0] ?? { start: 0, duration: 0 };
130
+ const message = `${plural(stray.length, "black span")} outside head/tail, first at ` +
131
+ `${round1(first.start)} s for ${round1(first.duration)} s.`;
132
+ return finding("media-black-detected", "warn", message);
133
+ }
134
+ function headTailFinding(excused, durationSec) {
135
+ const head = excused.find((span) => span.start <= HEAD_TAIL_SECONDS);
136
+ const tail = durationSec === undefined
137
+ ? undefined
138
+ : excused.find((span) => span.start + span.duration >= durationSec - HEAD_TAIL_SECONDS);
139
+ const parts = [
140
+ ...(head === undefined ? [] : [`head ${round1(head.start)} s`]),
141
+ ...(tail === undefined ? [] : [`tail ${round1(tail.start)} s`]),
142
+ ];
143
+ return finding("media-black-head-tail", "info", `Black at ${parts.join(" and ")} treated as intentional.`);
144
+ }
145
+ function unavailable(kind) {
146
+ const message = kind === "unreadable"
147
+ ? "ffmpeg could not decode the file for event detection."
148
+ : "Media event detection skipped; ffmpeg freezedetect/blackdetect unavailable.";
149
+ const code = kind === "unreadable" ? "media-events-unreadable" : "media-events-unavailable";
150
+ return { status: "UNKNOWN", findings: [finding(code, "warn", message)] };
151
+ }
152
+ /**
153
+ * Turns measured events into findings. A freeze or a mid-clip black run is a WARN,
154
+ * never fatal: both are judgment calls on the clip (CRAFT-RUBRIC §2 allows holds).
155
+ * Black at the head or tail is reported as info and leaves the status alone.
156
+ */
157
+ export function assessMediaEvents(outcome, durationSec) {
158
+ if (!outcome.ok)
159
+ return unavailable(outcome.kind);
160
+ const { frozen, black } = outcome.events;
161
+ const excused = black.filter((span) => nearEnd(span, durationSec));
162
+ const stray = black.filter((span) => !nearEnd(span, durationSec));
163
+ const findings = [
164
+ ...(frozen.length > 0 ? [freezeFinding(frozen)] : []),
165
+ ...(stray.length > 0 ? [strayBlackFinding(stray)] : []),
166
+ ...(excused.length > 0 ? [headTailFinding(excused, durationSec)] : []),
167
+ ];
168
+ if (findings.length === 0) {
169
+ findings.push(finding("media-events-clear", "info", "No frozen or black spans detected."));
170
+ }
171
+ return { status: frozen.length > 0 || stray.length > 0 ? "WARN" : "PASS", findings };
172
+ }
173
+ /** The spec's seam name: merges this probe's report into the base report. */
174
+ export const withMediaEvents = mergeReports;
@@ -0,0 +1,33 @@
1
+ import type { VerifyReport } from "@anvpx/motion-build";
2
+ import { type PlatformTemplate } from "@anvpx/motion-schema";
3
+ import type { Host } from "../types.ts";
4
+ import { type LoudnessAvailability, type LoudnessMeasure } from "./audio-loudness.ts";
5
+ export type PlatformLoad = {
6
+ ok: true;
7
+ template: PlatformTemplate;
8
+ } | {
9
+ ok: false;
10
+ message: string;
11
+ };
12
+ /** `data/platforms` of the schema package, resolved from its entry point. */
13
+ export declare const platformDir: () => string;
14
+ /** Finds a platform template by id or alias in the schema package's data directory. */
15
+ export declare function loadPlatform(host: Host, id: string, dir?: string): Promise<PlatformLoad>;
16
+ /** Sync platform lookup for template styles, read once from the schema package's data directory. */
17
+ export declare function platformLookup(host: Host, dir?: string): Promise<(id: string) => PlatformTemplate | undefined>;
18
+ export interface PlatformAudioPolicy {
19
+ readonly id: string;
20
+ readonly lufsTarget: number;
21
+ readonly truePeakDb: number;
22
+ }
23
+ /**
24
+ * Audio policy usable for QC: only an explicit verified template counts.
25
+ * Unverified or absent policy yields undefined so callers report UNKNOWN,
26
+ * never a universal-LUFS FAIL.
27
+ */
28
+ export declare const verifiedAudioPolicy: (template: PlatformTemplate | undefined) => PlatformAudioPolicy | undefined;
29
+ /**
30
+ * Verify-seam entry: judges a loudness measurement only against a verified
31
+ * platform policy. Reuses verify report/exit semantics (PASS/FAIL/UNKNOWN).
32
+ */
33
+ export declare const platformAudioReport: (measure: LoudnessMeasure | undefined, template: PlatformTemplate | undefined, availability?: LoudnessAvailability) => VerifyReport;
@@ -0,0 +1,59 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { parsePlatformTemplate } from "@anvpx/motion-schema";
4
+ import { assessAudioLoudness, } from "./audio-loudness.js";
5
+ const SCHEMA_ENTRY = "@anvpx/motion-schema";
6
+ const DATA_FROM_ENTRY = ["..", "data", "platforms"];
7
+ /** `data/platforms` of the schema package, resolved from its entry point. */
8
+ export const platformDir = () => join(dirname(createRequire(import.meta.url).resolve(SCHEMA_ENTRY)), ...DATA_FROM_ENTRY);
9
+ async function readTemplates(host, dir) {
10
+ const names = (await host.listDir(dir)).filter((name) => name.endsWith(".json")).sort();
11
+ const templates = [];
12
+ for (const name of names) {
13
+ const parsed = parsePlatformTemplate(JSON.parse(await host.readText(join(dir, name))));
14
+ if (parsed.ok)
15
+ templates.push(parsed.value);
16
+ }
17
+ return templates;
18
+ }
19
+ /** Finds a platform template by id or alias in the schema package's data directory. */
20
+ export async function loadPlatform(host, id, dir = platformDir()) {
21
+ let templates;
22
+ try {
23
+ templates = await readTemplates(host, dir);
24
+ }
25
+ catch (error) {
26
+ return { ok: false, message: `cannot read platform templates: ${error.message}` };
27
+ }
28
+ const wanted = id.toLowerCase();
29
+ const match = templates.find((t) => t.id === wanted || t.aliases.includes(wanted));
30
+ if (match)
31
+ return { ok: true, template: match };
32
+ const known = templates.map((t) => t.id).join(", ");
33
+ return { ok: false, message: `unknown platform "${id}"; use one of: ${known}` };
34
+ }
35
+ /** Sync platform lookup for template styles, read once from the schema package's data directory. */
36
+ export async function platformLookup(host, dir = platformDir()) {
37
+ const templates = await readTemplates(host, dir).catch(() => []);
38
+ return (id) => {
39
+ const wanted = id.toLowerCase();
40
+ return templates.find((t) => t.id === wanted || t.aliases.includes(wanted));
41
+ };
42
+ }
43
+ /**
44
+ * Audio policy usable for QC: only an explicit verified template counts.
45
+ * Unverified or absent policy yields undefined so callers report UNKNOWN,
46
+ * never a universal-LUFS FAIL.
47
+ */
48
+ export const verifiedAudioPolicy = (template) => template?.verified === true
49
+ ? {
50
+ id: template.id,
51
+ lufsTarget: template.audio.lufsTarget,
52
+ truePeakDb: template.audio.truePeakDb,
53
+ }
54
+ : undefined;
55
+ /**
56
+ * Verify-seam entry: judges a loudness measurement only against a verified
57
+ * platform policy. Reuses verify report/exit semantics (PASS/FAIL/UNKNOWN).
58
+ */
59
+ export const platformAudioReport = (measure, template, availability) => assessAudioLoudness(measure, template, availability);
@@ -0,0 +1,14 @@
1
+ import type { Io } from "../types.ts";
2
+ export type ProbeOutcome = {
3
+ ok: true;
4
+ probe: unknown;
5
+ } | {
6
+ ok: false;
7
+ kind: "missing-tool";
8
+ } | {
9
+ ok: false;
10
+ kind: "unreadable";
11
+ reason: string;
12
+ };
13
+ /** Runs `ffprobe` (found on PATH, as doctor finds ffmpeg) and parses its JSON. */
14
+ export declare function probeMedia(io: Io, file: string): Promise<ProbeOutcome>;
@@ -0,0 +1,23 @@
1
+ import { EXIT } from "../exit-codes.js";
2
+ const PROBE_ARGS = ["-v", "error", "-show_streams", "-show_format", "-of", "json"];
3
+ const REASON_WORDS = 12;
4
+ /** First stderr line, cut to a few words so a finding message stays short. */
5
+ const shortReason = (stderr) => (stderr.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "no details")
6
+ .trim()
7
+ .split(/\s+/)
8
+ .slice(-REASON_WORDS)
9
+ .join(" ");
10
+ /** Runs `ffprobe` (found on PATH, as doctor finds ffmpeg) and parses its JSON. */
11
+ export async function probeMedia(io, file) {
12
+ const result = await io.run("ffprobe", [...PROBE_ARGS, file]);
13
+ if (result.code === EXIT.MISSING_TOOL)
14
+ return { ok: false, kind: "missing-tool" };
15
+ if (result.code !== 0)
16
+ return { ok: false, kind: "unreadable", reason: shortReason(result.stderr) };
17
+ try {
18
+ return { ok: true, probe: JSON.parse(result.stdout) };
19
+ }
20
+ catch {
21
+ return { ok: false, kind: "unreadable", reason: "ffprobe printed invalid JSON" };
22
+ }
23
+ }
@@ -0,0 +1,15 @@
1
+ import type { VerifyReport } from "@anvpx/motion-build";
2
+ /** Findings shown before the rest are summarised as "+N more" (constitution 8.6). */
3
+ export declare const MAX_FINDINGS = 10;
4
+ /** PASS and WARN succeed, FAIL is a QA failure, UNKNOWN could not be decided (exit 2, doctor parity). */
5
+ export declare const exitCodeForStatus: (status: VerifyReport["status"]) => number;
6
+ /**
7
+ * Combines a base report with an opt-in probe's report: FAIL > UNKNOWN > WARN > PASS,
8
+ * findings base first. The merged status stays the one exit authority; the probes
9
+ * never set a status the base could not have reached on its own.
10
+ */
11
+ export declare function mergeReports(base: VerifyReport, extra: VerifyReport): VerifyReport;
12
+ /** `STATUS file` then one line per finding, most severe first, capped. */
13
+ export declare function formatReport(file: string, report: VerifyReport): string;
14
+ /** One compact JSON line: {status, file, findings, more?}. */
15
+ export declare function formatReportJson(file: string, report: VerifyReport): string;
@@ -0,0 +1,45 @@
1
+ import { basename } from "node:path";
2
+ import { EXIT } from "../exit-codes.js";
3
+ /** Findings shown before the rest are summarised as "+N more" (constitution 8.6). */
4
+ export const MAX_FINDINGS = 10;
5
+ const ORDER = { fatal: 0, warn: 1, info: 2 };
6
+ /** PASS and WARN succeed, FAIL is a QA failure, UNKNOWN could not be decided (exit 2, doctor parity). */
7
+ export const exitCodeForStatus = (status) => {
8
+ if (status === "FAIL")
9
+ return EXIT.QA_FAIL;
10
+ return status === "UNKNOWN" ? EXIT.USAGE : EXIT.OK;
11
+ };
12
+ /** Heaviest status wins when an opt-in probe is merged into the base report. */
13
+ const STATUS_ORDER = {
14
+ PASS: 0,
15
+ WARN: 1,
16
+ UNKNOWN: 2,
17
+ FAIL: 3,
18
+ };
19
+ /**
20
+ * Combines a base report with an opt-in probe's report: FAIL > UNKNOWN > WARN > PASS,
21
+ * findings base first. The merged status stays the one exit authority; the probes
22
+ * never set a status the base could not have reached on its own.
23
+ */
24
+ export function mergeReports(base, extra) {
25
+ const heavier = STATUS_ORDER[extra.status] > STATUS_ORDER[base.status] ? extra.status : base.status;
26
+ return { status: heavier, findings: [...base.findings, ...extra.findings] };
27
+ }
28
+ const ranked = (findings) => [...findings].sort((a, b) => ORDER[a.level] - ORDER[b.level]);
29
+ const line = (f) => `${f.level} ${f.code}: ${f.message}${f.path === undefined ? "" : ` (${f.path})`}`;
30
+ /** `STATUS file` then one line per finding, most severe first, capped. */
31
+ export function formatReport(file, report) {
32
+ const shown = ranked(report.findings).slice(0, MAX_FINDINGS);
33
+ const more = report.findings.length - shown.length;
34
+ const rows = [`${report.status} ${basename(file)}`, ...shown.map(line)];
35
+ if (more > 0)
36
+ rows.push(`(+${more} more)`);
37
+ return `${rows.join("\n")}\n`;
38
+ }
39
+ /** One compact JSON line: {status, file, findings, more?}. */
40
+ export function formatReportJson(file, report) {
41
+ const shown = ranked(report.findings).slice(0, MAX_FINDINGS);
42
+ const more = report.findings.length - shown.length;
43
+ const doc = { status: report.status, file: basename(file), findings: shown };
44
+ return `${JSON.stringify(more > 0 ? { ...doc, more } : doc)}\n`;
45
+ }
@@ -0,0 +1,27 @@
1
+ import { type MediaExpectation, type VerifyReport } from "@anvpx/motion-build";
2
+ import type { PlatformTemplate } from "@anvpx/motion-schema";
3
+ import type { Io } from "../types.ts";
4
+ import { type DeterminismPair } from "./determinism.ts";
5
+ export type VerifyRun = {
6
+ ok: true;
7
+ report: VerifyReport;
8
+ durationSec?: number;
9
+ } | {
10
+ ok: false;
11
+ code: number;
12
+ message: string;
13
+ };
14
+ export interface VerifyTarget {
15
+ file: string;
16
+ expect?: MediaExpectation;
17
+ platform?: PlatformTemplate;
18
+ /** When set, both renders are hashed and byte-identity is gated (exit 5 on mismatch). */
19
+ determinism?: DeterminismPair;
20
+ }
21
+ /** Probes a file with ffprobe and assesses it; never throws. */
22
+ export declare function verifyFile(io: Io, target: VerifyTarget): Promise<VerifyRun>;
23
+ /** The `--verify` step of draft and render: prints the report, resolves the exit code. */
24
+ export declare function verifyStep(io: Io, target: VerifyTarget): Promise<{
25
+ code: number;
26
+ status: VerifyReport["status"] | "not-run";
27
+ }>;
@@ -0,0 +1,53 @@
1
+ import { verifyMedia } from "@anvpx/motion-build";
2
+ import { EXIT } from "../exit-codes.js";
3
+ import { determinismFindings, hashPair, withDeterminism, } from "./determinism.js";
4
+ import { probeMedia } from "./probe.js";
5
+ import { exitCodeForStatus, formatReport } from "./report.js";
6
+ /** Seconds from the existing probe (`format.duration`); undefined when absent or malformed. */
7
+ const probedDuration = (probe) => {
8
+ const format = probe?.format;
9
+ const raw = format?.duration;
10
+ const seconds = typeof raw === "string" ? Number(raw) : raw;
11
+ return typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0
12
+ ? seconds
13
+ : undefined;
14
+ };
15
+ /** Probes a file with ffprobe and assesses it; never throws. */
16
+ export async function verifyFile(io, target) {
17
+ const probed = await probeMedia(io, target.file);
18
+ if (!probed.ok && probed.kind === "missing-tool") {
19
+ const message = "mk: ffprobe not found; install ffmpeg and put it on PATH";
20
+ return { ok: false, code: EXIT.MISSING_TOOL, message };
21
+ }
22
+ if (!probed.ok) {
23
+ const message = `ffprobe could not read the file: ${probed.reason}`;
24
+ const finding = { code: "media-unreadable", level: "fatal", message };
25
+ return { ok: true, report: { status: "FAIL", findings: [finding] } };
26
+ }
27
+ const { expect, platform, determinism } = target;
28
+ const assessed = verifyMedia({
29
+ probe: probed.probe,
30
+ ...(expect ? { expect } : {}),
31
+ ...(platform ? { platform } : {}),
32
+ });
33
+ const durationSec = probedDuration(probed.probe);
34
+ const timing = durationSec === undefined ? {} : { durationSec };
35
+ if (determinism === undefined)
36
+ return { ok: true, report: assessed, ...timing };
37
+ const hashes = await hashPair(io.host, determinism);
38
+ return {
39
+ ok: true,
40
+ report: withDeterminism(assessed, determinismFindings(hashes.first, hashes.second)),
41
+ ...timing,
42
+ };
43
+ }
44
+ /** The `--verify` step of draft and render: prints the report, resolves the exit code. */
45
+ export async function verifyStep(io, target) {
46
+ const run = await verifyFile(io, target);
47
+ if (!run.ok) {
48
+ io.stderr(`${run.message}\n`);
49
+ return { code: run.code, status: "not-run" };
50
+ }
51
+ io.stdout(formatReport(target.file, run.report));
52
+ return { code: exitCodeForStatus(run.report.status), status: run.report.status };
53
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const voiceCommand: Command;
3
+ export declare const makeVoiceCommand: () => Command;
@@ -0,0 +1,49 @@
1
+ import { parseFlags, requireStoryboard, textFlag, usageError } from "../args.js";
2
+ import { runVoice, VOICE_ENV_NOTE, VOICE_MEASURED_QC_NOTE, VOICE_WAV_FALLBACK_NOTE, } from "./run.js";
3
+ const USAGE = `mk voice <storyboard.json> --voice <id> [--out <manifest>] [--audio-dir <dir>] [--python <bin>]
4
+
5
+ Kokoro local primary. Synthesize narration via the voice venv and write a voice TimingManifest.
6
+ Reads narration per base scene, probes the venv, synthesizes each scene, aligns
7
+ words, gates silence with ffprobe, then writes .mk/<stem>/voice-manifest.json
8
+ plus licences-style voice-provenance.json. Feed it to the build timing path
9
+ (resolveTimeline voice override). No venv: fails closed with a doctor hint,
10
+ never silent fallback (fallback.ts is durations-only behind an explicit flag).
11
+ ${VOICE_WAV_FALLBACK_NOTE} when env blocked. ${VOICE_MEASURED_QC_NOTE}.
12
+ ${VOICE_ENV_NOTE}.
13
+ --voice <id> voice name from the storyboard/adapters (required; never hardcoded)
14
+ --out <file> manifest path (default .mk/<stem>/voice-manifest.json)
15
+ --audio-dir <d> per-scene wav directory (default .mk/<stem>/audio)
16
+ --python <bin> explicit venv python (skips the probe)
17
+ `;
18
+ const FLAGS = {
19
+ voice: { type: "string" },
20
+ out: { type: "string" },
21
+ "audio-dir": { type: "string" },
22
+ python: { type: "string" },
23
+ };
24
+ export const voiceCommand = {
25
+ name: "voice",
26
+ summary: "synthesize narration into a voice TimingManifest for the build timing path",
27
+ async run(args, io) {
28
+ const parsed = parseFlags("voice", USAGE, args, FLAGS, io);
29
+ if (parsed.kind === "exit")
30
+ return parsed.code;
31
+ const storyboard = requireStoryboard("voice", USAGE, parsed.positionals, io);
32
+ if (typeof storyboard !== "string")
33
+ return storyboard.code;
34
+ const voice = textFlag(parsed.flags, "voice");
35
+ if (voice === undefined || voice.trim() === "") {
36
+ return usageError("voice", USAGE, "--voice <id> is required", io).code;
37
+ }
38
+ const out = textFlag(parsed.flags, "out");
39
+ const audioDir = textFlag(parsed.flags, "audio-dir");
40
+ const python = textFlag(parsed.flags, "python");
41
+ return runVoice(storyboard, {
42
+ voice,
43
+ ...(out === undefined ? {} : { outPath: out }),
44
+ ...(audioDir === undefined ? {} : { audioDir }),
45
+ ...(python === undefined ? {} : { python }),
46
+ }, io);
47
+ },
48
+ };
49
+ export const makeVoiceCommand = () => voiceCommand;