@hypersocial/decks-cli 0.6.0 → 0.8.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.
@@ -0,0 +1,31 @@
1
+ // The page a brand import (brandImport.mjs, convex/brandDrafts.ts) is shown on
2
+ // before it is saved: a hero and a features slide from the layout library, in
3
+ // the draft's colours, fonts and logo (applyBrand, the same code new decks
4
+ // use). Served at /p/brand/<token> (app/p/brand/[token]/route.ts), and with
5
+ // { saved: true } for the saved brand on the dashboard's Brand tab
6
+ // (app/dashboard/brand/sample/route.ts).
7
+ import { applyBrand } from "./brand.mjs";
8
+ import { assembleLayoutDeck } from "./layouts.mjs";
9
+
10
+ const SAMPLE_CSS = ".hd-embedded :is(.hd-gate, .hd-controls, .hd-chapters, .hd-presenter, .hd-slide-note, .hd-note, .hd-flash) { display: none !important; }";
11
+ // The slides a sample shows, each whole in its own 16:9 frame.
12
+ export const SAMPLE_SLIDES = ["hero", "features"];
13
+
14
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
15
+
16
+ export function brandPreviewHtml(draft, { saved = false } = {}) {
17
+ const name = (draft?.name ?? "").trim() || "Your brand";
18
+ let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: SAMPLE_SLIDES })["index.html"];
19
+ // The hero's art slot is a stand-in a real deck fills; the preview has no art.
20
+ html = html.replace(/\s*<div class="art" role="presentation"><\/div>/, "");
21
+ html = html.replace(/(<div class="kicker">)brand-preview(<\/div>)/, saved ? "$1Your brand$2" : "$1Brand preview$2");
22
+ html = html.replace(
23
+ "One sentence on what this deck is about.",
24
+ saved ? "Your colours, fonts and logo on a sample slide. Every new deck starts from this." : "Your colours, fonts and logo on a sample slide. Nothing is saved until you say yes.",
25
+ );
26
+ // Framed on the Brand page (one iframe per slide, #scene=hero / #scene=features),
27
+ // the sample is a picture of the slide: no play gate, controls or presenter.
28
+ html = html.replace("</head>", () => `<style data-brand-sample>${SAMPLE_CSS}</style>\n</head>`);
29
+ html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(name)} · ${saved ? "brand" : "brand preview"}</title>`);
30
+ return applyBrand(html, { name: draft?.name ?? undefined, colors: draft?.colors ?? {}, fonts: draft?.fonts ?? {}, logoUrl: draft?.logoUrl ?? undefined });
31
+ }
@@ -41,6 +41,8 @@ export const MESSAGES = {
41
41
  mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
42
42
  slideId: (n) => `slide ${n} has no id`,
43
43
  selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
44
+ gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
45
+ gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
44
46
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
45
47
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
46
48
  zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
@@ -217,8 +219,33 @@ export function selectorMatches(body, selector) {
217
219
  });
218
220
  }
219
221
 
220
- // [go: <selector>] and [look: <selector>] in script.md must match something in
221
- // their slide ([go: slot] returns home and always resolves).
222
+ // The Minimoji head's gestures, for [gesture: <id>]: lib/deckScript.ts GESTURES
223
+ // (the published CLI cannot import it; a test keeps the two equal).
224
+ export const GESTURES = [
225
+ "big-double-laugh", "boiling-point", "bright-tilt", "buoyant-pop", "chuckle-bob", "clipped-huff", "comfort-tilt",
226
+ "curious-glance", "deep-snore", "delighted-sway", "dodge-glance", "double-take", "dream-twitch", "final-flop",
227
+ "fold-inward", "freeze-and-shiver", "hard-recoil", "head-shake", "knowing-nod", "lean-out-laugh", "lightbulb",
228
+ "look-away", "low-simmer", "nervous-scan", "nod-on-the-beat", "one-sided-lean", "other-side", "party-bounce",
229
+ "ponder-tilt", "pop-back", "pressure-build", "quiet-sigh", "radiant-lift", "shaky-sniff", "sharp-glare",
230
+ "side-eye-hmm", "sideways-flinch", "sleepy-sway", "slow-droop", "snap-and-fume", "sob-heave", "soft-reset",
231
+ "spark-salute", "sunny-nod", "thinking-it-over", "tiny-aftershock", "tiny-double-take", "tiny-wince", "uneasy-sway",
232
+ ];
233
+
234
+ // [gesture: <id>] in script.md must name one of the head's gestures.
235
+ export function cueGestureErrors(script = "") {
236
+ const errs = [];
237
+ script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
238
+ for (const [, colon, raw] of line.matchAll(/\[\s*gesture\b\s*(:?)\s*([^\]]*?)\s*\]/gi)) {
239
+ if (!GESTURES.includes(raw.toLowerCase())) errs.push(MESSAGES.gesture(i + 1, raw));
240
+ else if (!colon) errs.push(MESSAGES.gestureColon(i + 1, raw));
241
+ }
242
+ });
243
+ return errs;
244
+ }
245
+
246
+ // [go: <selector>] and [look: <selector>] (or [look <selector>]) in script.md
247
+ // must match something in their slide ([go: slot] returns home, and [look
248
+ // viewer], [look away] and [look back] need no element).
222
249
  export function cueSelectorErrors(html, script = "") {
223
250
  const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
224
251
  const errs = [];
@@ -227,8 +254,9 @@ export function cueSelectorErrors(html, script = "") {
227
254
  const heading = /^##\s+(\S+)/.exec(line);
228
255
  if (heading) { id = heading[1]; return; }
229
256
  if (!id || !bodies.has(id)) return;
230
- for (const [, cue, raw] of line.matchAll(/\[\s*(go|look)\s*:\s*([^\]]*?)\s*\]/gi)) {
231
- if (raw === "slot" || !raw) continue;
257
+ for (const [, colon, spaced, raw] of line.matchAll(/\[\s*(?:(go|look)\s*:\s*|(look)\s+)([^\]]*?)\s*\]/gi)) {
258
+ const cue = colon ?? spaced;
259
+ if (!raw || raw === "slot" || (cue.toLowerCase() === "look" && /^(viewer|away|back)$/i.test(raw))) continue;
232
260
  if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
233
261
  }
234
262
  });
@@ -252,13 +280,13 @@ export function playerScript(html) {
252
280
  return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
253
281
  }
254
282
 
255
- // Relative references: src, href, poster and data-presenter-image (either quote), srcset candidates and
283
+ // Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
256
284
  // CSS url(…) in style attributes and <style> blocks; everything that is not a URL
257
285
  // scheme, protocol-relative or a fragment. Query strings and fragments dropped.
258
286
  export function localRefs(html) {
259
287
  const doc = uncommented(html);
260
288
  const raw = [
261
- ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
289
+ ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
262
290
  ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
263
291
  ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
264
292
  ];
@@ -306,7 +334,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
306
334
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
307
335
  // local reference resolves inside the deck folder: true, false or "outside".
308
336
  export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
309
- const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...placeholderErrors(html, { hosted })];
337
+ const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
310
338
  const player = playerScript(html);
311
339
  if (!player) errs.push(MESSAGES.noPlayer());
312
340
  else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
@@ -386,9 +414,9 @@ export function timelineErrors(clip, timeline) {
386
414
  captions.forEach((c, i) => {
387
415
  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));
388
416
  });
389
- // Caption text carries the script's spelling; the timed words carry the
390
- // transcript's (which may spell a name differently, as the narrate guard's
391
- // pronunciation map allows), so coverage is checked on the text.
417
+ // Caption text carries the script's spelling; the timed words of an older
418
+ // clip carry the transcript's (which may spell a name differently, as the
419
+ // narrate guard's pronunciation map allows), so coverage is checked on the text.
392
420
  const covered = captions.map((c) => String(c.text ?? "")).join(" ");
393
421
  const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
394
422
  if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
@@ -487,7 +515,7 @@ export function stageSize(html) {
487
515
  export function absoluteRefs(html) {
488
516
  const doc = uncommented(html);
489
517
  const raw = [
490
- ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
518
+ ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
491
519
  ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
492
520
  ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
493
521
  ];
package/src/commands.mjs CHANGED
@@ -322,6 +322,9 @@ commands:
322
322
  file an image, video or font under the deck; prints its hosted URL
323
323
  guide [workflow|format|script|design|layouts|presenter|transitions|check]
324
324
  the skill, one topic at a time (what deck_guide serves)
325
+ play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
326
+ play the folder as it is, committed or not, with its real face and voice, on 127.0.0.1
327
+ until Ctrl-C; nothing is written, uploaded or shared, and changed lines need narrate first
325
328
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
326
329
  on a committed deck: check → narrate → import → share new per --to
327
330
  share new|list|on|off|forward|mute|unmute|rm|url
@@ -342,8 +345,10 @@ commands:
342
345
  [--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
343
346
  default model gpt-image-2.5; --seed applies to nano-banana-2.1
344
347
  image-job <jobId> [--json]
345
- gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
348
+ gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]
346
349
  a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
350
+ --loop starts and ends on one frame (no jump at the seam); --transparent cuts the subject
351
+ out as a WebM + a stacked-alpha MP4 for Safari (prints both URLs and the <video> markup)
347
352
  video-job <jobId> [--json]
348
353
  screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
349
354
  a PNG of a public web page (private and local addresses are refused)
@@ -356,7 +361,16 @@ commands:
356
361
  brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
357
362
  brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
358
363
  [--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
359
- [--presenter-style s] [--design <file.md|->] [--name "<company>"]
364
+ [--presenter-style s] [--design <file.md|->] [--name "<company>"] [--draft <token>]
365
+ brand import <url|file|slug> [--json]
366
+ read a brand from your website, a .pptx / .potx / .pdf or one of your decks: shows its
367
+ colours, fonts and logo and a preview link; save it with brand set --draft <token>
368
+ brand revise "<change>" [--json]
369
+ a draft of your brand with that change ("darker accent, serif titles") and a
370
+ preview link; save it with brand set --draft <token>
371
+ brand history the brand's last changes, from the dashboard, chat or here
372
+ brand undo [<version>]
373
+ undo the last change, or go back to a version from brand history
360
374
  brand edit edit design.md (voice, do and don't) in $EDITOR
361
375
  brand fonts the curated font pairings (display + body + mono)
362
376
  brand clear forget the brand; new decks get the template look
@@ -388,7 +402,7 @@ commands:
388
402
  avatar connect import your avatar from Minimoji with a code you enter on minimoji.com
389
403
  avatar default <name|id|dash> make it the default
390
404
  avatar remove <name|id> remove it; decks already shared keep working, captions only
391
- deck avatar <slug> <name|id|none|default> the deck's own presenter (applies at its next release)
405
+ deck avatar <slug> <name|id|none|default> the deck's own presenter (play and the draft link now; share links at its next release)
392
406
  narrator [--json] who narrates your decks (a face plus a voice) and the default
393
407
  narrator new a link where you pick a face and a voice; it becomes the default
394
408
  narrator default <name|id|dash> make it the default
@@ -472,7 +486,7 @@ async function imageJob(pos, opts) {
472
486
  async function genVideo(pos, opts) {
473
487
  const prompt = pos.join(" ").trim() || opts.prompt;
474
488
  if (!prompt) {
475
- 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>]');
489
+ throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]');
476
490
  }
477
491
  const queued = await ops.generateVideo(opts, {
478
492
  prompt,
@@ -480,12 +494,15 @@ async function genVideo(pos, opts) {
480
494
  seconds: opts.seconds,
481
495
  aspect: opts.aspect,
482
496
  model: opts.model,
497
+ loop: opts.loop,
498
+ transparent: opts.transparent,
483
499
  slug: opts.slug,
484
500
  name: opts.name,
485
501
  });
486
502
  // A clip takes a minute or more; poll so gen-video stays one blocking command.
487
503
  // Progress goes to stderr so stdout stays the URL (or --json).
488
- process.stderr.write(`queued a ${queued.seconds}s ${queued.model} clip — job ${queued.jobId}\n`);
504
+ const kind = [queued.loop && "looping", queued.transparent && "transparent"].filter(Boolean).join(" ");
505
+ process.stderr.write(`queued a ${queued.seconds}s ${kind ? `${kind} ` : ""}${queued.model} clip — job ${queued.jobId}\n`);
489
506
  const job = await ops.pollVideoJob(opts, queued.jobId, {
490
507
  onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
491
508
  });
@@ -493,13 +510,21 @@ async function genVideo(pos, opts) {
493
510
  const url = job.video?.url;
494
511
  if (!url) throw new Error(`video job ${queued.jobId} finished without a URL; check it with: video-job ${queued.jobId}`);
495
512
  // Print the paid clip's URL before downloading, so a failed download never hides it.
513
+ // A transparent clip prints both files (WebM, then Safari's stacked MP4) and the markup.
496
514
  console.log(opts.json ? JSON.stringify(job, null, 2) : url);
515
+ if (!opts.json && job.video?.stacked?.url) console.log(job.video.stacked.url);
516
+ if (!opts.json && job.transparent && job.markup) process.stderr.write(`markup: ${job.markup}\n`);
497
517
  if (job.creditsRemaining != null) process.stderr.write(`credits left: ${job.creditsRemaining}\n`);
498
518
  if (opts.download) {
499
- const res = await fetch(url);
500
- if (!res.ok) throw new Error(`download of ${url} failed (${res.status})`);
501
- writeFileSync(opts.download, Buffer.from(await res.arrayBuffer()));
502
- process.stderr.write(`saved to ${opts.download}\n`);
519
+ // A transparent clip is two files: the WebM where asked, the stacked MP4 beside it.
520
+ const files = [[url, opts.download]];
521
+ if (job.video?.stacked?.url) files.push([job.video.stacked.url, `${String(opts.download).replace(/\.[^./]+$/, "")}-stacked.mp4`]);
522
+ for (const [from, to] of files) {
523
+ const res = await fetch(from);
524
+ if (!res.ok) throw new Error(`download of ${from} failed (${res.status})`);
525
+ writeFileSync(to, Buffer.from(await res.arrayBuffer()));
526
+ process.stderr.write(`saved to ${to}\n`);
527
+ }
503
528
  }
504
529
  }
505
530
 
@@ -514,6 +539,8 @@ async function videoJob(pos, opts) {
514
539
  console.log(`status: ${job.status}`);
515
540
  if (job.error) console.log(`error: ${job.error}`);
516
541
  if (job.video?.url) console.log(job.video.url);
542
+ if (job.video?.stacked?.url) console.log(job.video.stacked.url);
543
+ if (job.markup) console.log(`markup: ${job.markup}`);
517
544
  }
518
545
 
519
546
  async function screenshot(pos, opts) {
@@ -675,6 +702,13 @@ function commandTable(pos, rest, opts) {
675
702
  const out = await release(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
676
703
  if (opts.json) console.log(JSON.stringify(out, null, 2));
677
704
  },
705
+ play: async () => {
706
+ const { play } = await import("./play.mjs");
707
+ await play(pos.slice(1), opts, opts.json ? {
708
+ log: (line) => console.error(line),
709
+ started: ({ url, slug, deckDir, presenter, who }) => console.log(JSON.stringify({ url, slug, deckDir, presenter, who })),
710
+ } : {});
711
+ },
678
712
  narrate: async () => {
679
713
  const { narrate } = await import("./narrate.mjs");
680
714
  const result = await narrate(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
@@ -756,7 +790,7 @@ function commandTable(pos, rest, opts) {
756
790
  "deck avatar": async () => {
757
791
  if (rest.length < 2) throw new Error("usage: deck avatar <slug> <name|id|none|default>");
758
792
  const out = await ops.setDeckAvatar(opts, rest[0], rest.slice(1).join(" "));
759
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (applies at its next release)`);
793
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (play and the draft link now; share links at its next release)`);
760
794
  },
761
795
  narrator: async () => {
762
796
  const { narratorList } = await import("./narrator.mjs");
@@ -801,7 +835,7 @@ function commandTable(pos, rest, opts) {
801
835
  "deck narrator": async () => {
802
836
  if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
803
837
  const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
804
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (applies at its next narrate and release)`);
838
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
805
839
  },
806
840
  "preview setup": async () => {
807
841
  const { setupCapture } = await import("./preview-capture.mjs");
@@ -853,6 +887,10 @@ function commandTable(pos, rest, opts) {
853
887
  "brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
854
888
  "brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
855
889
  "brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
890
+ "brand import": async () => (await import("./brandCli.mjs")).brandImport(rest[0], opts),
891
+ "brand revise": async () => (await import("./brandCli.mjs")).brandRevise(rest, opts),
892
+ "brand history": async () => (await import("./brandCli.mjs")).brandHistory(opts),
893
+ "brand undo": async () => (await import("./brandCli.mjs")).brandUndo(rest[0], opts),
856
894
  review: () => review(pos.slice(1), opts),
857
895
  "review-job": () => reviewJob(pos.slice(1), opts),
858
896
  poster: () => poster(pos.slice(1), opts),
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|data-presenter-image)\s*=\s*(["'])([^"']+)\2/gi,
161
+ /\b(src|href|poster|data-presenter-image|data-alpha-stacked)\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/ops.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { zipSync } from "fflate";
2
2
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
3
+ import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
3
4
  import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
4
5
  import {
5
6
  bytesFromText,
@@ -120,8 +121,9 @@ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs =
120
121
 
121
122
  // Generate a short clip via fal (MiniMax H3 Max by default, Kling v3 with
122
123
  // model "kling-v3": text→video, or image→video when an image URL is given as the
123
- // first frame). Always a background job: returns
124
- // { jobId, status, seconds, aspect?, creditsRemaining? } without waiting, so the MCP
124
+ // first frame; loop starts and ends on one frame, transparent mattes the background
125
+ // out). Always a background job: returns
126
+ // { jobId, status, seconds, aspect?, loop, transparent, creditsRemaining? } without waiting, so the MCP
125
127
  // tool never blocks; the CLI polls with pollVideoJob.
126
128
  export async function generateVideo(opts, fields = {}) {
127
129
  if (!fields.prompt) throw new Error("prompt is required");
@@ -131,6 +133,8 @@ export async function generateVideo(opts, fields = {}) {
131
133
  if (fields.seconds != null && fields.seconds !== "") args.seconds = Number(fields.seconds);
132
134
  if (fields.aspect) args.aspect = String(fields.aspect);
133
135
  if (fields.model) args.model = String(fields.model);
136
+ if (fields.loop === true || fields.loop === "true") args.loop = true;
137
+ if (fields.transparent === true || fields.transparent === "true") args.transparent = true;
134
138
  if (fields.slug) args.slug = String(fields.slug);
135
139
  if (fields.name) args.name = String(fields.name);
136
140
  return (await client(opts)).action(api.videogen.generateVideo, args);
@@ -232,11 +236,11 @@ export async function checkVideoJob(opts, jobId) {
232
236
  }
233
237
 
234
238
  // Poll a video job until it finishes. Returns the job (its `video` holds the hosted
235
- // URL); throws on job error or timeout. The server settles every job within 15
236
- // minutes, so the 16-minute default only trips if the job row itself stalls. A few
237
- // failed checks in a row (network, Convex hiccup) are tolerated: the job keeps
239
+ // URL); throws on job error or timeout. A few failed checks in a row (network, Convex hiccup) are tolerated: the job keeps
238
240
  // running server-side either way, so giving up early would only lose the result.
239
- export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs = 960_000, maxErrors = 5, onProgress } = {}) {
241
+ // 30 minutes: a transparent clip can take the render's 12 minutes, then up to 14
242
+ // more to matte and store it (convex/videogen.ts gives the matte its own clock).
243
+ export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs = 1_800_000, maxErrors = 5, onProgress } = {}) {
240
244
  const c = await client(opts);
241
245
  const start = Date.now();
242
246
  const resume = `check it later with: video-job ${jobId}`;
@@ -747,7 +751,7 @@ function designServeUrl(projectId, token, path) {
747
751
  }
748
752
 
749
753
  // Pull every local asset path a deck file references (HTML or CSS): the
750
- // ext-resource-dependency manifest, src/href/poster/data-presenter-image, srcset, <script>/<link>,
754
+ // ext-resource-dependency manifest, src/href/poster/data-presenter-image/data-alpha-stacked, srcset, <script>/<link>,
751
755
  // and CSS url(). Mirrors the importer's rewrite surface so we fetch the same set
752
756
  // it will later rewrite. External/data/blob refs are skipped.
753
757
  function extractDesignRefs(text) {
@@ -759,7 +763,7 @@ function extractDesignRefs(text) {
759
763
  if (clean) out.add(clean);
760
764
  };
761
765
  for (const m of text.matchAll(/ext-resource-dependency"\s+content="([^"]+)"/gi)) add(m[1]);
762
- for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
766
+ for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
763
767
  for (const m of text.matchAll(/\bsrcset\s*=\s*["']([^"']+)["']/gi)) {
764
768
  for (const part of m[1].split(",")) add(part.trim().split(/\s+/)[0]);
765
769
  }
@@ -960,6 +964,11 @@ export async function presenterFor(opts, slug, voice) {
960
964
  return (await client(opts)).query(api.avatars.presenterFor, { slug, ...(voice ? { voice: String(voice) } : {}) });
961
965
  }
962
966
 
967
+ // The same answer as presenterFor plus who presents in words: { presenter, who }.
968
+ export async function presenterWithReason(opts, slug, voice) {
969
+ return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}) });
970
+ }
971
+
963
972
  // ---- narrators (convex/narrators.ts) ------------------------------------------
964
973
  // Who narrates your decks: a face plus a voice. D.A.S.H. is built in. As with
965
974
  // avatars, an agent never adds one: `narrator new` hands back a link.
@@ -1097,8 +1106,10 @@ export async function getBrand(opts = {}) {
1097
1106
  // Merge into the saved brand: a field left out keeps its value, "" clears it.
1098
1107
  // fontPairing names one of FONT_PAIRINGS and fills the three fonts (an explicit
1099
1108
  // font still wins). logoUrl is fetched into our storage; logoBytes (with
1100
- // logoType) is a file the CLI read. clearLogo removes the logo.
1101
- export async function setBrand(opts, { name, designMd, colors, fonts, fontPairing, presenterStyle, logoUrl, logoBytes, logoType, clearLogo } = {}) {
1109
+ // logoType) is a file the CLI read. clearLogo removes the logo. draft is a
1110
+ // brand import's token (importBrand): its colours, fonts, name and logo,
1111
+ // saved after the person has seen the preview; other fields still win.
1112
+ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairing, presenterStyle, logoUrl, logoBytes, logoType, clearLogo, draft } = {}) {
1102
1113
  const c = await client(opts);
1103
1114
  let fontSet = fonts;
1104
1115
  if (fontPairing) {
@@ -1115,9 +1126,9 @@ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairin
1115
1126
  };
1116
1127
  known(colors, COLOR_KEYS, "colour");
1117
1128
  known(fontSet, FONT_ROLES, "font role");
1118
- const fields = { name, designMd, colors, fonts: fontSet, presenterStyle };
1129
+ const fields = { name, designMd, colors, fonts: fontSet, presenterStyle, draft: draft ? String(draft).trim() : undefined };
1119
1130
  const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
1120
- if (!Object.keys(defined).length && !logoUrl && !logoBytes && !clearLogo) throw new Error("nothing to set: pass colors, fonts, fontPairing, logoUrl, designMd, name or presenterStyle");
1131
+ if (!Object.keys(defined).length && !logoUrl && !logoBytes && !clearLogo) throw new Error("nothing to set: pass draft, colors, fonts, fontPairing, logoUrl, designMd, name or presenterStyle");
1121
1132
  let out = Object.keys(defined).length ? await c.mutation(api.brand.set, defined) : null;
1122
1133
  try {
1123
1134
  if (clearLogo) out = await c.mutation(api.brand.clear, { logoOnly: true });
@@ -1134,6 +1145,41 @@ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairin
1134
1145
  return out ?? getBrand(opts);
1135
1146
  }
1136
1147
 
1148
+ // Read a brand from a website (url), one of your decks (slug) or a .pptx /
1149
+ // .potx / .pdf (bytes + fileName, read here with brandImport.mjs) into a
1150
+ // draft. Returns { token, colors, fonts, logoUrl, notes, previewUrl, … };
1151
+ // nothing is saved until setBrand({ draft: token }).
1152
+ export async function importBrand(opts, { url, slug, bytes, fileName } = {}) {
1153
+ const given = [url, slug, bytes].filter((x) => x !== undefined && x !== null && x !== "");
1154
+ if (given.length !== 1) throw new Error("import a brand from one of: a website url, a deck slug or a .pptx / .potx / .pdf file");
1155
+ const c = await client(opts);
1156
+ if (url) return c.action(api.brandNode.importFromUrl, { url: String(url) });
1157
+ if (slug) return c.action(api.brandNode.importFromDeck, { slug: String(slug) });
1158
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
1159
+ if (data.byteLength > MAX_IMPORT_FILE_BYTES) throw new Error(`${fileName ?? "the file"} is over ${MAX_IMPORT_FILE_BYTES / 1024 / 1024} MB`);
1160
+ return c.action(api.brandNode.createDraft, draftArgs(extractFile(data, fileName)));
1161
+ }
1162
+
1163
+ // Revise the brand with a prompt ("darker accent, serif titles"): a draft
1164
+ // like importBrand's, with a previewUrl; nothing is saved until
1165
+ // setBrand({ draft: token }).
1166
+ export async function reviseBrand(opts, { instruction } = {}) {
1167
+ const ask = String(instruction ?? "").trim();
1168
+ if (!ask) throw new Error('say what to change, e.g. "make the accent darker and use a serif for titles"');
1169
+ return (await client(opts)).action(api.brandRevise.revise, { instruction: ask });
1170
+ }
1171
+
1172
+ // The brand's last changes, newest first (the first is the brand now).
1173
+ export async function brandHistory(opts = {}) {
1174
+ return { versions: await (await client(opts)).query(api.brand.history, {}) };
1175
+ }
1176
+
1177
+ // Undo the brand's last change, or go back to one version from brandHistory.
1178
+ export async function undoBrand(opts = {}, { version } = {}) {
1179
+ const c = await client(opts);
1180
+ return version ? c.mutation(api.brand.restore, { version: String(version) }) : c.mutation(api.brand.undo, {});
1181
+ }
1182
+
1137
1183
  export async function clearBrand(opts = {}) {
1138
1184
  await (await client(opts)).mutation(api.brand.clear, {});
1139
1185
  return { cleared: true };
package/src/play.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // hyperdecks play <slug|deck-dir> [--voice v] [--slug s] [--port n] [--no-open] [--json]
2
+ //
3
+ // Plays the working folder as it is, committed or not, the way it will play once
4
+ // released: the owner's face goes on the player tag of the page it serves, in
5
+ // memory only. It writes no file, uploads nothing, creates no share and renders
6
+ // no audio, so it plays the manifest index.html already points at; changed lines
7
+ // need `narrate` first. The server listens on 127.0.0.1 behind a random path until
8
+ // Ctrl-C. The deck's script.md and evidence.md are not served (preview-capture.mjs
9
+ // serves only the file types a page loads).
10
+ import { readFileSync } from "node:fs";
11
+ import { basename, join, relative } from "node:path";
12
+ import { playerScript, sectionIds } from "./checkRules.mjs";
13
+ import { resolveDeckDir } from "./check.mjs";
14
+ import * as ops from "./ops.mjs";
15
+ import { playsCaptionsOnly, presenterLine, withAvatar } from "./presenter.mjs";
16
+ import { serveSource } from "./preview-capture.mjs";
17
+ import { openBrowser } from "./runtime.mjs";
18
+
19
+ const USAGE = "usage: play <slug|deck-dir> [--voice <name|id>] [--slug <slug>] [--port <n>] [--no-open]";
20
+
21
+ function portOf(value) {
22
+ if (value === undefined) return 0;
23
+ const port = Number(value);
24
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`--port must be a port number (got ${value})`);
25
+ return port;
26
+ }
27
+
28
+ // A path `narrate` and `import` take: the argument to play may be a slug.
29
+ function pathFrom(cwd, deckDir) {
30
+ const rel = relative(cwd ?? process.cwd(), deckDir);
31
+ return !rel ? "." : rel.startsWith("..") ? deckDir : rel;
32
+ }
33
+
34
+ // The face to add, and { who } in words. Never stops the play: without an answer
35
+ // the page plays as written, as a release does when this lookup fails.
36
+ async function lookUpPresenter(opts, { slug, path, html, log }) {
37
+ const narrate = `hyperdecks narrate ${path}`;
38
+ const lead = playsCaptionsOnly(html) ? "captions only" : "playing as written";
39
+ if (!playerScript(html)?.manifest) {
40
+ log(`⚠ no narration yet, so this plays silent: run \`${narrate}\` first`);
41
+ return { presenter: null, who: null };
42
+ }
43
+ try {
44
+ const found = await ops.presenterWithReason(opts, slug, opts.voice);
45
+ if (!found.who) log(`⚠ ${lead}: no finished narration on this account for "${slug}": run \`${narrate}\` first`);
46
+ return found;
47
+ } catch (e) {
48
+ const why = ops.errorText(e);
49
+ if (/deck not found/.test(why)) log(`⚠ ${lead}: "${slug}" is not on this account yet, so it has no presenter. Register it (\`hyperdecks import ${join(path, "index.html")} --slug ${slug} --status draft\`), then run \`${narrate}\``);
50
+ else if (/forbidden/.test(why)) log(`⚠ ${lead}: the deck "${slug}" belongs to another account. Log in as its owner, or pass --slug with yours`);
51
+ else log(`⚠ ${lead}: could not ask who presents (${why}). Your avatar is not the problem; try again, and if it keeps failing this CLI may be newer than the backend`);
52
+ return { presenter: null, who: null };
53
+ }
54
+ }
55
+
56
+ // Says what the served page does, not what the lookup answered.
57
+ function sayWhoPresents({ html, page, presenter, who, log }) {
58
+ if (presenter && page === html) log("⚠ the player tag already names a presenter, so it plays as written");
59
+ else if (who?.face && !presenter && playsCaptionsOnly(html)) log(`⚠ captions only: the page names no presenter, and a face for voice "${who.voice}" is not added here`);
60
+ else if (who) log(presenterLine(who));
61
+ }
62
+
63
+ // A warning only. An error with text is the backend's answer (the local script
64
+ // differs from the hosted one, a line that won't compile), so it is shown; no
65
+ // script.md, or a backend that could not say, is not worth a line.
66
+ async function warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log }) {
67
+ try {
68
+ const script = readFileSync(join(deckDir, "script.md"), "utf8");
69
+ const plan = await ops.previewNarration(opts, { slug, script, voice: who.voice, sectionIds: sectionIds(html) });
70
+ const changed = plan.slides.filter((s) => s.render).map((s) => s.id);
71
+ if (changed.length) log(`⚠ script.md changed since the last narration (${changed.join(", ")}); this plays the old audio. Run \`hyperdecks narrate ${path}\` to hear the new lines`);
72
+ } catch (e) {
73
+ if (typeof e?.data === "string") log(`⚠ could not compare script.md with the last narration: ${e.data}`);
74
+ }
75
+ }
76
+
77
+ // Starts serving and returns; the caller closes it. `play` is the command.
78
+ export async function startPlay(pos, opts = {}, { log = (line) => console.log(line), open = openBrowser, cwd } = {}) {
79
+ const [deckDirArg] = pos;
80
+ if (!deckDirArg) throw new Error(USAGE);
81
+ for (const k of ["voice", "slug", "port"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
82
+ const port = portOf(opts.port);
83
+ const deckDir = resolveDeckDir(deckDirArg, cwd);
84
+ const path = pathFrom(cwd, deckDir);
85
+ const slug = opts.slug || basename(deckDir);
86
+ const file = join(deckDir, "index.html");
87
+ const html = readFileSync(file, "utf8");
88
+
89
+ const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
90
+ const page = presenter ? withAvatar(html, presenter) : html;
91
+ sayWhoPresents({ html, page, presenter, who, log });
92
+ // --voice names the voice it was narrated in, so there is nothing to compare it to.
93
+ if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
94
+
95
+ let server;
96
+ try {
97
+ server = await serveSource({ file, html: page, port });
98
+ } catch (e) {
99
+ throw new Error(e?.code === "EADDRINUSE" ? `port ${port} is in use; pick another with --port` : e?.message || String(e));
100
+ }
101
+ log(`▶ playing ${slug}: ${server.url}\n Ctrl-C to stop. Nothing is written, uploaded or shared.`);
102
+ if (!opts["no-open"]) open(server.url);
103
+ return { url: server.url, close: server.close, deckDir, slug, presenter: page === html ? null : presenter, who: who ?? null };
104
+ }
105
+
106
+ export async function play(pos, opts = {}, io = {}) {
107
+ const session = await startPlay(pos, opts, io);
108
+ await new Promise((stopped) => {
109
+ const stop = () => {
110
+ process.off("SIGINT", stop);
111
+ process.off("SIGTERM", stop);
112
+ stopped();
113
+ };
114
+ process.on("SIGINT", stop);
115
+ process.on("SIGTERM", stop);
116
+ io.started?.(session);
117
+ });
118
+ await session.close();
119
+ }
package/src/presenter.mjs CHANGED
@@ -1,26 +1,33 @@
1
1
  // The released deck's presenter (roadmap: avatars-pick-make-from-a-selfie-or-
2
2
  // import-your-minimoji). A deck narrated in the owner's own voice is written
3
3
  // captions-only (data-presenter="none"); at release, when the owner has an
4
- // avatar, its player tag gets that face instead. Shared by the hosted release
5
- // (convex/deckSources.ts) and the CLI's local release (release.mjs), so both
6
- // rewrite the tag the same way. A tag that already names a presenter (D.A.S.H.
4
+ // avatar, its player tag gets that face instead. Shared by both releases
5
+ // (convex/deckSources.ts, release.mjs), `hyperdecks play` and the /p draft
6
+ // preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
7
7
  // on a character-voice deck, or one set by hand) is left alone.
8
8
 
9
9
  const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
10
10
 
11
11
  const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
12
12
 
13
+ // Whether this tag plays captions only: data-presenter="none", or no presenter
14
+ // at all (the player's default when there is no data-presenter-image either).
15
+ const noPresenter = (tag) => /\sdata-presenter="none"/.test(tag) || (!/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag));
16
+
17
+ // Whether the page's player tag names nobody, so without a face it has captions only.
18
+ export function playsCaptionsOnly(html) {
19
+ const tag = PLAYER_TAG.exec(html)?.[0];
20
+ return !!tag && !/\sdata-avatar=/.test(tag) && noPresenter(tag);
21
+ }
22
+
13
23
  // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
14
24
  // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
15
25
  // or html unchanged when there is no such tag or it already has a presenter.
16
26
  export function withAvatar(html, { avatarUrl, imageUrl }) {
17
27
  const tag = PLAYER_TAG.exec(html)?.[0];
18
28
  if (!tag || /\sdata-avatar=/.test(tag)) return html;
19
- // Captions only: data-presenter="none", or no presenter at all (the player's
20
- // default when there is no data-presenter-image either).
29
+ if (!noPresenter(tag)) return html;
21
30
  const none = /\sdata-presenter="none"/.test(tag);
22
- const unset = !/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag);
23
- if (!none && !unset) return html;
24
31
  const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
25
32
  const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
26
33
  const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
@@ -19,7 +19,9 @@ export async function setupCapture(pos, opts) {
19
19
  }
20
20
 
21
21
  // Capability URL + realpath containment; captured code gets no general file server.
22
- export async function serveSource({ file, html }) {
22
+ // `html` alone serves that page; with `file` it replaces the page and the file's
23
+ // folder still serves the rest (play's in-memory copy with the face on it).
24
+ export async function serveSource({ file, html, port = 0 }) {
23
25
  const root = file ? await realpath(dirname(file)) : null;
24
26
  const prefix = '/' + randomUUID() + '/';
25
27
  const entry = prefix + (file ? encodeURIComponent(resolve(file).split(sep).pop()) : 'index.html');
@@ -27,7 +29,7 @@ export async function serveSource({ file, html }) {
27
29
  try {
28
30
  const path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
29
31
  if (req.method !== 'GET' || !path.startsWith(prefix)) { res.writeHead(404).end(); return; }
30
- if (!file && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
32
+ if (html != null && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
31
33
  const rel = path.slice(prefix.length);
32
34
  if (!root || rel.split('/').some(s => s.startsWith('.') || s.includes('\\')) || !MIME[extname(rel).toLowerCase()]) { res.writeHead(404).end(); return; }
33
35
  const full = await realpath(resolve(root, rel));
@@ -36,7 +38,7 @@ export async function serveSource({ file, html }) {
36
38
  res.end(await readFile(full));
37
39
  } catch { res.writeHead(404).end(); }
38
40
  });
39
- await new Promise((ok, fail) => { server.once('error', fail); server.listen(0, '127.0.0.1', ok); });
41
+ await new Promise((ok, fail) => { server.once('error', fail); server.listen(port, '127.0.0.1', ok); });
40
42
  const url = `http://127.0.0.1:${server.address().port}${entry}`;
41
43
  return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
42
44
  }