@hypersocial/decks-cli 0.9.11 → 0.9.12

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/README.md CHANGED
@@ -259,6 +259,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
259
259
 
260
260
  Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
261
261
 
262
+ To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
263
+
262
264
  `--json` prints one line on stdout, identical to `<out>/manifest.json`:
263
265
 
264
266
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -150,6 +150,8 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
150
150
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
151
151
  | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
152
152
  | Slides or scroll, viewer toggle, pace, AI-readable version | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default] [--ai-readable on\|off]` | `set_deck_view` |
153
+ | Give a deck to another account (only when asked; confirm the email) | `hyperdecks deck transfer <slug> --to <email>` (`deck transfers cancel <slug>` takes it back) | `transfer_deck` (`cancel: true`) |
154
+ | Transfers waiting on you, and accepting one (ask who narrates and, for each guest that needsPick, a voice and avatar of theirs) | `hyperdecks deck transfers`, then `deck transfers accept <id> --narrator <name\|dash\|none\|default> [--guest <n>=<voice>/<avatar>]` (or `decline <id>`) | `list_transfers`, then `accept_transfer` (`decline: true`) |
153
155
  | Present or check your own deck (owner link) | `hyperdecks deck link <slug>` (`deck open` opens it) | `ownerUrl` from `list_decks` or `check_deck` |
154
156
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
155
157
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
@@ -1012,6 +1014,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
1012
1014
 
1013
1015
  Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
1014
1016
 
1017
+ To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
1018
+
1015
1019
  `--json` prints one line on stdout, identical to `<out>/manifest.json`:
1016
1020
 
1017
1021
  ```json
@@ -128,6 +128,19 @@ makes the hello deck; continue from step 5 below.
128
128
  worker it answers with the `hyperdecks export <slug> --video` command
129
129
  instead; give that to the user to run.
130
130
 
131
+ ## Giving a deck to another account
132
+
133
+ Like moving a GitHub repo. Only when the person asks: confirm the
134
+ recipient's email, then `transfer_deck { slug, to }`. They get an email and
135
+ have 7 days; nothing moves until they accept, and `transfer_deck { slug,
136
+ cancel: true }` takes it back. On the other side, `list_transfers` shows what
137
+ waits. Before `accept_transfer`, ask who narrates it for them when
138
+ `needsNarrator` (`list_narrators`: theirs, dash, none or default), and a
139
+ voice and avatar of theirs for each guest with `needsPick`; the sender's
140
+ voice and face never move. Accepting moves the deck with its share links
141
+ (links already sent keep working), recipients and stats, and starts narrating
142
+ their picks; `release_deck` puts them on the links.
143
+
131
144
  ## Rules that matter
132
145
 
133
146
  - Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
@@ -0,0 +1,89 @@
1
+ // Pack management never changes avatar IDs or the user's default presenter.
2
+ // Publication is a distinct command; there is no public flag on create/import.
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { client } from "./runtime.mjs";
5
+
6
+ const ref = (name) => makeFunctionReference(`avatarPacks:${name}`);
7
+ const PACK_USAGE = "usage: avatar pack list | create <name> [--private-only] [--description text] | get <pack-id> | add <pack-id> <avatar-id> | grant <pack-id> --account <id>|--email <email>|--team <id> | grants <pack-id> | revoke <grant-id> | publish <pack-id> | unpublish <pack-id> | invitations | claim | catalog";
8
+ const TEAM_USAGE = "usage: avatar team list | create <name> | members <team-id> | add <team-id> --account <id>|--email <email> | remove <membership-id>";
9
+ const text = (value, label) => {
10
+ if (typeof value !== "string" || !value.trim() || value.startsWith("--")) throw new Error(`${label} requires a value`);
11
+ return value.trim();
12
+ };
13
+ function exactly(pos, count, usage) {
14
+ if (pos.length !== count || pos.some(value => !value)) throw new Error(usage);
15
+ return pos;
16
+ }
17
+ function recipient(opts, allowTeam) {
18
+ const names = allowTeam ? ["account", "email", "team"] : ["account", "email"];
19
+ const selected = names.filter(name => Object.hasOwn(opts, name));
20
+ if (selected.length !== 1 || (!allowTeam && Object.hasOwn(opts, "team"))) throw new Error(`choose exactly one of ${names.map(name => `--${name}`).join(", ")}`);
21
+ const key = selected[0], value = text(opts[key], `--${key}`);
22
+ return { [{ account: "accountId", email: "email", team: "teamId" }[key]]: value };
23
+ }
24
+ function output(result, opts, log) {
25
+ if (opts.json) log(JSON.stringify(result, null, 2));
26
+ else if (Array.isArray(result)) {
27
+ if (!result.length) log("(none)");
28
+ for (const item of result) log(`${item.id ?? item._id}\t${item.name ?? item.granteeId ?? item.memberId ?? item.email ?? item.teamId ?? ""}${item.visibility ? `\t${item.visibility}` : ""}${item.access ? ` (${item.access})` : ""}${item.privateOnly ? " · permanently private-only" : ""}${item.status ? `\t${item.status}` : ""}`);
29
+ } else log(JSON.stringify(result, null, 2));
30
+ return result;
31
+ }
32
+
33
+ export async function avatarPacksCommand(pos = [], opts = {}, { log = console.log } = {}) {
34
+ const [action = "list", ...args] = pos;
35
+ let method = "query", apiName, payload;
36
+ switch (action) {
37
+ case "list": exactly(args, 0, PACK_USAGE); apiName = "list"; payload = {}; break;
38
+ case "create":
39
+ if (!args.length) throw new Error(PACK_USAGE);
40
+ if (Object.hasOwn(opts, "public") || Object.hasOwn(opts, "publish")) throw new Error("packs are created private; use the separate publish command for an eligible pack");
41
+ apiName = "create"; method = "mutation";
42
+ payload = { name: text(args.join(" "), "pack name"), privateOnly: opts["private-only"] === true };
43
+ if (Object.hasOwn(opts, "description")) payload.description = text(opts.description, "--description");
44
+ break;
45
+ case "get": case "grants":
46
+ exactly(args, 1, PACK_USAGE); apiName = action; payload = { packId: args[0] }; break;
47
+ case "add":
48
+ exactly(args, 2, PACK_USAGE); apiName = "addAvatar"; method = "mutation";
49
+ payload = { packId: args[0], avatarId: args[1] }; break;
50
+ case "grant":
51
+ exactly(args, 1, PACK_USAGE); apiName = "grant"; method = "mutation";
52
+ payload = { packId: args[0], ...recipient(opts, true) }; break;
53
+ case "revoke":
54
+ exactly(args, 1, PACK_USAGE); apiName = "revoke"; method = "mutation"; payload = { grantId: args[0] }; break;
55
+ case "publish": case "unpublish":
56
+ exactly(args, 1, PACK_USAGE); apiName = action; method = "mutation"; payload = { packId: args[0] }; break;
57
+ case "invitations": case "claim":
58
+ exactly(args, 0, PACK_USAGE); apiName = action === "claim" ? "claimInvitations" : "invitations";
59
+ method = action === "claim" ? "mutation" : "query"; payload = {}; break;
60
+ case "catalog":
61
+ exactly(args, 0, PACK_USAGE); apiName = "publicCatalog";
62
+ payload = { paginationOpts: { numItems: 32, cursor: Object.hasOwn(opts, "cursor") ? text(opts.cursor, "--cursor") : null } }; break;
63
+ default: throw new Error(PACK_USAGE);
64
+ }
65
+ const result = await (await client(opts))[method](ref(apiName), payload);
66
+ if (action === "get" && !result) throw new Error("pack not found or unavailable to your account");
67
+ if (!opts.json && action === "create") log(result.privateOnly ? "Created privately. The private-only lock is permanent; this pack cannot be published." : "Created privately. Only an explicit publish command makes this pack public.");
68
+ return output(result, opts, log);
69
+ }
70
+
71
+ export async function avatarTeamsCommand(pos = [], opts = {}, { log = console.log } = {}) {
72
+ const [action = "list", ...args] = pos;
73
+ let method = "query", apiName, payload;
74
+ switch (action) {
75
+ case "list": exactly(args, 0, TEAM_USAGE); apiName = "teams"; payload = {}; break;
76
+ case "create":
77
+ if (!args.length) throw new Error(TEAM_USAGE);
78
+ apiName = "createTeam"; method = "mutation"; payload = { name: text(args.join(" "), "team name") }; break;
79
+ case "members":
80
+ exactly(args, 1, TEAM_USAGE); apiName = "teamMembers"; payload = { teamId: args[0] }; break;
81
+ case "add":
82
+ exactly(args, 1, TEAM_USAGE); apiName = "addTeamMember"; method = "mutation";
83
+ payload = { teamId: args[0], ...recipient(opts, false) }; break;
84
+ case "remove":
85
+ exactly(args, 1, TEAM_USAGE); apiName = "removeTeamMember"; method = "mutation"; payload = { membershipId: args[0] }; break;
86
+ default: throw new Error(TEAM_USAGE);
87
+ }
88
+ return output(await (await client(opts))[method](ref(apiName), payload), opts, log);
89
+ }
@@ -0,0 +1,34 @@
1
+ // Ephemeral HTML transform for play/capture/export. Never write its output to
2
+ // source push, release source, disk caches, or logs: it contains a view grant.
3
+ import { makeFunctionReference } from "convex/server";
4
+ const ISSUE_OWNER_VIEW = makeFunctionReference("avatarAccess:issueOwnerView");
5
+ const VIEW_META = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))[^>]*>/gi;
6
+ const PRIVATE_REFS = /\/avatar\/private\/|\bdata-hd-avatar\b/i;
7
+
8
+ export async function withAvatarView(client, slug, html, scope = "draft") {
9
+ if (typeof html !== "string") throw new Error("avatar preview requires HTML text");
10
+ if (scope !== "draft" && scope !== "released") throw new Error("avatar preview scope must be draft or released");
11
+ const hadView = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))/i.test(html);
12
+ const needsView = hadView || PRIVATE_REFS.test(html);
13
+ // Always request a session: guest URLs may live only in the narration
14
+ // manifest. Offline ordinary playback remains usable without a grant;
15
+ // protected endpoints still reject private models without authorization.
16
+ const clean = html.replace(VIEW_META, "");
17
+ if (!needsView && !/\bdata-(?:manifest|hd-speaker)\b/i.test(html)) return clean;
18
+ let grant;
19
+ try {
20
+ if (typeof slug !== "string" || !slug.trim()) throw new Error("a deck slug is required");
21
+ grant = await client.mutation(ISSUE_OWNER_VIEW, { slug, scope });
22
+ } catch (error) {
23
+ if (needsView) throw new Error("Cannot authorize private avatars for this preview. Sign in as the deck owner, verify pack access, and push the current deck source before retrying.", { cause: error });
24
+ return clean;
25
+ }
26
+ if (!grant) {
27
+ if (needsView) throw new Error("No current private-avatar view grant. Push the deck source and script so their avatar IDs are registered, then retry.");
28
+ return clean;
29
+ }
30
+ if (!/^[a-f0-9]{64}$/.test(grant.token) || !Number.isFinite(grant.expiresAt) || grant.expiresAt <= Date.now()) throw new Error("The server returned an invalid or expired avatar view grant");
31
+ const meta = `<meta name="hd-avatar-view" content="${grant.token}">`;
32
+ // Put the grant before any player script; the HTML string stays in memory.
33
+ return /<head\b[^>]*>/i.test(clean) ? clean.replace(/<head\b[^>]*>/i, match => `${match}\n${meta}`) : `${meta}\n${clean}`;
34
+ }
package/src/commands.mjs CHANGED
@@ -173,6 +173,65 @@ async function deckForward(pos, opts) {
173
173
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
174
174
  }
175
175
 
176
+ const TRANSFER_USAGE = "usage: deck transfer <slug> --to <email>";
177
+ async function deckTransfer(pos, opts) {
178
+ const [slug] = pos;
179
+ if (!slug || !opts.to) throw new Error(TRANSFER_USAGE);
180
+ const out = await ops.transferDeck(opts, slug, opts.to);
181
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
182
+ console.log(`OK ${out.existing ? "already offered" : "offered"} ${slug} to ${out.to}, until ${out.expiresAt.slice(0, 10)}`);
183
+ console.log(" Nothing moves until they accept (signed in to Hyperdecks with that email). Cancel: deck transfers cancel " + slug);
184
+ }
185
+
186
+ const TRANSFERS_USAGE = "usage: deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate] | decline <id> | cancel <slug>]";
187
+ // "1=Ana/Ana Face,2=stock:liam" → [{ guest: 1, voice: "Ana", avatar: "Ana Face" }, …]
188
+ export function parseGuestPicks(spec) {
189
+ if (!spec) return [];
190
+ return String(spec).split(",").map((part) => {
191
+ const m = /^\s*(\d+)\s*=\s*([^/]+?)\s*(?:\/\s*(.+?)\s*)?$/.exec(part);
192
+ if (!m) throw new Error(`--guest ${part}: write <n>=<voice> or <n>=<voice>/<avatar>`);
193
+ return { guest: Number(m[1]), voice: m[2], ...(m[3] ? { avatar: m[3] } : {}) };
194
+ });
195
+ }
196
+
197
+ async function deckTransfers(pos, opts) {
198
+ const [action, arg] = pos;
199
+ if (action === "accept") {
200
+ if (!arg) throw new Error(TRANSFERS_USAGE);
201
+ const out = await ops.acceptTransfer(opts, arg, { narrator: opts.narrator, guests: parseGuestPicks(opts.guest), renarrate: !opts["no-narrate"] });
202
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
203
+ console.log(`OK ${out.slug} is yours, with its share links and stats; links already sent keep working`);
204
+ if (out.narration?.jobId) console.log(` narrating your picks: job ${out.narration.jobId} (narrate status), then release ${out.slug}`);
205
+ else if (out.narration?.error) console.log(` ⚠ narration did not start: ${out.narration.error}\n ${out.narration.note}`);
206
+ else if (out.narration?.note) console.log(` ${out.narration.note}`);
207
+ return;
208
+ }
209
+ if (action === "decline") {
210
+ if (!arg) throw new Error(TRANSFERS_USAGE);
211
+ const out = await ops.declineTransfer(opts, arg);
212
+ return void console.log(opts.json ? JSON.stringify(out) : `OK declined ${out.slug}`);
213
+ }
214
+ if (action === "cancel") {
215
+ if (!arg) throw new Error(TRANSFERS_USAGE);
216
+ const out = await ops.cancelTransfer(opts, arg);
217
+ return void console.log(opts.json ? JSON.stringify(out) : `OK cancelled the transfer of ${out.slug} to ${out.to}`);
218
+ }
219
+ if (action) throw new Error(TRANSFERS_USAGE);
220
+ const out = await ops.listTransfers(opts);
221
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
222
+ if (out.emailUnverified) console.log("⚠ your account's email is not verified, so transfers sent to it can't be shown or accepted");
223
+ if (!out.incoming.length && !out.outgoing.length) return void console.log("No transfers waiting.");
224
+ for (const t of out.incoming) {
225
+ console.log(`← ${t.slug} "${t.title}" from ${t.from ?? "someone"} (until ${t.expiresAt.slice(0, 10)}) id ${t.id}`);
226
+ if (t.needsNarrator) console.log(" narrated in the sender's narrator or voice: pick yours with --narrator <name|dash|none|default>");
227
+ for (const g of t.guests) {
228
+ console.log(` guest ${g.guest} ${g.name} (${g.selector}): voice ${g.voice}${g.avatar ? `, avatar ${g.avatar}` : ""}${g.needsPick ? " → pick with --guest " + g.guest + "=<voice>/<avatar>" : ""}`);
229
+ }
230
+ console.log(` accept: deck transfers accept ${t.id}${t.needsNarrator ? " --narrator <…>" : ""}${t.guests.filter((g) => g.needsPick).map((g) => ` --guest ${g.guest}=<voice>/<avatar>`).join("")}`);
231
+ }
232
+ for (const t of out.outgoing) console.log(`→ ${t.slug} "${t.title}" to ${t.to} (until ${t.expiresAt.slice(0, 10)}); cancel: deck transfers cancel ${t.slug}`);
233
+ }
234
+
176
235
  const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
177
236
  const VIEWS = { slides: "slides", scroll: "scroll", default: null };
178
237
  const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
@@ -363,12 +422,20 @@ commands:
363
422
  login [--token <pat>] [--broker <url>] [--dev]
364
423
  logout [--dev]
365
424
  whoami [--json] [--dev]
366
- deck list|stats|open|link|rm|forward|view
425
+ deck list|stats|open|link|rm|forward|view|transfer|transfers
367
426
  deck link <slug> [--json]
368
427
  the owner link: present or check your own deck (only you, signed in; untracked;
369
428
  Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
370
429
  deck forward on|off <slug>
371
430
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
431
+ deck transfer <slug> --to <email>
432
+ give the deck to another Hyperdecks account (like a GitHub repo transfer). Nothing moves until
433
+ they accept, within 7 days; then its share links, recipients and stats are theirs, not yours.
434
+ deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate]
435
+ | decline <id> | cancel <slug>]
436
+ transfers waiting on you and the ones you sent. Accepting picks your narrator and a voice and
437
+ avatar for each guest in the sender's own voice or face, then narrates those lines as you;
438
+ the deck plays as released until you release it.
372
439
  deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
373
440
  how the deck opens in the player (default: as its HTML says, data-view or slides) and
374
441
  whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
@@ -471,6 +538,9 @@ commands:
471
538
 
472
539
  preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
473
540
  preview create --file <index.html> --out <directory> [--duration 4] [--width 600] [--delay 1000] [--label "Watch"] [--aspect-ratio 16:9]
541
+ preview create <slug> --out <directory> --scene <slide-id> [--start <seconds>] [--share <token> --upload]
542
+ the email GIF of one slide, from <seconds> into its narration (default 0): the guest
543
+ speaking then, its caption bubble and every cue fired by then; needs a player/v1 deck
474
544
  preview setup install isolated Chromium (Linux: see README for system dependencies)
475
545
 
476
546
  narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
@@ -483,6 +553,9 @@ commands:
483
553
  voice rm <name|id> delete a voice, also at the provider
484
554
  voice hear <voice> | voice hear --narrator <name|id>
485
555
  a short sample of the voice: its clip URL and the line it says
556
+ avatar pack [list|create|get|add|grant|grants|revoke|publish|unpublish|invitations|claim|catalog]
557
+ owner-managed packs; create <name> --private-only permanently prevents publication
558
+ avatar team [list|create|members|add|remove] owner-managed account groups for pack grants
486
559
  avatar [--json] your avatars (who presents decks in your voice) and the default
487
560
  avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
488
561
  avatar from-photo [file] [--save-to-minimoji]
@@ -805,7 +878,8 @@ function commandTable(pos, rest, opts) {
805
878
  "preview create": async () => {
806
879
  const { createPreview } = await import("./preview.mjs");
807
880
  const result = await createPreview(rest, opts);
808
- console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
881
+ const shows = result.moment ? `Shows ${result.moment.scene} at ${result.moment.start}s${result.moment.guest ? `: ${result.moment.guest.caption}` : ""}\n` : "";
882
+ console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${shows}${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
809
883
  },
810
884
  init: async () => {
811
885
  const { init } = await import("./init.mjs");
@@ -913,6 +987,8 @@ function commandTable(pos, rest, opts) {
913
987
  const out = await avatarList(opts);
914
988
  if (opts.json) console.log(JSON.stringify(out, null, 2));
915
989
  },
990
+ "avatar pack": async () => (await import("./avatar-packs.mjs")).avatarPacksCommand(rest, opts),
991
+ "avatar team": async () => (await import("./avatar-packs.mjs")).avatarTeamsCommand(rest, opts),
916
992
  "avatar new": async () => {
917
993
  const { avatarNew } = await import("./avatar.mjs");
918
994
  const { askLine } = await import("./init.mjs");
@@ -1019,6 +1095,8 @@ function commandTable(pos, rest, opts) {
1019
1095
  "deck rm": () => deckRemove(rest, opts),
1020
1096
  "deck forward": () => deckForward(rest, opts),
1021
1097
  "deck view": () => deckView(rest, opts),
1098
+ "deck transfer": () => deckTransfer(rest, opts),
1099
+ "deck transfers": () => deckTransfers(rest, opts),
1022
1100
  "share new": () => shareNew(rest, opts),
1023
1101
  "share list": () => shareList(rest, opts),
1024
1102
  "share on": () => shareToggle(rest, opts, true),
@@ -5,7 +5,8 @@
5
5
  // runs its own runtime (a legacy copy-and-swap deck.js, a plain HTML export)
6
6
  // gets none of them.
7
7
 
8
- export const loadsPlayer = (html) => /\/player\/v1\/player\.js\b/.test(String(html ?? ""));
8
+ // A copy of a page our routes served names a pinned build (/player/v1/b/<hash>/player.js).
9
+ export const loadsPlayer = (html) => /\/player\/v1\/(?:b\/[0-9a-f]+\/)?player\.js\b/.test(String(html ?? ""));
9
10
 
10
11
  const SCRIPT_SRC = /<script\b[^>]*\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>/gi;
11
12
  const MAX_SCRIPT_NAME = 120;
@@ -424,7 +424,8 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
424
424
  // A development checkout plays its own player/v1 (HYPERDECKS_PLAYER_DIR), not the released one.
425
425
  if (config.playerDir) {
426
426
  await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
427
- const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\//, '');
427
+ // A pinned build (/player/v1/b/<hash>/, lib/deckPlayer.ts) is the checkout's files too.
428
+ const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\/(b\/[^/]+\/)?/, '');
428
429
  if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
429
430
  const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
430
431
  try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
@@ -768,7 +769,11 @@ export async function exportVideo(arg, opts = {}, { log = (l) => process.stderr.
768
769
  } else {
769
770
  const deck = await ops.releasedDeck(opts, arg);
770
771
  const { serveSource } = await import('./preview-capture.mjs');
771
- const server = await serveSource({ html: deck.html });
772
+ // The worker imports recordVideo with already-scoped HTML and no CLI auth dependencies.
773
+ const { withAvatarView } = await import("./avatar-view.mjs");
774
+ const { client: avatarClient } = await import("./runtime.mjs");
775
+ const html = await withAvatarView({ mutation: async (ref, args) => (await avatarClient(opts)).mutation(ref, args) }, arg, deck.html, "released");
776
+ const server = await serveSource({ html });
772
777
  source = { slug: arg, title: deck.title, url: server.url, close: server.close, presenter: null };
773
778
  }
774
779
  try {
package/src/ops.mjs CHANGED
@@ -1359,6 +1359,112 @@ export async function setDeckNarrator(opts, slug, ref) {
1359
1359
  return (await client(opts)).mutation(api.narrators.setDeckNarrator, { slug, narrator: value });
1360
1360
  }
1361
1361
 
1362
+ // ---- deck transfers (convex/deckTransfers.ts) ----------------------------------
1363
+
1364
+ // The tool descriptions, shared by the stdio and remote MCP servers.
1365
+ export const TRANSFER_DECK_DESCRIPTION =
1366
+ "Give a deck to another Hyperdecks account, like transferring a GitHub repo: they get an email and accept within 7 days " +
1367
+ "(list_transfers, accept_transfer); until then nothing changes. Accepting moves the deck with its share links (links already sent " +
1368
+ "keep working), recipients and stats; you keep nothing. Only when the person asked for it, and confirm the email first. cancel: true takes the offer back.";
1369
+ export const LIST_TRANSFERS_DESCRIPTION =
1370
+ "Deck transfers waiting on the user (incoming: id, deck, sender, whether they must pick a narrator, and guests, numbered, with needsPick " +
1371
+ "when a guest speaks in the sender's own voice or face) and the ones they sent (outgoing).";
1372
+ export const ACCEPT_TRANSFER_DESCRIPTION =
1373
+ "Accept (or decline: true) a deck transfer from list_transfers. Ask the person first: who narrates it for them (list_narrators: one of " +
1374
+ "theirs, dash, none or default; required when needsNarrator), and a voice (list_voices) and avatar (list_avatars) for each guest with " +
1375
+ "needsPick. The sender's voice and face never move. Then their picks are narrated as them (narration.jobId; poll check_narration_job); " +
1376
+ "the deck keeps playing as released until they release_deck.";
1377
+
1378
+ // Offer a deck to the Hyperdecks account signed in with `to`. Nothing moves
1379
+ // until they accept; the offer lasts 7 days.
1380
+ export async function transferDeck(opts, slug, to) {
1381
+ if (!slug || !to) throw new Error("usage: deck transfer <slug> --to <email>");
1382
+ const out = await (await client(opts)).mutation(api.deckTransfers.create, { slug, to: String(to) });
1383
+ return { ...out, expiresAt: new Date(out.expiresAt).toISOString() };
1384
+ }
1385
+
1386
+ export async function cancelTransfer(opts, slug) {
1387
+ if (!slug) throw new Error("usage: deck transfers cancel <slug>");
1388
+ return (await client(opts)).mutation(api.deckTransfers.cancel, { slug });
1389
+ }
1390
+
1391
+ // Transfers waiting on you (incoming, numbered guests to pick for) and the
1392
+ // ones you sent (outgoing). A guest needs a pick when it speaks in the
1393
+ // sender's own voice or face (needsPick).
1394
+ export async function listTransfers(opts = {}) {
1395
+ const out = await (await client(opts)).query(api.deckTransfers.list, {});
1396
+ const when = (ms) => new Date(ms).toISOString();
1397
+ return {
1398
+ incoming: out.incoming.map((t) => ({
1399
+ id: t.id,
1400
+ slug: t.slug,
1401
+ title: t.title,
1402
+ from: t.from,
1403
+ expiresAt: when(t.expiresAt),
1404
+ needsNarrator: t.needsNarrator,
1405
+ guests: t.guests.map((g, i) => ({
1406
+ guest: i + 1,
1407
+ name: g.name,
1408
+ selector: g.selector,
1409
+ voice: g.voice ?? "(the sender's own voice)",
1410
+ avatar: g.personalAvatar ? "(the sender's own avatar)" : g.avatar,
1411
+ needsPick: g.personalVoice || g.personalAvatar,
1412
+ })),
1413
+ })),
1414
+ emailUnverified: !!out.emailUnverified,
1415
+ outgoing: out.outgoing.map((t) => ({ id: t.id, slug: t.slug, title: t.title, to: t.to, expiresAt: when(t.expiresAt) })),
1416
+ };
1417
+ }
1418
+
1419
+ // Accept an incoming transfer. narrator: one of yours (name or id), dash, none
1420
+ // or default; required when the deck narrates in the sender's narrator or
1421
+ // voice. guests: [{ guest, voice, avatar? }], guest numbers from
1422
+ // listTransfers, one for each guest that needsPick. Then, unless
1423
+ // renarrate is false, narrates the rewritten draft as you (only the
1424
+ // re-picked lines render; the rest is cached). The live deck plays as
1425
+ // released until you release it.
1426
+ export async function acceptTransfer(opts, id, picks = {}) {
1427
+ const { narrator, guests, renarrate = true } = picks;
1428
+ if (!id) throw new Error("usage: deck transfers accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]");
1429
+ const c = await client(opts);
1430
+ // Guest numbers are positions in list's guests; only then is the list needed.
1431
+ let listed = null;
1432
+ if (guests?.length) {
1433
+ const all = await c.query(api.deckTransfers.list, {});
1434
+ listed = all.incoming.find((t) => t.id === id);
1435
+ if (!listed) throw new Error(`no transfer ${id} is waiting for you${all.emailUnverified ? " (your account's email is not verified)" : ""} (see deck transfers)`);
1436
+ }
1437
+ const speakers = (guests ?? []).map((p) => {
1438
+ const g = listed.guests[Number(p.guest) - 1];
1439
+ if (!g) throw new Error(`no guest ${p.guest} in ${listed.slug} (guests: 1..${listed.guests.length})`);
1440
+ if (!p.voice) throw new Error(`guest ${p.guest}: name a voice`);
1441
+ return { key: g.key, voice: String(p.voice), ...(p.avatar ? { avatar: String(p.avatar) } : {}) };
1442
+ });
1443
+ const narratorArg = narrator === undefined || narrator === null || narrator === "" ? undefined
1444
+ : ["none", "default", "dash"].includes(String(narrator).toLowerCase()) ? String(narrator).toLowerCase()
1445
+ : (await findNarrator(opts, narrator)).id;
1446
+ const out = await c.mutation(api.deckTransfers.accept, { id, ...(narratorArg !== undefined ? { narrator: narratorArg } : {}), speakers });
1447
+ const result = { ...out, narration: null };
1448
+ if (out.renarrate && renarrate) {
1449
+ try {
1450
+ const job = await narrateDeck(opts, { slug: out.slug, fromSource: true });
1451
+ result.narration = { jobId: job.jobId, note: "narrating the re-picked lines; then release the deck to put them on its links" };
1452
+ } catch (e) {
1453
+ result.narration = { error: errorText(e), note: "the deck is yours; narrate it again when this is fixed, then release" };
1454
+ }
1455
+ } else if (out.renarrate) {
1456
+ result.narration = { note: "narrate the deck, then release it, to put your voice and face on its links" };
1457
+ } else if (!out.hosted) {
1458
+ result.narration = { note: "this deck has no hosted script, so nothing was re-narrated" };
1459
+ }
1460
+ return result;
1461
+ }
1462
+
1463
+ export async function declineTransfer(opts, id) {
1464
+ if (!id) throw new Error("usage: deck transfers decline <id>");
1465
+ return (await client(opts)).mutation(api.deckTransfers.decline, { id });
1466
+ }
1467
+
1362
1468
  // The account's brand (convex/brand.ts): what new_deck / deck new start from.
1363
1469
  export async function getBrand(opts = {}) {
1364
1470
  return (await client(opts)).query(api.brand.get, {});
package/src/play.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { withAvatarView } from "./avatar-view.mjs";
1
2
  // hyperdecks play <slug|deck-dir> [--voice v] [--slug s] [--port n] [--no-open] [--json]
2
3
  //
3
4
  // Plays the working folder as it is, committed or not, the way it will play once
@@ -14,7 +15,7 @@ import { resolveDeckDir } from "./check.mjs";
14
15
  import * as ops from "./ops.mjs";
15
16
  import { playsCaptionsOnly, presenterLine, withNarrator } from "./presenter.mjs";
16
17
  import { serveSource } from "./preview-capture.mjs";
17
- import { openBrowser } from "./runtime.mjs";
18
+ import { openBrowser, client } from "./runtime.mjs";
18
19
 
19
20
  const USAGE = "usage: play <slug|deck-dir> [--voice <name|id>] [--slug <slug>] [--port <n>] [--no-open]";
20
21
 
@@ -88,7 +89,8 @@ export async function startPlay(pos, opts = {}, { log = (line) => console.log(li
88
89
  const html = readFileSync(file, "utf8");
89
90
 
90
91
  const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
91
- const page = withNarrator(html, presenter, who);
92
+ let page = withNarrator(html, presenter, who);
93
+ page = await withAvatarView({ mutation: async (ref, args) => (await client(opts)).mutation(ref, args) }, slug, page, "draft");
92
94
  sayWhoPresents({ html, page, presenter, who, log });
93
95
  // --voice names the voice it was narrated in, so there is nothing to compare it to.
94
96
  if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
@@ -255,11 +255,36 @@ async function encode(ffmpeg, args) {
255
255
  try { await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-nostdin', ...args], { timeout: 90000, maxBuffer: 1024 * 1024, env: { PATH: dirname(ffmpeg), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } }); }
256
256
  catch (e) { throw new Error(`FFmpeg encoding failed (${e.killed ? '90s timeout' : 'check portable encoder installation'}); no global FFmpeg is used`); }
257
257
  }
258
+ // The numbered frames in `dir` (frame-0000.png…) as the preview's PNG poster
259
+ // (the first frame), looping GIF and silent MP4 in `out`.
260
+ async function encodeOutputs(ffmpeg, { dir, out, width, height, fps, duration }) {
261
+ const input = ['-framerate', String(fps), '-i', join(dir, 'frame-%04d.png')];
262
+ const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(out, `preview.${t}`)]));
263
+ await encode(ffmpeg, ['-i', join(dir,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
264
+ await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
265
+ await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
266
+ return Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
267
+ }
268
+ // Even output dimensions for `width` at `aspect`, as the encoders need them.
269
+ const outputSize = (width, aspect) => { const w = width % 2 ? width + 1 : width; return { width: w, height: Math.round(w / aspect / 2) * 2 }; };
270
+
271
+ async function captureMoment(config, chromium, ffmpeg) {
272
+ const { recordMoment, withTempDir } = await import('./preview-moment.mjs');
273
+ const fps = 10, frames = Math.round(config.duration * fps);
274
+ return withTempDir(async dir => {
275
+ const recorded = await recordMoment({ ...config, frames, fps, playerDir: config.playerDir ?? process.env.HYPERDECKS_PLAYER_DIR ?? null }, { chromium, serveSource, dir });
276
+ const files = await encodeOutputs(ffmpeg, { dir, out: config.out, ...outputSize(config.width, recorded.aspect), fps, duration: frames / fps });
277
+ return { files, warnings: recorded.warnings, moment: recorded.moment };
278
+ });
279
+ }
280
+
258
281
  export async function capturePreview(config) {
259
282
  const { chromium } = await import('playwright');
260
283
  let ffmpeg;
261
284
  try { ffmpeg = (await import('@ffmpeg-installer/ffmpeg')).default.path; await run(ffmpeg, ['-version'], { timeout:10000 }); }
262
285
  catch { throw new Error('Portable FFmpeg missing. Reinstall the CLI with optional dependencies and install scripts enabled (`npm install -g hyperdecks --include=optional`); pnpm users must approve @ffmpeg-installer platform build scripts.'); }
286
+ // --scene / --start: one moment of the narration, on the page clock (preview-moment.mjs).
287
+ if (config.scene != null || config.start != null) return captureMoment(config, chromium, ffmpeg);
263
288
  let browser, server, temp, deadline, timedOut = false, phase = 'launch';
264
289
  const warnings = [];
265
290
  try {
@@ -309,8 +334,7 @@ export async function capturePreview(config) {
309
334
  else if (busy > 1) warnings.push(`The deck kept its main thread busy for ${busy.toFixed(1)}s; script-driven motion may look choppy.`);
310
335
  if (!config.aspect && !detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
311
336
  const aspect = config.aspect ?? (detected >= 0.5 && detected <= 3 ? detected : 16/9);
312
- const width = config.width % 2 ? config.width + 1 : config.width;
313
- const height = Math.round(width / aspect / 2) * 2;
337
+ const { width, height } = outputSize(config.width, aspect);
314
338
  await page.setViewportSize({ width:1280, height:Math.round(1280/aspect) });
315
339
  const fps = 10, frames = Math.round(config.duration * fps), duration = frames/fps;
316
340
  // Screencast captures the live WebGL compositor and CSS motion without
@@ -339,14 +363,9 @@ export async function capturePreview(config) {
339
363
  await context.close();
340
364
  clearTimeout(deadline);
341
365
  phase = 'encode';
342
- const input = ['-framerate', String(fps), '-i', join(temp, 'frame-%04d.png')];
343
- const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(config.out, `preview.${t}`)]));
344
- await encode(ffmpeg, ['-i', join(temp,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
345
- await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
346
- await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
366
+ const files = await encodeOutputs(ffmpeg, { dir: temp, out: config.out, width, height, fps, duration });
347
367
  if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
348
368
  if (failed) warnings.push(`${failed} page/resource error(s); inspect the poster and increase --delay if necessary.`);
349
- const files = await Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
350
369
  return { files, warnings };
351
370
  } catch (e) { throw new Error(`Capture failed during ${phase}: ${timedOut ? '60s capture timeout; the renderer did not respond' : /Timeout/i.test(e.name) ? 'bounded capture timeout; inspect the deck or increase --delay' : e.message}`); }
352
371
  finally {
@@ -0,0 +1,243 @@
1
+ // `preview create --scene <id> --start <seconds>`: the email GIF of one moment
2
+ // of a narrated deck, not its opening.
3
+ //
4
+ // The ordinary preview (preview-capture.mjs capturePreview) opens the deck as
5
+ // a recipient would and screencasts it in real time; its narration never
6
+ // starts, so it can only show the opening. This one records the way the video
7
+ // export does (export-video.mjs): the player's export mode (?export=1: no
8
+ // chrome, plays without a tap, narration on a silent clock media element) on
9
+ // a page clock the recorder steps (frame-clock.mjs). The deck opens on the
10
+ // chosen slide (window.__hdExportFrom, export-mode.js), the clock is stepped
11
+ // `start` seconds into its narration, so every cue, guest line, caption
12
+ // bubble and mouth shape that would have happened by then has happened, and
13
+ // then each GIF frame is the clock advanced 1/fps and a screenshot taken.
14
+ // Nothing plays aloud; no view is counted (the capture request rules refuse
15
+ // /v/ and telemetry, and export mode sends no engagement events).
16
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { extname, join } from 'node:path';
19
+ import { installFrameClock } from './frame-clock.mjs';
20
+ import { exportPageUrl, launchRecorder, planTimeline } from './export-video.mjs';
21
+ import { recorderArgs, routeCaptureRequest } from './preview-capture.mjs';
22
+
23
+ // Page-clock milliseconds per step while seeking to `start`: timers fire in
24
+ // order within a step, so a coarser step only means fewer animation frames
25
+ // drawn on the way. The GIF's own frames are 1/fps apart.
26
+ const SEEK_STEP_MS = 100;
27
+ // Real milliseconds the deck may take to become ready (the head loads in it).
28
+ const READY_MS = 60000;
29
+ // Real milliseconds a slide's heads may take to finish loading once on it.
30
+ const HEADS_MS = 15000;
31
+
32
+ // Where to start: the slide (`scene`, the first slide when omitted) and the
33
+ // seconds into its narration, checked against the player's own plan
34
+ // ([{ id, duration, hold }], the manifest's clip length plus its hold).
35
+ export function seekTarget(plan, { scene, start = 0 } = {}) {
36
+ const timeline = planTimeline(plan ?? []);
37
+ if (!timeline.ids.length) throw new Error('the deck has no slides');
38
+ const index = scene == null ? 0 : timeline.ids.indexOf(scene);
39
+ if (index < 0) throw new Error(`Unknown scene "${scene}". This deck's slides: ${timeline.ids.join(', ')}`);
40
+ const id = timeline.ids[index], seconds = timeline.seconds[index], entry = plan[index];
41
+ // A clip whose length the plan doesn't know (a data-clip deck's): its start can't be bounded here.
42
+ const known = !entry.audio || entry.duration > 0;
43
+ if (known && start > 0 && !(start < seconds)) {
44
+ throw new Error(seconds > 0
45
+ ? `--start ${start} is past the end of slide ${id}: it runs ${seconds.toFixed(1)}s (${entry.audio ? 'its narration plus its hold' : 'its hold; it has no narration'}), so pick 0 to ${Math.max(0, seconds - 0.1).toFixed(1)}`
46
+ : `--start ${start}: slide ${id} has no narration to start into; use --start 0`);
47
+ }
48
+ return { index, id, start, seconds, narrated: !!entry.audio };
49
+ }
50
+
51
+ const within = async (promise, ms, fallback) => {
52
+ promise.catch(() => {});
53
+ let timer;
54
+ try { return await Promise.race([promise, new Promise((ok) => { timer = setTimeout(() => ok(fallback), ms); })]); }
55
+ finally { clearTimeout(timer); }
56
+ };
57
+
58
+ // What the first frame shows, read from the page: the slide the player is on,
59
+ // the guest speaking and its caption bubble's words (null when none), and the
60
+ // state of each character head on the slide (head-slots.js data-hd-head-state).
61
+ function readMoment() {
62
+ const log = window.__hdExport;
63
+ const slide = log?.slides?.at(-1)?.id ?? null;
64
+ const bubble = document.querySelector('.hd-guest-bubble:not([hidden])');
65
+ const heads = [...(document.getElementById(slide)?.querySelectorAll('[data-hd-head-state]') ?? [])].map((e) => e.dataset.hdHeadState);
66
+ return {
67
+ slide,
68
+ // Every slide the player showed, in order: the scene alone when it opened there.
69
+ visited: (log?.slides ?? []).map((s) => s.id),
70
+ guest: bubble ? { speaker: bubble.dataset.speaker ?? null, caption: bubble.textContent.trim() } : null,
71
+ caption: document.querySelector('.hd-caption')?.textContent.trim() || null,
72
+ heads,
73
+ };
74
+ }
75
+
76
+ // Records `frames` PNGs (frame-0000.png…) into `dir` and returns
77
+ // { aspect, moment, warnings }. config: { html | file, scene, start, frames,
78
+ // fps, delay, label, aspect, firstPartyOrigins, playerDir }.
79
+ export async function recordMoment(config, { chromium, serveSource, dir }) {
80
+ const warnings = [];
81
+ let browser, server, phase = 'launch';
82
+ try {
83
+ browser = await launchRecorder(chromium, {
84
+ headless: true, timeout: 30000, chromiumSandbox: config.sandbox ?? process.platform === 'linux',
85
+ args: ['--autoplay-policy=no-user-gesture-required', '--mute-audio', ...recorderArgs()],
86
+ env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: dir, HOME: dir },
87
+ });
88
+ server = await serveSource(config);
89
+ const pageUrl = exportPageUrl(server.url);
90
+ const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1, serviceWorkers: 'block', acceptDownloads: false });
91
+ let blocked = 0;
92
+ await context.route('**/*', async (route) => {
93
+ if (!await routeCaptureRequest(route, pageUrl, config.firstPartyOrigins)) blocked++;
94
+ });
95
+ // A development checkout (or a test) plays its own player/v1, as video export does.
96
+ const missingPlayer = [];
97
+ if (config.playerDir) {
98
+ await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
99
+ const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\/(b\/[^/]+\/)?/, '');
100
+ if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
101
+ const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
102
+ try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
103
+ catch { missingPlayer.push(name); await route.abort(); }
104
+ });
105
+ }
106
+ await context.routeWebSocket('**/*', (ws) => ws.close());
107
+ await context.addInitScript({ content: `(${installFrameClock.toString()})(window);` });
108
+ // The player opens on __hdExportFrom, read once its slides are in the page:
109
+ // the scene's index among them, as the player lists them (section.slide in order).
110
+ // An unknown scene opens the first slide; seekTarget then refuses it by name.
111
+ await context.addInitScript((scene) => {
112
+ navigator.sendBeacon = () => false;
113
+ window.open = () => null;
114
+ window.__hdExportWait = new Promise((go) => { window.__hdExportGo = go; });
115
+ if (scene == null) return;
116
+ Object.defineProperty(window, '__hdExportFrom', { configurable: true, get: () => Math.max(0, [...document.querySelectorAll('section.slide')].map((s) => s.id).indexOf(scene)) });
117
+ }, config.scene ?? null);
118
+ const page = await context.newPage();
119
+ page.setDefaultTimeout(30000);
120
+ const pageErrors = [];
121
+ page.on('pageerror', (e) => pageErrors.push(String(e?.message ?? e)));
122
+ // Every call into the page is bounded: a deck that holds its main thread fails, it doesn't hang the CLI.
123
+ const ask = async (fn, arg, what) => {
124
+ const none = Symbol('timeout');
125
+ const answer = await within(page.evaluate(fn, arg), 30000, none);
126
+ if (answer === none) throw new Error(`the page stopped answering ${what} (over 30s)`);
127
+ return answer;
128
+ };
129
+ const step = (ms, what) => ask(async (ms) => { window.__hdFrameClock.step(ms); await window.__hdFrameClock.settle(); return window.__hdExport; }, ms, what);
130
+ phase = 'page load';
131
+ await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
132
+ phase = 'player ready';
133
+ await page.waitForSelector('body[data-hd-ready="true"]', { timeout: READY_MS }).catch(() => {
134
+ const missing = missingPlayer.length ? ` (missing from the local player: ${missingPlayer.slice(0, 3).join(', ')})` : '';
135
+ throw new Error(`the deck was not ready after ${READY_MS / 1000}s${missing}; --scene and --start need a deck on player/v1 (https://hyperdecks.ai/player/v1/player.js)`);
136
+ });
137
+ const state = await ask(() => (window.__hdExport ? JSON.parse(JSON.stringify(window.__hdExport)) : null), undefined, 'for its export log');
138
+ if (!state) throw new Error('--scene and --start need a deck on player/v1 (https://hyperdecks.ai/player/v1/player.js); this deck has its own runtime');
139
+ if (state.manifestError) throw new Error(`the deck's narration manifest could not load (${state.manifestError.url}: ${state.manifestError.message})`);
140
+ if (!(state.version >= 3 && state.clock === 'page' && state.range === true)) throw new Error('the deck\'s player is too old to open on a slide; reload https://hyperdecks.ai/player/v1/player.js, or update the deck\'s copy');
141
+ phase = 'scene check';
142
+ const target = seekTarget(state.plan, { scene: config.scene, start: config.start ?? 0 });
143
+ if (state.silent && target.start > 0) throw new Error('--start needs a narrated deck: this one has no narration to start into (run `hyperdecks narrate`)');
144
+ const duration = config.frames / config.fps;
145
+ if (!state.silent && target.start + duration > target.seconds + 0.05) warnings.push(`slide ${target.id} ends ${Math.max(0, target.seconds - target.start).toFixed(1)}s after --start; the rest of the ${duration}s preview shows the deck moving on`);
146
+ const face = state.presenter;
147
+ if (face?.wanted === 'head' && face.shown !== 'head') warnings.push(`the deck's live head did not load${face.why ? ` (${face.why})` : ''}; the preview shows ${face.shown === 'static' ? 'its still image' : 'captions only'}`);
148
+
149
+ phase = 'layout probe';
150
+ // The deck's own shape, as the ordinary preview reads it (preview-capture.mjs), unless --aspect-ratio names one.
151
+ let aspect = config.aspect;
152
+ if (!aspect) {
153
+ const detected = await ask(() => {
154
+ const meta = document.querySelector('meta[name="preview-aspect-ratio"]')?.getAttribute('content');
155
+ if (meta && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(meta)) { const [w, h] = meta.split(':').map(Number); return w / h; }
156
+ const rect = document.querySelector('section.slide')?.getBoundingClientRect();
157
+ return rect && rect.width > 100 && rect.height > 100 ? rect.width / rect.height : null;
158
+ }, undefined, 'to the layout probe');
159
+ aspect = detected >= 0.5 && detected <= 3 ? detected : 16 / 9;
160
+ if (aspect !== detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
161
+ }
162
+ if (Math.abs(aspect - 16 / 9) > 0.001) {
163
+ await page.setViewportSize({ width: 1280, height: Math.round(1280 / aspect) });
164
+ await page.waitForTimeout(200); // the player refits on resize; the clock still runs in real time
165
+ }
166
+
167
+ // The slide's heads load before the tour starts: once it runs, the player gives late guest heads
168
+ // a budget on the page clock (player.js holdForGuestHeads), which the seek spends in moments.
169
+ // Polled from here: in-page waits run on timers and rAF, which the frozen clock holds.
170
+ const headsSettled = async () => {
171
+ for (const until = Date.now() + HEADS_MS; Date.now() < until; await new Promise((ok) => setTimeout(ok, 250))) {
172
+ const loading = await ask((id) => [...(document.getElementById(id)?.querySelectorAll('[data-hd-head-state="loading"]') ?? [])].length, target.id, 'while heads loaded');
173
+ if (!loading) return true;
174
+ }
175
+ return false;
176
+ };
177
+ phase = 'heads';
178
+ await headsSettled();
179
+
180
+ phase = 'seek';
181
+ // Clip lengths come from the manifest (player.js clipLength), as a recipient's <audio> would roughly end them.
182
+ await ask(async () => {
183
+ window.__hdFrameClock.freeze();
184
+ window.__hdExportGo();
185
+ await window.__hdFrameClock.settle();
186
+ }, undefined, 'as the tour started');
187
+ const failure = (l) => l.errors?.length && `narration failed on ${l.errors[0].id} (${l.errors[0].message})`;
188
+ // The start counts from when the slide's own clip plays (a clip that loads late starts late), or,
189
+ // for a slide with no narration of its own, from when it lands.
190
+ const arrived = (l) => l.silent || (target.narrated ? l.clips.some((c) => c.id === target.id && !c.audio) : l.slides.some((s) => s.id === target.id));
191
+ let log = await ask(() => window.__hdExport, undefined, 'for its export log');
192
+ for (let n = 0; !arrived(log); n++) {
193
+ if (failure(log)) throw new Error(failure(log));
194
+ if (n === 600) throw new Error(`slide ${target.id}'s narration did not start within 60s of the deck's time`);
195
+ log = await step(SEEK_STEP_MS, 'while the tour started');
196
+ }
197
+ for (let n = Math.round((target.start * 1000) / SEEK_STEP_MS); n > 0; n--) log = await step(SEEK_STEP_MS, 'while seeking');
198
+ if (failure(log)) throw new Error(failure(log));
199
+
200
+ phase = 'heads';
201
+ // The clock is frozen, so waiting changes nothing on the slide: it lets heads and images finish loading.
202
+ const headsLoaded = await headsSettled();
203
+ if (!headsLoaded) warnings.push(`a character head on ${target.id} was still loading after ${HEADS_MS / 1000}s; it shows as a still or silhouette`);
204
+ phase = 'delay';
205
+ await page.waitForTimeout(config.delay);
206
+ await step(0, 'after the delay');
207
+
208
+ phase = 'label overlay';
209
+ await ask((label) => {
210
+ const badge = document.createElement('div');
211
+ badge.textContent = '▶ ' + label;
212
+ badge.setAttribute('style', 'all:initial;position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.85);color:white;font:600 25px sans-serif;padding:14px 24px;border-radius:40px;z-index:2147483647;white-space:nowrap;max-width:90%;overflow:hidden;text-overflow:ellipsis;pointer-events:none');
213
+ document.documentElement.appendChild(badge);
214
+ }, config.label, 'to the label overlay');
215
+ const moment = { scene: target.id, start: target.start, ...(await ask(readMoment, undefined, 'to the moment check')) };
216
+ if (moment.slide !== target.id) throw new Error(`the player showed ${moment.slide ?? 'no slide'}, not ${target.id}`);
217
+ const unready = moment.heads.filter((s) => s !== 'ready').length;
218
+ if (headsLoaded && unready) warnings.push(`${unready} character head(s) on ${target.id} are not live (${[...new Set(moment.heads.filter((s) => s !== 'ready'))].join(', ')}); they show as stills or silhouettes`);
219
+
220
+ phase = 'frames';
221
+ const session = await context.newCDPSession(page);
222
+ for (let n = 0; n < config.frames; n++) {
223
+ if (n) log = await step(1000 / config.fps, 'while recording (a frame took over 30s to draw)');
224
+ const shot = await within(session.send('Page.captureScreenshot', { format: 'png' }), 30000, null);
225
+ if (!shot?.data) throw new Error('a frame took over 30s to capture');
226
+ await writeFile(join(dir, `frame-${String(n).padStart(4, '0')}.png`), Buffer.from(shot.data, 'base64'));
227
+ }
228
+ if (failure(log)) warnings.push(`${failure(log)} while recording; the preview may stop moving there`);
229
+ if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
230
+ if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s): ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
231
+ return { aspect, moment, warnings };
232
+ } catch (e) {
233
+ throw new Error(`Capture failed during ${phase}: ${e.message}`);
234
+ } finally {
235
+ if (browser) await browser.close().catch(() => {});
236
+ if (server) await server.close();
237
+ }
238
+ }
239
+
240
+ export async function withTempDir(fn) {
241
+ const dir = await mkdtemp(join(tmpdir(), 'hyperdecks-preview-'));
242
+ try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); }
243
+ }
package/src/preview.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { withAvatarView } from "./avatar-view.mjs";
1
2
  import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
2
3
  import { resolve, extname } from "node:path";
3
4
  import { randomUUID } from "node:crypto";
@@ -15,7 +16,7 @@ function bounded(value, fallback, min, max, name, integer = false) {
15
16
  return n;
16
17
  }
17
18
  export function previewOptions(pos, opts) {
18
- const allowed = new Set(['file', 'out', 'duration', 'width', 'delay', 'label', 'aspect-ratio', 'share', 'upload', 'json', 'prod', 'dev', 'admin', 'broker', 'convex', 'token', 'client']);
19
+ const allowed = new Set(['file', 'out', 'duration', 'width', 'delay', 'label', 'aspect-ratio', 'share', 'upload', 'scene', 'start', 'json', 'prod', 'dev', 'admin', 'broker', 'convex', 'token', 'client']);
19
20
  for (const k of Object.keys(opts)) if (!allowed.has(k)) throw new Error(`Unknown preview option --${k}`);
20
21
  for (const [k, v] of Object.entries(opts)) if (v === undefined) throw new Error(`--${k} requires a value`);
21
22
  if (pos.length > 1 || (opts.file ? pos.length !== 0 : pos.length !== 1)) throw new Error("usage: preview create <slug> --out <new-directory> OR preview create --file <index.html> --out <new-directory>");
@@ -33,7 +34,13 @@ export function previewOptions(pos, opts) {
33
34
  aspect = m ? Number(m[1]) / Number(m[2]) : NaN;
34
35
  bounded(aspect, null, 0.5, 3, '--aspect-ratio');
35
36
  }
36
- return { slug: pos[0], file: opts.file && resolve(opts.file), out: resolve(opts.out), duration: bounded(opts.duration, 4, 1, 10, '--duration'), width: bounded(opts.width, 600, 240, 1200, '--width', true), delay: bounded(opts.delay, 1000, 0, 10000, '--delay', true), aspect, label };
37
+ // --scene <slide-id> / --start <seconds>: a moment of the narration, not the opening (preview-moment.mjs).
38
+ // The id is checked against the deck's slides, and the start against that slide's length, once it loads.
39
+ if (opts.scene !== undefined && (typeof opts.scene !== 'string' || !opts.scene.trim() || opts.scene.length > 200 || /[\x00-\x1f]/.test(opts.scene))) throw new Error('--scene must be a slide id (the id of its <section class="slide">)');
40
+ const moment = opts.scene !== undefined || opts.start !== undefined
41
+ ? { scene: opts.scene ?? null, start: bounded(opts.start, 0, 0, 3600, '--start (seconds into the slide\'s narration)') }
42
+ : {};
43
+ return { slug: pos[0], file: opts.file && resolve(opts.file), out: resolve(opts.out), duration: bounded(opts.duration, 4, 1, 10, '--duration'), width: bounded(opts.width, 600, 240, 1200, '--width', true), delay: bounded(opts.delay, 1000, 0, 10000, '--delay', true), aspect, label, ...moment };
37
44
  }
38
45
  export function validateShare(rows, slug, token, now = Date.now()) {
39
46
  const row = rows.find(s => s.token === token && s.deckSlug === slug);
@@ -67,6 +74,7 @@ export async function createPreview(pos, opts, adapters = {}) {
67
74
  html = await res.text();
68
75
  }
69
76
  if (!html) throw new Error('Deck has no HTML to capture');
77
+ html = await withAvatarView(c, config.slug, html, 'released');
70
78
  }
71
79
  // A new directory prevents replacing artifacts referenced by an earlier manifest.
72
80
  await mkdir(config.out, { recursive: false });
@@ -78,6 +86,8 @@ export async function createPreview(pos, opts, adapters = {}) {
78
86
  const capture = adapters.capture || (await import('./preview-capture.mjs')).capturePreview;
79
87
  const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://hyperdecks.ai', 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
80
88
  result.files = captured.files;
89
+ // What the first frame shows (--scene/--start): the slide, the speaking guest and its caption.
90
+ if (captured.moment) result.moment = captured.moment;
81
91
  warnings.push(...captured.warnings);
82
92
  for (const f of result.files) if (f.bytes > 5 * 1024 * 1024) warnings.push(`${f.type} exceeds 5 MiB; shorten --duration or reduce --width for email`);
83
93
  result.status = 'exported';
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.11";
11
+ export const VERSION = "0.9.12";
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
@@ -180,6 +180,13 @@ export const api = {
180
180
  getJob: makeFunctionReference("narration:getJob"),
181
181
  listVoices: makeFunctionReference("narration:listVoices"),
182
182
  },
183
+ deckTransfers: {
184
+ create: makeFunctionReference("deckTransfers:create"),
185
+ cancel: makeFunctionReference("deckTransfers:cancel"),
186
+ list: makeFunctionReference("deckTransfers:list"),
187
+ accept: makeFunctionReference("deckTransfers:accept"),
188
+ decline: makeFunctionReference("deckTransfers:decline"),
189
+ },
183
190
  voices: {
184
191
  listVoices: makeFunctionReference("voices:listVoices"),
185
192
  getVoice: makeFunctionReference("voices:getVoice"),
@@ -194,7 +201,7 @@ export const api = {
194
201
  },
195
202
  };
196
203
 
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"]);
204
+ const BOOL = new Set(["private-only", "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-narrate", "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"]);
198
205
  // release's recipients, in order: each --to starts one, and an --email right
199
206
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
200
207
  // opts.email stays the last --email for the other commands (share new).
@@ -223,7 +230,7 @@ export function parse(args) {
223
230
  else {
224
231
  const v = args[++i];
225
232
  if (k === "to" || (k === "email" && opts.recipients)) addRecipient(opts, k, v);
226
- if (k === "agent" && opts[k]) opts[k] = `${opts[k]},${v}`;
233
+ if ((k === "agent" || k === "guest") && opts[k]) opts[k] = `${opts[k]},${v}`;
227
234
  else opts[k] = v;
228
235
  }
229
236
  } else {