@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,128 @@
1
+ import { resolve } from "node:path";
2
+ import { parseFlags, requireStoryboard, textFlag, usageError, } from "../args.js";
3
+ import { EXIT } from "../exit-codes.js";
4
+ import { deriveState, gatherEvidence } from "./evidence.js";
5
+ const USAGE = `mk state <storyboard.json> [--out <dir>] [--render <dir>] [--assets <dir>] [--ledger <assets.json>] [--audio <manifest>] [--music <bed.wav>] [--music-volume <n>] [--music-license <file>] [--json]
6
+
7
+ Report the highest project state the artifacts prove; read-only, never writes.
8
+ --out <dir> preview pack base (default <storyboard dir>/out)
9
+ --render <dir> render output dir (default <storyboard dir>/out/project); one manifest per dir
10
+ --assets <dir> the --assets used with \`mk preview\`/\`mk approve\`
11
+ --ledger <file> the --ledger used with \`mk preview\`/\`mk approve\`
12
+ --audio <manifest> the --audio used with \`mk preview\`/\`mk approve\`
13
+ --music <bed.wav> the --music used with \`mk preview\`/\`mk approve\`
14
+ --music-volume <n> the --music-volume used with \`mk preview\`/\`mk approve\`
15
+ --music-license <file> the --music-license used with \`mk preview\`/\`mk approve\`
16
+ --json one compact line {state, next, missing, problems, evidence}
17
+ Exit 0 consistent; 1 an artifact claims more than the evidence; 2 usage, invalid storyboard or unreadable inputs.
18
+ `;
19
+ const FLAGS = {
20
+ out: { type: "string" },
21
+ render: { type: "string" },
22
+ assets: { type: "string" },
23
+ ledger: { type: "string" },
24
+ audio: { type: "string" },
25
+ music: { type: "string" },
26
+ "music-volume": { type: "string" },
27
+ "music-license": { type: "string" },
28
+ json: { type: "boolean" },
29
+ };
30
+ const MAX_PROBLEM_LINES = 3;
31
+ /** The state word, the one missing artifact, then at most three problem lines. */
32
+ function formatText(report) {
33
+ const lines = [report.state];
34
+ if (report.next !== null)
35
+ lines.push(`missing: ${report.next}`);
36
+ for (const problem of report.problems.slice(0, MAX_PROBLEM_LINES)) {
37
+ lines.push(`problem: ${problem.code} ${problem.message}`);
38
+ }
39
+ return `${lines.join("\n")}\n`;
40
+ }
41
+ function isExit(value) {
42
+ return typeof value === "object" && value !== null && value.kind === "exit";
43
+ }
44
+ /** `--music-volume` as a number in (0, 1], or the usage exit. */
45
+ function musicVolumeOf(flags, io) {
46
+ const raw = textFlag(flags, "music-volume");
47
+ if (raw === undefined)
48
+ return {};
49
+ const volume = Number(raw);
50
+ if (!Number.isFinite(volume) || volume <= 0 || volume > 1) {
51
+ return usageError("state", USAGE, "--music-volume must be within (0, 1]", io);
52
+ }
53
+ return { volume };
54
+ }
55
+ /** `--music` needs `--audio`; its volume/license need `--music`. */
56
+ function musicDependency(flags, io) {
57
+ const music = textFlag(flags, "music");
58
+ if (music === undefined) {
59
+ return textFlag(flags, "music-volume") === undefined &&
60
+ textFlag(flags, "music-license") === undefined
61
+ ? undefined
62
+ : usageError("state", USAGE, "--music-volume/--music-license need --music", io);
63
+ }
64
+ return textFlag(flags, "audio") === undefined
65
+ ? usageError("state", USAGE, "--music needs --audio", io)
66
+ : undefined;
67
+ }
68
+ /** The same approval input scope `mk approve` used; without it vendored assets cannot resolve. */
69
+ function approvalOptions(flags, io) {
70
+ const volume = musicVolumeOf(flags, io);
71
+ if (isExit(volume))
72
+ return volume;
73
+ const dependency = musicDependency(flags, io);
74
+ if (dependency !== undefined)
75
+ return dependency;
76
+ const assets = textFlag(flags, "assets");
77
+ const ledger = textFlag(flags, "ledger");
78
+ const audio = textFlag(flags, "audio");
79
+ const music = textFlag(flags, "music");
80
+ const license = textFlag(flags, "music-license");
81
+ const options = {};
82
+ if (assets !== undefined)
83
+ options.assetsDir = resolve(assets);
84
+ if (ledger !== undefined)
85
+ options.ledgerPath = ledger;
86
+ if (audio !== undefined)
87
+ options.audioManifest = audio;
88
+ if (music !== undefined)
89
+ options.music = music;
90
+ if (volume.volume !== undefined)
91
+ options.musicVolume = volume.volume;
92
+ if (license !== undefined)
93
+ options.musicLicense = license;
94
+ return options;
95
+ }
96
+ /** Unreadable inputs are undecidable (exit 2); a render overclaim is a contradiction (exit 1). */
97
+ function exitFor(report) {
98
+ if (report.problems.some((problem) => problem.code === "inputs-unreadable"))
99
+ return EXIT.USAGE;
100
+ return report.problems.length > 0 ? EXIT.ERROR : EXIT.OK;
101
+ }
102
+ export const stateCommand = {
103
+ name: "state",
104
+ summary: "report the highest project state the artifacts prove; flags overclaims",
105
+ async run(args, io) {
106
+ const parsed = parseFlags("state", USAGE, args, FLAGS, io);
107
+ if (parsed.kind === "exit")
108
+ return parsed.code;
109
+ const storyboard = requireStoryboard("state", USAGE, parsed.positionals, io);
110
+ if (isExit(storyboard))
111
+ return storyboard.code;
112
+ const approval = approvalOptions(parsed.flags, io);
113
+ if (isExit(approval))
114
+ return approval.code;
115
+ const gathered = await gatherEvidence(io, storyboard, {
116
+ out: textFlag(parsed.flags, "out"),
117
+ render: textFlag(parsed.flags, "render"),
118
+ approval,
119
+ });
120
+ if (!gathered.ok) {
121
+ io.stderr(`mk state: ${gathered.message.trimEnd()}\n`);
122
+ return EXIT.USAGE;
123
+ }
124
+ const report = deriveState(gathered.facts);
125
+ io.stdout(parsed.flags.json === true ? `${JSON.stringify(report)}\n` : formatText(report));
126
+ return exitFor(report);
127
+ },
128
+ };
@@ -0,0 +1,71 @@
1
+ import { type ApprovalState } from "../approval/approval.ts";
2
+ import type { ApprovalBuildOptions } from "../approval/hash.ts";
3
+ import type { Io } from "../types.ts";
4
+ /** The highest state a project has current evidence for. */
5
+ export type ProjectState = "storyboard" | "previewed" | "approved" | "rendered" | "verified";
6
+ /** The next missing artifact, one word. */
7
+ export type NextState = "preview" | "approval" | "render" | "verify";
8
+ export type Presence = "missing" | "stale" | "current";
9
+ export type ApprovalEvidence = ApprovalState["state"];
10
+ export type VerifyEvidence = "PASS" | "not-run" | "unknown";
11
+ export type ProblemCode = "render-output-missing" | "render-output-mismatch" | "render-unapproved" | "verify-claim-unsupported" | "inputs-unreadable";
12
+ export interface Problem {
13
+ readonly code: ProblemCode;
14
+ readonly message: string;
15
+ }
16
+ export interface Evidence {
17
+ readonly storyboard: string;
18
+ readonly preview: Presence;
19
+ readonly approval: ApprovalEvidence;
20
+ readonly render: Presence;
21
+ readonly verify: VerifyEvidence;
22
+ }
23
+ export interface StateReport {
24
+ readonly state: ProjectState;
25
+ readonly next: NextState | null;
26
+ readonly missing: readonly NextState[];
27
+ readonly problems: readonly Problem[];
28
+ readonly evidence: Evidence;
29
+ }
30
+ /** A render manifest we understand: recorded output plus what is actually on disk. */
31
+ export interface RenderFacts {
32
+ readonly file: string;
33
+ readonly recordedSha256: string;
34
+ readonly diskSha256?: string;
35
+ /** `storyboardSha256` of the build manifest; absent when it is missing or unparsable. */
36
+ readonly buildStoryboardSha256?: string;
37
+ readonly verify: string;
38
+ }
39
+ /** Already-read facts; `deriveState` is pure so every combination is testable without files. */
40
+ export interface EvidenceFacts {
41
+ readonly storyboardSha256: string;
42
+ /** The hash a build would record: `sha256(JSON.stringify(board))`, so whitespace is not identity. */
43
+ readonly storyboardCanonicalSha256: string;
44
+ /** The approval hash the current inputs resolve to; absent when they cannot be read. */
45
+ readonly currentHash?: string;
46
+ /** `inputManifestSha256` recorded by the preview pack. */
47
+ readonly previewSha256?: string;
48
+ readonly approval: ApprovalEvidence;
49
+ readonly render?: RenderFacts;
50
+ /** Short, path-free reason the approval inputs could not be resolved. */
51
+ readonly unreadable?: string;
52
+ }
53
+ /** Derives the state, the next missing artifact and every overclaim from already-read facts. */
54
+ export declare function deriveState(facts: EvidenceFacts): StateReport;
55
+ export interface StateOptions {
56
+ /** Preview pack base; default `<storyboard dir>/out`. */
57
+ readonly out?: string;
58
+ /** Render output dir; default `<out>/project`. */
59
+ readonly render?: string;
60
+ /** The same approval input scope `mk approve` used (`--assets`, `--ledger`, audio, music). */
61
+ readonly approval?: ApprovalBuildOptions;
62
+ }
63
+ export type GatherResult = {
64
+ readonly ok: true;
65
+ readonly facts: EvidenceFacts;
66
+ } | {
67
+ readonly ok: false;
68
+ readonly message: string;
69
+ };
70
+ /** Reads every artifact once; validation failures and unreadable inputs are exit-2 values. */
71
+ export declare function gatherEvidence(io: Io, storyboard: string, options?: StateOptions): Promise<GatherResult>;
@@ -0,0 +1,243 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { validateStoryboardFull } from "@anvpx/motion-build";
4
+ import { approvalState } from "../approval/approval.js";
5
+ import { defaultOut, previewManifestPath } from "../preview/manifest.js";
6
+ import { checkStoryboard } from "../storyboard/check.js";
7
+ import { readJsonFile } from "../storyboard/load.js";
8
+ import { formatFindings } from "../storyboard/report.js";
9
+ import { RENDER_MANIFEST } from "../workflow/provenance.js";
10
+ /** `<render>/manifest.json`: the build manifest, the only artifact that names its storyboard. */
11
+ const BUILD_MANIFEST = "manifest.json";
12
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
13
+ const NEXT = {
14
+ storyboard: "preview",
15
+ previewed: "approval",
16
+ approved: "render",
17
+ rendered: "verify",
18
+ verified: null,
19
+ };
20
+ const previewEvidence = (facts) => {
21
+ if (facts.previewSha256 === undefined || facts.currentHash === undefined)
22
+ return "missing";
23
+ return facts.previewSha256 === facts.currentHash ? "current" : "stale";
24
+ };
25
+ /** Current only when the output holds AND the build manifest attributes it to this storyboard. */
26
+ const renderEvidence = (render, canonical) => {
27
+ if (render === undefined)
28
+ return "missing";
29
+ if (render.diskSha256 !== render.recordedSha256)
30
+ return "stale";
31
+ return render.buildStoryboardSha256 === canonical ? "current" : "stale";
32
+ };
33
+ const verifyEvidence = (render, supported) => {
34
+ if (render === undefined)
35
+ return "unknown";
36
+ if (render.verify === "PASS")
37
+ return supported ? "PASS" : "unknown";
38
+ return render.verify === "not-run" ? "not-run" : "unknown";
39
+ };
40
+ const renderProblems = (facts, supported) => {
41
+ const render = facts.render;
42
+ if (render === undefined)
43
+ return [];
44
+ const problems = [];
45
+ if (render.diskSha256 === undefined) {
46
+ problems.push({
47
+ code: "render-output-missing",
48
+ message: `${render.file} is recorded but not in the render dir`,
49
+ });
50
+ }
51
+ else if (render.diskSha256 !== render.recordedSha256) {
52
+ problems.push({
53
+ code: "render-output-mismatch",
54
+ message: `${render.file} sha256 differs from the render manifest`,
55
+ });
56
+ }
57
+ // Unreadable approval inputs leave the approval state unknown, not "not approved".
58
+ if (facts.unreadable !== undefined)
59
+ return problems;
60
+ if (facts.approval !== "approved") {
61
+ problems.push({
62
+ code: "render-unapproved",
63
+ message: "a render manifest exists without a current approval",
64
+ });
65
+ }
66
+ if (render.verify === "PASS" && !supported) {
67
+ problems.push({
68
+ code: "verify-claim-unsupported",
69
+ message: "verify PASS is recorded but the render evidence does not hold",
70
+ });
71
+ }
72
+ return problems;
73
+ };
74
+ const stateOf = (facts, preview, render, verify) => {
75
+ const approved = facts.approval === "approved";
76
+ if (approved && render === "current")
77
+ return verify === "PASS" ? "verified" : "rendered";
78
+ if (approved)
79
+ return "approved";
80
+ return preview === "current" ? "previewed" : "storyboard";
81
+ };
82
+ /** Derives the state, the next missing artifact and every overclaim from already-read facts. */
83
+ export function deriveState(facts) {
84
+ const preview = previewEvidence(facts);
85
+ const render = renderEvidence(facts.render, facts.storyboardCanonicalSha256);
86
+ const supported = render === "current" && facts.approval === "approved";
87
+ const verify = verifyEvidence(facts.render, supported);
88
+ const problems = renderProblems(facts, supported);
89
+ if (facts.unreadable !== undefined) {
90
+ problems.push({ code: "inputs-unreadable", message: facts.unreadable });
91
+ }
92
+ const state = stateOf(facts, preview, render, verify);
93
+ const next = NEXT[state];
94
+ return {
95
+ state,
96
+ next,
97
+ missing: next === null ? [] : [next],
98
+ problems,
99
+ evidence: {
100
+ storyboard: facts.storyboardSha256,
101
+ preview,
102
+ approval: facts.approval,
103
+ render,
104
+ verify,
105
+ },
106
+ };
107
+ }
108
+ const HEX64 = /^[a-f0-9]{64}$/;
109
+ const BASENAME = /^[^\\/]+$/;
110
+ const ABSOLUTE = /(?:[A-Za-z]:)?[\\/][^\s:;,"']+/g;
111
+ /** Keeps an error reason short and machine-independent: no absolute paths. */
112
+ const sanitize = (text) => {
113
+ const line = text.split("\n")[0] ?? "";
114
+ return line.replace(ABSOLUTE, "<path>").slice(0, 120);
115
+ };
116
+ const recordedPreviewSha = (value) => {
117
+ const row = value;
118
+ if (typeof row !== "object" || row === null)
119
+ return undefined;
120
+ const sha = row.inputManifestSha256;
121
+ return typeof sha === "string" && HEX64.test(sha) ? sha : undefined;
122
+ };
123
+ /** The storyboard a build manifest compiled. Unknown or older shapes are "cannot attribute". */
124
+ const recordedBuildSha = (value) => {
125
+ const row = value;
126
+ if (typeof row !== "object" || row === null)
127
+ return undefined;
128
+ const sha = row.storyboardSha256;
129
+ return typeof sha === "string" && HEX64.test(sha) ? sha : undefined;
130
+ };
131
+ /** Present and parsed, absent, or present but unreadable (which exits 2, never "missing"). */
132
+ async function readEvidenceJson(host, path, label) {
133
+ const loaded = await readJsonFile(path, host);
134
+ if (loaded.ok)
135
+ return { kind: "value", value: loaded.value };
136
+ if (loaded.kind === "syntax" || !(await host.exists(path)))
137
+ return { kind: "missing" };
138
+ return { kind: "unreadable", message: `${label} is unreadable` };
139
+ }
140
+ /** A render manifest shape we understand; unknown or older shapes are simply not evidence. */
141
+ async function parseRender(host, dir, value, buildStoryboardSha256) {
142
+ const row = value;
143
+ if (typeof row !== "object" || row === null || row.schema !== 1)
144
+ return undefined;
145
+ const output = row.output;
146
+ if (typeof output !== "object" || output === null)
147
+ return undefined;
148
+ const file = output.file;
149
+ const sha = output.sha256;
150
+ if (typeof file !== "string" || !BASENAME.test(file) || file === "." || file === "..") {
151
+ return undefined;
152
+ }
153
+ if (typeof sha !== "string" || !HEX64.test(sha))
154
+ return undefined;
155
+ return {
156
+ file,
157
+ recordedSha256: sha,
158
+ diskSha256: await host.sha256File(join(dir, file)),
159
+ ...(buildStoryboardSha256 === undefined ? {} : { buildStoryboardSha256 }),
160
+ verify: typeof row.verify === "string" ? row.verify : "unknown",
161
+ };
162
+ }
163
+ /** Tolerant by design: an absent or unparsable build manifest means "cannot attribute", not an error. */
164
+ async function readBuildStoryboardSha(host, dir) {
165
+ const loaded = await readJsonFile(join(dir, BUILD_MANIFEST), host);
166
+ return loaded.ok ? recordedBuildSha(loaded.value) : undefined;
167
+ }
168
+ const SAME_APPROVAL_FLAGS = "pass the same --assets/--ledger/--audio/--music flags used with `mk approve`";
169
+ async function readApproval(io, storyboard, options) {
170
+ try {
171
+ const state = await approvalState(io.host, storyboard, options);
172
+ return { approval: state.state, currentHash: state.hash };
173
+ }
174
+ catch (error) {
175
+ return {
176
+ approval: "missing",
177
+ unreadable: `approval inputs cannot be read: ${sanitize(error.message)}; ${SAME_APPROVAL_FLAGS}`,
178
+ };
179
+ }
180
+ }
181
+ async function loadStoryboard(io, storyboard) {
182
+ const loaded = await readJsonFile(storyboard, io.host);
183
+ if (!loaded.ok)
184
+ return { ok: false, message: loaded.message };
185
+ const report = checkStoryboard(loaded.value);
186
+ if (report.errors.length > 0) {
187
+ const findings = formatFindings(report.errors.slice(0, 3));
188
+ return { ok: false, message: `storyboard is invalid; run \`mk validate\`.\n${findings}` };
189
+ }
190
+ const board = validateStoryboardFull(loaded.value);
191
+ // checkStoryboard ran the same validator; this turns the impossible into a typed refusal.
192
+ if (!board.ok)
193
+ return { ok: false, message: "storyboard is invalid; run `mk validate`." };
194
+ const fileSha256 = await io.host.sha256File(storyboard);
195
+ if (fileSha256 === undefined)
196
+ return { ok: false, message: `cannot read ${storyboard}` };
197
+ // A build hashes `JSON.stringify(board)`: canonical, so whitespace and key order are not identity.
198
+ return { ok: true, sha256: fileSha256, canonicalSha256: sha256(JSON.stringify(board.value)) };
199
+ }
200
+ async function readPreview(io, storyboard, options) {
201
+ const out = options.out ?? defaultOut(storyboard);
202
+ const preview = await readEvidenceJson(io.host, previewManifestPath(out, storyboard), "preview manifest");
203
+ if (preview.kind === "unreadable")
204
+ return { ok: false, message: preview.message };
205
+ const sha256 = preview.kind === "value" ? recordedPreviewSha(preview.value) : undefined;
206
+ return sha256 === undefined ? { ok: true } : { ok: true, sha256 };
207
+ }
208
+ async function readRender(io, storyboard, options) {
209
+ const dir = options.render ?? join(defaultOut(storyboard), "project");
210
+ const json = await readEvidenceJson(io.host, join(dir, RENDER_MANIFEST), "render manifest");
211
+ if (json.kind === "unreadable")
212
+ return { ok: false, message: json.message };
213
+ if (json.kind !== "value")
214
+ return { ok: true };
215
+ const buildSha = await readBuildStoryboardSha(io.host, dir);
216
+ const facts = await parseRender(io.host, dir, json.value, buildSha);
217
+ return facts === undefined ? { ok: true } : { ok: true, facts };
218
+ }
219
+ /** Reads every artifact once; validation failures and unreadable inputs are exit-2 values. */
220
+ export async function gatherEvidence(io, storyboard, options = {}) {
221
+ const board = await loadStoryboard(io, storyboard);
222
+ if (!board.ok)
223
+ return board;
224
+ const approval = await readApproval(io, storyboard, options.approval ?? {});
225
+ const preview = await readPreview(io, storyboard, options);
226
+ if (!preview.ok)
227
+ return preview;
228
+ const render = await readRender(io, storyboard, options);
229
+ if (!render.ok)
230
+ return render;
231
+ return {
232
+ ok: true,
233
+ facts: {
234
+ storyboardSha256: board.sha256,
235
+ storyboardCanonicalSha256: board.canonicalSha256,
236
+ approval: approval.approval,
237
+ ...(approval.currentHash === undefined ? {} : { currentHash: approval.currentHash }),
238
+ ...(approval.unreadable === undefined ? {} : { unreadable: approval.unreadable }),
239
+ ...(preview.sha256 === undefined ? {} : { previewSha256: preview.sha256 }),
240
+ ...(render.facts === undefined ? {} : { render: render.facts }),
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,21 @@
1
+ import { type LintFinding, type PlatformMarginLookup } from "@anvpx/motion-build";
2
+ export type Finding = LintFinding;
3
+ export interface CheckReport {
4
+ errors: Finding[];
5
+ warnings: Finding[];
6
+ /** Total duration in seconds; present once the storyboard validates. */
7
+ total?: number;
8
+ /** Frame size and rate; present once the storyboard validates. */
9
+ meta?: {
10
+ width: number;
11
+ height: number;
12
+ fps: number;
13
+ };
14
+ }
15
+ /** A finding about the document itself (path "" is the JSON root). */
16
+ export declare const rootError: (message: string) => Finding;
17
+ /** Schema + per-scene validation, timing resolution and lint (unless `lint: false`), with no side effects. */
18
+ export declare function checkStoryboard(value: unknown, options?: {
19
+ lint?: boolean;
20
+ platforms?: PlatformMarginLookup;
21
+ }): CheckReport;
@@ -0,0 +1,39 @@
1
+ import { bundledThemeDocs, lintStoryboard, resolveTimeline, sceneWords, tryResolveTheme, validateStoryboardFull, } from "@anvpx/motion-build";
2
+ const asFinding = (error) => ({ severity: "error", ...error });
3
+ /** A finding about the document itself (path "" is the JSON root). */
4
+ export const rootError = (message) => ({ severity: "error", path: "", message });
5
+ const failure = (errors) => ({
6
+ errors: errors.map(asFinding),
7
+ warnings: [],
8
+ });
9
+ const themeInput = (name) => {
10
+ const theme = tryResolveTheme(name, bundledThemeDocs());
11
+ return theme === undefined ? {} : { theme };
12
+ };
13
+ /** Schema + per-scene validation, timing resolution and lint (unless `lint: false`), with no side effects. */
14
+ export function checkStoryboard(value, options = {}) {
15
+ const valid = validateStoryboardFull(value);
16
+ if (!valid.ok)
17
+ return failure(valid.errors);
18
+ const board = valid.value;
19
+ const timeline = resolveTimeline(board);
20
+ if (!timeline.ok)
21
+ return failure(timeline.errors);
22
+ const words = board.scenes.map((scene) => sceneWords(scene.props, scene.type));
23
+ const lint = options.lint === false
24
+ ? { errors: [], warnings: [] }
25
+ : lintStoryboard({
26
+ storyboard: board,
27
+ timeline: timeline.value,
28
+ words,
29
+ ...themeInput(board.meta.theme),
30
+ ...(options.platforms === undefined ? {} : { platforms: options.platforms }),
31
+ });
32
+ const { width, height, fps } = board.meta;
33
+ return {
34
+ errors: [...lint.errors],
35
+ warnings: [...lint.warnings],
36
+ total: timeline.value.total,
37
+ meta: { width, height, fps },
38
+ };
39
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Real-path containment for authored scenes.
3
+ *
4
+ * `props.dir` is checked textually by `resolveSceneDir`, but a symlink or NTFS
5
+ * junction points outside the project while still looking local. Loading such a
6
+ * scene would read outside files, and hashing it would record them as local
7
+ * approval inputs. Every path that reaches an authored scene therefore gets a
8
+ * real-path check; the check fails closed when a real path exists and leaves the
9
+ * allowed root.
10
+ *
11
+ * Fake/off-disk hosts have no realpath: an unresolvable path is treated as "not
12
+ * escaped" and left to the existing existence checks, because there is nothing
13
+ * for the real filesystem to resolve.
14
+ */
15
+ /** True when `target` is strictly inside `root` (not equal, not a sibling prefix). */
16
+ export declare const insideReal: (root: string, target: string) => boolean;
17
+ export interface SceneContainment {
18
+ /** False when the scene directory itself resolves outside the storyboard directory. */
19
+ readonly dirInside: boolean;
20
+ /** Scene-relative files whose real path leaves the scene directory. */
21
+ readonly escapedFiles: readonly string[];
22
+ }
23
+ /** Real-path containment for a scene directory plus the files it is about to read. */
24
+ export declare const sceneContainment: (storyboardDir: string, sceneDir: string, files: readonly string[]) => SceneContainment;
@@ -0,0 +1,43 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ /**
4
+ * Real-path containment for authored scenes.
5
+ *
6
+ * `props.dir` is checked textually by `resolveSceneDir`, but a symlink or NTFS
7
+ * junction points outside the project while still looking local. Loading such a
8
+ * scene would read outside files, and hashing it would record them as local
9
+ * approval inputs. Every path that reaches an authored scene therefore gets a
10
+ * real-path check; the check fails closed when a real path exists and leaves the
11
+ * allowed root.
12
+ *
13
+ * Fake/off-disk hosts have no realpath: an unresolvable path is treated as "not
14
+ * escaped" and left to the existing existence checks, because there is nothing
15
+ * for the real filesystem to resolve.
16
+ */
17
+ /** True when `target` is strictly inside `root` (not equal, not a sibling prefix). */
18
+ export const insideReal = (root, target) => {
19
+ const rel = relative(root, target);
20
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
21
+ };
22
+ const realPath = (path) => {
23
+ try {
24
+ return realpathSync(path);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ };
30
+ /** Real-path containment for a scene directory plus the files it is about to read. */
31
+ export const sceneContainment = (storyboardDir, sceneDir, files) => {
32
+ const rootReal = realPath(storyboardDir);
33
+ const dirReal = realPath(sceneDir);
34
+ if (rootReal === undefined || dirReal === undefined) {
35
+ return { dirInside: true, escapedFiles: [] };
36
+ }
37
+ const dirInside = insideReal(rootReal, dirReal);
38
+ const escapedFiles = files.filter((file) => {
39
+ const fileReal = realPath(join(sceneDir, file));
40
+ return fileReal !== undefined && !insideReal(dirReal, fileReal);
41
+ });
42
+ return { dirInside, escapedFiles };
43
+ };
@@ -0,0 +1,15 @@
1
+ import { type CustomSceneSource, type LintFinding } from "@anvpx/motion-build";
2
+ import type { Host } from "../types.ts";
3
+ /** True when the storyboard references an authored scene; cheap, no disk access. */
4
+ export declare const hasCustomScenes: (board: unknown) => boolean;
5
+ export interface CustomReadResult {
6
+ /** Authored scene sources by scene id; only the entries that read cleanly. */
7
+ readonly sources: Readonly<Record<string, CustomSceneSource>>;
8
+ readonly findings: readonly LintFinding[];
9
+ }
10
+ /**
11
+ * Reads every authored scene beside the storyboard, refuses directories that escape it
12
+ * (textually or through a symlink/junction), and runs the determinism lint. The build
13
+ * stays pure: this is the only place that touches disk.
14
+ */
15
+ export declare function readCustomScenes(board: unknown, storyboardPath: string, host: Host): Promise<CustomReadResult>;