@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.
- package/CHANGELOG.md +162 -0
- package/README.md +44 -20
- package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
- package/dist/cli.mjs +21 -7
- package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
- package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
- package/dist/poster-CbpzSzJu.mjs +143 -0
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/docs/live-jam.md +177 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +140 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +138 -0
- package/src/client/content/index.tsx +30 -6
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +223 -0
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +59 -13
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +3 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +80 -9
- package/src/client/shell/styles.css +23 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +20 -4
- package/templates/AGENTS-studio.md +20 -4
- package/templates/instructions/boards.md +47 -5
- package/templates/instructions/craft.md +17 -0
- package/templates/instructions/iterate.md +109 -14
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- package/templates/instructions/shape.md +15 -1
- 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-
|
|
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
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
278
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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-
|
|
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-
|
|
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);
|