@hypersocial/decks-cli 0.2.0 → 0.2.2

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/src/check.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
1
+ // hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
2
2
  //
3
3
  // The narrated-deck integrity gate, ported from the Netflix presentation's
4
4
  // scripts/check.mjs and its variant builders. Every rule returns its message from
@@ -14,6 +14,8 @@ export const DEFAULT_MAX_DURATION = 180;
14
14
  export const MOBILE_TITLE_CHARS = 28;
15
15
  export const CAPTION_MIN_CHARS = 4;
16
16
  export const CAPTION_MAX_CHARS = 85;
17
+ // Same names as PRESENTER_STYLES in lib/deckManifest.ts (the published CLI cannot import it).
18
+ export const PRESENTER_STYLES = ["stage", "bubble", "glass", "corner"];
17
19
 
18
20
  export const MESSAGES = {
19
21
  noPlayer: () => `no player script: add <script type="module" src="${PLAYER_V1}" data-manifest="…">`,
@@ -21,6 +23,7 @@ export const MESSAGES = {
21
23
  noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it",
22
24
  manifestUnavailable: (url, why) => `manifest ${url} could not be read (${why})`,
23
25
  noSlides: () => 'no slides: each slide is a <section class="slide" id="…">',
26
+ noLoading: () => "no loading state: copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>, and set --hd-ground to the first slide's colour",
24
27
  clipField: (id, field) => `clip ${id}: ${field}`,
25
28
  noCaptions: (id) => `clip ${id}: timeline has no captions`,
26
29
  order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
@@ -28,7 +31,7 @@ export const MESSAGES = {
28
31
  cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
29
32
  url: (id, key, url, why) => `clip ${id}: ${key} ${url} ${why}`,
30
33
  timing: (id, i, start, end, duration) => `clip ${id}: caption ${i + 1} runs ${start}s to ${end}s, outside 0 ≤ start < end ≤ ${duration}s + 0.1`,
31
- words: (id) => `clip ${id}: caption words do not match the clip text (the captions must cover the whole narration)`,
34
+ words: (id) => `clip ${id}: captions do not cover the clip text, or a caption has no timed words`,
32
35
  captionLength: (id, i, text) => `clip ${id}: caption ${i + 1} is ${text.length} characters ("${text}"); captions must be ${CAPTION_MIN_CHARS} to ${CAPTION_MAX_CHARS}`,
33
36
  localRef: (ref) => `local reference does not resolve: ${ref}`,
34
37
  outsideRef: (ref) => `local reference leaves the deck folder: ${ref}`,
@@ -36,8 +39,19 @@ export const MESSAGES = {
36
39
  note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
37
40
  mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
38
41
  slideId: (n) => `slide ${n} has no id`,
42
+ selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
43
+ presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
44
+ // Warnings (check --layout): the deck still passes.
45
+ layoutUnchecked: (why) => `layout not checked: ${why}`,
46
+ overlap: (id, style, stage) => `slide ${id}: the ${style} presenter would overlap the title at ${stage}; move the .narration-slot or pick another style`,
47
+ // An error, though only check --layout can see it.
48
+ phoneOverflow: (id, px) => `slide ${id}: its content runs ${px}px past the slide on a ${PHONE.width}×${PHONE.height} phone; a slide must fit one screen without scrolling, so trim it or tighten its @media (max-width: 650px) rules`,
39
49
  };
40
50
 
51
+ // The framed player's slide card on a phone (player.css): the screen less the
52
+ // 52px masthead, the 56px dock and 8px gutters.
53
+ export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736 } };
54
+
41
55
  const uncommented = (html) => html.replace(/<!--[\s\S]*?-->/g, "");
42
56
  const attr = (tag, name) => {
43
57
  const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
@@ -47,6 +61,62 @@ const round = (n) => Math.round(n * 100) / 100;
47
61
  const decode = (s) => s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'");
48
62
  const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
49
63
 
64
+ // Comments blanked but newlines kept, so line numbers still match the file.
65
+ const blankComments = (text) => text.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, " "));
66
+ const lineAt = (text, index) => text.slice(0, index).split("\n").length;
67
+
68
+ // Unknown presenter styles, with their line: data-presenter-style in index.html,
69
+ // presenterStyle in script.md's front matter and presenter-style under a heading.
70
+ export function presenterStyleErrors(html, script = "") {
71
+ const errs = [];
72
+ const page = blankComments(html);
73
+ for (const m of page.matchAll(/\sdata-presenter-style\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
74
+ const value = m[1] ?? m[2];
75
+ if (!PRESENTER_STYLES.includes(value)) errs.push(MESSAGES.presenterStyle(`index.html line ${lineAt(page, m.index)}`, value));
76
+ }
77
+ const lines = script.replace(/\r\n?/g, "\n").split("\n");
78
+ const frontEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
79
+ lines.forEach((line, i) => {
80
+ const m = (i > 0 && i < frontEnd ? /^\s*presenterStyle\s*:\s*(.*?)\s*$/ : /^\s*presenter-style\s*:\s*(.*?)\s*$/).exec(line);
81
+ if (m && !PRESENTER_STYLES.includes(m[1])) errs.push(MESSAGES.presenterStyle(`script.md line ${i + 1}`, m[1]));
82
+ });
83
+ return errs;
84
+ }
85
+
86
+ // Does a simple selector (tag, #id, .class, or one compound of them) match
87
+ // anything in this slide's markup? null when the selector is too complex to
88
+ // tell without a browser.
89
+ export function selectorMatches(body, selector) {
90
+ const m = /^([a-zA-Z][a-zA-Z0-9-]*)?(?:#([\w-]+))?((?:\.[\w-]+)*)$/.exec(selector.trim());
91
+ if (!m || !(m[1] || m[2] || m[3])) return null;
92
+ const [, tag, id, cls] = m;
93
+ const classes = cls.split(".").filter(Boolean);
94
+ return [...uncommented(body).matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)].some(([open, name]) => {
95
+ if (tag && name.toLowerCase() !== tag.toLowerCase()) return false;
96
+ if (id && attr(open, "id") !== id) return false;
97
+ const have = (attr(open, "class") ?? "").split(/\s+/);
98
+ return classes.every((c) => have.includes(c));
99
+ });
100
+ }
101
+
102
+ // [go: <selector>] and [look: <selector>] in script.md must match something in
103
+ // their slide ([go: slot] returns home and always resolves).
104
+ export function cueSelectorErrors(html, script = "") {
105
+ const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
106
+ const errs = [];
107
+ let id = null;
108
+ script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
109
+ const heading = /^##\s+(\S+)/.exec(line);
110
+ if (heading) { id = heading[1]; return; }
111
+ if (!id || !bodies.has(id)) return;
112
+ for (const [, cue, raw] of line.matchAll(/\[\s*(go|look)\s*:\s*([^\]]*?)\s*\]/gi)) {
113
+ if (raw === "slot" || !raw) continue;
114
+ if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
115
+ }
116
+ });
117
+ return errs;
118
+ }
119
+
50
120
  // Same normalization as the source check: accents folded, case and punctuation dropped.
51
121
  export const normalizeWords = (text) => text.normalize("NFKD").replace(/\p{Diacritic}/gu, "").toLowerCase().replace(/[^a-z0-9]/g, "");
52
122
 
@@ -64,13 +134,13 @@ export function playerScript(html) {
64
134
  return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
65
135
  }
66
136
 
67
- // Relative references: src, href and poster (either quote), srcset candidates and
137
+ // Relative references: src, href, poster and data-presenter-image (either quote), srcset candidates and
68
138
  // CSS url(…) in style attributes and <style> blocks; everything that is not a URL
69
139
  // scheme, protocol-relative or a fragment. Query strings and fragments dropped.
70
140
  export function localRefs(html) {
71
141
  const doc = uncommented(html);
72
142
  const raw = [
73
- ...[...doc.matchAll(/\s(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
143
+ ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
74
144
  ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
75
145
  ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
76
146
  ];
@@ -88,10 +158,14 @@ function phraseList(value) {
88
158
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
89
159
  // local reference resolves inside the deck folder: true, false or "outside".
90
160
  export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
91
- const errs = [];
161
+ const errs = [...presenterStyleErrors(html, script), ...cueSelectorErrors(html, script)];
92
162
  const player = playerScript(html);
93
163
  if (!player) errs.push(MESSAGES.noPlayer());
94
164
  else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
165
+ // The loading state must be in <head> to hold the page from its first paint,
166
+ // with its fail-open so a deck whose player never loads still shows.
167
+ const loading = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>([\s\S]*?)<\/style>/i.exec(/<head\b[\s\S]*?<\/head>/i.exec(uncommented(html))?.[0] ?? "");
168
+ if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
95
169
  const all = slides(html);
96
170
  if (!all.length) errs.push(MESSAGES.noSlides());
97
171
  all.forEach((s, i) => {
@@ -142,6 +216,9 @@ export function manifestErrors(manifest, slideIds, { maxDuration = DEFAULT_MAX_D
142
216
  const declared = Number(manifest?.totalDuration);
143
217
  if (!(Math.abs(sum - declared) < 0.01)) errs.push(MESSAGES.total(sum, manifest?.totalDuration));
144
218
  if (Number.isFinite(declared) && declared > maxDuration) errs.push(MESSAGES.cap(round(declared), maxDuration));
219
+ const badStyle = (v) => v !== undefined && !PRESENTER_STYLES.includes(v);
220
+ if (badStyle(manifest?.presenterStyle)) errs.push(MESSAGES.presenterStyle("manifest", manifest.presenterStyle));
221
+ for (const c of clips) if (badStyle(c?.presenterStyle)) errs.push(MESSAGES.presenterStyle(`clip ${c.id}`, c.presenterStyle));
145
222
  for (const phrase of phraseList(forbid)) {
146
223
  for (const c of clips) if (String(c?.text ?? "").toLowerCase().includes(phrase.toLowerCase())) errs.push(MESSAGES.forbidden(phrase, `clip ${c.id}`));
147
224
  }
@@ -157,8 +234,12 @@ export function timelineErrors(clip, timeline) {
157
234
  captions.forEach((c, i) => {
158
235
  if (!(c.start >= 0 && c.end > c.start && c.end <= duration + 0.1)) errs.push(MESSAGES.timing(clip.id, i, c.start, c.end, duration));
159
236
  });
160
- const spoken = captions.flatMap((c) => (Array.isArray(c.words) ? c.words.map((w) => w?.text ?? "") : [])).join(" ");
161
- if (normalizeWords(spoken) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
237
+ // Caption text carries the script's spelling; the timed words carry the
238
+ // transcript's (which may spell a name differently, as the narrate guard's
239
+ // pronunciation map allows), so coverage is checked on the text.
240
+ const covered = captions.map((c) => String(c.text ?? "")).join(" ");
241
+ const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
242
+ if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
162
243
  captions.forEach((c, i) => {
163
244
  const text = String(c.text ?? "");
164
245
  if (text.length < CAPTION_MIN_CHARS || text.length > CAPTION_MAX_CHARS) errs.push(MESSAGES.captionLength(clip.id, i, text));
@@ -166,6 +247,130 @@ export function timelineErrors(clip, timeline) {
166
247
  return errs;
167
248
  }
168
249
 
250
+ // Each slide's presenter style, as the player resolves it at desktop size: the
251
+ // slide's data-presenter-style, its clip's, the player tag's, the manifest's,
252
+ // then stage for the live head and bubble otherwise.
253
+ export function slideStyles(html, manifest = null) {
254
+ const page = uncommented(html);
255
+ const tag = [...page.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "";
256
+ const known = (v) => (PRESENTER_STYLES.includes(v) ? v : null);
257
+ const deck = known(attr(tag, "data-presenter-style")) ?? known(manifest?.presenterStyle) ?? (attr(tag, "data-presenter") === "head" ? "stage" : "bubble");
258
+ const clips = new Map((Array.isArray(manifest?.clips) ? manifest.clips : []).map((c) => [c?.id, c]));
259
+ return slides(html).map((s) => ({ id: s.id, style: known(attr(s.tag, "data-presenter-style")) ?? known(clips.get(s.id)?.presenterStyle) ?? deck }));
260
+ }
261
+
262
+ // What each style occupies at desktop size, in stage pixels (player.css): the
263
+ // head plus two caption lines. Corner sits in the stage's corner, not the slot.
264
+ export const PRESENTER_FOOTPRINT = {
265
+ stage: { width: 480, height: 490 },
266
+ glass: { width: 522, height: 534 },
267
+ bubble: { width: 446, height: 304 },
268
+ corner: { width: 400, height: 330, right: 28, bottom: 28 },
269
+ };
270
+
271
+ // Measured boxes → warnings. Each entry: { id, style, presenter, title } with
272
+ // { x, y, width, height } boxes (title null when the slide has none).
273
+ export function overlapWarnings(measured, stage = "1600×900") {
274
+ const hit = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 4 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 4;
275
+ return measured.filter((m) => hit(m.presenter, m.title)).map((m) => MESSAGES.overlap(m.id, m.style, stage));
276
+ }
277
+
278
+ // Slides whose content overflows the phone card, as errors. Each entry: { id, overflow } in px.
279
+ export function phoneOverflowErrors(measured) {
280
+ return measured.filter((m) => m.overflow > 1).map((m) => MESSAGES.phoneOverflow(m.id, Math.ceil(m.overflow)));
281
+ }
282
+
283
+ // Lay each slide out in Chromium at the deck's stage size with JavaScript off
284
+ // (no player, no deck scripts), put the style's footprint in its slot, and
285
+ // measure it against the title's text. Needs `hyperdecks preview setup`.
286
+ export async function measureLayout(deckDir, styles, { width = 1600, height = 900 } = {}) {
287
+ return withDeckPage(deckDir, { width, height }, (page) => page.evaluate(({ styles, footprint, width, height }) => {
288
+ const box = (r) => ({ x: r.left, y: r.top, width: r.width, height: r.height });
289
+ const sections = [...document.querySelectorAll("section.slide")];
290
+ const host = sections[0]?.parentElement ?? document.body;
291
+ Object.assign(host.style, { position: "relative", width: `${width}px`, height: `${height}px`, overflow: "hidden", margin: "0" });
292
+ document.body.style.margin = "0";
293
+ return sections.map((slide, i) => {
294
+ sections.forEach((s) => { s.style.display = s === slide ? "" : "none"; Object.assign(s.style, { position: "absolute", inset: "0" }); });
295
+ const { id, style } = styles[i] ?? { id: slide.id, style: "bubble" };
296
+ const size = footprint[style];
297
+ const probe = document.createElement("div");
298
+ Object.assign(probe.style, { width: `${size.width}px`, height: `${size.height}px`, flex: "none" });
299
+ const slot = slide.querySelector(".narration-slot");
300
+ let presenter = null;
301
+ if (style === "corner" || !slot) {
302
+ const right = size.right ?? 56, bottom = size.bottom ?? 56;
303
+ presenter = { x: width - right - size.width, y: height - bottom - size.height, width: size.width, height: size.height };
304
+ } else {
305
+ slot.append(probe);
306
+ presenter = box(probe.getBoundingClientRect());
307
+ probe.remove();
308
+ }
309
+ const heading = slide.querySelector("h1, h2");
310
+ let title = null;
311
+ if (heading) {
312
+ const range = document.createRange();
313
+ range.selectNodeContents(heading);
314
+ const rects = [...range.getClientRects()].filter((r) => r.width && r.height);
315
+ if (rects.length) {
316
+ const [x0, y0] = [Math.min(...rects.map((r) => r.left)), Math.min(...rects.map((r) => r.top))];
317
+ title = { x: x0, y: y0, width: Math.max(...rects.map((r) => r.right)) - x0, height: Math.max(...rects.map((r) => r.bottom)) - y0 };
318
+ }
319
+ }
320
+ return { id, style, presenter, title };
321
+ });
322
+ }, { styles, footprint: PRESENTER_FOOTPRINT, width, height }));
323
+ }
324
+
325
+ // Lay each slide out as the framed player shows it on a phone: the deck's own
326
+ // phone rules at a 390×844 viewport, its mobile titles, inside the card, with
327
+ // JavaScript off. Returns { id, overflow } per slide: how far its content runs
328
+ // past the slide's bottom (≤ 0 when it fits).
329
+ export async function measurePhoneFit(deckDir) {
330
+ return withDeckPage(deckDir, { width: PHONE.width, height: PHONE.height }, (page) => page.evaluate((card) => {
331
+ for (const n of document.querySelectorAll("[data-title-mobile]")) n.textContent = n.dataset.titleMobile;
332
+ const sections = [...document.querySelectorAll("section.slide")];
333
+ const host = sections[0]?.parentElement ?? document.body;
334
+ Object.assign(host.style, { position: "relative", width: `${card.width}px`, height: `${card.height}px`, overflow: "hidden", margin: "0" });
335
+ document.body.style.margin = "0";
336
+ return sections.map((slide) => {
337
+ sections.forEach((s) => { s.style.display = s === slide ? "" : "none"; Object.assign(s.style, { position: "absolute", inset: "0", overflow: "hidden" }); });
338
+ return { id: slide.id, overflow: slide.scrollHeight - slide.clientHeight };
339
+ });
340
+ }, PHONE.card));
341
+ }
342
+
343
+ // Open the deck's index.html in Chromium at `viewport` with JavaScript off and
344
+ // hand the page to `measure`. Needs `hyperdecks preview setup`.
345
+ async function withDeckPage(deckDir, viewport, measure) {
346
+ const { chromium } = await import("playwright");
347
+ const { serveSource, routeCaptureRequest } = await import("./preview-capture.mjs");
348
+ const { mkdtemp, rm } = await import("node:fs/promises");
349
+ const { tmpdir } = await import("node:os");
350
+ // The same isolation as preview capture: a throwaway HOME, the sandbox on
351
+ // Linux, and its request filter (no loopback, private or metadata hosts).
352
+ const temp = await mkdtemp(join(tmpdir(), "hyperdecks-layout-"));
353
+ let browser, server;
354
+ try {
355
+ try {
356
+ browser = await chromium.launch({ headless: true, timeout: 30000, chromiumSandbox: process.platform === "linux", env: { ...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp } });
357
+ } catch {
358
+ throw new Error("check --layout needs Chromium: run `hyperdecks preview setup`");
359
+ }
360
+ server = await serveSource({ file: join(deckDir, "index.html") });
361
+ const context = await browser.newContext({ viewport, javaScriptEnabled: false, serviceWorkers: "block" });
362
+ await context.route("**/*", (route) => routeCaptureRequest(route, server.url));
363
+ const page = await context.newPage();
364
+ await page.goto(server.url, { waitUntil: "load", timeout: 30000 });
365
+ await page.evaluate(() => document.fonts.ready);
366
+ return await measure(page);
367
+ } finally {
368
+ await server?.close().catch(() => {});
369
+ await browser?.close().catch(() => {});
370
+ await rm(temp, { recursive: true, force: true }).catch(() => {});
371
+ }
372
+ }
373
+
169
374
  // The deck's own cap: `maxTotalSeconds` in script.md's front matter.
170
375
  export function scriptCap(script) {
171
376
  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(script)?.[1] ?? "";
@@ -228,7 +433,7 @@ const FETCH_TIMEOUT_MS = 30_000;
228
433
  const JSON_TYPE = /json/;
229
434
 
230
435
  // Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
231
- export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd } = {}) {
436
+ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
232
437
  const deckDir = resolveDeckDir(dirArg, cwd);
233
438
  const html = readFileSync(join(deckDir, "index.html"), "utf8");
234
439
  const scriptPath = join(deckDir, "script.md");
@@ -292,6 +497,24 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
292
497
  errors.push(...timelineErrors(clip, timeline));
293
498
  }
294
499
  }
500
+ // A presenter over the title is a warning, not a failure: only a person can
501
+ // judge it. A slide that overflows the phone card is an error. Both need a
502
+ // browser, so they run only with --layout.
503
+ const warnings = [];
504
+ if (opts.layout) {
505
+ const [w, h] = /^(\d{3,4})x(\d{3,4})$/.exec(attr([...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "", "data-stage") ?? "")?.slice(1).map(Number) ?? [1600, 900];
506
+ try {
507
+ warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
508
+ } catch (e) {
509
+ warnings.push(MESSAGES.layoutUnchecked(e.message));
510
+ }
511
+ // A slide that scrolls on a phone fails: the player never scrolls a slide.
512
+ try {
513
+ errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
514
+ } catch (e) {
515
+ warnings.push(MESSAGES.layoutUnchecked(e.message));
516
+ }
517
+ }
295
518
  return {
296
519
  deckDir,
297
520
  slug: opts.slug || basename(deckDir),
@@ -300,6 +523,7 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
300
523
  manifestUrl: player?.manifest ?? null,
301
524
  passed: errors.length === 0,
302
525
  errors,
526
+ warnings: [...new Set(warnings)],
303
527
  };
304
528
  }
305
529
 
@@ -311,6 +535,7 @@ export async function check(pos, opts = {}, { log = (line) => console.log(line),
311
535
  log(`✗ ${result.errors.length} issue(s):`);
312
536
  for (const e of result.errors) log(` - ${e}`);
313
537
  } else log("✓ check passed");
538
+ for (const w of result.warnings) log(` ⚠ ${w}`);
314
539
  }
315
540
  return result;
316
541
  }
package/src/commands.mjs CHANGED
@@ -241,9 +241,10 @@ commands:
241
241
  logout [--dev]
242
242
  whoami [--json] [--dev]
243
243
  deck list|stats|open|rm
244
- deck new <slug> "<title>" --narrated [--dir <path>]
245
- scaffold decks/<slug>/: index.html (8 slides, player/v1), script.md, evidence.md
246
- check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
244
+ deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
245
+ scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
246
+ layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
247
+ check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
247
248
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
248
249
  local refs, pinned player, a data-note per slide, mobile titles
249
250
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
@@ -256,11 +257,24 @@ commands:
256
257
  skill install [--agent codex,claude-code] [--all-agents]
257
258
  skills install [--agent codex,claude-code] [--all-agents]
258
259
  activity [--deck <slug>] [--limit <n>] [--json]
259
- gen-image "<prompt>" [--transparent] [--image <url>] [--size <s>] [--quality <q>] [--count <n>] [--slug <deck>] [--download <file>]
260
+ gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--transparent] [--image <url>] [--size <s>]
261
+ [--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
262
+ default model gpt-image-2.5; --seed applies to nano-banana-2.1
260
263
  image-job <jobId> [--json]
261
- gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
262
- a 3–10s clip (no audio) via fal; --image animates that still and keeps its aspect
264
+ gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
265
+ a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
263
266
  video-job <jobId> [--json]
267
+ screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
268
+ a PNG of a public web page (private and local addresses are refused)
269
+ chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
270
+ a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
271
+ icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
272
+ a style-consistent set of transparent icons, one per subject (max 12)
273
+ review <slug> [--script <script.md>] [--json]
274
+ GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
275
+ review-job <jobId> [--json]
276
+ poster <slug> [--force] [--json]
277
+ regenerate the share-link poster (release makes one) and print its URL
264
278
  credits [--limit <n>] [--json] show your credit balance and recent history
265
279
 
266
280
  preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
@@ -289,7 +303,7 @@ async function genImage(pos, opts) {
289
303
  const prompt = pos.join(" ").trim() || opts.prompt;
290
304
  if (!prompt) {
291
305
  throw new Error(
292
- 'usage: gen-image "<prompt>" [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--slug <deck>] [--name <name>] [--download <file>]',
306
+ 'usage: gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--seed <n>] [--slug <deck>] [--name <name>] [--download <file>]',
293
307
  );
294
308
  }
295
309
  let out = await ops.generateImage(opts, {
@@ -300,6 +314,8 @@ async function genImage(pos, opts) {
300
314
  quality: opts.quality,
301
315
  count: opts.count,
302
316
  format: opts.format,
317
+ model: opts.model,
318
+ seed: opts.seed,
303
319
  slug: opts.slug,
304
320
  name: opts.name,
305
321
  });
@@ -348,19 +364,20 @@ async function imageJob(pos, opts) {
348
364
  async function genVideo(pos, opts) {
349
365
  const prompt = pos.join(" ").trim() || opts.prompt;
350
366
  if (!prompt) {
351
- throw new Error('usage: gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
367
+ throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
352
368
  }
353
369
  const queued = await ops.generateVideo(opts, {
354
370
  prompt,
355
371
  image: opts.image,
356
372
  seconds: opts.seconds,
357
373
  aspect: opts.aspect,
374
+ model: opts.model,
358
375
  slug: opts.slug,
359
376
  name: opts.name,
360
377
  });
361
378
  // A clip takes a minute or more; poll so gen-video stays one blocking command.
362
379
  // Progress goes to stderr so stdout stays the URL (or --json).
363
- process.stderr.write(`queued a ${queued.seconds}s clip — job ${queued.jobId}\n`);
380
+ process.stderr.write(`queued a ${queued.seconds}s ${queued.model} clip — job ${queued.jobId}\n`);
364
381
  const job = await ops.pollVideoJob(opts, queued.jobId, {
365
382
  onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
366
383
  });
@@ -391,6 +408,112 @@ async function videoJob(pos, opts) {
391
408
  if (job.video?.url) console.log(job.video.url);
392
409
  }
393
410
 
411
+ async function screenshot(pos, opts) {
412
+ const [url] = pos;
413
+ if (!url) throw new Error("usage: screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]");
414
+ const out = await ops.captureUrl(opts, {
415
+ url,
416
+ width: opts.width,
417
+ height: opts.height,
418
+ fullPage: opts["full-page"],
419
+ slug: opts.slug,
420
+ name: opts.name,
421
+ });
422
+ // Print the paid asset's URL before downloading, so a failed download never hides it.
423
+ console.log(opts.json ? JSON.stringify(out, null, 2) : out.url);
424
+ if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
425
+ await saveTo(opts.download, out.url);
426
+ }
427
+
428
+ async function chart(pos, opts) {
429
+ const [file] = pos;
430
+ if (!file) throw new Error("usage: chart <spec.json> [--slug <deck>] [--name <name>] [--download <file>]");
431
+ let spec;
432
+ try {
433
+ spec = JSON.parse(file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8"));
434
+ } catch (e) {
435
+ throw new Error(`could not read chart spec ${file}: ${e.message}`);
436
+ }
437
+ const out = await ops.renderChart(opts, { ...spec, slug: opts.slug ?? spec.slug, name: opts.name ?? spec.name });
438
+ console.log(opts.json ? JSON.stringify(out, null, 2) : out.url);
439
+ if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
440
+ await saveTo(opts.download, out.url);
441
+ }
442
+
443
+ async function icons(pos, opts) {
444
+ if (!pos.length || !opts.style) {
445
+ throw new Error('usage: icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--model nano-banana-2.1|gpt-image-2.5|gpt-image-2] [--slug <deck>] [--name <prefix>]');
446
+ }
447
+ const queued = await ops.generateIconSet(opts, {
448
+ subjects: pos,
449
+ style: opts.style,
450
+ palette: opts.palette ? String(opts.palette).split(",").map((c) => c.trim()).filter(Boolean) : undefined,
451
+ model: opts.model,
452
+ slug: opts.slug,
453
+ name: opts.name,
454
+ });
455
+ process.stderr.write(`queued ${queued.count} icon(s) — job ${queued.jobId}\n`);
456
+ const out = await ops.pollImageJob(opts, queued.jobId, {
457
+ onProgress: (job) => process.stderr.write(` ${job.progress} (${job.status})…\r`),
458
+ });
459
+ process.stderr.write("\n");
460
+ if (opts.json) return void console.log(JSON.stringify({ ...out, subjects: queued.subjects }, null, 2));
461
+ (out.images || []).forEach((im, i) => console.log(`${queued.subjects[i] ?? im.name}\t${im.url}`));
462
+ if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
463
+ }
464
+
465
+ function printReview(job) {
466
+ console.log(`review of ${job.slug}: ${job.fix} to fix, ${job.consider} to consider (${job.progress} slides, ${job.model})`);
467
+ for (const f of job.findings) {
468
+ console.log(` [${f.severity}] ${f.slide} (${f.viewport}, ${f.category}): ${f.issue}`);
469
+ console.log(` → ${f.suggestion}`);
470
+ }
471
+ for (const s of job.failedSlides || []) console.log(` ! ${s.slide}: not reviewed (${s.error}); not charged`);
472
+ if (!job.findings.length && !job.failedSlides?.length) console.log(" no findings: every slide looks clean");
473
+ }
474
+
475
+ async function review(pos, opts) {
476
+ const [slug] = pos;
477
+ if (!slug) throw new Error("usage: review <slug> [--script <script.md>] [--json]");
478
+ const script = opts.script ? readFileSync(opts.script, "utf8") : undefined;
479
+ const queued = await ops.reviewDeck(opts, { slug, script });
480
+ process.stderr.write(`reviewing ${queued.slides} slide(s) on desktop and phone — job ${queued.jobId}\n`);
481
+ const job = await ops.pollReviewJob(opts, queued.jobId, {
482
+ onProgress: (j) => process.stderr.write(` ${j.progress} slides…\r`),
483
+ });
484
+ process.stderr.write("\n");
485
+ if (opts.json) console.log(JSON.stringify(job, null, 2));
486
+ else printReview(job);
487
+ }
488
+
489
+ async function reviewJob(pos, opts) {
490
+ const [jobId] = pos;
491
+ if (!jobId) throw new Error("usage: review-job <jobId>");
492
+ const job = await ops.checkReviewJob(opts, jobId);
493
+ if (opts.json) return void console.log(JSON.stringify(job, null, 2));
494
+ console.log(`status: ${job.status}`);
495
+ if (job.error) console.log(`error: ${job.error}`);
496
+ printReview(job);
497
+ }
498
+
499
+ async function poster(pos, opts) {
500
+ const [slug] = pos;
501
+ if (!slug) throw new Error("usage: poster <slug> [--force] [--json]");
502
+ const out = await ops.generatePoster(opts, { slug, force: opts.force });
503
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
504
+ if (out.unchanged) process.stderr.write("poster already matches the deck (pass --force to re-render)\n");
505
+ console.log(out.url);
506
+ }
507
+
508
+ // Download a hosted asset to `file` when --download was given.
509
+ async function saveTo(file, url) {
510
+ if (!file || !url) return;
511
+ const res = await fetch(url);
512
+ if (!res.ok) throw new Error(`download of ${url} failed (${res.status})`);
513
+ writeFileSync(file, Buffer.from(await res.arrayBuffer()));
514
+ process.stderr.write(`saved to ${file}\n`);
515
+ }
516
+
394
517
  // The command table: "<group> <cmd>" or "<group>" → handler. Exported (via
395
518
  // COMMANDS) so the CLI/MCP parity test can enumerate it.
396
519
  function commandTable(pos, rest, opts) {
@@ -473,6 +596,12 @@ function commandTable(pos, rest, opts) {
473
596
  "image-job": () => imageJob(pos.slice(1), opts),
474
597
  "gen-video": () => genVideo(pos.slice(1), opts),
475
598
  "video-job": () => videoJob(pos.slice(1), opts),
599
+ screenshot: () => screenshot(pos.slice(1), opts),
600
+ chart: () => chart(pos.slice(1), opts),
601
+ icons: () => icons(pos.slice(1), opts),
602
+ review: () => review(pos.slice(1), opts),
603
+ "review-job": () => reviewJob(pos.slice(1), opts),
604
+ poster: () => poster(pos.slice(1), opts),
476
605
  credits: () => credits(pos.slice(1), opts),
477
606
  };
478
607
  }
package/src/importer.mjs CHANGED
@@ -158,7 +158,7 @@ export function rewriteTextAssetUrls(text, sourcePath, assets, uploadedByName) {
158
158
  export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
159
159
  const assetMap = buildAssetUrlMap(assets, uploadedByName, htmlEntry);
160
160
  let out = html.replace(
161
- /\b(src|href|poster)\s*=\s*(["'])([^"']+)\2/gi,
161
+ /\b(src|href|poster|data-presenter-image)\s*=\s*(["'])([^"']+)\2/gi,
162
162
  (match, attr, quote, raw) => {
163
163
  const url = resolveLocalUrl(raw, assetMap);
164
164
  return url ? `${attr}=${quote}${url}${quote}` : match;
package/src/narrate.mjs CHANGED
@@ -52,7 +52,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
52
52
  const { deckDir, fields } = narrationRequest(pos, opts);
53
53
  if (opts["dry-run"]) {
54
54
  const preview = await ops.previewNarration(opts, fields);
55
- for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars`);
55
+ if (preview.presenterStyle) log(`presenter style: ${preview.presenterStyle}`);
56
+ for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars${s.presenterStyle ? ` presenter-style: ${s.presenterStyle}` : ""}`);
56
57
  const render = preview.slides.filter((s) => s.render);
57
58
  log(`${render.length} of ${preview.slides.length} slide(s) would render in voice "${preview.voice}", ~${render.reduce((n, s) => n + s.chars, 0)} characters of TTS`);
58
59
  return preview;