@marver-design/marver 0.14.0 → 0.16.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 (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -4
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
  6. package/dist/cli.mjs +15 -6
  7. package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
  9. package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
  11. package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
  12. package/dist/poster-DOY7pax8.mjs +143 -0
  13. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  14. package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
  15. package/docs/live-jam.md +6 -2
  16. package/docs/publish.md +4 -1
  17. package/docs/slides.md +9 -3
  18. package/package.json +1 -1
  19. package/src/client/content/chart.tsx +62 -24
  20. package/src/client/content/index.tsx +5 -2
  21. package/src/client/content/video.tsx +132 -35
  22. package/src/client/frame-host/bridge.js +6 -1
  23. package/src/client/shell/App.tsx +33 -247
  24. package/src/client/shell/BoardList.tsx +408 -0
  25. package/src/client/shell/ContextMenu.tsx +59 -0
  26. package/src/client/shell/icons.tsx +7 -0
  27. package/src/client/shell/store.ts +156 -48
  28. package/src/client/shell/styles.css +42 -4
  29. package/src/shared/board-tree.ts +285 -0
  30. package/templates/AGENTS-embedded.md +35 -7
  31. package/templates/AGENTS-studio.md +35 -7
  32. package/templates/instructions/boards.md +120 -7
  33. package/templates/instructions/craft.md +21 -0
  34. package/templates/instructions/discover.md +7 -2
  35. package/templates/instructions/iterate.md +114 -18
  36. package/templates/instructions/jam.md +18 -2
  37. package/templates/instructions/review.md +4 -0
  38. package/templates/instructions/shape.md +16 -2
  39. package/templates/instructions/slides.md +5 -1
  40. package/templates/instructions/welcome.md +4 -1
  41. package/templates/instructions/wireframe.md +3 -0
  42. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -1,13 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
3
+ import { a as nodeExists, c as FOLDERS_FILE, f as readDescription, i as listBoardFiles, m as hash, n as checkBoardsDir, o as readRegistry, p as validateWire, r as isRegularFile, s as BOARD_NAME, t as boardFields } from "./boards-BdG1TJwU.mjs";
3
4
  import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
4
- import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DvOmglFp.mjs";
5
- import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
+ import { i as writeManifest, n as scanFrames, o as loadConfig, t as affectedFrameIds } from "./manifest-CpbsqQ_v.mjs";
6
+ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
6
7
  import { basename, dirname, join, resolve, sep } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { randomBytes } from "node:crypto";
9
10
  //#region src/server/api.ts
10
- const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
11
11
  const BODY_LIMIT = 1e6;
12
12
  const CSRF_MAX_AGE = 2592e3;
13
13
  /** Read one cookie value off the request. */
@@ -85,37 +85,18 @@ function contained(target, base) {
85
85
  }
86
86
  function apiMiddleware(root, opts = {}) {
87
87
  const boardsDir = join(root, "design", "boards");
88
+ const foldersPath = join(boardsDir, FOLDERS_FILE);
88
89
  const boardPath = (name) => {
89
- if (!BOARD_NAME.test(name)) return null;
90
+ if (!validName(name)) return null;
91
+ if (checkBoardsDir(root, boardsDir)) return null;
90
92
  mkdirSync(boardsDir, { recursive: true });
91
93
  const p = resolve(boardsDir, `${name}.json`);
92
94
  return contained(p, boardsDir) ? p : null;
93
95
  };
94
96
  const validName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
95
- const underRoot = (dir) => {
96
- try {
97
- const rr = realpathSync(root);
98
- const rd = realpathSync(dir);
99
- return rd === rr || rd.startsWith(rr + sep);
100
- } catch {
101
- return false;
102
- }
103
- };
104
- const notSymlink = (p) => {
105
- try {
106
- return !existsSync(p) || !lstatSync(p).isSymbolicLink();
107
- } catch {
108
- return false;
109
- }
110
- };
111
- const nodeExists = (p) => {
112
- try {
113
- lstatSync(p);
114
- return true;
115
- } catch {
116
- return false;
117
- }
118
- };
97
+ const dirError = () => checkBoardsDir(root, boardsDir);
98
+ const notSymlink = (p) => !nodeExists(p) || isRegularFile(p);
99
+ const nodeExists$1 = nodeExists;
119
100
  try {
120
101
  for (const f of readdirSync(boardsDir)) if (f.endsWith(".tmp")) rmSync(join(boardsDir, f), { force: true });
121
102
  } catch {}
@@ -144,20 +125,24 @@ function apiMiddleware(root, opts = {}) {
144
125
  return json(res, 200, { boards: {} });
145
126
  }
146
127
  if (path === "boards" && req.method === "GET") {
128
+ const de = dirError();
129
+ if (de) return json(res, 422, { error: de });
147
130
  mkdirSync(boardsDir, { recursive: true });
148
- return json(res, 200, readdirSync(boardsDir).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).map((f) => {
149
- const content = readFileSync(join(boardsDir, f), "utf8");
150
- let order;
151
- try {
152
- const o = JSON.parse(content)?.order;
153
- if (typeof o === "number" && Number.isFinite(o)) order = o;
154
- } catch {}
155
- return {
156
- name: f.replace(/\.json$/, ""),
157
- sha256: hash(content),
158
- order
159
- };
160
- }));
131
+ return json(res, 200, listBoardFiles(boardsDir).boards.map((b) => ({
132
+ name: b.name,
133
+ sha256: b.sha256,
134
+ ...boardFields(b.json, validName)
135
+ })));
136
+ }
137
+ if (path === "folders" && req.method === "GET") {
138
+ const de = dirError();
139
+ if (de) return json(res, 422, { error: de });
140
+ const reg = readRegistry(boardsDir);
141
+ if (reg.state === "malformed") return json(res, 422, { error: reg.error });
142
+ return json(res, 200, {
143
+ folders: reg.folders,
144
+ sha256: reg.sha256
145
+ });
161
146
  }
162
147
  if (path === "boards/rename" && req.method === "POST") {
163
148
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
@@ -173,21 +158,24 @@ function apiMiddleware(root, opts = {}) {
173
158
  const { from, to } = parsed;
174
159
  if (!validName(from) || !validName(to)) return json(res, 400, { error: "invalid board name" });
175
160
  if (to === "all-scenes" || from === "all-scenes" || to === from) return json(res, 400, { error: "invalid rename" });
176
- if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
161
+ {
162
+ const de = dirError();
163
+ if (de) return json(res, 400, { error: de });
164
+ }
177
165
  const fromPath = boardPath(from), toPath = boardPath(to);
178
166
  if (!fromPath || !toPath) return json(res, 400, { error: "invalid board name" });
179
167
  if (!existsSync(fromPath)) return json(res, 404, { error: `board "${from}" does not exist` });
180
168
  if (!notSymlink(fromPath)) return json(res, 400, { error: "refusing to rename a symlinked board file" });
181
169
  if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
182
170
  if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
183
- if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
171
+ if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
184
172
  try {
185
173
  linkSync(fromPath, toPath);
186
174
  } catch (err) {
187
175
  const code = err.code;
188
176
  if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
189
177
  if (code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
190
- if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
178
+ if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
191
179
  renameSync(fromPath, toPath);
192
180
  return json(res, 200, { name: to });
193
181
  }
@@ -205,25 +193,104 @@ function apiMiddleware(root, opts = {}) {
205
193
  return json(res, 400, { error: "malformed JSON" });
206
194
  }
207
195
  if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
208
- const order = parsed.order;
209
- if (!Array.isArray(order) || order.length < 1 || order.length > 200) return json(res, 400, { error: "invalid order" });
210
- if (!order.every(validName) || order.some((n) => n === "all-scenes")) return json(res, 400, { error: "invalid board name in order" });
211
- if (new Set(order).size !== order.length) return json(res, 400, { error: "duplicate board in order" });
212
- if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
213
- for (let i = 0; i < order.length; i++) {
214
- const p = boardPath(order[i]);
215
- if (!p || !existsSync(p) || !notSymlink(p)) continue;
196
+ const { tree, base } = parsed;
197
+ const bad = validateWire(tree);
198
+ if (bad) return json(res, 400, { error: bad });
199
+ const b = base;
200
+ if (!b || typeof b !== "object" || !b.boards || typeof b.boards !== "object" || b.folders !== null && typeof b.folders !== "string") return json(res, 400, { error: "expected base hashes" });
201
+ {
202
+ const de = dirError();
203
+ if (de) return json(res, 400, { error: de });
204
+ }
205
+ const reg = readRegistry(boardsDir);
206
+ if (reg.state === "malformed") return json(res, 422, { error: reg.error });
207
+ if (reg.sha256 !== b.folders) return json(res, 409, {
208
+ error: "folders changed on disk",
209
+ stale: [FOLDERS_FILE]
210
+ });
211
+ const unseen = listBoardFiles(boardsDir).boards.map((x) => x.name).filter((n) => !(n in b.boards));
212
+ if (unseen.length) return json(res, 409, {
213
+ error: "boards changed on disk",
214
+ stale: unseen
215
+ });
216
+ const plan = [];
217
+ const stale = [];
218
+ const consider = (name, order, folder) => {
219
+ const p = boardPath(name);
220
+ if (!p || !isRegularFile(p)) {
221
+ stale.push(name);
222
+ return null;
223
+ }
224
+ const content = readFileSync(p, "utf8");
225
+ if (b.boards[name] !== hash(content)) {
226
+ stale.push(name);
227
+ return null;
228
+ }
216
229
  let obj;
217
230
  try {
218
- obj = JSON.parse(readFileSync(p, "utf8"));
231
+ obj = JSON.parse(content);
219
232
  } catch {
233
+ return `board "${name}" is not valid JSON - fix the file`;
234
+ }
235
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `board "${name}" is not a JSON object - fix the file`;
236
+ plan.push({
237
+ name,
238
+ path: p,
239
+ obj,
240
+ order,
241
+ folder
242
+ });
243
+ return null;
244
+ };
245
+ const folders = [];
246
+ for (const [i, it] of tree.entries()) {
247
+ if (typeof it === "string") {
248
+ const e = consider(it, i, null);
249
+ if (e) return json(res, 422, { error: e });
220
250
  continue;
221
251
  }
222
- if (!obj || typeof obj !== "object") continue;
223
- obj.order = i;
224
- atomicWrite(p, JSON.stringify(obj, null, 2) + "\n");
252
+ const description = readDescription(it.description);
253
+ folders.push({
254
+ name: it.folder,
255
+ order: i,
256
+ ...description ? { description } : {}
257
+ });
258
+ for (const [j, kid] of it.boards.entries()) {
259
+ const e = consider(kid, j, it.folder);
260
+ if (e) return json(res, 422, { error: e });
261
+ }
262
+ }
263
+ if (stale.length) return json(res, 409, {
264
+ error: "boards changed on disk",
265
+ stale
266
+ });
267
+ const sha256 = {};
268
+ for (const w of plan) {
269
+ if (w.obj.order === w.order && (w.folder ? w.obj.folder === w.folder : w.obj.folder === void 0)) continue;
270
+ w.obj.order = w.order;
271
+ if (w.folder) w.obj.folder = w.folder;
272
+ else delete w.obj.folder;
273
+ const next = JSON.stringify(w.obj, null, 2) + "\n";
274
+ atomicWrite(w.path, next);
275
+ sha256[w.name] = hash(next);
225
276
  }
226
- return json(res, 200, { ok: true });
277
+ mkdirSync(boardsDir, { recursive: true });
278
+ let foldersSha = null;
279
+ if (folders.length) {
280
+ const next = JSON.stringify({
281
+ version: 1,
282
+ folders
283
+ }, null, 2) + "\n";
284
+ atomicWrite(foldersPath, next);
285
+ foldersSha = hash(next);
286
+ } else rmSync(foldersPath, { force: true });
287
+ return json(res, 200, {
288
+ ok: true,
289
+ sha256: {
290
+ boards: sha256,
291
+ folders: foldersSha
292
+ }
293
+ });
227
294
  }
228
295
  const boardMatch = /^boards\/([^/]+)$/.exec(path);
229
296
  if (boardMatch) {
@@ -268,9 +335,11 @@ function apiMiddleware(root, opts = {}) {
268
335
  }
269
336
  mkdirSync(boardsDir, { recursive: true });
270
337
  const incoming = body.board;
271
- if (incoming && typeof incoming === "object" && incoming.order === void 0 && current) try {
272
- const o = JSON.parse(current).order;
273
- if (typeof o === "number" && Number.isFinite(o)) incoming.order = o;
338
+ if (incoming && typeof incoming === "object" && current) try {
339
+ const disk = JSON.parse(current);
340
+ if (incoming.order === void 0 && typeof disk.order === "number" && Number.isFinite(disk.order)) incoming.order = disk.order;
341
+ if (incoming.folder === void 0 && validName(disk.folder)) incoming.folder = disk.folder;
342
+ if (incoming.description === void 0 && readDescription(disk.description)) incoming.description = disk.description;
274
343
  } catch {}
275
344
  const next2 = JSON.stringify(body.board, null, 2) + "\n";
276
345
  atomicWrite(p, next2);
@@ -283,27 +352,59 @@ function apiMiddleware(root, opts = {}) {
283
352
  if (!ownerGated(req) && !(token && req.headers["x-mv-work"] === token)) return json(res, 403, { error: "forbidden" });
284
353
  const frameId = url.searchParams.get("frame") ?? "";
285
354
  const theme = url.searchParams.get("theme") ?? "light";
355
+ const scale = url.searchParams.has("scale") ? Number(url.searchParams.get("scale")) : void 0;
356
+ const dim = (k) => {
357
+ const v = Math.round(Number(url.searchParams.get(k)));
358
+ return url.searchParams.has(k) && Number.isFinite(v) ? v : void 0;
359
+ };
360
+ const size = {
361
+ w: dim("w"),
362
+ h: dim("h")
363
+ };
364
+ const asPng = url.searchParams.get("format") === "png";
286
365
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
287
- const { shootFrame } = await import("./shot-kbR_xzJH.mjs");
366
+ const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
288
367
  const r = await shootFrame({
289
368
  root,
290
369
  viewports: opts.viewports ?? {},
291
370
  frameId,
292
371
  theme,
293
- origin
372
+ origin,
373
+ scale,
374
+ size
294
375
  });
295
- if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error === "invalid theme" ? 400 : 503, { error: r.error });
296
- return json(res, 200, {
376
+ if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error.startsWith("invalid ") ? 400 : 503, { error: r.error });
377
+ const summary = {
297
378
  path: r.path,
298
379
  frame: frameId,
299
380
  theme,
300
381
  width: r.width,
301
382
  height: r.height,
302
383
  scale: r.scale,
303
- ...r.truncated ? {
304
- truncated: true,
305
- note: r.note
306
- } : {}
384
+ ...r.truncated ? { truncated: true } : {},
385
+ ...r.note ? { note: r.note } : {}
386
+ };
387
+ if (!asPng) return json(res, 200, summary);
388
+ const png = readFileSync(join(root, r.path));
389
+ res.writeHead(200, {
390
+ "content-type": "image/png",
391
+ "content-length": png.length,
392
+ "cache-control": "no-store",
393
+ "x-mv-shot": Buffer.from(JSON.stringify(summary)).toString("base64url")
394
+ });
395
+ return res.end(png);
396
+ }
397
+ if (path === "poster" && req.method === "GET") {
398
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
399
+ const src = url.searchParams.get("src") ?? "";
400
+ const { ensurePoster, isLocalClip } = await import("./poster-DOY7pax8.mjs");
401
+ const { isLocalAssetRef } = await import("./build-Ct0iMoSi.mjs");
402
+ if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
403
+ const r = await ensurePoster(join(root, "design", "assets"), src);
404
+ if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
405
+ return json(res, 200, {
406
+ path: `design/assets/${src}.poster.png`,
407
+ generated: r.generated
307
408
  });
308
409
  }
309
410
  if (path === "me" && req.method === "GET") return json(res, 200, {
@@ -579,7 +680,12 @@ const VIRTUAL_CONFIG = "virtual:sh-config";
579
680
  const VIRTUAL_DATA = "virtual:sh-data";
580
681
  function marverPlugin(ctx) {
581
682
  const { root, clientDir, config } = ctx;
582
- let manifest = scanFrames(root);
683
+ let project = {
684
+ name: config.share.name || basename(root),
685
+ description: config.description
686
+ };
687
+ const projectInfo = () => project;
688
+ let manifest = scanFrames(root, projectInfo());
583
689
  let devServer = null;
584
690
  const bootId = String(process.hrtime.bigint());
585
691
  let invRev = 0;
@@ -720,9 +826,13 @@ function marverPlugin(ctx) {
720
826
  }));
721
827
  server.middlewares.use(routesMiddleware(server, clientDir));
722
828
  devServer = server;
829
+ let framesKey = JSON.stringify(manifest.frames);
723
830
  const regen = debounce(() => {
724
- manifest = scanFrames(root);
725
- if (writeManifest(root, manifest)) server.ws.send("sh:manifest", manifest);
831
+ manifest = scanFrames(root, projectInfo());
832
+ const changed = writeManifest(root, manifest);
833
+ const key = JSON.stringify(manifest.frames);
834
+ if (changed && key !== framesKey) server.ws.send("sh:manifest", manifest);
835
+ framesKey = key;
726
836
  }, 150);
727
837
  const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
728
838
  const inScope = (file) => watched.some((w) => file.startsWith(w));
@@ -759,14 +869,31 @@ function marverPlugin(ctx) {
759
869
  rescanTheme();
760
870
  }
761
871
  });
762
- server.watcher.on("change", (f) => inScope(f) && /\.(tsx|jsx)$/.test(f) && regen());
763
- writeManifest(root, scanFrames(root));
872
+ server.watcher.on("change", (f) => inScope(f) && (/\.(tsx|jsx)$/.test(f) || f.endsWith("_brief.md")) && regen());
873
+ const configFile = join(root, "design", "config.ts");
874
+ server.watcher.on("change", (f) => {
875
+ if (f !== configFile) return;
876
+ loadConfig(root).then((c) => {
877
+ project = {
878
+ name: c.share.name || basename(root),
879
+ description: c.description
880
+ };
881
+ regen();
882
+ }).catch(() => {});
883
+ });
884
+ writeManifest(root, manifest);
764
885
  const boardsDir = join(root, "design", "boards");
765
886
  try {
766
887
  mkdirSync(boardsDir, { recursive: true });
767
888
  const timers = /* @__PURE__ */ new Map();
889
+ let listTimer;
768
890
  const watcher = watch(boardsDir, (_event, file) => {
769
891
  if (!file || !file.endsWith(".json")) return;
892
+ clearTimeout(listTimer);
893
+ listTimer = setTimeout(() => {
894
+ server.ws.send("sh:boards", {});
895
+ regen();
896
+ }, 150);
770
897
  clearTimeout(timers.get(file));
771
898
  timers.set(file, setTimeout(() => {
772
899
  timers.delete(file);
@@ -817,7 +944,7 @@ function marverPlugin(ctx) {
817
944
  });
818
945
  return;
819
946
  }
820
- const { shootFrame } = await import("./shot-kbR_xzJH.mjs");
947
+ const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
821
948
  write(await shootFrame({
822
949
  root,
823
950
  viewports: config.viewports,
@@ -0,0 +1,143 @@
1
+ import { capture, findChrome } from "./shot-CwmHO5T4.mjs";
2
+ import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, sep } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { randomBytes } from "node:crypto";
6
+ import { tmpdir } from "node:os";
7
+ //#region src/server/poster.ts
8
+ /**
9
+ * Video posters, generated: a local clip without an authored poster gets one rendered
10
+ * from its own first moments - the same headless Chrome the shot renderer drives, no
11
+ * ffmpeg. The convention is `<clip>.poster.png` beside the clip in design/assets/, and
12
+ * the Video primitive reaches for that name whenever `poster` is omitted, so the dev
13
+ * server (on demand, when the frame first asks) and `marver build` (before assets are
14
+ * copied - a published canvas never ships a poster-less video) produce one file both
15
+ * agree on. An authored poster always wins: this only runs when the file is missing.
16
+ *
17
+ * The frame is taken at min(0.5s, half the duration) - the first frame of most clips is
18
+ * black or a fade. The page is a file:// document holding the clip as a file:// <video>,
19
+ * so nothing has to be served; readiness is the video element appearing in #root once
20
+ * the seek lands, which is the shot renderer's own readiness rule.
21
+ */
22
+ /** The conventional poster path for a local clip reference (relative to design/assets/). */
23
+ const posterNameFor = (src) => `${src}.poster.png`;
24
+ /** Is this a video reference the generator handles (a local design-asset clip)? The same
25
+ * grammar the client's assetUrl accepts: relative, forward slashes only, no dot segments. */
26
+ const isLocalClip = (src) => !/^https?:\/\//.test(src) && /\.(mp4|webm|mov|m4v|ogv)$/i.test(src) && !src.includes("\\") && !src.includes(":") && !src.startsWith("/") && !src.split("/").some((seg) => seg === ".." || seg === "." || seg === "");
27
+ /** realpath(p) is base or inside it. */
28
+ const inside = (p, base) => {
29
+ try {
30
+ const r = realpathSync(p);
31
+ return r === base || r.startsWith(base + sep);
32
+ } catch {
33
+ return false;
34
+ }
35
+ };
36
+ const PAGE = (video) => `<!doctype html><html><head><meta charset="utf-8"><style>
37
+ html,body{margin:0;background:#000}#root video{display:block}
38
+ </style></head><body><div id="root"></div><script>
39
+ (function(){
40
+ var v = document.createElement('video');
41
+ v.muted = true; v.playsInline = true; v.preload = 'auto'; v.src = ${JSON.stringify(video)};
42
+ var done = false;
43
+ function show(){ if (done) return; done = true;
44
+ var w = Math.min(1920, v.videoWidth || 1280), h = Math.round(w * ((v.videoHeight || 720) / (v.videoWidth || 1280)));
45
+ v.width = w; v.height = h; v.style.width = w + 'px'; v.style.height = h + 'px';
46
+ document.getElementById('root').appendChild(v) }
47
+ v.addEventListener('loadedmetadata', function(){
48
+ var t = Math.min(0.5, (isFinite(v.duration) ? v.duration : 1) / 2);
49
+ v.addEventListener('seeked', show, { once: true });
50
+ v.currentTime = t; });
51
+ v.addEventListener('error', function(){ window.__mvFrameError = 'the clip could not be decoded by this browser (' + (v.error && v.error.code) + ')' ; document.getElementById('root').appendChild(document.createElement('i')) });
52
+ setTimeout(function(){ if (!done && v.readyState >= 2) show() }, 4000);
53
+ })();
54
+ <\/script></body></html>`;
55
+ /** Render `<clip>.poster.png` for a clip under design/assets/. Idempotent: an existing
56
+ * poster (authored or generated) is left alone. */
57
+ async function ensurePoster(assetsDir, src) {
58
+ if (!isLocalClip(src)) return {
59
+ ok: false,
60
+ error: `not a local clip: ${src}`
61
+ };
62
+ const clip = join(assetsDir, src);
63
+ const out = join(assetsDir, posterNameFor(src));
64
+ if (existsSync(out)) return {
65
+ ok: true,
66
+ path: out,
67
+ width: 0,
68
+ height: 0,
69
+ generated: false
70
+ };
71
+ if (!existsSync(clip)) return {
72
+ ok: false,
73
+ error: `design/assets/${src} does not exist`
74
+ };
75
+ let realAssets;
76
+ try {
77
+ realAssets = realpathSync(assetsDir);
78
+ } catch {
79
+ return {
80
+ ok: false,
81
+ error: "design/assets/ does not exist"
82
+ };
83
+ }
84
+ if (!inside(clip, realAssets)) return {
85
+ ok: false,
86
+ error: `design/assets/${src} resolves outside design/assets/ - not rendered`
87
+ };
88
+ if (!inside(dirname(out), realAssets)) return {
89
+ ok: false,
90
+ error: `design/assets/${dirname(src)} resolves outside design/assets/ - not rendered`
91
+ };
92
+ if (!findChrome()) return {
93
+ ok: false,
94
+ error: `no Chrome/Chromium found to render a poster for ${src} - add poster="..." on the <Video>, or install Chrome`
95
+ };
96
+ const dir = mkdtempSync(join(tmpdir(), "mv-poster-"));
97
+ const page = join(dir, "poster.html");
98
+ writeFileSync(page, PAGE(pathToFileURL(realpathSync(clip)).href));
99
+ const tmp = join(dirname(out), `.${basename(out)}.${randomBytes(6).toString("hex")}.tmp`);
100
+ try {
101
+ const r = await capture({
102
+ url: pathToFileURL(page).href,
103
+ width: 1920,
104
+ height: 1080,
105
+ scale: 1,
106
+ out: tmp,
107
+ fullHeight: false,
108
+ clip: "#root video",
109
+ timeoutMs: 2e4
110
+ }, "poster");
111
+ if (!r.ok) return {
112
+ ok: false,
113
+ error: `could not render a poster for ${basename(src)} - ${r.error}`
114
+ };
115
+ try {
116
+ copyFileSync(tmp, out, constants.COPYFILE_EXCL);
117
+ } catch (err) {
118
+ if (err.code === "EEXIST") return {
119
+ ok: true,
120
+ path: out,
121
+ width: r.width,
122
+ height: r.height,
123
+ generated: false
124
+ };
125
+ throw err;
126
+ }
127
+ return {
128
+ ok: true,
129
+ path: out,
130
+ width: r.width,
131
+ height: r.height,
132
+ generated: true
133
+ };
134
+ } finally {
135
+ rmSync(tmp, { force: true });
136
+ rmSync(dir, {
137
+ recursive: true,
138
+ force: true
139
+ });
140
+ }
141
+ }
142
+ //#endregion
143
+ export { ensurePoster, isLocalClip };
@@ -8,12 +8,13 @@ import { readDevInfo } from "./work-CLrmY-vQ.mjs";
8
8
  * Thin wrapper over the dev server's /api/shot (shot.ts has the capture story).
9
9
  */
10
10
  async function shotCommand(root, frame, opts) {
11
- if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>]`);
11
+ if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>] [--scale 1-4]`);
12
12
  const info = readDevInfo(root);
13
13
  if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
14
14
  const qs = new URLSearchParams({
15
15
  frame,
16
- ...opts.theme ? { theme: opts.theme } : {}
16
+ ...opts.theme ? { theme: opts.theme } : {},
17
+ ...opts.scale != null ? { scale: String(opts.scale) } : {}
17
18
  });
18
19
  let res;
19
20
  try {