@hypersocial/decks-cli 0.9.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -149,7 +149,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
149
149
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
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
- | Slides or scroll, viewer toggle, pace | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default]` | `set_deck_view` |
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
153
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
154
154
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
155
155
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
@@ -692,6 +692,43 @@ you do).
692
692
  | `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
693
693
  | `[gesture: <id>]` | one of the head's tracks: `knowing-nod`, `ponder-tilt`, `lightbulb`, `side-eye-hmm`, `chuckle-bob`, `sunny-nod`, `double-take`… (check lists them all) |
694
694
  | `[custom: name]` | only the slide's controller sees it |
695
+ | `[after: reveal #id]` | any stage cue above, fired at the END of the word before it (reveal at the end of a phrase) instead of its start |
696
+ - Guest speakers: other avatars that speak their own lines on a slide, no
697
+ slide JS. A `[speaker: <selector> voice=<voice> avatar=<face> name=<caption name>]`
698
+ line runs to the end of its paragraph; the narrator takes the next one:
699
+
700
+ ```
701
+ ## own
702
+ Let's hear them.
703
+
704
+ [speaker: #own-c1 voice=stock:sarah avatar=cat] Ooh, nice move!
705
+
706
+ [speaker: #own-c2 voice=matilda avatar=monkey name="Mono"] [reveal: #own-c2] Hola, ¿qué tal?
707
+
708
+ That's the idea.
709
+ ```
710
+
711
+ `voice` is one of the user's voices, a built-in `stock:<id>` or a character
712
+ voice. `avatar` is a cast id (`cat`, `kitty`, `monkey`, `dog`, `rabbit`,
713
+ `penguin`, `ghost`, `alien`, `agent`…), one of the user's avatars or a
714
+ narrator's name; without one the guest speaks with captions only. `name`
715
+ labels its captions (default: the avatar, capitalised). The guest's head
716
+ fills the element the selector names, or the `[data-hd-speaker]` spot
717
+ inside it, so give each card a face box (e.g. 140×140). Narrate renders
718
+ each line in its own voice (cached, transcript-checked) into the slide's
719
+ timeline: the narrator pauses, the guests speak in turn with lip-sync and a
720
+ caption bubble by their element, its tail at their face, the spoken word
721
+ highlighted ("Cat: Ooh, nice move!"), only while they talk; the speaker's
722
+ element is spotlit and the other guests dim. The player draws that bubble:
723
+ don't put static speech bubbles or the guest's line on the slide too. The presenter
724
+ glances at whoever speaks, then the narrator goes on. A cue may open a
725
+ guest's line and fires as it starts. Inside a guest's line, `[nod]`,
726
+ `[shake]`, `[brow]` and `[gesture]` move the guest's head; the other cues
727
+ (`[reveal]`, `[go]`, `[mood]`, `[wave]`…) act on the slide and the presenter. Replay, Per step (each guest line is a
728
+ step), mute, You present and video export all include them.
729
+ - Audio and data files a slide uses itself (a sound effect, a chart's JSON):
730
+ `hyperdecks asset add` / `upload_deck_asset` take `.mp3`, `.m4a` and
731
+ `.json` as well as images, video and fonts.
695
732
  - `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
696
733
  character voice (not a clone of anyone) any account may use, for decks that
697
734
  D.A.S.H. presents. It works once the deployment sets `CHARACTER_VOICE_DASH`.
@@ -867,8 +904,10 @@ hyperdecks engagement "<name>" --deck <slug> --json # MCP: recipient_engagemen
867
904
  Then draft the email:
868
905
 
869
906
  1. Lead with the slide they spent most time on (`summary.mostTime`) or
870
- replayed (`replayedSlides`), and answer what that slide raises. Each
871
- slide's `title` says what it is about.
907
+ replayed (`replayedSlides`; `slideReplays` are the ones they replayed on
908
+ purpose, most first, and `replayNote` says it in one line), and answer what
909
+ that slide raises. Each slide's `title` says what it is about. Watching it
910
+ again from the start (`watchedAgain`) is strong interest.
872
911
  2. If they dropped off (`droppedOffOn`), offer the rest briefly, or a
873
912
  2-minute call, rather than asking them to rewatch.
874
913
  3. If they never opened it (`summary.visits` is 0), write a short nudge
@@ -889,7 +928,7 @@ the hosted MCP (`deck_guide`, `new_deck`, `get_deck_source`, `write_deck_source`
889
928
  `upload_deck_asset`, `check_deck`, `release_deck`; see `chat.md`). Move between
890
929
  the two with `hyperdecks source pull <slug> [dir]` (the hosted draft into a
891
930
  folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
892
- draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
931
+ draft), `hyperdecks asset add <slug> <file|url>` for an image, video, audio (.mp3, .m4a), font or JSON file, `hyperdecks guide
893
932
  [topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
894
933
 
895
934
  ## After you ship: say what cost you
@@ -15,7 +15,9 @@ narrators, credits) and the steps. No voice of their own: `clone_voice_link`
15
15
  in the chat, only after they agree to the consent statement, word for word.
16
16
  `hear_voice` plays a voice or a narrator in the chat. A face from a photo they
17
17
  attach: `avatar_from_photo { file }`, then `check_avatar_draft` until it hands
18
- back the link they approve it on. `rename_narrator` and `set_narrator_face`
18
+ back the link they approve it on (a pet photo comes back with an `offer`, say
19
+ "That looks like a cat. Make a cat avatar?": ask them, and on a yes
20
+ `new_avatar_link { species }`). `rename_narrator` and `set_narrator_face`
19
21
  change a narrator when they ask. Then `get_started { name, about, ask, voice }`
20
22
  makes the hello deck; continue from step 5 below.
21
23
 
@@ -95,7 +97,11 @@ makes the hello deck; continue from step 5 below.
95
97
  for how the deck opens, whether viewers may switch, and its pace (`auto`
96
98
  plays itself, `slide` waits for Next after each slide, `step` also stops
97
99
  before each `[reveal]`, so group a slide's reveals into meaningful steps)
98
- (every link, next load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
100
+ (every link, next load, no re-release). Any share link + `/md` (e.g.
101
+ https://decks.hypersocial.ai/v/<token>/md) returns the deck as markdown
102
+ (slide text, image descriptions and the narration), honouring the link's
103
+ gates; owners can turn it off with `set_deck_view { slug, aiReadable: false }`.
104
+ `set_share_notify` to mute a link's "opened" alerts;
99
105
  `set_recipient_archived` to tidy the activity feed; `credits` for the
100
106
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
101
107
  with the user first.
package/src/avatar.mjs CHANGED
@@ -59,7 +59,7 @@ export async function avatarNew(opts, { ask, log = console.log, interactive = in
59
59
 
60
60
  // A face from a photo file, or (no file) a selfie in the browser. Either way the
61
61
  // person approves it on the link before it is used.
62
- export async function avatarFromPhoto(file, opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
62
+ export async function avatarFromPhoto(file, opts, { ask, log = console.log, interactive = interactiveOf(opts) } = {}) {
63
63
  let photo = {};
64
64
  if (file) {
65
65
  const contentType = PHOTO_TYPES[extname(file).toLowerCase()];
@@ -67,8 +67,19 @@ export async function avatarFromPhoto(file, opts, { log = console.log, interacti
67
67
  photo = { bytes: readFileSync(file), contentType };
68
68
  }
69
69
  const out = await ops.avatarFromPhoto(opts, photo, { onProgress: (m) => !opts.json && log(m) });
70
- const url = out.reviewUrl ?? out.selfieUrl;
71
70
  if (opts.json) return out;
71
+ if (out.status === "failed" && out.suggestedSpecies) {
72
+ // No human face, but a pet: offer that species' faces instead.
73
+ log(out.offer);
74
+ if (!interactive || !ask) {
75
+ log(` hyperdecks avatar new --species ${out.suggestedSpecies}`);
76
+ return out;
77
+ }
78
+ const a = (await ask("Make one? [Y/n]: ")).trim().toLowerCase();
79
+ if (a && a !== "y" && a !== "yes") throw new Error(out.error || "no face was found in that photo");
80
+ return avatarNew({ ...opts, species: out.suggestedSpecies }, { ask, log, interactive });
81
+ }
82
+ const url = out.reviewUrl ?? out.selfieUrl;
72
83
  log(file ? `✓ Drew a cartoon of you. Check it before it's used:\n ${url}` : `No photo given. Take a selfie in your browser:\n ${url}`);
73
84
  if (opts["save-to-minimoji"]) log(" (tick \"Also save it to my Minimoji account\" there)");
74
85
  if (!interactive) return out;
@@ -37,6 +37,7 @@ export const MESSAGES = {
37
37
  noLoading: () => "no loading state: run `hyperdecks check <deck> --fix` (`check <slug> --hosted --fix`; over the MCP, write index.html again with write_deck_source), which needs a <head>…</head> and the player script; or copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>; set --hd-ground to the first slide's colour",
38
38
  clipField: (id, field) => `clip ${id}: ${field}`,
39
39
  noCaptions: (id) => `clip ${id}: timeline has no captions`,
40
+ speakerMissing: (id, name, selector) => `slide ${id}: guest ${name} speaks from "${selector}", which is not on the slide, so its lines play with captions only and no face`,
40
41
  order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
41
42
  total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
42
43
  cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
@@ -605,6 +606,23 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
605
606
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
606
607
  const isStr = (v) => typeof v === "string" && v.trim().length > 0;
607
608
 
609
+ // Whether a guest's selector names something on its slide: an #id, or a
610
+ // .class or [attr] inside that <section>. Other selectors are taken on trust.
611
+ export function speakerOnSlide(html, slideId, selector) {
612
+ const page = uncommented(html);
613
+ const open = new RegExp(`<section\\b[^>]*\\sid\\s*=\\s*["']${slideId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][^>]*>`, "i").exec(page);
614
+ if (!open) return false;
615
+ const end = page.indexOf("</section>", open.index);
616
+ const section = page.slice(open.index, end < 0 ? undefined : end);
617
+ const id = /^#([\w-]+)$/.exec(selector.trim());
618
+ if (id) return new RegExp(`\\sid\\s*=\\s*["']${id[1]}["']`).test(section);
619
+ const cls = /^\.([\w-]+)$/.exec(selector.trim());
620
+ if (cls) return new RegExp(`\\sclass\\s*=\\s*["'][^"']*\\b${cls[1]}\\b`).test(section);
621
+ const attr = /^\[([\w-]+)/.exec(selector.trim());
622
+ if (attr) return new RegExp(`\\s${attr[1]}(\\s*=|[\\s>])`).test(section);
623
+ return true;
624
+ }
625
+
608
626
  // Fields every clip needs before its other rules mean anything.
609
627
  export function clipFieldErrors(clip, i) {
610
628
  const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
@@ -937,6 +955,17 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
937
955
  continue;
938
956
  }
939
957
  errors.push(...timelineErrors(clip, timeline));
958
+ // Guest lines ([speaker: …]): each is audio of its own, and each speaker needs its element on the slide.
959
+ for (const [g, guest] of (Array.isArray(clip.guests) ? clip.guests : []).entries()) {
960
+ let url;
961
+ try { url = new URL(guest?.audio, player.manifest).href; } catch { errors.push(MESSAGES.clipField(clip.id, `guest line ${g + 1} needs an audio URL`)); continue; }
962
+ const got = await fetchCheck(fetchImpl, url, AUDIO);
963
+ if (got.why) errors.push(MESSAGES.url(clip.id, `guest line ${g + 1} audio`, url, got.why));
964
+ else await got.response.body?.cancel?.().catch(() => {});
965
+ }
966
+ for (const sp of Array.isArray(clip.speakers) ? clip.speakers : []) {
967
+ if (sp?.selector && !speakerOnSlide(html, clip.id, sp.selector)) warnings.push(MESSAGES.speakerMissing(clip.id, sp.name ?? "a guest", sp.selector));
968
+ }
940
969
  }
941
970
  }
942
971
  warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
package/src/commands.mjs CHANGED
@@ -158,28 +158,32 @@ async function deckForward(pos, opts) {
158
158
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
159
159
  }
160
160
 
161
- const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]";
161
+ const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
162
162
  const VIEWS = { slides: "slides", scroll: "scroll", default: null };
163
163
  const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
164
164
  async function deckView(pos, opts) {
165
165
  const [slug, mode] = pos;
166
166
  const toggle = opts.toggle;
167
167
  const pace = opts.pace;
168
- if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle") && !Object.hasOwn(opts, "pace"))) throw new Error(VIEW_USAGE);
168
+ const ai = opts["ai-readable"];
169
+ if (!slug || (mode === undefined && !["toggle", "pace", "ai-readable"].some((k) => Object.hasOwn(opts, k)))) throw new Error(VIEW_USAGE);
169
170
  if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
170
171
  // A trailing --toggle with no value is a mistake, not "leave it".
171
172
  if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
172
173
  if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
174
+ if (Object.hasOwn(opts, "ai-readable") && ai !== "on" && ai !== "off") throw new Error(VIEW_USAGE);
173
175
  const out = await ops.setDeckView(opts, slug, {
174
176
  view: mode === undefined ? undefined : VIEWS[mode],
175
177
  viewToggle: toggle === undefined ? undefined : toggle === "on",
176
178
  pace: pace === undefined ? undefined : PACES[pace],
179
+ aiReadable: ai === undefined ? undefined : ai === "on",
177
180
  });
178
181
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
179
182
  const said = [];
180
183
  if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
181
184
  if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
182
185
  if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
186
+ if (ai !== undefined) said.push(`AI-readable version (/v/<token>/md) ${ai}`);
183
187
  if (out.applies === false) {
184
188
  console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
185
189
  console.warn(`warning: ${out.warning}`);
@@ -256,7 +260,7 @@ async function engagement(pos, opts) {
256
260
  console.log(` visits: ${s.visits} (last ${new Date(s.lastSeenAt).toISOString().slice(0, 16).replace("T", " ")}) · ${fmtMs(s.totalMs)} engaged`);
257
261
  console.log(` narration: ${s.narration.finished ? "finished" : s.narration.started ? "started" : "not started"} · heard ${pct(s.narration.heardPct)}${s.narration.telemetry === "v3" ? "" : " (playback detail unavailable)"}`);
258
262
  console.log(` reached: slide ${s.furthestSlide}${e.deck.slides ? ` of ${e.deck.slides}` : ""}${s.droppedOffOn ? ` · dropped off on ${slideName(s.droppedOffOn)}` : ""}`);
259
- console.log(` replayed: ${list(s.replayedSlides)}`);
263
+ console.log(` replayed: ${list(s.replayedSlides)}${s.replayNote ? ` · ${s.replayNote}` : ""}`);
260
264
  console.log(` skipped: ${list(s.skippedSlides)}`);
261
265
  console.log(` most time: ${s.mostTime.map((m) => `${slideName(m)} (${fmtMs(m.ms)})`).join(", ") || "none"}`);
262
266
  if (!s.slideData) console.log(" slide data unavailable (this deck reports time only)");
@@ -347,9 +351,10 @@ commands:
347
351
  deck list|stats|open|rm|forward|view
348
352
  deck forward on|off <slug>
349
353
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
350
- deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]
354
+ deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
351
355
  how the deck opens in the player (default: as its HTML says, data-view or slides) and
352
356
  whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
357
+ --ai-readable: whether each link + /md serves the deck as markdown for AI assistants (on by default).
353
358
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
354
359
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
355
360
  in your saved brand's colours, fonts and logo (--no-brand: the template's look)
@@ -364,7 +369,7 @@ commands:
364
369
  source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
365
370
  move a hosted draft (index.html, script.md, evidence.md) to a folder and back
366
371
  asset add <slug> <file|url> [--name n]
367
- file an image, video or font under the deck; prints its hosted URL
372
+ file an image, video, audio (mp3, m4a), font or JSON under the deck; prints its URL
368
373
  guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
369
374
  the skill, one topic at a time (what deck_guide serves)
370
375
  play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
@@ -898,8 +903,11 @@ function commandTable(pos, rest, opts) {
898
903
  },
899
904
  "avatar from-photo": async () => {
900
905
  const { avatarFromPhoto } = await import("./avatar.mjs");
901
- const out = await avatarFromPhoto(rest[0], opts);
906
+ const { askLine } = await import("./init.mjs");
907
+ const out = await avatarFromPhoto(rest[0], opts, { ask: askLine });
902
908
  if (opts.json) console.log(JSON.stringify(out, null, 2));
909
+ // A pet photo is still a failed drawing: the offer is printed, the exit says so.
910
+ if (out?.status === "failed") process.exitCode = 1;
903
911
  },
904
912
  "avatar connect": async () => {
905
913
  const { avatarConnect } = await import("./avatar.mjs");
@@ -115,7 +115,8 @@ export function planTimeline(plan) {
115
115
  export function audioTimeline(log, markMs, lead = 0) {
116
116
  const audioOf = new Map((log.plan ?? []).map((s) => [s.id, s.audio]));
117
117
  return (log.clips ?? [])
118
- .map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000) }))
118
+ // A guest line's entry names its own audio; `seek`: the slide's track picking up again after one.
119
+ .map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000), ...(c.seek > 0 ? { seek: c.seek } : {}) }))
119
120
  .filter((c) => c.audio);
120
121
  }
121
122
 
@@ -125,7 +126,8 @@ export function audioTimeline(log, markMs, lead = 0) {
125
126
  export function timelineDrift(log, markMs) {
126
127
  const plan = planTimeline(log.plan ?? []);
127
128
  const startOf = new Map(plan.ids.map((id, i) => [id, plan.starts[i]]));
128
- return (log.clips ?? []).filter((c) => startOf.has(c.id)).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
129
+ // Only each slide's own start: guest lines and resumes (entries with their own audio) are inside it.
130
+ return (log.clips ?? []).filter((c) => startOf.has(c.id) && !c.audio).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
129
131
  }
130
132
 
131
133
  // ffmpeg args for the audio track, `seconds` long: silence up to the first
@@ -147,7 +149,9 @@ export function audioMixArgs({ inputs, seconds, out }) {
147
149
  }
148
150
  clips.forEach((c, n) => {
149
151
  const end = n + 1 < clips.length ? clips[n + 1].offset : seconds;
150
- parts.push(`[${n}:a]${fmt},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
152
+ // `seek`: start that far into the file (a slide's track resuming after a guest line).
153
+ const from = c.seek > 0 ? `,atrim=start=${Number(c.seek).toFixed(3)},asetpts=PTS-STARTPTS` : '';
154
+ parts.push(`[${n}:a]${fmt}${from},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
151
155
  labels.push(`[c${n}]`);
152
156
  });
153
157
  parts.push(`${labels.join('')}concat=n=${labels.length}:v=0:a=1[mix]`);
@@ -185,7 +189,7 @@ export const SEAM_SECONDS = 2;
185
189
  export const SEAM_FRAMES = SEAM_SECONDS * VIDEO_FPS;
186
190
  export const MAX_SPLIT_CLIPS = 200;
187
191
  export function splittableAudio(plan) {
188
- const audio = plan.map((s) => s.audio).filter(Boolean);
192
+ const audio = plan.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean);
189
193
  return audio.length <= MAX_SPLIT_CLIPS && audio.every((a) => typeof a === 'string' && a.length <= MAX_SPLIT_AUDIO_URL && /^https?:\/\//i.test(a));
190
194
  }
191
195
 
@@ -223,7 +227,7 @@ export function joinTimeline(segments, fps = VIDEO_FPS) {
223
227
  let frames = 0;
224
228
  const clips = [];
225
229
  for (const s of segments) {
226
- for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)) });
230
+ for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)), ...(c.seek > 0 ? { seek: c.seek } : {}) });
227
231
  frames += s.frames;
228
232
  }
229
233
  return { clips, frames, seconds: frames / fps };
@@ -503,8 +507,9 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
503
507
  // A range needs only its own slides' clips.
504
508
  const audioFiles = new Map(), clipLengths = {};
505
509
  const wanted = (state.plan ?? []).filter((s) => inRange(s.id));
506
- for (const [n, url] of [...new Set(wanted.map((s) => s.audio).filter(Boolean))].entries()) {
507
- const id = wanted.find((s) => s.audio === url).id;
510
+ // A slide's guest lines are audio of their own.
511
+ for (const [n, url] of [...new Set(wanted.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean))].entries()) {
512
+ const id = wanted.find((s) => s.audio === url || s.guests?.includes(url)).id;
508
513
  const path = clipPath(temp, n, url);
509
514
  await downloadClip(url, path, { id, entryUrl: pageUrl });
510
515
  audioFiles.set(url, path);
@@ -643,13 +648,13 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
643
648
  file: out, seconds: kept / VIDEO_FPS, width: geometry.width, height: geometry.height, bytes, slides: to - from + 1, clips: own.length, render, warnings,
644
649
  // frames: in the file. A clip's offset is from the file's first frame:
645
650
  // after a dropped seam, a slide's clip may start (just) before it.
646
- segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS })) },
651
+ segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS, ...(c.seek ? { seek: c.seek } : {}) })) },
647
652
  };
648
653
  }
649
654
  const inputs = clips.map((c) => {
650
655
  const path = audioFiles.get(c.audio);
651
656
  if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
652
- return { path, offset: c.offset };
657
+ return { path, offset: c.offset, seek: c.seek };
653
658
  });
654
659
  const audio = join(temp, 'audio.wav');
655
660
  await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
@@ -695,7 +700,7 @@ export async function joinVideo({ segments, out, origin = null }, { log = () =>
695
700
  }
696
701
  phase = 'audio';
697
702
  const audio = join(temp, 'audio.wav');
698
- await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset })), seconds: timeline.seconds, out: audio }), 300000);
703
+ await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset, seek: c.seek })), seconds: timeline.seconds, out: audio }), 300000);
699
704
  phase = 'join';
700
705
  const list = join(temp, 'segments.txt');
701
706
  await writeFile(list, concatList(segments.map((s) => s.file)));
@@ -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,5 +1,6 @@
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
6
  import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
@@ -33,6 +34,8 @@ export async function listDecks(opts = {}, { all = false } = {}) {
33
34
  viewToggle: d.viewToggle !== false,
34
35
  // How the player moves on: auto, slide or step (null: as its HTML says, else auto).
35
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,
36
39
  // false: the deck runs its own player, so view/viewToggle do not apply
37
40
  // (null: not known yet, treated as on).
38
41
  usesPlayer: d.usesPlayer ?? null,
@@ -53,7 +56,11 @@ export async function activityOverview(opts = {}) {
53
56
  // looked up as a token; anything else is a recipient name.
54
57
  export const RECIPIENT_ENGAGEMENT_DESCRIPTION =
55
58
  "What one recipient watched, per deck link: visits, narration heard (%), replayed / skipped slides, " +
56
- "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 " +
57
64
  "with what they spent time on, and never tell the recipient they were tracked.";
58
65
  export const SHARE_TOKEN = /^[a-f0-9]{16}$/;
59
66
  export async function recipientEngagement(opts, who, deck) {
@@ -626,14 +633,16 @@ export async function setDeckForwarding(opts, slug, allowForward) {
626
633
  // "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
627
634
  // true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
628
635
  // slide), "step" (also stops before each [reveal] cue), or null for the deck's
629
- // own HTML. Any may be left out; all go in one owner-only mutation, so a
630
- // failure changes none.
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.
631
639
  // Applies on every link's next load (the route injects it), with no re-release.
632
640
  export const DECK_VIEWS = ["slides", "scroll"];
633
641
  export const DECK_PACES = ["auto", "slide", "step"];
634
- export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
642
+ export async function setDeckView(opts, slug, { view, viewToggle, pace, aiReadable } = {}) {
635
643
  if (!slug) throw new Error("slug is required");
636
- if (view === undefined && viewToggle === undefined && pace === undefined) throw new Error("give a view (slides, scroll or default), a viewToggle and/or a pace (auto, slide, step or default)");
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");
637
646
  if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
638
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)})`);
639
648
  const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
@@ -641,14 +650,17 @@ export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
641
650
  ...(view !== undefined ? { view } : {}),
642
651
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
643
652
  ...(pace !== undefined ? { pace } : {}),
653
+ ...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
644
654
  });
645
- // Saved either way; a deck on its own player says the setting won't apply.
646
- 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;
647
658
  return {
648
659
  slug,
649
660
  ...(view !== undefined ? { view } : {}),
650
661
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
651
662
  ...(pace !== undefined ? { pace } : {}),
663
+ ...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
652
664
  applies,
653
665
  ...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
654
666
  };
@@ -1114,6 +1126,22 @@ export async function addPickedAvatar(opts, avatarKey) {
1114
1126
  return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
1115
1127
  }
1116
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
+
1117
1145
  // A face from a photo. With bytes: upload and draw it, then hand back the link
1118
1146
  // where the person approves it (nothing is used before). Without: the link
1119
1147
  // where the person takes a selfie in their browser.
@@ -1129,7 +1157,11 @@ export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onPro
1129
1157
  onProgress?.("drawing…");
1130
1158
  const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
1131
1159
  if (!draft) throw new Error("the drawing is gone; run the command again");
1132
- 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
+ }
1133
1165
  const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
1134
1166
  return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
1135
1167
  }
@@ -1287,7 +1319,11 @@ export async function avatarDraftStatus(opts, draftId) {
1287
1319
  const draft = await c.query(api.avatars.getDraft, { draftId: String(draftId) });
1288
1320
  if (!draft) throw new Error("the drawing is gone; start again with avatar_from_photo");
1289
1321
  if (draft.status === "drawing") return { status: "drawing", draftId };
1290
- 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
+ }
1291
1327
  if (draft.status === "added") return { status: "added", draftId, avatarId: draft.avatarId ?? null };
1292
1328
  if (draft.status !== "drawn") throw new Error(`the drawing is in an unexpected state (${draft.status}); start again with avatar_from_photo`);
1293
1329
  const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
package/src/release.mjs CHANGED
@@ -12,13 +12,16 @@
12
12
  // folder: script.md, evidence.md and narration/ stay private);
13
13
  // 5. creates one share per --to (paired in order with --email) and prints the URLs.
14
14
  import { execFileSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
15
16
  import { readFileSync } from "node:fs";
16
17
  import { join } from "node:path";
17
18
  import { zipSync } from "fflate";
18
19
  import * as ops from "./ops.mjs";
19
20
  import { releasedPresenter, withAvatar } from "./presenter.mjs";
20
21
  import { linkBase } from "./runtime.mjs";
21
- import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
22
+ import { checkDeck, deckFiles, deckFolder, deckPathResolver, resolveDeckDir } from "./check.mjs";
23
+ import { mediaRefs, withMediaPreviews } from "./mediaPreviews.mjs";
24
+ import { bundledFfmpeg, HTTPS_TOO, makePreview } from "./mediaPreviewMake.mjs";
22
25
  import { narrate as runNarrate } from "./narrate.mjs";
23
26
 
24
27
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -54,19 +57,79 @@ export function deckCommit(deckDir) {
54
57
  // it brought in, so release can say what came from where.
55
58
  // `presenter` (from avatars.presenterFor) puts the owner's avatar on the
56
59
  // uploaded page's player tag; the committed index.html is never changed.
57
- export function releaseBundle(deckDir, presenter = null) {
60
+ // `media` (from mediaPreviewsFor) writes the blur previews onto the uploaded
61
+ // page and adds the posters it made; like the avatar, never to the commit.
62
+ export function releaseBundle(deckDir, presenter = null, media = null) {
58
63
  const raw = readFileSync(join(deckDir, "index.html"));
59
- const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
64
+ const page = presenter ? withAvatar(raw.toString("utf8"), presenter) : raw.toString("utf8");
60
65
  // check has already refused refs that leave the folder or do not resolve.
61
- const found = deckFiles(html.toString("utf8"), deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
66
+ const found = deckFiles(page, deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
67
+ const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) : presenter ? Buffer.from(page) : raw;
68
+ const made = Object.entries(media?.files ?? {});
62
69
  const added = {};
63
70
  for (const f of found) if (f.from !== "html") (added[f.from] ??= []).push(f.path);
64
- if (!found.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
71
+ if (!found.length && !made.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
65
72
  const files = { "index.html": html };
66
73
  for (const f of found) files[f.path] = readFileSync(join(deckDir, f.path));
74
+ for (const [path, bytes] of made) files[path] = bytes;
67
75
  return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files), added };
68
76
  }
69
77
 
78
+ // Blur previews (and posters for videos without one) for the media the page
79
+ // shows: local files inside the deck folder and https URLs, read by ffmpeg.
80
+ // Returns { previews, files, failed }: previews keyed by src as written, the
81
+ // poster files to bundle (hd-media/<hash>.jpg), and what could not be read. A
82
+ // preview is an extra, never a reason to stop a release.
83
+ export async function mediaPreviewsFor(deckDir, html, { make = makePreview, ffmpeg } = {}) {
84
+ const { images, videos } = mediaRefs(html);
85
+ const previews = {}, files = {}, failed = [];
86
+ if (!images.length && !videos.length) return { previews, files, failed };
87
+ let bin = ffmpeg;
88
+ try {
89
+ bin ??= await bundledFfmpeg();
90
+ } catch (e) {
91
+ return { previews, files, failed: [...images, ...videos].map((src) => ({ src, why: `no ffmpeg (${e.message})` })) };
92
+ }
93
+ const inside = deckPathResolver(deckDir);
94
+ const inputOf = (src) => {
95
+ if (/^https:\/\//i.test(src)) return src;
96
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(src)) return null; // http:, file:, protocol-relative: not read
97
+ let path;
98
+ try {
99
+ path = decodeURIComponent(src.split(/[?#]/)[0]).replace(/^\.\//, "");
100
+ } catch {
101
+ return null;
102
+ }
103
+ return inside(path) === true ? join(deckDir, path) : null;
104
+ };
105
+ const jobs = [...images.map((src) => ({ src, video: false })), ...videos.map((src) => ({ src, video: true }))];
106
+ const work = async ({ src, video }) => {
107
+ const input = inputOf(src);
108
+ if (!input) return failed.push({ src, why: "not a file in the deck folder or an https URL" });
109
+ try {
110
+ // A URL input may use https (the user's own machine reads it); a file stays local-only.
111
+ const p = await make(bin, input, { video, ...(input === src ? { allow: HTTPS_TOO } : {}) });
112
+ if (p.poster) {
113
+ const path = `hd-media/${createHash("sha256").update(src).digest("hex").slice(0, 16)}.jpg`;
114
+ files[path] = p.poster;
115
+ p.poster = path;
116
+ }
117
+ previews[src] = p;
118
+ } catch (e) {
119
+ failed.push({ src, why: e.message });
120
+ }
121
+ };
122
+ // A few at a time: each is an ffmpeg process.
123
+ for (let i = 0; i < jobs.length; i += 4) await Promise.all(jobs.slice(i, i + 4).map(work));
124
+ return { previews, files, failed };
125
+ }
126
+
127
+ function logPreviews(log, media) {
128
+ const n = Object.keys(media.previews).length;
129
+ if (n) log(`✓ blur previews for ${n} image(s)/video(s)${Object.keys(media.files).length ? `, ${Object.keys(media.files).length} poster(s) made` : ""}`);
130
+ for (const f of media.failed) log(` ⚠ no blur preview for ${f.src}: ${f.why}`);
131
+ }
132
+
70
133
  // From the CLI, parse() collects opts.recipients in order (each --email follows
71
134
  // its --to); MCP passes the same shape.
72
135
  export function recipients(opts) {
@@ -77,7 +140,7 @@ export function recipients(opts) {
77
140
  }
78
141
 
79
142
  export async function release(pos, opts = {}, io = {}) {
80
- const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd, measure, measureFit } = io;
143
+ const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, previews = mediaPreviewsFor, fetch, cwd, measure, measureFit } = io;
81
144
  const deckDir = resolveDeckDir(pos[0], cwd);
82
145
  const to = recipients(opts);
83
146
 
@@ -97,7 +160,15 @@ export async function release(pos, opts = {}, io = {}) {
97
160
  for (const w of checked.warnings) log(` ⚠ ${w}`);
98
161
  for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
99
162
 
100
- if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: bundle, summary: null });
163
+ let media;
164
+ try {
165
+ media = await previews(deckDir, readFileSync(join(deckDir, "index.html"), "utf8"));
166
+ logPreviews(log, media);
167
+ } catch (e) {
168
+ media = null; // a preview is an extra: the release goes on without them
169
+ log(` ⚠ no blur previews: ${e.message}`);
170
+ }
171
+ if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: releaseBundle(deckDir, null, media), summary: null });
101
172
  // Cached slides cost nothing, so this only renders what changed since the commit.
102
173
  // Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
103
174
  const narrateOpts = { ...opts, slug };
@@ -118,7 +189,7 @@ export async function release(pos, opts = {}, io = {}) {
118
189
  } catch (e) {
119
190
  lookupError = ops.errorText(e);
120
191
  }
121
- const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
192
+ const released = releaseBundle(deckDir, presenter, media);
122
193
  // Say who presents from the page that ships, never from the lookup alone: a
123
194
  // resolved face that did not land on the player tag must not be claimed, and
124
195
  // a failed lookup is captions only only when the tag names no face of its own.
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.8";
11
+ export const VERSION = "0.9.9";
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
package/src/source.mjs CHANGED
@@ -112,7 +112,7 @@ export async function sourcePush(pos, opts = {}, { log = (line) => console.log(l
112
112
  return { slug, rev, files };
113
113
  }
114
114
 
115
- const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff" };
115
+ const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".json": "application/json" };
116
116
 
117
117
  export async function assetAdd(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
118
118
  const [slug, from] = pos;
@@ -122,7 +122,7 @@ export async function assetAdd(pos, opts = {}, { log = (line) => console.log(lin
122
122
  else {
123
123
  const path = resolve(/*turbopackIgnore: true*/ cwd, from);
124
124
  const contentType = TYPES[extname(path).toLowerCase()];
125
- if (!contentType) throw new Error(`${from}: asset add takes images, video and fonts (${Object.keys(TYPES).join(" ")})`);
125
+ if (!contentType) throw new Error(`${from}: asset add takes images, video, audio, fonts and JSON (${Object.keys(TYPES).join(" ")})`);
126
126
  const c = await client(opts);
127
127
  const bytes = readFileSync(path);
128
128
  const storageId = await uploadBytes(c, bytes, contentType);