@anvpx/motion-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -2
  3. package/dist/analyze/command.d.ts +2 -0
  4. package/dist/analyze/command.js +190 -0
  5. package/dist/approval/approval.d.ts +32 -0
  6. package/dist/approval/approval.js +141 -0
  7. package/dist/approval/command.d.ts +2 -0
  8. package/dist/approval/command.js +255 -0
  9. package/dist/approval/gate.d.ts +7 -0
  10. package/dist/approval/gate.js +23 -0
  11. package/dist/approval/hash.d.ts +71 -0
  12. package/dist/approval/hash.js +464 -0
  13. package/dist/args.d.ts +30 -0
  14. package/dist/args.js +53 -0
  15. package/dist/assets/command.d.ts +2 -0
  16. package/dist/assets/command.js +165 -0
  17. package/dist/captions/command.d.ts +2 -0
  18. package/dist/captions/command.js +341 -0
  19. package/dist/captions/cues.d.ts +10 -0
  20. package/dist/captions/cues.js +61 -0
  21. package/dist/catalog/command.d.ts +2 -0
  22. package/dist/catalog/command.js +60 -0
  23. package/dist/catalog/format.d.ts +13 -0
  24. package/dist/catalog/format.js +54 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +35 -0
  27. package/dist/commands.d.ts +3 -0
  28. package/dist/commands.js +59 -0
  29. package/dist/compare/command.d.ts +4 -0
  30. package/dist/compare/command.js +144 -0
  31. package/dist/contact/argv.d.ts +24 -0
  32. package/dist/contact/argv.js +42 -0
  33. package/dist/contact/command.d.ts +2 -0
  34. package/dist/contact/command.js +79 -0
  35. package/dist/doctor/checks.d.ts +25 -0
  36. package/dist/doctor/checks.js +571 -0
  37. package/dist/doctor/command.d.ts +4 -0
  38. package/dist/doctor/command.js +88 -0
  39. package/dist/doctor/format.d.ts +7 -0
  40. package/dist/doctor/format.js +41 -0
  41. package/dist/doctor/heal.d.ts +14 -0
  42. package/dist/doctor/heal.js +71 -0
  43. package/dist/doctor/pins.d.ts +8 -0
  44. package/dist/doctor/pins.js +28 -0
  45. package/dist/doctor/skills.d.ts +15 -0
  46. package/dist/doctor/skills.js +58 -0
  47. package/dist/doctor/types.d.ts +36 -0
  48. package/dist/doctor/types.js +1 -0
  49. package/dist/doctor/vendored.d.ts +15 -0
  50. package/dist/doctor/vendored.js +90 -0
  51. package/dist/doctor/version.d.ts +8 -0
  52. package/dist/doctor/version.js +11 -0
  53. package/dist/exit-codes.d.ts +16 -0
  54. package/dist/exit-codes.js +15 -0
  55. package/dist/fit/command.d.ts +5 -0
  56. package/dist/fit/command.js +154 -0
  57. package/dist/fit/solve.d.ts +21 -0
  58. package/dist/fit/solve.js +80 -0
  59. package/dist/fit/target.d.ts +18 -0
  60. package/dist/fit/target.js +47 -0
  61. package/dist/generate3d/command.d.ts +3 -0
  62. package/dist/generate3d/command.js +38 -0
  63. package/dist/generate3d/run.d.ts +39 -0
  64. package/dist/generate3d/run.js +77 -0
  65. package/dist/icon/command.d.ts +2 -0
  66. package/dist/icon/command.js +113 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +16 -0
  69. package/dist/install-mk.d.ts +31 -0
  70. package/dist/install-mk.js +168 -0
  71. package/dist/integration/preview.integration.d.ts +1 -0
  72. package/dist/integration/preview.integration.js +53 -0
  73. package/dist/integration/smoke.integration.d.ts +1 -0
  74. package/dist/integration/smoke.integration.js +91 -0
  75. package/dist/main.d.ts +2 -0
  76. package/dist/main.js +4 -0
  77. package/dist/node-io.d.ts +14 -0
  78. package/dist/node-io.js +152 -0
  79. package/dist/optimize/command.d.ts +12 -0
  80. package/dist/optimize/command.js +110 -0
  81. package/dist/packs/command.d.ts +2 -0
  82. package/dist/packs/command.js +109 -0
  83. package/dist/patch/apply.d.ts +36 -0
  84. package/dist/patch/apply.js +186 -0
  85. package/dist/patch/command.d.ts +2 -0
  86. package/dist/patch/command.js +92 -0
  87. package/dist/plugin/install.d.ts +40 -0
  88. package/dist/plugin/install.js +170 -0
  89. package/dist/preview/command.d.ts +10 -0
  90. package/dist/preview/command.js +764 -0
  91. package/dist/preview/manifest.d.ts +94 -0
  92. package/dist/preview/manifest.js +19 -0
  93. package/dist/preview/pack.d.ts +24 -0
  94. package/dist/preview/pack.js +53 -0
  95. package/dist/preview/plan.d.ts +58 -0
  96. package/dist/preview/plan.js +160 -0
  97. package/dist/preview/provenance.d.ts +23 -0
  98. package/dist/preview/provenance.js +38 -0
  99. package/dist/publish/command.d.ts +2 -0
  100. package/dist/publish/command.js +70 -0
  101. package/dist/publish/player.d.ts +25 -0
  102. package/dist/publish/player.js +108 -0
  103. package/dist/publish/static.d.ts +16 -0
  104. package/dist/publish/static.js +78 -0
  105. package/dist/qc.d.ts +2 -0
  106. package/dist/qc.js +192 -0
  107. package/dist/scene/author.d.ts +16 -0
  108. package/dist/scene/author.js +279 -0
  109. package/dist/scene/command.d.ts +2 -0
  110. package/dist/scene/command.js +110 -0
  111. package/dist/scene/ops.d.ts +23 -0
  112. package/dist/scene/ops.js +151 -0
  113. package/dist/state/command.d.ts +2 -0
  114. package/dist/state/command.js +128 -0
  115. package/dist/state/evidence.d.ts +71 -0
  116. package/dist/state/evidence.js +243 -0
  117. package/dist/storyboard/check.d.ts +21 -0
  118. package/dist/storyboard/check.js +39 -0
  119. package/dist/storyboard/containment.d.ts +24 -0
  120. package/dist/storyboard/containment.js +43 -0
  121. package/dist/storyboard/custom.d.ts +15 -0
  122. package/dist/storyboard/custom.js +133 -0
  123. package/dist/storyboard/edit.d.ts +19 -0
  124. package/dist/storyboard/edit.js +39 -0
  125. package/dist/storyboard/load.d.ts +11 -0
  126. package/dist/storyboard/load.js +52 -0
  127. package/dist/storyboard/report.d.ts +5 -0
  128. package/dist/storyboard/report.js +8 -0
  129. package/dist/template/coerce.d.ts +6 -0
  130. package/dist/template/coerce.js +44 -0
  131. package/dist/template/command.d.ts +5 -0
  132. package/dist/template/command.js +163 -0
  133. package/dist/template/env.d.ts +34 -0
  134. package/dist/template/env.js +38 -0
  135. package/dist/template/fork.d.ts +8 -0
  136. package/dist/template/fork.js +156 -0
  137. package/dist/template/format.d.ts +21 -0
  138. package/dist/template/format.js +161 -0
  139. package/dist/template/list.d.ts +15 -0
  140. package/dist/template/list.js +116 -0
  141. package/dist/template/new.d.ts +6 -0
  142. package/dist/template/new.js +302 -0
  143. package/dist/testing/env.d.ts +8 -0
  144. package/dist/testing/env.js +19 -0
  145. package/dist/testing/fakes.d.ts +33 -0
  146. package/dist/testing/fakes.js +95 -0
  147. package/dist/testing/fixtures.d.ts +8 -0
  148. package/dist/testing/fixtures.js +6 -0
  149. package/dist/testing/probe.d.ts +15 -0
  150. package/dist/testing/probe.js +20 -0
  151. package/dist/testing/project.d.ts +22 -0
  152. package/dist/testing/project.js +26 -0
  153. package/dist/tooling/session.d.ts +40 -0
  154. package/dist/tooling/session.js +106 -0
  155. package/dist/types.d.ts +59 -0
  156. package/dist/types.js +1 -0
  157. package/dist/verify/audio-loudness.d.ts +46 -0
  158. package/dist/verify/audio-loudness.js +186 -0
  159. package/dist/verify/command.d.ts +2 -0
  160. package/dist/verify/command.js +161 -0
  161. package/dist/verify/determinism.d.ts +30 -0
  162. package/dist/verify/determinism.js +59 -0
  163. package/dist/verify/expectation.d.ts +15 -0
  164. package/dist/verify/expectation.js +19 -0
  165. package/dist/verify/media-events.d.ts +61 -0
  166. package/dist/verify/media-events.js +174 -0
  167. package/dist/verify/platform.d.ts +33 -0
  168. package/dist/verify/platform.js +59 -0
  169. package/dist/verify/probe.d.ts +14 -0
  170. package/dist/verify/probe.js +23 -0
  171. package/dist/verify/report.d.ts +15 -0
  172. package/dist/verify/report.js +45 -0
  173. package/dist/verify/run.d.ts +27 -0
  174. package/dist/verify/run.js +53 -0
  175. package/dist/voice/command.d.ts +3 -0
  176. package/dist/voice/command.js +49 -0
  177. package/dist/voice/manifest.d.ts +33 -0
  178. package/dist/voice/manifest.js +69 -0
  179. package/dist/voice/run.d.ts +27 -0
  180. package/dist/voice/run.js +190 -0
  181. package/dist/workflow/build.d.ts +2 -0
  182. package/dist/workflow/build.js +25 -0
  183. package/dist/workflow/draft.d.ts +9 -0
  184. package/dist/workflow/draft.js +215 -0
  185. package/dist/workflow/finish.d.ts +23 -0
  186. package/dist/workflow/finish.js +28 -0
  187. package/dist/workflow/project.d.ts +73 -0
  188. package/dist/workflow/project.js +198 -0
  189. package/dist/workflow/provenance.d.ts +18 -0
  190. package/dist/workflow/provenance.js +36 -0
  191. package/dist/workflow/render.d.ts +31 -0
  192. package/dist/workflow/render.js +721 -0
  193. package/dist/workflow/still.d.ts +5 -0
  194. package/dist/workflow/still.js +62 -0
  195. package/dist/workflow/validate.d.ts +2 -0
  196. package/dist/workflow/validate.js +49 -0
  197. package/package.json +38 -3
@@ -0,0 +1,106 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { readPins } from "../doctor/pins.js";
4
+ import { formatVersion, parseVersion } from "../doctor/version.js";
5
+ import { EXIT } from "../exit-codes.js";
6
+ export const LOG_FILE = "render-log.txt";
7
+ const TAIL_LINES = 15;
8
+ /** Renders of long storyboards are slow; the probe default of 30 s would kill them. */
9
+ const STEP_TIMEOUT_MS = 60 * 60_000;
10
+ const pkgRequire = createRequire(import.meta.url);
11
+ /**
12
+ * Absolute path of the hyperframes CLI entry point, resolved from the packages
13
+ * installed alongside this one (hyperframes is a runtime dependency of
14
+ * `@anvpx/motion`). Throws when the install cannot provide it.
15
+ */
16
+ export function hyperframesCliPath() {
17
+ return join(dirname(pkgRequire.resolve("hyperframes/package.json")), "dist", "cli.js");
18
+ }
19
+ /** Absolute path of the installed hyperframes manifest (doctor reports its version). */
20
+ export function hyperframesPackagePath() {
21
+ return pkgRequire.resolve("hyperframes/package.json");
22
+ }
23
+ /**
24
+ * Why hyperframes may not be used (missing install or mismatched pin), or undefined when
25
+ * it is runnable. A pin is optional: consumer projects get it from `@anvpx/motion`, so
26
+ * no pin plus a runnable installed CLI is acceptable.
27
+ */
28
+ export async function pinProblem(io, project, resolveCli = hyperframesCliPath) {
29
+ const pin = readPins(await io.host.readRootPackageJson()).hyperframes;
30
+ let cli;
31
+ try {
32
+ cli = resolveCli();
33
+ }
34
+ catch {
35
+ const message = pin
36
+ ? `cannot resolve hyperframes@${pin} from the installed packages; fix: reinstall @anvpx/motion (it ships hyperframes@${pin} as a runtime dependency)`
37
+ : "cannot resolve hyperframes from the installed packages; fix: reinstall @anvpx/motion (it ships hyperframes as a runtime dependency)";
38
+ return { message, code: EXIT.MISSING_TOOL };
39
+ }
40
+ const result = await io.run(process.execPath, [cli, "--version"], { cwd: project });
41
+ const installed = result.code === 0 ? parseVersion(result.stdout) : undefined;
42
+ if (!installed) {
43
+ const message = pin
44
+ ? `cannot run hyperframes --version (node ${cli}) in ${project}; fix: reinstall @anvpx/motion so hyperframes@${pin} is installed alongside it`
45
+ : `cannot run hyperframes --version (node ${cli}) in ${project}; fix: reinstall @anvpx/motion so its hyperframes dependency is installed alongside it`;
46
+ return { message, code: EXIT.MISSING_TOOL };
47
+ }
48
+ if (pin === undefined)
49
+ return undefined;
50
+ const found = formatVersion(installed);
51
+ if (found === pin)
52
+ return undefined;
53
+ const message = `hyperframes ${found} installed but ${pin} pinned; fix: install hyperframes@${pin} alongside @anvpx/motion (or update motionKit.pins.hyperframes)`;
54
+ return { message, code: EXIT.ERROR };
55
+ }
56
+ const tail = (result) => `${result.stdout}\n${result.stderr}`
57
+ .split(/\r?\n/)
58
+ .filter((line) => line.trim().length > 0)
59
+ .slice(-TAIL_LINES)
60
+ .join("\n");
61
+ /**
62
+ * Checks the hyperframes pin, runs `steps` in the project directory, and always writes
63
+ * `render-log.txt` with every command's output. Resolves to the exit code: OK, ERROR on a failed step, MISSING_TOOL when a program cannot run.
64
+ */
65
+ export async function runInProject(io, project, steps) {
66
+ const problem = await pinProblem(io, project);
67
+ if (problem) {
68
+ io.stderr(`mk: ${problem.message}\n`);
69
+ return problem.code;
70
+ }
71
+ const log = [];
72
+ const outcome = {};
73
+ const cli = hyperframesCliPath();
74
+ const probe = async (cmd, args, options) => {
75
+ if (outcome.failure) {
76
+ return { code: EXIT.ERROR, stdout: "", stderr: "skipped: an earlier step failed" };
77
+ }
78
+ const command = [cmd, ...args].join(" ");
79
+ const result = await io.run(cmd, args, {
80
+ cwd: project,
81
+ timeoutMs: STEP_TIMEOUT_MS,
82
+ ...options,
83
+ });
84
+ log.push(`$ ${command}\n${result.stdout}${result.stderr}\n[exit ${result.code}]\n\n`);
85
+ if (result.code !== 0)
86
+ outcome.failure = { command, result };
87
+ return result;
88
+ };
89
+ const exec = async (cmd, args, options) => (await probe(cmd, args, options)).code === 0;
90
+ const logPath = join(project, LOG_FILE);
91
+ try {
92
+ await steps({
93
+ hf: (args, options) => exec(process.execPath, [cli, ...args], options),
94
+ tool: exec,
95
+ probe,
96
+ });
97
+ }
98
+ finally {
99
+ await io.host.writeText(logPath, log.join(""));
100
+ }
101
+ if (!outcome.failure)
102
+ return EXIT.OK;
103
+ const { command, result } = outcome.failure;
104
+ io.stderr(`mk: \`${command}\` failed (exit ${result.code}); last ${TAIL_LINES} lines:\n${tail(result)}\nlog: ${logPath}\n`);
105
+ return result.code === EXIT.MISSING_TOOL ? EXIT.MISSING_TOOL : EXIT.ERROR;
106
+ }
@@ -0,0 +1,59 @@
1
+ export interface CommandResult {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ export interface RunOptions {
7
+ cwd?: string;
8
+ /** Merged over the parent environment. */
9
+ env?: Readonly<Record<string, string>>;
10
+ /** Kill the program after this long; defaults to a short probe timeout. */
11
+ timeoutMs?: number;
12
+ }
13
+ /** Runs an external program. Never throws: a missing program yields code 127. */
14
+ export type Runner = (cmd: string, args: readonly string[], options?: RunOptions) => Promise<CommandResult>;
15
+ export interface Host {
16
+ platform: NodeJS.Platform;
17
+ /** Without leading "v", e.g. "24.12.0". */
18
+ nodeVersion: string;
19
+ homeDir: string;
20
+ /** Process environment; absent in old fakes means empty. Prefer host.env over process.env in checks. */
21
+ env?: Readonly<Record<string, string | undefined>>;
22
+ /** Entry names of a directory; rejects when it does not exist. */
23
+ listDir(path: string): Promise<string[]>;
24
+ /** Creates a directory recursively. Optional so minimal test hosts need not implement it. */
25
+ mkdir?(path: string): Promise<void>;
26
+ /** Parsed repo-root package.json, or undefined when absent/unreadable. */
27
+ readRootPackageJson(): Promise<unknown>;
28
+ /** UTF-8 file contents; rejects when unreadable. */
29
+ readText(path: string): Promise<string>;
30
+ /** Writes UTF-8 text, creating parent directories. */
31
+ writeText(path: string, text: string): Promise<void>;
32
+ /** Replaces a file atomically (temp file in the same directory, then rename): readers never see a partial write. */
33
+ writeTextAtomic(path: string, text: string): Promise<void>;
34
+ /** True when a file or directory exists at the path. */
35
+ exists(path: string): Promise<boolean>;
36
+ /** Hex sha256 of a file's bytes, or undefined when it does not exist. */
37
+ sha256File(path: string): Promise<string | undefined>;
38
+ /** Copies a file byte for byte, creating parent directories. */
39
+ copyFile(from: string, to: string): Promise<void>;
40
+ /**
41
+ * Moves `from` over `to`, replacing it in one step (same volume), creating the
42
+ * parent directory. Readers never see a half-written `to`.
43
+ */
44
+ renameFile(from: string, to: string): Promise<void>;
45
+ /** Deletes a file; a missing file is not an error. */
46
+ remove(path: string): Promise<void>;
47
+ }
48
+ export interface Io {
49
+ stdout(text: string): void;
50
+ stderr(text: string): void;
51
+ run: Runner;
52
+ host: Host;
53
+ }
54
+ export interface Command {
55
+ name: string;
56
+ summary: string;
57
+ /** Resolves to the process exit code. */
58
+ run(args: readonly string[], io: Io): Promise<number>;
59
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import type { VerifyReport } from "@anvpx/motion-build";
2
+ import type { PlatformTemplate } from "@anvpx/motion-schema";
3
+ import type { Io } from "../types.ts";
4
+ /** Accepted drift for integrated loudness vs a verified platform target. */
5
+ export declare const LOUDNESS_TOLERANCE_LU = 1;
6
+ /** Accepted excess for true peak above a verified platform ceiling. */
7
+ export declare const TRUE_PEAK_TOLERANCE_DB = 0.5;
8
+ /** Fixed measurement filter; never user-supplied (no gain, ducking, or arbitrary strings). */
9
+ export declare const LOUDNORM_FILTER = "loudnorm=print_format=json";
10
+ /** Fixed ffmpeg argv template; `IN` is replaced with the file under test. */
11
+ export declare const LOUDNORM_ARGS: readonly string[];
12
+ export interface LoudnessMeasure {
13
+ readonly integratedLufs: number;
14
+ readonly truePeakDb: number;
15
+ }
16
+ export type LoudnessUnavailableKind = "missing-tool" | "missing-filter" | "no-audio-stream" | "parse-failed" | "non-finite" | "unreadable";
17
+ export interface LoudnessAvailability {
18
+ readonly kind: LoudnessUnavailableKind;
19
+ readonly reason?: string;
20
+ }
21
+ export type LoudnessOutcome = {
22
+ ok: true;
23
+ measure: LoudnessMeasure;
24
+ } | {
25
+ ok: false;
26
+ kind: LoudnessUnavailableKind;
27
+ reason?: string;
28
+ };
29
+ /**
30
+ * Parses `loudnorm=print_format=json` output. Returns undefined when the JSON
31
+ * is missing, lacks fields, or is non-finite (silence reports -inf).
32
+ */
33
+ export declare function parseLoudnormJson(text: string): LoudnessMeasure | undefined;
34
+ /** Builds the fixed ffmpeg argv for one file; the filter string never varies. */
35
+ export declare const loudnormArgsFor: (file: string) => string[];
36
+ /**
37
+ * Measures integrated loudness + true peak with installed FFmpeg loudnorm.
38
+ * Measurement only: single pass, no gain or normalization applied.
39
+ */
40
+ export declare function measureAudioLoudness(io: Io, file: string): Promise<LoudnessOutcome>;
41
+ /**
42
+ * Judges a measurement only against an explicit verified platform policy.
43
+ * Unverified or absent policy, and any measurement unavailability, is UNKNOWN
44
+ * (exit 2); only a verified-policy breach is FAIL (exit 5).
45
+ */
46
+ export declare function assessAudioLoudness(measure: LoudnessMeasure | undefined, platform: PlatformTemplate | undefined, availability?: LoudnessAvailability): VerifyReport;
@@ -0,0 +1,186 @@
1
+ import { EXIT } from "../exit-codes.js";
2
+ /** Accepted drift for integrated loudness vs a verified platform target. */
3
+ export const LOUDNESS_TOLERANCE_LU = 1;
4
+ /** Accepted excess for true peak above a verified platform ceiling. */
5
+ export const TRUE_PEAK_TOLERANCE_DB = 0.5;
6
+ /** Fixed measurement filter; never user-supplied (no gain, ducking, or arbitrary strings). */
7
+ export const LOUDNORM_FILTER = "loudnorm=print_format=json";
8
+ /** Fixed ffmpeg argv template; `IN` is replaced with the file under test. */
9
+ export const LOUDNORM_ARGS = [
10
+ "-v",
11
+ "info",
12
+ "-i",
13
+ "IN",
14
+ "-filter:a",
15
+ LOUDNORM_FILTER,
16
+ "-f",
17
+ "null",
18
+ "-",
19
+ ];
20
+ const numberOrUndefined = (value) => {
21
+ if (typeof value === "number")
22
+ return value;
23
+ if (typeof value === "string" && value.trim().length > 0)
24
+ return Number(value);
25
+ return undefined;
26
+ };
27
+ /** Extracts the JSON object ffmpeg prints (usually on stderr) from surrounding noise. */
28
+ const jsonObjectOf = (text) => {
29
+ const start = text.indexOf("{");
30
+ const end = text.lastIndexOf("}");
31
+ if (start === -1 || end <= start)
32
+ return undefined;
33
+ try {
34
+ return JSON.parse(text.slice(start, end + 1));
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ };
40
+ const rawOf = (doc) => ({
41
+ i: numberOrUndefined(doc.measured_I ?? doc.input_i ?? doc.measured_i ?? doc.input_I ?? doc.integrated),
42
+ tp: numberOrUndefined(doc.measured_TP ?? doc.input_tp ?? doc.measured_tp ?? doc.input_TP ?? doc.true_peak),
43
+ });
44
+ /**
45
+ * Parses `loudnorm=print_format=json` output. Returns undefined when the JSON
46
+ * is missing, lacks fields, or is non-finite (silence reports -inf).
47
+ */
48
+ export function parseLoudnormJson(text) {
49
+ const doc = jsonObjectOf(text);
50
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc))
51
+ return undefined;
52
+ const { i, tp } = rawOf(doc);
53
+ if (i === undefined || tp === undefined)
54
+ return undefined;
55
+ if (!Number.isFinite(i) || !Number.isFinite(tp))
56
+ return undefined;
57
+ return { integratedLufs: i, truePeakDb: tp };
58
+ }
59
+ /** Builds the fixed ffmpeg argv for one file; the filter string never varies. */
60
+ export const loudnormArgsFor = (file) => LOUDNORM_ARGS.map((arg) => (arg === "IN" ? file : arg));
61
+ const shortReason = (stderr) => (stderr.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "no details")
62
+ .trim()
63
+ .split(/\s+/)
64
+ .slice(-12)
65
+ .join(" ");
66
+ /**
67
+ * Measures integrated loudness + true peak with installed FFmpeg loudnorm.
68
+ * Measurement only: single pass, no gain or normalization applied.
69
+ */
70
+ export async function measureAudioLoudness(io, file) {
71
+ const result = await io.run("ffmpeg", loudnormArgsFor(file));
72
+ if (result.code === EXIT.MISSING_TOOL)
73
+ return { ok: false, kind: "missing-tool" };
74
+ if (result.code !== 0) {
75
+ const stderr = result.stderr ?? "";
76
+ if (/no such filter|unknown filter|not recognized/i.test(stderr)) {
77
+ return { ok: false, kind: "missing-filter", reason: shortReason(stderr) };
78
+ }
79
+ if (/does not contain any stream|matches no streams|no audio|no stream/i.test(stderr)) {
80
+ return { ok: false, kind: "no-audio-stream", reason: shortReason(stderr) };
81
+ }
82
+ return { ok: false, kind: "unreadable", reason: shortReason(stderr) };
83
+ }
84
+ const combined = `${result.stderr ?? ""}\n${result.stdout ?? ""}`;
85
+ const parsed = parseLoudnormJson(combined);
86
+ if (parsed)
87
+ return { ok: true, measure: parsed };
88
+ const doc = jsonObjectOf(combined);
89
+ if (typeof doc === "object" && doc !== null && !Array.isArray(doc)) {
90
+ const { i, tp } = rawOf(doc);
91
+ const hasBoth = i !== undefined && tp !== undefined;
92
+ const nonFinite = hasBoth &&
93
+ ((typeof i === "number" && !Number.isFinite(i)) ||
94
+ (typeof tp === "number" && !Number.isFinite(tp)));
95
+ if (nonFinite)
96
+ return { ok: false, kind: "non-finite" };
97
+ }
98
+ return { ok: false, kind: "parse-failed", reason: "ffmpeg loudnorm printed no parsable JSON" };
99
+ }
100
+ const finding = (code, level, message) => ({
101
+ code,
102
+ level,
103
+ message,
104
+ });
105
+ const round1 = (n) => (Math.round(n * 10) / 10).toFixed(1);
106
+ /**
107
+ * Judges a measurement only against an explicit verified platform policy.
108
+ * Unverified or absent policy, and any measurement unavailability, is UNKNOWN
109
+ * (exit 2); only a verified-policy breach is FAIL (exit 5).
110
+ */
111
+ export function assessAudioLoudness(measure, platform, availability) {
112
+ if (availability) {
113
+ if (availability.kind === "no-audio-stream") {
114
+ return {
115
+ status: "UNKNOWN",
116
+ findings: [
117
+ finding("audio-loudness-no-stream", "warn", "No audio stream to measure loudness from."),
118
+ ],
119
+ };
120
+ }
121
+ if (availability.kind === "missing-tool" || availability.kind === "missing-filter") {
122
+ return {
123
+ status: "UNKNOWN",
124
+ findings: [
125
+ finding("audio-loudness-unavailable", "warn", "Audio loudness could not be measured; ffmpeg loudnorm unavailable."),
126
+ ],
127
+ };
128
+ }
129
+ return {
130
+ status: "UNKNOWN",
131
+ findings: [
132
+ finding("audio-loudness-unmeasurable", "warn", "Audio loudness could not be measured; loudnorm output unparsable."),
133
+ ],
134
+ };
135
+ }
136
+ if (platform === undefined) {
137
+ return {
138
+ status: "UNKNOWN",
139
+ findings: [
140
+ finding("audio-loudness-no-policy", "info", "No platform audio policy supplied; loudness not judged."),
141
+ ],
142
+ };
143
+ }
144
+ if (!platform.verified) {
145
+ return {
146
+ status: "UNKNOWN",
147
+ findings: [
148
+ finding("audio-loudness-unverified", "info", `Template ${platform.id} has verified: false; loudness not judged.`),
149
+ ],
150
+ };
151
+ }
152
+ if (measure === undefined ||
153
+ !Number.isFinite(measure.integratedLufs) ||
154
+ !Number.isFinite(measure.truePeakDb)) {
155
+ return {
156
+ status: "UNKNOWN",
157
+ findings: [
158
+ finding("audio-loudness-unmeasurable", "warn", "Audio loudness could not be measured; loudnorm output unparsable."),
159
+ ],
160
+ };
161
+ }
162
+ const targetI = platform.audio.lufsTarget;
163
+ const targetTp = platform.audio.truePeakDb;
164
+ if (Math.abs(measure.integratedLufs - targetI) > LOUDNESS_TOLERANCE_LU) {
165
+ return {
166
+ status: "FAIL",
167
+ findings: [
168
+ finding("audio-loudness-mismatch", "fatal", `Integrated ${round1(measure.integratedLufs)} LUFS differs from ${platform.id} target ${round1(targetI)} LUFS by more than ${round1(LOUDNESS_TOLERANCE_LU)} LU.`),
169
+ ],
170
+ };
171
+ }
172
+ if (measure.truePeakDb - targetTp > TRUE_PEAK_TOLERANCE_DB) {
173
+ return {
174
+ status: "FAIL",
175
+ findings: [
176
+ finding("audio-true-peak-mismatch", "fatal", `True peak ${round1(measure.truePeakDb)} dBTP exceeds ${platform.id} ceiling ${round1(targetTp)} dBTP.`),
177
+ ],
178
+ };
179
+ }
180
+ return {
181
+ status: "PASS",
182
+ findings: [
183
+ finding("audio-loudness-ok", "info", `Integrated ${round1(measure.integratedLufs)} LUFS, true peak ${round1(measure.truePeakDb)} dBTP within ${platform.id} policy.`),
184
+ ],
185
+ };
186
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const verifyCommand: Command;
@@ -0,0 +1,161 @@
1
+ import { parseFlags, requirePositional, textFlag, usageError } from "../args.js";
2
+ import { EXIT } from "../exit-codes.js";
3
+ import { checkStoryboard } from "../storyboard/check.js";
4
+ import { readJsonFile } from "../storyboard/load.js";
5
+ import { formatFindings } from "../storyboard/report.js";
6
+ import { measureAudioLoudness } from "./audio-loudness.js";
7
+ import { draftExpectation, finalExpectation } from "./expectation.js";
8
+ import { assessMediaEvents, measureMediaEvents } from "./media-events.js";
9
+ import { loadPlatform, platformAudioReport } from "./platform.js";
10
+ import { exitCodeForStatus, formatReport, formatReportJson, mergeReports } from "./report.js";
11
+ import { verifyFile } from "./run.js";
12
+ const USAGE = `mk verify <file.mp4> [--storyboard <sb.json>] [--platform <id>] [--draft] [--determinism <second.mp4>] [--events] [--loudness] [--json]
13
+
14
+ Re-probe a rendered file with ffprobe and report findings; QC measures, it never decides for you.
15
+ --storyboard <f> expect its size, fps and total duration; frames = duration x fps (+-1)
16
+ --platform <id> also check aspect, fps and duration cap against a platform template
17
+ --draft with --storyboard: expect a 960 px wide 15 fps draft
18
+ --determinism <f> compare a second render byte-for-byte; a mismatch is a QA FAIL
19
+ (same machine/GPU/driver only; deterministic scenes only: seeded grain, no rAF)
20
+ --events also detect frozen and black spans (one extra ffmpeg decode; never fatal)
21
+ --loudness also measure integrated LUFS + true peak against a verified platform policy
22
+ --json one compact JSON line {status, file, findings}
23
+ Exit 0 PASS or WARN; 5 FAIL; 2 UNKNOWN (could not decide); 2 bad arguments; 127 ffprobe missing.
24
+ `;
25
+ const FLAGS = {
26
+ storyboard: { type: "string" },
27
+ platform: { type: "string" },
28
+ draft: { type: "boolean" },
29
+ determinism: { type: "string" },
30
+ events: { type: "boolean" },
31
+ loudness: { type: "boolean" },
32
+ json: { type: "boolean" },
33
+ };
34
+ const DRAFT_NEEDS_STORYBOARD = "--draft needs --storyboard";
35
+ async function expectationFromStoryboard(path, draft, io) {
36
+ const loaded = await readJsonFile(path, io.host);
37
+ if (!loaded.ok) {
38
+ io.stderr(`mk verify: ${loaded.message}\n`);
39
+ return { kind: "exit", code: EXIT.USAGE };
40
+ }
41
+ const report = checkStoryboard(loaded.value);
42
+ if (report.errors.length > 0 || !report.meta || report.total === undefined) {
43
+ io.stderr(`mk verify: storyboard is invalid; run \`mk validate\`.\n${formatFindings(report.errors.slice(0, 3))}`);
44
+ return { kind: "exit", code: EXIT.USAGE };
45
+ }
46
+ return draft
47
+ ? draftExpectation(report.meta, report.total)
48
+ : finalExpectation(report.meta, report.total, report.meta.fps);
49
+ }
50
+ async function platformFrom(id, io) {
51
+ if (id === undefined)
52
+ return undefined;
53
+ const loaded = await loadPlatform(io.host, id);
54
+ if (loaded.ok)
55
+ return loaded.template;
56
+ io.stderr(`mk verify: ${loaded.message}\n`);
57
+ return { kind: "exit", code: EXIT.USAGE };
58
+ }
59
+ const isExit = (value) => typeof value === "object" && value !== null && value.kind === "exit";
60
+ /** The `--determinism` second render, or the usage failure to exit with. */
61
+ async function determinismFrom(second, file, io) {
62
+ if (second === undefined)
63
+ return undefined;
64
+ if (!(await io.host.exists(second))) {
65
+ io.stderr(`mk verify: cannot read ${second}; check the path.\n`);
66
+ return { kind: "exit", code: EXIT.USAGE };
67
+ }
68
+ return { first: file, second };
69
+ }
70
+ /** File reads and check setup, or the usage failure to exit with. */
71
+ async function resolveChecks(io, file, inputs) {
72
+ if (!(await io.host.exists(file))) {
73
+ io.stderr(`mk verify: cannot read ${file}; check the path.\n`);
74
+ return { kind: "exit", code: EXIT.USAGE };
75
+ }
76
+ const { storyboard, draft, platform, second } = inputs;
77
+ const expectation = storyboard === undefined ? undefined : await expectationFromStoryboard(storyboard, draft, io);
78
+ if (isExit(expectation))
79
+ return expectation;
80
+ const template = await platformFrom(platform, io);
81
+ if (isExit(template))
82
+ return template;
83
+ const determinism = await determinismFrom(second, file, io);
84
+ if (isExit(determinism))
85
+ return determinism;
86
+ return {
87
+ ...(expectation ? { expect: expectation } : {}),
88
+ ...(template ? { platform: template } : {}),
89
+ ...(determinism ? { determinism } : {}),
90
+ };
91
+ }
92
+ /** Turns arguments into what to check, or the usage failure to exit with. */
93
+ async function resolveTarget(args, io) {
94
+ const parsed = parseFlags("verify", USAGE, args, FLAGS, io);
95
+ if (parsed.kind === "exit")
96
+ return parsed;
97
+ const file = requirePositional("verify", USAGE, parsed.positionals, "<file.mp4>", io);
98
+ if (typeof file !== "string")
99
+ return file;
100
+ const storyboard = textFlag(parsed.flags, "storyboard");
101
+ const draft = parsed.flags.draft === true;
102
+ if (draft && storyboard === undefined) {
103
+ return usageError("verify", USAGE, DRAFT_NEEDS_STORYBOARD, io);
104
+ }
105
+ const checks = await resolveChecks(io, file, {
106
+ storyboard,
107
+ draft,
108
+ platform: textFlag(parsed.flags, "platform"),
109
+ second: textFlag(parsed.flags, "determinism"),
110
+ });
111
+ if (isExit(checks))
112
+ return checks;
113
+ return {
114
+ file,
115
+ json: parsed.flags.json === true,
116
+ events: parsed.flags.events === true,
117
+ loudness: parsed.flags.loudness === true,
118
+ ...checks,
119
+ };
120
+ }
121
+ /** Judges one measurement only against the loaded verified platform policy. */
122
+ function loudnessReport(outcome, platform) {
123
+ return outcome.ok
124
+ ? platformAudioReport(outcome.measure, platform)
125
+ : platformAudioReport(undefined, platform, { kind: outcome.kind, reason: outcome.reason });
126
+ }
127
+ /** Layers the opt-in probes on the base report; the merged status is the exit authority. */
128
+ async function withProbes(io, target, base, durationSec, probes) {
129
+ let report = base;
130
+ if (probes.events) {
131
+ const outcome = await measureMediaEvents(io, target.file, durationSec);
132
+ report = mergeReports(report, assessMediaEvents(outcome, durationSec));
133
+ }
134
+ if (probes.loudness) {
135
+ const outcome = await measureAudioLoudness(io, target.file);
136
+ report = mergeReports(report, loudnessReport(outcome, target.platform));
137
+ }
138
+ return report;
139
+ }
140
+ export const verifyCommand = {
141
+ name: "verify",
142
+ summary: "re-probe a rendered mp4: streams, size, fps, duration, frame count, platform, determinism",
143
+ async run(args, io) {
144
+ const target = await resolveTarget(args, io);
145
+ if (isExit(target))
146
+ return target.code;
147
+ const { file, json, events, loudness, ...rest } = target;
148
+ const run = await verifyFile(io, { file, ...rest });
149
+ if (!run.ok) {
150
+ io.stderr(`${run.message}\n`);
151
+ return run.code;
152
+ }
153
+ const report = await withProbes(io, { file, ...rest }, run.report, run.durationSec, {
154
+ events,
155
+ loudness,
156
+ });
157
+ const print = json ? formatReportJson : formatReport;
158
+ io.stdout(print(file, report));
159
+ return exitCodeForStatus(report.status);
160
+ },
161
+ };
@@ -0,0 +1,30 @@
1
+ import type { VerifyFinding, VerifyReport } from "@anvpx/motion-build";
2
+ import type { Host } from "../types.ts";
3
+ /**
4
+ * Same-machine determinism bar from the scene3d spike: two renders reached
5
+ * SSIM All=1.0 and byte-identical md5. The gate passes on byte-identical
6
+ * sha256; anything else fails closed and names the SSIM bar for the
7
+ * frame-level re-check.
8
+ */
9
+ export declare const SSIM_GATE = 0.9999;
10
+ /**
11
+ * Scope of the claim: same machine, GPU and driver only. Cross-machine and
12
+ * cross-GPU output is NOT claimed (GPU float differs); deterministic scenes
13
+ * only — seeded grain, pure function of t, no rAF/Clock/random (scene3d and
14
+ * caption qualify).
15
+ */
16
+ export declare const DETERMINISM_NOTE: string;
17
+ export interface DeterminismPair {
18
+ first: string;
19
+ second: string;
20
+ }
21
+ export interface DeterminismHashes {
22
+ first: string | undefined;
23
+ second: string | undefined;
24
+ }
25
+ /** sha256 of both renders; a missing file hashes to undefined (fails closed downstream). */
26
+ export declare function hashPair(host: Host, pair: DeterminismPair): Promise<DeterminismHashes>;
27
+ /** Pure hash comparison: identical passes, anything else is a fatal finding. */
28
+ export declare function determinismFindings(first: string | undefined, second: string | undefined): VerifyFinding[];
29
+ /** Merges gate findings into a verify report; any fatal escalates the status to FAIL. */
30
+ export declare function withDeterminism(report: VerifyReport, findings: readonly VerifyFinding[]): VerifyReport;
@@ -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;