@hypersocial/decks-cli 0.9.8 → 0.9.10

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/commands.mjs CHANGED
@@ -72,12 +72,23 @@ async function deckStats(pos, opts) {
72
72
  }
73
73
  }
74
74
 
75
+ // The owner link: present or check your own deck (untracked, owner-only).
76
+ function deckLink(pos, opts) {
77
+ const [slug] = pos;
78
+ if (!slug) throw new Error("usage: deck link <slug> [--json]");
79
+ const out = ops.ownerLink(opts, slug);
80
+ if (opts.json) return console.log(JSON.stringify(out, null, 2));
81
+ console.log(out.ownerUrl);
82
+ if (out.backupUrl) console.log(`backup (other domain): ${out.backupUrl}`);
83
+ console.log("Only you can open it, signed in; nothing is tracked. Present (or H) hides your owner tools for a screen share.");
84
+ }
85
+
75
86
  function deckOpen(pos, opts) {
76
87
  const [slug] = pos;
77
88
  if (!slug) throw new Error("usage: deck open <slug>");
78
- const url = `${target(opts).brokerBase}/d/${slug}`;
79
- console.log(url);
80
- openBrowser(url);
89
+ const { ownerUrl } = ops.ownerLink(opts, slug);
90
+ console.log(ownerUrl);
91
+ openBrowser(ownerUrl);
81
92
  }
82
93
 
83
94
  async function deckRemove(pos, opts) {
@@ -89,8 +100,11 @@ async function deckRemove(pos, opts) {
89
100
 
90
101
  async function shareNew(pos, opts) {
91
102
  const [slug, recipient] = pos;
92
- if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
103
+ const usage = "usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]";
104
+ if (!slug || !recipient) throw new Error(usage);
93
105
  if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
106
+ // The link's own pace; without it the link follows the deck's.
107
+ if ("pace" in opts && !ops.DECK_PACES.includes(opts.pace)) throw new Error(usage);
94
108
  const { token } = await ops.createShare(opts, {
95
109
  slug,
96
110
  recipient,
@@ -100,6 +114,7 @@ async function shareNew(pos, opts) {
100
114
  qa: opts.qa || undefined,
101
115
  notify: opts["no-notify"] ? false : undefined,
102
116
  allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
117
+ pace: opts.pace,
103
118
  expires: opts.expires,
104
119
  limit: opts.limit,
105
120
  });
@@ -123,7 +138,7 @@ async function shareList(pos, opts) {
123
138
  // Rows arrive with each link's forwards right after it; indent those.
124
139
  for (const r of rows) {
125
140
  const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
126
- console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
141
+ console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.pace ? `\tpace:${r.pace}` : ""}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
127
142
  }
128
143
  if (!rows.length) console.log("(no shares)");
129
144
  }
@@ -158,28 +173,32 @@ async function deckForward(pos, opts) {
158
173
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
159
174
  }
160
175
 
161
- const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]";
176
+ const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
162
177
  const VIEWS = { slides: "slides", scroll: "scroll", default: null };
163
178
  const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
164
179
  async function deckView(pos, opts) {
165
180
  const [slug, mode] = pos;
166
181
  const toggle = opts.toggle;
167
182
  const pace = opts.pace;
168
- if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle") && !Object.hasOwn(opts, "pace"))) throw new Error(VIEW_USAGE);
183
+ const ai = opts["ai-readable"];
184
+ if (!slug || (mode === undefined && !["toggle", "pace", "ai-readable"].some((k) => Object.hasOwn(opts, k)))) throw new Error(VIEW_USAGE);
169
185
  if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
170
186
  // A trailing --toggle with no value is a mistake, not "leave it".
171
187
  if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
172
188
  if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
189
+ if (Object.hasOwn(opts, "ai-readable") && ai !== "on" && ai !== "off") throw new Error(VIEW_USAGE);
173
190
  const out = await ops.setDeckView(opts, slug, {
174
191
  view: mode === undefined ? undefined : VIEWS[mode],
175
192
  viewToggle: toggle === undefined ? undefined : toggle === "on",
176
193
  pace: pace === undefined ? undefined : PACES[pace],
194
+ aiReadable: ai === undefined ? undefined : ai === "on",
177
195
  });
178
196
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
179
197
  const said = [];
180
198
  if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
181
199
  if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
182
200
  if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
201
+ if (ai !== undefined) said.push(`AI-readable version (/v/<token>/md) ${ai}`);
183
202
  if (out.applies === false) {
184
203
  console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
185
204
  console.warn(`warning: ${out.warning}`);
@@ -256,7 +275,7 @@ async function engagement(pos, opts) {
256
275
  console.log(` visits: ${s.visits} (last ${new Date(s.lastSeenAt).toISOString().slice(0, 16).replace("T", " ")}) · ${fmtMs(s.totalMs)} engaged`);
257
276
  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
277
  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)}`);
278
+ console.log(` replayed: ${list(s.replayedSlides)}${s.replayNote ? ` · ${s.replayNote}` : ""}`);
260
279
  console.log(` skipped: ${list(s.skippedSlides)}`);
261
280
  console.log(` most time: ${s.mostTime.map((m) => `${slideName(m)} (${fmtMs(m.ms)})`).join(", ") || "none"}`);
262
281
  if (!s.slideData) console.log(" slide data unavailable (this deck reports time only)");
@@ -344,12 +363,16 @@ commands:
344
363
  login [--token <pat>] [--broker <url>] [--dev]
345
364
  logout [--dev]
346
365
  whoami [--json] [--dev]
347
- deck list|stats|open|rm|forward|view
366
+ deck list|stats|open|link|rm|forward|view
367
+ deck link <slug> [--json]
368
+ the owner link: present or check your own deck (only you, signed in; untracked;
369
+ Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
348
370
  deck forward on|off <slug>
349
371
  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]
372
+ deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
351
373
  how the deck opens in the player (default: as its HTML says, data-view or slides) and
352
374
  whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
375
+ --ai-readable: whether each link + /md serves the deck as markdown for AI assistants (on by default).
353
376
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
354
377
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
355
378
  in your saved brand's colours, fonts and logo (--no-brand: the template's look)
@@ -364,7 +387,7 @@ commands:
364
387
  source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
365
388
  move a hosted draft (index.html, script.md, evidence.md) to a folder and back
366
389
  asset add <slug> <file|url> [--name n]
367
- file an image, video or font under the deck; prints its hosted URL
390
+ file an image, video, audio (mp3, m4a), font or JSON under the deck; prints its URL
368
391
  guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
369
392
  the skill, one topic at a time (what deck_guide serves)
370
393
  play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
@@ -373,7 +396,7 @@ commands:
373
396
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
374
397
  on a committed deck: check → narrate → import → share new per --to
375
398
  share new|list|on|off|forward|mute|unmute|rm|url
376
- share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
399
+ share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]
377
400
  share forward on|off|default <token>
378
401
  let this link's viewer forward it as their own tracked link ("Dana via Peter",
379
402
  one hop); default follows the deck. share off on a link also turns off its forwards.
@@ -898,8 +921,11 @@ function commandTable(pos, rest, opts) {
898
921
  },
899
922
  "avatar from-photo": async () => {
900
923
  const { avatarFromPhoto } = await import("./avatar.mjs");
901
- const out = await avatarFromPhoto(rest[0], opts);
924
+ const { askLine } = await import("./init.mjs");
925
+ const out = await avatarFromPhoto(rest[0], opts, { ask: askLine });
902
926
  if (opts.json) console.log(JSON.stringify(out, null, 2));
927
+ // A pet photo is still a failed drawing: the offer is printed, the exit says so.
928
+ if (out?.status === "failed") process.exitCode = 1;
903
929
  },
904
930
  "avatar connect": async () => {
905
931
  const { avatarConnect } = await import("./avatar.mjs");
@@ -964,9 +990,20 @@ function commandTable(pos, rest, opts) {
964
990
  console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
965
991
  },
966
992
  "deck narrator": async () => {
967
- if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
993
+ if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default> [--dir <deck folder>]");
968
994
  const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
969
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
995
+ const log = opts.json ? (line) => console.error(line) : (line) => console.log(line);
996
+ log(`OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
997
+ // The local file names the face too, so the draft opened from disk shows it.
998
+ const { writeDraftFace } = await import("./draftFace.mjs");
999
+ const dir = resolve(typeof opts.dir === "string" ? opts.dir : join("decks", out.slug));
1000
+ let local = { wrote: false };
1001
+ try {
1002
+ local = await writeDraftFace(opts, out.slug, join(dir, "index.html"), { log });
1003
+ } catch (e) {
1004
+ log(`⚠ presenter: the server was updated but index.html was not (${e.message})`);
1005
+ }
1006
+ if (opts.json) console.log(JSON.stringify({ ...out, indexHtml: local.wrote ? join(dir, "index.html") : null }, null, 2));
970
1007
  },
971
1008
  "preview setup": async () => {
972
1009
  const { setupCapture } = await import("./preview-capture.mjs");
@@ -978,6 +1015,7 @@ function commandTable(pos, rest, opts) {
978
1015
  "deck list": () => deckList(opts),
979
1016
  "deck stats": () => deckStats(rest, opts),
980
1017
  "deck open": () => deckOpen(rest, opts),
1018
+ "deck link": () => deckLink(rest, opts),
981
1019
  "deck rm": () => deckRemove(rest, opts),
982
1020
  "deck forward": () => deckForward(rest, opts),
983
1021
  "deck view": () => deckView(rest, opts),
@@ -0,0 +1,56 @@
1
+ // The narrator's face written into a draft's index.html on disk (roadmap:
2
+ // a-narrated-draft-shows-its-narrator-s-face-from-the-file-on-), so the file
3
+ // plays with its face however it is opened, not only through play and release.
4
+ // deck new, deck narrator and narrate use it.
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import * as ops from "./ops.mjs";
7
+ import { DASH_PRESENTER, withNarrator, writeNarratorFace } from "./presenter.mjs";
8
+
9
+ // What to write for `slug`: { face } (null: captions only on purpose), or
10
+ // { face: undefined, why } when the narrator has no face and nobody chose captions
11
+ // only (writeDraftFace then takes a CLI-written face off), or the lookup failed
12
+ // (`failed`: nothing is decided, the file stays).
13
+ export async function draftFace(opts, slug) {
14
+ let found;
15
+ try {
16
+ found = await ops.presenterWithReason(opts, slug, undefined, { draft: true });
17
+ } catch (e) {
18
+ return { face: undefined, why: `could not look up the narrator's face (${e.message})`, failed: true };
19
+ }
20
+ if (found?.presenter) return { face: found.presenter, name: found.who?.face ?? null };
21
+ // Captions only on purpose only when the deck says so; faces off or a missing face stays unwritten.
22
+ if (!found?.presenter && found?.captionsOnly) return { face: null, name: null };
23
+ return { face: undefined, why: found?.who?.reason ?? "no narrator with a face" };
24
+ }
25
+
26
+ // Write `slug`'s face into `htmlPath`. `fallback`: the face to write when the
27
+ // lookup fails (deck new writes D.A.S.H.). Logs one line; returns what it wrote.
28
+ export async function writeDraftFace(opts, slug, htmlPath, { log = () => {}, fallback } = {}) {
29
+ if (!existsSync(htmlPath)) {
30
+ log(`⚠ presenter: ${htmlPath} not found, so the file was not updated (the server was); pass --dir <deck folder>`);
31
+ return { wrote: false };
32
+ }
33
+ const got = await draftFace(opts, slug);
34
+ const face = got.face === undefined && got.failed && fallback ? fallback : got.face;
35
+ const html = readFileSync(htmlPath, "utf8");
36
+ if (face === undefined) {
37
+ // The narrator has no face now: a face the CLI wrote for an earlier one comes off, as play and release do.
38
+ const off = got.failed ? html : withNarrator(html, null, { face: null });
39
+ if (off !== html) {
40
+ writeFileSync(htmlPath, off);
41
+ log(`⚠ presenter: no face (${got.why}); the earlier narrator's face was taken off index.html`);
42
+ return { wrote: true, face: null, why: got.why };
43
+ }
44
+ log(`⚠ presenter: index.html left as written: ${got.why}`);
45
+ return { wrote: false, why: got.why };
46
+ }
47
+ const out = writeNarratorFace(html, face);
48
+ if (!out.wrote) {
49
+ log(`⚠ presenter: index.html left as written: ${out.why}`);
50
+ return { wrote: false, why: out.why };
51
+ }
52
+ if (out.html !== html) writeFileSync(htmlPath, out.html);
53
+ const who = face === null ? "captions only" : face === DASH_PRESENTER && got.failed ? `D.A.S.H. (${got.why}; deck narrator <name> fixes it)` : got.name ?? "the narrator's face";
54
+ log(` presenter: ${who}, written to index.html`);
55
+ return { wrote: true, face };
56
+ }
@@ -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/narrate.mjs CHANGED
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { basename, join, resolve } from "node:path";
10
10
  import * as ops from "./ops.mjs";
11
11
  import { presenterLine } from "./presenter.mjs";
12
+ import { writeDraftFace } from "./draftFace.mjs";
12
13
  export { sectionIds, setManifestUrl } from "./checkRules.mjs";
13
14
  import { sectionIds, setManifestUrl } from "./checkRules.mjs";
14
15
 
@@ -39,7 +40,8 @@ export function narrationRequest(pos, opts) {
39
40
  };
40
41
  }
41
42
 
42
- export async function narrate(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
43
+ // writeFace false: leave the presenter as written (release narrates, then puts the face on the upload itself).
44
+ export async function narrate(pos, opts = {}, { log = (line) => console.log(line), writeFace = true } = {}) {
43
45
  const { deckDir, fields } = narrationRequest(pos, opts);
44
46
  if (opts["dry-run"]) {
45
47
  const preview = await ops.previewNarration(opts, fields);
@@ -85,6 +87,9 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
85
87
  writeFileSync(htmlPath, next);
86
88
  log(` index.html: data-manifest → ${job.manifestUrl}`);
87
89
  }
90
+ // The narrator's face too, so the file opened as written shows what play and release show.
91
+ // The narration is paid for and saved: a failed face write warns and never fails narrate.
92
+ if (writeFace) await writeDraftFace(opts, fields.slug, htmlPath, { log }).catch((e) => log(`⚠ presenter: index.html not updated (${e?.message ?? e})`));
88
93
  }
89
94
  log(`✓ ${job.manifest.clips.length} clips, ${job.manifest.spokenDuration}s spoken, ${job.manifest.totalDuration}s with holds; ${job.renderedChars} characters rendered`);
90
95
  log(` manifest: ${job.manifestUrl}\n saved: ${join(out, "manifest.json")}`);