@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/ops.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer.mjs";
2
2
  import { zipSync } from "fflate";
3
+ import { SPECIES } from "./species.mjs";
3
4
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
4
5
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
5
- import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
+ import { api, backupLinkUrl, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
7
  import {
7
8
  bytesFromText,
8
9
  isTextAsset,
@@ -20,6 +21,17 @@ export async function whoami(opts = {}) {
20
21
  }
21
22
 
22
23
  // Your own decks. `all: true` lists every deck, for admins only (support tools).
24
+ // The owner link (/d/<slug>, app/d/[slug]): the released deck for its owner
25
+ // only, untracked, with the owner toolbar (Present hides it for a screen
26
+ // share). How an owner or their agent presents or checks a deck; anyone else
27
+ // gets a sign-in notice. QA links are only for testing what a recipient sees.
28
+ // `backupUrl`: the same link on the other app host (null in dev).
29
+ export function ownerLink(opts, slug) {
30
+ if (!slug) throw new Error("slug is required");
31
+ const url = `${target(opts).brokerBase}/d/${encodeURIComponent(slug)}`;
32
+ return { slug, ownerUrl: url, backupUrl: backupLinkUrl(url) };
33
+ }
34
+
23
35
  export async function listDecks(opts = {}, { all = false } = {}) {
24
36
  const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
25
37
  return rows.map((d) => ({
@@ -33,9 +45,14 @@ export async function listDecks(opts = {}, { all = false } = {}) {
33
45
  viewToggle: d.viewToggle !== false,
34
46
  // How the player moves on: auto, slide or step (null: as its HTML says, else auto).
35
47
  pace: d.pace ?? null,
48
+ // Whether each link's AI-readable version (/v/<token>/md) serves (on unless set off).
49
+ aiReadable: d.aiReadable !== false,
36
50
  // false: the deck runs its own player, so view/viewToggle do not apply
37
51
  // (null: not known yet, treated as on).
38
52
  usesPlayer: d.usesPlayer ?? null,
53
+ // Present or check it yourself here (owner-only, untracked); not a link to
54
+ // send. Null with nothing released, or on someone else's deck (admins' all).
55
+ ownerUrl: !all && (d.html || d.htmlStorageId || d.hasHtml) ? ownerLink(opts, d.slug).ownerUrl : null,
39
56
  }));
40
57
  }
41
58
 
@@ -53,7 +70,11 @@ export async function activityOverview(opts = {}) {
53
70
  // looked up as a token; anything else is a recipient name.
54
71
  export const RECIPIENT_ENGAGEMENT_DESCRIPTION =
55
72
  "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 " +
73
+ "the slides they replayed on purpose and how often (summary.slideReplays), whether they watched it again " +
74
+ "from the start (summary.watchedAgain), a one-line read of both (summary.replayNote), " +
75
+ "where they dropped off, time per slide, each visit, and forwards. Each link's aiReads lists the AI " +
76
+ "assistants (ChatGPT, Claude, ...) that read its text version (/v/<token>/md), separately: those are " +
77
+ "not views. Use it to draft a follow-up: lead " +
57
78
  "with what they spent time on, and never tell the recipient they were tracked.";
58
79
  export const SHARE_TOKEN = /^[a-f0-9]{16}$/;
59
80
  export async function recipientEngagement(opts, who, deck) {
@@ -561,6 +582,8 @@ export async function listShares(opts, slug) {
561
582
  // viewer who forwarded it. Rows come back with each link's forwards after it.
562
583
  allowForward: !!r.allowForward,
563
584
  allowForwardOverride: r.allowForwardOverride ?? null,
585
+ // The link's own pace (auto, slide, step); null = it follows the deck.
586
+ pace: r.pace ?? null,
564
587
  parentToken: r.parentToken ?? null,
565
588
  forwardedBy: r.forwardedBy ?? null,
566
589
  probablyForwarded: !!r.probablyForwarded,
@@ -577,6 +600,11 @@ export async function createShare(opts, fields) {
577
600
  if (fields.note) args.note = fields.note;
578
601
  if (fields.requireEmail) args.requireEmail = true;
579
602
  if (fields.allowForward !== undefined) args.allowForward = !!fields.allowForward;
603
+ // The link's own pace; absent = it follows the deck's.
604
+ if (fields.pace !== undefined) {
605
+ if (!DECK_PACES.includes(fields.pace)) throw new Error(`pace must be auto, slide or step (got ${JSON.stringify(fields.pace)})`);
606
+ args.pace = fields.pace;
607
+ }
580
608
  if (fields.expires) {
581
609
  const t = Date.parse(fields.expires);
582
610
  if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
@@ -626,14 +654,16 @@ export async function setDeckForwarding(opts, slug, allowForward) {
626
654
  // "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
627
655
  // true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
628
656
  // 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.
657
+ // own HTML. aiReadable true/false: whether each link's AI-readable version
658
+ // (/v/<token>/md) serves (on by default; off is a 404 there). Any may be left
659
+ // out; all go in one owner-only mutation, so a failure changes none.
631
660
  // Applies on every link's next load (the route injects it), with no re-release.
632
661
  export const DECK_VIEWS = ["slides", "scroll"];
633
662
  export const DECK_PACES = ["auto", "slide", "step"];
634
- export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
663
+ export async function setDeckView(opts, slug, { view, viewToggle, pace, aiReadable } = {}) {
635
664
  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)");
665
+ const player = view !== undefined || viewToggle !== undefined || pace !== undefined;
666
+ 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
667
  if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
638
668
  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
669
  const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
@@ -641,14 +671,17 @@ export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
641
671
  ...(view !== undefined ? { view } : {}),
642
672
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
643
673
  ...(pace !== undefined ? { pace } : {}),
674
+ ...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
644
675
  });
645
- // Saved either way; a deck on its own player says the setting won't apply.
646
- const applies = res?.applies !== false;
676
+ // Saved either way; a deck on its own player says the setting won't apply
677
+ // (aiReadable alone always applies: /md doesn't need the player).
678
+ const applies = !player || res?.applies !== false;
647
679
  return {
648
680
  slug,
649
681
  ...(view !== undefined ? { view } : {}),
650
682
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
651
683
  ...(pace !== undefined ? { pace } : {}),
684
+ ...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
652
685
  applies,
653
686
  ...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
654
687
  };
@@ -1114,6 +1147,22 @@ export async function addPickedAvatar(opts, avatarKey) {
1114
1147
  return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
1115
1148
  }
1116
1149
 
1150
+ // A drawing that failed on a pet photo (no human face, but Minimoji saw a cat,
1151
+ // a dog…): the offer to make that species' avatar instead, or null. The
1152
+ // person still picks the face (new_avatar_link with the species).
1153
+ export function petOffer(draft) {
1154
+ const s = SPECIES.find((x) => x.id === draft?.suggestedSpecies && x.id !== "human");
1155
+ if (!s) return null;
1156
+ const word = s.label.toLowerCase();
1157
+ return {
1158
+ status: "failed",
1159
+ error: draft.error ?? null,
1160
+ suggestedSpecies: s.id,
1161
+ offer: `That looks like a ${word}. Make a ${word} avatar?`,
1162
+ 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}\``,
1163
+ };
1164
+ }
1165
+
1117
1166
  // A face from a photo. With bytes: upload and draw it, then hand back the link
1118
1167
  // where the person approves it (nothing is used before). Without: the link
1119
1168
  // where the person takes a selfie in their browser.
@@ -1129,7 +1178,11 @@ export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onPro
1129
1178
  onProgress?.("drawing…");
1130
1179
  const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
1131
1180
  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");
1181
+ if (draft.status === "failed") {
1182
+ const offer = petOffer(draft);
1183
+ if (offer) return { ...offer, draftId };
1184
+ throw new Error(draft.error || "the drawing failed; try another photo");
1185
+ }
1133
1186
  const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
1134
1187
  return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
1135
1188
  }
@@ -1159,8 +1212,9 @@ export async function presenterFor(opts, slug, voice) {
1159
1212
  }
1160
1213
 
1161
1214
  // The same answer as presenterFor plus who presents in words: { presenter, who }.
1162
- export async function presenterWithReason(opts, slug, voice) {
1163
- return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}) });
1215
+ // `draft`: also answer for a deck not narrated yet, from the voice its narrate would use.
1216
+ export async function presenterWithReason(opts, slug, voice, { draft = false } = {}) {
1217
+ return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}), ...(draft ? { draft: true } : {}) });
1164
1218
  }
1165
1219
 
1166
1220
  // ---- narrators (convex/narrators.ts) ------------------------------------------
@@ -1287,7 +1341,11 @@ export async function avatarDraftStatus(opts, draftId) {
1287
1341
  const draft = await c.query(api.avatars.getDraft, { draftId: String(draftId) });
1288
1342
  if (!draft) throw new Error("the drawing is gone; start again with avatar_from_photo");
1289
1343
  if (draft.status === "drawing") return { status: "drawing", draftId };
1290
- if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
1344
+ if (draft.status === "failed") {
1345
+ const offer = petOffer(draft);
1346
+ if (offer) return { ...offer, draftId };
1347
+ throw new Error(draft.error || "the drawing failed; try another photo");
1348
+ }
1291
1349
  if (draft.status === "added") return { status: "added", draftId, avatarId: draft.avatarId ?? null };
1292
1350
  if (draft.status !== "drawn") throw new Error(`the drawing is in an unexpected state (${draft.status}); start again with avatar_from_photo`);
1293
1351
  const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
package/src/play.mjs CHANGED
@@ -12,7 +12,7 @@ import { basename, join, relative } from "node:path";
12
12
  import { playerScript, sectionIds } from "./checkRules.mjs";
13
13
  import { resolveDeckDir } from "./check.mjs";
14
14
  import * as ops from "./ops.mjs";
15
- import { playsCaptionsOnly, presenterLine, withAvatar } from "./presenter.mjs";
15
+ import { playsCaptionsOnly, presenterLine, withNarrator } from "./presenter.mjs";
16
16
  import { serveSource } from "./preview-capture.mjs";
17
17
  import { openBrowser } from "./runtime.mjs";
18
18
 
@@ -88,7 +88,7 @@ export async function startPlay(pos, opts = {}, { log = (line) => console.log(li
88
88
  const html = readFileSync(file, "utf8");
89
89
 
90
90
  const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
91
- const page = presenter ? withAvatar(html, presenter) : html;
91
+ const page = withNarrator(html, presenter, who);
92
92
  sayWhoPresents({ html, page, presenter, who, log });
93
93
  // --voice names the voice it was narrated in, so there is nothing to compare it to.
94
94
  if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
package/src/presenter.mjs CHANGED
@@ -3,8 +3,10 @@
3
3
  // captions-only (data-presenter="none"); at release, when the owner has an
4
4
  // avatar, its player tag gets that face instead. Shared by both releases
5
5
  // (convex/deckSources.ts, release.mjs), `hyperdecks play` and the /p draft
6
- // preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
7
- // on a character-voice deck, or one set by hand) is left alone.
6
+ // preview, so all rewrite the tag the same way; only a release stores it. A tag
7
+ // that already names a presenter by hand is left alone. deck new, deck narrator
8
+ // and narrate write the narrator's face into the file itself (writeNarratorFace),
9
+ // marked, so a draft on disk shows its face and release still follows the narrator.
8
10
 
9
11
  // D.A.S.H.'s face: the head doc and still that ship with the player. A deck
10
12
  // narrated in its character voice gets this face at release, even when the
@@ -59,36 +61,98 @@ export function playsCaptionsOnly(html) {
59
61
  return !!found && !tagFace(found.tag);
60
62
  }
61
63
 
64
+ // Who wrote the tag's face: data-presenter-from="narrator" is a face the CLI
65
+ // (deck new, deck narrator, narrate) wrote from the deck's narrator,
66
+ // so it follows the narrator at release and in play; "captions-only" is captions
67
+ // only on purpose (deck new --captions-only, deck narrator none). No marker: the
68
+ // tag is as its author wrote it, and a face there is hand-set. The player ignores it.
69
+ export const PRESENTER_FROM = "data-presenter-from";
70
+ const FACE_ATTRS = ["data-presenter", "data-avatar", "data-presenter-image", PRESENTER_FROM];
71
+ const attrRe = (name) => new RegExp(`\\s${name}\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s"'=<>\`]+)`);
72
+
73
+ // The tag with its face attributes replaced by `face` (an ordered [name, value] list), in place of
74
+ // the first one it had (else before the closing >), leaving every other attribute as written.
75
+ function setFace(tag, face) {
76
+ const at = FACE_ATTRS.map((n) => attrRe(n).exec(tag)).filter(Boolean).sort((a, b) => a.index - b.index)[0];
77
+ let next = tag;
78
+ for (const n of FACE_ATTRS) next = next.replace(attrRe(n), at && attrRe(n).exec(tag)?.index === at.index ? "\u0000" : "");
79
+ const text = face.map(([n, v]) => ` ${n}="${attr(v)}"`).join("");
80
+ return next.includes("\u0000") ? next.replace("\u0000", text) : next.replace(/\s*\/?>$/, (end) => `${text}${end}`);
81
+ }
82
+
83
+ const replaceTag = (html, found, tag) => (tag === found.tag ? html : html.slice(0, found.index) + tag + html.slice(found.index + found.tag.length));
84
+
62
85
  // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
63
86
  // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
64
87
  // html unchanged when there is no such tag, the tag already shows a face, or it
65
88
  // names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
66
89
  // Anything else the player would play captions only (data-presenter none or
67
- // missing, head with no data-avatar, static with no still) gets the face.
90
+ // missing, head with no data-avatar, static with no still) gets the face. A face
91
+ // the CLI wrote from the narrator (data-presenter-from="narrator") is replaced by
92
+ // this one, so a narrator changed since follows; captions only on purpose stays.
68
93
  export function withAvatar(html, { avatarUrl, imageUrl }) {
69
94
  const found = playerTag(html);
70
95
  if (!found) return html;
71
- const { tag, index } = found;
96
+ const { tag } = found;
97
+ const from = value(tag, PRESENTER_FROM);
98
+ if (from === "captions-only") return html;
99
+ if (from === "narrator") return replaceTag(html, found, setFace(tag, narratorFace({ avatarUrl, imageUrl })));
72
100
  if (tagFace(tag) || value(tag, "data-avatar")) return html;
73
101
  const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
74
102
  const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
75
- const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
103
+ const named = attrRe("data-presenter");
76
104
  const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
77
- return html.slice(0, index) + next + html.slice(index + tag.length);
105
+ return replaceTag(html, found, next);
106
+ }
107
+
108
+ // The page as its narrator presents it, for play, /p and both releases: the
109
+ // resolved face put on (withAvatar), or, when the narration is on record and
110
+ // resolves to no face (`who.face` null: narrator none, faces off, a face
111
+ // removed), a face the CLI wrote from an earlier narrator taken off again, so
112
+ // it never shows someone who no longer presents. A hand-set face is kept.
113
+ export function withNarrator(html, presenter, who) {
114
+ if (presenter) return withAvatar(html, presenter);
115
+ const found = playerTag(html);
116
+ if (!who || who.face || !found || value(found.tag, PRESENTER_FROM) !== "narrator") return html;
117
+ return replaceTag(html, found, setFace(found.tag, [["data-presenter", "none"], [PRESENTER_FROM, "narrator"]]));
118
+ }
119
+
120
+ const narratorFace = ({ avatarUrl, imageUrl }) => [
121
+ ["data-presenter", "head"], ["data-avatar", avatarUrl], ...(imageUrl ? [["data-presenter-image", imageUrl]] : []), [PRESENTER_FROM, "narrator"],
122
+ ];
123
+
124
+ // Write the narrator's face into the page as a file on disk plays it: `face`
125
+ // ({ avatarUrl, imageUrl }) as a marked head, or null for captions only on
126
+ // purpose. For deck new, deck narrator and narrate, so a draft opened with any
127
+ // static server shows what play and release show. A face set by hand (a face or
128
+ // data-avatar with no marker) is left alone. Returns { html, wrote }, and with
129
+ // wrote false a `why`.
130
+ export function writeNarratorFace(html, face) {
131
+ const found = playerTag(html);
132
+ if (!found) return { html, wrote: false, why: "the page has no live player tag" };
133
+ const { tag } = found;
134
+ if (!value(tag, PRESENTER_FROM) && (tagFace(tag) || value(tag, "data-avatar"))) {
135
+ return { html, wrote: false, why: "the player tag names a face of its own (set by hand), so it is left as written" };
136
+ }
137
+ const next = setFace(tag, face ? narratorFace(face) : [["data-presenter", "none"], [PRESENTER_FROM, "captions-only"]]);
138
+ return { html: replaceTag(html, found, next), wrote: true };
78
139
  }
79
140
 
80
141
  // What is wrong with the player tag's presenter as written, for check: null, or
81
142
  // { kind, presenter }. "unfilled": it asks for a face (head or static) but names
82
143
  // none, so it plays captions only until release fills it in. "kept": it has a
83
144
  // data-avatar the player ignores (data-presenter="none"), and release leaves it.
84
- // A plain "none" or no data-presenter is not an issue: release adds the face.
145
+ // "captions": data-presenter none or missing, so a file opened as written (a
146
+ // static server, the screenshot pass) plays captions only though release adds a
147
+ // face. Captions only on purpose (data-presenter-from="captions-only") is not an issue.
85
148
  export function presenterTagIssue(html) {
86
149
  const found = playerTag(html);
87
150
  if (!found || tagFace(found.tag)) return null;
88
151
  const presenter = value(found.tag, "data-presenter");
89
152
  if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
90
153
  if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
91
- return null;
154
+ if (value(found.tag, PRESENTER_FROM) === "captions-only") return null;
155
+ return { kind: "captions", presenter };
92
156
  }
93
157
 
94
158
  // Why a resolved face did not land on the page: withAvatar leaves a tag with
@@ -109,7 +173,7 @@ function keptOutReason(html) {
109
173
  // Both releases use it: release.mjs and convex/deckSources.ts.
110
174
  export const TAG_FACE = "the face set on the player tag";
111
175
  export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
112
- const page = presenter ? withAvatar(html, presenter) : html;
176
+ const page = withNarrator(html, presenter, who);
113
177
  const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
114
178
  const shown = presentedFace(page);
115
179
  if (shown) {
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
- import { releasedPresenter, withAvatar } from "./presenter.mjs";
20
+ import { releasedPresenter, withNarrator } 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}$/;
@@ -53,20 +56,81 @@ export function deckCommit(deckDir) {
53
56
  // turn (deckFiles). `added` maps each bundled script or stylesheet to the files
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
- // uploaded page's player tag; the committed index.html is never changed.
57
- export function releaseBundle(deckDir, presenter = null) {
59
+ // uploaded page's player tag; the committed index.html is never changed. `who`
60
+ // (narrate's presenter summary) takes a CLI-written face off when nobody presents now.
61
+ // `media` (from mediaPreviewsFor) writes the blur previews onto the uploaded
62
+ // page and adds the posters it made; like the avatar, never to the commit.
63
+ export function releaseBundle(deckDir, presenter = null, media = null, who = null) {
58
64
  const raw = readFileSync(join(deckDir, "index.html"));
59
- const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
65
+ const page = withNarrator(raw.toString("utf8"), presenter, who);
60
66
  // 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");
67
+ const found = deckFiles(page, deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
68
+ const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) : page !== raw.toString("utf8") ? Buffer.from(page) : raw;
69
+ const made = Object.entries(media?.files ?? {});
62
70
  const added = {};
63
71
  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 };
72
+ if (!found.length && !made.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
65
73
  const files = { "index.html": html };
66
74
  for (const f of found) files[f.path] = readFileSync(join(deckDir, f.path));
75
+ for (const [path, bytes] of made) files[path] = bytes;
67
76
  return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files), added };
68
77
  }
69
78
 
79
+ // Blur previews (and posters for videos without one) for the media the page
80
+ // shows: local files inside the deck folder and https URLs, read by ffmpeg.
81
+ // Returns { previews, files, failed }: previews keyed by src as written, the
82
+ // poster files to bundle (hd-media/<hash>.jpg), and what could not be read. A
83
+ // preview is an extra, never a reason to stop a release.
84
+ export async function mediaPreviewsFor(deckDir, html, { make = makePreview, ffmpeg } = {}) {
85
+ const { images, videos } = mediaRefs(html);
86
+ const previews = {}, files = {}, failed = [];
87
+ if (!images.length && !videos.length) return { previews, files, failed };
88
+ let bin = ffmpeg;
89
+ try {
90
+ bin ??= await bundledFfmpeg();
91
+ } catch (e) {
92
+ return { previews, files, failed: [...images, ...videos].map((src) => ({ src, why: `no ffmpeg (${e.message})` })) };
93
+ }
94
+ const inside = deckPathResolver(deckDir);
95
+ const inputOf = (src) => {
96
+ if (/^https:\/\//i.test(src)) return src;
97
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(src)) return null; // http:, file:, protocol-relative: not read
98
+ let path;
99
+ try {
100
+ path = decodeURIComponent(src.split(/[?#]/)[0]).replace(/^\.\//, "");
101
+ } catch {
102
+ return null;
103
+ }
104
+ return inside(path) === true ? join(deckDir, path) : null;
105
+ };
106
+ const jobs = [...images.map((src) => ({ src, video: false })), ...videos.map((src) => ({ src, video: true }))];
107
+ const work = async ({ src, video }) => {
108
+ const input = inputOf(src);
109
+ if (!input) return failed.push({ src, why: "not a file in the deck folder or an https URL" });
110
+ try {
111
+ // A URL input may use https (the user's own machine reads it); a file stays local-only.
112
+ const p = await make(bin, input, { video, ...(input === src ? { allow: HTTPS_TOO } : {}) });
113
+ if (p.poster) {
114
+ const path = `hd-media/${createHash("sha256").update(src).digest("hex").slice(0, 16)}.jpg`;
115
+ files[path] = p.poster;
116
+ p.poster = path;
117
+ }
118
+ previews[src] = p;
119
+ } catch (e) {
120
+ failed.push({ src, why: e.message });
121
+ }
122
+ };
123
+ // A few at a time: each is an ffmpeg process.
124
+ for (let i = 0; i < jobs.length; i += 4) await Promise.all(jobs.slice(i, i + 4).map(work));
125
+ return { previews, files, failed };
126
+ }
127
+
128
+ function logPreviews(log, media) {
129
+ const n = Object.keys(media.previews).length;
130
+ if (n) log(`✓ blur previews for ${n} image(s)/video(s)${Object.keys(media.files).length ? `, ${Object.keys(media.files).length} poster(s) made` : ""}`);
131
+ for (const f of media.failed) log(` ⚠ no blur preview for ${f.src}: ${f.why}`);
132
+ }
133
+
70
134
  // From the CLI, parse() collects opts.recipients in order (each --email follows
71
135
  // its --to); MCP passes the same shape.
72
136
  export function recipients(opts) {
@@ -77,7 +141,7 @@ export function recipients(opts) {
77
141
  }
78
142
 
79
143
  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;
144
+ const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, previews = mediaPreviewsFor, fetch, cwd, measure, measureFit } = io;
81
145
  const deckDir = resolveDeckDir(pos[0], cwd);
82
146
  const to = recipients(opts);
83
147
 
@@ -97,13 +161,21 @@ export async function release(pos, opts = {}, io = {}) {
97
161
  for (const w of checked.warnings) log(` ⚠ ${w}`);
98
162
  for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
99
163
 
100
- if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: bundle, summary: null });
164
+ let media;
165
+ try {
166
+ media = await previews(deckDir, readFileSync(join(deckDir, "index.html"), "utf8"));
167
+ logPreviews(log, media);
168
+ } catch (e) {
169
+ media = null; // a preview is an extra: the release goes on without them
170
+ log(` ⚠ no blur previews: ${e.message}`);
171
+ }
172
+ if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: releaseBundle(deckDir, null, media), summary: null });
101
173
  // Cached slides cost nothing, so this only renders what changed since the commit.
102
174
  // Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
103
175
  const narrateOpts = { ...opts, slug };
104
176
  for (const k of ["slide", "json", "dry-run", "to", "email", "recipients"]) delete narrateOpts[k];
105
177
  if (narrateOpts.voice === undefined) delete narrateOpts.voice;
106
- const job = await narrate([deckDir], narrateOpts, { log });
178
+ const job = await narrate([deckDir], narrateOpts, { log, writeFace: false });
107
179
  if (!job?.manifestUrl) throw new Error(`narration finished without a manifest (${job?.notice || "no manifest URL"}); fix it and release again`);
108
180
  const after = changes(deckDir);
109
181
  if (after) {
@@ -118,7 +190,7 @@ export async function release(pos, opts = {}, io = {}) {
118
190
  } catch (e) {
119
191
  lookupError = ops.errorText(e);
120
192
  }
121
- const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
193
+ const released = releaseBundle(deckDir, presenter, media, job.presenter);
122
194
  // Say who presents from the page that ships, never from the lookup alone: a
123
195
  // resolved face that did not land on the player tag must not be claimed, and
124
196
  // 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.10";
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/scaffold.mjs CHANGED
@@ -1,21 +1,25 @@
1
- // hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
1
+ // hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--captions-only] [--dir <path>]
2
2
  //
3
3
  // Writes decks/<slug>/ (or --dir) from the packaged templates: index.html (eight
4
4
  // slides for the hosted player/v1, or one slide per --layouts entry from
5
5
  // templates/layouts/), script.md (the narration) and evidence.md (where each
6
6
  // fact comes from), then registers the deck as a draft you own so narrate can
7
- // render into it.
8
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
+ // render into it. The player tag gets the narrator's face (the account's default
8
+ // narrator, else D.A.S.H.), so the file plays with a face however it is opened;
9
+ // --captions-only writes data-presenter="none" on purpose instead.
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
11
  import { join, resolve } from "node:path";
10
12
  import * as ops from "./ops.mjs";
11
13
  import { PRESENTER_STYLES } from "./checkRules.mjs";
12
14
  import { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts } from "./layouts.mjs";
13
15
  import { applyBrand, brandForNewDeck, brandSummary } from "./brand.mjs";
16
+ import { writeDraftFace } from "./draftFace.mjs";
17
+ import { DASH_PRESENTER, writeNarratorFace } from "./presenter.mjs";
14
18
 
15
19
  export { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts, readLayout } from "./layouts.mjs";
16
20
 
17
21
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
18
- const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--no-brand] [--dir <path>]';
22
+ const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--captions-only] [--no-brand] [--dir <path>]';
19
23
 
20
24
  export async function deckNew(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
21
25
  const [slug, title] = pos;
@@ -46,7 +50,24 @@ export async function deckNew(pos, opts = {}, { log = (line) => console.log(line
46
50
  rmSync(dir, { recursive: true, force: true });
47
51
  throw new Error(`deck ${slug} is registered but its files could not be written (${e.message}); remove the draft with \`hyperdecks deck rm ${slug}\` and run deck new again`);
48
52
  }
53
+ // The face goes into the file now the deck is registered (the server answers whose).
54
+ const htmlPath = join(/*turbopackIgnore: true*/ dir, "index.html");
55
+ const lines = [];
56
+ let presenter;
57
+ if (opts["captions-only"]) {
58
+ writeFileSync(htmlPath, writeNarratorFace(readFileSync(htmlPath, "utf8"), null).html);
59
+ // The server too, so release, play and /p agree the deck has no face.
60
+ try {
61
+ await ops.setDeckNarrator(opts, slug, "none");
62
+ lines.push(" presenter: captions only (--captions-only), written to index.html and set on the deck");
63
+ } catch (e) {
64
+ lines.push(`⚠ presenter: captions only written to index.html, but the deck could not be set to none (${e.message}): run \`hyperdecks deck narrator ${slug} none\``);
65
+ }
66
+ presenter = { face: null };
67
+ } else {
68
+ presenter = await writeDraftFace(opts, slug, htmlPath, { log: (line) => lines.push(line), fallback: DASH_PRESENTER });
69
+ }
49
70
  const summary = brandSummary(brand, readError);
50
- log(`✓ created deck "${slug}" → ${dir}\n files: ${Object.keys(NARRATED_FILES).join(", ")}\n brand: ${summary.error ? `could not read it (${summary.error}); template look` : summary.applied.length ? summary.applied.join(", ") : "none applied (hyperdecks brand set)"}\n next: hyperdecks narrate ${dir} --dry-run`);
51
- return { slug, title, dir, files: Object.keys(NARRATED_FILES), brand: summary };
71
+ log(`✓ created deck "${slug}" → ${dir}\n files: ${Object.keys(NARRATED_FILES).join(", ")}\n brand: ${summary.error ? `could not read it (${summary.error}); template look` : summary.applied.length ? summary.applied.join(", ") : "none applied (hyperdecks brand set)"}\n${lines.map((l) => (l.startsWith(" ") ? l : ` ${l}`)).join("\n")}\n next: hyperdecks narrate ${dir} --dry-run`);
72
+ return { slug, title, dir, files: Object.keys(NARRATED_FILES), brand: summary, presenter: presenter.face ?? null };
52
73
  }
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);