@hypersocial/decks-cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -35,7 +35,48 @@ word, and wait for them:
35
35
  - voice: cloning needs them to type their consent in a terminal (`hyperdecks
36
36
  voice clone <file> --name <name>`), or to agree to the statement before you
37
37
  call `clone_voice`. Never type or confirm consent for them. `--voice dash`
38
- lets D.A.S.H., a character voice, present instead.
38
+ lets D.A.S.H., a character voice, present instead;
39
+ - narrator: who narrates their decks, a face plus a voice. Without one, a deck
40
+ in the user's own voice plays captions only. See *Narrators* below; only the
41
+ person picks one.
42
+
43
+ ### Narrators: who narrates a deck (a face plus a voice)
44
+
45
+ A narrator pairs a face (one of the user's avatars) with a voice (one of their
46
+ ready voices, or D.A.S.H.'s). D.A.S.H. is built in. A deck uses its own
47
+ narrator, else the default: `narrate` speaks in the narrator's voice (an
48
+ explicit `--voice` or the script's `voice:` still wins) and `release` puts its
49
+ face on the deck. **Never add a narrator for the person**: `narrator new`
50
+ returns a link; hand it to them, word for word, and wait.
51
+
52
+ | Step | CLI | MCP |
53
+ | --- | --- | --- |
54
+ | List them, the default and the voices | `hyperdecks narrator --json` | `list_narrators` |
55
+ | Add one (the person picks face and voice) | `hyperdecks narrator new --json` → `url` | `new_narrator_link` |
56
+ | Make one the default (the person named it) | `hyperdecks narrator default <name>` | `set_default_narrator` |
57
+ | Remove one (confirm first) | `hyperdecks narrator remove <name>` | `remove_narrator` |
58
+ | Change its voice (the person asked) | `hyperdecks narrator voice <name> <voice>` | `set_narrator_voice` |
59
+ | One deck's own narrator | `hyperdecks deck narrator <slug> <name\|none\|default>` | `set_deck_narrator` |
60
+
61
+ ### Avatars: who presents a deck in the user's voice
62
+
63
+ The faces narrators wear; these commands still work as before.
64
+
65
+ A deck narrated in the user's own voice is presented by their default avatar
66
+ (a cartoon Minimoji, never a photo) from its next release; with none it plays
67
+ captions only. D.A.S.H. presents only in its own voice. **Never pick or approve
68
+ a face for the person.** Every command that needs a choice returns a link (and,
69
+ for an import, a code): hand it to them, word for word, and wait.
70
+
71
+ | Step | CLI | MCP |
72
+ | --- | --- | --- |
73
+ | List them and the default | `hyperdecks avatar --json` | `list_avatars` |
74
+ | Pick a species and an avatar | `hyperdecks avatar new [--species cat] --json` → `pickUrl` | `new_avatar_link` |
75
+ | A cartoon from a photo | `hyperdecks avatar from-photo [me.jpg] --json` → `reviewUrl` (or `selfieUrl`) | `avatar_from_photo` |
76
+ | Import from Minimoji | `hyperdecks avatar connect --json` → `url` + `code` | `connect_avatar` |
77
+ | Make one the default (the person named it) | `hyperdecks avatar default <name>` | `set_default_avatar` |
78
+ | Remove one (confirm first) | `hyperdecks avatar remove <name>` | `remove_avatar` |
79
+ | One deck's own presenter | `hyperdecks deck avatar <slug> <name\|none\|default>` | `set_deck_avatar` |
39
80
 
40
81
  ## Target Selection
41
82
 
@@ -92,11 +133,21 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
92
133
  | Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
93
134
  | Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
94
135
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
95
- | One more link | `hyperdecks share new <slug> "<name>"` | `create_share` |
136
+ | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
137
+ | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
138
+ | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
96
139
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
97
140
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
98
141
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
99
142
 
143
+ Forwarding is on by default: a viewer can forward the deck to someone, which
144
+ makes a child link tracked on its own ("Dana via Peter"; `share list` shows it
145
+ indented under Peter's, `list_shares` gives `parentToken` and `forwardedBy`).
146
+ One hop only: a forwarded link can't be forwarded again, nor can a QA link.
147
+ Disabling the parent link (`share off`) turns off its forwards too, and
148
+ `share forward default` (or `allowForward: null`) drops a link's own setting
149
+ so it follows the deck's.
150
+
100
151
  ### 1. Ground it in the user's own material
101
152
 
102
153
  Build the deck from what you can already read in this session: files in the
package/src/avatar.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // hyperdecks avatar [list] | new [--species x] | from-photo [file] [--save-to-minimoji] | connect | default <id> | remove <id>
2
+ //
3
+ // The face that presents decks narrated in your own voice (convex/avatars.ts).
4
+ // Only a person picks or approves a face: in a terminal you pick by number;
5
+ // with --json, or without a terminal (an agent), each command returns the link
6
+ // (and for connect, the code) to hand the person, and never chooses.
7
+ import { readFileSync } from "node:fs";
8
+ import { extname } from "node:path";
9
+ import * as ops from "./ops.mjs";
10
+ import { SPECIES as TABLE } from "./species.mjs";
11
+
12
+ export const SPECIES = TABLE.map((s) => s.id);
13
+ const PHOTO_TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png" };
14
+
15
+ const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY;
16
+
17
+ export async function avatarList(opts, { log = console.log } = {}) {
18
+ const out = await ops.listAvatars(opts);
19
+ if (opts.json) return out;
20
+ for (const a of out.avatars) log(`${a.isDefault ? "*" : " "} ${a.name}\t${a.source}${a.species ? ` (${a.species})` : ""}\t${a.id}`);
21
+ log("\n* the default; decks narrated in your voice use it unless they set their own (deck avatar <slug> <id>)");
22
+ return out;
23
+ }
24
+
25
+ // Pick a species and one of its avatars in the terminal; s shuffles, u undoes.
26
+ export async function avatarNew(opts, { ask, log = console.log, interactive = interactiveOf(opts) } = {}) {
27
+ const species = opts.species ? String(opts.species).toLowerCase() : null;
28
+ if (species && !SPECIES.includes(species)) throw new Error(`--species is one of ${SPECIES.join(", ")}`);
29
+ if (!interactive) {
30
+ const link = await ops.newAvatarLink(opts, species ? { species } : {});
31
+ if (!opts.json) log(`Pick an avatar here (only you can choose it):\n ${link.pickUrl}`);
32
+ return link;
33
+ }
34
+ log(`Species: ${SPECIES.join(" ")}`);
35
+ let chosen = species;
36
+ while (!chosen) {
37
+ const a = (await ask("Species [human]: ")).trim().toLowerCase() || "human";
38
+ if (SPECIES.includes(a)) chosen = a;
39
+ else log(` not a species; one of ${SPECIES.join(", ")}`);
40
+ }
41
+ const link = await ops.newAvatarLink(opts, { species: chosen });
42
+ log(`Preview them: ${link.pickUrl}`);
43
+ const seeds = [0];
44
+ for (;;) {
45
+ const page = await ops.avatarOptions(opts, chosen, seeds[seeds.length - 1]);
46
+ log(page.options.map((o, i) => ` ${String(i + 1).padStart(2)} ${o.name}`).join("\n"));
47
+ const keys = [page.canShuffle && "s to shuffle", seeds.length > 1 && "u to undo"].filter(Boolean).join(", ");
48
+ const a = (await ask(`Pick 1-${page.options.length}${keys ? `, or ${keys}` : ""}: `)).trim().toLowerCase();
49
+ if (a === "s" && page.canShuffle) { seeds.push(seeds[seeds.length - 1] + 1); continue; }
50
+ if (a === "u" && seeds.length > 1) { seeds.pop(); continue; }
51
+ const n = Number(a);
52
+ const option = Number.isInteger(n) ? page.options[n - 1] : null;
53
+ if (!option) continue;
54
+ await ops.addPickedAvatar(opts, option.avatarKey);
55
+ log(`✓ Added ${option.name} (${chosen}). Default for decks in your voice.`);
56
+ return { status: "added", name: option.name, species: chosen };
57
+ }
58
+ }
59
+
60
+ // A face from a photo file, or (no file) a selfie in the browser. Either way the
61
+ // person approves it on the link before it is used.
62
+ export async function avatarFromPhoto(file, opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
63
+ let photo = {};
64
+ if (file) {
65
+ const contentType = PHOTO_TYPES[extname(file).toLowerCase()];
66
+ if (!contentType) throw new Error("the photo must be a .jpg or .png");
67
+ photo = { bytes: readFileSync(file), contentType };
68
+ }
69
+ const out = await ops.avatarFromPhoto(opts, photo, { onProgress: (m) => !opts.json && log(m) });
70
+ const url = out.reviewUrl ?? out.selfieUrl;
71
+ if (opts.json) return out;
72
+ log(file ? `✓ Drew a cartoon of you. Check it before it's used:\n ${url}` : `No photo given. Take a selfie in your browser:\n ${url}`);
73
+ if (opts["save-to-minimoji"]) log(" (tick \"Also save it to my Minimoji account\" there)");
74
+ if (!interactive) return out;
75
+ log("Waiting for you…");
76
+ const avatar = await ops.waitForLink(opts, out.token);
77
+ log(`✓ ${avatar.name} presents your decks now. Your photo was deleted.`);
78
+ return { status: "added", name: avatar.name };
79
+ }
80
+
81
+ export async function avatarConnect(opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
82
+ const out = await ops.connectAvatar(opts);
83
+ if (opts.json) return out;
84
+ log(`On any device, go to ${out.url}\nand enter ${out.code} (expires in ${Math.round(out.expiresIn / 60)} min)`);
85
+ if (!interactive) return out;
86
+ log("Waiting for you to pick a face…");
87
+ const done = await ops.waitForConnect(opts, out.connectId);
88
+ if (done.status !== "added") throw new Error(done.status === "denied" ? "not shared on minimoji.com" : done.status === "expired" ? "the code expired; run avatar connect again" : done.error || `the import ended (${done.status})`);
89
+ log("✓ Presenter: your Minimoji. Your decks use it from the next release.");
90
+ return { status: "added", avatarId: done.avatarId };
91
+ }
package/src/commands.mjs CHANGED
@@ -87,7 +87,8 @@ function shareBase(opts) {
87
87
 
88
88
  async function shareNew(pos, opts) {
89
89
  const [slug, recipient] = pos;
90
- if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]");
90
+ if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
91
+ if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
91
92
  const { token } = await ops.createShare(opts, {
92
93
  slug,
93
94
  recipient,
@@ -96,6 +97,7 @@ async function shareNew(pos, opts) {
96
97
  requireEmail: opts["require-email"],
97
98
  qa: opts.qa || undefined,
98
99
  notify: opts["no-notify"] ? false : undefined,
100
+ allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
99
101
  expires: opts.expires,
100
102
  limit: opts.limit,
101
103
  });
@@ -112,7 +114,11 @@ async function shareList(pos, opts) {
112
114
  console.log(JSON.stringify(rows, null, 2));
113
115
  return;
114
116
  }
115
- for (const r of rows) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.archived ? "\t(archived)" : ""}`);
117
+ // Rows arrive with each link's forwards right after it; indent those.
118
+ for (const r of rows) {
119
+ const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
120
+ console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
121
+ }
116
122
  if (!rows.length) console.log("(no shares)");
117
123
  }
118
124
 
@@ -126,8 +132,24 @@ async function shareToggle(pos, opts, enabled) {
126
132
  async function shareRm(pos, opts) {
127
133
  const [token] = pos;
128
134
  if (!token) throw new Error("usage: share rm <token>");
129
- await ops.removeShare(opts, token);
130
- console.log(`OK removed ${token}`);
135
+ const { removedForwards } = await ops.removeShare(opts, token);
136
+ console.log(`OK removed ${token}${removedForwards ? ` and ${removedForwards} forwarded link${removedForwards === 1 ? "" : "s"}` : ""}`);
137
+ }
138
+
139
+ const FORWARD = { on: true, off: false, default: null };
140
+
141
+ async function shareForward(pos, opts) {
142
+ const [mode, token] = pos;
143
+ if (!Object.hasOwn(FORWARD, mode ?? "") || !token) throw new Error("usage: share forward on|off|default <token>");
144
+ await ops.setShareForwarding(opts, token, FORWARD[mode]);
145
+ console.log(`OK forwarding ${mode === "default" ? "follows the deck default" : mode} for ${token}`);
146
+ }
147
+
148
+ async function deckForward(pos, opts) {
149
+ const [mode, slug] = pos;
150
+ if ((mode !== "on" && mode !== "off") || !slug) throw new Error("usage: deck forward on|off <slug>");
151
+ await ops.setDeckForwarding(opts, slug, mode === "on");
152
+ console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
131
153
  }
132
154
 
133
155
  function shareUrl(pos, opts) {
@@ -281,7 +303,9 @@ commands:
281
303
  login [--token <pat>] [--broker <url>] [--dev]
282
304
  logout [--dev]
283
305
  whoami [--json] [--dev]
284
- deck list|stats|open|rm
306
+ deck list|stats|open|rm|forward
307
+ deck forward on|off <slug>
308
+ whether viewers can forward the deck's links (on by default; a link's own setting wins)
285
309
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
286
310
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
287
311
  layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
@@ -298,8 +322,11 @@ commands:
298
322
  the skill, one topic at a time (what deck_guide serves)
299
323
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
300
324
  on a committed deck: check → narrate → import → share new per --to
301
- share new|list|on|off|mute|unmute|rm|url
302
- share new <slug> <recipient> [--qa] [--no-notify]
325
+ share new|list|on|off|forward|mute|unmute|rm|url
326
+ share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
327
+ share forward on|off|default <token>
328
+ let this link's viewer forward it as their own tracked link ("Dana via Peter",
329
+ one hop); default follows the deck. share off on a link also turns off its forwards.
303
330
  recipient archive|unarchive
304
331
  import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live]
305
332
  import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
@@ -341,6 +368,20 @@ commands:
341
368
  asks you to type "I consent" before anything is uploaded
342
369
  voice upload <file...> upload recordings and print their sample ids (for the MCP clone_voice tool)
343
370
  voice rm <name|id> delete a voice, also at the provider
371
+ avatar [--json] your avatars (who presents decks in your voice) and the default
372
+ avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
373
+ avatar from-photo [file] [--save-to-minimoji]
374
+ draw a cartoon of you from a photo (no file: a selfie link); you approve it on a link first
375
+ avatar connect import your avatar from Minimoji with a code you enter on minimoji.com
376
+ avatar default <name|id|dash> make it the default
377
+ avatar remove <name|id> remove it; decks already shared keep working, captions only
378
+ deck avatar <slug> <name|id|none|default> the deck's own presenter (applies at its next release)
379
+ narrator [--json] who narrates your decks (a face plus a voice) and the default
380
+ narrator new a link where you pick a face and a voice; it becomes the default
381
+ narrator default <name|id|dash> make it the default
382
+ narrator remove <name|id> remove it; decks already shared keep working
383
+ narrator voice <name|id> <voice> change its voice (one of yours, or dash)
384
+ deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
344
385
 
345
386
  global:
346
387
  --json
@@ -664,6 +705,72 @@ function commandTable(pos, rest, opts) {
664
705
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
665
706
  console.log(`OK removed voice "${out.name}"${out.removedAtProvider ? " (deleted at the provider; decks already narrated with it keep playing)" : ""}`);
666
707
  },
708
+ avatar: async () => {
709
+ const { avatarList } = await import("./avatar.mjs");
710
+ const out = await avatarList(opts);
711
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
712
+ },
713
+ "avatar new": async () => {
714
+ const { avatarNew } = await import("./avatar.mjs");
715
+ const { askLine } = await import("./init.mjs");
716
+ const out = await avatarNew(opts, { ask: askLine });
717
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
718
+ },
719
+ "avatar from-photo": async () => {
720
+ const { avatarFromPhoto } = await import("./avatar.mjs");
721
+ const out = await avatarFromPhoto(rest[0], opts);
722
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
723
+ },
724
+ "avatar connect": async () => {
725
+ const { avatarConnect } = await import("./avatar.mjs");
726
+ const out = await avatarConnect(opts);
727
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
728
+ },
729
+ "avatar default": async () => {
730
+ if (!rest[0]) throw new Error("usage: avatar default <name|id|dash>");
731
+ const out = await ops.setDefaultAvatar(opts, rest.join(" "));
732
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} is the default for decks in your voice`);
733
+ },
734
+ "avatar remove": async () => {
735
+ if (!rest[0]) throw new Error("usage: avatar remove <name|id>");
736
+ const out = await ops.removeAvatar(opts, rest.join(" "));
737
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK removed ${out.name}; decks already shared keep working`);
738
+ },
739
+ "deck avatar": async () => {
740
+ if (rest.length < 2) throw new Error("usage: deck avatar <slug> <name|id|none|default>");
741
+ const out = await ops.setDeckAvatar(opts, rest[0], rest.slice(1).join(" "));
742
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (applies at its next release)`);
743
+ },
744
+ narrator: async () => {
745
+ const { narratorList } = await import("./narrator.mjs");
746
+ const out = await narratorList(opts);
747
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
748
+ },
749
+ "narrator new": async () => {
750
+ const { narratorNew } = await import("./narrator.mjs");
751
+ const out = await narratorNew(opts);
752
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
753
+ },
754
+ "narrator default": async () => {
755
+ if (!rest[0]) throw new Error("usage: narrator default <name|id|dash>");
756
+ const out = await ops.setDefaultNarrator(opts, rest.join(" "));
757
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} narrates your decks by default`);
758
+ },
759
+ "narrator remove": async () => {
760
+ if (!rest[0]) throw new Error("usage: narrator remove <name|id>");
761
+ const out = await ops.removeNarrator(opts, rest.join(" "));
762
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK removed ${out.name}; decks already shared keep working`);
763
+ },
764
+ "narrator voice": async () => {
765
+ if (rest.length < 2) throw new Error("usage: narrator voice <name|id> <voice>");
766
+ const out = await ops.setNarratorVoice(opts, rest[0], rest.slice(1).join(" "));
767
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
768
+ },
769
+ "deck narrator": async () => {
770
+ if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
771
+ const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
772
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (applies at its next narrate and release)`);
773
+ },
667
774
  "preview setup": async () => {
668
775
  const { setupCapture } = await import("./preview-capture.mjs");
669
776
  await setupCapture(rest, opts);
@@ -675,10 +782,12 @@ function commandTable(pos, rest, opts) {
675
782
  "deck stats": () => deckStats(rest, opts),
676
783
  "deck open": () => deckOpen(rest, opts),
677
784
  "deck rm": () => deckRemove(rest, opts),
785
+ "deck forward": () => deckForward(rest, opts),
678
786
  "share new": () => shareNew(rest, opts),
679
787
  "share list": () => shareList(rest, opts),
680
788
  "share on": () => shareToggle(rest, opts, true),
681
789
  "share off": () => shareToggle(rest, opts, false),
790
+ "share forward": () => shareForward(rest, opts),
682
791
  "share mute": async () => { if (!rest[0]) throw new Error("usage: share mute <token>"); await ops.setShareNotify(opts, rest[0], false); console.log(`OK muted ${rest[0]}`); },
683
792
  "share unmute": async () => { if (!rest[0]) throw new Error("usage: share unmute <token>"); await ops.setShareNotify(opts, rest[0], true); console.log(`OK notification preference requested for ${rest[0]} (QA links remain muted)`); },
684
793
  "share rm": () => shareRm(rest, opts),
package/src/init.mjs CHANGED
@@ -279,6 +279,7 @@ export async function init(pos = [], opts = {}, io = {}) {
279
279
  env = process.env,
280
280
  random = () => randomBytes(2).toString("hex"),
281
281
  fetch,
282
+ avatars = null, // the avatar.mjs commands (tests pass fakes)
282
283
  } = io;
283
284
  if (pos.length) throw new Error(`unexpected argument(s): ${pos.join(" ")}; init takes flags only (hyperdecks init --help)`);
284
285
  for (const k of ["voice", "name", "about", "ask", "dir"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
@@ -335,7 +336,17 @@ export async function init(pos = [], opts = {}, io = {}) {
335
336
  const voiceStep = await chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log, state, who });
336
337
  record(voiceStep);
337
338
  if (voiceStep.status === "needs-human") return finish("incomplete");
338
- const { voice, presenter } = voiceStep;
339
+ let { voice, presenter } = voiceStep;
340
+
341
+ // 3b. Who presents a deck in your own voice: the deck stays captions-only in
342
+ // its source, and release puts your default avatar on it.
343
+ if (presenter === "self") {
344
+ const face = await presenterStep(base, opts, { interactive, ask, log, sub, avatars: avatars ?? (await import("./avatar.mjs")), state });
345
+ record(face);
346
+ if (face.presenter === "dash") ({ voice, presenter } = face);
347
+ // Asked once, unless the step could not even look (it asks again next run).
348
+ if (!face.warning) save({ presenterChosen: true });
349
+ }
339
350
 
340
351
  // 4. The first deck.
341
352
  const scaffold = await scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save });
@@ -494,6 +505,48 @@ async function chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log,
494
505
  }
495
506
  }
496
507
 
508
+ // Who narrates your decks: your own voice needs a face, or the deck plays
509
+ // captions only. Only you pick it: without a terminal (an agent) this points
510
+ // at `hyperdecks narrator new`.
511
+ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars, state }) {
512
+ let mine;
513
+ try {
514
+ mine = await ops.listAvatars(base);
515
+ } catch (e) {
516
+ const why = ops.errorText(e);
517
+ if (/not switched on/.test(why)) return step("presenter", "skipped", "presenter: captions only (avatars are not switched on for this account yet)");
518
+ return step("presenter", "skipped", `presenter: captions only for now (${why}); run init again, or \`hyperdecks avatar new\``, { warning: true });
519
+ }
520
+ const current = mine.avatars.find((a) => a.isDefault && a.source !== "dash");
521
+ if (current) return step("presenter", "done", `presenter: ${current.name}`);
522
+ if (!interactive || state.presenterChosen) {
523
+ return step("presenter", "skipped", "presenter: captions only; give your decks a narrator (a face plus your voice) with `hyperdecks narrator new`");
524
+ }
525
+ const io = { ask, log: sub, interactive: true };
526
+ for (;;) {
527
+ log("\nWho narrates your decks?");
528
+ log(" 1 D.A.S.H. our robot, in its own voice");
529
+ log(" 2 Pick a Minimoji choose a species, in your voice");
530
+ log(" 3 Make one from a selfie a cartoon of you, you approve it");
531
+ log(" 4 Import from Minimoji one you already have");
532
+ log(" 5 Nobody captions only");
533
+ const pick = (await ask("Choose 1-5 [2]: ")).trim() || "2";
534
+ try {
535
+ if (pick === "1") return step("presenter", "done", "D.A.S.H. presents, in the dash character voice", { voice: "dash", presenter: "dash" });
536
+ if (pick === "2") { const out = await avatars.avatarNew(base, io); return step("presenter", "ran", `presenter: ${out.name}`); }
537
+ if (pick === "3") {
538
+ const file = parseDroppedPaths(await ask("Drag a photo (.jpg or .png) here, or press Enter for a selfie link: "))[0];
539
+ const out = await avatars.avatarFromPhoto(file, base, io);
540
+ return step("presenter", "ran", `presenter: ${out.name}`);
541
+ }
542
+ if (pick === "4") { await avatars.avatarConnect(base, io); return step("presenter", "ran", "presenter: your Minimoji"); }
543
+ if (pick === "5") return step("presenter", "skipped", "presenter: captions only; add a narrator later with `hyperdecks narrator new`");
544
+ } catch (e) {
545
+ log(`That did not finish: ${ops.errorText(e)}`);
546
+ }
547
+ }
548
+ }
549
+
497
550
  async function deckOwnedBy(base, slug, who) {
498
551
  const deck = await (await client(base)).query(api.decks.get, { slug });
499
552
  return deck ? { exists: true, mine: deck.ownerId === who.subject } : { exists: false, mine: false };
@@ -0,0 +1,29 @@
1
+ // hyperdecks narrator [list] | new | default <id> | remove <id> | voice <id> <voice>
2
+ //
3
+ // Who narrates your decks: a face plus a voice (convex/narrators.ts). Only a
4
+ // person adds one: `narrator new` hands back a link where they pick the face
5
+ // and the voice; in a terminal it waits for them, with --json (or an agent) it
6
+ // returns the link and never chooses.
7
+ import * as ops from "./ops.mjs";
8
+
9
+ const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY;
10
+
11
+ export async function narratorList(opts, { log = console.log } = {}) {
12
+ const out = await ops.listNarrators(opts);
13
+ if (opts.json) return out;
14
+ for (const n of out.narrators) log(`${n.isDefault ? "*" : " "} ${n.name}\t${n.face ?? "(no face)"} · ${n.voice ? `${n.voice}${n.voiceReady ? "" : " (not ready)"}` : "(voice removed)"}\t${n.id}`);
15
+ if (out.note) log(`\n${out.note}`);
16
+ log("\n* the default; decks use it unless they set their own (deck narrator <slug> <id>)");
17
+ return out;
18
+ }
19
+
20
+ export async function narratorNew(opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
21
+ const link = await ops.newNarratorLink(opts);
22
+ if (opts.json) return link;
23
+ log(`Pick your narrator's face and voice here (only you can choose them):\n ${link.url}`);
24
+ if (!interactive) return link;
25
+ log("Waiting for you…");
26
+ const narrator = await ops.waitForNarrator(opts, link.token);
27
+ log(`✓ Added ${narrator.name}. \`hyperdecks narrator\` shows which one is the default.`);
28
+ return { status: "added", ...narrator };
29
+ }
package/src/ops.mjs CHANGED
@@ -395,6 +395,14 @@ export async function listShares(opts, slug) {
395
395
  recipientEmail: r.recipientEmail ?? null,
396
396
  expiresAt: r.expiresAt ?? null,
397
397
  viewLimit: r.viewLimit ?? null,
398
+ // Forwarding: allowForward is the effective setting (link override, else
399
+ // the deck's, else on); a forwarded link carries its parent's token and the
400
+ // viewer who forwarded it. Rows come back with each link's forwards after it.
401
+ allowForward: !!r.allowForward,
402
+ allowForwardOverride: r.allowForwardOverride ?? null,
403
+ parentToken: r.parentToken ?? null,
404
+ forwardedBy: r.forwardedBy ?? null,
405
+ probablyForwarded: !!r.probablyForwarded,
398
406
  }));
399
407
  }
400
408
 
@@ -407,6 +415,7 @@ export async function createShare(opts, fields) {
407
415
  if (fields.email) args.recipientEmail = fields.email;
408
416
  if (fields.note) args.note = fields.note;
409
417
  if (fields.requireEmail) args.requireEmail = true;
418
+ if (fields.allowForward !== undefined) args.allowForward = !!fields.allowForward;
410
419
  if (fields.expires) {
411
420
  const t = Date.parse(fields.expires);
412
421
  if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
@@ -433,8 +442,23 @@ export async function setShareNotify(opts, token, notify) {
433
442
 
434
443
  export async function removeShare(opts, token) {
435
444
  if (!token) throw new Error("token is required");
436
- await (await client(opts)).mutation(api.shares.remove, { token });
437
- return { token, removed: true };
445
+ const out = await (await client(opts)).mutation(api.shares.remove, { token });
446
+ return { token, removed: true, removedForwards: out?.removedForwards ?? 0 };
447
+ }
448
+
449
+ // Per-link forwarding override: true/false, or null to follow the deck default.
450
+ export async function setShareForwarding(opts, token, allowForward) {
451
+ if (!token) throw new Error("token is required");
452
+ const value = allowForward === null || allowForward === undefined ? null : !!allowForward;
453
+ await (await client(opts)).mutation(api.shares.setAllowForward, { token, allowForward: value });
454
+ return { token, allowForward: value };
455
+ }
456
+
457
+ // The deck default for every link without its own override (on unless set off).
458
+ export async function setDeckForwarding(opts, slug, allowForward) {
459
+ if (!slug) throw new Error("slug is required");
460
+ await (await client(opts)).mutation(api.decks.setAllowForward, { slug, allowForward: !!allowForward });
461
+ return { slug, allowForward: !!allowForward };
438
462
  }
439
463
 
440
464
  // Register a new deck (draft) you own. Refuses any slug that already exists:
@@ -830,3 +854,195 @@ export async function importDeckFromDesign(opts = {}, fields = {}, adapter = {})
830
854
  );
831
855
  return { ...result, fetched: files.size, missing };
832
856
  }
857
+
858
+ // ---- avatars (convex/avatars.ts) --------------------------------------------
859
+ // The face that presents decks narrated in your own voice. An agent never picks
860
+ // or approves a face: every way that needs a choice hands back a link (and for
861
+ // an import, a code) for the person; only an interactive terminal picks itself.
862
+
863
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
864
+
865
+ // Your avatars, D.A.S.H. first, after following any edits or revokes made on
866
+ // minimoji.com (best effort: a Minimoji blip still lists them).
867
+ export async function listAvatars(opts = {}) {
868
+ const c = await client(opts);
869
+ await c.action(api.avatarsNode.refresh, {}).catch(() => {});
870
+ const out = await c.query(api.avatars.listAvatars, {});
871
+ if (!out.enabled) throw new Error("avatars are not switched on for this account yet");
872
+ return { default: out.defaultId, avatars: out.avatars.map(({ id, name, source, species, isDefault, linked }) => ({ id, name, source, species: species ?? null, isDefault, linked: !!linked })) };
873
+ }
874
+
875
+ export async function findAvatar(opts, ref) {
876
+ const want = String(ref ?? "").trim().toLowerCase();
877
+ if (!want) throw new Error("name an avatar (its id or name; `dash` for D.A.S.H.)");
878
+ const { avatars } = await listAvatars(opts);
879
+ const hit = avatars.find((a) => a.id === ref || a.name.toLowerCase() === want || (want === "dash" && a.source === "dash"));
880
+ if (!hit) throw new Error(`no avatar "${ref}" (yours: ${avatars.map((a) => a.name).join(", ")})`);
881
+ return hit;
882
+ }
883
+
884
+ export async function setDefaultAvatar(opts, ref) {
885
+ const a = await findAvatar(opts, ref);
886
+ await (await client(opts)).mutation(api.avatars.setDefault, { avatarId: a.id });
887
+ return { default: a.id, name: a.name };
888
+ }
889
+
890
+ export async function removeAvatar(opts, ref) {
891
+ const a = await findAvatar(opts, ref);
892
+ if (a.source === "dash") throw new Error("D.A.S.H. is built in and cannot be removed");
893
+ return { ...(await (await client(opts)).mutation(api.avatars.removeAvatar, { avatarId: a.id })), id: a.id };
894
+ }
895
+
896
+ // A deck's own presenter: an avatar, `none` (captions only) or `default`.
897
+ export async function setDeckAvatar(opts, slug, ref) {
898
+ if (!slug) throw new Error("usage: deck avatar <slug> <avatar|none|default>");
899
+ const value = ref === "none" || ref === "default" ? ref : (await findAvatar(opts, ref)).id;
900
+ if (value === "dash") throw new Error("D.A.S.H. presents decks in its own voice; for an own-voice deck pick one of your avatars, none or default");
901
+ return (await client(opts)).mutation(api.avatars.setDeckAvatar, { slug, avatar: value });
902
+ }
903
+
904
+ // The link where the person picks one (optionally of one species).
905
+ export async function newAvatarLink(opts, { species } = {}) {
906
+ const link = await (await client(opts)).mutation(api.avatars.createLink, { kind: "pick", ...(species ? { species: String(species) } : {}) });
907
+ return { status: "waiting", pickUrl: link.url, token: link.token, expiresAt: link.expiresAt };
908
+ }
909
+
910
+ export async function avatarOptions(opts, species, seed = 0) {
911
+ return (await client(opts)).action(api.avatarsNode.options, { species: String(species), seed });
912
+ }
913
+
914
+ export async function addPickedAvatar(opts, avatarKey) {
915
+ return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
916
+ }
917
+
918
+ // A face from a photo. With bytes: upload and draw it, then hand back the link
919
+ // where the person approves it (nothing is used before). Without: the link
920
+ // where the person takes a selfie in their browser.
921
+ export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onProgress } = {}) {
922
+ const c = await client(opts);
923
+ if (!bytes) {
924
+ const link = await c.mutation(api.avatars.createLink, { kind: "selfie" });
925
+ return { status: "waiting", selfieUrl: link.url, token: link.token, expiresAt: link.expiresAt };
926
+ }
927
+ if (!/^image\/(jpeg|png)$/.test(contentType ?? "")) throw new Error("the photo must be a .jpg or .png");
928
+ const storageId = await runtimeUploadBytes(c, bytes, contentType);
929
+ const { draftId } = await c.mutation(api.avatars.drawSelfie, { storageId });
930
+ onProgress?.("drawing…");
931
+ const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
932
+ if (!draft) throw new Error("the drawing is gone; run the command again");
933
+ if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
934
+ const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
935
+ return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
936
+ }
937
+
938
+ // Import from Minimoji with a device code: the person opens `url`, enters
939
+ // `code` and picks one there. The server watches for the answer.
940
+ export async function connectAvatar(opts = {}) {
941
+ const out = await (await client(opts)).action(api.avatarsNode.startConnect, { makeDefault: true });
942
+ return { status: "waiting", url: out.url, code: out.userCode, expiresIn: out.expiresIn, connectId: out.connectId };
943
+ }
944
+
945
+ export async function waitForConnect(opts, connectId, { timeoutMs = 11 * 60_000 } = {}) {
946
+ const c = await client(opts);
947
+ return pollUntil(() => c.query(api.avatars.getConnect, { connectId }), (x) => x.status !== "waiting", { timeoutMs });
948
+ }
949
+
950
+ // Wait for the person to finish on a /presenter link; returns the avatar added.
951
+ export async function waitForLink(opts, token, { timeoutMs = 60 * 60_000 } = {}) {
952
+ const c = await client(opts);
953
+ const link = await pollUntil(() => c.query(api.avatars.getLink, { token }), (l) => !!l.avatar || l.expired, { timeoutMs });
954
+ if (!link.avatar) throw new Error("the link expired before an avatar was added; run the command again");
955
+ return link.avatar;
956
+ }
957
+
958
+ export async function presenterFor(opts, slug, voice) {
959
+ return (await client(opts)).query(api.avatars.presenterFor, { slug, ...(voice ? { voice: String(voice) } : {}) });
960
+ }
961
+
962
+ // ---- narrators (convex/narrators.ts) ------------------------------------------
963
+ // Who narrates your decks: a face plus a voice. D.A.S.H. is built in. As with
964
+ // avatars, an agent never adds one: `narrator new` hands back a link.
965
+
966
+ export async function listNarrators(opts = {}) {
967
+ const out = await (await client(opts)).query(api.narrators.list, {});
968
+ return {
969
+ default: out.defaultId,
970
+ ...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
971
+ narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
972
+ voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
973
+ };
974
+ }
975
+
976
+ export async function findNarrator(opts, ref) {
977
+ const want = String(ref ?? "").trim().toLowerCase();
978
+ if (!want) throw new Error("name a narrator (its id or name; `dash` for D.A.S.H.)");
979
+ const { narrators } = await listNarrators(opts);
980
+ const byId = narrators.find((n) => n.id === ref || (want === "dash" && n.builtin));
981
+ if (byId) return byId;
982
+ // Names repeat ("Me" is the default): an ambiguous name never picks one.
983
+ const named = narrators.filter((n) => n.name.toLowerCase() === want);
984
+ if (named.length > 1) throw new Error(`"${ref}" matches ${named.length} narrators (ids: ${named.map((n) => n.id).join(", ")}); use the id`);
985
+ if (!named.length) throw new Error(`no narrator "${ref}" (yours: ${narrators.map((n) => `${n.name} ${n.id}`).join(", ")})`);
986
+ return named[0];
987
+ }
988
+
989
+ // The link where the person picks a face and a voice; it becomes their default.
990
+ export async function newNarratorLink(opts = {}) {
991
+ const link = await (await client(opts)).mutation(api.narrators.newLink, {});
992
+ return { status: "waiting", url: link.url, token: link.token, expiresAt: link.expiresAt };
993
+ }
994
+
995
+ export async function waitForNarrator(opts, token, { timeoutMs = 60 * 60_000 } = {}) {
996
+ const c = await client(opts);
997
+ const link = await pollUntil(() => c.query(api.avatars.getLink, { token }), (l) => !!l.narrator || l.expired, { timeoutMs });
998
+ if (!link.narrator) throw new Error("the link expired before a narrator was added; run the command again");
999
+ return link.narrator;
1000
+ }
1001
+
1002
+ export async function setDefaultNarrator(opts, ref) {
1003
+ const n = await findNarrator(opts, ref);
1004
+ await (await client(opts)).mutation(api.narrators.setDefault, { narrator: n.id });
1005
+ return { default: n.id, name: n.name };
1006
+ }
1007
+
1008
+ export async function removeNarrator(opts, ref) {
1009
+ const n = await findNarrator(opts, ref);
1010
+ if (n.builtin) throw new Error("D.A.S.H. is built in and cannot be removed");
1011
+ return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
1012
+ }
1013
+
1014
+ // voice: one of your ready voices (name or id) or dash.
1015
+ export async function setNarratorVoice(opts, ref, voice) {
1016
+ if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
1017
+ const n = await findNarrator(opts, ref);
1018
+ await (await client(opts)).mutation(api.narrators.setVoice, { narrator: n.id, voice: String(voice) });
1019
+ return { narrator: n.id, name: n.name, voice: String(voice) };
1020
+ }
1021
+
1022
+ // A deck's own narrator: a narrator, `none` (captions only) or `default`.
1023
+ export async function setDeckNarrator(opts, slug, ref) {
1024
+ if (!slug || !ref) throw new Error("usage: deck narrator <slug> <narrator|none|default>");
1025
+ const value = ref === "none" || ref === "default" ? ref : (await findNarrator(opts, ref)).id;
1026
+ return (await client(opts)).mutation(api.narrators.setDeckNarrator, { slug, narrator: value });
1027
+ }
1028
+
1029
+ // Polls `read` until `done`; a few failed reads in a row (a network blip) are
1030
+ // retried, since the person may still be finishing in the browser.
1031
+ async function pollUntil(read, done, { intervalMs = 2000, timeoutMs }) {
1032
+ const start = Date.now();
1033
+ let misses = 0;
1034
+ for (;;) {
1035
+ let value;
1036
+ try {
1037
+ value = await read();
1038
+ misses = 0;
1039
+ } catch (e) {
1040
+ if (++misses >= 5) throw e;
1041
+ await sleep(intervalMs);
1042
+ continue;
1043
+ }
1044
+ if (done(value)) return value;
1045
+ if (Date.now() - start > timeoutMs) throw new Error("timed out waiting; run the command again");
1046
+ await sleep(intervalMs);
1047
+ }
1048
+ }
@@ -0,0 +1,28 @@
1
+ // The released deck's presenter (roadmap: avatars-pick-make-from-a-selfie-or-
2
+ // import-your-minimoji). A deck narrated in the owner's own voice is written
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.
7
+ // on a character-voice deck, or one set by hand) is left alone.
8
+
9
+ const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
10
+
11
+ const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
12
+
13
+ // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
14
+ // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
15
+ // or html unchanged when there is no such tag or it already has a presenter.
16
+ export function withAvatar(html, { avatarUrl, imageUrl }) {
17
+ const tag = PLAYER_TAG.exec(html)?.[0];
18
+ 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).
21
+ 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
+ const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
25
+ const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
26
+ const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
27
+ return html.replace(tag, () => next);
28
+ }
package/src/release.mjs CHANGED
@@ -15,6 +15,7 @@ import { readFileSync } from "node:fs";
15
15
  import { join, posix } from "node:path";
16
16
  import { zipSync } from "fflate";
17
17
  import * as ops from "./ops.mjs";
18
+ import { withAvatar } from "./presenter.mjs";
18
19
  import { target } from "./runtime.mjs";
19
20
  import { checkDeck, localRefs, resolveDeckDir } from "./check.mjs";
20
21
  import { narrate as runNarrate } from "./narrate.mjs";
@@ -47,8 +48,11 @@ export function deckCommit(deckDir) {
47
48
  }
48
49
 
49
50
  // What import uploads: the page alone, or a zip of the page and its local files.
50
- export function releaseBundle(deckDir) {
51
- const html = readFileSync(join(deckDir, "index.html"));
51
+ // `presenter` (from avatars.presenterFor) puts the owner's avatar on the
52
+ // uploaded page's player tag; the committed index.html is never changed.
53
+ export function releaseBundle(deckDir, presenter = null) {
54
+ const raw = readFileSync(join(deckDir, "index.html"));
55
+ const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
52
56
  // check has already refused refs that leave the folder or do not decode.
53
57
  const refs = [...new Set(localRefs(html.toString("utf8")).map((r) => posix.normalize(decodeURIComponent(r)).replace(/^(\.?\/)+/, "")))];
54
58
  if (!refs.length) return { bytes: html, filename: "index.html", files: ["index.html"] };
@@ -95,13 +99,22 @@ export async function release(pos, opts = {}, io = {}) {
95
99
  throw new Error(`narration changed the deck (${after.split("\n").length} file(s)):\n${after}\n` +
96
100
  `run \`hyperdecks check ${pos[0]}\`, look at it, commit (index.html and narration/), then run release again`);
97
101
  }
102
+ // Narrated in your own voice: the released page gets your avatar's face.
103
+ let presenter = null;
104
+ try {
105
+ presenter = await ops.presenterFor(opts, slug, opts.voice);
106
+ } catch (e) {
107
+ log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
108
+ }
109
+ const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
110
+ if (presenter) log("✓ presented by your avatar");
98
111
  const imported = await ops.importDeckFromBytes(opts, {
99
112
  slug,
100
113
  title: opts.title,
101
114
  status: opts.status || "live",
102
- bytes: bundle.bytes,
103
- filename: bundle.filename,
104
- htmlFile: bundle.htmlFile,
115
+ bytes: released.bytes,
116
+ filename: released.filename,
117
+ htmlFile: released.htmlFile,
105
118
  });
106
119
  const sha = commit(deckDir);
107
120
  log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
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.3.1";
11
+ export const VERSION = "0.4.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -20,6 +20,32 @@ export const PROD = {
20
20
 
21
21
  export const api = {
22
22
  previews: { get: makeFunctionReference("previews:get") },
23
+ avatars: {
24
+ listAvatars: makeFunctionReference("avatars:listAvatars"),
25
+ setDefault: makeFunctionReference("avatars:setDefault"),
26
+ removeAvatar: makeFunctionReference("avatars:removeAvatar"),
27
+ setDeckAvatar: makeFunctionReference("avatars:setDeckAvatar"),
28
+ addPicked: makeFunctionReference("avatars:addPicked"),
29
+ drawSelfie: makeFunctionReference("avatars:drawSelfie"),
30
+ getDraft: makeFunctionReference("avatars:getDraft"),
31
+ getConnect: makeFunctionReference("avatars:getConnect"),
32
+ createLink: makeFunctionReference("avatars:createLink"),
33
+ getLink: makeFunctionReference("avatars:getLink"),
34
+ presenterFor: makeFunctionReference("avatars:presenterFor"),
35
+ },
36
+ narrators: {
37
+ list: makeFunctionReference("narrators:list"),
38
+ setDefault: makeFunctionReference("narrators:setDefault"),
39
+ remove: makeFunctionReference("narrators:remove"),
40
+ setVoice: makeFunctionReference("narrators:setVoice"),
41
+ setDeckNarrator: makeFunctionReference("narrators:setDeckNarrator"),
42
+ newLink: makeFunctionReference("narrators:newLink"),
43
+ },
44
+ avatarsNode: {
45
+ options: makeFunctionReference("avatarsNode:options"),
46
+ startConnect: makeFunctionReference("avatarsNode:startConnect"),
47
+ refresh: makeFunctionReference("avatarsNode:refresh"),
48
+ },
23
49
  assets: {
24
50
  generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
25
51
  saveAsset: makeFunctionReference("assets:saveAsset"),
@@ -48,6 +74,7 @@ export const api = {
48
74
  get: makeFunctionReference("decks:get"),
49
75
  listForViewer: makeFunctionReference("decks:listForViewer"),
50
76
  remove: makeFunctionReference("decks:remove"),
77
+ setAllowForward: makeFunctionReference("decks:setAllowForward"),
51
78
  setHtmlStorage: makeFunctionReference("decks:setHtmlStorage"),
52
79
  upsert: makeFunctionReference("decks:upsert"),
53
80
  whoami: makeFunctionReference("decks:whoami"),
@@ -57,6 +84,7 @@ export const api = {
57
84
  deckAnalytics: makeFunctionReference("shares:deckAnalytics"),
58
85
  listForDeck: makeFunctionReference("shares:listForDeck"),
59
86
  remove: makeFunctionReference("shares:remove"),
87
+ setAllowForward: makeFunctionReference("shares:setAllowForward"),
60
88
  setEnabled: makeFunctionReference("shares:setEnabled"),
61
89
  setNotify: makeFunctionReference("shares:setNotify"),
62
90
  },
@@ -102,7 +130,7 @@ export const api = {
102
130
  },
103
131
  };
104
132
 
105
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open"]);
133
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji"]);
106
134
  // release's recipients, in order: each --to starts one, and an --email right
107
135
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
108
136
  // opts.email stays the last --email for the other commands (share new).
@@ -0,0 +1,15 @@
1
+ // The avatar picker's species (roadmap: avatars-pick-make-from-a-selfie-or-
2
+ // import-your-minimoji), shared by the backend (convex/lib/avatars/species.ts),
3
+ // the CLI (avatar.mjs) and the web picker. `family` is Minimoji's character
4
+ // family in GET /capabilities; humans have none (we deal their documents).
5
+ export const SPECIES = [
6
+ { id: "human", label: "Human", family: null },
7
+ { id: "robot", label: "Robot", family: "dash" },
8
+ { id: "alien", label: "Alien", family: "alien" },
9
+ { id: "cat", label: "Cat", family: "cat" },
10
+ { id: "dog", label: "Dog", family: "dog" },
11
+ { id: "rabbit", label: "Rabbit", family: "rabbit" },
12
+ { id: "monkey", label: "Monkey", family: "monkey" },
13
+ { id: "penguin", label: "Penguin", family: "bird" },
14
+ { id: "ghost", label: "Ghost", family: "ghost" },
15
+ ];