@hypersocial/decks-cli 0.5.0 → 0.7.0
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/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +73 -7
- package/skills/hyperdecks/chat.md +46 -11
- package/src/brand.mjs +171 -0
- package/src/brandCli.mjs +166 -0
- package/src/brandImport.mjs +981 -0
- package/src/brandPreview.mjs +24 -0
- package/src/checkRules.mjs +70 -10
- package/src/commands.mjs +59 -13
- package/src/files.mjs +1 -0
- package/src/firstDeck.mjs +2 -0
- package/src/guide.mjs +3 -1
- package/src/hosted.mjs +15 -6
- package/src/icons.mjs +66 -0
- package/src/importer.mjs +1 -1
- package/src/narrate.mjs +8 -1
- package/src/ops.mjs +82 -8
- package/src/presenter.mjs +9 -0
- package/src/release.mjs +3 -1
- package/src/runtime.mjs +14 -2
- package/src/scaffold.mjs +15 -7
- package/templates/icons/LICENSE +43 -0
- package/templates/icons/lucide.json +1 -0
- package/templates/layouts/_deck.html +1 -1
- package/templates/layouts/chart.html +1 -1
- package/templates/layouts/features.html +8 -8
- package/templates/layouts/hero.html +6 -8
- package/templates/layouts/product.html +1 -1
- package/templates/narrated-deck.html +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The page a brand import (brandImport.mjs, convex/brandDrafts.ts) is shown on
|
|
2
|
+
// before it is saved: a hero and a features slide from the layout library, in
|
|
3
|
+
// the draft's colours, fonts and logo (applyBrand, the same code new decks
|
|
4
|
+
// use). Served at /p/brand/<token> (app/p/brand/[token]/route.ts), and with
|
|
5
|
+
// { saved: true } for the saved brand on the dashboard's Brand tab
|
|
6
|
+
// (app/dashboard/brand/sample/route.ts).
|
|
7
|
+
import { applyBrand } from "./brand.mjs";
|
|
8
|
+
import { assembleLayoutDeck } from "./layouts.mjs";
|
|
9
|
+
|
|
10
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
11
|
+
|
|
12
|
+
export function brandPreviewHtml(draft, { saved = false } = {}) {
|
|
13
|
+
const name = (draft?.name ?? "").trim() || "Your brand";
|
|
14
|
+
let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: ["hero", "features"] })["index.html"];
|
|
15
|
+
// The hero's art slot is a stand-in a real deck fills; the preview has no art.
|
|
16
|
+
html = html.replace(/\s*<div class="art" role="presentation"><\/div>/, "");
|
|
17
|
+
html = html.replace(/(<div class="kicker">)brand-preview(<\/div>)/, saved ? "$1Your brand$2" : "$1Brand preview$2");
|
|
18
|
+
html = html.replace(
|
|
19
|
+
"One sentence on what this deck is about.",
|
|
20
|
+
saved ? "Your colours, fonts and logo on a sample slide. Every new deck starts from this." : "Your colours, fonts and logo on a sample slide. Nothing is saved until you say yes.",
|
|
21
|
+
);
|
|
22
|
+
html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(name)} · ${saved ? "brand" : "brand preview"}</title>`);
|
|
23
|
+
return applyBrand(html, { name: draft?.name ?? undefined, colors: draft?.colors ?? {}, fonts: draft?.fonts ?? {}, logoUrl: draft?.logoUrl ?? undefined });
|
|
24
|
+
}
|
package/src/checkRules.mjs
CHANGED
|
@@ -41,12 +41,16 @@ export const MESSAGES = {
|
|
|
41
41
|
mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
|
|
42
42
|
slideId: (n) => `slide ${n} has no id`,
|
|
43
43
|
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
44
|
+
gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
|
|
45
|
+
gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
|
|
44
46
|
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
45
47
|
transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
|
|
46
48
|
zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
|
|
47
49
|
morphPair: (id, prev) => `slide ${id}: morph needs an element with the same data-morph="<name>" on this slide and the one before it (${prev})`,
|
|
48
50
|
transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
|
|
49
51
|
flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
|
|
52
|
+
// A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
|
|
53
|
+
placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
|
|
50
54
|
// Warnings (check --layout): the deck still passes.
|
|
51
55
|
layoutUnchecked: (why) => `layout not checked: ${why}`,
|
|
52
56
|
hotlink: (url) => `${url} is hotlinked from another site and can vanish after release; upload it with upload_deck_asset`,
|
|
@@ -215,8 +219,33 @@ export function selectorMatches(body, selector) {
|
|
|
215
219
|
});
|
|
216
220
|
}
|
|
217
221
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
222
|
+
// The Minimoji head's gestures, for [gesture: <id>]: lib/deckScript.ts GESTURES
|
|
223
|
+
// (the published CLI cannot import it; a test keeps the two equal).
|
|
224
|
+
export const GESTURES = [
|
|
225
|
+
"big-double-laugh", "boiling-point", "bright-tilt", "buoyant-pop", "chuckle-bob", "clipped-huff", "comfort-tilt",
|
|
226
|
+
"curious-glance", "deep-snore", "delighted-sway", "dodge-glance", "double-take", "dream-twitch", "final-flop",
|
|
227
|
+
"fold-inward", "freeze-and-shiver", "hard-recoil", "head-shake", "knowing-nod", "lean-out-laugh", "lightbulb",
|
|
228
|
+
"look-away", "low-simmer", "nervous-scan", "nod-on-the-beat", "one-sided-lean", "other-side", "party-bounce",
|
|
229
|
+
"ponder-tilt", "pop-back", "pressure-build", "quiet-sigh", "radiant-lift", "shaky-sniff", "sharp-glare",
|
|
230
|
+
"side-eye-hmm", "sideways-flinch", "sleepy-sway", "slow-droop", "snap-and-fume", "sob-heave", "soft-reset",
|
|
231
|
+
"spark-salute", "sunny-nod", "thinking-it-over", "tiny-aftershock", "tiny-double-take", "tiny-wince", "uneasy-sway",
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
// [gesture: <id>] in script.md must name one of the head's gestures.
|
|
235
|
+
export function cueGestureErrors(script = "") {
|
|
236
|
+
const errs = [];
|
|
237
|
+
script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
|
238
|
+
for (const [, colon, raw] of line.matchAll(/\[\s*gesture\b\s*(:?)\s*([^\]]*?)\s*\]/gi)) {
|
|
239
|
+
if (!GESTURES.includes(raw.toLowerCase())) errs.push(MESSAGES.gesture(i + 1, raw));
|
|
240
|
+
else if (!colon) errs.push(MESSAGES.gestureColon(i + 1, raw));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return errs;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// [go: <selector>] and [look: <selector>] (or [look <selector>]) in script.md
|
|
247
|
+
// must match something in their slide ([go: slot] returns home, and [look
|
|
248
|
+
// viewer], [look away] and [look back] need no element).
|
|
220
249
|
export function cueSelectorErrors(html, script = "") {
|
|
221
250
|
const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
|
|
222
251
|
const errs = [];
|
|
@@ -225,8 +254,9 @@ export function cueSelectorErrors(html, script = "") {
|
|
|
225
254
|
const heading = /^##\s+(\S+)/.exec(line);
|
|
226
255
|
if (heading) { id = heading[1]; return; }
|
|
227
256
|
if (!id || !bodies.has(id)) return;
|
|
228
|
-
for (const [,
|
|
229
|
-
|
|
257
|
+
for (const [, colon, spaced, raw] of line.matchAll(/\[\s*(?:(go|look)\s*:\s*|(look)\s+)([^\]]*?)\s*\]/gi)) {
|
|
258
|
+
const cue = colon ?? spaced;
|
|
259
|
+
if (!raw || raw === "slot" || (cue.toLowerCase() === "look" && /^(viewer|away|back)$/i.test(raw))) continue;
|
|
230
260
|
if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
|
|
231
261
|
}
|
|
232
262
|
});
|
|
@@ -250,13 +280,13 @@ export function playerScript(html) {
|
|
|
250
280
|
return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
|
|
251
281
|
}
|
|
252
282
|
|
|
253
|
-
// Relative references: src, href, poster
|
|
283
|
+
// Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
|
|
254
284
|
// CSS url(…) in style attributes and <style> blocks; everything that is not a URL
|
|
255
285
|
// scheme, protocol-relative or a fragment. Query strings and fragments dropped.
|
|
256
286
|
export function localRefs(html) {
|
|
257
287
|
const doc = uncommented(html);
|
|
258
288
|
const raw = [
|
|
259
|
-
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
289
|
+
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
260
290
|
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
261
291
|
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
262
292
|
];
|
|
@@ -271,10 +301,40 @@ export function phraseList(value) {
|
|
|
271
301
|
return value.map((s) => String(s).trim()).filter(Boolean);
|
|
272
302
|
}
|
|
273
303
|
|
|
304
|
+
// Layout stand-ins a deck must swap for its own file, found by the templates'
|
|
305
|
+
// own markers: the empty div (or the chart's labelled svg). A div that holds
|
|
306
|
+
// its own picture, or gets one from a url() in its style or a <style> rule
|
|
307
|
+
// naming its class, is the deck's art. A deck that wants none removes it.
|
|
308
|
+
const PLACEHOLDERS = [
|
|
309
|
+
{
|
|
310
|
+
layout: "l-hero", tag: "div", cls: "art", what: "the hero's art", img: '<img class="art" src="…" alt="">, or delete the div for no art (the presenter is the visual)',
|
|
311
|
+
how: { hosted: "make a picture of the real subject with generate_image", cli: "make a picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
|
|
315
|
+
how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
layout: "l-chart", tag: "svg", cls: "chart", label: /placeholder/i, what: "the chart (made-up numbers)", img: '<img class="chart" src="…" alt="…">',
|
|
319
|
+
how: { hosted: "draw the real data with render_chart", cli: "draw the real data with `hyperdecks chart <spec.json>`" },
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
const classes = (tag) => (attr(tag, "class") ?? "").split(/\s+/).filter(Boolean);
|
|
323
|
+
export function placeholderErrors(html, { hosted = false } = {}) {
|
|
324
|
+
const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
|
|
325
|
+
const styled = (cls) => [...css.matchAll(/([^{}]*)\{([^{}]*)\}/g)].some(([, sel, body]) => new RegExp(`\\.${cls}(?![\\w-])`).test(sel) && /url\(/i.test(body));
|
|
326
|
+
return slides(html).flatMap((s, i) => {
|
|
327
|
+
const layouts = classes(s.tag);
|
|
328
|
+
return PLACEHOLDERS.filter((p) => layouts.includes(p.layout) && !(p.tag === "div" && styled(p.cls)) && [...s.body.matchAll(new RegExp(`(<${p.tag}\\b[^>]*>)([\\s\\S]*?)</${p.tag}>`, "gi"))].some(([, tag, inner]) =>
|
|
329
|
+
classes(tag).includes(p.cls) && (p.label ? p.label.test(attr(tag, "aria-label") ?? "") : !inner.trim() && !/url\(/i.test(attr(tag, "style") ?? ""))))
|
|
330
|
+
.map((p) => MESSAGES.placeholder(s.id || `${i + 1}`, p.what, `<${p.tag} class="${p.cls}">`, hosted ? p.how.hosted : p.how.cli, p.img));
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
274
334
|
// Everything checkable from the deck's own files. `exists(ref)` answers whether a
|
|
275
335
|
// local reference resolves inside the deck folder: true, false or "outside".
|
|
276
|
-
export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
|
|
277
|
-
const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script)];
|
|
336
|
+
export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
|
|
337
|
+
const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
|
|
278
338
|
const player = playerScript(html);
|
|
279
339
|
if (!player) errs.push(MESSAGES.noPlayer());
|
|
280
340
|
else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
|
|
@@ -455,7 +515,7 @@ export function stageSize(html) {
|
|
|
455
515
|
export function absoluteRefs(html) {
|
|
456
516
|
const doc = uncommented(html);
|
|
457
517
|
const raw = [
|
|
458
|
-
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
518
|
+
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
459
519
|
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
460
520
|
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
461
521
|
];
|
|
@@ -486,7 +546,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
486
546
|
throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
|
|
487
547
|
}
|
|
488
548
|
const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
|
|
489
|
-
const errors = htmlErrors(html, { exists, forbid, script });
|
|
549
|
+
const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
|
|
490
550
|
const ids = slides(html).map((s) => s.id);
|
|
491
551
|
const player = playerScript(html);
|
|
492
552
|
let manifest = null;
|
package/src/commands.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
VERSION,
|
|
10
10
|
} from "./runtime.mjs";
|
|
11
11
|
import * as ops from "./ops.mjs";
|
|
12
|
+
import { presenterLine } from "./presenter.mjs";
|
|
12
13
|
|
|
13
14
|
export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
|
|
14
15
|
: p.narrationStarted ? "narration started"
|
|
@@ -306,8 +307,9 @@ commands:
|
|
|
306
307
|
deck list|stats|open|rm|forward
|
|
307
308
|
deck forward on|off <slug>
|
|
308
309
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
309
|
-
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
|
|
310
|
-
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
|
|
310
|
+
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
|
|
311
|
+
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
|
|
312
|
+
in your saved brand's colours, fonts and logo (--no-brand: the template's look)
|
|
311
313
|
layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
|
|
312
314
|
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
|
|
313
315
|
the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
|
|
@@ -320,7 +322,7 @@ commands:
|
|
|
320
322
|
file an image, video or font under the deck; prints its hosted URL
|
|
321
323
|
guide [workflow|format|script|design|layouts|presenter|transitions|check]
|
|
322
324
|
the skill, one topic at a time (what deck_guide serves)
|
|
323
|
-
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
|
|
325
|
+
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
|
|
324
326
|
on a committed deck: check → narrate → import → share new per --to
|
|
325
327
|
share new|list|on|off|forward|mute|unmute|rm|url
|
|
326
328
|
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
|
|
@@ -340,15 +342,29 @@ commands:
|
|
|
340
342
|
[--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
|
|
341
343
|
default model gpt-image-2.5; --seed applies to nano-banana-2.1
|
|
342
344
|
image-job <jobId> [--json]
|
|
343
|
-
gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
|
|
345
|
+
gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]
|
|
344
346
|
a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
|
|
347
|
+
--loop starts and ends on one frame (no jump at the seam); --transparent cuts the subject
|
|
348
|
+
out as a WebM + a stacked-alpha MP4 for Safari (prints both URLs and the <video> markup)
|
|
345
349
|
video-job <jobId> [--json]
|
|
346
350
|
screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
|
|
347
351
|
a PNG of a public web page (private and local addresses are refused)
|
|
348
352
|
chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
|
|
349
353
|
a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
|
|
354
|
+
icons search <word...> [--limit 8] [--stroke-width 2] [--json]
|
|
355
|
+
real icons from the Lucide library as inline SVG in currentColor (free, offline)
|
|
350
356
|
icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
|
|
351
|
-
a style-consistent set of
|
|
357
|
+
a style-consistent set of generated (illustrated) icons, one per subject (max 12)
|
|
358
|
+
brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
|
|
359
|
+
brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
|
|
360
|
+
[--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
|
|
361
|
+
[--presenter-style s] [--design <file.md|->] [--name "<company>"] [--draft <token>]
|
|
362
|
+
brand import <url|file|slug> [--json]
|
|
363
|
+
read a brand from your website, a .pptx / .potx / .pdf or one of your decks: shows its
|
|
364
|
+
colours, fonts and logo and a preview link; save it with brand set --draft <token>
|
|
365
|
+
brand edit edit design.md (voice, do and don't) in $EDITOR
|
|
366
|
+
brand fonts the curated font pairings (display + body + mono)
|
|
367
|
+
brand clear forget the brand; new decks get the template look
|
|
352
368
|
review <slug> [--script <script.md>] [--json]
|
|
353
369
|
GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
|
|
354
370
|
review-job <jobId> [--json]
|
|
@@ -461,7 +477,7 @@ async function imageJob(pos, opts) {
|
|
|
461
477
|
async function genVideo(pos, opts) {
|
|
462
478
|
const prompt = pos.join(" ").trim() || opts.prompt;
|
|
463
479
|
if (!prompt) {
|
|
464
|
-
throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
|
|
480
|
+
throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]');
|
|
465
481
|
}
|
|
466
482
|
const queued = await ops.generateVideo(opts, {
|
|
467
483
|
prompt,
|
|
@@ -469,12 +485,15 @@ async function genVideo(pos, opts) {
|
|
|
469
485
|
seconds: opts.seconds,
|
|
470
486
|
aspect: opts.aspect,
|
|
471
487
|
model: opts.model,
|
|
488
|
+
loop: opts.loop,
|
|
489
|
+
transparent: opts.transparent,
|
|
472
490
|
slug: opts.slug,
|
|
473
491
|
name: opts.name,
|
|
474
492
|
});
|
|
475
493
|
// A clip takes a minute or more; poll so gen-video stays one blocking command.
|
|
476
494
|
// Progress goes to stderr so stdout stays the URL (or --json).
|
|
477
|
-
|
|
495
|
+
const kind = [queued.loop && "looping", queued.transparent && "transparent"].filter(Boolean).join(" ");
|
|
496
|
+
process.stderr.write(`queued a ${queued.seconds}s ${kind ? `${kind} ` : ""}${queued.model} clip — job ${queued.jobId}\n`);
|
|
478
497
|
const job = await ops.pollVideoJob(opts, queued.jobId, {
|
|
479
498
|
onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
|
|
480
499
|
});
|
|
@@ -482,13 +501,21 @@ async function genVideo(pos, opts) {
|
|
|
482
501
|
const url = job.video?.url;
|
|
483
502
|
if (!url) throw new Error(`video job ${queued.jobId} finished without a URL; check it with: video-job ${queued.jobId}`);
|
|
484
503
|
// Print the paid clip's URL before downloading, so a failed download never hides it.
|
|
504
|
+
// A transparent clip prints both files (WebM, then Safari's stacked MP4) and the markup.
|
|
485
505
|
console.log(opts.json ? JSON.stringify(job, null, 2) : url);
|
|
506
|
+
if (!opts.json && job.video?.stacked?.url) console.log(job.video.stacked.url);
|
|
507
|
+
if (!opts.json && job.transparent && job.markup) process.stderr.write(`markup: ${job.markup}\n`);
|
|
486
508
|
if (job.creditsRemaining != null) process.stderr.write(`credits left: ${job.creditsRemaining}\n`);
|
|
487
509
|
if (opts.download) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
510
|
+
// A transparent clip is two files: the WebM where asked, the stacked MP4 beside it.
|
|
511
|
+
const files = [[url, opts.download]];
|
|
512
|
+
if (job.video?.stacked?.url) files.push([job.video.stacked.url, `${String(opts.download).replace(/\.[^./]+$/, "")}-stacked.mp4`]);
|
|
513
|
+
for (const [from, to] of files) {
|
|
514
|
+
const res = await fetch(from);
|
|
515
|
+
if (!res.ok) throw new Error(`download of ${from} failed (${res.status})`);
|
|
516
|
+
writeFileSync(to, Buffer.from(await res.arrayBuffer()));
|
|
517
|
+
process.stderr.write(`saved to ${to}\n`);
|
|
518
|
+
}
|
|
492
519
|
}
|
|
493
520
|
}
|
|
494
521
|
|
|
@@ -503,6 +530,8 @@ async function videoJob(pos, opts) {
|
|
|
503
530
|
console.log(`status: ${job.status}`);
|
|
504
531
|
if (job.error) console.log(`error: ${job.error}`);
|
|
505
532
|
if (job.video?.url) console.log(job.video.url);
|
|
533
|
+
if (job.video?.stacked?.url) console.log(job.video.stacked.url);
|
|
534
|
+
if (job.markup) console.log(`markup: ${job.markup}`);
|
|
506
535
|
}
|
|
507
536
|
|
|
508
537
|
async function screenshot(pos, opts) {
|
|
@@ -629,7 +658,7 @@ function commandTable(pos, rest, opts) {
|
|
|
629
658
|
if (opts.hosted) {
|
|
630
659
|
const { hostedNewDeck } = await import("./source.mjs");
|
|
631
660
|
if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
|
|
632
|
-
const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"] });
|
|
661
|
+
const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"], brand: !opts["no-brand"] });
|
|
633
662
|
return void console.log(opts.json ? JSON.stringify(out, null, 2) : `✓ created hosted deck "${out.slug}" (rev ${out.rev})\n preview: ${out.previewUrl}\n edit locally: hyperdecks source pull ${out.slug}`);
|
|
634
663
|
}
|
|
635
664
|
const { deckNew } = await import("./scaffold.mjs");
|
|
@@ -652,9 +681,11 @@ function commandTable(pos, rest, opts) {
|
|
|
652
681
|
release: async () => {
|
|
653
682
|
if (opts.hosted) {
|
|
654
683
|
const { hostedRelease } = await import("./source.mjs");
|
|
655
|
-
const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid });
|
|
684
|
+
const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] });
|
|
656
685
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
657
686
|
console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
|
|
687
|
+
const who = presenterLine(out.presenter);
|
|
688
|
+
if (who) console.log(who);
|
|
658
689
|
for (const s of out.shares) console.log(`✓ share for "${s.recipient}"${s.qa ? " (QA, untracked)" : ""} → ${s.url}`);
|
|
659
690
|
return;
|
|
660
691
|
}
|
|
@@ -826,6 +857,21 @@ function commandTable(pos, rest, opts) {
|
|
|
826
857
|
screenshot: () => screenshot(pos.slice(1), opts),
|
|
827
858
|
chart: () => chart(pos.slice(1), opts),
|
|
828
859
|
icons: () => icons(pos.slice(1), opts),
|
|
860
|
+
// The icon library (Lucide); `icons search … --style` still generates.
|
|
861
|
+
"icons search": async () => {
|
|
862
|
+
if (opts.style) return icons(pos.slice(1), opts);
|
|
863
|
+
if (!rest.length) throw new Error("usage: icons search <word...> [--limit N] [--stroke-width N] [--json]");
|
|
864
|
+
const { icons: searchIcons } = await import("./icons.mjs");
|
|
865
|
+
const out = await searchIcons(rest, opts);
|
|
866
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
867
|
+
},
|
|
868
|
+
brand: async () => (await import("./brandCli.mjs")).brandShow(opts),
|
|
869
|
+
"brand show": async () => (await import("./brandCli.mjs")).brandShow(opts),
|
|
870
|
+
"brand set": async () => (await import("./brandCli.mjs")).brandSet(opts),
|
|
871
|
+
"brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
|
|
872
|
+
"brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
|
|
873
|
+
"brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
|
|
874
|
+
"brand import": async () => (await import("./brandCli.mjs")).brandImport(rest[0], opts),
|
|
829
875
|
review: () => review(pos.slice(1), opts),
|
|
830
876
|
"review-job": () => reviewJob(pos.slice(1), opts),
|
|
831
877
|
poster: () => poster(pos.slice(1), opts),
|
package/src/files.mjs
CHANGED
|
@@ -19,6 +19,7 @@ const FILES = {
|
|
|
19
19
|
"templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
|
|
20
20
|
"templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
|
|
21
21
|
"templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
|
|
22
|
+
"templates/icons/lucide.json": () => new URL("../templates/icons/lucide.json", import.meta.url),
|
|
22
23
|
"templates/first-deck/self.json": () => new URL("../templates/first-deck/self.json", import.meta.url),
|
|
23
24
|
"templates/first-deck/character.json": () => new URL("../templates/first-deck/character.json", import.meta.url),
|
|
24
25
|
"skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),
|
package/src/firstDeck.mjs
CHANGED
|
@@ -52,6 +52,8 @@ export function fillFirstDeck({ html, title, answers, presenter, voice }) {
|
|
|
52
52
|
const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
|
|
53
53
|
let out = section(html, "hero", (s) => {
|
|
54
54
|
s = note(s);
|
|
55
|
+
// No art: the presenter is the opening's visual, and check fails on the stand-in.
|
|
56
|
+
s = swap(s, /\s*<div class="art"[^>]*><\/div>/, "", "hero art");
|
|
55
57
|
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
|
|
56
58
|
s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
|
|
57
59
|
return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
|
package/src/guide.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// each layout file's leading comment.
|
|
7
7
|
import { packageText } from "./files.mjs";
|
|
8
8
|
import { LAYOUTS, readLayout } from "./layouts.mjs";
|
|
9
|
+
import { fontPairingsMarkdown } from "./brand.mjs";
|
|
9
10
|
|
|
10
11
|
export const GUIDE_TOPICS = ["workflow", "format", "script", "design", "layouts", "presenter", "transitions", "check"];
|
|
11
12
|
const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
|
|
@@ -43,7 +44,8 @@ export function deckGuide(topic = "workflow") {
|
|
|
43
44
|
else {
|
|
44
45
|
const section = skillSection(packageText("skills/hyperdecks/SKILL.md"), t);
|
|
45
46
|
if (!section) throw new Error(`SKILL.md has no <!-- topic: ${t} --> section`);
|
|
46
|
-
|
|
47
|
+
// The design topic carries the curated font pairings (brand.mjs) in place of its marker.
|
|
48
|
+
text = CLI_NOTE + section.replace("<!-- font pairings -->", () => fontPairingsMarkdown());
|
|
47
49
|
}
|
|
48
50
|
return { topic: t, topics: GUIDE_TOPICS, text };
|
|
49
51
|
}
|
package/src/hosted.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// (source.mjs) share these; no local file I/O, so the route can bundle them.
|
|
4
4
|
import { api, client, target } from "./runtime.mjs";
|
|
5
5
|
import { PRESENTER_STYLES } from "./checkRules.mjs";
|
|
6
|
-
import { NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
|
|
6
|
+
import { NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts } from "./layouts.mjs";
|
|
7
|
+
import { applyBrand, brandForNewDeck, brandSummary } from "./brand.mjs";
|
|
8
|
+
import { getBrand } from "./ops.mjs";
|
|
7
9
|
|
|
8
10
|
export const SOURCE_FILES = Object.keys(NARRATED_FILES);
|
|
9
11
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -13,16 +15,22 @@ const urls = (opts, slug, previewToken) => {
|
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
// Scaffold the files (the same templates as deck new) and register the draft.
|
|
16
|
-
|
|
18
|
+
// The account's brand (colours, fonts, logo, presenter style) goes on the
|
|
19
|
+
// template unless brand is false; an explicit presenterStyle wins over it.
|
|
20
|
+
export async function hostedNewDeck(opts, { slug, title, layouts, presenterStyle, brand: useBrand } = {}) {
|
|
17
21
|
if (!slug || !title) throw new Error("slug and title are required");
|
|
18
22
|
if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
|
|
19
23
|
if (presenterStyle !== undefined && !PRESENTER_STYLES.includes(presenterStyle)) throw new Error(`presenterStyle must be one of ${PRESENTER_STYLES.join(", ")}`);
|
|
20
24
|
const list = Array.isArray(layouts) ? layouts.join(",") : layouts;
|
|
25
|
+
if (list) parseLayouts(list);
|
|
26
|
+
const { brand, readError } = await brandForNewDeck(() => getBrand(opts), useBrand);
|
|
27
|
+
const style = presenterStyle ?? (PRESENTER_STYLES.includes(brand?.presenterStyle) ? brand.presenterStyle : undefined);
|
|
21
28
|
const files = list
|
|
22
|
-
? assembleLayoutDeck({ slug, title, presenterStyle, layouts: list })
|
|
23
|
-
: Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle })]));
|
|
29
|
+
? assembleLayoutDeck({ slug, title, presenterStyle: style, layouts: list })
|
|
30
|
+
: Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle: style })]));
|
|
31
|
+
files["index.html"] = applyBrand(files["index.html"], brand);
|
|
24
32
|
const made = await (await client(opts)).mutation(api.deckSources.create, { slug, title, files });
|
|
25
|
-
return { slug, rev: made.rev, files, ...urls(opts, slug, made.previewToken) };
|
|
33
|
+
return { slug, rev: made.rev, files, brand: brandSummary(brand, readError), ...urls(opts, slug, made.previewToken) };
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export async function getDeckSource(opts, { slug, file } = {}) {
|
|
@@ -59,7 +67,7 @@ export async function hostedCheck(opts, { slug, forbid, maxDuration } = {}) {
|
|
|
59
67
|
return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}) });
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice } = {}) {
|
|
70
|
+
export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice, captionsOnly } = {}) {
|
|
63
71
|
if (!slug) throw new Error("slug is required");
|
|
64
72
|
const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
|
|
65
73
|
const args = { slug };
|
|
@@ -69,6 +77,7 @@ export async function hostedRelease(opts, { slug, recipients, qa, title, forbid,
|
|
|
69
77
|
if (list?.length) args.forbid = list;
|
|
70
78
|
if (rev !== undefined && rev !== null) args.rev = Number(rev);
|
|
71
79
|
if (voice) args.voice = String(voice);
|
|
80
|
+
if (captionsOnly) args.captionsOnly = true;
|
|
72
81
|
return (await client(opts)).action(api.deckSources.release, args);
|
|
73
82
|
}
|
|
74
83
|
|
package/src/icons.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// hyperdecks icons search <word> [--limit N] [--stroke-width N]
|
|
2
|
+
//
|
|
3
|
+
// The icon library: Lucide's open-source line icons (ISC licence, vendored in
|
|
4
|
+
// templates/icons/lucide.json by scripts/sync-icons.mjs). A search returns
|
|
5
|
+
// inline SVG stroked in currentColor, so a pasted icon takes the colour of the
|
|
6
|
+
// text around it, i.e. the deck's palette. No network and no credits; for
|
|
7
|
+
// illustrated icons in a style of your own, `hyperdecks icons <subject...>
|
|
8
|
+
// --style` (generate_icon_set) still draws them.
|
|
9
|
+
import { packageText } from "./files.mjs";
|
|
10
|
+
|
|
11
|
+
export const MAX_ICON_RESULTS = 24;
|
|
12
|
+
const DEFAULT_RESULTS = 8;
|
|
13
|
+
let library;
|
|
14
|
+
const lib = () => (library ??= JSON.parse(packageText("templates/icons/lucide.json")));
|
|
15
|
+
|
|
16
|
+
export function iconSvg(name, { strokeWidth = 2 } = {}) {
|
|
17
|
+
const icon = lib().icons[name];
|
|
18
|
+
if (!icon) return null;
|
|
19
|
+
const sw = Number(strokeWidth);
|
|
20
|
+
const width = Number.isFinite(sw) && sw > 0 && sw <= 4 ? sw : 2;
|
|
21
|
+
return `<svg class="icon lucide-${name}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon[0]}</svg>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Best matches first: the exact name, a name starting with or containing a
|
|
25
|
+
// word, then an exact tag, then a tag containing a word. Several words rank by
|
|
26
|
+
// how many of them match.
|
|
27
|
+
export function searchIcons({ query, limit, strokeWidth } = {}) {
|
|
28
|
+
const words = String(query ?? "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
29
|
+
if (!words.length) throw new Error("search needs a word, e.g. rocket");
|
|
30
|
+
const n = Math.min(Math.max(Number(limit) || DEFAULT_RESULTS, 1), MAX_ICON_RESULTS);
|
|
31
|
+
const phrase = words.join("-");
|
|
32
|
+
const scored = [];
|
|
33
|
+
for (const [name, [, tagText]] of Object.entries(lib().icons)) {
|
|
34
|
+
const parts = name.split("-");
|
|
35
|
+
const tags = tagText ? tagText.split(",") : [];
|
|
36
|
+
let score = name === phrase ? 100 : 0;
|
|
37
|
+
for (const w of words) {
|
|
38
|
+
if (parts[0] === w) score += 12;
|
|
39
|
+
else if (parts.includes(w)) score += 10;
|
|
40
|
+
else if (name.includes(w)) score += 6;
|
|
41
|
+
else if (tags.includes(w)) score += 5;
|
|
42
|
+
else if (tags.some((t) => t.split(" ").includes(w))) score += 3;
|
|
43
|
+
else if (tags.some((t) => t.includes(w))) score += 1;
|
|
44
|
+
}
|
|
45
|
+
// Plainer icons first among equals: rocket before rocket-launch.
|
|
46
|
+
if (score) scored.push({ name, tags, score: score - parts.length * 0.1 });
|
|
47
|
+
}
|
|
48
|
+
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
49
|
+
return {
|
|
50
|
+
query: words.join(" "),
|
|
51
|
+
library: lib().source,
|
|
52
|
+
license: lib().license,
|
|
53
|
+
total: scored.length,
|
|
54
|
+
icons: scored.slice(0, n).map(({ name, tags }) => ({ name, tags, svg: iconSvg(name, { strokeWidth }) })),
|
|
55
|
+
usage: "Paste svg inline (e.g. in .l-features .card). It is stroked in currentColor: set `color` on it or its parent to put it in the deck's palette.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function icons(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
60
|
+
const out = searchIcons({ query: pos.join(" "), limit: opts.limit, strokeWidth: opts["stroke-width"] });
|
|
61
|
+
if (opts.json) return out;
|
|
62
|
+
if (!out.icons.length) log(`no icons match "${out.query}"; try a plainer word (e.g. chart, user, lock)`);
|
|
63
|
+
for (const icon of out.icons) log(`${icon.name}${icon.tags.length ? ` (${icon.tags.slice(0, 5).join(", ")})` : ""}\n${icon.svg}\n`);
|
|
64
|
+
if (out.icons.length) log(`${out.icons.length} of ${out.total} from ${out.library}. ${out.usage}`);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
package/src/importer.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export function rewriteTextAssetUrls(text, sourcePath, assets, uploadedByName) {
|
|
|
158
158
|
export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
|
|
159
159
|
const assetMap = buildAssetUrlMap(assets, uploadedByName, htmlEntry);
|
|
160
160
|
let out = html.replace(
|
|
161
|
-
/\b(src|href|poster|data-presenter-image)\s*=\s*(["'])([^"']+)\2/gi,
|
|
161
|
+
/\b(src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(["'])([^"']+)\2/gi,
|
|
162
162
|
(match, attr, quote, raw) => {
|
|
163
163
|
const url = resolveLocalUrl(raw, assetMap);
|
|
164
164
|
return url ? `${attr}=${quote}${url}${quote}` : match;
|
package/src/narrate.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
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
|
+
import { presenterLine } from "./presenter.mjs";
|
|
11
12
|
export { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
12
13
|
import { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
13
14
|
|
|
@@ -46,10 +47,14 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
46
47
|
for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars${s.presenterStyle ? ` presenter-style: ${s.presenterStyle}` : ""}`);
|
|
47
48
|
const render = preview.slides.filter((s) => s.render);
|
|
48
49
|
log(`${render.length} of ${preview.slides.length} slide(s) would render in voice "${preview.voice}", ~${render.reduce((n, s) => n + s.chars, 0)} characters of TTS`);
|
|
50
|
+
const who = presenterLine(preview.presenter);
|
|
51
|
+
if (who) log(who);
|
|
49
52
|
return preview;
|
|
50
53
|
}
|
|
51
|
-
const { jobId, slides } = await ops.narrateDeck(opts, fields);
|
|
54
|
+
const { jobId, slides, presenter } = await ops.narrateDeck(opts, fields);
|
|
52
55
|
log(`narration job ${jobId}: ${slides.filter((s) => s.status === "render").length} to render, ${slides.filter((s) => s.status === "cached").length} cached`);
|
|
56
|
+
const who = presenterLine(presenter);
|
|
57
|
+
if (who) log(who);
|
|
53
58
|
const seen = new Map();
|
|
54
59
|
const job = await ops.pollNarrationJob(opts, jobId, {
|
|
55
60
|
onProgress: (j) => {
|
|
@@ -63,6 +68,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
63
68
|
},
|
|
64
69
|
});
|
|
65
70
|
if (job.status === "error") throw new Error(job.error || "narration failed");
|
|
71
|
+
// release reads who presents from here (older backends send no presenter).
|
|
72
|
+
if (presenter) job.presenter = presenter;
|
|
66
73
|
if (!job.manifest) {
|
|
67
74
|
log(`⚠ ${job.notice || "done, but no manifest was written"}`);
|
|
68
75
|
return job;
|