dsh-claude-motion 2.8.2 → 2.9.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.
package/lib/index.js CHANGED
@@ -1,21 +1,28 @@
1
- // dsh-claude-motion 2.8.2: built from baoyu-design.
1
+ // dsh-claude-motion 2.9.1: built from baoyu-design.
2
2
  var define_PLUGIN_default = { name: "claude-motion", provider: "dsh-claude-motion", skills: [{ name: "claude-motion", description: "Use this for a local Motion film: a short animated video (10–30 s unless asked for more, 900 s at most), such as an explainer, product walkthrough or UI demo, launch teaser, logo sting, social clip, animated chart or title sequence, that plays in the bundled Motion player and exports from there to MP4 or GIF. A narrated one carries a voice track: a take a scene, made here with a speech provider of the user's or recorded by them, spoken and captioned by the player, the motion timed to its words. Everything on screen is code, a pure function of time, written scene by scene into the film's own store with this skill's script while the user watches it fill in. Read it when creating, continuing, finalizing or revising one. Not for editing real footage, subtitles or audio (that is a video editor), a still picture, a slide deck or a static HTML page.", scriptAlias: "A", comments: true, requests: true }] };
3
3
 
4
4
  import { spawn as spawn2 } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
6
- import { statSync } from "node:fs";
6
+ import { statSync as statSync2 } from "node:fs";
7
7
  import { readFile } from "node:fs/promises";
8
- import { basename as basename2, delimiter, dirname, isAbsolute, join as join2, sep } from "node:path";
8
+ import { basename as basename2, delimiter, dirname as dirname2, isAbsolute, join as join2, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  import { spawn } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
- import { basename, join } from "node:path";
15
+ import { basename, dirname, join, parse } from "node:path";
16
16
 
17
- var listPath = (skill) => `/api/baoyu-design.${skill}.artifacts`;
18
- var openPath = (skill) => `/api/baoyu-design.${skill}.artifacts.open`;
17
+ var base = (skill) => `/api/baoyu-design.${skill}.artifacts`;
18
+ var listPath = base;
19
+ var openPath = (skill) => `${base(skill)}.open`;
20
+ var previewPath = (skill) => `${base(skill)}.preview`;
21
+ var releasePath = (skill) => `${base(skill)}.release`;
22
+ var thumbnailPath = (skill) => `${base(skill)}.thumbnail`;
23
+ var updatePath = (skill) => `${base(skill)}.update`;
24
+ var deletePath = (skill) => `${base(skill)}.delete`;
25
+ var restorePath = (skill) => `${base(skill)}.restore`;
19
26
 
20
27
  var TITLES = {
21
28
  "claude-slides": ["project/deck.json", "title"],
@@ -26,6 +33,18 @@ var TITLES = {
26
33
  "claude-motion": ["db/film/meta.json", "title"]
27
34
  };
28
35
  var OPEN_TIMEOUT_MS = 3e4;
36
+ var TITLE_MAX = 200;
37
+ var EDITOR_FILES = [".artifact-server.json", ".artifact-server.log", ".sharing.json", ".gitignore", ".DS_Store", "comments.json", "history", "exports"];
38
+ var FOLDER = {
39
+ "claude-slides": ["project"],
40
+ "claude-design": ["project"],
41
+ "claude-design-system": ["project"],
42
+ "claude-dashboard": ["db", "_blobs", "src"],
43
+ "claude-docs": ["doc.json", "tabs", "_blobs", ".sessions.json"],
44
+ "claude-motion": ["db", "_blobs", "src"]
45
+ };
46
+ var NOT_CONTENT = /* @__PURE__ */ new Set([...EDITOR_FILES, ".sessions.json", ".git", "node_modules"]);
47
+ var STAMP_MAX_ENTRIES = 2e4;
29
48
  function dshHome() {
30
49
  const set = process.env.DSH_HOME?.trim();
31
50
  return set ? set : join(homedir(), ".dsh");
@@ -33,8 +52,15 @@ function dshHome() {
33
52
  function artifactsDir() {
34
53
  return join(dshHome(), "baoyu-design", "artifacts");
35
54
  }
55
+ var folderHash = (root) => createHash("sha256").update(root).digest("hex").slice(0, 24);
36
56
  function recordFile(root) {
37
- return join(artifactsDir(), `${createHash("sha256").update(root).digest("hex").slice(0, 24)}.json`);
57
+ return join(artifactsDir(), `${folderHash(root)}.json`);
58
+ }
59
+ function thumbnailFile(root) {
60
+ return join(dshHome(), "baoyu-design", "thumbnails", `${folderHash(root)}.jpg`);
61
+ }
62
+ function trashDir() {
63
+ return join(dshHome(), "baoyu-design", "trash");
38
64
  }
39
65
  function artifactTitle(skill, root) {
40
66
  const at = TITLES[skill];
@@ -59,9 +85,20 @@ function readRecord(file) {
59
85
  return null;
60
86
  }
61
87
  }
88
+ function writeWhole(file, data) {
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
91
+ writeFileSync(tmp, data);
92
+ renameSync(tmp, file);
93
+ }
94
+ function writeRecord(record) {
95
+ writeWhole(recordFile(record.root), `${JSON.stringify(record, null, 2)}
96
+ `);
97
+ }
62
98
  function recordOpened(skill, plugin, sessionId, root, now = /* @__PURE__ */ new Date()) {
63
99
  const file = recordFile(root);
64
100
  const before = readRecord(file);
101
+ const mine = before?.root === root ? before : null;
65
102
  const record = {
66
103
  v: 1,
67
104
  root,
@@ -70,15 +107,55 @@ function recordOpened(skill, plugin, sessionId, root, now = /* @__PURE__ */ new
70
107
  sessionId,
71
108
  title: artifactTitle(skill, root) ?? before?.title ?? basename(root),
72
109
  openedAt: now.toISOString(),
73
- firstOpenedAt: before?.root === root ? before.firstOpenedAt ?? before.openedAt : now.toISOString()
110
+ firstOpenedAt: mine ? mine.firstOpenedAt ?? mine.openedAt : now.toISOString(),
111
+ ...mine?.pinned ? { pinned: true } : {},
112
+ ...mine?.thumbStamp ? { thumbStamp: mine.thumbStamp } : {}
74
113
  };
75
- mkdirSync(artifactsDir(), { recursive: true });
76
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
77
- writeFileSync(tmp, `${JSON.stringify(record, null, 2)}
78
- `);
79
- renameSync(tmp, file);
114
+ writeRecord(record);
80
115
  return record;
81
116
  }
117
+ function contentStamp(root) {
118
+ let newest = 0;
119
+ let entries = 0;
120
+ const walk = (dir, top) => {
121
+ let names;
122
+ try {
123
+ names = readdirSync(dir);
124
+ } catch {
125
+ return;
126
+ }
127
+ for (const name2 of names) {
128
+ if (++entries > STAMP_MAX_ENTRIES) return;
129
+ if (name2.endsWith(".tmp") || top && NOT_CONTENT.has(name2)) continue;
130
+ const path = join(dir, name2);
131
+ try {
132
+ const stat = statSync(path);
133
+ if (stat.isDirectory()) walk(path, false);
134
+ else if (stat.mtimeMs > newest) newest = stat.mtimeMs;
135
+ } catch {
136
+ }
137
+ }
138
+ };
139
+ walk(root, true);
140
+ return newest > 0 ? new Date(newest).toISOString() : "";
141
+ }
142
+ function isShared(root) {
143
+ try {
144
+ return JSON.parse(readFileSync(join(root, ".sharing.json"), "utf8")).lan === true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ function isDeletable(skill, root) {
150
+ const own = FOLDER[skill];
151
+ if (own === void 0) return false;
152
+ if (root === homedir() || root === parse(root).root) return false;
153
+ try {
154
+ return readdirSync(root).every((name2) => own.includes(name2) || EDITOR_FILES.includes(name2) || name2.endsWith(".tmp"));
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
82
159
  function listArtifacts() {
83
160
  let names;
84
161
  try {
@@ -97,7 +174,13 @@ function listArtifacts() {
97
174
  plugin: record.plugin,
98
175
  sessionId: record.sessionId,
99
176
  title: artifactTitle(record.skill, record.root) ?? record.title,
100
- openedAt: record.openedAt
177
+ openedAt: record.openedAt,
178
+ firstOpenedAt: record.firstOpenedAt ?? record.openedAt,
179
+ updatedAt: contentStamp(record.root) || record.openedAt,
180
+ pinned: record.pinned === true,
181
+ shared: isShared(record.root),
182
+ deletable: isDeletable(record.skill, record.root),
183
+ thumbnail: record.thumbStamp !== void 0 && existsSync(thumbnailFile(record.root)) ? record.thumbStamp : null
101
184
  });
102
185
  }
103
186
  return views.sort((a, b) => a.openedAt < b.openedAt ? 1 : a.openedAt > b.openedAt ? -1 : 0);
@@ -114,31 +197,109 @@ function scriptEnv(node) {
114
197
  }
115
198
  return env;
116
199
  }
117
- function runOpen(node, script, root) {
200
+ function runScript(node, script, args, input) {
118
201
  return new Promise((resolve, reject) => {
119
- const child = spawn(node.path, [script, "open", root], { env: scriptEnv(node), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
202
+ const child = spawn(node.path, [script, ...args], { env: scriptEnv(node), stdio: [input === void 0 ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true });
120
203
  let stdout = "";
121
204
  let stderr = "";
122
205
  const timer = setTimeout(() => {
123
206
  child.kill();
124
- reject(new Error("the editor did not start in time"));
207
+ reject(new Error(`${args[0]} did not finish in time`));
125
208
  }, OPEN_TIMEOUT_MS);
126
209
  child.stdout?.setEncoding("utf8").on("data", (chunk) => stdout += chunk);
127
210
  child.stderr?.setEncoding("utf8").on("data", (chunk) => stderr += chunk);
211
+ child.stdin?.end(input);
128
212
  child.on("error", (error) => {
129
213
  clearTimeout(timer);
130
214
  reject(error);
131
215
  });
132
216
  child.on("close", (code) => {
133
217
  clearTimeout(timer);
134
- const out = firstJson(stdout);
135
- if (code === 0 && typeof out?.url === "string") return resolve({ url: out.url, reused: out.reused === true });
136
- const failure = firstJson(stderr);
137
- const message = typeof failure?.message === "string" ? failure.message : stderr.trim() || `open exited with ${code ?? "a signal"}`;
218
+ if (code === 0) return resolve(firstJson(stdout));
219
+ const failure = firstJson(stderr) ?? firstJson(stdout);
220
+ const message = typeof failure?.message === "string" ? failure.message : stderr.trim() || `${args[0]} exited with ${code ?? "a signal"}`;
138
221
  reject(new Error(message));
139
222
  });
140
223
  });
141
224
  }
225
+ async function runOpen(node, script, root) {
226
+ const out = await runScript(node, script, ["open", root]);
227
+ if (typeof out?.url !== "string") throw new Error("open printed no address");
228
+ return { url: out.url, reused: out.reused === true };
229
+ }
230
+ async function renameArtifact(node, script, record, title) {
231
+ const { root, skill } = record;
232
+ const at = TITLES[skill];
233
+ if (at === void 0) throw new Error(`no title to change for ${skill}`);
234
+ if (skill === "claude-dashboard" || skill === "claude-motion") {
235
+ const path = at[0].replace(/^db\//, "").replace(/\.json$/, "");
236
+ await runScript(node, script, ["write-db", root, "-"], JSON.stringify([{ op: "update", path, data: { [at[1]]: title } }]));
237
+ return;
238
+ }
239
+ const file = join(root, at[0]);
240
+ const index = JSON.parse(readFileSync(file, "utf8"));
241
+ if (skill === "claude-docs") {
242
+ await runScript(node, script, ["update", root, JSON.stringify({ ref: { object: "project", id: String(index.id) }, payload: { name: title } })]);
243
+ return;
244
+ }
245
+ writeWhole(file, `${JSON.stringify({ ...index, [at[1]]: title }, null, 2)}
246
+ `);
247
+ }
248
+ function cleanTitle(raw) {
249
+ if (typeof raw !== "string") return null;
250
+ const title = raw.replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
251
+ return title || null;
252
+ }
253
+ function moveFolder(from, to) {
254
+ mkdirSync(dirname(to), { recursive: true });
255
+ try {
256
+ renameSync(from, to);
257
+ } catch (error) {
258
+ if (error.code !== "EXDEV") throw error;
259
+ cpSync(from, to, { recursive: true, preserveTimestamps: true });
260
+ rmSync(from, { recursive: true, force: true });
261
+ }
262
+ }
263
+ var TRASH_ID = /^[0-9a-f]{24}-\d+$/;
264
+ function trashArtifact(record) {
265
+ const id = `${folderHash(record.root)}-${Date.now()}`;
266
+ const entry = join(trashDir(), id);
267
+ try {
268
+ for (const name2 of readdirSync(trashDir())) {
269
+ if (TRASH_ID.test(name2) && !existsSync(join(trashDir(), name2, "folder"))) rmSync(join(trashDir(), name2), { recursive: true, force: true });
270
+ }
271
+ } catch {
272
+ }
273
+ mkdirSync(entry, { recursive: true });
274
+ writeFileSync(join(entry, "record.json"), `${JSON.stringify(record, null, 2)}
275
+ `);
276
+ const thumbnail = thumbnailFile(record.root);
277
+ if (existsSync(thumbnail)) renameSync(thumbnail, join(entry, "thumbnail.jpg"));
278
+ const moved = isDeletable(record.skill, record.root);
279
+ const path = join(entry, "folder", basename(record.root));
280
+ if (moved) moveFolder(record.root, path);
281
+ rmSync(recordFile(record.root), { force: true });
282
+ return { trash: id, moved, path: moved ? path : record.root };
283
+ }
284
+ function restoreArtifact(id) {
285
+ if (!TRASH_ID.test(id)) throw new Error("not a trash entry");
286
+ const entry = join(trashDir(), id);
287
+ const record = readRecord(join(entry, "record.json"));
288
+ if (record === null) throw new Error("nothing to put back");
289
+ const folder = join(entry, "folder", basename(record.root));
290
+ if (existsSync(folder)) {
291
+ if (existsSync(record.root)) throw new Error(`${record.root} is taken`);
292
+ moveFolder(folder, record.root);
293
+ }
294
+ writeRecord(record);
295
+ const thumbnail = join(entry, "thumbnail.jpg");
296
+ if (existsSync(thumbnail)) {
297
+ mkdirSync(dirname(thumbnailFile(record.root)), { recursive: true });
298
+ renameSync(thumbnail, thumbnailFile(record.root));
299
+ }
300
+ rmSync(entry, { recursive: true, force: true });
301
+ return record;
302
+ }
142
303
  function firstJson(text) {
143
304
  const start = text.indexOf("{");
144
305
  const end = text.lastIndexOf("}");
@@ -177,6 +338,129 @@ function registerRoutes(routes, skill, opened) {
177
338
  }
178
339
  }
179
340
  });
341
+ const rootOf = (request) => new URL(request.url).searchParams.get("root") ?? "";
342
+ const mine = (root) => {
343
+ const record = root ? findArtifact(root) : null;
344
+ return record?.skill === skill.name && existsSync(root) ? record : null;
345
+ };
346
+ const notMine = () => json({ error: "not a recorded artifact of this skill" }, 404);
347
+ const started = /* @__PURE__ */ new Map();
348
+ routes.register({
349
+ path: previewPath(skill.name),
350
+ methods: ["POST"],
351
+ requestBody: "buffered",
352
+ fetch: async (request) => {
353
+ const root = rootOf(request);
354
+ const record = mine(root);
355
+ if (record === null) return notMine();
356
+ try {
357
+ const { url, reused } = await runOpen(skill.node, skill.script, root);
358
+ if (!reused) started.set(root, record.openedAt);
359
+ return json({ url, reused });
360
+ } catch (error) {
361
+ return json({ error: error.message }, 500);
362
+ }
363
+ }
364
+ });
365
+ routes.register({
366
+ path: releasePath(skill.name),
367
+ methods: ["POST"],
368
+ requestBody: "buffered",
369
+ fetch: async (request) => {
370
+ const root = rootOf(request);
371
+ const since = started.get(root);
372
+ started.delete(root);
373
+ const record = mine(root);
374
+ if (record === null) return notMine();
375
+ if (since === void 0 || since !== record.openedAt || new URL(request.url).searchParams.get("keep") === "1") return json({ stopped: false });
376
+ try {
377
+ await runScript(skill.node, skill.script, ["stop", root]);
378
+ return json({ stopped: true });
379
+ } catch (error) {
380
+ return json({ error: error.message }, 500);
381
+ }
382
+ }
383
+ });
384
+ routes.register({
385
+ path: thumbnailPath(skill.name),
386
+ methods: ["GET", "POST"],
387
+ requestBody: "buffered",
388
+ fetch: async (request) => {
389
+ const root = rootOf(request);
390
+ const record = root ? findArtifact(root) : null;
391
+ if (record === null) return json({ error: "not a recorded artifact" }, 404);
392
+ if (request.method === "GET") {
393
+ try {
394
+ const bytes2 = readFileSync(thumbnailFile(root));
395
+ return new Response(bytes2, { headers: { "content-type": "image/jpeg", "cache-control": "private, max-age=31536000, immutable" } });
396
+ } catch {
397
+ return json({ error: "no thumbnail yet" }, 404);
398
+ }
399
+ }
400
+ const bytes = new Uint8Array(await request.arrayBuffer());
401
+ if (bytes.length < 3 || bytes[0] !== 255 || bytes[1] !== 216) return json({ error: "a thumbnail is a JPEG" }, 400);
402
+ writeWhole(thumbnailFile(root), bytes);
403
+ const stamp = contentStamp(root) || record.openedAt;
404
+ writeRecord({ ...findArtifact(root) ?? record, thumbStamp: stamp });
405
+ return json({ thumbnail: stamp, updatedAt: stamp });
406
+ }
407
+ });
408
+ routes.register({
409
+ path: updatePath(skill.name),
410
+ methods: ["POST"],
411
+ requestBody: "buffered",
412
+ fetch: async (request) => {
413
+ const root = rootOf(request);
414
+ const record = mine(root);
415
+ if (record === null) return notMine();
416
+ const patch = await request.json().catch(() => null);
417
+ if (patch === null || typeof patch !== "object") return json({ error: "not a JSON object" }, 400);
418
+ try {
419
+ if (patch.title !== void 0) {
420
+ const title = cleanTitle(patch.title);
421
+ if (title === null) return json({ error: "a title cannot be empty" }, 400);
422
+ await renameArtifact(skill.node, skill.script, record, title);
423
+ }
424
+ const now = findArtifact(root) ?? record;
425
+ const next = { ...now, title: artifactTitle(skill.name, root) ?? now.title };
426
+ if (patch.pinned === true) next.pinned = true;
427
+ else if (patch.pinned === false) delete next.pinned;
428
+ writeRecord(next);
429
+ return json({ title: next.title, pinned: next.pinned === true });
430
+ } catch (error) {
431
+ return json({ error: error.message }, 500);
432
+ }
433
+ }
434
+ });
435
+ routes.register({
436
+ path: deletePath(skill.name),
437
+ methods: ["POST"],
438
+ requestBody: "buffered",
439
+ fetch: async (request) => {
440
+ const root = rootOf(request);
441
+ const record = mine(root);
442
+ if (record === null) return notMine();
443
+ try {
444
+ if (isDeletable(skill.name, root)) await runScript(skill.node, skill.script, ["stop", root]).catch(() => null);
445
+ started.delete(root);
446
+ return json(trashArtifact(record));
447
+ } catch (error) {
448
+ return json({ error: error.message }, 500);
449
+ }
450
+ }
451
+ });
452
+ routes.register({
453
+ path: restorePath(skill.name),
454
+ methods: ["POST"],
455
+ requestBody: "buffered",
456
+ fetch: (request) => {
457
+ try {
458
+ return Promise.resolve(json({ root: restoreArtifact(new URL(request.url).searchParams.get("trash") ?? "").root }));
459
+ } catch (error) {
460
+ return Promise.resolve(json({ error: error.message }, 400));
461
+ }
462
+ }
463
+ });
180
464
  }
181
465
 
182
466
  var PLUGIN = define_PLUGIN_default;
@@ -186,7 +470,7 @@ var BUNDLED_SKILL_RANK = 600;
186
470
  var packageRoot = fileURLToPath(new URL("../", import.meta.url));
187
471
  function isFile(path) {
188
472
  try {
189
- return statSync(path).isFile();
473
+ return statSync2(path).isFile();
190
474
  } catch {
191
475
  return false;
192
476
  }
@@ -204,10 +488,10 @@ function resolveNode(config = {}) {
204
488
  const roots = [];
205
489
  const resourcesPath = process.resourcesPath;
206
490
  if (typeof resourcesPath === "string") roots.push(resourcesPath);
207
- let dir = dirname(process.execPath);
491
+ let dir = dirname2(process.execPath);
208
492
  for (let i = 0; i < 6; i++) {
209
493
  roots.push(join2(dir, "Resources"), join2(dir, "resources"));
210
- const parent = dirname(dir);
494
+ const parent = dirname2(dir);
211
495
  if (parent === dir) break;
212
496
  dir = parent;
213
497
  }
@@ -341,7 +625,7 @@ function bashStdout(result) {
341
625
  return text || null;
342
626
  }
343
627
  function openedEditor(script, exec, result) {
344
- if (exec.name !== "bash" || exec.agent === void 0 || result.isError) return null;
628
+ if (exec.name !== "bash" && exec.name !== "pwsh" || exec.agent === void 0 || result.isError) return null;
345
629
  const args = exec.arguments;
346
630
  const command = args?.command;
347
631
  if (typeof command !== "string" || args?.run_in_background === true) return null;
@@ -355,7 +639,7 @@ function openedEditor(script, exec, result) {
355
639
  function ranScript(script, command, stop) {
356
640
  if (typeof stop !== "string") return command.includes(script);
357
641
  if (stop.includes(script)) return true;
358
- const folder = basename2(dirname(dirname(script)));
642
+ const folder = basename2(dirname2(dirname2(script)));
359
643
  return new RegExp(`[/\\\\]${folder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[/\\\\]scripts[/\\\\]artifact\\.mjs`).test(stop);
360
644
  }
361
645
  var bound = (text) => text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX - 1)}…`;
@@ -554,7 +838,7 @@ function apply(ctx, config = {}) {
554
838
  const skill = skills.get(candidate.name);
555
839
  if (skill === void 0) throw new Error(`${PLUGIN.name}: no skill named ${candidate.name}`);
556
840
  const raw = await readFile(candidate.path, { encoding: "utf8", signal: options?.signal });
557
- const directory = dirname(candidate.path);
841
+ const directory = dirname2(candidate.path);
558
842
  return { ...summary, content: skillBody(raw, candidate.path) + runtimeSection(skill, directory, node) };
559
843
  }
560
844
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-claude-motion",
3
- "version": "2.8.2",
3
+ "version": "2.9.1",
4
4
  "description": "DeepSeek Harness plugin: the claude-motion skill from baoyu-design. The agent writes short animated films as code, scene by scene, and plays them in a local Motion player that exports MP4 or GIF.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -71,7 +71,7 @@ below, `A` is `<this skill's folder>/scripts/artifact.mjs`.
71
71
  | `node $A score <root> [--levels]` | What `score.js` plays, no page open: `silent` (drum and effect names the synth doesn't know: they play nothing), `marimba` (pitched patches it doesn't know), tempo and span, `beds` (the recorded music it plays, each `playing` or not) and, when it sets `S.tempo`, `cuts` (each scene's start as a beat of that grid, `off` when it misses one); then the sound as the export renders it: loudness, the voice's level, the music alone in each gap between takes, and `quiet` seconds. Writes nothing. |
72
72
  | `node $A music <file> [--bpm n] [--step s]` | A music file measured, no film needed: its seconds, the near silence it starts (`lead`) and ends with, loudness and peak, its tempo and where its first beat falls (estimated, with how `sure`; `--bpm` gives it), and its level every 4 s. Recorded music, below. |
73
73
  | `node $A arrange --in <music> --bpm n [--t0 s] --map <file \| json \| -> [--out bed.wav]` | A track cut to the film: pieces of it, counted in its bars, placed at film seconds, each cut crossfaded on the beat, as one file to upload as the bed. Recorded music, below. |
74
- | `node $A open <root> [--port n] [--lan]` | Starts the player in the background and returns `{"url", "pid", "stop", …}`. All the films open on this computer share one server and port (4760, the next free port up when that is taken; `--port n` looks for it at n and starts from n, `--port 0` runs a server of its own on any port); `url` is this film's own page, whose path, `/motion/<id>/`, stays the same from one start to the next. Run again, it reuses the one running. `--lan`: also on the local network (see Sharing). |
74
+ | `node $A open <root> [--port n] [--lan]` | Starts the player in the background and returns `{"url", "pid", "stop", …}`. All the films open on this computer share one server and port (4760, the next free port up when that is taken; `--port n` looks for it at n and starts from n, `--port 0` runs a server of its own on any port); `url` is this film's own page, whose path, `/motion/<id>/`, stays the same from one start to the next. Run again, it reuses the one running. `--lan`: also on the local network (see Sharing). `NETWORK_BLOCKED` from `open` means your commands run in a sandbox without local network. Run `open`, `share` and `comments <root> --wait` with local network allowed, outside the sandbox, asking for that permission if your commands need approval; in the sandbox `comments --wait` says `NO_SERVER` even while the page is open. Commands that only read and write files work in the sandbox. |
75
75
  | `node $A serve <root> [--port n] [--lan]` / `stop <root>` | The same server in the foreground / takes this folder off the server, which ends with the last one. |
76
76
  | `node $A share <root>` | Opens it to the local network and prints a link per role; `--disable` / `--enable` / `--reset <role>`, `--role <guest>=<role>`, `--off`. See Sharing. |
77
77
  | `node $A comments <root>` / `comment-reply` / `comment-resolve` | The comments people leave in the open player, and your answers: Comments, below. |
@@ -79,6 +79,26 @@ var PortsTaken = class extends Error {
79
79
  this.to = to;
80
80
  }
81
81
  };
82
+ var NetworkBlocked = class extends Error {
83
+ code = "NETWORK_BLOCKED";
84
+ constructor() {
85
+ super(
86
+ "A sandbox blocks local network here (127.0.0.1), so the server cannot start and no port is really in use. Run this same command with local network allowed, outside the sandbox, asking for that permission if your commands need approval."
87
+ );
88
+ }
89
+ };
90
+ function loopbackBlocked(port) {
91
+ return new Promise((resolve) => {
92
+ const socket = net.connect({ host: "127.0.0.1", port });
93
+ const done = (blocked) => {
94
+ socket.destroy();
95
+ resolve(blocked);
96
+ };
97
+ socket.setTimeout(300, () => done(false));
98
+ socket.once("connect", () => done(false));
99
+ socket.once("error", (error) => done(error.code === "EPERM" || error.code === "EACCES"));
100
+ });
101
+ }
82
102
  var isBenchmarkRange = (address) => /^198\.1[89]\./.test(address);
83
103
  function isTailnet(address) {
84
104
  const m = /^100\.(\d{1,3})\./.exec(address);
@@ -125,6 +145,7 @@ async function answersAnyPort(address) {
125
145
  return port > 0 && answers(address, port);
126
146
  }
127
147
  async function listenFree(server, from, host, first) {
148
+ if (await loopbackBlocked(first || from || 1)) throw new NetworkBlocked();
128
149
  if (from === 0) return listen(server, 0, host);
129
150
  const to = Math.min(from + PORT_TRIES - 1, 65535);
130
151
  const range = Array.from({ length: to - from + 1 }, (_, i) => from + i);
@@ -965,6 +986,7 @@ async function serveHub(call) {
965
986
  }
966
987
  }
967
988
  async function openOnHub(call) {
989
+ if (await loopbackBlocked(call.port || 1)) throw new NetworkBlocked();
968
990
  const joined = await joinHub(call);
969
991
  if (joined) return { state: joined };
970
992
  const logFile = path3.join(call.root, LOG_FILE);
@@ -1070,7 +1092,7 @@ async function writeZip(entries, write) {
1070
1092
  for (const entry of entries) {
1071
1093
  let data;
1072
1094
  try {
1073
- data = await fsp.readFile(entry.file);
1095
+ data = entry.data ?? await fsp.readFile(entry.file);
1074
1096
  } catch (error) {
1075
1097
  if (error.code === "ENOENT") continue;
1076
1098
  throw error;
@@ -4804,7 +4826,7 @@ function findChrome() {
4804
4826
  }
4805
4827
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4806
4828
  async function launchChrome(chrome, args = []) {
4807
- const profile = fs13.mkdtempSync(path12.join(os8.tmpdir(), "claude-motion-chrome-"));
4829
+ const profile = fs13.mkdtempSync(path12.join(os8.tmpdir(), "baoyu-chrome-"));
4808
4830
  const child = spawn5(
4809
4831
  chrome,
4810
4832
  [