@hypersocial/decks-cli 0.9.9 → 0.9.11
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 +95 -48
- package/skills/hyperdecks/chat.md +8 -1
- package/src/brand.mjs +57 -5
- package/src/checkRules.mjs +90 -2
- package/src/commands.mjs +39 -9
- package/src/draftFace.mjs +56 -0
- package/src/files.mjs +3 -0
- package/src/layouts.mjs +3 -2
- package/src/loadingBlock.mjs +22 -7
- package/src/narrate.mjs +6 -1
- package/src/opening.mjs +117 -0
- package/src/ops.mjs +25 -3
- package/src/play.mjs +2 -2
- package/src/presenter.mjs +73 -9
- package/src/release.mjs +8 -7
- package/src/runtime.mjs +1 -1
- package/src/scaffold.mjs +28 -7
- package/templates/layouts/_deck.html +144 -36
- package/templates/layouts/closer.html +4 -2
- package/templates/layouts/cover-band.html +44 -0
- package/templates/layouts/cover-poster.html +42 -0
- package/templates/layouts/cover-type.html +31 -0
- package/templates/layouts/hero.html +27 -12
- package/templates/layouts/phones.html +5 -5
- package/templates/layouts/split.html +4 -4
- package/templates/narrated-deck.html +74 -3
package/src/commands.mjs
CHANGED
|
@@ -72,12 +72,23 @@ async function deckStats(pos, opts) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// The owner link: present or check your own deck (untracked, owner-only).
|
|
76
|
+
function deckLink(pos, opts) {
|
|
77
|
+
const [slug] = pos;
|
|
78
|
+
if (!slug) throw new Error("usage: deck link <slug> [--json]");
|
|
79
|
+
const out = ops.ownerLink(opts, slug);
|
|
80
|
+
if (opts.json) return console.log(JSON.stringify(out, null, 2));
|
|
81
|
+
console.log(out.ownerUrl);
|
|
82
|
+
if (out.backupUrl) console.log(`backup (other domain): ${out.backupUrl}`);
|
|
83
|
+
console.log("Only you can open it, signed in; nothing is tracked. Present (or H) hides your owner tools for a screen share.");
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
function deckOpen(pos, opts) {
|
|
76
87
|
const [slug] = pos;
|
|
77
88
|
if (!slug) throw new Error("usage: deck open <slug>");
|
|
78
|
-
const
|
|
79
|
-
console.log(
|
|
80
|
-
openBrowser(
|
|
89
|
+
const { ownerUrl } = ops.ownerLink(opts, slug);
|
|
90
|
+
console.log(ownerUrl);
|
|
91
|
+
openBrowser(ownerUrl);
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
async function deckRemove(pos, opts) {
|
|
@@ -89,8 +100,11 @@ async function deckRemove(pos, opts) {
|
|
|
89
100
|
|
|
90
101
|
async function shareNew(pos, opts) {
|
|
91
102
|
const [slug, recipient] = pos;
|
|
92
|
-
|
|
103
|
+
const usage = "usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]";
|
|
104
|
+
if (!slug || !recipient) throw new Error(usage);
|
|
93
105
|
if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
|
|
106
|
+
// The link's own pace; without it the link follows the deck's.
|
|
107
|
+
if ("pace" in opts && !ops.DECK_PACES.includes(opts.pace)) throw new Error(usage);
|
|
94
108
|
const { token } = await ops.createShare(opts, {
|
|
95
109
|
slug,
|
|
96
110
|
recipient,
|
|
@@ -100,6 +114,7 @@ async function shareNew(pos, opts) {
|
|
|
100
114
|
qa: opts.qa || undefined,
|
|
101
115
|
notify: opts["no-notify"] ? false : undefined,
|
|
102
116
|
allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
|
|
117
|
+
pace: opts.pace,
|
|
103
118
|
expires: opts.expires,
|
|
104
119
|
limit: opts.limit,
|
|
105
120
|
});
|
|
@@ -123,7 +138,7 @@ async function shareList(pos, opts) {
|
|
|
123
138
|
// Rows arrive with each link's forwards right after it; indent those.
|
|
124
139
|
for (const r of rows) {
|
|
125
140
|
const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
|
|
126
|
-
console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
|
|
141
|
+
console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.pace ? `\tpace:${r.pace}` : ""}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
|
|
127
142
|
}
|
|
128
143
|
if (!rows.length) console.log("(no shares)");
|
|
129
144
|
}
|
|
@@ -348,7 +363,10 @@ commands:
|
|
|
348
363
|
login [--token <pat>] [--broker <url>] [--dev]
|
|
349
364
|
logout [--dev]
|
|
350
365
|
whoami [--json] [--dev]
|
|
351
|
-
deck list|stats|open|rm|forward|view
|
|
366
|
+
deck list|stats|open|link|rm|forward|view
|
|
367
|
+
deck link <slug> [--json]
|
|
368
|
+
the owner link: present or check your own deck (only you, signed in; untracked;
|
|
369
|
+
Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
|
|
352
370
|
deck forward on|off <slug>
|
|
353
371
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
354
372
|
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
|
|
@@ -378,7 +396,7 @@ commands:
|
|
|
378
396
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
|
|
379
397
|
on a committed deck: check → narrate → import → share new per --to
|
|
380
398
|
share new|list|on|off|forward|mute|unmute|rm|url
|
|
381
|
-
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
|
|
399
|
+
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]
|
|
382
400
|
share forward on|off|default <token>
|
|
383
401
|
let this link's viewer forward it as their own tracked link ("Dana via Peter",
|
|
384
402
|
one hop); default follows the deck. share off on a link also turns off its forwards.
|
|
@@ -972,9 +990,20 @@ function commandTable(pos, rest, opts) {
|
|
|
972
990
|
console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
|
|
973
991
|
},
|
|
974
992
|
"deck narrator": async () => {
|
|
975
|
-
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
|
|
993
|
+
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default> [--dir <deck folder>]");
|
|
976
994
|
const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
977
|
-
|
|
995
|
+
const log = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
996
|
+
log(`OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
|
|
997
|
+
// The local file names the face too, so the draft opened from disk shows it.
|
|
998
|
+
const { writeDraftFace } = await import("./draftFace.mjs");
|
|
999
|
+
const dir = resolve(typeof opts.dir === "string" ? opts.dir : join("decks", out.slug));
|
|
1000
|
+
let local = { wrote: false };
|
|
1001
|
+
try {
|
|
1002
|
+
local = await writeDraftFace(opts, out.slug, join(dir, "index.html"), { log });
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
log(`⚠ presenter: the server was updated but index.html was not (${e.message})`);
|
|
1005
|
+
}
|
|
1006
|
+
if (opts.json) console.log(JSON.stringify({ ...out, indexHtml: local.wrote ? join(dir, "index.html") : null }, null, 2));
|
|
978
1007
|
},
|
|
979
1008
|
"preview setup": async () => {
|
|
980
1009
|
const { setupCapture } = await import("./preview-capture.mjs");
|
|
@@ -986,6 +1015,7 @@ function commandTable(pos, rest, opts) {
|
|
|
986
1015
|
"deck list": () => deckList(opts),
|
|
987
1016
|
"deck stats": () => deckStats(rest, opts),
|
|
988
1017
|
"deck open": () => deckOpen(rest, opts),
|
|
1018
|
+
"deck link": () => deckLink(rest, opts),
|
|
989
1019
|
"deck rm": () => deckRemove(rest, opts),
|
|
990
1020
|
"deck forward": () => deckForward(rest, opts),
|
|
991
1021
|
"deck view": () => deckView(rest, opts),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The narrator's face written into a draft's index.html on disk (roadmap:
|
|
2
|
+
// a-narrated-draft-shows-its-narrator-s-face-from-the-file-on-), so the file
|
|
3
|
+
// plays with its face however it is opened, not only through play and release.
|
|
4
|
+
// deck new, deck narrator and narrate use it.
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import * as ops from "./ops.mjs";
|
|
7
|
+
import { DASH_PRESENTER, withNarrator, writeNarratorFace } from "./presenter.mjs";
|
|
8
|
+
|
|
9
|
+
// What to write for `slug`: { face } (null: captions only on purpose), or
|
|
10
|
+
// { face: undefined, why } when the narrator has no face and nobody chose captions
|
|
11
|
+
// only (writeDraftFace then takes a CLI-written face off), or the lookup failed
|
|
12
|
+
// (`failed`: nothing is decided, the file stays).
|
|
13
|
+
export async function draftFace(opts, slug) {
|
|
14
|
+
let found;
|
|
15
|
+
try {
|
|
16
|
+
found = await ops.presenterWithReason(opts, slug, undefined, { draft: true });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { face: undefined, why: `could not look up the narrator's face (${e.message})`, failed: true };
|
|
19
|
+
}
|
|
20
|
+
if (found?.presenter) return { face: found.presenter, name: found.who?.face ?? null };
|
|
21
|
+
// Captions only on purpose only when the deck says so; faces off or a missing face stays unwritten.
|
|
22
|
+
if (!found?.presenter && found?.captionsOnly) return { face: null, name: null };
|
|
23
|
+
return { face: undefined, why: found?.who?.reason ?? "no narrator with a face" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Write `slug`'s face into `htmlPath`. `fallback`: the face to write when the
|
|
27
|
+
// lookup fails (deck new writes D.A.S.H.). Logs one line; returns what it wrote.
|
|
28
|
+
export async function writeDraftFace(opts, slug, htmlPath, { log = () => {}, fallback } = {}) {
|
|
29
|
+
if (!existsSync(htmlPath)) {
|
|
30
|
+
log(`⚠ presenter: ${htmlPath} not found, so the file was not updated (the server was); pass --dir <deck folder>`);
|
|
31
|
+
return { wrote: false };
|
|
32
|
+
}
|
|
33
|
+
const got = await draftFace(opts, slug);
|
|
34
|
+
const face = got.face === undefined && got.failed && fallback ? fallback : got.face;
|
|
35
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
36
|
+
if (face === undefined) {
|
|
37
|
+
// The narrator has no face now: a face the CLI wrote for an earlier one comes off, as play and release do.
|
|
38
|
+
const off = got.failed ? html : withNarrator(html, null, { face: null });
|
|
39
|
+
if (off !== html) {
|
|
40
|
+
writeFileSync(htmlPath, off);
|
|
41
|
+
log(`⚠ presenter: no face (${got.why}); the earlier narrator's face was taken off index.html`);
|
|
42
|
+
return { wrote: true, face: null, why: got.why };
|
|
43
|
+
}
|
|
44
|
+
log(`⚠ presenter: index.html left as written: ${got.why}`);
|
|
45
|
+
return { wrote: false, why: got.why };
|
|
46
|
+
}
|
|
47
|
+
const out = writeNarratorFace(html, face);
|
|
48
|
+
if (!out.wrote) {
|
|
49
|
+
log(`⚠ presenter: index.html left as written: ${out.why}`);
|
|
50
|
+
return { wrote: false, why: out.why };
|
|
51
|
+
}
|
|
52
|
+
if (out.html !== html) writeFileSync(htmlPath, out.html);
|
|
53
|
+
const who = face === null ? "captions only" : face === DASH_PRESENTER && got.failed ? `D.A.S.H. (${got.why}; deck narrator <name> fixes it)` : got.name ?? "the narrator's face";
|
|
54
|
+
log(` presenter: ${who}, written to index.html`);
|
|
55
|
+
return { wrote: true, face };
|
|
56
|
+
}
|
package/src/files.mjs
CHANGED
|
@@ -13,6 +13,9 @@ const FILES = {
|
|
|
13
13
|
"templates/layouts/closer.html": () => new URL("../templates/layouts/closer.html", import.meta.url),
|
|
14
14
|
"templates/layouts/compare.html": () => new URL("../templates/layouts/compare.html", import.meta.url),
|
|
15
15
|
"templates/layouts/features.html": () => new URL("../templates/layouts/features.html", import.meta.url),
|
|
16
|
+
"templates/layouts/cover-band.html": () => new URL("../templates/layouts/cover-band.html", import.meta.url),
|
|
17
|
+
"templates/layouts/cover-poster.html": () => new URL("../templates/layouts/cover-poster.html", import.meta.url),
|
|
18
|
+
"templates/layouts/cover-type.html": () => new URL("../templates/layouts/cover-type.html", import.meta.url),
|
|
16
19
|
"templates/layouts/hero.html": () => new URL("../templates/layouts/hero.html", import.meta.url),
|
|
17
20
|
"templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
|
|
18
21
|
"templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
|
package/src/layouts.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
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
|
-
// The library
|
|
8
|
-
|
|
7
|
+
// The library: the four covers first (hero is the default; a deck opens on one), then
|
|
8
|
+
// the rest in a sensible deck order. `--layouts` takes any order and repeats.
|
|
9
|
+
export const LAYOUTS = ["hero", "cover-band", "cover-poster", "cover-type", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
|
|
9
10
|
const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features", "image-copy": "split", "image-text": "split" };
|
|
10
11
|
|
|
11
12
|
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
package/src/loadingBlock.mjs
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
// (lib/loadingBlock.test.ts keeps the three in step). A JS string, not a read of
|
|
5
5
|
// the template, because Convex (the hosted draft's write) cannot read package files.
|
|
6
6
|
// No Node imports: the CLI and Convex (and so the MCP route's writes) use it.
|
|
7
|
+
// The opening card (opening.mjs) is the loader: its rules in the style, and the
|
|
8
|
+
// <script data-hd-opening> after it that builds it once the page is parsed. A
|
|
9
|
+
// frame or ?thumb (data-hd-frame) gets the quiet dots instead; an export neither.
|
|
10
|
+
import { OPENING_CSS, OPENING_PAGE_SCRIPT } from "./opening.mjs";
|
|
11
|
+
|
|
7
12
|
export const LOADING_BLOCK = [
|
|
8
13
|
"<!-- Loading state (keep verbatim; `hyperdecks check` requires it). Until the",
|
|
9
14
|
" player marks body[data-hd-ready] the page shows only the slide card, at its",
|
|
10
15
|
" final size, in the deck's --hd-ground (its first slide's colour) on the",
|
|
11
|
-
" page's brand colour, with
|
|
16
|
+
" page's brand colour, with the opening card over it (the title and the",
|
|
17
|
+
" presenter's still), or in a frame quiet dots in --hd-ground-ink; then the",
|
|
12
18
|
" slides fade in. If the player never arrives, the slides show after 10 s",
|
|
13
19
|
" rather than never. Put data-layout=\"overlay\" on <html> for the overlay. -->",
|
|
14
20
|
"<link rel=\"modulepreload\" href=\"https://hyperdecks.ai/player/v1/player.js\">",
|
|
@@ -22,7 +28,7 @@ export const LOADING_BLOCK = [
|
|
|
22
28
|
" /* The card as the player draws it (player.css, framed): 16:9, scaled to fit",
|
|
23
29
|
" between the 64px masthead and the 56px dock, 24px in from the sides. */",
|
|
24
30
|
" body::before { inset: 64px 24px 56px; margin: auto; width: min(100vw - 48px, (100dvh - 120px) * 16 / 9); height: min(100dvh - 120px, (100vw - 48px) * 9 / 16); border-radius: calc(min(100vw - 48px, (100dvh - 120px) * 16 / 9) * .01125); background: var(--hd-ground, #fff); box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-ground-ink, #0b0b0f) 16%, transparent); }",
|
|
25
|
-
" body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
|
|
31
|
+
" [data-hd-frame] body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
|
|
26
32
|
" body[data-hd-ready]::before, body[data-hd-ready]::after { opacity: 0; animation: none; }",
|
|
27
33
|
" /* The overlay (embedded, ?thumb, or data-layout=\"overlay\"): the stage fills the window. */",
|
|
28
34
|
" :is([data-hd-frame], [data-layout=\"overlay\"]) body::before { inset: 0; width: min(100vw, 100dvh * 16 / 9); height: min(100dvh, 100vw * 9 / 16); border-radius: calc(min(100vw, 100dvh * 16 / 9) * .01125); box-shadow: none; }",
|
|
@@ -42,21 +48,28 @@ export const LOADING_BLOCK = [
|
|
|
42
48
|
" @keyframes hd-in { from { opacity: 0; } }",
|
|
43
49
|
" @keyframes hd-dots { from { opacity: .15; } to { opacity: .6; } }",
|
|
44
50
|
" @keyframes hd-fail-open { to { visibility: visible; overflow: auto; } }",
|
|
51
|
+
...OPENING_CSS.replace(/\n$/, "").split("\n"),
|
|
45
52
|
"</style>",
|
|
53
|
+
OPENING_PAGE_SCRIPT,
|
|
46
54
|
].join("\n");
|
|
47
55
|
|
|
48
56
|
const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i;
|
|
49
57
|
const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
|
|
50
58
|
const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
|
|
51
59
|
const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
const OPENING_SCRIPT = /<script data-hd-opening>[\s\S]*?<\/script>/;
|
|
61
|
+
const OPENING_SCRIPT_PIECE = new RegExp(`${OPENING_SCRIPT.source}[ \\t]*\\n?`, "g");
|
|
62
|
+
// The script with the line break before it (as the block writes it after the style).
|
|
63
|
+
const OPENING_SCRIPT_LINE = new RegExp(`\\n?[ \\t]*${OPENING_SCRIPT.source}`, "g");
|
|
64
|
+
// The block's pieces besides its style, on any player host: the two preloads,
|
|
65
|
+
// the frame flag and the card's <script data-hd-opening> (each kept once), and its own comment (matched only as a whole
|
|
54
66
|
// comment, so a person's comments in <head> are never touched).
|
|
55
67
|
const PIECES = [
|
|
56
68
|
/<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
|
|
57
69
|
/<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
|
|
58
70
|
/<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
|
|
59
71
|
new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
|
|
72
|
+
OPENING_SCRIPT_PIECE,
|
|
60
73
|
];
|
|
61
74
|
const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
|
|
62
75
|
|
|
@@ -93,11 +106,13 @@ export function withLoadingBlock(html) {
|
|
|
93
106
|
const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
|
|
94
107
|
return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
|
|
95
108
|
}
|
|
96
|
-
|
|
109
|
+
// The card's script goes right after the style, as the template's (a block
|
|
110
|
+
// from before the card gets it; a stray copy elsewhere goes).
|
|
111
|
+
const deduped = trimPieces(rawHead, 1).split(/(<!--[\s\S]*?-->)/).map((part, i) => (i % 2 ? part : part.replace(OPENING_SCRIPT_LINE, ""))).join("");
|
|
97
112
|
// The one style left, outside comments, made the template's.
|
|
98
113
|
const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
|
|
99
114
|
const style = STYLE.exec(blank);
|
|
100
|
-
const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
|
|
115
|
+
const fresh = deduped.slice(0, style.index) + LOADING_STYLE + "\n" + OPENING_PAGE_SCRIPT + deduped.slice(style.index + style[0].length);
|
|
101
116
|
if (fresh === rawHead) return { html, fixed: null };
|
|
102
|
-
return { html: splice(fresh), fixed:
|
|
117
|
+
return { html: splice(fresh), fixed: trimPieces(rawHead, 1) !== rawHead ? "deduped" : "refreshed" };
|
|
103
118
|
}
|
package/src/narrate.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
9
9
|
import { basename, join, resolve } from "node:path";
|
|
10
10
|
import * as ops from "./ops.mjs";
|
|
11
11
|
import { presenterLine } from "./presenter.mjs";
|
|
12
|
+
import { writeDraftFace } from "./draftFace.mjs";
|
|
12
13
|
export { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
13
14
|
import { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
14
15
|
|
|
@@ -39,7 +40,8 @@ export function narrationRequest(pos, opts) {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
// writeFace false: leave the presenter as written (release narrates, then puts the face on the upload itself).
|
|
44
|
+
export async function narrate(pos, opts = {}, { log = (line) => console.log(line), writeFace = true } = {}) {
|
|
43
45
|
const { deckDir, fields } = narrationRequest(pos, opts);
|
|
44
46
|
if (opts["dry-run"]) {
|
|
45
47
|
const preview = await ops.previewNarration(opts, fields);
|
|
@@ -85,6 +87,9 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
85
87
|
writeFileSync(htmlPath, next);
|
|
86
88
|
log(` index.html: data-manifest → ${job.manifestUrl}`);
|
|
87
89
|
}
|
|
90
|
+
// The narrator's face too, so the file opened as written shows what play and release show.
|
|
91
|
+
// The narration is paid for and saved: a failed face write warns and never fails narrate.
|
|
92
|
+
if (writeFace) await writeDraftFace(opts, fields.slug, htmlPath, { log }).catch((e) => log(`⚠ presenter: index.html not updated (${e?.message ?? e})`));
|
|
88
93
|
}
|
|
89
94
|
log(`✓ ${job.manifest.clips.length} clips, ${job.manifest.spokenDuration}s spoken, ${job.manifest.totalDuration}s with holds; ${job.renderedChars} characters rendered`);
|
|
90
95
|
log(` manifest: ${job.manifestUrl}\n saved: ${join(out, "manifest.json")}`);
|
package/src/opening.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The opening card a deck shows while its player loads (#207): the first
|
|
2
|
+
// slide blurred and breathing where the slide will be, and a glass card with
|
|
3
|
+
// the presenter's still hopping, the title and the player's real boot steps
|
|
4
|
+
// (body[data-hd-boot], player.js). When the player marks body[data-hd-ready]
|
|
5
|
+
// the presenter jumps, the card fades and the slide sharpens.
|
|
6
|
+
//
|
|
7
|
+
// One card, two ways in: a route that serves the deck adds it with the deck's
|
|
8
|
+
// poster and narrator (lib/deckLoadingOverlay.ts), and every deck's own
|
|
9
|
+
// loading state (loadingBlock.mjs, the templates) builds it in the page from
|
|
10
|
+
// what the file has (its <title>, the player tag's data-presenter-image), so a
|
|
11
|
+
// raw deck file opens on it too. The page's own card steps aside when a route
|
|
12
|
+
// already added one. Never in a frame, a thumbnail (?thumb,
|
|
13
|
+
// data-hyperdecks-thumb) or an export (?export); a deck ready within FAST_MS
|
|
14
|
+
// never shows it; if the player never says ready it goes after FAIL_OPEN_MS.
|
|
15
|
+
// No Node imports: the CLI, Convex and the app all use it.
|
|
16
|
+
|
|
17
|
+
export const FAST_MS = 150;
|
|
18
|
+
export const FAIL_OPEN_MS = 10000;
|
|
19
|
+
|
|
20
|
+
// The boot steps player.js reports on body[data-hd-boot], in the order it
|
|
21
|
+
// reaches them: [progress %, copy]. {name} is the presenter's.
|
|
22
|
+
export const BOOT_STEPS = Object.freeze({
|
|
23
|
+
page: [10, "Opening the deck…"],
|
|
24
|
+
player: [38, "Loading the player…"],
|
|
25
|
+
presenter: [62, "Waking up {name}…"],
|
|
26
|
+
narration: [84, "Getting the narration…"],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// The card's rules (the route wraps them in <style data-hd-opening>; the
|
|
30
|
+
// loading block carries them in its <style data-hd-loading>).
|
|
31
|
+
export const OPENING_CSS = `#hd-open{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:visible!important;animation:none!important;opacity:0;font-family:var(--hd-ui-font,"Space Grotesk",system-ui,sans-serif);color:#0b0b0f}
|
|
32
|
+
#hd-open.is-shown{opacity:1;transition:opacity .2s ease-out}
|
|
33
|
+
#hd-open.is-gone{opacity:0;transition:opacity .45s ease-out}
|
|
34
|
+
#hd-open .hdo-slide{position:absolute;inset:64px 24px 56px;margin:auto;width:min(100vw - 48px,(100dvh - 120px)*16/9);height:min(100dvh - 120px,(100vw - 48px)*9/16);border-radius:calc(min(100vw - 48px,(100dvh - 120px)*16/9)*.01125);overflow:hidden;container-type:inline-size;background:var(--hd-ground,#fff)}
|
|
35
|
+
#hd-open .hdo-slide img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;filter:blur(2.2cqi) saturate(1.15);transform:scale(1.12);animation:hdo-breathe 2.4s ease-in-out infinite}
|
|
36
|
+
#hd-open .hdo-slide::after{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.3) 50%,transparent 70%);background-size:250% 100%;animation:hdo-shim 1.8s ease-in-out infinite;mix-blend-mode:soft-light}
|
|
37
|
+
#hd-open.is-done .hdo-slide img{animation:hdo-deblur .5s cubic-bezier(.2,.8,.2,1) .5s forwards}
|
|
38
|
+
#hd-open.is-done .hdo-slide::after{opacity:0;transition:opacity .3s}
|
|
39
|
+
.hdo-card{position:absolute;left:50%;top:50%;translate:-50% -50%;width:min(86vw,420px);display:grid;justify-items:center;gap:.6em;padding:1.3em 1.5em 1.2em;border-radius:22px;background:rgba(255,255,255,.74);-webkit-backdrop-filter:blur(14px) saturate(1.3);backdrop-filter:blur(14px) saturate(1.3);box-shadow:4px 4px 0 #0b0b0f;border:1.5px solid #0b0b0f;text-align:center;box-sizing:border-box}
|
|
40
|
+
#hd-open.is-done .hdo-card{opacity:0;transform:scale(.96);transition:opacity .25s .5s,transform .25s .5s}
|
|
41
|
+
.hdo-buddy{position:relative;width:92px;height:96px;margin-top:-62px;display:grid;justify-items:center}
|
|
42
|
+
.hdo-hop{width:86px;height:80px;transform-origin:50% 100%;animation:hdo-hop 1.15s cubic-bezier(.3,0,.4,1) infinite}
|
|
43
|
+
.hdo-look{width:100%;height:100%;animation:hdo-look 4.6s ease-in-out infinite}
|
|
44
|
+
.hdo-buddy img{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 3px 0 rgba(11,11,15,.18))}
|
|
45
|
+
.hdo-shadow{position:absolute;bottom:4px;width:46px;height:8px;border-radius:50%;background:rgba(11,11,15,.22);animation:hdo-shadow 1.15s cubic-bezier(.3,0,.4,1) infinite}
|
|
46
|
+
.hdo-think{position:absolute;right:-30px;top:-6px;display:flex;gap:3px;padding:5px 8px;border-radius:14px;background:#fff;border:1.5px solid #0b0b0f;box-shadow:2px 2px 0 #0b0b0f}
|
|
47
|
+
.hdo-think::after{content:"";position:absolute;left:4px;bottom:-7px;width:7px;height:7px;border-radius:50%;background:#fff;border:1.5px solid #0b0b0f}
|
|
48
|
+
.hdo-think i{width:5px;height:5px;border-radius:50%;background:#0b0b0f;animation:hdo-tdot 1s ease-in-out infinite}
|
|
49
|
+
.hdo-think i:nth-child(2){animation-delay:.15s}.hdo-think i:nth-child(3){animation-delay:.3s}
|
|
50
|
+
.hdo-spark{position:absolute;left:50%;top:40%;width:8px;height:8px;margin:-4px;opacity:0}
|
|
51
|
+
.hdo-spark::before{content:"";position:absolute;inset:0;background:#0b0b0f;clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%)}
|
|
52
|
+
#hd-open.is-done .hdo-hop{animation:hdo-yay .55s cubic-bezier(.2,1.4,.4,1) forwards}
|
|
53
|
+
#hd-open.is-done :is(.hdo-look,.hdo-shadow){animation:none}
|
|
54
|
+
#hd-open.is-done .hdo-think{opacity:0;transition:opacity .15s}
|
|
55
|
+
#hd-open.is-done .hdo-spark{animation:hdo-spark .6s ease-out forwards}
|
|
56
|
+
.hdo-title{font-weight:700;font-size:18px;line-height:1.2;text-wrap:balance;overflow-wrap:anywhere}
|
|
57
|
+
.hdo-by{font:12.5px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#3d3b35}
|
|
58
|
+
.hdo-bar{width:100%;height:5px;border-radius:999px;background:rgba(11,11,15,.12);overflow:hidden}
|
|
59
|
+
.hdo-bar i{display:block;height:100%;width:8%;background:#0b0b0f;border-radius:999px;transition:width .35s ease-out}
|
|
60
|
+
.hdo-step{font:12px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#55534b;min-height:1.3em}
|
|
61
|
+
@keyframes hdo-breathe{50%{filter:blur(2.6cqi) saturate(1.25) brightness(1.05)}}
|
|
62
|
+
@keyframes hdo-shim{0%{background-position:120% 0}100%{background-position:-120% 0}}
|
|
63
|
+
@keyframes hdo-deblur{to{filter:blur(0);transform:none}}
|
|
64
|
+
@keyframes hdo-hop{0%,100%{transform:translateY(0) scale(1.06,.92)}12%{transform:translateY(0) scale(.95,1.06)}45%{transform:translateY(-14px) scale(1,1)}70%{transform:translateY(0) scale(1.04,.95)}82%{transform:translateY(0) scale(1,1)}}
|
|
65
|
+
@keyframes hdo-shadow{0%,100%{transform:scale(1.05);opacity:.9}45%{transform:scale(.62);opacity:.45}70%{transform:scale(1);opacity:.85}}
|
|
66
|
+
@keyframes hdo-look{0%,18%{transform:rotate(0)}24%,38%{transform:rotate(-9deg) translateX(-2px)}44%,58%{transform:rotate(8deg) translateX(2px)}64%,100%{transform:rotate(0)}}
|
|
67
|
+
@keyframes hdo-tdot{0%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
|
|
68
|
+
@keyframes hdo-yay{0%{transform:translateY(0) scale(1.08,.9)}40%{transform:translateY(-26px) scale(.94,1.08) rotate(-6deg)}100%{transform:translateY(-6px) scale(1) rotate(0)}}
|
|
69
|
+
@keyframes hdo-spark{0%{opacity:0;transform:translate(0,0) scale(.4)}30%{opacity:1}100%{opacity:0;transform:translate(var(--x),var(--y)) scale(1.2) rotate(90deg)}}
|
|
70
|
+
:is([data-hd-frame],[data-layout="overlay"]) #hd-open .hdo-slide{inset:0;width:min(100vw,100dvh*16/9);height:min(100dvh,100vw*9/16)}
|
|
71
|
+
@media (max-width:650px){#hd-open .hdo-slide{inset:52px 8px calc(56px + env(safe-area-inset-bottom));width:auto;height:auto;border-radius:14px}.hdo-card{width:min(84vw,320px);padding:1em 1.1em .95em}.hdo-title{font-size:15.5px}.hdo-buddy{transform:scale(.85);margin-top:-58px}}
|
|
72
|
+
@media (prefers-reduced-motion:reduce){#hd-open *,#hd-open *::before,#hd-open *::after{animation:none!important;transition:none!important}#hd-open .hdo-slide::after{display:none}#hd-open.is-done .hdo-card{opacity:1;transform:none}}
|
|
73
|
+
@media print{#hd-open{display:none!important}}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// Runs the card `o` (#hd-open): shows it after FAST_MS unless the deck is
|
|
77
|
+
// ready, follows data-hd-boot, and on data-hd-ready jumps, fades and removes
|
|
78
|
+
// itself. In a frame, a thumbnail or an export it removes itself at once.
|
|
79
|
+
export const OPENING_RUN = `function(o){var b=document.body,d=document.documentElement;if(!o||!b)return;
|
|
80
|
+
if(self!==top||/[?&](thumb|export)\\b/.test(location.search)||d.hasAttribute("data-hyperdecks-thumb")){o.remove();return}
|
|
81
|
+
var S=${JSON.stringify(BOOT_STEPS)},bar=o.querySelector(".hdo-bar i"),step=o.querySelector(".hdo-step"),who=o.getAttribute("data-name")||"the presenter",at=0,shown=false,done=false,mo;
|
|
82
|
+
var rm=window.matchMedia&&matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
83
|
+
function boot(){var s=S[b.getAttribute("data-hd-boot")];if(!s||s[0]<=at)return;at=s[0];bar.style.width=s[0]+"%";step.textContent=s[1].split("{name}").join(who)}
|
|
84
|
+
function finish(){if(done)return;done=true;if(mo)mo.disconnect();clearTimeout(t1);clearTimeout(t2);
|
|
85
|
+
if(!shown){o.remove();return}bar.style.width="100%";step.textContent="Ready";
|
|
86
|
+
o.classList.add("is-done");setTimeout(function(){o.classList.add("is-gone")},rm?0:500);setTimeout(function(){o.remove()},rm?500:1000)}
|
|
87
|
+
function check(){if(b.hasAttribute("data-hd-ready"))finish();else boot()}
|
|
88
|
+
var t1=setTimeout(function(){if(!done){shown=true;o.classList.add("is-shown")}},${FAST_MS});
|
|
89
|
+
var t2=setTimeout(finish,${FAIL_OPEN_MS});
|
|
90
|
+
step.textContent=S.page[1];
|
|
91
|
+
if(window.MutationObserver){mo=new MutationObserver(check);mo.observe(b,{attributes:true,attributeFilter:["data-hd-ready","data-hd-boot"]})}
|
|
92
|
+
check()}`;
|
|
93
|
+
|
|
94
|
+
// Five sparks around the presenter for the jump at ready.
|
|
95
|
+
export const OPENING_SPARKS = [[-40, -30], [42, -34], [-30, 10], [36, 6], [0, -46]]
|
|
96
|
+
.map(([x, y]) => `<span class="hdo-spark" style="--x:${x}px;--y:${y}px"></span>`).join("");
|
|
97
|
+
// The presenter's still in the card (its src set by whoever builds it).
|
|
98
|
+
export const OPENING_BUDDY = `<div class="hdo-buddy"><div class="hdo-hop"><div class="hdo-look"><img src="" alt=""></div></div><div class="hdo-shadow"></div><div class="hdo-think"><i></i><i></i><i></i></div>${OPENING_SPARKS}</div>`;
|
|
99
|
+
|
|
100
|
+
// The page's own card (the loading block's <script data-hd-opening>): once the
|
|
101
|
+
// page is parsed (readyState "interactive", before the player module runs),
|
|
102
|
+
// unless a route already added a card, the player is ready, or the page is
|
|
103
|
+
// framed, a thumbnail or an export, it puts a card at the top of <body> with
|
|
104
|
+
// the page's <title> and the player tag's presenter still (none when it
|
|
105
|
+
// presents nobody), with no poster and no narrator's name, and runs it.
|
|
106
|
+
export const OPENING_PAGE_SCRIPT = `<script data-hd-opening>(function(){var d=document;
|
|
107
|
+
if(self!==top||/[?&](thumb|export)\\b/.test(location.search))return;
|
|
108
|
+
function go(){if(d.readyState==="loading")return;d.removeEventListener("readystatechange",go);
|
|
109
|
+
var b=d.body,p=d.querySelector('script[src*="/player/v"][src*="/player.js"]');
|
|
110
|
+
if(!b||!p||d.getElementById("hd-open")||b.hasAttribute("data-hd-ready")||d.documentElement.hasAttribute("data-hyperdecks-thumb"))return;
|
|
111
|
+
var o=d.createElement("div"),img=p.getAttribute("data-presenter")!=="none"&&p.getAttribute("data-presenter-image");
|
|
112
|
+
o.id="hd-open";o.setAttribute("aria-hidden","true");
|
|
113
|
+
o.innerHTML='<div class="hdo-slide"></div><div class="hdo-card">'+(img?${JSON.stringify(OPENING_BUDDY)}:'')+'<div class="hdo-title"></div><div class="hdo-bar"><i></i></div><div class="hdo-step"></div></div>';
|
|
114
|
+
if(img){var i=o.querySelector(".hdo-buddy img");i.addEventListener("error",function(){var y=o.querySelector(".hdo-buddy");if(y)y.remove()});i.src=img}
|
|
115
|
+
o.querySelector(".hdo-title").textContent=(d.title||"").replace(/\\s+/g," ").trim();
|
|
116
|
+
b.insertBefore(o,b.firstChild);(${OPENING_RUN})(o)}
|
|
117
|
+
d.addEventListener("readystatechange",go);go()})();</script>`;
|
package/src/ops.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { zipSync } from "fflate";
|
|
|
3
3
|
import { SPECIES } from "./species.mjs";
|
|
4
4
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
5
5
|
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
6
|
-
import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
6
|
+
import { api, backupLinkUrl, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
7
7
|
import {
|
|
8
8
|
bytesFromText,
|
|
9
9
|
isTextAsset,
|
|
@@ -21,6 +21,17 @@ export async function whoami(opts = {}) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
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
|
+
|
|
24
35
|
export async function listDecks(opts = {}, { all = false } = {}) {
|
|
25
36
|
const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
|
|
26
37
|
return rows.map((d) => ({
|
|
@@ -39,6 +50,9 @@ export async function listDecks(opts = {}, { all = false } = {}) {
|
|
|
39
50
|
// false: the deck runs its own player, so view/viewToggle do not apply
|
|
40
51
|
// (null: not known yet, treated as on).
|
|
41
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,
|
|
42
56
|
}));
|
|
43
57
|
}
|
|
44
58
|
|
|
@@ -568,6 +582,8 @@ export async function listShares(opts, slug) {
|
|
|
568
582
|
// viewer who forwarded it. Rows come back with each link's forwards after it.
|
|
569
583
|
allowForward: !!r.allowForward,
|
|
570
584
|
allowForwardOverride: r.allowForwardOverride ?? null,
|
|
585
|
+
// The link's own pace (auto, slide, step); null = it follows the deck.
|
|
586
|
+
pace: r.pace ?? null,
|
|
571
587
|
parentToken: r.parentToken ?? null,
|
|
572
588
|
forwardedBy: r.forwardedBy ?? null,
|
|
573
589
|
probablyForwarded: !!r.probablyForwarded,
|
|
@@ -584,6 +600,11 @@ export async function createShare(opts, fields) {
|
|
|
584
600
|
if (fields.note) args.note = fields.note;
|
|
585
601
|
if (fields.requireEmail) args.requireEmail = true;
|
|
586
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
|
+
}
|
|
587
608
|
if (fields.expires) {
|
|
588
609
|
const t = Date.parse(fields.expires);
|
|
589
610
|
if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
|
|
@@ -1191,8 +1212,9 @@ export async function presenterFor(opts, slug, voice) {
|
|
|
1191
1212
|
}
|
|
1192
1213
|
|
|
1193
1214
|
// The same answer as presenterFor plus who presents in words: { presenter, who }.
|
|
1194
|
-
|
|
1195
|
-
|
|
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 } : {}) });
|
|
1196
1218
|
}
|
|
1197
1219
|
|
|
1198
1220
|
// ---- narrators (convex/narrators.ts) ------------------------------------------
|
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 });
|