@hypersocial/decks-cli 0.9.6 → 0.9.8

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/init.mjs CHANGED
@@ -17,7 +17,7 @@ import { createInterface } from "node:readline/promises";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import * as ops from "./ops.mjs";
19
19
  import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
20
- import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
20
+ import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, linkBase, login as runLogin, openBrowser, target } from "./runtime.mjs";
21
21
  import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
22
22
  import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
23
23
  import { narrate as runNarrate } from "./narrate.mjs";
@@ -450,9 +450,13 @@ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars,
450
450
  }
451
451
  }
452
452
 
453
+ // decks:get answers only for decks the caller owns, so whether the slug is
454
+ // taken at all comes from slugTaken.
453
455
  async function deckOwnedBy(base, slug, who) {
454
- const deck = await (await client(base)).query(api.decks.get, { slug });
455
- return deck ? { exists: true, mine: deck.ownerId === who.subject } : { exists: false, mine: false };
456
+ const c = await client(base);
457
+ if (!(await c.query(api.decks.slugTaken, { slug }))) return { exists: false, mine: false };
458
+ const deck = await c.query(api.decks.get, { slug });
459
+ return { exists: true, mine: !!deck && deck.ownerId === who.subject };
456
460
  }
457
461
 
458
462
  async function scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save }) {
@@ -544,7 +548,7 @@ async function releaseStep(base, opts, state, { release, sub, save, fetch }) {
544
548
  // A run killed after release made its link but before init.json recorded it.
545
549
  const mine = !state.releasedAt && shares.find((s) => s.qa && s.recipient === "Me" && s.enabled);
546
550
  if (mine) {
547
- const url = `${target(base).shareBase}/v/${mine.token}`;
551
+ const url = `${await linkBase(base)}/v/${mine.token}`;
548
552
  save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: mine.token });
549
553
  return step("release", "done", `released: ${url}`);
550
554
  }
package/src/layouts.mjs CHANGED
@@ -5,8 +5,8 @@ import { packageText } from "./files.mjs";
5
5
 
6
6
  export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
7
7
  // The library, in a sensible deck order; `--layouts` takes any order and repeats.
8
- export const LAYOUTS = ["hero", "number", "product", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
- const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features" };
8
+ export const LAYOUTS = ["hero", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
+ const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features", "image-copy": "split", "image-text": "split" };
10
10
 
11
11
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
12
 
@@ -20,7 +20,7 @@ export function fillTemplate(file, { slug, title, presenterStyle }) {
20
20
  }
21
21
 
22
22
  // One layout file: a leading comment of `key: value` lines (layout, summary,
23
- // use, slot, presenter, cues, script), its <style> and its <section>.
23
+ // use, slot, presenter, nopresenter, cues, script), its <style> and its <section>.
24
24
  export function readLayout(name) {
25
25
  const text = packageText(`templates/layouts/${name}.html`);
26
26
  const head = /^<!--([\s\S]*?)-->/.exec(text);
@@ -49,24 +49,55 @@ const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i
49
49
  const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
50
50
  const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
51
51
  const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
52
+ // The block's pieces besides its style, on any player host: the two preloads and
53
+ // the frame flag (each kept once), and its own comment (matched only as a whole
54
+ // comment, so a person's comments in <head> are never touched).
55
+ const PIECES = [
56
+ /<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
57
+ /<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
58
+ /<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
59
+ new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
60
+ ];
61
+ const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
52
62
 
53
- // The page with its loading state put right: inserted after <title> (or at the
54
- // top of <head>) when <head> has none, or its <style data-hd-loading> replaced
55
- // when it differs from the template's. Only a page with the player script, as
56
- // the check asks only those. Returns { html, fixed: "inserted" | "refreshed" | null,
63
+ // <head>'s text with each piece kept `keep` times (0: all out, 1: the first stays),
64
+ // working outside comments only; the block's own comment counts as a piece too.
65
+ function trimPieces(head, keep) {
66
+ const seen = new Array(PIECES.length + 1).fill(0);
67
+ return head
68
+ .split(/(<!--[\s\S]*?-->[ \t]*\n?)/)
69
+ .map((part, i) => {
70
+ if (i % 2) return BLOCK_COMMENT.test(part) && ++seen[PIECES.length] > keep ? "" : part;
71
+ return PIECES.reduce((text, re, k) => text.replace(re, (m) => (++seen[k] > keep ? "" : m)), part);
72
+ })
73
+ .join("");
74
+ }
75
+
76
+ // The page with its loading state put right. Only a page with the player script,
77
+ // as the check asks only those. With no <style data-hd-loading> in <head>, the
78
+ // block goes in after <title> (or at the top of <head>), any of its pieces already
79
+ // there taken out first. With one, it becomes the template's (refreshed) and any
80
+ // second copy of the style or a piece goes (deduped). A person's comments are left
81
+ // alone. Returns { html, fixed: "inserted" | "refreshed" | "deduped" | null,
57
82
  // skipped? }: a player page with no <head>…</head> is left alone, with `skipped` saying why.
58
83
  export function withLoadingBlock(html) {
59
84
  const live = html.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length)); // same offsets, comments blanked
60
85
  const head = HEAD.exec(live);
61
86
  if (!PLAYER.test(live)) return { html, fixed: null };
62
87
  if (!head) return { html, fixed: null, skipped: "no <head>…</head> to put the loading state in" };
63
- const style = STYLE.exec(head[0]);
64
- if (style) {
65
- const at = head.index + style.index;
66
- if (html.slice(at, at + style[0].length) === LOADING_STYLE) return { html, fixed: null };
67
- return { html: html.slice(0, at) + LOADING_STYLE + html.slice(at + style[0].length), fixed: "refreshed" };
88
+ const rawHead = html.slice(head.index, head.index + head[0].length);
89
+ const splice = (text) => html.slice(0, head.index) + text + html.slice(head.index + head[0].length);
90
+ if (!STYLE.test(head[0])) {
91
+ const cleaned = trimPieces(rawHead, 0);
92
+ const title = /<\/title>/i.exec(cleaned);
93
+ const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
94
+ return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
68
95
  }
69
- const title = /<\/title>/i.exec(head[0]);
70
- const at = head.index + (title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(head[0])[0].length);
71
- return { html: `${html.slice(0, at)}\n${LOADING_BLOCK}${html.slice(at)}`, fixed: "inserted" };
96
+ const deduped = trimPieces(rawHead, 1);
97
+ // The one style left, outside comments, made the template's.
98
+ const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
99
+ const style = STYLE.exec(blank);
100
+ const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
101
+ if (fresh === rawHead) return { html, fixed: null };
102
+ return { html: splice(fresh), fixed: deduped !== rawHead ? "deduped" : "refreshed" };
72
103
  }
package/src/ops.mjs CHANGED
@@ -2,7 +2,7 @@ import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer
2
2
  import { zipSync } from "fflate";
3
3
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
4
4
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
5
- import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
5
+ import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
6
  import {
7
7
  bytesFromText,
8
8
  isTextAsset,
@@ -19,8 +19,9 @@ export async function whoami(opts = {}) {
19
19
  return (await client(opts)).query(api.decks.whoami, {});
20
20
  }
21
21
 
22
- export async function listDecks(opts = {}) {
23
- const rows = await (await client(opts)).query(api.decks.listForViewer, {});
22
+ // Your own decks. `all: true` lists every deck, for admins only (support tools).
23
+ export async function listDecks(opts = {}, { all = false } = {}) {
24
+ const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
24
25
  return rows.map((d) => ({
25
26
  slug: d.slug,
26
27
  title: d.title,
@@ -30,6 +31,8 @@ export async function listDecks(opts = {}) {
30
31
  // How it opens (null: as its HTML says) and whether viewers may switch.
31
32
  view: d.view ?? null,
32
33
  viewToggle: d.viewToggle !== false,
34
+ // How the player moves on: auto, slide or step (null: as its HTML says, else auto).
35
+ pace: d.pace ?? null,
33
36
  // false: the deck runs its own player, so view/viewToggle do not apply
34
37
  // (null: not known yet, treated as on).
35
38
  usesPlayer: d.usesPlayer ?? null,
@@ -78,6 +81,7 @@ export async function generateImage(opts, fields = {}) {
78
81
  if (fields.format) args.format = String(fields.format);
79
82
  if (fields.model) args.model = String(fields.model);
80
83
  if (fields.seed != null && fields.seed !== "") args.seed = Number(fields.seed);
84
+ if (fields.brand) args.brand = true;
81
85
  // Returns { images } for fast requests, or { jobId, status, count } for slow ones
82
86
  // (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
83
87
  // never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
@@ -179,8 +183,10 @@ export async function renderChart(opts, spec = {}) {
179
183
  export async function generateIconSet(opts, fields = {}) {
180
184
  const subjects = (fields.subjects || []).map(String).filter(Boolean);
181
185
  if (!subjects.length) throw new Error("subjects is required (one or more things to draw)");
182
- if (!fields.style) throw new Error("style is required");
183
- const args = { subjects, style: String(fields.style) };
186
+ if (!fields.style && !fields.preset) throw new Error("style or preset is required");
187
+ const args = { subjects };
188
+ if (fields.style) args.style = String(fields.style);
189
+ if (fields.preset) args.preset = String(fields.preset);
184
190
  if (fields.palette?.length) args.palette = fields.palette.map(String);
185
191
  if (fields.slug) args.slug = String(fields.slug);
186
192
  if (fields.name) args.name = String(fields.name);
@@ -229,7 +235,7 @@ export async function requestVideoExport(opts, fields = {}) {
229
235
  const c = await client(opts);
230
236
  try {
231
237
  const out = await c.mutation(api.videoExports.requestVideo, { slug, options });
232
- return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a deck renders in about its own length` };
238
+ return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a 4-minute deck takes about 12 minutes` };
233
239
  } catch (e) {
234
240
  // A backend from before the render worker has no requestVideo.
235
241
  if (/Could not find public function/i.test(errorText(e))) throw new Error(videoFromCli(slug));
@@ -261,7 +267,11 @@ export async function ownedDeck(opts, slug) {
261
267
  const c = await client(opts);
262
268
  const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
263
269
  if (!me) throw new Error("not logged in: run `hyperdecks login` first");
264
- if (!deck) throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
270
+ if (!deck) {
271
+ // get answers only for your own decks; say so when the slug is someone else's.
272
+ if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`forbidden: ${slug} is not your deck`);
273
+ throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
274
+ }
265
275
  if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
266
276
  return deck;
267
277
  }
@@ -285,7 +295,7 @@ export const VIDEO_RECIPIENT = "Video viewers";
285
295
  export async function videoShareLink(opts, slug) {
286
296
  const live = (await listShares(opts, slug)).find((s) => s.recipient === VIDEO_RECIPIENT && s.enabled && !s.qa && !s.archived && !s.requireEmail && (s.expiresAt == null || s.expiresAt > Date.now()) && (s.viewLimit == null || s.opens < s.viewLimit));
287
297
  const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
288
- return `${target(opts).shareBase}/v/${token}`;
298
+ return `${await linkBase(opts)}/v/${token}`;
289
299
  }
290
300
 
291
301
  // `export --video --upload`: file the MP4 with the deck's exports (30 days).
@@ -425,10 +435,24 @@ export async function checkNarrationJob(opts, jobId) {
425
435
  return (await client(opts)).query(api.narration.getJob, { jobId: String(jobId) });
426
436
  }
427
437
 
438
+ // Your own voices (what clone, rename and delete act on).
428
439
  export async function listVoices(opts = {}) {
429
440
  return (await client(opts)).query(api.voices.listVoices, {});
430
441
  }
431
442
 
443
+ // Every voice you can narrate with: yours, then the built-in ones (builtin: true,
444
+ // id stock:<id>), which any account may use. What list_voices shows.
445
+ export async function listAllVoices(opts = {}) {
446
+ const c = await client(opts);
447
+ try {
448
+ return await c.query(api.voices.listVoices, { builtin: true });
449
+ } catch (e) {
450
+ // A backend from before built-in voices refuses the argument: your own voices, then.
451
+ if (!/builtin|extra field|Validator/i.test(errorText(e))) throw e;
452
+ return c.query(api.voices.listVoices, {});
453
+ }
454
+ }
455
+
432
456
  // ---- voice cloning (convex/voices.ts) ---------------------------------------
433
457
 
434
458
  // The text of a backend error: ConvexError data (production redacts the rest), else the message.
@@ -600,18 +624,23 @@ export async function setDeckForwarding(opts, slug, allowForward) {
600
624
 
601
625
  // How a deck opens in the player and whether viewers may switch: view is
602
626
  // "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
603
- // true/false. Either may be left out; both go in one owner-only mutation, so a
604
- // failure changes neither.
627
+ // true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
628
+ // slide), "step" (also stops before each [reveal] cue), or null for the deck's
629
+ // own HTML. Any may be left out; all go in one owner-only mutation, so a
630
+ // failure changes none.
605
631
  // Applies on every link's next load (the route injects it), with no re-release.
606
632
  export const DECK_VIEWS = ["slides", "scroll"];
607
- export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
633
+ export const DECK_PACES = ["auto", "slide", "step"];
634
+ export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
608
635
  if (!slug) throw new Error("slug is required");
609
- if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a viewToggle");
636
+ if (view === undefined && viewToggle === undefined && pace === undefined) throw new Error("give a view (slides, scroll or default), a viewToggle and/or a pace (auto, slide, step or default)");
610
637
  if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
638
+ if (pace !== undefined && pace !== null && !DECK_PACES.includes(pace)) throw new Error(`pace must be auto, slide, step or null (got ${JSON.stringify(pace)})`);
611
639
  const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
612
640
  slug,
613
641
  ...(view !== undefined ? { view } : {}),
614
642
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
643
+ ...(pace !== undefined ? { pace } : {}),
615
644
  });
616
645
  // Saved either way; a deck on its own player says the setting won't apply.
617
646
  const applies = res?.applies !== false;
@@ -619,6 +648,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
619
648
  slug,
620
649
  ...(view !== undefined ? { view } : {}),
621
650
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
651
+ ...(pace !== undefined ? { pace } : {}),
622
652
  applies,
623
653
  ...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
624
654
  };
@@ -628,7 +658,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
628
658
  // upsert would otherwise reset your own live deck to a draft and rename it.
629
659
  export async function createDeck(opts, { slug, title }) {
630
660
  const c = await client(opts);
631
- if (await c.query(api.decks.get, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
661
+ if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
632
662
  await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
633
663
  return { slug, title, status: "draft" };
634
664
  }
@@ -1143,7 +1173,7 @@ export async function listNarrators(opts = {}) {
1143
1173
  default: out.defaultId,
1144
1174
  ...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
1145
1175
  narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, spokenName: n.spokenName ?? null, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
1146
- voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
1176
+ voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, builtin: !!x.builtin, narrators: x.narrators })),
1147
1177
  };
1148
1178
  }
1149
1179
 
@@ -1185,7 +1215,8 @@ export async function removeNarrator(opts, ref) {
1185
1215
  return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
1186
1216
  }
1187
1217
 
1188
- // voice: one of your ready voices (name or id) or dash.
1218
+ // voice: one of your ready voices (name or id), a built-in voice (name or
1219
+ // stock:<id>, list_voices), or dash.
1189
1220
  export async function setNarratorVoice(opts, ref, voice) {
1190
1221
  if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
1191
1222
  const n = await findNarrator(opts, ref);
package/src/presenter.mjs CHANGED
@@ -12,32 +12,118 @@
12
12
  export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
13
13
  export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
14
14
 
15
- const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
15
+ const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/gi;
16
16
 
17
17
  const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
18
18
 
19
- // Whether this tag plays captions only: data-presenter="none", or no presenter
20
- // at all (the player's default when there is no data-presenter-image either).
21
- const noPresenter = (tag) => /\sdata-presenter="none"/.test(tag) || (!/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag));
19
+ // The live player tag: the first one outside an HTML comment (a commented-out
20
+ // copy is not what plays), with where it starts. check's playerScript reads it
21
+ // the same way (closed comments only, so a stray "<!--" in a script hides nothing).
22
+ function playerTag(html) {
23
+ const comments = [...html.matchAll(/<!--[\s\S]*?-->/g)].map((m) => [m.index, m.index + m[0].length]);
24
+ for (const m of html.matchAll(PLAYER_TAG)) {
25
+ if (!comments.some(([a, b]) => m.index >= a && m.index < b)) return { tag: m[0], index: m.index };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const value = (tag, name) => {
31
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
32
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
33
+ };
34
+
35
+ // The face this tag shows, decided as packages/player/player.js does: data-presenter
36
+ // head|static|none, else static when there is a still, else none. head draws
37
+ // data-avatar (and falls back to the still without one); static draws the
38
+ // still. Null: the deck plays the voice and captions with no face.
39
+ function tagFace(tag) {
40
+ const presenter = value(tag, "data-presenter");
41
+ const avatarUrl = value(tag, "data-avatar") || null;
42
+ const imageUrl = value(tag, "data-presenter-image") || null;
43
+ const mode = ["head", "static", "none"].includes(presenter) ? presenter : imageUrl ? "static" : "none";
44
+ if (mode === "head" && (avatarUrl || imageUrl)) return { avatarUrl, imageUrl };
45
+ if (mode === "static" && imageUrl) return { avatarUrl: null, imageUrl };
46
+ return null;
47
+ }
48
+
49
+ // The face the page's player shows ({ avatarUrl, imageUrl }, either may be
50
+ // null), or null when it plays captions only or has no player tag.
51
+ export function presentedFace(html) {
52
+ const found = playerTag(html);
53
+ return found ? tagFace(found.tag) : null;
54
+ }
22
55
 
23
- // Whether the page's player tag names nobody, so without a face it has captions only.
56
+ // Whether the page's player tag shows no face, so it has captions only.
24
57
  export function playsCaptionsOnly(html) {
25
- const tag = PLAYER_TAG.exec(html)?.[0];
26
- return !!tag && !/\sdata-avatar=/.test(tag) && noPresenter(tag);
58
+ const found = playerTag(html);
59
+ return !!found && !tagFace(found.tag);
27
60
  }
28
61
 
29
62
  // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
30
- // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
31
- // or html unchanged when there is no such tag or it already has a presenter.
63
+ // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
64
+ // html unchanged when there is no such tag, the tag already shows a face, or it
65
+ // names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
66
+ // Anything else the player would play captions only (data-presenter none or
67
+ // missing, head with no data-avatar, static with no still) gets the face.
32
68
  export function withAvatar(html, { avatarUrl, imageUrl }) {
33
- const tag = PLAYER_TAG.exec(html)?.[0];
34
- if (!tag || /\sdata-avatar=/.test(tag)) return html;
35
- if (!noPresenter(tag)) return html;
36
- const none = /\sdata-presenter="none"/.test(tag);
37
- const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
69
+ const found = playerTag(html);
70
+ if (!found) return html;
71
+ const { tag, index } = found;
72
+ if (tagFace(tag) || value(tag, "data-avatar")) return html;
73
+ const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
38
74
  const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
39
- const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
40
- return html.replace(tag, () => next);
75
+ const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
76
+ const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
77
+ return html.slice(0, index) + next + html.slice(index + tag.length);
78
+ }
79
+
80
+ // What is wrong with the player tag's presenter as written, for check: null, or
81
+ // { kind, presenter }. "unfilled": it asks for a face (head or static) but names
82
+ // none, so it plays captions only until release fills it in. "kept": it has a
83
+ // data-avatar the player ignores (data-presenter="none"), and release leaves it.
84
+ // A plain "none" or no data-presenter is not an issue: release adds the face.
85
+ export function presenterTagIssue(html) {
86
+ const found = playerTag(html);
87
+ if (!found || tagFace(found.tag)) return null;
88
+ const presenter = value(found.tag, "data-presenter");
89
+ if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
90
+ if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
91
+ return null;
92
+ }
93
+
94
+ // Why a resolved face did not land on the page: withAvatar leaves a tag with
95
+ // its own data-avatar alone, and a page with no live player tag has nowhere to put it.
96
+ function keptOutReason(html) {
97
+ const found = playerTag(html);
98
+ if (!found) return "the page has no live player tag to put the face on";
99
+ const mode = value(found.tag, "data-presenter");
100
+ return `the player tag names its own data-avatar, but data-presenter=${mode === null ? " is missing" : `"${mode}"`} so it plays no face, and release leaves a hand-set data-avatar alone: set data-presenter="head", or remove data-avatar`;
101
+ }
102
+
103
+ // What a release says about the face, from the page as written (`html`), the
104
+ // face it puts on (`presenter`, from avatars.presenterFor; null for none), who
105
+ // the backend says presents (`who`: { narrator, voice, face, reason }) and, when
106
+ // the face lookup itself failed, `lookupError`. Returns the page that ships, a
107
+ // line for the terminal and the summary to report: "presented by" only when
108
+ // that page shows a face, and with no resolved face on it, the tag's own.
109
+ // Both releases use it: release.mjs and convex/deckSources.ts.
110
+ export const TAG_FACE = "the face set on the player tag";
111
+ export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
112
+ const page = presenter ? withAvatar(html, presenter) : html;
113
+ const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
114
+ const shown = presentedFace(page);
115
+ if (shown) {
116
+ // Left as written, with no resolved face or another one: whatever the tag names is what shows.
117
+ if (page === html && shown.avatarUrl !== presenter?.avatarUrl) {
118
+ return { page, line: `✓ presented by ${TAG_FACE} (${shown.avatarUrl ? "data-avatar" : "data-presenter-image"})`, summary: who ? { ...who, face: TAG_FACE } : null };
119
+ }
120
+ return { page, line: `✓ presented by ${who?.face ?? "your avatar"}`, summary: who ?? null };
121
+ }
122
+ if (lookupError) return captions(`could not look up your avatar (${lookupError}); release again to try once more`);
123
+ if (presenter) return captions(keptOutReason(page));
124
+ if (who && !who.face) return { page, line: `⚠ releasing captions only: ${who.reason ?? "no face"}`, summary: who };
125
+ if (who?.face) return captions(`no face for "${who.face}" came back to put on the player tag`);
126
+ return { page, line: "", summary: null };
41
127
  }
42
128
 
43
129
  // One line for who presents a deck, from narrate's or release's `presenter`
package/src/preview.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
2
2
  import { resolve, extname } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { api, client, target, uploadBytes } from "./runtime.mjs";
4
+ import { api, client, linkBase, target, uploadBytes } from "./runtime.mjs";
5
5
 
6
6
  const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
7
7
  export function httpUrl(value) {
@@ -58,7 +58,7 @@ export async function createPreview(pos, opts, adapters = {}) {
58
58
  if (opts.share) {
59
59
  if (deck.status === 'archived') throw new Error('Archived deck cannot provide a sendable share preview');
60
60
  validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
61
- shareUrl = httpUrl(`${target(opts).shareBase}/v/${opts.share}`);
61
+ shareUrl = httpUrl(`${await linkBase(opts)}/v/${opts.share}`);
62
62
  }
63
63
  html = deck.html;
64
64
  if (!html && deck.htmlUrl) {
package/src/release.mjs CHANGED
@@ -16,8 +16,8 @@ import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { zipSync } from "fflate";
18
18
  import * as ops from "./ops.mjs";
19
- import { withAvatar } from "./presenter.mjs";
20
- import { target } from "./runtime.mjs";
19
+ import { releasedPresenter, withAvatar } from "./presenter.mjs";
20
+ import { linkBase } from "./runtime.mjs";
21
21
  import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
22
22
  import { narrate as runNarrate } from "./narrate.mjs";
23
23
 
@@ -112,16 +112,20 @@ export async function release(pos, opts = {}, io = {}) {
112
112
  }
113
113
  // The released page gets the narrator's face: your avatar, or D.A.S.H.'s for its voice.
114
114
  let presenter = null;
115
+ let lookupError;
115
116
  try {
116
117
  presenter = await ops.presenterFor(opts, slug, opts.voice);
117
118
  } catch (e) {
118
- log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
119
+ lookupError = ops.errorText(e);
119
120
  }
120
121
  const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
121
- if (presenter) log(`✓ presented by ${job.presenter?.face ?? "your avatar"}`);
122
+ // Say who presents from the page that ships, never from the lookup alone: a
123
+ // resolved face that did not land on the player tag must not be claimed, and
124
+ // a failed lookup is captions only only when the tag names no face of its own.
125
+ const said = releasedPresenter(readFileSync(join(deckDir, "index.html"), "utf8"), presenter, job.presenter, { lookupError });
122
126
  // A terminal user sees this before it ships, so the CLI warns rather than refuses.
123
- else if (job.presenter && !job.presenter.face) log(`⚠ releasing captions only: ${job.presenter.reason ?? "no face"}`);
124
- return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: job.presenter ?? null });
127
+ if (said.line) log(said.line);
128
+ return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: said.summary });
125
129
  }
126
130
 
127
131
  // Import the checked bundle and make the links: the end of every release.
@@ -137,7 +141,7 @@ async function publish({ opts, io: { log, commit }, deckDir, slug, to, checked,
137
141
  const sha = commit(deckDir);
138
142
  log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
139
143
 
140
- const base = target(opts).shareBase;
144
+ const base = await linkBase(opts);
141
145
  const shares = [];
142
146
  for (const r of to) {
143
147
  let token;
package/src/runtime.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { ConvexHttpClient } from "convex/browser";
9
9
  import { makeFunctionReference } from "convex/server";
10
10
 
11
- export const VERSION = "0.9.6";
11
+ export const VERSION = "0.9.8";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  // The app's home. HYPERDECKS_HOST points the prod target's broker and share
@@ -117,7 +117,9 @@ export const api = {
117
117
  },
118
118
  decks: {
119
119
  get: makeFunctionReference("decks:get"),
120
+ slugTaken: makeFunctionReference("decks:slugTaken"),
120
121
  listForViewer: makeFunctionReference("decks:listForViewer"),
122
+ listAll: makeFunctionReference("decks:listAll"),
121
123
  remove: makeFunctionReference("decks:remove"),
122
124
  setAllowForward: makeFunctionReference("decks:setAllowForward"),
123
125
  setView: makeFunctionReference("decks:setView"),
@@ -135,6 +137,7 @@ export const api = {
135
137
  setAllowForward: makeFunctionReference("shares:setAllowForward"),
136
138
  setEnabled: makeFunctionReference("shares:setEnabled"),
137
139
  setNotify: makeFunctionReference("shares:setNotify"),
140
+ linkOrigins: makeFunctionReference("shares:linkOrigins"),
138
141
  },
139
142
  imagegen: {
140
143
  generateImage: makeFunctionReference("imagegen:generateImage"),
@@ -191,7 +194,7 @@ export const api = {
191
194
  },
192
195
  };
193
196
 
194
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
197
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card", "backup"]);
195
198
  // release's recipients, in order: each --to starts one, and an --email right
196
199
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
197
200
  // opts.email stays the last --email for the other commands (share new).
@@ -259,6 +262,63 @@ export function target(opts = {}) {
259
262
  };
260
263
  }
261
264
 
265
+ // Where recipient links (/v/<token>) are printed: the server says
266
+ // (shares:linkOrigins, RECIPIENT_LINK_ORIGIN in lib/linkOrigin.ts), so links
267
+ // follow the deployment rather than a default baked into this version. Both app
268
+ // hosts serve every link; this only picks the one a new link names.
269
+ // HYPERDECKS_LINK_ORIGIN overrides it; the dev target keeps its share base.
270
+ export const DEFAULT_LINK_ORIGIN = "https://decks.hypersocial.ai";
271
+ const LINK_HOSTS = ["https://decks.hypersocial.ai", "https://hyperdecks.ai"];
272
+ let linkOriginPin = null;
273
+ const linkOriginCache = new Map();
274
+
275
+ // The hosted MCP pins it to the server's own setting (lib/serverCliHost.ts).
276
+ export function pinLinkOrigin(origin) {
277
+ linkOriginPin = origin ? normalizeBase(origin) : null;
278
+ linkOriginCache.clear();
279
+ }
280
+
281
+ const isLinkHost = (o) => typeof o === "string" && LINK_HOSTS.includes(normalizeBase(o.trim()).toLowerCase());
282
+
283
+ async function askLinkOrigin(c) {
284
+ try {
285
+ const out = await c.query(api.shares.linkOrigins, {});
286
+ // The server only ever names one of the two app hosts; print nothing else.
287
+ if (isLinkHost(out?.origin)) return normalizeBase(out.origin.trim()).toLowerCase();
288
+ console.warn(`warning: the server named no usable link origin; printing links on ${DEFAULT_LINK_ORIGIN}`);
289
+ } catch (e) {
290
+ // An older server without shares:linkOrigins stays quiet: links on the
291
+ // established host open everywhere. Anything else is worth a line.
292
+ const msg = String(e?.message ?? e);
293
+ if (!/Could not find public function/.test(msg)) console.warn(`warning: could not ask where to print links (${msg}); printing them on ${DEFAULT_LINK_ORIGIN}`);
294
+ }
295
+ return DEFAULT_LINK_ORIGIN;
296
+ }
297
+
298
+ export async function linkBase(opts = {}) {
299
+ const t = target(opts);
300
+ if (t.name === "dev") return t.shareBase;
301
+ const env = process.env.HYPERDECKS_LINK_ORIGIN?.trim();
302
+ if (env) {
303
+ if (isLinkHost(env) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(env)) return normalizeBase(env).toLowerCase();
304
+ console.warn(`warning: HYPERDECKS_LINK_ORIGIN must be ${LINK_HOSTS.join(" or ")} (or a localhost URL); ignoring ${JSON.stringify(env)}`);
305
+ }
306
+ if (linkOriginPin) return linkOriginPin;
307
+ // A caller's own client (the hosted MCP, tests) is asked each time.
308
+ if (opts.client) return askLinkOrigin(opts.client);
309
+ if (!linkOriginCache.has(t.convexUrl)) linkOriginCache.set(t.convexUrl, askLinkOrigin(new ConvexHttpClient(t.convexUrl)));
310
+ return linkOriginCache.get(t.convexUrl);
311
+ }
312
+
313
+ // The same link on the other app host, for a recipient whose network blocks
314
+ // the printed one; null for a link that is not on an app host (dev).
315
+ export function backupLinkUrl(url) {
316
+ const m = /^(https?:\/\/[^/]+)(\/.*)$/.exec(String(url || ""));
317
+ if (!m) return null;
318
+ const i = LINK_HOSTS.indexOf(m[1].toLowerCase());
319
+ return i < 0 ? null : `${LINK_HOSTS[1 - i]}${m[2]}`;
320
+ }
321
+
262
322
  export function loadCreds() {
263
323
  if (!existsSync(CRED_FILE)) return {};
264
324
  try {