@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,279 @@
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 { ease, interpolate, progress } from "../../runtime/compose.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
+ const element = /** @type {HTMLElement | null} */ (root.querySelector(".authored-element"));
33
+ if (!element) throw new Error("authored scene: missing .authored-element");
34
+ // Replace the markup, layout and these values with the direction's composition.
35
+ // Derive every animated value on EVERY seek, including backward seeks.
36
+ const seek = (t) => {
37
+ const amount = ease("out", progress(t, 0, 0.8));
38
+ element.style.opacity = String(amount);
39
+ element.style.transform = "translateY(" + interpolate(24, 0, amount) + "px)";
40
+ };
41
+ seek(0);
42
+ return {
43
+ seek,
44
+ dispose() {
45
+ element.style.removeProperty("opacity");
46
+ element.style.removeProperty("transform");
47
+ },
48
+ ready: Promise.resolve(),
49
+ };
50
+ }
51
+ `;
52
+ const FRAGMENT_HTML = `<div class="authored-composition">
53
+ <h1 class="authored-element" data-focus-key="subject">Your composition</h1>
54
+ </div>
55
+ `;
56
+ const STYLE_CSS = `/* A blank starting surface: design its grid, layers and type from the brief. */
57
+ #scene-root .authored-composition { display: grid; place-items: center; width: 100%; height: 100%; }
58
+ #scene-root .authored-element {
59
+ margin: 0;
60
+ font: inherit;
61
+ font-size: clamp(32px, 6vw, 96px);
62
+ color: var(--mk-color-fg);
63
+ }
64
+ `;
65
+ const notes = (id) => `# ${id}
66
+
67
+ Why this scene exists: <one line>.
68
+
69
+ Direction: <concept, composition, type, motion and sound decisions>.
70
+
71
+ The markup and motion are yours to replace. Shared \`runtime/compose.js\` offers
72
+ \`progress(t, start, duration)\`, \`interpolate(from, to, amount)\`,
73
+ \`stagger(index, step, start)\` and the existing \`ease(name, amount)\` functions.
74
+ All times are seconds. Progress clamps to 0..1; a zero duration is a cut.
75
+ Assign every animated property from t so seeking A -> B -> A restores A.
76
+
77
+ Promotion to the catalog (\`mk scene promote\`) is optional when reuse justifies it.
78
+ `;
79
+ const FILES = (id) => [
80
+ ["scene.js", SCENE_JS],
81
+ ["fragment.html", FRAGMENT_HTML],
82
+ ["style.css", STYLE_CSS],
83
+ ["notes.md", notes(id)],
84
+ ];
85
+ async function runNew(args, io) {
86
+ const parsed = parseFlags("scene", USAGE, args, NEW_FLAGS, io);
87
+ if (parsed.kind === "exit")
88
+ return parsed.code;
89
+ const [id, ...extra] = parsed.positionals;
90
+ if (id === undefined)
91
+ return usageError("scene", USAGE, "missing <id>", io).code;
92
+ if (extra.length > 0)
93
+ return usageError("scene", USAGE, `unexpected "${extra[0]}"`, io).code;
94
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
95
+ return usageError("scene", USAGE, "<id> must be a lowercase slug", io).code;
96
+ }
97
+ const requested = textFlag(parsed.flags, "dir") ?? join("scenes", id);
98
+ const dir = resolve(requested);
99
+ const force = parsed.flags.force === true;
100
+ const targets = FILES(id).map(([name]) => join(dir, name));
101
+ if (!force) {
102
+ const taken = await Promise.all(targets.map((path) => io.host.exists(path)));
103
+ if (taken.some(Boolean)) {
104
+ return usageError("scene", USAGE, `${dir} already has files; pass --force to overwrite`, io)
105
+ .code;
106
+ }
107
+ }
108
+ for (const [name, text] of FILES(id))
109
+ await io.host.writeText(join(dir, name), text);
110
+ io.stdout(`wrote ${requested} (${FILES(id).length} files)\n`);
111
+ io.stdout(`next: add { "type": "custom", "props": { "dir": "${requested.replaceAll("\\", "/")}" } } and run mk validate\n`);
112
+ return EXIT.OK;
113
+ }
114
+ async function runLint(args, io) {
115
+ const parsed = parseFlags("scene", USAGE, args, {}, io);
116
+ if (parsed.kind === "exit")
117
+ return parsed.code;
118
+ const [dir, ...extra] = parsed.positionals;
119
+ if (dir === undefined)
120
+ return usageError("scene", USAGE, "missing <dir>", io).code;
121
+ if (extra.length > 0)
122
+ return usageError("scene", USAGE, `unexpected "${extra[0]}"`, io).code;
123
+ const base = resolve(dir);
124
+ const required = ["scene.js", "fragment.html"];
125
+ const missing = [];
126
+ for (const file of required) {
127
+ if (!(await io.host.exists(join(base, file))))
128
+ missing.push(file);
129
+ }
130
+ if (missing.length > 0) {
131
+ io.stderr(`mk scene lint: missing ${missing.join(", ")} in ${dir}\n`);
132
+ return EXIT.USAGE;
133
+ }
134
+ const js = await io.host.readText(join(base, "scene.js"));
135
+ const html = await io.host.readText(join(base, "fragment.html"));
136
+ const cssPath = join(base, "style.css");
137
+ const css = (await io.host.exists(cssPath)) ? await io.host.readText(cssPath) : undefined;
138
+ const findings = lintCustomSource({ js, html, ...(css === undefined ? {} : { css }) }, dir);
139
+ // A linked directory or file can point outside while still looking local.
140
+ const containment = sceneContainment(dirname(base), base, [...required, "style.css"]);
141
+ if (!containment.dirInside) {
142
+ findings.push({
143
+ severity: "error",
144
+ path: base,
145
+ message: "scene directory resolves outside its parent (symlink/junction)",
146
+ suggestion: "replace the link with a real directory",
147
+ });
148
+ }
149
+ for (const file of containment.escapedFiles) {
150
+ findings.push({
151
+ severity: "error",
152
+ path: `${dir}/${file}`,
153
+ message: "scene file resolves outside the scene directory (symlink/junction)",
154
+ suggestion: "replace the link with a real file",
155
+ });
156
+ }
157
+ const closure = await readRelativeClosure(js, async (path) => {
158
+ const check = sceneContainment(dirname(base), base, [path]);
159
+ if (!check.dirInside || check.escapedFiles.length > 0)
160
+ return undefined;
161
+ const full = join(base, path);
162
+ return (await io.host.exists(full)) ? io.host.readText(full) : undefined;
163
+ });
164
+ for (const specifier of closure.escaping) {
165
+ findings.push({
166
+ severity: "error",
167
+ path: "scene.js",
168
+ message: `relative import "${specifier}" escapes the scene directory`,
169
+ suggestion: "keep helpers inside the scene directory (./lib/format.js)",
170
+ });
171
+ }
172
+ for (const path of closure.missing) {
173
+ findings.push({
174
+ severity: "error",
175
+ path: "scene.js",
176
+ message: `cannot import "./${path}": no such file beside the scene`,
177
+ suggestion: "add the file, or inline the code",
178
+ });
179
+ }
180
+ if (closure.overflow) {
181
+ findings.push({
182
+ severity: "error",
183
+ path: "scene.js",
184
+ message: "scene helper closure is too large",
185
+ suggestion: "keep the scene to a few small helpers",
186
+ });
187
+ }
188
+ for (const helper of closure.files) {
189
+ findings.push(...lintCustomSource({ js: helper.text }, dir).map((finding) => ({
190
+ ...finding,
191
+ path: finding.path.replace(/scene\.js:/, `${helper.path}:`),
192
+ })));
193
+ }
194
+ io.stdout(formatFindings(findings));
195
+ io.stdout(summaryLine(findings.length, 0));
196
+ return findings.length === 0 ? EXIT.OK : EXIT.USAGE;
197
+ }
198
+ /** camelCase export name for a promoted scene type. */
199
+ const exportName = (type) => `${type.replace(/[^a-zA-Z0-9]+(.)/g, (_, char) => char.toUpperCase())}Scene`;
200
+ /**
201
+ * Files for a promoted catalog scene. A promoted scene is static: it keeps its markup
202
+ * and code verbatim, so the plugin only needs an empty strict schema and a body.
203
+ * Adding props is a human design step, deliberately not guessed here.
204
+ */
205
+ export const promoteFiles = (type, source) => {
206
+ const index = `import { z } from "zod";
207
+ import { defineScene } from "../../define-scene.ts";
208
+ import { readText } from "../../text-file.ts";
209
+
210
+ const FRAGMENT = readText("./fragment.html", import.meta.url);
211
+
212
+ /** Promoted from an authored scene. Give it props before it ships: configSchema is empty. */
213
+ export const ${exportName(type)} = defineScene({
214
+ type: "${type}",
215
+ configSchema: z.strictObject({}),
216
+ meta: { purpose: "TODO: one line for the catalog.", example: {} },
217
+ style: readText("./style.css", import.meta.url),
218
+ script: readText("./scene.js", import.meta.url),
219
+ render: () => ({ body: FRAGMENT }),
220
+ });
221
+ `;
222
+ const files = [
223
+ ["index.ts", index],
224
+ ["scene.js", source.js],
225
+ ["fragment.html", source.html],
226
+ ];
227
+ return source.css === undefined ? files : [...files, ["style.css", source.css]];
228
+ };
229
+ const CATALOG_DIR = join("packages", "scenes", "src", "scenes");
230
+ async function runPromote(args, io) {
231
+ const parsed = parseFlags("scene", USAGE, args, PROMOTE_FLAGS, io);
232
+ if (parsed.kind === "exit")
233
+ return parsed.code;
234
+ const [dir, ...extra] = parsed.positionals;
235
+ if (dir === undefined)
236
+ return usageError("scene", USAGE, "missing <dir>", io).code;
237
+ if (extra.length > 0)
238
+ return usageError("scene", USAGE, `unexpected "${extra[0]}"`, io).code;
239
+ const type = textFlag(parsed.flags, "as");
240
+ if (type === undefined)
241
+ return usageError("scene", USAGE, "missing --as <type>", io).code;
242
+ if (!/^[a-z][a-z0-9-]*$/.test(type)) {
243
+ return usageError("scene", USAGE, "--as must be a lowercase slug", io).code;
244
+ }
245
+ const source = resolve(dir);
246
+ const required = ["scene.js", "fragment.html"];
247
+ const missing = [];
248
+ for (const file of required) {
249
+ if (!(await io.host.exists(join(source, file))))
250
+ missing.push(file);
251
+ }
252
+ if (missing.length > 0) {
253
+ io.stderr(`mk scene promote: missing ${missing.join(", ")} in ${dir}\n`);
254
+ return EXIT.USAGE;
255
+ }
256
+ const target = resolve(CATALOG_DIR, type);
257
+ if (await io.host.exists(target)) {
258
+ return usageError("scene", USAGE, `${CATALOG_DIR}/${type} already exists`, io).code;
259
+ }
260
+ const js = await io.host.readText(join(source, "scene.js"));
261
+ const html = await io.host.readText(join(source, "fragment.html"));
262
+ const cssPath = join(source, "style.css");
263
+ const css = (await io.host.exists(cssPath)) ? await io.host.readText(cssPath) : undefined;
264
+ for (const [name, text] of promoteFiles(type, {
265
+ js,
266
+ html,
267
+ ...(css === undefined ? {} : { css }),
268
+ })) {
269
+ await io.host.writeText(join(target, name), text);
270
+ }
271
+ io.stdout(`promoted ${dir} -> ${CATALOG_DIR}/${type}\n`);
272
+ io.stdout(`register it in the catalog registry:\n`);
273
+ io.stdout(` import { ${exportName(type)} } from "./scenes/${type}/index.ts";\n`);
274
+ io.stdout(` ${exportName(type)},\n`);
275
+ io.stdout(`then run pnpm test (the catalog snapshot will ask for the new type).\n`);
276
+ return EXIT.OK;
277
+ }
278
+ const PROMOTE_FLAGS = { as: { type: "string" } };
279
+ 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;
@@ -0,0 +1,110 @@
1
+ import { listFlag, parseFlags, textFlag, usageError } from "../args.js";
2
+ import { editStoryboard } from "../storyboard/edit.js";
3
+ import { sceneLint, sceneNew, scenePromote } from "./author.js";
4
+ import { addScene, moveScene, removeScene } from "./ops.js";
5
+ const USAGE = `mk scene new <id> [--dir <path>] [--force]
6
+ mk scene lint <dir>
7
+ mk scene promote <dir> --as <type>
8
+ mk scene add <storyboard.json> <type> [--id x] [--after id] [--set k=v ...] [--duration s] [--over id] [--offset s]
9
+ mk scene remove <storyboard.json> <id>
10
+ mk scene move <storyboard.json> <id> (--after <id> | --first)
11
+
12
+ Edit the scene list of a storyboard; the result is validated and written atomically (never written invalid).
13
+ add insert a scene of <type> built from its minimal catalog example (see \`mk catalog <type>\`)
14
+ --id x scene id (default <type>-<n>) --after id insert after that scene (default: at the end)
15
+ --set k=v top-level prop; v is JSON when it parses (3, true, ["a","b"]), else text; repeatable
16
+ --duration s seconds (default: auto from the text)
17
+ --over id overlay types only: the base scene it covers (required), --offset s seconds into it (default 0)
18
+ remove delete a scene (an overlay still pointing at it makes the result invalid)
19
+ move reorder: land after another scene, or first
20
+ Nested prop edits: \`mk patch\`. Prints one line.
21
+ `;
22
+ const FLAGS = {
23
+ id: { type: "string" },
24
+ after: { type: "string" },
25
+ first: { type: "boolean" },
26
+ set: { type: "string", multiple: true },
27
+ duration: { type: "string" },
28
+ over: { type: "string" },
29
+ offset: { type: "string" },
30
+ };
31
+ const SUBCOMMANDS = ["add", "remove", "move"];
32
+ const isSub = (value) => SUBCOMMANDS.some((sub) => sub === value);
33
+ /** Flags each subcommand accepts; anything else is a usage error rather than silently ignored. */
34
+ const ALLOWED = {
35
+ add: ["id", "after", "set", "duration", "over", "offset"],
36
+ remove: [],
37
+ move: ["after", "first"],
38
+ };
39
+ const seconds = (flags, name) => {
40
+ const text = textFlag(flags, name);
41
+ if (text === undefined)
42
+ return undefined;
43
+ const value = Number(text);
44
+ return Number.isFinite(value) && value >= 0 ? value : "bad";
45
+ };
46
+ /** Subcommand, storyboard path, target and flags checked together; an exit code on bad usage. */
47
+ function parseTarget(args, io) {
48
+ const parsed = parseFlags("scene", USAGE, args, FLAGS, io);
49
+ if (parsed.kind === "exit")
50
+ return parsed.code;
51
+ const [sub, path, target, ...extra] = parsed.positionals;
52
+ const usage = (message) => usageError("scene", USAGE, message, io).code;
53
+ if (!isSub(sub))
54
+ return usage(`use one of: ${SUBCOMMANDS.join("|")}`);
55
+ const stray = Object.keys(parsed.flags).find((flag) => !ALLOWED[sub].includes(flag));
56
+ if (stray !== undefined)
57
+ return usage(`--${stray} does not apply to ${sub}`);
58
+ if (path === undefined)
59
+ return usage("missing <storyboard.json>");
60
+ if (target === undefined)
61
+ return usage(sub === "add" ? "missing <type>" : "missing <id>");
62
+ if (extra.length > 0)
63
+ return usage(`unexpected argument "${extra[0]}"`);
64
+ return { sub, path, target, flags: parsed.flags };
65
+ }
66
+ function runMove(request, io) {
67
+ const after = textFlag(request.flags, "after");
68
+ if ((after === undefined) === (request.flags.first !== true)) {
69
+ return usageError("scene", USAGE, "move needs exactly one of --after <id> or --first", io).code;
70
+ }
71
+ return editStoryboard("scene", request.path, io, (doc) => moveScene(doc, { id: request.target, after }));
72
+ }
73
+ function runAdd(request, io) {
74
+ const { flags } = request;
75
+ const duration = seconds(flags, "duration");
76
+ const offset = seconds(flags, "offset");
77
+ if (duration === "bad" || offset === "bad" || duration === 0) {
78
+ return usageError("scene", USAGE, "--duration and --offset need positive seconds", io).code;
79
+ }
80
+ return editStoryboard("scene", request.path, io, (doc) => addScene(doc, {
81
+ type: request.target,
82
+ id: textFlag(flags, "id"),
83
+ after: textFlag(flags, "after"),
84
+ sets: listFlag(flags, "set"),
85
+ duration,
86
+ over: textFlag(flags, "over"),
87
+ offset,
88
+ }));
89
+ }
90
+ export const sceneCommand = {
91
+ name: "scene",
92
+ summary: "add, remove, move, scaffold or lint scenes",
93
+ async run(args, io) {
94
+ const head = args[0];
95
+ if (head === "new")
96
+ return sceneNew(args.slice(1), io);
97
+ if (head === "lint")
98
+ return sceneLint(args.slice(1), io);
99
+ if (head === "promote")
100
+ return scenePromote(args.slice(1), io);
101
+ const request = parseTarget(args, io);
102
+ if (typeof request === "number")
103
+ return request;
104
+ if (request.sub === "move")
105
+ return runMove(request, io);
106
+ if (request.sub === "add")
107
+ return runAdd(request, io);
108
+ return editStoryboard("scene", request.path, io, (doc) => removeScene(doc, request.target));
109
+ },
110
+ };
@@ -0,0 +1,23 @@
1
+ import { type Edit } from "../storyboard/edit.ts";
2
+ export interface AddRequest {
3
+ type: string;
4
+ id?: string | undefined;
5
+ after?: string | undefined;
6
+ /** `k=v` pairs; v is JSON when it parses (numbers, true, [..]), else a string. */
7
+ sets: readonly string[];
8
+ duration?: number | undefined;
9
+ /** Overlay scenes only. */
10
+ over?: string | undefined;
11
+ offset?: number | undefined;
12
+ }
13
+ /** Inserts a scene built from the catalog's minimal example for `type`. */
14
+ export declare function addScene(doc: unknown, request: AddRequest): Edit;
15
+ /** Removes a scene by id; an overlay still pointing at it makes the validator refuse the result. */
16
+ export declare function removeScene(doc: unknown, id: string): Edit;
17
+ export interface MoveRequest {
18
+ id: string;
19
+ /** Scene id to land after; `undefined` moves to the start. */
20
+ after: string | undefined;
21
+ }
22
+ /** Moves a scene to follow another one (or to the start). */
23
+ export declare function moveScene(doc: unknown, request: MoveRequest): Edit;
@@ -0,0 +1,151 @@
1
+ import { sceneDocs } from "@anvpx/motion-build";
2
+ import { forbiddenKeys } from "@anvpx/motion-schema";
3
+ import { round1 } from "../storyboard/edit.js";
4
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
+ const sceneList = (doc) => {
6
+ const scenes = isRecord(doc) ? doc.scenes : undefined;
7
+ return Array.isArray(scenes) ? scenes : undefined;
8
+ };
9
+ const indexOfId = (scenes, id) => scenes.findIndex((scene) => scene.id === id);
10
+ const knownIds = (scenes) => scenes.map((s) => String(s.id)).join(", ");
11
+ const NO_SCENES = { ok: false, message: "the storyboard has no scenes array" };
12
+ const OVERLAY_SECONDS = 3;
13
+ const jsonOrText = (text) => {
14
+ try {
15
+ return JSON.parse(text);
16
+ }
17
+ catch {
18
+ return text;
19
+ }
20
+ };
21
+ /** `k=v` pairs onto the props; keys are top-level prop names (use `mk patch` for nested edits). */
22
+ function applySets(props, sets) {
23
+ for (const pair of sets) {
24
+ const cut = pair.indexOf("=");
25
+ const key = cut > 0 ? pair.slice(0, cut) : "";
26
+ if (key === "")
27
+ return `--set "${pair}" needs k=v`;
28
+ const [problem] = forbiddenKeys({ [key]: 0 }, []);
29
+ if (problem !== undefined)
30
+ return `--set ${key}: ${problem.message}`;
31
+ props[key] = jsonOrText(pair.slice(cut + 1));
32
+ }
33
+ return undefined;
34
+ }
35
+ const freeId = (type, scenes) => {
36
+ const taken = new Set(scenes.map((scene) => scene.id));
37
+ for (let n = 1;; n++)
38
+ if (!taken.has(`${type}-${n}`))
39
+ return `${type}-${n}`;
40
+ };
41
+ /** The entry's timing fields: `duration`, and `layer`/`over`/`offset` for overlays. */
42
+ function timingOf(overlay, request) {
43
+ const duration = request.duration ?? (overlay ? OVERLAY_SECONDS : undefined);
44
+ return {
45
+ ...(duration === undefined ? {} : { duration }),
46
+ ...(overlay ? { layer: "overlay", over: request.over, offset: request.offset ?? 0 } : {}),
47
+ };
48
+ }
49
+ /** The scene entry for `type`, or the reason it cannot be built. */
50
+ function buildEntry(scenes, request) {
51
+ const docs = sceneDocs();
52
+ const found = docs.find((entry) => entry.type === request.type);
53
+ if (found === undefined) {
54
+ const known = docs.map((d) => d.type).join("|");
55
+ return { ok: false, message: `unknown scene type "${request.type}"; use one of: ${known}` };
56
+ }
57
+ const id = request.id ?? freeId(request.type, scenes);
58
+ if (indexOfId(scenes, id) !== -1)
59
+ return { ok: false, message: `scene id "${id}" already exists` };
60
+ const props = structuredClone(found.example);
61
+ const problem = applySets(props, request.sets);
62
+ if (problem !== undefined)
63
+ return { ok: false, message: problem };
64
+ const overlay = found.layer === "overlay";
65
+ if (overlay && (request.over === undefined || indexOfId(scenes, request.over) === -1)) {
66
+ const known = knownIds(scenes);
67
+ return {
68
+ ok: false,
69
+ message: `${request.type} is an overlay: give --over <base scene id> (one of: ${known})`,
70
+ };
71
+ }
72
+ return { ok: true, entry: { id, type: request.type, props, ...timingOf(overlay, request) } };
73
+ }
74
+ /** Index to insert at: after `--after`, else the end; undefined when that scene does not exist. */
75
+ function insertionIndex(scenes, after) {
76
+ if (after === undefined)
77
+ return scenes.length;
78
+ const at = indexOfId(scenes, after);
79
+ return at === -1 ? undefined : at + 1;
80
+ }
81
+ /** Inserts a scene built from the catalog's minimal example for `type`. */
82
+ export function addScene(doc, request) {
83
+ const scenes = sceneList(doc);
84
+ if (scenes === undefined)
85
+ return NO_SCENES;
86
+ const built = buildEntry(scenes, request);
87
+ if (!built.ok)
88
+ return built;
89
+ const at = insertionIndex(scenes, request.after);
90
+ if (at === undefined) {
91
+ return {
92
+ ok: false,
93
+ message: `--after "${request.after}": no such scene (have: ${knownIds(scenes)})`,
94
+ };
95
+ }
96
+ const next = structuredClone(doc);
97
+ next.scenes.splice(at, 0, built.entry);
98
+ return {
99
+ ok: true,
100
+ value: next,
101
+ lines: (report) => [
102
+ `scene add ok | ${built.entry.id} (${request.type}) at ${at + 1}/${next.scenes.length} | total ${round1(report.total ?? 0)}s | ${report.errors.length} errors, ${report.warnings.length} warnings`,
103
+ ],
104
+ };
105
+ }
106
+ /** Removes a scene by id; an overlay still pointing at it makes the validator refuse the result. */
107
+ export function removeScene(doc, id) {
108
+ const scenes = sceneList(doc);
109
+ if (scenes === undefined)
110
+ return NO_SCENES;
111
+ const at = indexOfId(scenes, id);
112
+ if (at === -1)
113
+ return { ok: false, message: `no scene "${id}" (have: ${knownIds(scenes)})` };
114
+ const next = structuredClone(doc);
115
+ next.scenes.splice(at, 1);
116
+ return {
117
+ ok: true,
118
+ value: next,
119
+ lines: (report) => [
120
+ `scene remove ok | ${id} | scenes ${next.scenes.length} | total ${round1(report.total ?? 0)}s | ${report.errors.length} errors, ${report.warnings.length} warnings`,
121
+ ],
122
+ };
123
+ }
124
+ /** Moves a scene to follow another one (or to the start). */
125
+ export function moveScene(doc, request) {
126
+ const scenes = sceneList(doc);
127
+ if (scenes === undefined)
128
+ return NO_SCENES;
129
+ const from = indexOfId(scenes, request.id);
130
+ if (from === -1)
131
+ return { ok: false, message: `no scene "${request.id}" (have: ${knownIds(scenes)})` };
132
+ if (request.after === request.id)
133
+ return { ok: false, message: "cannot move a scene after itself" };
134
+ const next = structuredClone(doc);
135
+ const [moved] = next.scenes.splice(from, 1);
136
+ const anchor = request.after === undefined ? -1 : indexOfId(next.scenes, request.after);
137
+ if (request.after !== undefined && anchor === -1) {
138
+ return {
139
+ ok: false,
140
+ message: `--after "${request.after}": no such scene (have: ${knownIds(scenes)})`,
141
+ };
142
+ }
143
+ next.scenes.splice(anchor + 1, 0, moved);
144
+ return {
145
+ ok: true,
146
+ value: next,
147
+ lines: (report) => [
148
+ `scene move ok | ${request.id} now at ${anchor + 2}/${next.scenes.length} | total ${round1(report.total ?? 0)}s | ${report.errors.length} errors, ${report.warnings.length} warnings`,
149
+ ],
150
+ };
151
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const stateCommand: Command;