@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/README.md +1 -0
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +94 -28
- package/skills/hyperdecks/chat.md +13 -2
- package/src/avatar.mjs +13 -2
- package/src/brand.mjs +38 -5
- package/src/checkRules.mjs +79 -2
- package/src/commands.mjs +53 -15
- package/src/draftFace.mjs +56 -0
- package/src/export-video.mjs +15 -10
- package/src/mediaPreviewMake.mjs +72 -0
- package/src/mediaPreviews.mjs +160 -0
- package/src/narrate.mjs +6 -1
- package/src/ops.mjs +70 -12
- package/src/play.mjs +2 -2
- package/src/presenter.mjs +73 -9
- package/src/release.mjs +83 -11
- package/src/runtime.mjs +1 -1
- package/src/scaffold.mjs +27 -6
- package/src/source.mjs +2 -2
- package/templates/layouts/_deck.html +71 -32
- package/templates/layouts/closer.html +1 -1
- package/templates/layouts/phones.html +5 -5
- package/templates/layouts/split.html +2 -2
- package/templates/narrated-deck.html +3 -1
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
|
-
"
|
|
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.
|
|
630
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
1163
|
-
|
|
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")
|
|
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,
|
|
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 =
|
|
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
|
|
7
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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);
|