@hypersocial/decks-cli 0.9.6 → 0.9.8
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 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +104 -17
- package/skills/hyperdecks/chat.md +10 -7
- package/src/checkRules.mjs +107 -0
- package/src/commands.mjs +42 -25
- package/src/files.mjs +1 -0
- package/src/guide.mjs +2 -2
- package/src/init.mjs +8 -4
- package/src/layouts.mjs +3 -3
- package/src/loadingBlock.mjs +43 -12
- package/src/ops.mjs +46 -15
- package/src/presenter.mjs +102 -16
- package/src/preview.mjs +2 -2
- package/src/release.mjs +11 -7
- package/src/runtime.mjs +62 -2
- package/templates/layouts/_deck.html +51 -0
- package/templates/layouts/chart.html +5 -0
- package/templates/layouts/closer.html +5 -0
- package/templates/layouts/compare.html +5 -0
- package/templates/layouts/features.html +6 -0
- package/templates/layouts/hero.html +5 -0
- package/templates/layouts/number.html +5 -0
- package/templates/layouts/phones.html +5 -0
- package/templates/layouts/product.html +5 -0
- package/templates/layouts/quote.html +6 -0
- package/templates/layouts/split.html +53 -0
- package/templates/layouts/team.html +6 -0
- package/templates/layouts/timeline.html +5 -0
package/src/init.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import * as ops from "./ops.mjs";
|
|
19
19
|
import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
|
|
20
|
-
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
20
|
+
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, linkBase, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
21
21
|
import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
|
|
22
22
|
import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
|
|
23
23
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
@@ -450,9 +450,13 @@ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars,
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// decks:get answers only for decks the caller owns, so whether the slug is
|
|
454
|
+
// taken at all comes from slugTaken.
|
|
453
455
|
async function deckOwnedBy(base, slug, who) {
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
+
const c = await client(base);
|
|
457
|
+
if (!(await c.query(api.decks.slugTaken, { slug }))) return { exists: false, mine: false };
|
|
458
|
+
const deck = await c.query(api.decks.get, { slug });
|
|
459
|
+
return { exists: true, mine: !!deck && deck.ownerId === who.subject };
|
|
456
460
|
}
|
|
457
461
|
|
|
458
462
|
async function scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save }) {
|
|
@@ -544,7 +548,7 @@ async function releaseStep(base, opts, state, { release, sub, save, fetch }) {
|
|
|
544
548
|
// A run killed after release made its link but before init.json recorded it.
|
|
545
549
|
const mine = !state.releasedAt && shares.find((s) => s.qa && s.recipient === "Me" && s.enabled);
|
|
546
550
|
if (mine) {
|
|
547
|
-
const url = `${
|
|
551
|
+
const url = `${await linkBase(base)}/v/${mine.token}`;
|
|
548
552
|
save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: mine.token });
|
|
549
553
|
return step("release", "done", `released: ${url}`);
|
|
550
554
|
}
|
package/src/layouts.mjs
CHANGED
|
@@ -5,8 +5,8 @@ import { packageText } from "./files.mjs";
|
|
|
5
5
|
|
|
6
6
|
export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
|
|
7
7
|
// The library, in a sensible deck order; `--layouts` takes any order and repeats.
|
|
8
|
-
export const LAYOUTS = ["hero", "number", "product", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
|
|
9
|
-
const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features" };
|
|
8
|
+
export const LAYOUTS = ["hero", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
|
|
9
|
+
const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features", "image-copy": "split", "image-text": "split" };
|
|
10
10
|
|
|
11
11
|
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
12
|
|
|
@@ -20,7 +20,7 @@ export function fillTemplate(file, { slug, title, presenterStyle }) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// One layout file: a leading comment of `key: value` lines (layout, summary,
|
|
23
|
-
// use, slot, presenter, cues, script), its <style> and its <section>.
|
|
23
|
+
// use, slot, presenter, nopresenter, cues, script), its <style> and its <section>.
|
|
24
24
|
export function readLayout(name) {
|
|
25
25
|
const text = packageText(`templates/layouts/${name}.html`);
|
|
26
26
|
const head = /^<!--([\s\S]*?)-->/.exec(text);
|
package/src/loadingBlock.mjs
CHANGED
|
@@ -49,24 +49,55 @@ const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i
|
|
|
49
49
|
const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
|
|
50
50
|
const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
|
|
51
51
|
const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
|
|
52
|
+
// The block's pieces besides its style, on any player host: the two preloads and
|
|
53
|
+
// the frame flag (each kept once), and its own comment (matched only as a whole
|
|
54
|
+
// comment, so a person's comments in <head> are never touched).
|
|
55
|
+
const PIECES = [
|
|
56
|
+
/<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
|
|
57
|
+
/<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
|
|
58
|
+
/<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
|
|
59
|
+
new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
|
|
60
|
+
];
|
|
61
|
+
const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
|
|
52
62
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
// <head>'s text with each piece kept `keep` times (0: all out, 1: the first stays),
|
|
64
|
+
// working outside comments only; the block's own comment counts as a piece too.
|
|
65
|
+
function trimPieces(head, keep) {
|
|
66
|
+
const seen = new Array(PIECES.length + 1).fill(0);
|
|
67
|
+
return head
|
|
68
|
+
.split(/(<!--[\s\S]*?-->[ \t]*\n?)/)
|
|
69
|
+
.map((part, i) => {
|
|
70
|
+
if (i % 2) return BLOCK_COMMENT.test(part) && ++seen[PIECES.length] > keep ? "" : part;
|
|
71
|
+
return PIECES.reduce((text, re, k) => text.replace(re, (m) => (++seen[k] > keep ? "" : m)), part);
|
|
72
|
+
})
|
|
73
|
+
.join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The page with its loading state put right. Only a page with the player script,
|
|
77
|
+
// as the check asks only those. With no <style data-hd-loading> in <head>, the
|
|
78
|
+
// block goes in after <title> (or at the top of <head>), any of its pieces already
|
|
79
|
+
// there taken out first. With one, it becomes the template's (refreshed) and any
|
|
80
|
+
// second copy of the style or a piece goes (deduped). A person's comments are left
|
|
81
|
+
// alone. Returns { html, fixed: "inserted" | "refreshed" | "deduped" | null,
|
|
57
82
|
// skipped? }: a player page with no <head>…</head> is left alone, with `skipped` saying why.
|
|
58
83
|
export function withLoadingBlock(html) {
|
|
59
84
|
const live = html.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length)); // same offsets, comments blanked
|
|
60
85
|
const head = HEAD.exec(live);
|
|
61
86
|
if (!PLAYER.test(live)) return { html, fixed: null };
|
|
62
87
|
if (!head) return { html, fixed: null, skipped: "no <head>…</head> to put the loading state in" };
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
const rawHead = html.slice(head.index, head.index + head[0].length);
|
|
89
|
+
const splice = (text) => html.slice(0, head.index) + text + html.slice(head.index + head[0].length);
|
|
90
|
+
if (!STYLE.test(head[0])) {
|
|
91
|
+
const cleaned = trimPieces(rawHead, 0);
|
|
92
|
+
const title = /<\/title>/i.exec(cleaned);
|
|
93
|
+
const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
|
|
94
|
+
return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
|
|
68
95
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
const deduped = trimPieces(rawHead, 1);
|
|
97
|
+
// The one style left, outside comments, made the template's.
|
|
98
|
+
const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
|
|
99
|
+
const style = STYLE.exec(blank);
|
|
100
|
+
const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
|
|
101
|
+
if (fresh === rawHead) return { html, fixed: null };
|
|
102
|
+
return { html: splice(fresh), fixed: deduped !== rawHead ? "deduped" : "refreshed" };
|
|
72
103
|
}
|
package/src/ops.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer
|
|
|
2
2
|
import { zipSync } from "fflate";
|
|
3
3
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
4
4
|
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
5
|
-
import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
5
|
+
import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
6
6
|
import {
|
|
7
7
|
bytesFromText,
|
|
8
8
|
isTextAsset,
|
|
@@ -19,8 +19,9 @@ export async function whoami(opts = {}) {
|
|
|
19
19
|
return (await client(opts)).query(api.decks.whoami, {});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Your own decks. `all: true` lists every deck, for admins only (support tools).
|
|
23
|
+
export async function listDecks(opts = {}, { all = false } = {}) {
|
|
24
|
+
const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
|
|
24
25
|
return rows.map((d) => ({
|
|
25
26
|
slug: d.slug,
|
|
26
27
|
title: d.title,
|
|
@@ -30,6 +31,8 @@ export async function listDecks(opts = {}) {
|
|
|
30
31
|
// How it opens (null: as its HTML says) and whether viewers may switch.
|
|
31
32
|
view: d.view ?? null,
|
|
32
33
|
viewToggle: d.viewToggle !== false,
|
|
34
|
+
// How the player moves on: auto, slide or step (null: as its HTML says, else auto).
|
|
35
|
+
pace: d.pace ?? null,
|
|
33
36
|
// false: the deck runs its own player, so view/viewToggle do not apply
|
|
34
37
|
// (null: not known yet, treated as on).
|
|
35
38
|
usesPlayer: d.usesPlayer ?? null,
|
|
@@ -78,6 +81,7 @@ export async function generateImage(opts, fields = {}) {
|
|
|
78
81
|
if (fields.format) args.format = String(fields.format);
|
|
79
82
|
if (fields.model) args.model = String(fields.model);
|
|
80
83
|
if (fields.seed != null && fields.seed !== "") args.seed = Number(fields.seed);
|
|
84
|
+
if (fields.brand) args.brand = true;
|
|
81
85
|
// Returns { images } for fast requests, or { jobId, status, count } for slow ones
|
|
82
86
|
// (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
|
|
83
87
|
// never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
|
|
@@ -179,8 +183,10 @@ export async function renderChart(opts, spec = {}) {
|
|
|
179
183
|
export async function generateIconSet(opts, fields = {}) {
|
|
180
184
|
const subjects = (fields.subjects || []).map(String).filter(Boolean);
|
|
181
185
|
if (!subjects.length) throw new Error("subjects is required (one or more things to draw)");
|
|
182
|
-
if (!fields.style) throw new Error("style is required");
|
|
183
|
-
const args = { subjects
|
|
186
|
+
if (!fields.style && !fields.preset) throw new Error("style or preset is required");
|
|
187
|
+
const args = { subjects };
|
|
188
|
+
if (fields.style) args.style = String(fields.style);
|
|
189
|
+
if (fields.preset) args.preset = String(fields.preset);
|
|
184
190
|
if (fields.palette?.length) args.palette = fields.palette.map(String);
|
|
185
191
|
if (fields.slug) args.slug = String(fields.slug);
|
|
186
192
|
if (fields.name) args.name = String(fields.name);
|
|
@@ -229,7 +235,7 @@ export async function requestVideoExport(opts, fields = {}) {
|
|
|
229
235
|
const c = await client(opts);
|
|
230
236
|
try {
|
|
231
237
|
const out = await c.mutation(api.videoExports.requestVideo, { slug, options });
|
|
232
|
-
return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a deck
|
|
238
|
+
return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a 4-minute deck takes about 12 minutes` };
|
|
233
239
|
} catch (e) {
|
|
234
240
|
// A backend from before the render worker has no requestVideo.
|
|
235
241
|
if (/Could not find public function/i.test(errorText(e))) throw new Error(videoFromCli(slug));
|
|
@@ -261,7 +267,11 @@ export async function ownedDeck(opts, slug) {
|
|
|
261
267
|
const c = await client(opts);
|
|
262
268
|
const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
|
|
263
269
|
if (!me) throw new Error("not logged in: run `hyperdecks login` first");
|
|
264
|
-
if (!deck)
|
|
270
|
+
if (!deck) {
|
|
271
|
+
// get answers only for your own decks; say so when the slug is someone else's.
|
|
272
|
+
if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`forbidden: ${slug} is not your deck`);
|
|
273
|
+
throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
|
|
274
|
+
}
|
|
265
275
|
if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
|
|
266
276
|
return deck;
|
|
267
277
|
}
|
|
@@ -285,7 +295,7 @@ export const VIDEO_RECIPIENT = "Video viewers";
|
|
|
285
295
|
export async function videoShareLink(opts, slug) {
|
|
286
296
|
const live = (await listShares(opts, slug)).find((s) => s.recipient === VIDEO_RECIPIENT && s.enabled && !s.qa && !s.archived && !s.requireEmail && (s.expiresAt == null || s.expiresAt > Date.now()) && (s.viewLimit == null || s.opens < s.viewLimit));
|
|
287
297
|
const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
|
|
288
|
-
return `${
|
|
298
|
+
return `${await linkBase(opts)}/v/${token}`;
|
|
289
299
|
}
|
|
290
300
|
|
|
291
301
|
// `export --video --upload`: file the MP4 with the deck's exports (30 days).
|
|
@@ -425,10 +435,24 @@ export async function checkNarrationJob(opts, jobId) {
|
|
|
425
435
|
return (await client(opts)).query(api.narration.getJob, { jobId: String(jobId) });
|
|
426
436
|
}
|
|
427
437
|
|
|
438
|
+
// Your own voices (what clone, rename and delete act on).
|
|
428
439
|
export async function listVoices(opts = {}) {
|
|
429
440
|
return (await client(opts)).query(api.voices.listVoices, {});
|
|
430
441
|
}
|
|
431
442
|
|
|
443
|
+
// Every voice you can narrate with: yours, then the built-in ones (builtin: true,
|
|
444
|
+
// id stock:<id>), which any account may use. What list_voices shows.
|
|
445
|
+
export async function listAllVoices(opts = {}) {
|
|
446
|
+
const c = await client(opts);
|
|
447
|
+
try {
|
|
448
|
+
return await c.query(api.voices.listVoices, { builtin: true });
|
|
449
|
+
} catch (e) {
|
|
450
|
+
// A backend from before built-in voices refuses the argument: your own voices, then.
|
|
451
|
+
if (!/builtin|extra field|Validator/i.test(errorText(e))) throw e;
|
|
452
|
+
return c.query(api.voices.listVoices, {});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
432
456
|
// ---- voice cloning (convex/voices.ts) ---------------------------------------
|
|
433
457
|
|
|
434
458
|
// The text of a backend error: ConvexError data (production redacts the rest), else the message.
|
|
@@ -600,18 +624,23 @@ export async function setDeckForwarding(opts, slug, allowForward) {
|
|
|
600
624
|
|
|
601
625
|
// How a deck opens in the player and whether viewers may switch: view is
|
|
602
626
|
// "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
|
|
603
|
-
// true/false
|
|
604
|
-
//
|
|
627
|
+
// true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
|
|
628
|
+
// 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.
|
|
605
631
|
// Applies on every link's next load (the route injects it), with no re-release.
|
|
606
632
|
export const DECK_VIEWS = ["slides", "scroll"];
|
|
607
|
-
export
|
|
633
|
+
export const DECK_PACES = ["auto", "slide", "step"];
|
|
634
|
+
export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
|
|
608
635
|
if (!slug) throw new Error("slug is required");
|
|
609
|
-
if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a
|
|
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)");
|
|
610
637
|
if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
|
|
638
|
+
if (pace !== undefined && pace !== null && !DECK_PACES.includes(pace)) throw new Error(`pace must be auto, slide, step or null (got ${JSON.stringify(pace)})`);
|
|
611
639
|
const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
|
|
612
640
|
slug,
|
|
613
641
|
...(view !== undefined ? { view } : {}),
|
|
614
642
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
643
|
+
...(pace !== undefined ? { pace } : {}),
|
|
615
644
|
});
|
|
616
645
|
// Saved either way; a deck on its own player says the setting won't apply.
|
|
617
646
|
const applies = res?.applies !== false;
|
|
@@ -619,6 +648,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
|
|
|
619
648
|
slug,
|
|
620
649
|
...(view !== undefined ? { view } : {}),
|
|
621
650
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
651
|
+
...(pace !== undefined ? { pace } : {}),
|
|
622
652
|
applies,
|
|
623
653
|
...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
|
|
624
654
|
};
|
|
@@ -628,7 +658,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
|
|
|
628
658
|
// upsert would otherwise reset your own live deck to a draft and rename it.
|
|
629
659
|
export async function createDeck(opts, { slug, title }) {
|
|
630
660
|
const c = await client(opts);
|
|
631
|
-
if (await c.query(api.decks.
|
|
661
|
+
if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
|
|
632
662
|
await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
|
|
633
663
|
return { slug, title, status: "draft" };
|
|
634
664
|
}
|
|
@@ -1143,7 +1173,7 @@ export async function listNarrators(opts = {}) {
|
|
|
1143
1173
|
default: out.defaultId,
|
|
1144
1174
|
...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
|
|
1145
1175
|
narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, spokenName: n.spokenName ?? null, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
|
|
1146
|
-
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
|
|
1176
|
+
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, builtin: !!x.builtin, narrators: x.narrators })),
|
|
1147
1177
|
};
|
|
1148
1178
|
}
|
|
1149
1179
|
|
|
@@ -1185,7 +1215,8 @@ export async function removeNarrator(opts, ref) {
|
|
|
1185
1215
|
return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
|
|
1186
1216
|
}
|
|
1187
1217
|
|
|
1188
|
-
// voice: one of your ready voices (name or id) or
|
|
1218
|
+
// voice: one of your ready voices (name or id), a built-in voice (name or
|
|
1219
|
+
// stock:<id>, list_voices), or dash.
|
|
1189
1220
|
export async function setNarratorVoice(opts, ref, voice) {
|
|
1190
1221
|
if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
|
|
1191
1222
|
const n = await findNarrator(opts, ref);
|
package/src/presenter.mjs
CHANGED
|
@@ -12,32 +12,118 @@
|
|
|
12
12
|
export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
|
|
13
13
|
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
|
|
14
14
|
|
|
15
|
-
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/
|
|
15
|
+
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/gi;
|
|
16
16
|
|
|
17
17
|
const attr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// The live player tag: the first one outside an HTML comment (a commented-out
|
|
20
|
+
// copy is not what plays), with where it starts. check's playerScript reads it
|
|
21
|
+
// the same way (closed comments only, so a stray "<!--" in a script hides nothing).
|
|
22
|
+
function playerTag(html) {
|
|
23
|
+
const comments = [...html.matchAll(/<!--[\s\S]*?-->/g)].map((m) => [m.index, m.index + m[0].length]);
|
|
24
|
+
for (const m of html.matchAll(PLAYER_TAG)) {
|
|
25
|
+
if (!comments.some(([a, b]) => m.index >= a && m.index < b)) return { tag: m[0], index: m.index };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const value = (tag, name) => {
|
|
31
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
|
|
32
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The face this tag shows, decided as packages/player/player.js does: data-presenter
|
|
36
|
+
// head|static|none, else static when there is a still, else none. head draws
|
|
37
|
+
// data-avatar (and falls back to the still without one); static draws the
|
|
38
|
+
// still. Null: the deck plays the voice and captions with no face.
|
|
39
|
+
function tagFace(tag) {
|
|
40
|
+
const presenter = value(tag, "data-presenter");
|
|
41
|
+
const avatarUrl = value(tag, "data-avatar") || null;
|
|
42
|
+
const imageUrl = value(tag, "data-presenter-image") || null;
|
|
43
|
+
const mode = ["head", "static", "none"].includes(presenter) ? presenter : imageUrl ? "static" : "none";
|
|
44
|
+
if (mode === "head" && (avatarUrl || imageUrl)) return { avatarUrl, imageUrl };
|
|
45
|
+
if (mode === "static" && imageUrl) return { avatarUrl: null, imageUrl };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The face the page's player shows ({ avatarUrl, imageUrl }, either may be
|
|
50
|
+
// null), or null when it plays captions only or has no player tag.
|
|
51
|
+
export function presentedFace(html) {
|
|
52
|
+
const found = playerTag(html);
|
|
53
|
+
return found ? tagFace(found.tag) : null;
|
|
54
|
+
}
|
|
22
55
|
|
|
23
|
-
// Whether the page's player tag
|
|
56
|
+
// Whether the page's player tag shows no face, so it has captions only.
|
|
24
57
|
export function playsCaptionsOnly(html) {
|
|
25
|
-
const
|
|
26
|
-
return !!
|
|
58
|
+
const found = playerTag(html);
|
|
59
|
+
return !!found && !tagFace(found.tag);
|
|
27
60
|
}
|
|
28
61
|
|
|
29
62
|
// html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
|
|
30
|
-
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
|
|
31
|
-
//
|
|
63
|
+
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
|
|
64
|
+
// html unchanged when there is no such tag, the tag already shows a face, or it
|
|
65
|
+
// names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
|
|
66
|
+
// Anything else the player would play captions only (data-presenter none or
|
|
67
|
+
// missing, head with no data-avatar, static with no still) gets the face.
|
|
32
68
|
export function withAvatar(html, { avatarUrl, imageUrl }) {
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const image = imageUrl &&
|
|
69
|
+
const found = playerTag(html);
|
|
70
|
+
if (!found) return html;
|
|
71
|
+
const { tag, index } = found;
|
|
72
|
+
if (tagFace(tag) || value(tag, "data-avatar")) return html;
|
|
73
|
+
const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
|
|
38
74
|
const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
|
|
39
|
-
const
|
|
40
|
-
|
|
75
|
+
const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
|
|
76
|
+
const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
|
|
77
|
+
return html.slice(0, index) + next + html.slice(index + tag.length);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What is wrong with the player tag's presenter as written, for check: null, or
|
|
81
|
+
// { kind, presenter }. "unfilled": it asks for a face (head or static) but names
|
|
82
|
+
// none, so it plays captions only until release fills it in. "kept": it has a
|
|
83
|
+
// data-avatar the player ignores (data-presenter="none"), and release leaves it.
|
|
84
|
+
// A plain "none" or no data-presenter is not an issue: release adds the face.
|
|
85
|
+
export function presenterTagIssue(html) {
|
|
86
|
+
const found = playerTag(html);
|
|
87
|
+
if (!found || tagFace(found.tag)) return null;
|
|
88
|
+
const presenter = value(found.tag, "data-presenter");
|
|
89
|
+
if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
|
|
90
|
+
if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Why a resolved face did not land on the page: withAvatar leaves a tag with
|
|
95
|
+
// its own data-avatar alone, and a page with no live player tag has nowhere to put it.
|
|
96
|
+
function keptOutReason(html) {
|
|
97
|
+
const found = playerTag(html);
|
|
98
|
+
if (!found) return "the page has no live player tag to put the face on";
|
|
99
|
+
const mode = value(found.tag, "data-presenter");
|
|
100
|
+
return `the player tag names its own data-avatar, but data-presenter=${mode === null ? " is missing" : `"${mode}"`} so it plays no face, and release leaves a hand-set data-avatar alone: set data-presenter="head", or remove data-avatar`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What a release says about the face, from the page as written (`html`), the
|
|
104
|
+
// face it puts on (`presenter`, from avatars.presenterFor; null for none), who
|
|
105
|
+
// the backend says presents (`who`: { narrator, voice, face, reason }) and, when
|
|
106
|
+
// the face lookup itself failed, `lookupError`. Returns the page that ships, a
|
|
107
|
+
// line for the terminal and the summary to report: "presented by" only when
|
|
108
|
+
// that page shows a face, and with no resolved face on it, the tag's own.
|
|
109
|
+
// Both releases use it: release.mjs and convex/deckSources.ts.
|
|
110
|
+
export const TAG_FACE = "the face set on the player tag";
|
|
111
|
+
export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
|
|
112
|
+
const page = presenter ? withAvatar(html, presenter) : html;
|
|
113
|
+
const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
|
|
114
|
+
const shown = presentedFace(page);
|
|
115
|
+
if (shown) {
|
|
116
|
+
// Left as written, with no resolved face or another one: whatever the tag names is what shows.
|
|
117
|
+
if (page === html && shown.avatarUrl !== presenter?.avatarUrl) {
|
|
118
|
+
return { page, line: `✓ presented by ${TAG_FACE} (${shown.avatarUrl ? "data-avatar" : "data-presenter-image"})`, summary: who ? { ...who, face: TAG_FACE } : null };
|
|
119
|
+
}
|
|
120
|
+
return { page, line: `✓ presented by ${who?.face ?? "your avatar"}`, summary: who ?? null };
|
|
121
|
+
}
|
|
122
|
+
if (lookupError) return captions(`could not look up your avatar (${lookupError}); release again to try once more`);
|
|
123
|
+
if (presenter) return captions(keptOutReason(page));
|
|
124
|
+
if (who && !who.face) return { page, line: `⚠ releasing captions only: ${who.reason ?? "no face"}`, summary: who };
|
|
125
|
+
if (who?.face) return captions(`no face for "${who.face}" came back to put on the player tag`);
|
|
126
|
+
return { page, line: "", summary: null };
|
|
41
127
|
}
|
|
42
128
|
|
|
43
129
|
// One line for who presents a deck, from narrate's or release's `presenter`
|
package/src/preview.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
2
2
|
import { resolve, extname } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { api, client, target, uploadBytes } from "./runtime.mjs";
|
|
4
|
+
import { api, client, linkBase, target, uploadBytes } from "./runtime.mjs";
|
|
5
5
|
|
|
6
6
|
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
7
7
|
export function httpUrl(value) {
|
|
@@ -58,7 +58,7 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
58
58
|
if (opts.share) {
|
|
59
59
|
if (deck.status === 'archived') throw new Error('Archived deck cannot provide a sendable share preview');
|
|
60
60
|
validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
|
|
61
|
-
shareUrl = httpUrl(`${
|
|
61
|
+
shareUrl = httpUrl(`${await linkBase(opts)}/v/${opts.share}`);
|
|
62
62
|
}
|
|
63
63
|
html = deck.html;
|
|
64
64
|
if (!html && deck.htmlUrl) {
|
package/src/release.mjs
CHANGED
|
@@ -16,8 +16,8 @@ import { readFileSync } from "node:fs";
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { zipSync } from "fflate";
|
|
18
18
|
import * as ops from "./ops.mjs";
|
|
19
|
-
import { withAvatar } from "./presenter.mjs";
|
|
20
|
-
import {
|
|
19
|
+
import { releasedPresenter, withAvatar } from "./presenter.mjs";
|
|
20
|
+
import { linkBase } from "./runtime.mjs";
|
|
21
21
|
import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
|
|
22
22
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
23
23
|
|
|
@@ -112,16 +112,20 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
112
112
|
}
|
|
113
113
|
// The released page gets the narrator's face: your avatar, or D.A.S.H.'s for its voice.
|
|
114
114
|
let presenter = null;
|
|
115
|
+
let lookupError;
|
|
115
116
|
try {
|
|
116
117
|
presenter = await ops.presenterFor(opts, slug, opts.voice);
|
|
117
118
|
} catch (e) {
|
|
118
|
-
|
|
119
|
+
lookupError = ops.errorText(e);
|
|
119
120
|
}
|
|
120
121
|
const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
|
|
121
|
-
|
|
122
|
+
// Say who presents from the page that ships, never from the lookup alone: a
|
|
123
|
+
// resolved face that did not land on the player tag must not be claimed, and
|
|
124
|
+
// a failed lookup is captions only only when the tag names no face of its own.
|
|
125
|
+
const said = releasedPresenter(readFileSync(join(deckDir, "index.html"), "utf8"), presenter, job.presenter, { lookupError });
|
|
122
126
|
// A terminal user sees this before it ships, so the CLI warns rather than refuses.
|
|
123
|
-
|
|
124
|
-
return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary:
|
|
127
|
+
if (said.line) log(said.line);
|
|
128
|
+
return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: said.summary });
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
// Import the checked bundle and make the links: the end of every release.
|
|
@@ -137,7 +141,7 @@ async function publish({ opts, io: { log, commit }, deckDir, slug, to, checked,
|
|
|
137
141
|
const sha = commit(deckDir);
|
|
138
142
|
log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
|
|
139
143
|
|
|
140
|
-
const base =
|
|
144
|
+
const base = await linkBase(opts);
|
|
141
145
|
const shares = [];
|
|
142
146
|
for (const r of to) {
|
|
143
147
|
let token;
|
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.8";
|
|
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
|
|
@@ -117,7 +117,9 @@ export const api = {
|
|
|
117
117
|
},
|
|
118
118
|
decks: {
|
|
119
119
|
get: makeFunctionReference("decks:get"),
|
|
120
|
+
slugTaken: makeFunctionReference("decks:slugTaken"),
|
|
120
121
|
listForViewer: makeFunctionReference("decks:listForViewer"),
|
|
122
|
+
listAll: makeFunctionReference("decks:listAll"),
|
|
121
123
|
remove: makeFunctionReference("decks:remove"),
|
|
122
124
|
setAllowForward: makeFunctionReference("decks:setAllowForward"),
|
|
123
125
|
setView: makeFunctionReference("decks:setView"),
|
|
@@ -135,6 +137,7 @@ export const api = {
|
|
|
135
137
|
setAllowForward: makeFunctionReference("shares:setAllowForward"),
|
|
136
138
|
setEnabled: makeFunctionReference("shares:setEnabled"),
|
|
137
139
|
setNotify: makeFunctionReference("shares:setNotify"),
|
|
140
|
+
linkOrigins: makeFunctionReference("shares:linkOrigins"),
|
|
138
141
|
},
|
|
139
142
|
imagegen: {
|
|
140
143
|
generateImage: makeFunctionReference("imagegen:generateImage"),
|
|
@@ -191,7 +194,7 @@ export const api = {
|
|
|
191
194
|
},
|
|
192
195
|
};
|
|
193
196
|
|
|
194
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
|
|
197
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card", "backup"]);
|
|
195
198
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
196
199
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
197
200
|
// opts.email stays the last --email for the other commands (share new).
|
|
@@ -259,6 +262,63 @@ export function target(opts = {}) {
|
|
|
259
262
|
};
|
|
260
263
|
}
|
|
261
264
|
|
|
265
|
+
// Where recipient links (/v/<token>) are printed: the server says
|
|
266
|
+
// (shares:linkOrigins, RECIPIENT_LINK_ORIGIN in lib/linkOrigin.ts), so links
|
|
267
|
+
// follow the deployment rather than a default baked into this version. Both app
|
|
268
|
+
// hosts serve every link; this only picks the one a new link names.
|
|
269
|
+
// HYPERDECKS_LINK_ORIGIN overrides it; the dev target keeps its share base.
|
|
270
|
+
export const DEFAULT_LINK_ORIGIN = "https://decks.hypersocial.ai";
|
|
271
|
+
const LINK_HOSTS = ["https://decks.hypersocial.ai", "https://hyperdecks.ai"];
|
|
272
|
+
let linkOriginPin = null;
|
|
273
|
+
const linkOriginCache = new Map();
|
|
274
|
+
|
|
275
|
+
// The hosted MCP pins it to the server's own setting (lib/serverCliHost.ts).
|
|
276
|
+
export function pinLinkOrigin(origin) {
|
|
277
|
+
linkOriginPin = origin ? normalizeBase(origin) : null;
|
|
278
|
+
linkOriginCache.clear();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const isLinkHost = (o) => typeof o === "string" && LINK_HOSTS.includes(normalizeBase(o.trim()).toLowerCase());
|
|
282
|
+
|
|
283
|
+
async function askLinkOrigin(c) {
|
|
284
|
+
try {
|
|
285
|
+
const out = await c.query(api.shares.linkOrigins, {});
|
|
286
|
+
// The server only ever names one of the two app hosts; print nothing else.
|
|
287
|
+
if (isLinkHost(out?.origin)) return normalizeBase(out.origin.trim()).toLowerCase();
|
|
288
|
+
console.warn(`warning: the server named no usable link origin; printing links on ${DEFAULT_LINK_ORIGIN}`);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
// An older server without shares:linkOrigins stays quiet: links on the
|
|
291
|
+
// established host open everywhere. Anything else is worth a line.
|
|
292
|
+
const msg = String(e?.message ?? e);
|
|
293
|
+
if (!/Could not find public function/.test(msg)) console.warn(`warning: could not ask where to print links (${msg}); printing them on ${DEFAULT_LINK_ORIGIN}`);
|
|
294
|
+
}
|
|
295
|
+
return DEFAULT_LINK_ORIGIN;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function linkBase(opts = {}) {
|
|
299
|
+
const t = target(opts);
|
|
300
|
+
if (t.name === "dev") return t.shareBase;
|
|
301
|
+
const env = process.env.HYPERDECKS_LINK_ORIGIN?.trim();
|
|
302
|
+
if (env) {
|
|
303
|
+
if (isLinkHost(env) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(env)) return normalizeBase(env).toLowerCase();
|
|
304
|
+
console.warn(`warning: HYPERDECKS_LINK_ORIGIN must be ${LINK_HOSTS.join(" or ")} (or a localhost URL); ignoring ${JSON.stringify(env)}`);
|
|
305
|
+
}
|
|
306
|
+
if (linkOriginPin) return linkOriginPin;
|
|
307
|
+
// A caller's own client (the hosted MCP, tests) is asked each time.
|
|
308
|
+
if (opts.client) return askLinkOrigin(opts.client);
|
|
309
|
+
if (!linkOriginCache.has(t.convexUrl)) linkOriginCache.set(t.convexUrl, askLinkOrigin(new ConvexHttpClient(t.convexUrl)));
|
|
310
|
+
return linkOriginCache.get(t.convexUrl);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The same link on the other app host, for a recipient whose network blocks
|
|
314
|
+
// the printed one; null for a link that is not on an app host (dev).
|
|
315
|
+
export function backupLinkUrl(url) {
|
|
316
|
+
const m = /^(https?:\/\/[^/]+)(\/.*)$/.exec(String(url || ""));
|
|
317
|
+
if (!m) return null;
|
|
318
|
+
const i = LINK_HOSTS.indexOf(m[1].toLowerCase());
|
|
319
|
+
return i < 0 ? null : `${LINK_HOSTS[1 - i]}${m[2]}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
262
322
|
export function loadCreds() {
|
|
263
323
|
if (!existsSync(CRED_FILE)) return {};
|
|
264
324
|
try {
|