@anvpx/motion-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -2
  3. package/dist/analyze/command.d.ts +2 -0
  4. package/dist/analyze/command.js +190 -0
  5. package/dist/approval/approval.d.ts +32 -0
  6. package/dist/approval/approval.js +141 -0
  7. package/dist/approval/command.d.ts +2 -0
  8. package/dist/approval/command.js +255 -0
  9. package/dist/approval/gate.d.ts +7 -0
  10. package/dist/approval/gate.js +23 -0
  11. package/dist/approval/hash.d.ts +71 -0
  12. package/dist/approval/hash.js +451 -0
  13. package/dist/args.d.ts +30 -0
  14. package/dist/args.js +53 -0
  15. package/dist/assets/command.d.ts +2 -0
  16. package/dist/assets/command.js +165 -0
  17. package/dist/captions/command.d.ts +2 -0
  18. package/dist/captions/command.js +341 -0
  19. package/dist/captions/cues.d.ts +10 -0
  20. package/dist/captions/cues.js +61 -0
  21. package/dist/catalog/command.d.ts +2 -0
  22. package/dist/catalog/command.js +60 -0
  23. package/dist/catalog/format.d.ts +13 -0
  24. package/dist/catalog/format.js +54 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +35 -0
  27. package/dist/commands.d.ts +3 -0
  28. package/dist/commands.js +59 -0
  29. package/dist/compare/command.d.ts +4 -0
  30. package/dist/compare/command.js +144 -0
  31. package/dist/contact/argv.d.ts +24 -0
  32. package/dist/contact/argv.js +42 -0
  33. package/dist/contact/command.d.ts +2 -0
  34. package/dist/contact/command.js +79 -0
  35. package/dist/doctor/checks.d.ts +25 -0
  36. package/dist/doctor/checks.js +571 -0
  37. package/dist/doctor/command.d.ts +4 -0
  38. package/dist/doctor/command.js +88 -0
  39. package/dist/doctor/format.d.ts +7 -0
  40. package/dist/doctor/format.js +41 -0
  41. package/dist/doctor/heal.d.ts +14 -0
  42. package/dist/doctor/heal.js +71 -0
  43. package/dist/doctor/pins.d.ts +8 -0
  44. package/dist/doctor/pins.js +28 -0
  45. package/dist/doctor/skills.d.ts +15 -0
  46. package/dist/doctor/skills.js +58 -0
  47. package/dist/doctor/types.d.ts +36 -0
  48. package/dist/doctor/types.js +1 -0
  49. package/dist/doctor/vendored.d.ts +15 -0
  50. package/dist/doctor/vendored.js +90 -0
  51. package/dist/doctor/version.d.ts +8 -0
  52. package/dist/doctor/version.js +11 -0
  53. package/dist/exit-codes.d.ts +16 -0
  54. package/dist/exit-codes.js +15 -0
  55. package/dist/fit/command.d.ts +5 -0
  56. package/dist/fit/command.js +154 -0
  57. package/dist/fit/solve.d.ts +21 -0
  58. package/dist/fit/solve.js +80 -0
  59. package/dist/fit/target.d.ts +18 -0
  60. package/dist/fit/target.js +47 -0
  61. package/dist/generate3d/command.d.ts +3 -0
  62. package/dist/generate3d/command.js +38 -0
  63. package/dist/generate3d/run.d.ts +39 -0
  64. package/dist/generate3d/run.js +77 -0
  65. package/dist/icon/command.d.ts +2 -0
  66. package/dist/icon/command.js +113 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +16 -0
  69. package/dist/install-mk.d.ts +31 -0
  70. package/dist/install-mk.js +168 -0
  71. package/dist/integration/preview.integration.d.ts +1 -0
  72. package/dist/integration/preview.integration.js +53 -0
  73. package/dist/integration/smoke.integration.d.ts +1 -0
  74. package/dist/integration/smoke.integration.js +91 -0
  75. package/dist/main.d.ts +2 -0
  76. package/dist/main.js +4 -0
  77. package/dist/node-io.d.ts +14 -0
  78. package/dist/node-io.js +152 -0
  79. package/dist/optimize/command.d.ts +12 -0
  80. package/dist/optimize/command.js +110 -0
  81. package/dist/packs/command.d.ts +2 -0
  82. package/dist/packs/command.js +109 -0
  83. package/dist/patch/apply.d.ts +36 -0
  84. package/dist/patch/apply.js +186 -0
  85. package/dist/patch/command.d.ts +2 -0
  86. package/dist/patch/command.js +92 -0
  87. package/dist/plugin/install.d.ts +40 -0
  88. package/dist/plugin/install.js +170 -0
  89. package/dist/preview/command.d.ts +10 -0
  90. package/dist/preview/command.js +758 -0
  91. package/dist/preview/manifest.d.ts +94 -0
  92. package/dist/preview/manifest.js +19 -0
  93. package/dist/preview/pack.d.ts +24 -0
  94. package/dist/preview/pack.js +53 -0
  95. package/dist/preview/plan.d.ts +58 -0
  96. package/dist/preview/plan.js +160 -0
  97. package/dist/preview/provenance.d.ts +23 -0
  98. package/dist/preview/provenance.js +38 -0
  99. package/dist/publish/command.d.ts +2 -0
  100. package/dist/publish/command.js +70 -0
  101. package/dist/publish/player.d.ts +25 -0
  102. package/dist/publish/player.js +108 -0
  103. package/dist/publish/static.d.ts +16 -0
  104. package/dist/publish/static.js +78 -0
  105. package/dist/qc.d.ts +2 -0
  106. package/dist/qc.js +192 -0
  107. package/dist/scene/author.d.ts +16 -0
  108. package/dist/scene/author.js +260 -0
  109. package/dist/scene/command.d.ts +2 -0
  110. package/dist/scene/command.js +110 -0
  111. package/dist/scene/ops.d.ts +23 -0
  112. package/dist/scene/ops.js +151 -0
  113. package/dist/state/command.d.ts +2 -0
  114. package/dist/state/command.js +128 -0
  115. package/dist/state/evidence.d.ts +71 -0
  116. package/dist/state/evidence.js +243 -0
  117. package/dist/storyboard/check.d.ts +21 -0
  118. package/dist/storyboard/check.js +39 -0
  119. package/dist/storyboard/containment.d.ts +24 -0
  120. package/dist/storyboard/containment.js +43 -0
  121. package/dist/storyboard/custom.d.ts +15 -0
  122. package/dist/storyboard/custom.js +133 -0
  123. package/dist/storyboard/edit.d.ts +19 -0
  124. package/dist/storyboard/edit.js +39 -0
  125. package/dist/storyboard/load.d.ts +11 -0
  126. package/dist/storyboard/load.js +52 -0
  127. package/dist/storyboard/report.d.ts +5 -0
  128. package/dist/storyboard/report.js +8 -0
  129. package/dist/template/coerce.d.ts +6 -0
  130. package/dist/template/coerce.js +44 -0
  131. package/dist/template/command.d.ts +5 -0
  132. package/dist/template/command.js +163 -0
  133. package/dist/template/env.d.ts +34 -0
  134. package/dist/template/env.js +38 -0
  135. package/dist/template/fork.d.ts +8 -0
  136. package/dist/template/fork.js +156 -0
  137. package/dist/template/format.d.ts +21 -0
  138. package/dist/template/format.js +161 -0
  139. package/dist/template/list.d.ts +15 -0
  140. package/dist/template/list.js +116 -0
  141. package/dist/template/new.d.ts +6 -0
  142. package/dist/template/new.js +302 -0
  143. package/dist/testing/env.d.ts +8 -0
  144. package/dist/testing/env.js +19 -0
  145. package/dist/testing/fakes.d.ts +33 -0
  146. package/dist/testing/fakes.js +95 -0
  147. package/dist/testing/fixtures.d.ts +8 -0
  148. package/dist/testing/fixtures.js +6 -0
  149. package/dist/testing/probe.d.ts +15 -0
  150. package/dist/testing/probe.js +20 -0
  151. package/dist/testing/project.d.ts +22 -0
  152. package/dist/testing/project.js +26 -0
  153. package/dist/tooling/session.d.ts +40 -0
  154. package/dist/tooling/session.js +106 -0
  155. package/dist/types.d.ts +59 -0
  156. package/dist/types.js +1 -0
  157. package/dist/verify/audio-loudness.d.ts +46 -0
  158. package/dist/verify/audio-loudness.js +186 -0
  159. package/dist/verify/command.d.ts +2 -0
  160. package/dist/verify/command.js +161 -0
  161. package/dist/verify/determinism.d.ts +30 -0
  162. package/dist/verify/determinism.js +59 -0
  163. package/dist/verify/expectation.d.ts +15 -0
  164. package/dist/verify/expectation.js +19 -0
  165. package/dist/verify/media-events.d.ts +61 -0
  166. package/dist/verify/media-events.js +174 -0
  167. package/dist/verify/platform.d.ts +33 -0
  168. package/dist/verify/platform.js +59 -0
  169. package/dist/verify/probe.d.ts +14 -0
  170. package/dist/verify/probe.js +23 -0
  171. package/dist/verify/report.d.ts +15 -0
  172. package/dist/verify/report.js +45 -0
  173. package/dist/verify/run.d.ts +27 -0
  174. package/dist/verify/run.js +53 -0
  175. package/dist/voice/command.d.ts +3 -0
  176. package/dist/voice/command.js +49 -0
  177. package/dist/voice/manifest.d.ts +33 -0
  178. package/dist/voice/manifest.js +69 -0
  179. package/dist/voice/run.d.ts +21 -0
  180. package/dist/voice/run.js +181 -0
  181. package/dist/workflow/build.d.ts +2 -0
  182. package/dist/workflow/build.js +25 -0
  183. package/dist/workflow/draft.d.ts +9 -0
  184. package/dist/workflow/draft.js +201 -0
  185. package/dist/workflow/finish.d.ts +23 -0
  186. package/dist/workflow/finish.js +28 -0
  187. package/dist/workflow/project.d.ts +73 -0
  188. package/dist/workflow/project.js +198 -0
  189. package/dist/workflow/provenance.d.ts +18 -0
  190. package/dist/workflow/provenance.js +36 -0
  191. package/dist/workflow/render.d.ts +31 -0
  192. package/dist/workflow/render.js +705 -0
  193. package/dist/workflow/still.d.ts +5 -0
  194. package/dist/workflow/still.js +62 -0
  195. package/dist/workflow/validate.d.ts +2 -0
  196. package/dist/workflow/validate.js +49 -0
  197. package/package.json +38 -3
@@ -0,0 +1,108 @@
1
+ /** Files `mk publish` adds beside a built project. */
2
+ export const PLAYER_FILE = "player.js";
3
+ export const COMPOSITION_FILE = "composition.html";
4
+ export const WRAPPER_FILE = "index.html";
5
+ /**
6
+ * The playback loop. The built project only animates when something dispatches the renderer's
7
+ * `hf-seek` event, so the player stands in for HyperFrames: it dispatches the same event on a
8
+ * requestAnimationFrame loop, looping the timeline forever. Nothing else in the page changes.
9
+ */
10
+ export const PLAYER_JS = `// motion-kit player: loops the composition by dispatching the renderer's own seek event.
11
+ const root = document.getElementById("root");
12
+ const total = Number(root?.dataset.duration ?? 0) || 1;
13
+ let startedAt = null;
14
+ let paused = false;
15
+ let pausedAt = 0;
16
+
17
+ const seek = (time) => {
18
+ window.dispatchEvent(
19
+ new CustomEvent("hf-seek", { detail: { time, waitUntil: () => undefined } }),
20
+ );
21
+ };
22
+
23
+ const tick = (now) => {
24
+ if (startedAt === null) startedAt = now;
25
+ if (!paused) seek((((now - startedAt) / 1000) % total));
26
+ requestAnimationFrame(tick);
27
+ };
28
+
29
+ // Click or Space toggles playback; the timeline keeps looping either way.
30
+ window.addEventListener("click", () => {
31
+ paused = !paused;
32
+ if (!paused) startedAt = performance.now() - pausedAt * 1000;
33
+ else pausedAt = ((performance.now() - startedAt) / 1000) % total;
34
+ });
35
+ window.addEventListener("keydown", (event) => {
36
+ if (event.code === "Space") {
37
+ event.preventDefault();
38
+ window.dispatchEvent(new Event("click"));
39
+ }
40
+ });
41
+
42
+ requestAnimationFrame(tick);
43
+ `;
44
+ const escapeHtml = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
45
+ /**
46
+ * The composition with the player appended. Every other byte is the build's, so `composition.html`
47
+ * and the project `mk build` produced stay comparable.
48
+ */
49
+ export const renderCompositionHtml = (indexHtml) => {
50
+ const at = indexHtml.lastIndexOf("</body>");
51
+ if (at < 0)
52
+ throw new Error("built index.html has no </body>; is this a HyperFrames project?");
53
+ const lineStart = indexHtml.lastIndexOf("\n", at) + 1;
54
+ const tag = ` <script type="module" src="${PLAYER_FILE}"></script>\n`;
55
+ return `${indexHtml.slice(0, lineStart)}${tag}${indexHtml.slice(lineStart)}`;
56
+ };
57
+ /**
58
+ * A 16:9 stage that scales the fixed-size composition to the window. This is the whole of the
59
+ * "web page" claim: same timeline, same pixels, fitted to the viewport, no responsive re-layout.
60
+ */
61
+ export const renderWrapperHtml = (input) => `<!doctype html>
62
+ <html lang="en">
63
+ <head>
64
+ <meta charset="UTF-8" />
65
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
66
+ <title>${escapeHtml(input.title)}</title>
67
+ <style>
68
+ html, body { margin: 0; height: 100%; background: #05070c; }
69
+ body { display: grid; place-items: center; }
70
+ .mk-stage {
71
+ position: relative;
72
+ width: min(100vw, calc(100vh * ${input.width} / ${input.height}));
73
+ aspect-ratio: ${input.width} / ${input.height};
74
+ overflow: hidden;
75
+ box-shadow: 0 40px 120px -60px #000;
76
+ }
77
+ .mk-stage iframe {
78
+ position: absolute;
79
+ inset: 0;
80
+ width: ${input.width}px;
81
+ height: ${input.height}px;
82
+ border: 0;
83
+ transform-origin: 0 0;
84
+ }
85
+ </style>
86
+ </head>
87
+ <body>
88
+ <div class="mk-stage">
89
+ <iframe
90
+ id="mk-composition"
91
+ src="${COMPOSITION_FILE}"
92
+ title="${escapeHtml(input.title)}"
93
+ scrolling="no"
94
+ ></iframe>
95
+ </div>
96
+ <script>
97
+ const stage = document.querySelector(".mk-stage");
98
+ const frame = document.getElementById("mk-composition");
99
+ const fit = () => {
100
+ const width = stage.getBoundingClientRect().width;
101
+ frame.style.transform = "scale(" + width / ${input.width} + ")";
102
+ };
103
+ new ResizeObserver(fit).observe(stage);
104
+ fit();
105
+ </script>
106
+ </body>
107
+ </html>
108
+ `;
@@ -0,0 +1,16 @@
1
+ /** Content type by extension; unknown types are served as bytes rather than guessed. */
2
+ export declare const contentTypeOf: (path: string) => string;
3
+ /**
4
+ * Absolute path a request maps to inside `root`, or null when it escapes the root or is not a
5
+ * plain file path. Traversal is rejected after decoding, so `%2e%2e` cannot climb out either.
6
+ */
7
+ export declare const resolveRequestPath: (root: string, url: string) => string | null;
8
+ export interface Served {
9
+ readonly url: string;
10
+ close(): Promise<void>;
11
+ }
12
+ /**
13
+ * Serves `root` on `port` (0 picks a free one) until closed. Publishing needs a real origin:
14
+ * ES modules and import maps do not load from `file://`.
15
+ */
16
+ export declare const serveStatic: (root: string, port: number) => Promise<Served>;
@@ -0,0 +1,78 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import { extname, join, resolve, sep } from "node:path";
5
+ const TYPES = {
6
+ ".html": "text/html; charset=utf-8",
7
+ ".js": "text/javascript; charset=utf-8",
8
+ ".css": "text/css; charset=utf-8",
9
+ ".json": "application/json; charset=utf-8",
10
+ ".woff2": "font/woff2",
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".svg": "image/svg+xml",
15
+ ".mp4": "video/mp4",
16
+ ".wav": "audio/wav",
17
+ ".cube": "text/plain; charset=utf-8",
18
+ };
19
+ /** Content type by extension; unknown types are served as bytes rather than guessed. */
20
+ export const contentTypeOf = (path) => TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
21
+ /**
22
+ * Absolute path a request maps to inside `root`, or null when it escapes the root or is not a
23
+ * plain file path. Traversal is rejected after decoding, so `%2e%2e` cannot climb out either.
24
+ */
25
+ export const resolveRequestPath = (root, url) => {
26
+ const raw = url.split("?")[0]?.split("#")[0] ?? "/";
27
+ let path;
28
+ try {
29
+ path = decodeURIComponent(raw);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (path.includes("\0"))
35
+ return null;
36
+ const relative = path === "/" ? "/index.html" : path;
37
+ const base = resolve(root);
38
+ const target = resolve(join(base, relative));
39
+ return target === base || target.startsWith(base + sep) ? target : null;
40
+ };
41
+ /**
42
+ * Serves `root` on `port` (0 picks a free one) until closed. Publishing needs a real origin:
43
+ * ES modules and import maps do not load from `file://`.
44
+ */
45
+ export const serveStatic = async (root, port) => {
46
+ const base = resolve(root);
47
+ const server = createServer((request, response) => {
48
+ const target = resolveRequestPath(base, request.url ?? "/");
49
+ if (target === null) {
50
+ response.writeHead(403).end("forbidden");
51
+ return;
52
+ }
53
+ stat(target).then((info) => {
54
+ if (!info.isFile()) {
55
+ response.writeHead(404).end("not found");
56
+ return;
57
+ }
58
+ response.writeHead(200, {
59
+ "content-type": contentTypeOf(target),
60
+ "content-length": String(info.size),
61
+ // The composition is served from the same origin as the wrapper it is framed in.
62
+ "cache-control": "no-store",
63
+ });
64
+ createReadStream(target).pipe(response);
65
+ }, () => {
66
+ response.writeHead(404).end("not found");
67
+ });
68
+ });
69
+ await new Promise((done) => server.listen(port, "127.0.0.1", done));
70
+ const address = server.address();
71
+ const actual = typeof address === "object" && address !== null ? address.port : port;
72
+ return {
73
+ url: `http://127.0.0.1:${actual}/`,
74
+ close: () => new Promise((done) => {
75
+ server.close(() => done());
76
+ }),
77
+ };
78
+ };
package/dist/qc.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { Command } from "./types.ts";
2
+ export declare const qcCommand: Command;
package/dist/qc.js ADDED
@@ -0,0 +1,192 @@
1
+ import { detectSilence, wordsInSilence } from "@anvpx/motion-audio";
2
+ import { TimingManifestSchema } from "@anvpx/motion-schema";
3
+ import { parseFlags, requirePositional, textFlag, usageError } from "./args.js";
4
+ import { EXIT } from "./exit-codes.js";
5
+ import { readJsonFile } from "./storyboard/load.js";
6
+ import { probeMedia } from "./verify/probe.js";
7
+ import { exitCodeForStatus, formatReport, formatReportJson } from "./verify/report.js";
8
+ const USAGE = `mk qc <manifest.json> [--audio <audio.wav>] [--words <words.json>] [--json]
9
+
10
+ Gate a voice TimingManifest plus its audio; QC measures, it never decides for you.
11
+ <manifest.json> voice TimingManifest to gate (required)
12
+ --audio <file> audio covering the manifest timeline; absent audio is UNKNOWN, never FAIL
13
+ --words <file> known word timings JSON [{w,start,end}]; overrides manifest words
14
+ --json one compact JSON line {status, file, findings}
15
+ Checks: manifest schema; words fully inside silence are fatal; audio duration
16
+ must match the manifest total within 0.5 s. Without --audio the audio gate
17
+ could not be decided: UNKNOWN.
18
+ Exit 0 PASS or WARN; 5 FAIL; 2 UNKNOWN (could not decide) or bad arguments; 127 ffprobe missing.
19
+ `;
20
+ const FLAGS = {
21
+ audio: { type: "string" },
22
+ words: { type: "string" },
23
+ json: { type: "boolean" },
24
+ };
25
+ /** Audio duration within 0.5 s of the manifest total (measured, never inferred). */
26
+ const DURATION_TOLERANCE_S = 0.5;
27
+ const FFMPEG_HINT = "mk: ffprobe not found; install ffmpeg and put it on PATH";
28
+ const isExit = (value) => typeof value === "object" && value !== null && value.kind === "exit";
29
+ const isWordList = (value) => Array.isArray(value) &&
30
+ value.every((entry) => typeof entry === "object" &&
31
+ entry !== null &&
32
+ typeof entry.w === "string" &&
33
+ Number.isFinite(entry.start) &&
34
+ Number.isFinite(entry.end));
35
+ /** Manifest words: explicit --words wins, then manifest audio, then scene words. */
36
+ async function resolveWords(io, manifest, wordsPath) {
37
+ if (wordsPath === undefined) {
38
+ return manifest.audio?.words ?? manifest.scenes.flatMap((scene) => scene.words ?? []);
39
+ }
40
+ const loaded = await readJsonFile(wordsPath, io.host);
41
+ if (!loaded.ok) {
42
+ io.stderr(`mk qc: ${loaded.message}\n`);
43
+ return { kind: "exit", code: EXIT.USAGE };
44
+ }
45
+ if (!isWordList(loaded.value)) {
46
+ return usageError("qc", USAGE, "words file must be a JSON array of {w,start,end}", io);
47
+ }
48
+ return loaded.value;
49
+ }
50
+ /** Read the manifest; an invalid manifest fails the gate, an unreadable one is usage. */
51
+ async function loadManifest(io, path) {
52
+ const loaded = await readJsonFile(path, io.host);
53
+ if (!loaded.ok) {
54
+ io.stderr(`mk qc: ${loaded.message}\n`);
55
+ return { kind: "exit", code: EXIT.USAGE };
56
+ }
57
+ const manifest = TimingManifestSchema.safeParse(loaded.value);
58
+ if (manifest.success)
59
+ return manifest.data;
60
+ return {
61
+ problems: manifest.error.issues
62
+ .map((issue) => `${issue.path.join("/")}: ${issue.message}`)
63
+ .join("; "),
64
+ };
65
+ }
66
+ /** An UNKNOWN gate report: measured nothing, decided nothing. */
67
+ const unknownReport = (code, message) => ({
68
+ status: "UNKNOWN",
69
+ findings: [{ code, level: "warn", message }],
70
+ });
71
+ /** Silence intervals plus measured duration; unknown stays UNKNOWN, missing tools exit. */
72
+ async function measureAudio(io, audio) {
73
+ const silence = await detectSilence(io.run, { audioPath: audio });
74
+ if (!silence.ok && silence.status === "missing") {
75
+ io.stderr(`${FFMPEG_HINT}\n`);
76
+ return { kind: "exit", code: EXIT.MISSING_TOOL };
77
+ }
78
+ if (!silence.ok) {
79
+ return unknownReport("audio-unreadable", `silence gate failed: ${silence.hint}`);
80
+ }
81
+ const probed = await probeMedia(io, audio);
82
+ if (!probed.ok && probed.kind === "missing-tool") {
83
+ io.stderr(`${FFMPEG_HINT}\n`);
84
+ return { kind: "exit", code: EXIT.MISSING_TOOL };
85
+ }
86
+ if (!probed.ok) {
87
+ return unknownReport("audio-unreadable", `could not read audio: ${probed.reason}`);
88
+ }
89
+ const format = probed.probe.format;
90
+ const raw = Number(format?.duration);
91
+ const audioDuration = Number.isFinite(raw) && raw > 0 ? raw : undefined;
92
+ return { intervals: silence.intervals, audioDuration };
93
+ }
94
+ /** Silence gate plus duration cross-check; never throws. */
95
+ async function gateAudio(io, manifest, audio, wordsPath) {
96
+ if (!(await io.host.exists(audio))) {
97
+ io.stderr(`mk qc: cannot read ${audio}; check the path.\n`);
98
+ return { kind: "exit", code: EXIT.USAGE };
99
+ }
100
+ const words = await resolveWords(io, manifest, wordsPath);
101
+ if (isExit(words))
102
+ return words;
103
+ const measured = await measureAudio(io, audio);
104
+ if (isExit(measured) || "status" in measured)
105
+ return measured;
106
+ const findings = audioFindings(words ?? [], measured.intervals, manifest, measured.audioDuration);
107
+ return { status: statusOf(findings, (words ?? []).length > 0), findings };
108
+ }
109
+ const statusOf = (findings, decidable) => {
110
+ if (findings.some((f) => f.level === "fatal"))
111
+ return "FAIL";
112
+ if (!decidable)
113
+ return "UNKNOWN";
114
+ return findings.some((f) => f.level === "warn") ? "WARN" : "PASS";
115
+ };
116
+ const silenceFinding = (inside) => {
117
+ const shown = inside
118
+ .slice(0, 3)
119
+ .map((word) => `"${word.w}" at ${word.start.toFixed(2)}s`)
120
+ .join(", ");
121
+ return {
122
+ code: "words-in-silence",
123
+ level: "fatal",
124
+ message: `${inside.length} word(s) inside silence (${shown}); refusing fake timing`,
125
+ };
126
+ };
127
+ const audioFindings = (words, intervals, manifest, audioDuration) => {
128
+ const findings = [];
129
+ if (words.length === 0) {
130
+ findings.push({
131
+ code: "audio-words-missing",
132
+ level: "warn",
133
+ message: "no word timings (manifest audio, scene words or --words); gate undecided (unaligned narration, e.g. Japanese j-voice, stays UNKNOWN, never FAIL)",
134
+ });
135
+ }
136
+ else {
137
+ const swallowed = wordsInSilence(words, intervals);
138
+ if (swallowed.length > 0)
139
+ findings.push(silenceFinding(swallowed));
140
+ }
141
+ const total = manifest.scenes.reduce((sum, scene) => sum + scene.duration, 0);
142
+ if (audioDuration !== undefined && Math.abs(audioDuration - total) > DURATION_TOLERANCE_S) {
143
+ findings.push({
144
+ code: "audio-duration-mismatch",
145
+ level: "fatal",
146
+ message: `Audio is ${audioDuration.toFixed(2)}s, manifest total is ${total.toFixed(2)}s.`,
147
+ });
148
+ }
149
+ return findings;
150
+ };
151
+ const audioAbsent = () => ({
152
+ status: "UNKNOWN",
153
+ findings: [
154
+ {
155
+ code: "audio-not-provided",
156
+ level: "warn",
157
+ message: "no --audio; the audio gate could not be decided (audio is opt-in)",
158
+ },
159
+ ],
160
+ });
161
+ export const qcCommand = {
162
+ name: "qc",
163
+ summary: "gate a voice manifest plus its audio: PASS, WARN, FAIL or UNKNOWN",
164
+ async run(args, io) {
165
+ const parsed = parseFlags("qc", USAGE, args, FLAGS, io);
166
+ if (parsed.kind === "exit")
167
+ return parsed.code;
168
+ const manifestPath = requirePositional("qc", USAGE, parsed.positionals, "<manifest.json>", io);
169
+ if (typeof manifestPath !== "string")
170
+ return manifestPath.code;
171
+ const manifest = await loadManifest(io, manifestPath);
172
+ if (isExit(manifest))
173
+ return manifest.code;
174
+ if ("problems" in manifest) {
175
+ const invalid = {
176
+ status: "FAIL",
177
+ findings: [{ code: "manifest-invalid", level: "fatal", message: manifest.problems }],
178
+ };
179
+ io.stdout(reportOf(parsed.flags.json === true, manifestPath, invalid));
180
+ return exitCodeForStatus(invalid.status);
181
+ }
182
+ const audio = textFlag(parsed.flags, "audio");
183
+ const gated = audio === undefined
184
+ ? audioAbsent()
185
+ : await gateAudio(io, manifest, audio, textFlag(parsed.flags, "words"));
186
+ if (isExit(gated))
187
+ return gated.code;
188
+ io.stdout(reportOf(parsed.flags.json === true, manifestPath, gated));
189
+ return exitCodeForStatus(gated.status);
190
+ },
191
+ };
192
+ const reportOf = (json, file, report) => json ? formatReportJson(file, report) : formatReport(file, report);
@@ -0,0 +1,16 @@
1
+ import type { Io } from "../types.ts";
2
+ declare const USAGE = "mk scene new <id> [--dir <path>] [--force]\nmk scene lint <dir>\n\nAuthor a scene (type \"custom\"): three files beside the storyboard, rendered like a catalog scene.\n new scaffold scene.js, fragment.html, style.css and notes.md (refuses to overwrite without --force)\n --dir path where to write it (default scenes/<id>)\n lint determinism and safety check: clocks, timers, network, workers, window, CSS animation/transition\n promote copy an authored scene into the catalog package (maintainer workflow) and print the registry lines\n --as type catalog type name (required)\nExit 0 when clean; 2 on lint errors or bad usage.\n";
3
+ declare function runNew(args: readonly string[], io: Io): Promise<number>;
4
+ declare function runLint(args: readonly string[], io: Io): Promise<number>;
5
+ /**
6
+ * Files for a promoted catalog scene. A promoted scene is static: it keeps its markup
7
+ * and code verbatim, so the plugin only needs an empty strict schema and a body.
8
+ * Adding props is a human design step, deliberately not guessed here.
9
+ */
10
+ export declare const promoteFiles: (type: string, source: {
11
+ js: string;
12
+ html: string;
13
+ css?: string;
14
+ }) => readonly (readonly [string, string])[];
15
+ declare function runPromote(args: readonly string[], io: Io): Promise<number>;
16
+ export { runLint as sceneLint, runNew as sceneNew, runPromote as scenePromote, USAGE as SCENE_AUTHOR_USAGE, };
@@ -0,0 +1,260 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { lintCustomSource, readRelativeClosure } from "@anvpx/motion-build";
3
+ import { parseFlags, textFlag, usageError } from "../args.js";
4
+ import { EXIT } from "../exit-codes.js";
5
+ import { sceneContainment } from "../storyboard/containment.js";
6
+ import { formatFindings, summaryLine } from "../storyboard/report.js";
7
+ const USAGE = `mk scene new <id> [--dir <path>] [--force]
8
+ mk scene lint <dir>
9
+
10
+ Author a scene (type "custom"): three files beside the storyboard, rendered like a catalog scene.
11
+ new scaffold scene.js, fragment.html, style.css and notes.md (refuses to overwrite without --force)
12
+ --dir path where to write it (default scenes/<id>)
13
+ lint determinism and safety check: clocks, timers, network, workers, window, CSS animation/transition
14
+ promote copy an authored scene into the catalog package (maintainer workflow) and print the registry lines
15
+ --as type catalog type name (required)
16
+ Exit 0 when clean; 2 on lint errors or bad usage.
17
+ `;
18
+ const NEW_FLAGS = {
19
+ dir: { type: "string" },
20
+ force: { type: "boolean" },
21
+ };
22
+ const SCENE_JS = `// @ts-check
23
+ import { mountTimeline } from "../../runtime/mount.js";
24
+
25
+ /**
26
+ * Authored scene: seek(t) must be a pure function of t.
27
+ * No clocks, no timers, no unseeded random, no network, no window globals.
28
+ * Guide: the authoring-scene reference in the installed motion-kit skill
29
+ * @type {import("../../runtime/mount.js").CreateScene}
30
+ */
31
+ export function createScene(root, config, ctx) {
32
+ return mountTimeline(root, config, ctx, { baseDelay: 0.3, staggerFrames: 6, rise: 48 });
33
+ }
34
+ `;
35
+ const FRAGMENT_HTML = `<div class="authored-stack">
36
+ <p class="authored-kicker" data-anim="rise" data-step="0" data-focus-key="kicker">Kicker</p>
37
+ <h1 class="authored-title" data-anim="rise" data-step="1" data-focus-key="title">Your scene</h1>
38
+ </div>
39
+ `;
40
+ const STYLE_CSS = `#scene-root .authored-stack { display: flex; flex-direction: column; gap: 24px; }
41
+ #scene-root .authored-kicker {
42
+ font-size: 22px;
43
+ letter-spacing: 0.12em;
44
+ text-transform: uppercase;
45
+ color: var(--mk-color-accent);
46
+ }
47
+ #scene-root .authored-title {
48
+ font-size: 96px;
49
+ line-height: 1.05;
50
+ font-weight: 800;
51
+ letter-spacing: -0.02em;
52
+ }
53
+ `;
54
+ const notes = (id) => `# ${id}
55
+
56
+ Why this scene exists: <one line>.
57
+
58
+ Promote it to the catalog (\`mk scene promote\`) once two projects use it.
59
+ `;
60
+ const FILES = (id) => [
61
+ ["scene.js", SCENE_JS],
62
+ ["fragment.html", FRAGMENT_HTML],
63
+ ["style.css", STYLE_CSS],
64
+ ["notes.md", notes(id)],
65
+ ];
66
+ async function runNew(args, io) {
67
+ const parsed = parseFlags("scene", USAGE, args, NEW_FLAGS, io);
68
+ if (parsed.kind === "exit")
69
+ return parsed.code;
70
+ const [id, ...extra] = parsed.positionals;
71
+ if (id === undefined)
72
+ return usageError("scene", USAGE, "missing <id>", io).code;
73
+ if (extra.length > 0)
74
+ return usageError("scene", USAGE, `unexpected "${extra[0]}"`, io).code;
75
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
76
+ return usageError("scene", USAGE, "<id> must be a lowercase slug", io).code;
77
+ }
78
+ const requested = textFlag(parsed.flags, "dir") ?? join("scenes", id);
79
+ const dir = resolve(requested);
80
+ const force = parsed.flags.force === true;
81
+ const targets = FILES(id).map(([name]) => join(dir, name));
82
+ if (!force) {
83
+ const taken = await Promise.all(targets.map((path) => io.host.exists(path)));
84
+ if (taken.some(Boolean)) {
85
+ return usageError("scene", USAGE, `${dir} already has files; pass --force to overwrite`, io)
86
+ .code;
87
+ }
88
+ }
89
+ for (const [name, text] of FILES(id))
90
+ await io.host.writeText(join(dir, name), text);
91
+ io.stdout(`wrote ${requested} (${FILES(id).length} files)\n`);
92
+ io.stdout(`next: add { "type": "custom", "props": { "dir": "${requested.replaceAll("\\", "/")}" } } and run mk validate\n`);
93
+ return EXIT.OK;
94
+ }
95
+ async function runLint(args, io) {
96
+ const parsed = parseFlags("scene", USAGE, args, {}, io);
97
+ if (parsed.kind === "exit")
98
+ return parsed.code;
99
+ const [dir, ...extra] = parsed.positionals;
100
+ if (dir === undefined)
101
+ return usageError("scene", USAGE, "missing <dir>", io).code;
102
+ if (extra.length > 0)
103
+ return usageError("scene", USAGE, `unexpected "${extra[0]}"`, io).code;
104
+ const base = resolve(dir);
105
+ const required = ["scene.js", "fragment.html"];
106
+ const missing = [];
107
+ for (const file of required) {
108
+ if (!(await io.host.exists(join(base, file))))
109
+ missing.push(file);
110
+ }
111
+ if (missing.length > 0) {
112
+ io.stderr(`mk scene lint: missing ${missing.join(", ")} in ${dir}\n`);
113
+ return EXIT.USAGE;
114
+ }
115
+ const js = await io.host.readText(join(base, "scene.js"));
116
+ const html = await io.host.readText(join(base, "fragment.html"));
117
+ const cssPath = join(base, "style.css");
118
+ const css = (await io.host.exists(cssPath)) ? await io.host.readText(cssPath) : undefined;
119
+ const findings = lintCustomSource({ js, html, ...(css === undefined ? {} : { css }) }, dir);
120
+ // A linked directory or file can point outside while still looking local.
121
+ const containment = sceneContainment(dirname(base), base, [...required, "style.css"]);
122
+ if (!containment.dirInside) {
123
+ findings.push({
124
+ severity: "error",
125
+ path: base,
126
+ message: "scene directory resolves outside its parent (symlink/junction)",
127
+ suggestion: "replace the link with a real directory",
128
+ });
129
+ }
130
+ for (const file of containment.escapedFiles) {
131
+ findings.push({
132
+ severity: "error",
133
+ path: `${dir}/${file}`,
134
+ message: "scene file resolves outside the scene directory (symlink/junction)",
135
+ suggestion: "replace the link with a real file",
136
+ });
137
+ }
138
+ const closure = await readRelativeClosure(js, async (path) => {
139
+ const check = sceneContainment(dirname(base), base, [path]);
140
+ if (!check.dirInside || check.escapedFiles.length > 0)
141
+ return undefined;
142
+ const full = join(base, path);
143
+ return (await io.host.exists(full)) ? io.host.readText(full) : undefined;
144
+ });
145
+ for (const specifier of closure.escaping) {
146
+ findings.push({
147
+ severity: "error",
148
+ path: "scene.js",
149
+ message: `relative import "${specifier}" escapes the scene directory`,
150
+ suggestion: "keep helpers inside the scene directory (./lib/format.js)",
151
+ });
152
+ }
153
+ for (const path of closure.missing) {
154
+ findings.push({
155
+ severity: "error",
156
+ path: "scene.js",
157
+ message: `cannot import "./${path}": no such file beside the scene`,
158
+ suggestion: "add the file, or inline the code",
159
+ });
160
+ }
161
+ if (closure.overflow) {
162
+ findings.push({
163
+ severity: "error",
164
+ path: "scene.js",
165
+ message: "scene helper closure is too large",
166
+ suggestion: "keep the scene to a few small helpers",
167
+ });
168
+ }
169
+ for (const helper of closure.files) {
170
+ findings.push(...lintCustomSource({ js: helper.text }, dir).map((finding) => ({
171
+ ...finding,
172
+ path: finding.path.replace(/scene\.js:/, `${helper.path}:`),
173
+ })));
174
+ }
175
+ io.stdout(formatFindings(findings));
176
+ io.stdout(summaryLine(findings.length, 0));
177
+ return findings.length === 0 ? EXIT.OK : EXIT.USAGE;
178
+ }
179
+ /** camelCase export name for a promoted scene type. */
180
+ const exportName = (type) => `${type.replace(/[^a-zA-Z0-9]+(.)/g, (_, char) => char.toUpperCase())}Scene`;
181
+ /**
182
+ * Files for a promoted catalog scene. A promoted scene is static: it keeps its markup
183
+ * and code verbatim, so the plugin only needs an empty strict schema and a body.
184
+ * Adding props is a human design step, deliberately not guessed here.
185
+ */
186
+ export const promoteFiles = (type, source) => {
187
+ const index = `import { z } from "zod";
188
+ import { defineScene } from "../../define-scene.ts";
189
+ import { readText } from "../../text-file.ts";
190
+
191
+ const FRAGMENT = readText("./fragment.html", import.meta.url);
192
+
193
+ /** Promoted from an authored scene. Give it props before it ships: configSchema is empty. */
194
+ export const ${exportName(type)} = defineScene({
195
+ type: "${type}",
196
+ configSchema: z.strictObject({}),
197
+ meta: { purpose: "TODO: one line for the catalog.", example: {} },
198
+ style: readText("./style.css", import.meta.url),
199
+ script: readText("./scene.js", import.meta.url),
200
+ render: () => ({ body: FRAGMENT }),
201
+ });
202
+ `;
203
+ const files = [
204
+ ["index.ts", index],
205
+ ["scene.js", source.js],
206
+ ["fragment.html", source.html],
207
+ ];
208
+ return source.css === undefined ? files : [...files, ["style.css", source.css]];
209
+ };
210
+ const CATALOG_DIR = join("packages", "scenes", "src", "scenes");
211
+ async function runPromote(args, io) {
212
+ const parsed = parseFlags("scene", USAGE, args, PROMOTE_FLAGS, io);
213
+ if (parsed.kind === "exit")
214
+ return parsed.code;
215
+ const [dir, ...extra] = parsed.positionals;
216
+ if (dir === undefined)
217
+ return usageError("scene", USAGE, "missing <dir>", io).code;
218
+ if (extra.length > 0)
219
+ return usageError("scene", USAGE, `unexpected "${extra[0]}"`, io).code;
220
+ const type = textFlag(parsed.flags, "as");
221
+ if (type === undefined)
222
+ return usageError("scene", USAGE, "missing --as <type>", io).code;
223
+ if (!/^[a-z][a-z0-9-]*$/.test(type)) {
224
+ return usageError("scene", USAGE, "--as must be a lowercase slug", io).code;
225
+ }
226
+ const source = resolve(dir);
227
+ const required = ["scene.js", "fragment.html"];
228
+ const missing = [];
229
+ for (const file of required) {
230
+ if (!(await io.host.exists(join(source, file))))
231
+ missing.push(file);
232
+ }
233
+ if (missing.length > 0) {
234
+ io.stderr(`mk scene promote: missing ${missing.join(", ")} in ${dir}\n`);
235
+ return EXIT.USAGE;
236
+ }
237
+ const target = resolve(CATALOG_DIR, type);
238
+ if (await io.host.exists(target)) {
239
+ return usageError("scene", USAGE, `${CATALOG_DIR}/${type} already exists`, io).code;
240
+ }
241
+ const js = await io.host.readText(join(source, "scene.js"));
242
+ const html = await io.host.readText(join(source, "fragment.html"));
243
+ const cssPath = join(source, "style.css");
244
+ const css = (await io.host.exists(cssPath)) ? await io.host.readText(cssPath) : undefined;
245
+ for (const [name, text] of promoteFiles(type, {
246
+ js,
247
+ html,
248
+ ...(css === undefined ? {} : { css }),
249
+ })) {
250
+ await io.host.writeText(join(target, name), text);
251
+ }
252
+ io.stdout(`promoted ${dir} -> ${CATALOG_DIR}/${type}\n`);
253
+ io.stdout(`register it in the catalog registry:\n`);
254
+ io.stdout(` import { ${exportName(type)} } from "./scenes/${type}/index.ts";\n`);
255
+ io.stdout(` ${exportName(type)},\n`);
256
+ io.stdout(`then run pnpm test (the catalog snapshot will ask for the new type).\n`);
257
+ return EXIT.OK;
258
+ }
259
+ const PROMOTE_FLAGS = { as: { type: "string" } };
260
+ export { runLint as sceneLint, runNew as sceneNew, runPromote as scenePromote, USAGE as SCENE_AUTHOR_USAGE, };
@@ -0,0 +1,2 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const sceneCommand: Command;