@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/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
|
}
|
|
@@ -158,28 +173,32 @@ async function deckForward(pos, opts) {
|
|
|
158
173
|
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
159
174
|
}
|
|
160
175
|
|
|
161
|
-
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]";
|
|
176
|
+
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
|
|
162
177
|
const VIEWS = { slides: "slides", scroll: "scroll", default: null };
|
|
163
178
|
const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
|
|
164
179
|
async function deckView(pos, opts) {
|
|
165
180
|
const [slug, mode] = pos;
|
|
166
181
|
const toggle = opts.toggle;
|
|
167
182
|
const pace = opts.pace;
|
|
168
|
-
|
|
183
|
+
const ai = opts["ai-readable"];
|
|
184
|
+
if (!slug || (mode === undefined && !["toggle", "pace", "ai-readable"].some((k) => Object.hasOwn(opts, k)))) throw new Error(VIEW_USAGE);
|
|
169
185
|
if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
|
|
170
186
|
// A trailing --toggle with no value is a mistake, not "leave it".
|
|
171
187
|
if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
|
|
172
188
|
if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
|
|
189
|
+
if (Object.hasOwn(opts, "ai-readable") && ai !== "on" && ai !== "off") throw new Error(VIEW_USAGE);
|
|
173
190
|
const out = await ops.setDeckView(opts, slug, {
|
|
174
191
|
view: mode === undefined ? undefined : VIEWS[mode],
|
|
175
192
|
viewToggle: toggle === undefined ? undefined : toggle === "on",
|
|
176
193
|
pace: pace === undefined ? undefined : PACES[pace],
|
|
194
|
+
aiReadable: ai === undefined ? undefined : ai === "on",
|
|
177
195
|
});
|
|
178
196
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
179
197
|
const said = [];
|
|
180
198
|
if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
|
|
181
199
|
if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
|
|
182
200
|
if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
|
|
201
|
+
if (ai !== undefined) said.push(`AI-readable version (/v/<token>/md) ${ai}`);
|
|
183
202
|
if (out.applies === false) {
|
|
184
203
|
console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
|
|
185
204
|
console.warn(`warning: ${out.warning}`);
|
|
@@ -256,7 +275,7 @@ async function engagement(pos, opts) {
|
|
|
256
275
|
console.log(` visits: ${s.visits} (last ${new Date(s.lastSeenAt).toISOString().slice(0, 16).replace("T", " ")}) · ${fmtMs(s.totalMs)} engaged`);
|
|
257
276
|
console.log(` narration: ${s.narration.finished ? "finished" : s.narration.started ? "started" : "not started"} · heard ${pct(s.narration.heardPct)}${s.narration.telemetry === "v3" ? "" : " (playback detail unavailable)"}`);
|
|
258
277
|
console.log(` reached: slide ${s.furthestSlide}${e.deck.slides ? ` of ${e.deck.slides}` : ""}${s.droppedOffOn ? ` · dropped off on ${slideName(s.droppedOffOn)}` : ""}`);
|
|
259
|
-
console.log(` replayed: ${list(s.replayedSlides)}`);
|
|
278
|
+
console.log(` replayed: ${list(s.replayedSlides)}${s.replayNote ? ` · ${s.replayNote}` : ""}`);
|
|
260
279
|
console.log(` skipped: ${list(s.skippedSlides)}`);
|
|
261
280
|
console.log(` most time: ${s.mostTime.map((m) => `${slideName(m)} (${fmtMs(m.ms)})`).join(", ") || "none"}`);
|
|
262
281
|
if (!s.slideData) console.log(" slide data unavailable (this deck reports time only)");
|
|
@@ -344,12 +363,16 @@ commands:
|
|
|
344
363
|
login [--token <pat>] [--broker <url>] [--dev]
|
|
345
364
|
logout [--dev]
|
|
346
365
|
whoami [--json] [--dev]
|
|
347
|
-
deck list|stats|open|rm|forward|view
|
|
366
|
+
deck list|stats|open|link|rm|forward|view
|
|
367
|
+
deck link <slug> [--json]
|
|
368
|
+
the owner link: present or check your own deck (only you, signed in; untracked;
|
|
369
|
+
Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
|
|
348
370
|
deck forward on|off <slug>
|
|
349
371
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
350
|
-
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]
|
|
372
|
+
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
|
|
351
373
|
how the deck opens in the player (default: as its HTML says, data-view or slides) and
|
|
352
374
|
whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
|
|
375
|
+
--ai-readable: whether each link + /md serves the deck as markdown for AI assistants (on by default).
|
|
353
376
|
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
|
|
354
377
|
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
|
|
355
378
|
in your saved brand's colours, fonts and logo (--no-brand: the template's look)
|
|
@@ -364,7 +387,7 @@ commands:
|
|
|
364
387
|
source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
|
|
365
388
|
move a hosted draft (index.html, script.md, evidence.md) to a folder and back
|
|
366
389
|
asset add <slug> <file|url> [--name n]
|
|
367
|
-
file an image, video
|
|
390
|
+
file an image, video, audio (mp3, m4a), font or JSON under the deck; prints its URL
|
|
368
391
|
guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
|
|
369
392
|
the skill, one topic at a time (what deck_guide serves)
|
|
370
393
|
play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
@@ -373,7 +396,7 @@ commands:
|
|
|
373
396
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
|
|
374
397
|
on a committed deck: check → narrate → import → share new per --to
|
|
375
398
|
share new|list|on|off|forward|mute|unmute|rm|url
|
|
376
|
-
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
|
|
399
|
+
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]
|
|
377
400
|
share forward on|off|default <token>
|
|
378
401
|
let this link's viewer forward it as their own tracked link ("Dana via Peter",
|
|
379
402
|
one hop); default follows the deck. share off on a link also turns off its forwards.
|
|
@@ -898,8 +921,11 @@ function commandTable(pos, rest, opts) {
|
|
|
898
921
|
},
|
|
899
922
|
"avatar from-photo": async () => {
|
|
900
923
|
const { avatarFromPhoto } = await import("./avatar.mjs");
|
|
901
|
-
const
|
|
924
|
+
const { askLine } = await import("./init.mjs");
|
|
925
|
+
const out = await avatarFromPhoto(rest[0], opts, { ask: askLine });
|
|
902
926
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
927
|
+
// A pet photo is still a failed drawing: the offer is printed, the exit says so.
|
|
928
|
+
if (out?.status === "failed") process.exitCode = 1;
|
|
903
929
|
},
|
|
904
930
|
"avatar connect": async () => {
|
|
905
931
|
const { avatarConnect } = await import("./avatar.mjs");
|
|
@@ -964,9 +990,20 @@ function commandTable(pos, rest, opts) {
|
|
|
964
990
|
console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
|
|
965
991
|
},
|
|
966
992
|
"deck narrator": async () => {
|
|
967
|
-
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
|
|
993
|
+
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default> [--dir <deck folder>]");
|
|
968
994
|
const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
969
|
-
|
|
995
|
+
const log = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
996
|
+
log(`OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
|
|
997
|
+
// The local file names the face too, so the draft opened from disk shows it.
|
|
998
|
+
const { writeDraftFace } = await import("./draftFace.mjs");
|
|
999
|
+
const dir = resolve(typeof opts.dir === "string" ? opts.dir : join("decks", out.slug));
|
|
1000
|
+
let local = { wrote: false };
|
|
1001
|
+
try {
|
|
1002
|
+
local = await writeDraftFace(opts, out.slug, join(dir, "index.html"), { log });
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
log(`⚠ presenter: the server was updated but index.html was not (${e.message})`);
|
|
1005
|
+
}
|
|
1006
|
+
if (opts.json) console.log(JSON.stringify({ ...out, indexHtml: local.wrote ? join(dir, "index.html") : null }, null, 2));
|
|
970
1007
|
},
|
|
971
1008
|
"preview setup": async () => {
|
|
972
1009
|
const { setupCapture } = await import("./preview-capture.mjs");
|
|
@@ -978,6 +1015,7 @@ function commandTable(pos, rest, opts) {
|
|
|
978
1015
|
"deck list": () => deckList(opts),
|
|
979
1016
|
"deck stats": () => deckStats(rest, opts),
|
|
980
1017
|
"deck open": () => deckOpen(rest, opts),
|
|
1018
|
+
"deck link": () => deckLink(rest, opts),
|
|
981
1019
|
"deck rm": () => deckRemove(rest, opts),
|
|
982
1020
|
"deck forward": () => deckForward(rest, opts),
|
|
983
1021
|
"deck view": () => deckView(rest, opts),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The narrator's face written into a draft's index.html on disk (roadmap:
|
|
2
|
+
// a-narrated-draft-shows-its-narrator-s-face-from-the-file-on-), so the file
|
|
3
|
+
// plays with its face however it is opened, not only through play and release.
|
|
4
|
+
// deck new, deck narrator and narrate use it.
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import * as ops from "./ops.mjs";
|
|
7
|
+
import { DASH_PRESENTER, withNarrator, writeNarratorFace } from "./presenter.mjs";
|
|
8
|
+
|
|
9
|
+
// What to write for `slug`: { face } (null: captions only on purpose), or
|
|
10
|
+
// { face: undefined, why } when the narrator has no face and nobody chose captions
|
|
11
|
+
// only (writeDraftFace then takes a CLI-written face off), or the lookup failed
|
|
12
|
+
// (`failed`: nothing is decided, the file stays).
|
|
13
|
+
export async function draftFace(opts, slug) {
|
|
14
|
+
let found;
|
|
15
|
+
try {
|
|
16
|
+
found = await ops.presenterWithReason(opts, slug, undefined, { draft: true });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { face: undefined, why: `could not look up the narrator's face (${e.message})`, failed: true };
|
|
19
|
+
}
|
|
20
|
+
if (found?.presenter) return { face: found.presenter, name: found.who?.face ?? null };
|
|
21
|
+
// Captions only on purpose only when the deck says so; faces off or a missing face stays unwritten.
|
|
22
|
+
if (!found?.presenter && found?.captionsOnly) return { face: null, name: null };
|
|
23
|
+
return { face: undefined, why: found?.who?.reason ?? "no narrator with a face" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Write `slug`'s face into `htmlPath`. `fallback`: the face to write when the
|
|
27
|
+
// lookup fails (deck new writes D.A.S.H.). Logs one line; returns what it wrote.
|
|
28
|
+
export async function writeDraftFace(opts, slug, htmlPath, { log = () => {}, fallback } = {}) {
|
|
29
|
+
if (!existsSync(htmlPath)) {
|
|
30
|
+
log(`⚠ presenter: ${htmlPath} not found, so the file was not updated (the server was); pass --dir <deck folder>`);
|
|
31
|
+
return { wrote: false };
|
|
32
|
+
}
|
|
33
|
+
const got = await draftFace(opts, slug);
|
|
34
|
+
const face = got.face === undefined && got.failed && fallback ? fallback : got.face;
|
|
35
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
36
|
+
if (face === undefined) {
|
|
37
|
+
// The narrator has no face now: a face the CLI wrote for an earlier one comes off, as play and release do.
|
|
38
|
+
const off = got.failed ? html : withNarrator(html, null, { face: null });
|
|
39
|
+
if (off !== html) {
|
|
40
|
+
writeFileSync(htmlPath, off);
|
|
41
|
+
log(`⚠ presenter: no face (${got.why}); the earlier narrator's face was taken off index.html`);
|
|
42
|
+
return { wrote: true, face: null, why: got.why };
|
|
43
|
+
}
|
|
44
|
+
log(`⚠ presenter: index.html left as written: ${got.why}`);
|
|
45
|
+
return { wrote: false, why: got.why };
|
|
46
|
+
}
|
|
47
|
+
const out = writeNarratorFace(html, face);
|
|
48
|
+
if (!out.wrote) {
|
|
49
|
+
log(`⚠ presenter: index.html left as written: ${out.why}`);
|
|
50
|
+
return { wrote: false, why: out.why };
|
|
51
|
+
}
|
|
52
|
+
if (out.html !== html) writeFileSync(htmlPath, out.html);
|
|
53
|
+
const who = face === null ? "captions only" : face === DASH_PRESENTER && got.failed ? `D.A.S.H. (${got.why}; deck narrator <name> fixes it)` : got.name ?? "the narrator's face";
|
|
54
|
+
log(` presenter: ${who}, written to index.html`);
|
|
55
|
+
return { wrote: true, face };
|
|
56
|
+
}
|
package/src/export-video.mjs
CHANGED
|
@@ -115,7 +115,8 @@ export function planTimeline(plan) {
|
|
|
115
115
|
export function audioTimeline(log, markMs, lead = 0) {
|
|
116
116
|
const audioOf = new Map((log.plan ?? []).map((s) => [s.id, s.audio]));
|
|
117
117
|
return (log.clips ?? [])
|
|
118
|
-
|
|
118
|
+
// A guest line's entry names its own audio; `seek`: the slide's track picking up again after one.
|
|
119
|
+
.map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000), ...(c.seek > 0 ? { seek: c.seek } : {}) }))
|
|
119
120
|
.filter((c) => c.audio);
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -125,7 +126,8 @@ export function audioTimeline(log, markMs, lead = 0) {
|
|
|
125
126
|
export function timelineDrift(log, markMs) {
|
|
126
127
|
const plan = planTimeline(log.plan ?? []);
|
|
127
128
|
const startOf = new Map(plan.ids.map((id, i) => [id, plan.starts[i]]));
|
|
128
|
-
|
|
129
|
+
// Only each slide's own start: guest lines and resumes (entries with their own audio) are inside it.
|
|
130
|
+
return (log.clips ?? []).filter((c) => startOf.has(c.id) && !c.audio).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
// ffmpeg args for the audio track, `seconds` long: silence up to the first
|
|
@@ -147,7 +149,9 @@ export function audioMixArgs({ inputs, seconds, out }) {
|
|
|
147
149
|
}
|
|
148
150
|
clips.forEach((c, n) => {
|
|
149
151
|
const end = n + 1 < clips.length ? clips[n + 1].offset : seconds;
|
|
150
|
-
|
|
152
|
+
// `seek`: start that far into the file (a slide's track resuming after a guest line).
|
|
153
|
+
const from = c.seek > 0 ? `,atrim=start=${Number(c.seek).toFixed(3)},asetpts=PTS-STARTPTS` : '';
|
|
154
|
+
parts.push(`[${n}:a]${fmt}${from},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
|
|
151
155
|
labels.push(`[c${n}]`);
|
|
152
156
|
});
|
|
153
157
|
parts.push(`${labels.join('')}concat=n=${labels.length}:v=0:a=1[mix]`);
|
|
@@ -185,7 +189,7 @@ export const SEAM_SECONDS = 2;
|
|
|
185
189
|
export const SEAM_FRAMES = SEAM_SECONDS * VIDEO_FPS;
|
|
186
190
|
export const MAX_SPLIT_CLIPS = 200;
|
|
187
191
|
export function splittableAudio(plan) {
|
|
188
|
-
const audio = plan.
|
|
192
|
+
const audio = plan.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean);
|
|
189
193
|
return audio.length <= MAX_SPLIT_CLIPS && audio.every((a) => typeof a === 'string' && a.length <= MAX_SPLIT_AUDIO_URL && /^https?:\/\//i.test(a));
|
|
190
194
|
}
|
|
191
195
|
|
|
@@ -223,7 +227,7 @@ export function joinTimeline(segments, fps = VIDEO_FPS) {
|
|
|
223
227
|
let frames = 0;
|
|
224
228
|
const clips = [];
|
|
225
229
|
for (const s of segments) {
|
|
226
|
-
for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)) });
|
|
230
|
+
for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)), ...(c.seek > 0 ? { seek: c.seek } : {}) });
|
|
227
231
|
frames += s.frames;
|
|
228
232
|
}
|
|
229
233
|
return { clips, frames, seconds: frames / fps };
|
|
@@ -503,8 +507,9 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
503
507
|
// A range needs only its own slides' clips.
|
|
504
508
|
const audioFiles = new Map(), clipLengths = {};
|
|
505
509
|
const wanted = (state.plan ?? []).filter((s) => inRange(s.id));
|
|
506
|
-
|
|
507
|
-
|
|
510
|
+
// A slide's guest lines are audio of their own.
|
|
511
|
+
for (const [n, url] of [...new Set(wanted.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean))].entries()) {
|
|
512
|
+
const id = wanted.find((s) => s.audio === url || s.guests?.includes(url)).id;
|
|
508
513
|
const path = clipPath(temp, n, url);
|
|
509
514
|
await downloadClip(url, path, { id, entryUrl: pageUrl });
|
|
510
515
|
audioFiles.set(url, path);
|
|
@@ -643,13 +648,13 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
643
648
|
file: out, seconds: kept / VIDEO_FPS, width: geometry.width, height: geometry.height, bytes, slides: to - from + 1, clips: own.length, render, warnings,
|
|
644
649
|
// frames: in the file. A clip's offset is from the file's first frame:
|
|
645
650
|
// after a dropped seam, a slide's clip may start (just) before it.
|
|
646
|
-
segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS })) },
|
|
651
|
+
segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS, ...(c.seek ? { seek: c.seek } : {}) })) },
|
|
647
652
|
};
|
|
648
653
|
}
|
|
649
654
|
const inputs = clips.map((c) => {
|
|
650
655
|
const path = audioFiles.get(c.audio);
|
|
651
656
|
if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
|
|
652
|
-
return { path, offset: c.offset };
|
|
657
|
+
return { path, offset: c.offset, seek: c.seek };
|
|
653
658
|
});
|
|
654
659
|
const audio = join(temp, 'audio.wav');
|
|
655
660
|
await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
|
|
@@ -695,7 +700,7 @@ export async function joinVideo({ segments, out, origin = null }, { log = () =>
|
|
|
695
700
|
}
|
|
696
701
|
phase = 'audio';
|
|
697
702
|
const audio = join(temp, 'audio.wav');
|
|
698
|
-
await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset })), seconds: timeline.seconds, out: audio }), 300000);
|
|
703
|
+
await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset, seek: c.seek })), seconds: timeline.seconds, out: audio }), 300000);
|
|
699
704
|
phase = 'join';
|
|
700
705
|
const list = join(temp, 'segments.txt');
|
|
701
706
|
await writeFile(list, concatList(segments.map((s) => s.file)));
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The pixels behind mediaPreviews.mjs: ffmpeg (the @ffmpeg-installer build the
|
|
2
|
+
// CLI's video export and Convex's videoStack already use) reads one image or a
|
|
3
|
+
// video's first frame and writes
|
|
4
|
+
// - a ~20 px wide JPEG as a data URL (the blur preview, well under 1 KB), and
|
|
5
|
+
// - for a video, a poster: its first frame as a JPEG at most 1280 px wide.
|
|
6
|
+
// Used by the CLI release (local files) and convex/mediaPreviewsNode.ts (fetched
|
|
7
|
+
// bytes written to a temp file). The size comes from ffmpeg's own stream line.
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { streamSize } from "./mediaPreviews.mjs";
|
|
10
|
+
|
|
11
|
+
export const PREVIEW_WIDTH = 20;
|
|
12
|
+
const TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
function run(ffmpeg, args) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
execFile(ffmpeg, args, { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
17
|
+
const text = stderr.toString("utf8");
|
|
18
|
+
if (err) {
|
|
19
|
+
const why = err.killed ? `ffmpeg timed out after ${TIMEOUT_MS / 1000}s` : text.trim().split("\n").slice(-2).join(" ") || err.message;
|
|
20
|
+
return reject(new Error(why.slice(0, 300)));
|
|
21
|
+
}
|
|
22
|
+
resolve({ stdout, stderr: text });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Only local files by default: a "video" that is really a playlist (HLS) would
|
|
28
|
+
// otherwise make ffmpeg fetch its segment URLs itself, past any SSRF guard.
|
|
29
|
+
// Nested demuxers inherit the whitelist. The CLI widens it for an https input.
|
|
30
|
+
export const LOCAL_ONLY = "file,pipe";
|
|
31
|
+
export const HTTPS_TOO = "file,pipe,https,tls,tcp,crypto";
|
|
32
|
+
// A picture with more pixels than this (a "pixel bomb": a small file that
|
|
33
|
+
// decodes to gigabytes) is refused by the decoder instead of exhausting memory.
|
|
34
|
+
export const MAX_PIXELS = 64 * 1024 * 1024;
|
|
35
|
+
const frameArgs = (input, allow, vf, q, codec = "mjpeg") => ["-hide_banner", "-nostdin", "-protocol_whitelist", allow, "-max_pixels", String(MAX_PIXELS), "-i", input, "-map", "0:v:0", "-frames:v", "1", "-vf", vf, ...(codec === "mjpeg" ? ["-q:v", String(q)] : []), "-f", "image2pipe", "-c:v", codec, "pipe:1"];
|
|
36
|
+
// A picture with an alpha channel (a background-removed cutout) keeps it: a
|
|
37
|
+
// JPEG preview would put a black box behind the cutout while it loads.
|
|
38
|
+
export const hasAlpha = (stderr) => /Stream #[^\n]*?Video:[^\n]*?\b(rgba|bgra|argb|abgr|ya8|ya16\w*|yuva\w*|gbrap\w*|rgba64\w*|pal8)\b/.test(stderr ?? "");
|
|
39
|
+
|
|
40
|
+
// { w, h, blur, poster? } for one file (a path, or a URL ffmpeg can open).
|
|
41
|
+
// `poster` (video only) is the JPEG bytes; the caller stores it and swaps in its URL.
|
|
42
|
+
// A picture or video container by its first bytes: JPEG, PNG, GIF, WebP, ISO
|
|
43
|
+
// BMFF (MP4/MOV/AVIF/HEIC) or EBML (WebM). Anything else (an HLS playlist, an
|
|
44
|
+
// HTML error page) never reaches ffmpeg.
|
|
45
|
+
export function knownMedia(bytes) {
|
|
46
|
+
const b = bytes.subarray(0, 12), ascii = (from, to) => String.fromCharCode(...b.subarray(from, to));
|
|
47
|
+
return (b[0] === 0xff && b[1] === 0xd8) || ascii(1, 4) === "PNG" || ascii(0, 3) === "GIF"
|
|
48
|
+
|| (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") || ascii(4, 8) === "ftyp"
|
|
49
|
+
|| (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function makePreview(ffmpeg, input, { video = false, allow = LOCAL_ONLY } = {}) {
|
|
53
|
+
let small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=yuvj420p`, 12));
|
|
54
|
+
const size = streamSize(small.stderr);
|
|
55
|
+
if (!size) throw new Error(`no picture in ${input}`);
|
|
56
|
+
let type = "image/jpeg";
|
|
57
|
+
if (!video && hasAlpha(small.stderr)) {
|
|
58
|
+
small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=rgba`, 0, "png"));
|
|
59
|
+
type = "image/png";
|
|
60
|
+
}
|
|
61
|
+
if (!small.stdout.length) throw new Error(`ffmpeg wrote no preview for ${input}`);
|
|
62
|
+
const out = { ...size, blur: `data:${type};base64,${small.stdout.toString("base64")}` };
|
|
63
|
+
if (!video) return out;
|
|
64
|
+
const frame = await run(ffmpeg, frameArgs(input, allow, "scale='min(1280,iw)':-2,format=yuvj420p", 4));
|
|
65
|
+
if (!frame.stdout.length) throw new Error(`ffmpeg wrote no poster for ${input}`);
|
|
66
|
+
return { ...out, poster: frame.stdout };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The bundled ffmpeg's path, loaded only when release needs it.
|
|
70
|
+
export async function bundledFfmpeg() {
|
|
71
|
+
return (await import("@ffmpeg-installer/ffmpeg")).default.path;
|
|
72
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Blur previews for a deck's media, added at release (CLI release.mjs and the
|
|
2
|
+
// hosted release in convex/deckSources.ts). Pure string work, no Node APIs, so
|
|
3
|
+
// the Convex mutation can run it; the pixels come from mediaPreviewMake.mjs.
|
|
4
|
+
//
|
|
5
|
+
// mediaRefs(html) lists what needs a preview: each <img>, each <video> without
|
|
6
|
+
// a poster (its first frame becomes one) and each inline background image.
|
|
7
|
+
// withMediaPreviews(html, previews) writes them onto the tags:
|
|
8
|
+
// data-hd-blur="data:image/jpeg;base64,…" a ~20 px wide preview (< 1 KB)
|
|
9
|
+
// style="--hd-ar:W/H;…" so the box has its shape before the pixels arrive
|
|
10
|
+
// (player.css: :where([data-hd-blur]) { aspect-ratio: var(--hd-ar) })
|
|
11
|
+
// poster="…" a <video> that had none
|
|
12
|
+
// The player (packages/player/media-load.js) shows the preview, blurred and
|
|
13
|
+
// shimmering, until the real media is ready. Idempotent: a tag that already has
|
|
14
|
+
// data-hd-blur (or opts out with data-hd-no-blur) is left as it is, so a
|
|
15
|
+
// re-release changes nothing.
|
|
16
|
+
|
|
17
|
+
// Media this small is an icon: a blurred icon is noise, not a preview.
|
|
18
|
+
export const MIN_PREVIEW_SIDE = 64;
|
|
19
|
+
// The longest data URL written onto a tag; a bigger preview is dropped.
|
|
20
|
+
export const MAX_BLUR_CHARS = 1600;
|
|
21
|
+
|
|
22
|
+
const SKIP = /^(data:|blob:|javascript:|#)/i;
|
|
23
|
+
const VECTOR = /\.svg(?:[?#]|$)/i;
|
|
24
|
+
|
|
25
|
+
export function tagAttr(tag, name) {
|
|
26
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i").exec(tag);
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
return (m[1] ?? m[2] ?? m[3]).replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'");
|
|
29
|
+
}
|
|
30
|
+
const hasAttr = (tag, name) => new RegExp(`\\s${name}(?:\\s*=|[\\s/>])`, "i").test(tag);
|
|
31
|
+
|
|
32
|
+
function usable(src) {
|
|
33
|
+
const s = (src ?? "").trim();
|
|
34
|
+
return s && !SKIP.test(s) && !VECTOR.test(s) ? s : null;
|
|
35
|
+
}
|
|
36
|
+
// A tag the author sized as an icon (both sides given and small) is skipped up front.
|
|
37
|
+
function iconSized(tag) {
|
|
38
|
+
const w = Number(tagAttr(tag, "width")), h = Number(tagAttr(tag, "height"));
|
|
39
|
+
return w > 0 && h > 0 && Math.max(w, h) < MIN_PREVIEW_SIDE;
|
|
40
|
+
}
|
|
41
|
+
const done = (tag) => hasAttr(tag, "data-hd-blur") || hasAttr(tag, "data-hd-no-blur");
|
|
42
|
+
|
|
43
|
+
// Tags are matched quote-aware, so a `>` inside an attribute value (alt="a > b") does not end the tag.
|
|
44
|
+
const ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
|
|
45
|
+
const IMG = new RegExp(`<img\\b${ATTRS}>`, "gi");
|
|
46
|
+
const VIDEO_OPEN = new RegExp(`^<video\\b${ATTRS}>`, "i");
|
|
47
|
+
const VIDEO = new RegExp(`<video\\b${ATTRS}>([\\s\\S]*?)<\\/video\\s*>`, "gi");
|
|
48
|
+
const STYLED = new RegExp(`<[a-z][\\w-]*\\b(?:[^>"']|"[^"]*"|'[^']*')*?\\sstyle\\s*=\\s*(?:"[^"]*"|'[^']*')${ATTRS}>`, "gi");
|
|
49
|
+
// Script, style, comment and template text is code, not markup: never read or rewritten.
|
|
50
|
+
const OPAQUE = /<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>|<!--[\s\S]*?-->|<template\b[\s\S]*?<\/template\s*>/gi;
|
|
51
|
+
function markupOnly(html, rewrite) {
|
|
52
|
+
const kept = [];
|
|
53
|
+
const masked = html.replace(OPAQUE, (m) => `\u0000${kept.push(m) - 1}\u0000`);
|
|
54
|
+
return rewrite(masked).replace(/\u0000(\d+)\u0000/g, (_, i) => kept[Number(i)]);
|
|
55
|
+
}
|
|
56
|
+
const BG_URL = /background(?:-image)?\s*:[^;]*?url\(\s*(?:"([^"]+)"|'([^']+)'|([^)\s]+))\s*\)/i;
|
|
57
|
+
|
|
58
|
+
function videoSrc(open, body) {
|
|
59
|
+
return usable(tagAttr(open, "src")) ?? usable([...body.matchAll(/<source\b[^>]*>/gi)].map((m) => tagAttr(m[0], "src")).find(usable));
|
|
60
|
+
}
|
|
61
|
+
function bgSrc(tag) {
|
|
62
|
+
const m = BG_URL.exec(tagAttr(tag, "style") ?? "");
|
|
63
|
+
return m ? usable(m[1] ?? m[2] ?? m[3]) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What release must make previews for: { images: [src], videos: [src] }, each
|
|
67
|
+
// src once, as written in the page (relative or absolute).
|
|
68
|
+
export function mediaRefs(page) {
|
|
69
|
+
const images = new Set(), videos = new Set();
|
|
70
|
+
const html = page.replace(OPAQUE, "");
|
|
71
|
+
for (const [tag] of html.matchAll(IMG)) {
|
|
72
|
+
const src = usable(tagAttr(tag, "src"));
|
|
73
|
+
if (src && !done(tag) && !iconSized(tag)) images.add(src);
|
|
74
|
+
}
|
|
75
|
+
for (const m of html.matchAll(VIDEO)) {
|
|
76
|
+
const open = VIDEO_OPEN.exec(m[0])[0];
|
|
77
|
+
// A transparent clip (alpha-video.js swaps it for a canvas) has no frame to blur.
|
|
78
|
+
if (done(open) || hasAttr(open, "data-alpha-stacked")) continue;
|
|
79
|
+
const src = videoSrc(open, m[1]);
|
|
80
|
+
if (src) videos.add(src);
|
|
81
|
+
}
|
|
82
|
+
for (const [tag] of html.matchAll(STYLED)) {
|
|
83
|
+
if (/^<(img|video)\b/i.test(tag) || done(tag)) continue;
|
|
84
|
+
const src = bgSrc(tag);
|
|
85
|
+
if (src) images.add(src);
|
|
86
|
+
}
|
|
87
|
+
return { images: [...images], videos: [...videos] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A preview is usable when it is a small data URL and its media is not an icon.
|
|
91
|
+
export function usablePreview(p) {
|
|
92
|
+
return !!p && typeof p.blur === "string" && p.blur.startsWith("data:image/") && p.blur.length <= MAX_BLUR_CHARS && !/["'<>\s]/.test(p.blur)
|
|
93
|
+
&& Number.isFinite(p.w) && Number.isFinite(p.h) && Math.min(p.w, p.h) > 0 && Math.max(p.w, p.h) >= MIN_PREVIEW_SIDE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const escapeAttr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
97
|
+
|
|
98
|
+
// Add attributes before the tag's closing `>` (or `/>`). The shape goes in as
|
|
99
|
+
// --hd-ar, which player.css applies with zero specificity, so any aspect-ratio
|
|
100
|
+
// the deck sets (in a stylesheet or the tag) still wins.
|
|
101
|
+
function annotate(tag, p, { ratio = true, poster = null } = {}) {
|
|
102
|
+
let out = tag;
|
|
103
|
+
if (ratio) {
|
|
104
|
+
const rule = `--hd-ar:${Math.round(p.w)}/${Math.round(p.h)};`;
|
|
105
|
+
const m = /\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(out);
|
|
106
|
+
if (m) {
|
|
107
|
+
const q = m[1] !== undefined ? '"' : "'";
|
|
108
|
+
out = out.replace(m[0], ` style=${q}${rule}${m[1] ?? m[2]}${q}`);
|
|
109
|
+
} else out = out.replace(/\s*\/?>$/, (end) => ` style="${rule}"${end}`);
|
|
110
|
+
}
|
|
111
|
+
const extra = `${poster ? ` poster="${escapeAttr(poster)}"` : ""} data-hd-blur="${p.blur}"`;
|
|
112
|
+
return out.replace(/\s*(\/?)>$/, (_, slash) => `${extra}${slash ? " /" : ""}>`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// previews: { [src]: { w, h, blur, poster? } } keyed by the src as mediaRefs
|
|
116
|
+
// returned it. Media without a usable preview is left as written. Returns the
|
|
117
|
+
// new page and how many tags it changed.
|
|
118
|
+
export function withMediaPreviews(page, previews) {
|
|
119
|
+
const get = (src) => (src && usablePreview(previews[src]) ? previews[src] : null);
|
|
120
|
+
let count = 0;
|
|
121
|
+
const html = markupOnly(page, (html) => {
|
|
122
|
+
let out = html.replace(IMG, (tag) => {
|
|
123
|
+
const p = done(tag) || iconSized(tag) ? null : get(usable(tagAttr(tag, "src")));
|
|
124
|
+
if (!p) return tag;
|
|
125
|
+
count++;
|
|
126
|
+
// An author who sized both sides chose the shape already.
|
|
127
|
+
return annotate(tag, p, { ratio: !(hasAttr(tag, "width") && hasAttr(tag, "height")) });
|
|
128
|
+
});
|
|
129
|
+
out = out.replace(VIDEO, (whole, body) => {
|
|
130
|
+
const open = VIDEO_OPEN.exec(whole)[0];
|
|
131
|
+
if (done(open) || hasAttr(open, "data-alpha-stacked")) return whole;
|
|
132
|
+
const p = get(videoSrc(open, body));
|
|
133
|
+
if (!p) return whole;
|
|
134
|
+
count++;
|
|
135
|
+
const poster = hasAttr(open, "poster") ? null : p.poster ?? null;
|
|
136
|
+
return annotate(open, p, { ratio: !(hasAttr(open, "width") && hasAttr(open, "height")), poster }) + whole.slice(open.length);
|
|
137
|
+
});
|
|
138
|
+
out = out.replace(STYLED, (tag) => {
|
|
139
|
+
if (/^<(img|video)\b/i.test(tag) || done(tag)) return tag;
|
|
140
|
+
const p = get(bgSrc(tag));
|
|
141
|
+
if (!p) return tag;
|
|
142
|
+
count++;
|
|
143
|
+
// A background's box is the element's own layout, never the image's shape.
|
|
144
|
+
return annotate(tag, p, { ratio: false });
|
|
145
|
+
});
|
|
146
|
+
return out;
|
|
147
|
+
});
|
|
148
|
+
return { html, count };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The size ffmpeg reports for the first video stream ("Stream #0:0: Video: …, 1920x1080").
|
|
152
|
+
export function streamSize(stderr) {
|
|
153
|
+
const m = /Stream #[^\n]*?Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/.exec(stderr ?? "");
|
|
154
|
+
if (!m) return null;
|
|
155
|
+
let w = Number(m[1]), h = Number(m[2]);
|
|
156
|
+
// A phone video stored sideways carries a rotation: its shown shape is swapped.
|
|
157
|
+
const rot = /rotate\s*:\s*-?(90|270)\b|rotation of -?(90|270)/.exec(stderr ?? "");
|
|
158
|
+
if (rot) [w, h] = [h, w];
|
|
159
|
+
return { w, h };
|
|
160
|
+
}
|
package/src/narrate.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
9
9
|
import { basename, join, resolve } from "node:path";
|
|
10
10
|
import * as ops from "./ops.mjs";
|
|
11
11
|
import { presenterLine } from "./presenter.mjs";
|
|
12
|
+
import { writeDraftFace } from "./draftFace.mjs";
|
|
12
13
|
export { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
13
14
|
import { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
14
15
|
|
|
@@ -39,7 +40,8 @@ export function narrationRequest(pos, opts) {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
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")}`);
|