@hypersocial/decks-cli 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/guide.mjs CHANGED
@@ -29,7 +29,7 @@ export function skillSection(skill, topic) {
29
29
  function layoutsTopic() {
30
30
  const rows = LAYOUTS.map((name) => {
31
31
  const { meta } = readLayout(name);
32
- return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
32
+ return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.nopresenter && `Without a presenter: ${meta.nopresenter}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
33
33
  });
34
34
  return `# Layouts\n\nPass any of these, in order, as new_deck's \`layouts\` (repeat one for more of the same: number, number gives slides number and number-2).\n\n${rows.join("\n\n")}`;
35
35
  }
package/src/init.mjs CHANGED
@@ -17,7 +17,7 @@ import { createInterface } from "node:readline/promises";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import * as ops from "./ops.mjs";
19
19
  import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
20
- import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
20
+ import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, linkBase, login as runLogin, openBrowser, target } from "./runtime.mjs";
21
21
  import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
22
22
  import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
23
23
  import { narrate as runNarrate } from "./narrate.mjs";
@@ -450,9 +450,13 @@ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars,
450
450
  }
451
451
  }
452
452
 
453
+ // decks:get answers only for decks the caller owns, so whether the slug is
454
+ // taken at all comes from slugTaken.
453
455
  async function deckOwnedBy(base, slug, who) {
454
- const deck = await (await client(base)).query(api.decks.get, { slug });
455
- return deck ? { exists: true, mine: deck.ownerId === who.subject } : { exists: false, mine: false };
456
+ const c = await client(base);
457
+ if (!(await c.query(api.decks.slugTaken, { slug }))) return { exists: false, mine: false };
458
+ const deck = await c.query(api.decks.get, { slug });
459
+ return { exists: true, mine: !!deck && deck.ownerId === who.subject };
456
460
  }
457
461
 
458
462
  async function scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save }) {
@@ -544,7 +548,7 @@ async function releaseStep(base, opts, state, { release, sub, save, fetch }) {
544
548
  // A run killed after release made its link but before init.json recorded it.
545
549
  const mine = !state.releasedAt && shares.find((s) => s.qa && s.recipient === "Me" && s.enabled);
546
550
  if (mine) {
547
- const url = `${target(base).shareBase}/v/${mine.token}`;
551
+ const url = `${await linkBase(base)}/v/${mine.token}`;
548
552
  save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: mine.token });
549
553
  return step("release", "done", `released: ${url}`);
550
554
  }
package/src/layouts.mjs CHANGED
@@ -20,7 +20,7 @@ export function fillTemplate(file, { slug, title, presenterStyle }) {
20
20
  }
21
21
 
22
22
  // One layout file: a leading comment of `key: value` lines (layout, summary,
23
- // use, slot, presenter, cues, script), its <style> and its <section>.
23
+ // use, slot, presenter, nopresenter, cues, script), its <style> and its <section>.
24
24
  export function readLayout(name) {
25
25
  const text = packageText(`templates/layouts/${name}.html`);
26
26
  const head = /^<!--([\s\S]*?)-->/.exec(text);
@@ -0,0 +1,72 @@
1
+ // The pixels behind mediaPreviews.mjs: ffmpeg (the @ffmpeg-installer build the
2
+ // CLI's video export and Convex's videoStack already use) reads one image or a
3
+ // video's first frame and writes
4
+ // - a ~20 px wide JPEG as a data URL (the blur preview, well under 1 KB), and
5
+ // - for a video, a poster: its first frame as a JPEG at most 1280 px wide.
6
+ // Used by the CLI release (local files) and convex/mediaPreviewsNode.ts (fetched
7
+ // bytes written to a temp file). The size comes from ffmpeg's own stream line.
8
+ import { execFile } from "node:child_process";
9
+ import { streamSize } from "./mediaPreviews.mjs";
10
+
11
+ export const PREVIEW_WIDTH = 20;
12
+ const TIMEOUT_MS = 30_000;
13
+
14
+ function run(ffmpeg, args) {
15
+ return new Promise((resolve, reject) => {
16
+ execFile(ffmpeg, args, { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: TIMEOUT_MS }, (err, stdout, stderr) => {
17
+ const text = stderr.toString("utf8");
18
+ if (err) {
19
+ const why = err.killed ? `ffmpeg timed out after ${TIMEOUT_MS / 1000}s` : text.trim().split("\n").slice(-2).join(" ") || err.message;
20
+ return reject(new Error(why.slice(0, 300)));
21
+ }
22
+ resolve({ stdout, stderr: text });
23
+ });
24
+ });
25
+ }
26
+
27
+ // Only local files by default: a "video" that is really a playlist (HLS) would
28
+ // otherwise make ffmpeg fetch its segment URLs itself, past any SSRF guard.
29
+ // Nested demuxers inherit the whitelist. The CLI widens it for an https input.
30
+ export const LOCAL_ONLY = "file,pipe";
31
+ export const HTTPS_TOO = "file,pipe,https,tls,tcp,crypto";
32
+ // A picture with more pixels than this (a "pixel bomb": a small file that
33
+ // decodes to gigabytes) is refused by the decoder instead of exhausting memory.
34
+ export const MAX_PIXELS = 64 * 1024 * 1024;
35
+ const frameArgs = (input, allow, vf, q, codec = "mjpeg") => ["-hide_banner", "-nostdin", "-protocol_whitelist", allow, "-max_pixels", String(MAX_PIXELS), "-i", input, "-map", "0:v:0", "-frames:v", "1", "-vf", vf, ...(codec === "mjpeg" ? ["-q:v", String(q)] : []), "-f", "image2pipe", "-c:v", codec, "pipe:1"];
36
+ // A picture with an alpha channel (a background-removed cutout) keeps it: a
37
+ // JPEG preview would put a black box behind the cutout while it loads.
38
+ export const hasAlpha = (stderr) => /Stream #[^\n]*?Video:[^\n]*?\b(rgba|bgra|argb|abgr|ya8|ya16\w*|yuva\w*|gbrap\w*|rgba64\w*|pal8)\b/.test(stderr ?? "");
39
+
40
+ // { w, h, blur, poster? } for one file (a path, or a URL ffmpeg can open).
41
+ // `poster` (video only) is the JPEG bytes; the caller stores it and swaps in its URL.
42
+ // A picture or video container by its first bytes: JPEG, PNG, GIF, WebP, ISO
43
+ // BMFF (MP4/MOV/AVIF/HEIC) or EBML (WebM). Anything else (an HLS playlist, an
44
+ // HTML error page) never reaches ffmpeg.
45
+ export function knownMedia(bytes) {
46
+ const b = bytes.subarray(0, 12), ascii = (from, to) => String.fromCharCode(...b.subarray(from, to));
47
+ return (b[0] === 0xff && b[1] === 0xd8) || ascii(1, 4) === "PNG" || ascii(0, 3) === "GIF"
48
+ || (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") || ascii(4, 8) === "ftyp"
49
+ || (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3);
50
+ }
51
+
52
+ export async function makePreview(ffmpeg, input, { video = false, allow = LOCAL_ONLY } = {}) {
53
+ let small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=yuvj420p`, 12));
54
+ const size = streamSize(small.stderr);
55
+ if (!size) throw new Error(`no picture in ${input}`);
56
+ let type = "image/jpeg";
57
+ if (!video && hasAlpha(small.stderr)) {
58
+ small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=rgba`, 0, "png"));
59
+ type = "image/png";
60
+ }
61
+ if (!small.stdout.length) throw new Error(`ffmpeg wrote no preview for ${input}`);
62
+ const out = { ...size, blur: `data:${type};base64,${small.stdout.toString("base64")}` };
63
+ if (!video) return out;
64
+ const frame = await run(ffmpeg, frameArgs(input, allow, "scale='min(1280,iw)':-2,format=yuvj420p", 4));
65
+ if (!frame.stdout.length) throw new Error(`ffmpeg wrote no poster for ${input}`);
66
+ return { ...out, poster: frame.stdout };
67
+ }
68
+
69
+ // The bundled ffmpeg's path, loaded only when release needs it.
70
+ export async function bundledFfmpeg() {
71
+ return (await import("@ffmpeg-installer/ffmpeg")).default.path;
72
+ }
@@ -0,0 +1,160 @@
1
+ // Blur previews for a deck's media, added at release (CLI release.mjs and the
2
+ // hosted release in convex/deckSources.ts). Pure string work, no Node APIs, so
3
+ // the Convex mutation can run it; the pixels come from mediaPreviewMake.mjs.
4
+ //
5
+ // mediaRefs(html) lists what needs a preview: each <img>, each <video> without
6
+ // a poster (its first frame becomes one) and each inline background image.
7
+ // withMediaPreviews(html, previews) writes them onto the tags:
8
+ // data-hd-blur="data:image/jpeg;base64,…" a ~20 px wide preview (< 1 KB)
9
+ // style="--hd-ar:W/H;…" so the box has its shape before the pixels arrive
10
+ // (player.css: :where([data-hd-blur]) { aspect-ratio: var(--hd-ar) })
11
+ // poster="…" a <video> that had none
12
+ // The player (packages/player/media-load.js) shows the preview, blurred and
13
+ // shimmering, until the real media is ready. Idempotent: a tag that already has
14
+ // data-hd-blur (or opts out with data-hd-no-blur) is left as it is, so a
15
+ // re-release changes nothing.
16
+
17
+ // Media this small is an icon: a blurred icon is noise, not a preview.
18
+ export const MIN_PREVIEW_SIDE = 64;
19
+ // The longest data URL written onto a tag; a bigger preview is dropped.
20
+ export const MAX_BLUR_CHARS = 1600;
21
+
22
+ const SKIP = /^(data:|blob:|javascript:|#)/i;
23
+ const VECTOR = /\.svg(?:[?#]|$)/i;
24
+
25
+ export function tagAttr(tag, name) {
26
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i").exec(tag);
27
+ if (!m) return null;
28
+ return (m[1] ?? m[2] ?? m[3]).replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
29
+ }
30
+ const hasAttr = (tag, name) => new RegExp(`\\s${name}(?:\\s*=|[\\s/>])`, "i").test(tag);
31
+
32
+ function usable(src) {
33
+ const s = (src ?? "").trim();
34
+ return s && !SKIP.test(s) && !VECTOR.test(s) ? s : null;
35
+ }
36
+ // A tag the author sized as an icon (both sides given and small) is skipped up front.
37
+ function iconSized(tag) {
38
+ const w = Number(tagAttr(tag, "width")), h = Number(tagAttr(tag, "height"));
39
+ return w > 0 && h > 0 && Math.max(w, h) < MIN_PREVIEW_SIDE;
40
+ }
41
+ const done = (tag) => hasAttr(tag, "data-hd-blur") || hasAttr(tag, "data-hd-no-blur");
42
+
43
+ // Tags are matched quote-aware, so a `>` inside an attribute value (alt="a > b") does not end the tag.
44
+ const ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
45
+ const IMG = new RegExp(`<img\\b${ATTRS}>`, "gi");
46
+ const VIDEO_OPEN = new RegExp(`^<video\\b${ATTRS}>`, "i");
47
+ const VIDEO = new RegExp(`<video\\b${ATTRS}>([\\s\\S]*?)<\\/video\\s*>`, "gi");
48
+ const STYLED = new RegExp(`<[a-z][\\w-]*\\b(?:[^>"']|"[^"]*"|'[^']*')*?\\sstyle\\s*=\\s*(?:"[^"]*"|'[^']*')${ATTRS}>`, "gi");
49
+ // Script, style, comment and template text is code, not markup: never read or rewritten.
50
+ const OPAQUE = /<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>|<!--[\s\S]*?-->|<template\b[\s\S]*?<\/template\s*>/gi;
51
+ function markupOnly(html, rewrite) {
52
+ const kept = [];
53
+ const masked = html.replace(OPAQUE, (m) => `\u0000${kept.push(m) - 1}\u0000`);
54
+ return rewrite(masked).replace(/\u0000(\d+)\u0000/g, (_, i) => kept[Number(i)]);
55
+ }
56
+ const BG_URL = /background(?:-image)?\s*:[^;]*?url\(\s*(?:"([^"]+)"|'([^']+)'|([^)\s]+))\s*\)/i;
57
+
58
+ function videoSrc(open, body) {
59
+ return usable(tagAttr(open, "src")) ?? usable([...body.matchAll(/<source\b[^>]*>/gi)].map((m) => tagAttr(m[0], "src")).find(usable));
60
+ }
61
+ function bgSrc(tag) {
62
+ const m = BG_URL.exec(tagAttr(tag, "style") ?? "");
63
+ return m ? usable(m[1] ?? m[2] ?? m[3]) : null;
64
+ }
65
+
66
+ // What release must make previews for: { images: [src], videos: [src] }, each
67
+ // src once, as written in the page (relative or absolute).
68
+ export function mediaRefs(page) {
69
+ const images = new Set(), videos = new Set();
70
+ const html = page.replace(OPAQUE, "");
71
+ for (const [tag] of html.matchAll(IMG)) {
72
+ const src = usable(tagAttr(tag, "src"));
73
+ if (src && !done(tag) && !iconSized(tag)) images.add(src);
74
+ }
75
+ for (const m of html.matchAll(VIDEO)) {
76
+ const open = VIDEO_OPEN.exec(m[0])[0];
77
+ // A transparent clip (alpha-video.js swaps it for a canvas) has no frame to blur.
78
+ if (done(open) || hasAttr(open, "data-alpha-stacked")) continue;
79
+ const src = videoSrc(open, m[1]);
80
+ if (src) videos.add(src);
81
+ }
82
+ for (const [tag] of html.matchAll(STYLED)) {
83
+ if (/^<(img|video)\b/i.test(tag) || done(tag)) continue;
84
+ const src = bgSrc(tag);
85
+ if (src) images.add(src);
86
+ }
87
+ return { images: [...images], videos: [...videos] };
88
+ }
89
+
90
+ // A preview is usable when it is a small data URL and its media is not an icon.
91
+ export function usablePreview(p) {
92
+ return !!p && typeof p.blur === "string" && p.blur.startsWith("data:image/") && p.blur.length <= MAX_BLUR_CHARS && !/["'<>\s]/.test(p.blur)
93
+ && Number.isFinite(p.w) && Number.isFinite(p.h) && Math.min(p.w, p.h) > 0 && Math.max(p.w, p.h) >= MIN_PREVIEW_SIDE;
94
+ }
95
+
96
+ const escapeAttr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
97
+
98
+ // Add attributes before the tag's closing `>` (or `/>`). The shape goes in as
99
+ // --hd-ar, which player.css applies with zero specificity, so any aspect-ratio
100
+ // the deck sets (in a stylesheet or the tag) still wins.
101
+ function annotate(tag, p, { ratio = true, poster = null } = {}) {
102
+ let out = tag;
103
+ if (ratio) {
104
+ const rule = `--hd-ar:${Math.round(p.w)}/${Math.round(p.h)};`;
105
+ const m = /\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(out);
106
+ if (m) {
107
+ const q = m[1] !== undefined ? '"' : "'";
108
+ out = out.replace(m[0], ` style=${q}${rule}${m[1] ?? m[2]}${q}`);
109
+ } else out = out.replace(/\s*\/?>$/, (end) => ` style="${rule}"${end}`);
110
+ }
111
+ const extra = `${poster ? ` poster="${escapeAttr(poster)}"` : ""} data-hd-blur="${p.blur}"`;
112
+ return out.replace(/\s*(\/?)>$/, (_, slash) => `${extra}${slash ? " /" : ""}>`);
113
+ }
114
+
115
+ // previews: { [src]: { w, h, blur, poster? } } keyed by the src as mediaRefs
116
+ // returned it. Media without a usable preview is left as written. Returns the
117
+ // new page and how many tags it changed.
118
+ export function withMediaPreviews(page, previews) {
119
+ const get = (src) => (src && usablePreview(previews[src]) ? previews[src] : null);
120
+ let count = 0;
121
+ const html = markupOnly(page, (html) => {
122
+ let out = html.replace(IMG, (tag) => {
123
+ const p = done(tag) || iconSized(tag) ? null : get(usable(tagAttr(tag, "src")));
124
+ if (!p) return tag;
125
+ count++;
126
+ // An author who sized both sides chose the shape already.
127
+ return annotate(tag, p, { ratio: !(hasAttr(tag, "width") && hasAttr(tag, "height")) });
128
+ });
129
+ out = out.replace(VIDEO, (whole, body) => {
130
+ const open = VIDEO_OPEN.exec(whole)[0];
131
+ if (done(open) || hasAttr(open, "data-alpha-stacked")) return whole;
132
+ const p = get(videoSrc(open, body));
133
+ if (!p) return whole;
134
+ count++;
135
+ const poster = hasAttr(open, "poster") ? null : p.poster ?? null;
136
+ return annotate(open, p, { ratio: !(hasAttr(open, "width") && hasAttr(open, "height")), poster }) + whole.slice(open.length);
137
+ });
138
+ out = out.replace(STYLED, (tag) => {
139
+ if (/^<(img|video)\b/i.test(tag) || done(tag)) return tag;
140
+ const p = get(bgSrc(tag));
141
+ if (!p) return tag;
142
+ count++;
143
+ // A background's box is the element's own layout, never the image's shape.
144
+ return annotate(tag, p, { ratio: false });
145
+ });
146
+ return out;
147
+ });
148
+ return { html, count };
149
+ }
150
+
151
+ // The size ffmpeg reports for the first video stream ("Stream #0:0: Video: …, 1920x1080").
152
+ export function streamSize(stderr) {
153
+ const m = /Stream #[^\n]*?Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/.exec(stderr ?? "");
154
+ if (!m) return null;
155
+ let w = Number(m[1]), h = Number(m[2]);
156
+ // A phone video stored sideways carries a rotation: its shown shape is swapped.
157
+ const rot = /rotate\s*:\s*-?(90|270)\b|rotation of -?(90|270)/.exec(stderr ?? "");
158
+ if (rot) [w, h] = [h, w];
159
+ return { w, h };
160
+ }
package/src/ops.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer.mjs";
2
2
  import { zipSync } from "fflate";
3
+ import { SPECIES } from "./species.mjs";
3
4
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
4
5
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
5
- import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
+ import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
7
  import {
7
8
  bytesFromText,
8
9
  isTextAsset,
@@ -19,8 +20,9 @@ export async function whoami(opts = {}) {
19
20
  return (await client(opts)).query(api.decks.whoami, {});
20
21
  }
21
22
 
22
- export async function listDecks(opts = {}) {
23
- const rows = await (await client(opts)).query(api.decks.listForViewer, {});
23
+ // Your own decks. `all: true` lists every deck, for admins only (support tools).
24
+ export async function listDecks(opts = {}, { all = false } = {}) {
25
+ const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
24
26
  return rows.map((d) => ({
25
27
  slug: d.slug,
26
28
  title: d.title,
@@ -30,6 +32,10 @@ export async function listDecks(opts = {}) {
30
32
  // How it opens (null: as its HTML says) and whether viewers may switch.
31
33
  view: d.view ?? null,
32
34
  viewToggle: d.viewToggle !== false,
35
+ // How the player moves on: auto, slide or step (null: as its HTML says, else auto).
36
+ pace: d.pace ?? null,
37
+ // Whether each link's AI-readable version (/v/<token>/md) serves (on unless set off).
38
+ aiReadable: d.aiReadable !== false,
33
39
  // false: the deck runs its own player, so view/viewToggle do not apply
34
40
  // (null: not known yet, treated as on).
35
41
  usesPlayer: d.usesPlayer ?? null,
@@ -50,7 +56,11 @@ export async function activityOverview(opts = {}) {
50
56
  // looked up as a token; anything else is a recipient name.
51
57
  export const RECIPIENT_ENGAGEMENT_DESCRIPTION =
52
58
  "What one recipient watched, per deck link: visits, narration heard (%), replayed / skipped slides, " +
53
- "where they dropped off, time per slide, each visit, and forwards. Use it to draft a follow-up: lead " +
59
+ "the slides they replayed on purpose and how often (summary.slideReplays), whether they watched it again " +
60
+ "from the start (summary.watchedAgain), a one-line read of both (summary.replayNote), " +
61
+ "where they dropped off, time per slide, each visit, and forwards. Each link's aiReads lists the AI " +
62
+ "assistants (ChatGPT, Claude, ...) that read its text version (/v/<token>/md), separately: those are " +
63
+ "not views. Use it to draft a follow-up: lead " +
54
64
  "with what they spent time on, and never tell the recipient they were tracked.";
55
65
  export const SHARE_TOKEN = /^[a-f0-9]{16}$/;
56
66
  export async function recipientEngagement(opts, who, deck) {
@@ -264,7 +274,11 @@ export async function ownedDeck(opts, slug) {
264
274
  const c = await client(opts);
265
275
  const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
266
276
  if (!me) throw new Error("not logged in: run `hyperdecks login` first");
267
- if (!deck) throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
277
+ if (!deck) {
278
+ // get answers only for your own decks; say so when the slug is someone else's.
279
+ if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`forbidden: ${slug} is not your deck`);
280
+ throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
281
+ }
268
282
  if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
269
283
  return deck;
270
284
  }
@@ -288,7 +302,7 @@ export const VIDEO_RECIPIENT = "Video viewers";
288
302
  export async function videoShareLink(opts, slug) {
289
303
  const live = (await listShares(opts, slug)).find((s) => s.recipient === VIDEO_RECIPIENT && s.enabled && !s.qa && !s.archived && !s.requireEmail && (s.expiresAt == null || s.expiresAt > Date.now()) && (s.viewLimit == null || s.opens < s.viewLimit));
290
304
  const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
291
- return `${target(opts).shareBase}/v/${token}`;
305
+ return `${await linkBase(opts)}/v/${token}`;
292
306
  }
293
307
 
294
308
  // `export --video --upload`: file the MP4 with the deck's exports (30 days).
@@ -428,10 +442,24 @@ export async function checkNarrationJob(opts, jobId) {
428
442
  return (await client(opts)).query(api.narration.getJob, { jobId: String(jobId) });
429
443
  }
430
444
 
445
+ // Your own voices (what clone, rename and delete act on).
431
446
  export async function listVoices(opts = {}) {
432
447
  return (await client(opts)).query(api.voices.listVoices, {});
433
448
  }
434
449
 
450
+ // Every voice you can narrate with: yours, then the built-in ones (builtin: true,
451
+ // id stock:<id>), which any account may use. What list_voices shows.
452
+ export async function listAllVoices(opts = {}) {
453
+ const c = await client(opts);
454
+ try {
455
+ return await c.query(api.voices.listVoices, { builtin: true });
456
+ } catch (e) {
457
+ // A backend from before built-in voices refuses the argument: your own voices, then.
458
+ if (!/builtin|extra field|Validator/i.test(errorText(e))) throw e;
459
+ return c.query(api.voices.listVoices, {});
460
+ }
461
+ }
462
+
435
463
  // ---- voice cloning (convex/voices.ts) ---------------------------------------
436
464
 
437
465
  // The text of a backend error: ConvexError data (production redacts the rest), else the message.
@@ -603,25 +631,36 @@ export async function setDeckForwarding(opts, slug, allowForward) {
603
631
 
604
632
  // How a deck opens in the player and whether viewers may switch: view is
605
633
  // "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
606
- // true/false. Either may be left out; both go in one owner-only mutation, so a
607
- // failure changes neither.
634
+ // true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
635
+ // slide), "step" (also stops before each [reveal] cue), or null for the deck's
636
+ // own HTML. aiReadable true/false: whether each link's AI-readable version
637
+ // (/v/<token>/md) serves (on by default; off is a 404 there). Any may be left
638
+ // out; all go in one owner-only mutation, so a failure changes none.
608
639
  // Applies on every link's next load (the route injects it), with no re-release.
609
640
  export const DECK_VIEWS = ["slides", "scroll"];
610
- export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
641
+ export const DECK_PACES = ["auto", "slide", "step"];
642
+ export async function setDeckView(opts, slug, { view, viewToggle, pace, aiReadable } = {}) {
611
643
  if (!slug) throw new Error("slug is required");
612
- if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a viewToggle");
644
+ const player = view !== undefined || viewToggle !== undefined || pace !== undefined;
645
+ if (!player && aiReadable === undefined) throw new Error("give a view (slides, scroll or default), a viewToggle, a pace (auto, slide, step or default) and/or aiReadable");
613
646
  if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
647
+ if (pace !== undefined && pace !== null && !DECK_PACES.includes(pace)) throw new Error(`pace must be auto, slide, step or null (got ${JSON.stringify(pace)})`);
614
648
  const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
615
649
  slug,
616
650
  ...(view !== undefined ? { view } : {}),
617
651
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
652
+ ...(pace !== undefined ? { pace } : {}),
653
+ ...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
618
654
  });
619
- // Saved either way; a deck on its own player says the setting won't apply.
620
- const applies = res?.applies !== false;
655
+ // Saved either way; a deck on its own player says the setting won't apply
656
+ // (aiReadable alone always applies: /md doesn't need the player).
657
+ const applies = !player || res?.applies !== false;
621
658
  return {
622
659
  slug,
623
660
  ...(view !== undefined ? { view } : {}),
624
661
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
662
+ ...(pace !== undefined ? { pace } : {}),
663
+ ...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
625
664
  applies,
626
665
  ...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
627
666
  };
@@ -631,7 +670,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
631
670
  // upsert would otherwise reset your own live deck to a draft and rename it.
632
671
  export async function createDeck(opts, { slug, title }) {
633
672
  const c = await client(opts);
634
- if (await c.query(api.decks.get, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
673
+ if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
635
674
  await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
636
675
  return { slug, title, status: "draft" };
637
676
  }
@@ -1087,6 +1126,22 @@ export async function addPickedAvatar(opts, avatarKey) {
1087
1126
  return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
1088
1127
  }
1089
1128
 
1129
+ // A drawing that failed on a pet photo (no human face, but Minimoji saw a cat,
1130
+ // a dog…): the offer to make that species' avatar instead, or null. The
1131
+ // person still picks the face (new_avatar_link with the species).
1132
+ export function petOffer(draft) {
1133
+ const s = SPECIES.find((x) => x.id === draft?.suggestedSpecies && x.id !== "human");
1134
+ if (!s) return null;
1135
+ const word = s.label.toLowerCase();
1136
+ return {
1137
+ status: "failed",
1138
+ error: draft.error ?? null,
1139
+ suggestedSpecies: s.id,
1140
+ offer: `That looks like a ${word}. Make a ${word} avatar?`,
1141
+ next: `if they want one: new_avatar_link with species "${s.id}" (a link to pick from the ${word} faces), or \`hyperdecks avatar new --species ${s.id}\``,
1142
+ };
1143
+ }
1144
+
1090
1145
  // A face from a photo. With bytes: upload and draw it, then hand back the link
1091
1146
  // where the person approves it (nothing is used before). Without: the link
1092
1147
  // where the person takes a selfie in their browser.
@@ -1102,7 +1157,11 @@ export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onPro
1102
1157
  onProgress?.("drawing…");
1103
1158
  const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
1104
1159
  if (!draft) throw new Error("the drawing is gone; run the command again");
1105
- if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
1160
+ if (draft.status === "failed") {
1161
+ const offer = petOffer(draft);
1162
+ if (offer) return { ...offer, draftId };
1163
+ throw new Error(draft.error || "the drawing failed; try another photo");
1164
+ }
1106
1165
  const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
1107
1166
  return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
1108
1167
  }
@@ -1146,7 +1205,7 @@ export async function listNarrators(opts = {}) {
1146
1205
  default: out.defaultId,
1147
1206
  ...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
1148
1207
  narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, spokenName: n.spokenName ?? null, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
1149
- voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
1208
+ voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, builtin: !!x.builtin, narrators: x.narrators })),
1150
1209
  };
1151
1210
  }
1152
1211
 
@@ -1188,7 +1247,8 @@ export async function removeNarrator(opts, ref) {
1188
1247
  return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
1189
1248
  }
1190
1249
 
1191
- // voice: one of your ready voices (name or id) or dash.
1250
+ // voice: one of your ready voices (name or id), a built-in voice (name or
1251
+ // stock:<id>, list_voices), or dash.
1192
1252
  export async function setNarratorVoice(opts, ref, voice) {
1193
1253
  if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
1194
1254
  const n = await findNarrator(opts, ref);
@@ -1259,7 +1319,11 @@ export async function avatarDraftStatus(opts, draftId) {
1259
1319
  const draft = await c.query(api.avatars.getDraft, { draftId: String(draftId) });
1260
1320
  if (!draft) throw new Error("the drawing is gone; start again with avatar_from_photo");
1261
1321
  if (draft.status === "drawing") return { status: "drawing", draftId };
1262
- if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
1322
+ if (draft.status === "failed") {
1323
+ const offer = petOffer(draft);
1324
+ if (offer) return { ...offer, draftId };
1325
+ throw new Error(draft.error || "the drawing failed; try another photo");
1326
+ }
1263
1327
  if (draft.status === "added") return { status: "added", draftId, avatarId: draft.avatarId ?? null };
1264
1328
  if (draft.status !== "drawn") throw new Error(`the drawing is in an unexpected state (${draft.status}); start again with avatar_from_photo`);
1265
1329
  const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
package/src/presenter.mjs CHANGED
@@ -12,32 +12,118 @@
12
12
  export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
13
13
  export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
14
14
 
15
- const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
15
+ const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/gi;
16
16
 
17
17
  const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
18
18
 
19
- // Whether this tag plays captions only: data-presenter="none", or no presenter
20
- // at all (the player's default when there is no data-presenter-image either).
21
- const noPresenter = (tag) => /\sdata-presenter="none"/.test(tag) || (!/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag));
19
+ // The live player tag: the first one outside an HTML comment (a commented-out
20
+ // copy is not what plays), with where it starts. check's playerScript reads it
21
+ // the same way (closed comments only, so a stray "<!--" in a script hides nothing).
22
+ function playerTag(html) {
23
+ const comments = [...html.matchAll(/<!--[\s\S]*?-->/g)].map((m) => [m.index, m.index + m[0].length]);
24
+ for (const m of html.matchAll(PLAYER_TAG)) {
25
+ if (!comments.some(([a, b]) => m.index >= a && m.index < b)) return { tag: m[0], index: m.index };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const value = (tag, name) => {
31
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
32
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
33
+ };
34
+
35
+ // The face this tag shows, decided as packages/player/player.js does: data-presenter
36
+ // head|static|none, else static when there is a still, else none. head draws
37
+ // data-avatar (and falls back to the still without one); static draws the
38
+ // still. Null: the deck plays the voice and captions with no face.
39
+ function tagFace(tag) {
40
+ const presenter = value(tag, "data-presenter");
41
+ const avatarUrl = value(tag, "data-avatar") || null;
42
+ const imageUrl = value(tag, "data-presenter-image") || null;
43
+ const mode = ["head", "static", "none"].includes(presenter) ? presenter : imageUrl ? "static" : "none";
44
+ if (mode === "head" && (avatarUrl || imageUrl)) return { avatarUrl, imageUrl };
45
+ if (mode === "static" && imageUrl) return { avatarUrl: null, imageUrl };
46
+ return null;
47
+ }
48
+
49
+ // The face the page's player shows ({ avatarUrl, imageUrl }, either may be
50
+ // null), or null when it plays captions only or has no player tag.
51
+ export function presentedFace(html) {
52
+ const found = playerTag(html);
53
+ return found ? tagFace(found.tag) : null;
54
+ }
22
55
 
23
- // Whether the page's player tag names nobody, so without a face it has captions only.
56
+ // Whether the page's player tag shows no face, so it has captions only.
24
57
  export function playsCaptionsOnly(html) {
25
- const tag = PLAYER_TAG.exec(html)?.[0];
26
- return !!tag && !/\sdata-avatar=/.test(tag) && noPresenter(tag);
58
+ const found = playerTag(html);
59
+ return !!found && !tagFace(found.tag);
27
60
  }
28
61
 
29
62
  // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
30
- // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
31
- // or html unchanged when there is no such tag or it already has a presenter.
63
+ // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
64
+ // html unchanged when there is no such tag, the tag already shows a face, or it
65
+ // names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
66
+ // Anything else the player would play captions only (data-presenter none or
67
+ // missing, head with no data-avatar, static with no still) gets the face.
32
68
  export function withAvatar(html, { avatarUrl, imageUrl }) {
33
- const tag = PLAYER_TAG.exec(html)?.[0];
34
- if (!tag || /\sdata-avatar=/.test(tag)) return html;
35
- if (!noPresenter(tag)) return html;
36
- const none = /\sdata-presenter="none"/.test(tag);
37
- const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
69
+ const found = playerTag(html);
70
+ if (!found) return html;
71
+ const { tag, index } = found;
72
+ if (tagFace(tag) || value(tag, "data-avatar")) return html;
73
+ const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
38
74
  const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
39
- const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
40
- return html.replace(tag, () => next);
75
+ const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
76
+ const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
77
+ return html.slice(0, index) + next + html.slice(index + tag.length);
78
+ }
79
+
80
+ // What is wrong with the player tag's presenter as written, for check: null, or
81
+ // { kind, presenter }. "unfilled": it asks for a face (head or static) but names
82
+ // none, so it plays captions only until release fills it in. "kept": it has a
83
+ // data-avatar the player ignores (data-presenter="none"), and release leaves it.
84
+ // A plain "none" or no data-presenter is not an issue: release adds the face.
85
+ export function presenterTagIssue(html) {
86
+ const found = playerTag(html);
87
+ if (!found || tagFace(found.tag)) return null;
88
+ const presenter = value(found.tag, "data-presenter");
89
+ if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
90
+ if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
91
+ return null;
92
+ }
93
+
94
+ // Why a resolved face did not land on the page: withAvatar leaves a tag with
95
+ // its own data-avatar alone, and a page with no live player tag has nowhere to put it.
96
+ function keptOutReason(html) {
97
+ const found = playerTag(html);
98
+ if (!found) return "the page has no live player tag to put the face on";
99
+ const mode = value(found.tag, "data-presenter");
100
+ return `the player tag names its own data-avatar, but data-presenter=${mode === null ? " is missing" : `"${mode}"`} so it plays no face, and release leaves a hand-set data-avatar alone: set data-presenter="head", or remove data-avatar`;
101
+ }
102
+
103
+ // What a release says about the face, from the page as written (`html`), the
104
+ // face it puts on (`presenter`, from avatars.presenterFor; null for none), who
105
+ // the backend says presents (`who`: { narrator, voice, face, reason }) and, when
106
+ // the face lookup itself failed, `lookupError`. Returns the page that ships, a
107
+ // line for the terminal and the summary to report: "presented by" only when
108
+ // that page shows a face, and with no resolved face on it, the tag's own.
109
+ // Both releases use it: release.mjs and convex/deckSources.ts.
110
+ export const TAG_FACE = "the face set on the player tag";
111
+ export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
112
+ const page = presenter ? withAvatar(html, presenter) : html;
113
+ const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
114
+ const shown = presentedFace(page);
115
+ if (shown) {
116
+ // Left as written, with no resolved face or another one: whatever the tag names is what shows.
117
+ if (page === html && shown.avatarUrl !== presenter?.avatarUrl) {
118
+ return { page, line: `✓ presented by ${TAG_FACE} (${shown.avatarUrl ? "data-avatar" : "data-presenter-image"})`, summary: who ? { ...who, face: TAG_FACE } : null };
119
+ }
120
+ return { page, line: `✓ presented by ${who?.face ?? "your avatar"}`, summary: who ?? null };
121
+ }
122
+ if (lookupError) return captions(`could not look up your avatar (${lookupError}); release again to try once more`);
123
+ if (presenter) return captions(keptOutReason(page));
124
+ if (who && !who.face) return { page, line: `⚠ releasing captions only: ${who.reason ?? "no face"}`, summary: who };
125
+ if (who?.face) return captions(`no face for "${who.face}" came back to put on the player tag`);
126
+ return { page, line: "", summary: null };
41
127
  }
42
128
 
43
129
  // One line for who presents a deck, from narrate's or release's `presenter`