@marver-design/marver 0.13.0 → 0.15.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 (49) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +44 -20
  3. package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
  4. package/dist/cli.mjs +21 -7
  5. package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
  6. package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
  7. package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
  8. package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
  9. package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
  10. package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
  11. package/dist/poster-CbpzSzJu.mjs +143 -0
  12. package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
  13. package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
  14. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  15. package/docs/live-jam.md +177 -0
  16. package/docs/publish.md +270 -0
  17. package/docs/sharing.md +333 -0
  18. package/docs/slides.md +140 -0
  19. package/package.json +3 -1
  20. package/src/client/const.ts +13 -0
  21. package/src/client/content/chart-engine.ts +33 -0
  22. package/src/client/content/chart.tsx +138 -0
  23. package/src/client/content/index.tsx +30 -6
  24. package/src/client/content/slide.tsx +238 -0
  25. package/src/client/content/video.tsx +223 -0
  26. package/src/client/frame-host/bridge.js +6 -1
  27. package/src/client/shell/App.tsx +59 -13
  28. package/src/client/shell/LockedApp.tsx +7 -2
  29. package/src/client/shell/Play.tsx +138 -24
  30. package/src/client/shell/Toolbar.tsx +12 -3
  31. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  32. package/src/client/shell/hash.ts +3 -1
  33. package/src/client/shell/icons.tsx +3 -0
  34. package/src/client/shell/play-order.ts +22 -0
  35. package/src/client/shell/store.ts +80 -9
  36. package/src/client/shell/styles.css +23 -27
  37. package/src/client/stage/main.tsx +54 -3
  38. package/src/shared/utm.ts +3 -2
  39. package/templates/AGENTS-embedded.md +20 -4
  40. package/templates/AGENTS-studio.md +20 -4
  41. package/templates/instructions/boards.md +47 -5
  42. package/templates/instructions/craft.md +17 -0
  43. package/templates/instructions/iterate.md +109 -14
  44. package/templates/instructions/jam.md +18 -2
  45. package/templates/instructions/publish.md +7 -0
  46. package/templates/instructions/reference/deck-layouts.md +230 -0
  47. package/templates/instructions/reference/deck-story.md +110 -0
  48. package/templates/instructions/shape.md +15 -1
  49. package/templates/instructions/slides.md +402 -0
@@ -1,5 +1,5 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { a as writeManifest, c as detectAgent, l as detectHost, o as DEFAULTS, r as scanFrames, u as readJson } from "./manifest-CS6krOTe.mjs";
2
+ import { a as writeManifest, c as detectAgent, l as detectHost, o as DEFAULTS, r as scanFrames, u as readJson } from "./manifest-BzxSMoDB.mjs";
3
3
  import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, relative } from "node:path";
@@ -143,7 +143,71 @@ function init(root, opts) {
143
143
  refresh("providers.tsx", [providersTemplate(null, null), providersTemplate(null, host.toaster, host.routerPkg)], providersTemplate(host.router, host.toaster, host.routerPkg));
144
144
  refresh("tsconfig.json", [STANDALONE_TSCONFIG], tsconfigNow());
145
145
  }
146
- write(".gitignore", ".local/\n.dist/\n.dist-seeds/\n");
146
+ write(".gitignore", ".local/\n.dist/\n.dist-seeds/\nslides-inspiration/\n");
147
+ {
148
+ const gi = join(design, ".gitignore");
149
+ const cur = readFileSync(gi, "utf8");
150
+ if (!cur.split("\n").some((l) => l.trim() === "slides-inspiration/" || l.trim() === "slides-inspiration")) {
151
+ writeFileSync(gi, cur + (cur.endsWith("\n") || cur === "" ? "" : "\n") + "slides-inspiration/\n");
152
+ created.push("design/.gitignore (+ slides-inspiration/)");
153
+ }
154
+ }
155
+ write("slides.md", [
156
+ "# This project's slide layouts and house rules",
157
+ "",
158
+ "The agent reads BOTH the shipped doctrine (instructions/slides.md) and this",
159
+ "file - and this file WINS where they disagree. Add your own layouts, house",
160
+ "rules, and banned moves here; it is yours and marver never touches it.",
161
+ "",
162
+ "To grow it from examples: drop decks (PPTX, PDF, screenshots) into",
163
+ "design/slides-inspiration/ and ask the agent to \"study my inspiration\" -",
164
+ "it will propose additions here for your review.",
165
+ "",
166
+ "## The deck look",
167
+ "",
168
+ "How a deck wears this brand. The agent drafts it from DESIGN.md and",
169
+ "theme.css on the first deck (a reviewed edit, building on provisionally",
170
+ "with the theme's tokens); anything it cannot settle stays `TBD` for you.",
171
+ "Each line shows the shape with an example - replace the example, keep the",
172
+ "shape.",
173
+ "",
174
+ "- **Tokens** (`--marver-slide-*` in theme.css, read by the Slide root):",
175
+ " ground / ink / muted, each with a `-dark` twin; accent, one value for",
176
+ " both themes. e.g. ground = the page background, ink = the heading",
177
+ " colour, accent = the primary button.",
178
+ "- **Type** (`--marver-slide-font`): one family, the theme's; the type",
179
+ " roles carry their weights. e.g. Inter.",
180
+ "- **The mark**: which asset, where, how big. e.g. wordmark SVG, bottom-left",
181
+ " of the cover and the closing only, 120px wide - never on content slides.",
182
+ "- **Colour meaning** (charts, cards, badges): hue = category, fixed across",
183
+ " the deck. e.g. accent = us, slate = competitors, muted = the baseline;",
184
+ " green / amber / red reserved for status.",
185
+ "- **Backgrounds allowed**: e.g. the flat ground; a theme gradient on covers",
186
+ " and sections only; no photos behind text without a scrim.",
187
+ "- **Imagery**: e.g. product screenshots on a device-less frame, real people,",
188
+ " no stock; illustrations in the product's line style only. Evidentiary",
189
+ " images (a report fragment, a log line, a reasoning trace, a real",
190
+ " screen) are evidence objects, not decoration - hero material when the",
191
+ " message warrants it.",
192
+ "- **Tempo** (`--marver-slide-tempo`): e.g. 350ms - one value, every deck.",
193
+ "- **Numbers**: e.g. $ and k / M (lowercase k), fiscal years as FY26,",
194
+ " negatives in brackets, one decimal on percentages.",
195
+ "- **Voice**: three words the deck sounds like, and the words it never",
196
+ " uses. e.g. direct, warm, specific; never \"leverage\", \"seamless\", \"journey\".",
197
+ "- **Terminology**: user-facing word → never-shown internal word.",
198
+ " e.g. \"Comments\" → \"Enrichment\".",
199
+ "- **End card**: yes / no. e.g. yes - the mark on the ground, no text; the",
200
+ " ask lives on the slide before it.",
201
+ "",
202
+ "## Layouts",
203
+ "",
204
+ "(none yet - the shipped recipe list and atlas apply)",
205
+ "",
206
+ "## House rules",
207
+ "",
208
+ "(none yet)",
209
+ ""
210
+ ].join("\n"));
147
211
  write(".gitattributes", "comments/*.jsonl merge=union\n");
148
212
  write("scenes/_layout.tsx", readFileSync(join(templates, "root-layout.tsx"), "utf8"));
149
213
  if (!existsSync(join(design, "boards"))) {
@@ -382,6 +382,10 @@ function extractMeta(src) {
382
382
  const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
383
383
  return r ? r[2] : void 0;
384
384
  };
385
+ const pickBool = (key) => {
386
+ const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(true|false)\\b`).exec(body);
387
+ return r ? r[1] === "true" : void 0;
388
+ };
385
389
  const out = {};
386
390
  const title = pick("title");
387
391
  if (title) out.title = title;
@@ -395,22 +399,35 @@ function extractMeta(src) {
395
399
  if (variant) out.variant = variant;
396
400
  const intent = pick("intent");
397
401
  if (intent) out.intent = intent;
402
+ const slide = pickBool("slide");
403
+ if (slide !== void 0) out.slide = slide;
398
404
  return out;
399
405
  }
400
- /** Content-frame detection: LEXICAL by convention - the frame file
401
- * itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
402
- * string in a UI frame can never misbadge it; barrels/re-exports are not
403
- * detected - meta.intent is the taught path and always works). Returns the
404
- * inferred intent + natural width, or null for UI frames. */
406
+ /** Content-frame detection: LEXICAL by convention - the frame file itself imports
407
+ * PKG/content (an import specifier scan, so "<Diagram" inside a string in a UI frame can
408
+ * never misbadge it; barrels/re-exports are not detected - meta.intent is the taught path
409
+ * and always works) AND renders a DOCUMENT primitive: Doc, Md, Diagram or Img (reference
410
+ * imagery - a moodboard is Rows of Img). The import alone is not enough - Chart, Video, Row,
411
+ * Col and Space are shared blocks a UI screen or a slide uses too, and a dashboard with one
412
+ * <Chart> is still a screen (device height, no document measuring), never a spec. Returns
413
+ * the inferred intent + natural width, or null for UI frames. */
405
414
  const CONTENT_IMPORT = new RegExp(`from\\s+['"]${PKG}/content['"]`);
406
415
  const WIDE_DOC = /<Doc\b[^>]*\blayout\s*=\s*["']wide["']/;
407
416
  const contentWidthOf = (src) => WIDE_DOC.test(src) ? CONTENT_WIDTH.wide : CONTENT_WIDTH.document;
417
+ /** The source with comments, template literals and CODE-shaped strings blanked (a JSX
418
+ * expression string `{'<Diagram>'}`, an attribute value), so a tag name quoted in an example
419
+ * or a comment never counts as a rendered element. Prose is left alone: JSX text has
420
+ * apostrophes ("it's"), and a blanket quote match would swallow real tags between two of them. */
421
+ const codeOnly = (src) => src.replace(/`(?:[^`\\]|\\[\s\S])*`/g, "``").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/\{\s*(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')\s*\}/g, "{\"\"}").replace(/([=(,:[{?+|&!]|\breturn)\s*(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')/g, "$1\"\"").replace(/([=(,:[{?+|&!]|\breturn)\s*\/(?:[^/\\\n[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+\/[gimsuy]*/g, "$1/re/");
408
422
  function contentScan(src) {
409
423
  if (!CONTENT_IMPORT.test(src)) return null;
410
- const count = (re) => (src.match(re) ?? []).length;
424
+ const code = codeOnly(src);
425
+ const count = (re) => (code.match(re) ?? []).length;
426
+ const docs = count(/<Doc[\s>/]/g);
411
427
  const diagrams = count(/<Diagram[\s>/]/g);
412
428
  const imgs = count(/<Img[\s>/]/g);
413
429
  const mds = count(/<Md[\s>/]/g);
430
+ if (docs + diagrams + mds + imgs === 0) return null;
414
431
  return {
415
432
  intent: diagrams > 0 ? "diagram" : imgs > mds ? "moodboard" : "spec",
416
433
  width: contentWidthOf(src)
@@ -484,6 +501,7 @@ function scanFrames(root) {
484
501
  if (meta.theme) entry.theme = meta.theme;
485
502
  if (meta.of) entry.variantGroup = meta.of;
486
503
  if (meta.variant) entry.variant = meta.variant;
504
+ if (meta.slide) entry.slide = true;
487
505
  const content = contentScan(src);
488
506
  if (content || meta.intent) {
489
507
  entry.intent = meta.intent ?? content.intent;
@@ -1,6 +1,6 @@
1
1
  import { i as avatarSourceFor, n as attachAvatar, p as provisionFromMarverId } from "./auth-DdUKeVKt.mjs";
2
2
  import { TransactionStore, browserBinding, verifyAssertion } from "./marver-id-CVCoaMGI.mjs";
3
- import { t as poweredByUrl } from "./serve-EjEqsiYa.mjs";
3
+ import { t as poweredByUrl } from "./serve-Bcwfpvhl.mjs";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { isIP } from "node:net";
6
6
  import { lookup } from "node:dns/promises";
@@ -1,7 +1,7 @@
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
3
  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-CS6krOTe.mjs";
4
+ import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-BzxSMoDB.mjs";
5
5
  import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join, resolve, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -125,6 +125,24 @@ function apiMiddleware(root, opts = {}) {
125
125
  const path = url.pathname.slice(`${ROUTE}/api/`.length);
126
126
  if (req.method === "GET" && !cookie(req, "mv_c")) res.setHeader("set-cookie", `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${CSRF_MAX_AGE}; SameSite=Lax`);
127
127
  try {
128
+ if (path === "policy" && req.method === "GET") try {
129
+ const raw = JSON.parse(readFileSync(join(root, "design", "publish.json"), "utf8"));
130
+ const boards = {};
131
+ for (const [n, level] of Object.entries(raw?.boards ?? {})) {
132
+ if (typeof level !== "object" || level === null) continue;
133
+ const p2 = level;
134
+ boards[n] = {
135
+ ...typeof p2.type === "string" ? { type: p2.type } : {},
136
+ ...typeof p2.open === "string" ? { open: p2.open } : {},
137
+ ...p2.lock === true ? { lock: true } : {},
138
+ ...typeof p2.transition === "string" ? { transition: p2.transition } : {},
139
+ ...typeof p2.chrome === "string" ? { chrome: p2.chrome } : {}
140
+ };
141
+ }
142
+ return json(res, 200, { boards });
143
+ } catch {
144
+ return json(res, 200, { boards: {} });
145
+ }
128
146
  if (path === "boards" && req.method === "GET") {
129
147
  mkdirSync(boardsDir, { recursive: true });
130
148
  return json(res, 200, readdirSync(boardsDir).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).map((f) => {
@@ -265,27 +283,59 @@ function apiMiddleware(root, opts = {}) {
265
283
  if (!ownerGated(req) && !(token && req.headers["x-mv-work"] === token)) return json(res, 403, { error: "forbidden" });
266
284
  const frameId = url.searchParams.get("frame") ?? "";
267
285
  const theme = url.searchParams.get("theme") ?? "light";
286
+ const scale = url.searchParams.has("scale") ? Number(url.searchParams.get("scale")) : void 0;
287
+ const dim = (k) => {
288
+ const v = Math.round(Number(url.searchParams.get(k)));
289
+ return url.searchParams.has(k) && Number.isFinite(v) ? v : void 0;
290
+ };
291
+ const size = {
292
+ w: dim("w"),
293
+ h: dim("h")
294
+ };
295
+ const asPng = url.searchParams.get("format") === "png";
268
296
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
269
- const { shootFrame } = await import("./shot-Cyv3GN79.mjs");
297
+ const { shootFrame } = await import("./shot-BWhoz6cU.mjs");
270
298
  const r = await shootFrame({
271
299
  root,
272
300
  viewports: opts.viewports ?? {},
273
301
  frameId,
274
302
  theme,
275
- origin
303
+ origin,
304
+ scale,
305
+ size
276
306
  });
277
- if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error === "invalid theme" ? 400 : 503, { error: r.error });
278
- return json(res, 200, {
307
+ if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error.startsWith("invalid ") ? 400 : 503, { error: r.error });
308
+ const summary = {
279
309
  path: r.path,
280
310
  frame: frameId,
281
311
  theme,
282
312
  width: r.width,
283
313
  height: r.height,
284
314
  scale: r.scale,
285
- ...r.truncated ? {
286
- truncated: true,
287
- note: r.note
288
- } : {}
315
+ ...r.truncated ? { truncated: true } : {},
316
+ ...r.note ? { note: r.note } : {}
317
+ };
318
+ if (!asPng) return json(res, 200, summary);
319
+ const png = readFileSync(join(root, r.path));
320
+ res.writeHead(200, {
321
+ "content-type": "image/png",
322
+ "content-length": png.length,
323
+ "cache-control": "no-store",
324
+ "x-mv-shot": Buffer.from(JSON.stringify(summary)).toString("base64url")
325
+ });
326
+ return res.end(png);
327
+ }
328
+ if (path === "poster" && req.method === "GET") {
329
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
330
+ const src = url.searchParams.get("src") ?? "";
331
+ const { ensurePoster, isLocalClip } = await import("./poster-CbpzSzJu.mjs");
332
+ const { isLocalAssetRef } = await import("./build-DfuTQZlY.mjs");
333
+ if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
334
+ const r = await ensurePoster(join(root, "design", "assets"), src);
335
+ if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
336
+ return json(res, 200, {
337
+ path: `design/assets/${src}.poster.png`,
338
+ generated: r.generated
289
339
  });
290
340
  }
291
341
  if (path === "me" && req.method === "GET") return json(res, 200, {
@@ -799,7 +849,7 @@ function marverPlugin(ctx) {
799
849
  });
800
850
  return;
801
851
  }
802
- const { shootFrame } = await import("./shot-Cyv3GN79.mjs");
852
+ const { shootFrame } = await import("./shot-BWhoz6cU.mjs");
803
853
  write(await shootFrame({
804
854
  root,
805
855
  viewports: config.viewports,
@@ -0,0 +1,143 @@
1
+ import { capture, findChrome } from "./shot-BWhoz6cU.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 };
@@ -11,7 +11,8 @@ import { createServer } from "node:http";
11
11
  * utm_source = the surface class ('published-canvas' | 'dev-canvas')
12
12
  * utm_medium = the link unit ('powered-by')
13
13
  * utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
14
- * utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page)
14
+ * utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page |
15
+ * 'play-brand' the present/slides brand pill)
15
16
  */
16
17
  function poweredByUrl(canvasName, source, content) {
17
18
  const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -146,7 +147,7 @@ async function serve(root, portFlag) {
146
147
  console.error(`[${NAME}] MARVER_ID_ISSUER is set but MARVER_PUBLIC_ORIGIN is not.\n Every assertion is bound to this canvas's exact origin, and it cannot be\n inferred from request headers - a proxy can make any request look local.\n Set it to the origin people actually reach this canvas on:\n MARVER_PUBLIC_ORIGIN=https://canvas.example.com\n MARVER_PUBLIC_ORIGIN=http://localhost:${portFlag ?? (Number(process.env.PORT) || 4199)} (development)`);
147
148
  process.exit(1);
148
149
  }
149
- const { marverIdHandler } = await import("./marver-id-gate-B2uraTHS.mjs");
150
+ const { marverIdHandler } = await import("./marver-id-gate-D6By7XHj.mjs");
150
151
  const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
151
152
  const canvasName = humanName(meta.name);
152
153
  const { ceilingsFromRights } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);