@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.
- package/CHANGELOG.md +164 -0
- package/README.md +7 -4
- package/dist/boards-6hKVW42a.mjs +57 -0
- package/dist/boards-BdG1TJwU.mjs +267 -0
- package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
- package/dist/cli.mjs +15 -6
- package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
- package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
- package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
- package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
- package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
- package/dist/poster-DOY7pax8.mjs +143 -0
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
- package/docs/live-jam.md +6 -2
- package/docs/publish.md +4 -1
- package/docs/slides.md +9 -3
- package/package.json +1 -1
- package/src/client/content/chart.tsx +62 -24
- package/src/client/content/index.tsx +5 -2
- package/src/client/content/video.tsx +132 -35
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +33 -247
- package/src/client/shell/BoardList.tsx +408 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/icons.tsx +7 -0
- package/src/client/shell/store.ts +156 -48
- package/src/client/shell/styles.css +42 -4
- package/src/shared/board-tree.ts +285 -0
- package/templates/AGENTS-embedded.md +35 -7
- package/templates/AGENTS-studio.md +35 -7
- package/templates/instructions/boards.md +120 -7
- package/templates/instructions/craft.md +21 -0
- package/templates/instructions/discover.md +7 -2
- package/templates/instructions/iterate.md +114 -18
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +16 -2
- package/templates/instructions/slides.md +5 -1
- package/templates/instructions/welcome.md +4 -1
- package/templates/instructions/wireframe.md +3 -0
- 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 {
|
|
5
|
-
import { copyFileSync, existsSync, linkSync,
|
|
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 (!
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
if (!
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
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(
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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" &&
|
|
272
|
-
const
|
|
273
|
-
if (typeof
|
|
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-
|
|
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
|
|
296
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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 {
|