@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.
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/analyze/command.d.ts +2 -0
- package/dist/analyze/command.js +190 -0
- package/dist/approval/approval.d.ts +32 -0
- package/dist/approval/approval.js +141 -0
- package/dist/approval/command.d.ts +2 -0
- package/dist/approval/command.js +255 -0
- package/dist/approval/gate.d.ts +7 -0
- package/dist/approval/gate.js +23 -0
- package/dist/approval/hash.d.ts +71 -0
- package/dist/approval/hash.js +451 -0
- package/dist/args.d.ts +30 -0
- package/dist/args.js +53 -0
- package/dist/assets/command.d.ts +2 -0
- package/dist/assets/command.js +165 -0
- package/dist/captions/command.d.ts +2 -0
- package/dist/captions/command.js +341 -0
- package/dist/captions/cues.d.ts +10 -0
- package/dist/captions/cues.js +61 -0
- package/dist/catalog/command.d.ts +2 -0
- package/dist/catalog/command.js +60 -0
- package/dist/catalog/format.d.ts +13 -0
- package/dist/catalog/format.js +54 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +35 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +59 -0
- package/dist/compare/command.d.ts +4 -0
- package/dist/compare/command.js +144 -0
- package/dist/contact/argv.d.ts +24 -0
- package/dist/contact/argv.js +42 -0
- package/dist/contact/command.d.ts +2 -0
- package/dist/contact/command.js +79 -0
- package/dist/doctor/checks.d.ts +25 -0
- package/dist/doctor/checks.js +571 -0
- package/dist/doctor/command.d.ts +4 -0
- package/dist/doctor/command.js +88 -0
- package/dist/doctor/format.d.ts +7 -0
- package/dist/doctor/format.js +41 -0
- package/dist/doctor/heal.d.ts +14 -0
- package/dist/doctor/heal.js +71 -0
- package/dist/doctor/pins.d.ts +8 -0
- package/dist/doctor/pins.js +28 -0
- package/dist/doctor/skills.d.ts +15 -0
- package/dist/doctor/skills.js +58 -0
- package/dist/doctor/types.d.ts +36 -0
- package/dist/doctor/types.js +1 -0
- package/dist/doctor/vendored.d.ts +15 -0
- package/dist/doctor/vendored.js +90 -0
- package/dist/doctor/version.d.ts +8 -0
- package/dist/doctor/version.js +11 -0
- package/dist/exit-codes.d.ts +16 -0
- package/dist/exit-codes.js +15 -0
- package/dist/fit/command.d.ts +5 -0
- package/dist/fit/command.js +154 -0
- package/dist/fit/solve.d.ts +21 -0
- package/dist/fit/solve.js +80 -0
- package/dist/fit/target.d.ts +18 -0
- package/dist/fit/target.js +47 -0
- package/dist/generate3d/command.d.ts +3 -0
- package/dist/generate3d/command.js +38 -0
- package/dist/generate3d/run.d.ts +39 -0
- package/dist/generate3d/run.js +77 -0
- package/dist/icon/command.d.ts +2 -0
- package/dist/icon/command.js +113 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/install-mk.d.ts +31 -0
- package/dist/install-mk.js +168 -0
- package/dist/integration/preview.integration.d.ts +1 -0
- package/dist/integration/preview.integration.js +53 -0
- package/dist/integration/smoke.integration.d.ts +1 -0
- package/dist/integration/smoke.integration.js +91 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +4 -0
- package/dist/node-io.d.ts +14 -0
- package/dist/node-io.js +152 -0
- package/dist/optimize/command.d.ts +12 -0
- package/dist/optimize/command.js +110 -0
- package/dist/packs/command.d.ts +2 -0
- package/dist/packs/command.js +109 -0
- package/dist/patch/apply.d.ts +36 -0
- package/dist/patch/apply.js +186 -0
- package/dist/patch/command.d.ts +2 -0
- package/dist/patch/command.js +92 -0
- package/dist/plugin/install.d.ts +40 -0
- package/dist/plugin/install.js +170 -0
- package/dist/preview/command.d.ts +10 -0
- package/dist/preview/command.js +758 -0
- package/dist/preview/manifest.d.ts +94 -0
- package/dist/preview/manifest.js +19 -0
- package/dist/preview/pack.d.ts +24 -0
- package/dist/preview/pack.js +53 -0
- package/dist/preview/plan.d.ts +58 -0
- package/dist/preview/plan.js +160 -0
- package/dist/preview/provenance.d.ts +23 -0
- package/dist/preview/provenance.js +38 -0
- package/dist/publish/command.d.ts +2 -0
- package/dist/publish/command.js +70 -0
- package/dist/publish/player.d.ts +25 -0
- package/dist/publish/player.js +108 -0
- package/dist/publish/static.d.ts +16 -0
- package/dist/publish/static.js +78 -0
- package/dist/qc.d.ts +2 -0
- package/dist/qc.js +192 -0
- package/dist/scene/author.d.ts +16 -0
- package/dist/scene/author.js +260 -0
- package/dist/scene/command.d.ts +2 -0
- package/dist/scene/command.js +110 -0
- package/dist/scene/ops.d.ts +23 -0
- package/dist/scene/ops.js +151 -0
- package/dist/state/command.d.ts +2 -0
- package/dist/state/command.js +128 -0
- package/dist/state/evidence.d.ts +71 -0
- package/dist/state/evidence.js +243 -0
- package/dist/storyboard/check.d.ts +21 -0
- package/dist/storyboard/check.js +39 -0
- package/dist/storyboard/containment.d.ts +24 -0
- package/dist/storyboard/containment.js +43 -0
- package/dist/storyboard/custom.d.ts +15 -0
- package/dist/storyboard/custom.js +133 -0
- package/dist/storyboard/edit.d.ts +19 -0
- package/dist/storyboard/edit.js +39 -0
- package/dist/storyboard/load.d.ts +11 -0
- package/dist/storyboard/load.js +52 -0
- package/dist/storyboard/report.d.ts +5 -0
- package/dist/storyboard/report.js +8 -0
- package/dist/template/coerce.d.ts +6 -0
- package/dist/template/coerce.js +44 -0
- package/dist/template/command.d.ts +5 -0
- package/dist/template/command.js +163 -0
- package/dist/template/env.d.ts +34 -0
- package/dist/template/env.js +38 -0
- package/dist/template/fork.d.ts +8 -0
- package/dist/template/fork.js +156 -0
- package/dist/template/format.d.ts +21 -0
- package/dist/template/format.js +161 -0
- package/dist/template/list.d.ts +15 -0
- package/dist/template/list.js +116 -0
- package/dist/template/new.d.ts +6 -0
- package/dist/template/new.js +302 -0
- package/dist/testing/env.d.ts +8 -0
- package/dist/testing/env.js +19 -0
- package/dist/testing/fakes.d.ts +33 -0
- package/dist/testing/fakes.js +95 -0
- package/dist/testing/fixtures.d.ts +8 -0
- package/dist/testing/fixtures.js +6 -0
- package/dist/testing/probe.d.ts +15 -0
- package/dist/testing/probe.js +20 -0
- package/dist/testing/project.d.ts +22 -0
- package/dist/testing/project.js +26 -0
- package/dist/tooling/session.d.ts +40 -0
- package/dist/tooling/session.js +106 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/verify/audio-loudness.d.ts +46 -0
- package/dist/verify/audio-loudness.js +186 -0
- package/dist/verify/command.d.ts +2 -0
- package/dist/verify/command.js +161 -0
- package/dist/verify/determinism.d.ts +30 -0
- package/dist/verify/determinism.js +59 -0
- package/dist/verify/expectation.d.ts +15 -0
- package/dist/verify/expectation.js +19 -0
- package/dist/verify/media-events.d.ts +61 -0
- package/dist/verify/media-events.js +174 -0
- package/dist/verify/platform.d.ts +33 -0
- package/dist/verify/platform.js +59 -0
- package/dist/verify/probe.d.ts +14 -0
- package/dist/verify/probe.js +23 -0
- package/dist/verify/report.d.ts +15 -0
- package/dist/verify/report.js +45 -0
- package/dist/verify/run.d.ts +27 -0
- package/dist/verify/run.js +53 -0
- package/dist/voice/command.d.ts +3 -0
- package/dist/voice/command.js +49 -0
- package/dist/voice/manifest.d.ts +33 -0
- package/dist/voice/manifest.js +69 -0
- package/dist/voice/run.d.ts +21 -0
- package/dist/voice/run.js +181 -0
- package/dist/workflow/build.d.ts +2 -0
- package/dist/workflow/build.js +25 -0
- package/dist/workflow/draft.d.ts +9 -0
- package/dist/workflow/draft.js +201 -0
- package/dist/workflow/finish.d.ts +23 -0
- package/dist/workflow/finish.js +28 -0
- package/dist/workflow/project.d.ts +73 -0
- package/dist/workflow/project.js +198 -0
- package/dist/workflow/provenance.d.ts +18 -0
- package/dist/workflow/provenance.js +36 -0
- package/dist/workflow/render.d.ts +31 -0
- package/dist/workflow/render.js +705 -0
- package/dist/workflow/still.d.ts +5 -0
- package/dist/workflow/still.js +62 -0
- package/dist/workflow/validate.d.ts +2 -0
- package/dist/workflow/validate.js +49 -0
- package/package.json +38 -3
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { onTestFinished } from "vitest";
|
|
5
|
+
import { hyperframesCliPath } from "../tooling/session.js";
|
|
6
|
+
import { demoText } from "./fixtures.js";
|
|
7
|
+
export const PIN = "0.8.212";
|
|
8
|
+
export const PINNED_PACKAGE = { motionKit: { pins: { hyperframes: PIN } } };
|
|
9
|
+
/** Fake-runner key for `node <resolved hyperframes CLI> <args>` (mirrors runInProject). */
|
|
10
|
+
export const hfKey = (...args) => [process.execPath, hyperframesCliPath(), ...args].join(" ");
|
|
11
|
+
/** Fake-runner key for the hyperframes pin check. */
|
|
12
|
+
export const VERSION_KEY = hfKey("--version");
|
|
13
|
+
/** A temp directory holding the demo storyboard; removed when the test finishes. */
|
|
14
|
+
export function tempProject(storyboardText = demoText()) {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), "mk-cli-"));
|
|
16
|
+
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
|
17
|
+
const storyboard = join(dir, "sb.json");
|
|
18
|
+
writeFileSync(storyboard, storyboardText);
|
|
19
|
+
return { dir, storyboard, out: join(dir, "out", "project") };
|
|
20
|
+
}
|
|
21
|
+
/** Creates empty files, as a stand-in for what an external tool would produce. */
|
|
22
|
+
export function touch(dir, ...names) {
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
for (const name of names)
|
|
25
|
+
writeFileSync(join(dir, name), "x");
|
|
26
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CommandResult, Io } from "../types.ts";
|
|
2
|
+
export declare const LOG_FILE = "render-log.txt";
|
|
3
|
+
/**
|
|
4
|
+
* Absolute path of the hyperframes CLI entry point, resolved from the packages
|
|
5
|
+
* installed alongside this one (hyperframes is a runtime dependency of
|
|
6
|
+
* `@anvpx/motion`). Throws when the install cannot provide it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hyperframesCliPath(): string;
|
|
9
|
+
/** Absolute path of the installed hyperframes manifest (doctor reports its version). */
|
|
10
|
+
export declare function hyperframesPackagePath(): string;
|
|
11
|
+
export interface StepOptions {
|
|
12
|
+
env?: Readonly<Record<string, string>>;
|
|
13
|
+
}
|
|
14
|
+
export interface Session {
|
|
15
|
+
/** Runs the resolved hyperframes CLI with node; resolves false (and records the failure) on a non-zero exit. */
|
|
16
|
+
hf(args: readonly string[], options?: StepOptions): Promise<boolean>;
|
|
17
|
+
/** Runs another program (e.g. ffmpeg) in the project directory. */
|
|
18
|
+
tool(cmd: string, args: readonly string[], options?: StepOptions): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Runs another program and returns its captured output, so a caller can parse
|
|
21
|
+
* it (e.g. loudnorm measurement). A non-zero exit still fails the run.
|
|
22
|
+
*/
|
|
23
|
+
probe(cmd: string, args: readonly string[], options?: StepOptions): Promise<CommandResult>;
|
|
24
|
+
}
|
|
25
|
+
interface PinProblem {
|
|
26
|
+
message: string;
|
|
27
|
+
code: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Why hyperframes may not be used (missing install or mismatched pin), or undefined when
|
|
31
|
+
* it is runnable. A pin is optional: consumer projects get it from `@anvpx/motion`, so
|
|
32
|
+
* no pin plus a runnable installed CLI is acceptable.
|
|
33
|
+
*/
|
|
34
|
+
export declare function pinProblem(io: Io, project: string, resolveCli?: () => string): Promise<PinProblem | undefined>;
|
|
35
|
+
/**
|
|
36
|
+
* Checks the hyperframes pin, runs `steps` in the project directory, and always writes
|
|
37
|
+
* `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.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runInProject(io: Io, project: string, steps: (session: Session) => Promise<void>): Promise<number>;
|
|
40
|
+
export {};
|
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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,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;
|