@hypersocial/decks-cli 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +6 -1
- package/skills/hyperdecks/SKILL.md +211 -12
- package/src/check.mjs +233 -8
- package/src/commands.mjs +138 -9
- package/src/importer.mjs +1 -1
- package/src/narrate.mjs +2 -1
- package/src/ops.mjs +97 -5
- package/src/runtime.mjs +16 -2
- package/src/scaffold.mjs +74 -8
- package/templates/layouts/_deck.html +102 -0
- package/templates/layouts/chart.html +39 -0
- package/templates/layouts/closer.html +28 -0
- package/templates/layouts/compare.html +33 -0
- package/templates/layouts/features.html +35 -0
- package/templates/layouts/hero.html +35 -0
- package/templates/layouts/number.html +29 -0
- package/templates/layouts/product.html +57 -0
- package/templates/layouts/quote.html +31 -0
- package/templates/layouts/team.html +37 -0
- package/templates/layouts/timeline.html +39 -0
- package/templates/narrated-deck.html +57 -11
- package/templates/script.md +5 -2
package/src/check.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
|
|
1
|
+
// hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
|
|
2
2
|
//
|
|
3
3
|
// The narrated-deck integrity gate, ported from the Netflix presentation's
|
|
4
4
|
// scripts/check.mjs and its variant builders. Every rule returns its message from
|
|
@@ -14,6 +14,8 @@ export const DEFAULT_MAX_DURATION = 180;
|
|
|
14
14
|
export const MOBILE_TITLE_CHARS = 28;
|
|
15
15
|
export const CAPTION_MIN_CHARS = 4;
|
|
16
16
|
export const CAPTION_MAX_CHARS = 85;
|
|
17
|
+
// Same names as PRESENTER_STYLES in lib/deckManifest.ts (the published CLI cannot import it).
|
|
18
|
+
export const PRESENTER_STYLES = ["stage", "bubble", "glass", "corner"];
|
|
17
19
|
|
|
18
20
|
export const MESSAGES = {
|
|
19
21
|
noPlayer: () => `no player script: add <script type="module" src="${PLAYER_V1}" data-manifest="…">`,
|
|
@@ -21,6 +23,7 @@ export const MESSAGES = {
|
|
|
21
23
|
noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it",
|
|
22
24
|
manifestUnavailable: (url, why) => `manifest ${url} could not be read (${why})`,
|
|
23
25
|
noSlides: () => 'no slides: each slide is a <section class="slide" id="…">',
|
|
26
|
+
noLoading: () => "no loading state: copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>, and set --hd-ground to the first slide's colour",
|
|
24
27
|
clipField: (id, field) => `clip ${id}: ${field}`,
|
|
25
28
|
noCaptions: (id) => `clip ${id}: timeline has no captions`,
|
|
26
29
|
order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
|
|
@@ -28,7 +31,7 @@ export const MESSAGES = {
|
|
|
28
31
|
cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
|
|
29
32
|
url: (id, key, url, why) => `clip ${id}: ${key} ${url} ${why}`,
|
|
30
33
|
timing: (id, i, start, end, duration) => `clip ${id}: caption ${i + 1} runs ${start}s to ${end}s, outside 0 ≤ start < end ≤ ${duration}s + 0.1`,
|
|
31
|
-
words: (id) => `clip ${id}:
|
|
34
|
+
words: (id) => `clip ${id}: captions do not cover the clip text, or a caption has no timed words`,
|
|
32
35
|
captionLength: (id, i, text) => `clip ${id}: caption ${i + 1} is ${text.length} characters ("${text}"); captions must be ${CAPTION_MIN_CHARS} to ${CAPTION_MAX_CHARS}`,
|
|
33
36
|
localRef: (ref) => `local reference does not resolve: ${ref}`,
|
|
34
37
|
outsideRef: (ref) => `local reference leaves the deck folder: ${ref}`,
|
|
@@ -36,8 +39,19 @@ export const MESSAGES = {
|
|
|
36
39
|
note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
|
|
37
40
|
mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
|
|
38
41
|
slideId: (n) => `slide ${n} has no id`,
|
|
42
|
+
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
43
|
+
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
44
|
+
// Warnings (check --layout): the deck still passes.
|
|
45
|
+
layoutUnchecked: (why) => `layout not checked: ${why}`,
|
|
46
|
+
overlap: (id, style, stage) => `slide ${id}: the ${style} presenter would overlap the title at ${stage}; move the .narration-slot or pick another style`,
|
|
47
|
+
// An error, though only check --layout can see it.
|
|
48
|
+
phoneOverflow: (id, px) => `slide ${id}: its content runs ${px}px past the slide on a ${PHONE.width}×${PHONE.height} phone; a slide must fit one screen without scrolling, so trim it or tighten its @media (max-width: 650px) rules`,
|
|
39
49
|
};
|
|
40
50
|
|
|
51
|
+
// The framed player's slide card on a phone (player.css): the screen less the
|
|
52
|
+
// 52px masthead, the 56px dock and 8px gutters.
|
|
53
|
+
export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736 } };
|
|
54
|
+
|
|
41
55
|
const uncommented = (html) => html.replace(/<!--[\s\S]*?-->/g, "");
|
|
42
56
|
const attr = (tag, name) => {
|
|
43
57
|
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
|
|
@@ -47,6 +61,62 @@ const round = (n) => Math.round(n * 100) / 100;
|
|
|
47
61
|
const decode = (s) => s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'");
|
|
48
62
|
const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
49
63
|
|
|
64
|
+
// Comments blanked but newlines kept, so line numbers still match the file.
|
|
65
|
+
const blankComments = (text) => text.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, " "));
|
|
66
|
+
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
67
|
+
|
|
68
|
+
// Unknown presenter styles, with their line: data-presenter-style in index.html,
|
|
69
|
+
// presenterStyle in script.md's front matter and presenter-style under a heading.
|
|
70
|
+
export function presenterStyleErrors(html, script = "") {
|
|
71
|
+
const errs = [];
|
|
72
|
+
const page = blankComments(html);
|
|
73
|
+
for (const m of page.matchAll(/\sdata-presenter-style\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
74
|
+
const value = m[1] ?? m[2];
|
|
75
|
+
if (!PRESENTER_STYLES.includes(value)) errs.push(MESSAGES.presenterStyle(`index.html line ${lineAt(page, m.index)}`, value));
|
|
76
|
+
}
|
|
77
|
+
const lines = script.replace(/\r\n?/g, "\n").split("\n");
|
|
78
|
+
const frontEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
79
|
+
lines.forEach((line, i) => {
|
|
80
|
+
const m = (i > 0 && i < frontEnd ? /^\s*presenterStyle\s*:\s*(.*?)\s*$/ : /^\s*presenter-style\s*:\s*(.*?)\s*$/).exec(line);
|
|
81
|
+
if (m && !PRESENTER_STYLES.includes(m[1])) errs.push(MESSAGES.presenterStyle(`script.md line ${i + 1}`, m[1]));
|
|
82
|
+
});
|
|
83
|
+
return errs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Does a simple selector (tag, #id, .class, or one compound of them) match
|
|
87
|
+
// anything in this slide's markup? null when the selector is too complex to
|
|
88
|
+
// tell without a browser.
|
|
89
|
+
export function selectorMatches(body, selector) {
|
|
90
|
+
const m = /^([a-zA-Z][a-zA-Z0-9-]*)?(?:#([\w-]+))?((?:\.[\w-]+)*)$/.exec(selector.trim());
|
|
91
|
+
if (!m || !(m[1] || m[2] || m[3])) return null;
|
|
92
|
+
const [, tag, id, cls] = m;
|
|
93
|
+
const classes = cls.split(".").filter(Boolean);
|
|
94
|
+
return [...uncommented(body).matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)].some(([open, name]) => {
|
|
95
|
+
if (tag && name.toLowerCase() !== tag.toLowerCase()) return false;
|
|
96
|
+
if (id && attr(open, "id") !== id) return false;
|
|
97
|
+
const have = (attr(open, "class") ?? "").split(/\s+/);
|
|
98
|
+
return classes.every((c) => have.includes(c));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// [go: <selector>] and [look: <selector>] in script.md must match something in
|
|
103
|
+
// their slide ([go: slot] returns home and always resolves).
|
|
104
|
+
export function cueSelectorErrors(html, script = "") {
|
|
105
|
+
const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
|
|
106
|
+
const errs = [];
|
|
107
|
+
let id = null;
|
|
108
|
+
script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
|
109
|
+
const heading = /^##\s+(\S+)/.exec(line);
|
|
110
|
+
if (heading) { id = heading[1]; return; }
|
|
111
|
+
if (!id || !bodies.has(id)) return;
|
|
112
|
+
for (const [, cue, raw] of line.matchAll(/\[\s*(go|look)\s*:\s*([^\]]*?)\s*\]/gi)) {
|
|
113
|
+
if (raw === "slot" || !raw) continue;
|
|
114
|
+
if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return errs;
|
|
118
|
+
}
|
|
119
|
+
|
|
50
120
|
// Same normalization as the source check: accents folded, case and punctuation dropped.
|
|
51
121
|
export const normalizeWords = (text) => text.normalize("NFKD").replace(/\p{Diacritic}/gu, "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
52
122
|
|
|
@@ -64,13 +134,13 @@ export function playerScript(html) {
|
|
|
64
134
|
return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
|
|
65
135
|
}
|
|
66
136
|
|
|
67
|
-
// Relative references: src, href and
|
|
137
|
+
// Relative references: src, href, poster and data-presenter-image (either quote), srcset candidates and
|
|
68
138
|
// CSS url(…) in style attributes and <style> blocks; everything that is not a URL
|
|
69
139
|
// scheme, protocol-relative or a fragment. Query strings and fragments dropped.
|
|
70
140
|
export function localRefs(html) {
|
|
71
141
|
const doc = uncommented(html);
|
|
72
142
|
const raw = [
|
|
73
|
-
...[...doc.matchAll(/\s(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
143
|
+
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
74
144
|
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
75
145
|
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
76
146
|
];
|
|
@@ -88,10 +158,14 @@ function phraseList(value) {
|
|
|
88
158
|
// Everything checkable from the deck's own files. `exists(ref)` answers whether a
|
|
89
159
|
// local reference resolves inside the deck folder: true, false or "outside".
|
|
90
160
|
export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
|
|
91
|
-
const errs = [];
|
|
161
|
+
const errs = [...presenterStyleErrors(html, script), ...cueSelectorErrors(html, script)];
|
|
92
162
|
const player = playerScript(html);
|
|
93
163
|
if (!player) errs.push(MESSAGES.noPlayer());
|
|
94
164
|
else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
|
|
165
|
+
// The loading state must be in <head> to hold the page from its first paint,
|
|
166
|
+
// with its fail-open so a deck whose player never loads still shows.
|
|
167
|
+
const loading = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>([\s\S]*?)<\/style>/i.exec(/<head\b[\s\S]*?<\/head>/i.exec(uncommented(html))?.[0] ?? "");
|
|
168
|
+
if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
|
|
95
169
|
const all = slides(html);
|
|
96
170
|
if (!all.length) errs.push(MESSAGES.noSlides());
|
|
97
171
|
all.forEach((s, i) => {
|
|
@@ -142,6 +216,9 @@ export function manifestErrors(manifest, slideIds, { maxDuration = DEFAULT_MAX_D
|
|
|
142
216
|
const declared = Number(manifest?.totalDuration);
|
|
143
217
|
if (!(Math.abs(sum - declared) < 0.01)) errs.push(MESSAGES.total(sum, manifest?.totalDuration));
|
|
144
218
|
if (Number.isFinite(declared) && declared > maxDuration) errs.push(MESSAGES.cap(round(declared), maxDuration));
|
|
219
|
+
const badStyle = (v) => v !== undefined && !PRESENTER_STYLES.includes(v);
|
|
220
|
+
if (badStyle(manifest?.presenterStyle)) errs.push(MESSAGES.presenterStyle("manifest", manifest.presenterStyle));
|
|
221
|
+
for (const c of clips) if (badStyle(c?.presenterStyle)) errs.push(MESSAGES.presenterStyle(`clip ${c.id}`, c.presenterStyle));
|
|
145
222
|
for (const phrase of phraseList(forbid)) {
|
|
146
223
|
for (const c of clips) if (String(c?.text ?? "").toLowerCase().includes(phrase.toLowerCase())) errs.push(MESSAGES.forbidden(phrase, `clip ${c.id}`));
|
|
147
224
|
}
|
|
@@ -157,8 +234,12 @@ export function timelineErrors(clip, timeline) {
|
|
|
157
234
|
captions.forEach((c, i) => {
|
|
158
235
|
if (!(c.start >= 0 && c.end > c.start && c.end <= duration + 0.1)) errs.push(MESSAGES.timing(clip.id, i, c.start, c.end, duration));
|
|
159
236
|
});
|
|
160
|
-
|
|
161
|
-
|
|
237
|
+
// Caption text carries the script's spelling; the timed words carry the
|
|
238
|
+
// transcript's (which may spell a name differently, as the narrate guard's
|
|
239
|
+
// pronunciation map allows), so coverage is checked on the text.
|
|
240
|
+
const covered = captions.map((c) => String(c.text ?? "")).join(" ");
|
|
241
|
+
const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
|
|
242
|
+
if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
|
|
162
243
|
captions.forEach((c, i) => {
|
|
163
244
|
const text = String(c.text ?? "");
|
|
164
245
|
if (text.length < CAPTION_MIN_CHARS || text.length > CAPTION_MAX_CHARS) errs.push(MESSAGES.captionLength(clip.id, i, text));
|
|
@@ -166,6 +247,130 @@ export function timelineErrors(clip, timeline) {
|
|
|
166
247
|
return errs;
|
|
167
248
|
}
|
|
168
249
|
|
|
250
|
+
// Each slide's presenter style, as the player resolves it at desktop size: the
|
|
251
|
+
// slide's data-presenter-style, its clip's, the player tag's, the manifest's,
|
|
252
|
+
// then stage for the live head and bubble otherwise.
|
|
253
|
+
export function slideStyles(html, manifest = null) {
|
|
254
|
+
const page = uncommented(html);
|
|
255
|
+
const tag = [...page.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "";
|
|
256
|
+
const known = (v) => (PRESENTER_STYLES.includes(v) ? v : null);
|
|
257
|
+
const deck = known(attr(tag, "data-presenter-style")) ?? known(manifest?.presenterStyle) ?? (attr(tag, "data-presenter") === "head" ? "stage" : "bubble");
|
|
258
|
+
const clips = new Map((Array.isArray(manifest?.clips) ? manifest.clips : []).map((c) => [c?.id, c]));
|
|
259
|
+
return slides(html).map((s) => ({ id: s.id, style: known(attr(s.tag, "data-presenter-style")) ?? known(clips.get(s.id)?.presenterStyle) ?? deck }));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// What each style occupies at desktop size, in stage pixels (player.css): the
|
|
263
|
+
// head plus two caption lines. Corner sits in the stage's corner, not the slot.
|
|
264
|
+
export const PRESENTER_FOOTPRINT = {
|
|
265
|
+
stage: { width: 480, height: 490 },
|
|
266
|
+
glass: { width: 522, height: 534 },
|
|
267
|
+
bubble: { width: 446, height: 304 },
|
|
268
|
+
corner: { width: 400, height: 330, right: 28, bottom: 28 },
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Measured boxes → warnings. Each entry: { id, style, presenter, title } with
|
|
272
|
+
// { x, y, width, height } boxes (title null when the slide has none).
|
|
273
|
+
export function overlapWarnings(measured, stage = "1600×900") {
|
|
274
|
+
const hit = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 4 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 4;
|
|
275
|
+
return measured.filter((m) => hit(m.presenter, m.title)).map((m) => MESSAGES.overlap(m.id, m.style, stage));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Slides whose content overflows the phone card, as errors. Each entry: { id, overflow } in px.
|
|
279
|
+
export function phoneOverflowErrors(measured) {
|
|
280
|
+
return measured.filter((m) => m.overflow > 1).map((m) => MESSAGES.phoneOverflow(m.id, Math.ceil(m.overflow)));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Lay each slide out in Chromium at the deck's stage size with JavaScript off
|
|
284
|
+
// (no player, no deck scripts), put the style's footprint in its slot, and
|
|
285
|
+
// measure it against the title's text. Needs `hyperdecks preview setup`.
|
|
286
|
+
export async function measureLayout(deckDir, styles, { width = 1600, height = 900 } = {}) {
|
|
287
|
+
return withDeckPage(deckDir, { width, height }, (page) => page.evaluate(({ styles, footprint, width, height }) => {
|
|
288
|
+
const box = (r) => ({ x: r.left, y: r.top, width: r.width, height: r.height });
|
|
289
|
+
const sections = [...document.querySelectorAll("section.slide")];
|
|
290
|
+
const host = sections[0]?.parentElement ?? document.body;
|
|
291
|
+
Object.assign(host.style, { position: "relative", width: `${width}px`, height: `${height}px`, overflow: "hidden", margin: "0" });
|
|
292
|
+
document.body.style.margin = "0";
|
|
293
|
+
return sections.map((slide, i) => {
|
|
294
|
+
sections.forEach((s) => { s.style.display = s === slide ? "" : "none"; Object.assign(s.style, { position: "absolute", inset: "0" }); });
|
|
295
|
+
const { id, style } = styles[i] ?? { id: slide.id, style: "bubble" };
|
|
296
|
+
const size = footprint[style];
|
|
297
|
+
const probe = document.createElement("div");
|
|
298
|
+
Object.assign(probe.style, { width: `${size.width}px`, height: `${size.height}px`, flex: "none" });
|
|
299
|
+
const slot = slide.querySelector(".narration-slot");
|
|
300
|
+
let presenter = null;
|
|
301
|
+
if (style === "corner" || !slot) {
|
|
302
|
+
const right = size.right ?? 56, bottom = size.bottom ?? 56;
|
|
303
|
+
presenter = { x: width - right - size.width, y: height - bottom - size.height, width: size.width, height: size.height };
|
|
304
|
+
} else {
|
|
305
|
+
slot.append(probe);
|
|
306
|
+
presenter = box(probe.getBoundingClientRect());
|
|
307
|
+
probe.remove();
|
|
308
|
+
}
|
|
309
|
+
const heading = slide.querySelector("h1, h2");
|
|
310
|
+
let title = null;
|
|
311
|
+
if (heading) {
|
|
312
|
+
const range = document.createRange();
|
|
313
|
+
range.selectNodeContents(heading);
|
|
314
|
+
const rects = [...range.getClientRects()].filter((r) => r.width && r.height);
|
|
315
|
+
if (rects.length) {
|
|
316
|
+
const [x0, y0] = [Math.min(...rects.map((r) => r.left)), Math.min(...rects.map((r) => r.top))];
|
|
317
|
+
title = { x: x0, y: y0, width: Math.max(...rects.map((r) => r.right)) - x0, height: Math.max(...rects.map((r) => r.bottom)) - y0 };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { id, style, presenter, title };
|
|
321
|
+
});
|
|
322
|
+
}, { styles, footprint: PRESENTER_FOOTPRINT, width, height }));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Lay each slide out as the framed player shows it on a phone: the deck's own
|
|
326
|
+
// phone rules at a 390×844 viewport, its mobile titles, inside the card, with
|
|
327
|
+
// JavaScript off. Returns { id, overflow } per slide: how far its content runs
|
|
328
|
+
// past the slide's bottom (≤ 0 when it fits).
|
|
329
|
+
export async function measurePhoneFit(deckDir) {
|
|
330
|
+
return withDeckPage(deckDir, { width: PHONE.width, height: PHONE.height }, (page) => page.evaluate((card) => {
|
|
331
|
+
for (const n of document.querySelectorAll("[data-title-mobile]")) n.textContent = n.dataset.titleMobile;
|
|
332
|
+
const sections = [...document.querySelectorAll("section.slide")];
|
|
333
|
+
const host = sections[0]?.parentElement ?? document.body;
|
|
334
|
+
Object.assign(host.style, { position: "relative", width: `${card.width}px`, height: `${card.height}px`, overflow: "hidden", margin: "0" });
|
|
335
|
+
document.body.style.margin = "0";
|
|
336
|
+
return sections.map((slide) => {
|
|
337
|
+
sections.forEach((s) => { s.style.display = s === slide ? "" : "none"; Object.assign(s.style, { position: "absolute", inset: "0", overflow: "hidden" }); });
|
|
338
|
+
return { id: slide.id, overflow: slide.scrollHeight - slide.clientHeight };
|
|
339
|
+
});
|
|
340
|
+
}, PHONE.card));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Open the deck's index.html in Chromium at `viewport` with JavaScript off and
|
|
344
|
+
// hand the page to `measure`. Needs `hyperdecks preview setup`.
|
|
345
|
+
async function withDeckPage(deckDir, viewport, measure) {
|
|
346
|
+
const { chromium } = await import("playwright");
|
|
347
|
+
const { serveSource, routeCaptureRequest } = await import("./preview-capture.mjs");
|
|
348
|
+
const { mkdtemp, rm } = await import("node:fs/promises");
|
|
349
|
+
const { tmpdir } = await import("node:os");
|
|
350
|
+
// The same isolation as preview capture: a throwaway HOME, the sandbox on
|
|
351
|
+
// Linux, and its request filter (no loopback, private or metadata hosts).
|
|
352
|
+
const temp = await mkdtemp(join(tmpdir(), "hyperdecks-layout-"));
|
|
353
|
+
let browser, server;
|
|
354
|
+
try {
|
|
355
|
+
try {
|
|
356
|
+
browser = await chromium.launch({ headless: true, timeout: 30000, chromiumSandbox: process.platform === "linux", env: { ...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp } });
|
|
357
|
+
} catch {
|
|
358
|
+
throw new Error("check --layout needs Chromium: run `hyperdecks preview setup`");
|
|
359
|
+
}
|
|
360
|
+
server = await serveSource({ file: join(deckDir, "index.html") });
|
|
361
|
+
const context = await browser.newContext({ viewport, javaScriptEnabled: false, serviceWorkers: "block" });
|
|
362
|
+
await context.route("**/*", (route) => routeCaptureRequest(route, server.url));
|
|
363
|
+
const page = await context.newPage();
|
|
364
|
+
await page.goto(server.url, { waitUntil: "load", timeout: 30000 });
|
|
365
|
+
await page.evaluate(() => document.fonts.ready);
|
|
366
|
+
return await measure(page);
|
|
367
|
+
} finally {
|
|
368
|
+
await server?.close().catch(() => {});
|
|
369
|
+
await browser?.close().catch(() => {});
|
|
370
|
+
await rm(temp, { recursive: true, force: true }).catch(() => {});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
169
374
|
// The deck's own cap: `maxTotalSeconds` in script.md's front matter.
|
|
170
375
|
export function scriptCap(script) {
|
|
171
376
|
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(script)?.[1] ?? "";
|
|
@@ -228,7 +433,7 @@ const FETCH_TIMEOUT_MS = 30_000;
|
|
|
228
433
|
const JSON_TYPE = /json/;
|
|
229
434
|
|
|
230
435
|
// Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
|
|
231
|
-
export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd } = {}) {
|
|
436
|
+
export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
|
|
232
437
|
const deckDir = resolveDeckDir(dirArg, cwd);
|
|
233
438
|
const html = readFileSync(join(deckDir, "index.html"), "utf8");
|
|
234
439
|
const scriptPath = join(deckDir, "script.md");
|
|
@@ -292,6 +497,24 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
|
|
|
292
497
|
errors.push(...timelineErrors(clip, timeline));
|
|
293
498
|
}
|
|
294
499
|
}
|
|
500
|
+
// A presenter over the title is a warning, not a failure: only a person can
|
|
501
|
+
// judge it. A slide that overflows the phone card is an error. Both need a
|
|
502
|
+
// browser, so they run only with --layout.
|
|
503
|
+
const warnings = [];
|
|
504
|
+
if (opts.layout) {
|
|
505
|
+
const [w, h] = /^(\d{3,4})x(\d{3,4})$/.exec(attr([...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "", "data-stage") ?? "")?.slice(1).map(Number) ?? [1600, 900];
|
|
506
|
+
try {
|
|
507
|
+
warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
|
|
508
|
+
} catch (e) {
|
|
509
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
510
|
+
}
|
|
511
|
+
// A slide that scrolls on a phone fails: the player never scrolls a slide.
|
|
512
|
+
try {
|
|
513
|
+
errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
|
|
514
|
+
} catch (e) {
|
|
515
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
295
518
|
return {
|
|
296
519
|
deckDir,
|
|
297
520
|
slug: opts.slug || basename(deckDir),
|
|
@@ -300,6 +523,7 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
|
|
|
300
523
|
manifestUrl: player?.manifest ?? null,
|
|
301
524
|
passed: errors.length === 0,
|
|
302
525
|
errors,
|
|
526
|
+
warnings: [...new Set(warnings)],
|
|
303
527
|
};
|
|
304
528
|
}
|
|
305
529
|
|
|
@@ -311,6 +535,7 @@ export async function check(pos, opts = {}, { log = (line) => console.log(line),
|
|
|
311
535
|
log(`✗ ${result.errors.length} issue(s):`);
|
|
312
536
|
for (const e of result.errors) log(` - ${e}`);
|
|
313
537
|
} else log("✓ check passed");
|
|
538
|
+
for (const w of result.warnings) log(` ⚠ ${w}`);
|
|
314
539
|
}
|
|
315
540
|
return result;
|
|
316
541
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -241,9 +241,10 @@ commands:
|
|
|
241
241
|
logout [--dev]
|
|
242
242
|
whoami [--json] [--dev]
|
|
243
243
|
deck list|stats|open|rm
|
|
244
|
-
deck new <slug> "<title>" --narrated [--dir <path>]
|
|
245
|
-
scaffold decks/<slug>/: index.html (8 slides, player/v1), script.md, evidence.md
|
|
246
|
-
|
|
244
|
+
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
|
|
245
|
+
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
|
|
246
|
+
layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
|
|
247
|
+
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
|
|
247
248
|
the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
|
|
248
249
|
local refs, pinned player, a data-note per slide, mobile titles
|
|
249
250
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
|
|
@@ -256,11 +257,24 @@ commands:
|
|
|
256
257
|
skill install [--agent codex,claude-code] [--all-agents]
|
|
257
258
|
skills install [--agent codex,claude-code] [--all-agents]
|
|
258
259
|
activity [--deck <slug>] [--limit <n>] [--json]
|
|
259
|
-
gen-image "<prompt>" [--
|
|
260
|
+
gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--transparent] [--image <url>] [--size <s>]
|
|
261
|
+
[--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
|
|
262
|
+
default model gpt-image-2.5; --seed applies to nano-banana-2.1
|
|
260
263
|
image-job <jobId> [--json]
|
|
261
|
-
gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
|
|
262
|
-
a 3–10s clip (
|
|
264
|
+
gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
|
|
265
|
+
a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
|
|
263
266
|
video-job <jobId> [--json]
|
|
267
|
+
screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
|
|
268
|
+
a PNG of a public web page (private and local addresses are refused)
|
|
269
|
+
chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
|
|
270
|
+
a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
|
|
271
|
+
icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
|
|
272
|
+
a style-consistent set of transparent icons, one per subject (max 12)
|
|
273
|
+
review <slug> [--script <script.md>] [--json]
|
|
274
|
+
GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
|
|
275
|
+
review-job <jobId> [--json]
|
|
276
|
+
poster <slug> [--force] [--json]
|
|
277
|
+
regenerate the share-link poster (release makes one) and print its URL
|
|
264
278
|
credits [--limit <n>] [--json] show your credit balance and recent history
|
|
265
279
|
|
|
266
280
|
preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
|
|
@@ -289,7 +303,7 @@ async function genImage(pos, opts) {
|
|
|
289
303
|
const prompt = pos.join(" ").trim() || opts.prompt;
|
|
290
304
|
if (!prompt) {
|
|
291
305
|
throw new Error(
|
|
292
|
-
'usage: gen-image "<prompt>" [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--slug <deck>] [--name <name>] [--download <file>]',
|
|
306
|
+
'usage: gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--seed <n>] [--slug <deck>] [--name <name>] [--download <file>]',
|
|
293
307
|
);
|
|
294
308
|
}
|
|
295
309
|
let out = await ops.generateImage(opts, {
|
|
@@ -300,6 +314,8 @@ async function genImage(pos, opts) {
|
|
|
300
314
|
quality: opts.quality,
|
|
301
315
|
count: opts.count,
|
|
302
316
|
format: opts.format,
|
|
317
|
+
model: opts.model,
|
|
318
|
+
seed: opts.seed,
|
|
303
319
|
slug: opts.slug,
|
|
304
320
|
name: opts.name,
|
|
305
321
|
});
|
|
@@ -348,19 +364,20 @@ async function imageJob(pos, opts) {
|
|
|
348
364
|
async function genVideo(pos, opts) {
|
|
349
365
|
const prompt = pos.join(" ").trim() || opts.prompt;
|
|
350
366
|
if (!prompt) {
|
|
351
|
-
throw new Error('usage: gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
|
|
367
|
+
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>]');
|
|
352
368
|
}
|
|
353
369
|
const queued = await ops.generateVideo(opts, {
|
|
354
370
|
prompt,
|
|
355
371
|
image: opts.image,
|
|
356
372
|
seconds: opts.seconds,
|
|
357
373
|
aspect: opts.aspect,
|
|
374
|
+
model: opts.model,
|
|
358
375
|
slug: opts.slug,
|
|
359
376
|
name: opts.name,
|
|
360
377
|
});
|
|
361
378
|
// A clip takes a minute or more; poll so gen-video stays one blocking command.
|
|
362
379
|
// Progress goes to stderr so stdout stays the URL (or --json).
|
|
363
|
-
process.stderr.write(`queued a ${queued.seconds}s clip — job ${queued.jobId}\n`);
|
|
380
|
+
process.stderr.write(`queued a ${queued.seconds}s ${queued.model} clip — job ${queued.jobId}\n`);
|
|
364
381
|
const job = await ops.pollVideoJob(opts, queued.jobId, {
|
|
365
382
|
onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
|
|
366
383
|
});
|
|
@@ -391,6 +408,112 @@ async function videoJob(pos, opts) {
|
|
|
391
408
|
if (job.video?.url) console.log(job.video.url);
|
|
392
409
|
}
|
|
393
410
|
|
|
411
|
+
async function screenshot(pos, opts) {
|
|
412
|
+
const [url] = pos;
|
|
413
|
+
if (!url) throw new Error("usage: screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]");
|
|
414
|
+
const out = await ops.captureUrl(opts, {
|
|
415
|
+
url,
|
|
416
|
+
width: opts.width,
|
|
417
|
+
height: opts.height,
|
|
418
|
+
fullPage: opts["full-page"],
|
|
419
|
+
slug: opts.slug,
|
|
420
|
+
name: opts.name,
|
|
421
|
+
});
|
|
422
|
+
// Print the paid asset's URL before downloading, so a failed download never hides it.
|
|
423
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : out.url);
|
|
424
|
+
if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
|
|
425
|
+
await saveTo(opts.download, out.url);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function chart(pos, opts) {
|
|
429
|
+
const [file] = pos;
|
|
430
|
+
if (!file) throw new Error("usage: chart <spec.json> [--slug <deck>] [--name <name>] [--download <file>]");
|
|
431
|
+
let spec;
|
|
432
|
+
try {
|
|
433
|
+
spec = JSON.parse(file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8"));
|
|
434
|
+
} catch (e) {
|
|
435
|
+
throw new Error(`could not read chart spec ${file}: ${e.message}`);
|
|
436
|
+
}
|
|
437
|
+
const out = await ops.renderChart(opts, { ...spec, slug: opts.slug ?? spec.slug, name: opts.name ?? spec.name });
|
|
438
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : out.url);
|
|
439
|
+
if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
|
|
440
|
+
await saveTo(opts.download, out.url);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function icons(pos, opts) {
|
|
444
|
+
if (!pos.length || !opts.style) {
|
|
445
|
+
throw new Error('usage: icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--model nano-banana-2.1|gpt-image-2.5|gpt-image-2] [--slug <deck>] [--name <prefix>]');
|
|
446
|
+
}
|
|
447
|
+
const queued = await ops.generateIconSet(opts, {
|
|
448
|
+
subjects: pos,
|
|
449
|
+
style: opts.style,
|
|
450
|
+
palette: opts.palette ? String(opts.palette).split(",").map((c) => c.trim()).filter(Boolean) : undefined,
|
|
451
|
+
model: opts.model,
|
|
452
|
+
slug: opts.slug,
|
|
453
|
+
name: opts.name,
|
|
454
|
+
});
|
|
455
|
+
process.stderr.write(`queued ${queued.count} icon(s) — job ${queued.jobId}\n`);
|
|
456
|
+
const out = await ops.pollImageJob(opts, queued.jobId, {
|
|
457
|
+
onProgress: (job) => process.stderr.write(` ${job.progress} (${job.status})…\r`),
|
|
458
|
+
});
|
|
459
|
+
process.stderr.write("\n");
|
|
460
|
+
if (opts.json) return void console.log(JSON.stringify({ ...out, subjects: queued.subjects }, null, 2));
|
|
461
|
+
(out.images || []).forEach((im, i) => console.log(`${queued.subjects[i] ?? im.name}\t${im.url}`));
|
|
462
|
+
if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function printReview(job) {
|
|
466
|
+
console.log(`review of ${job.slug}: ${job.fix} to fix, ${job.consider} to consider (${job.progress} slides, ${job.model})`);
|
|
467
|
+
for (const f of job.findings) {
|
|
468
|
+
console.log(` [${f.severity}] ${f.slide} (${f.viewport}, ${f.category}): ${f.issue}`);
|
|
469
|
+
console.log(` → ${f.suggestion}`);
|
|
470
|
+
}
|
|
471
|
+
for (const s of job.failedSlides || []) console.log(` ! ${s.slide}: not reviewed (${s.error}); not charged`);
|
|
472
|
+
if (!job.findings.length && !job.failedSlides?.length) console.log(" no findings: every slide looks clean");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function review(pos, opts) {
|
|
476
|
+
const [slug] = pos;
|
|
477
|
+
if (!slug) throw new Error("usage: review <slug> [--script <script.md>] [--json]");
|
|
478
|
+
const script = opts.script ? readFileSync(opts.script, "utf8") : undefined;
|
|
479
|
+
const queued = await ops.reviewDeck(opts, { slug, script });
|
|
480
|
+
process.stderr.write(`reviewing ${queued.slides} slide(s) on desktop and phone — job ${queued.jobId}\n`);
|
|
481
|
+
const job = await ops.pollReviewJob(opts, queued.jobId, {
|
|
482
|
+
onProgress: (j) => process.stderr.write(` ${j.progress} slides…\r`),
|
|
483
|
+
});
|
|
484
|
+
process.stderr.write("\n");
|
|
485
|
+
if (opts.json) console.log(JSON.stringify(job, null, 2));
|
|
486
|
+
else printReview(job);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function reviewJob(pos, opts) {
|
|
490
|
+
const [jobId] = pos;
|
|
491
|
+
if (!jobId) throw new Error("usage: review-job <jobId>");
|
|
492
|
+
const job = await ops.checkReviewJob(opts, jobId);
|
|
493
|
+
if (opts.json) return void console.log(JSON.stringify(job, null, 2));
|
|
494
|
+
console.log(`status: ${job.status}`);
|
|
495
|
+
if (job.error) console.log(`error: ${job.error}`);
|
|
496
|
+
printReview(job);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function poster(pos, opts) {
|
|
500
|
+
const [slug] = pos;
|
|
501
|
+
if (!slug) throw new Error("usage: poster <slug> [--force] [--json]");
|
|
502
|
+
const out = await ops.generatePoster(opts, { slug, force: opts.force });
|
|
503
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
504
|
+
if (out.unchanged) process.stderr.write("poster already matches the deck (pass --force to re-render)\n");
|
|
505
|
+
console.log(out.url);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Download a hosted asset to `file` when --download was given.
|
|
509
|
+
async function saveTo(file, url) {
|
|
510
|
+
if (!file || !url) return;
|
|
511
|
+
const res = await fetch(url);
|
|
512
|
+
if (!res.ok) throw new Error(`download of ${url} failed (${res.status})`);
|
|
513
|
+
writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
|
514
|
+
process.stderr.write(`saved to ${file}\n`);
|
|
515
|
+
}
|
|
516
|
+
|
|
394
517
|
// The command table: "<group> <cmd>" or "<group>" → handler. Exported (via
|
|
395
518
|
// COMMANDS) so the CLI/MCP parity test can enumerate it.
|
|
396
519
|
function commandTable(pos, rest, opts) {
|
|
@@ -473,6 +596,12 @@ function commandTable(pos, rest, opts) {
|
|
|
473
596
|
"image-job": () => imageJob(pos.slice(1), opts),
|
|
474
597
|
"gen-video": () => genVideo(pos.slice(1), opts),
|
|
475
598
|
"video-job": () => videoJob(pos.slice(1), opts),
|
|
599
|
+
screenshot: () => screenshot(pos.slice(1), opts),
|
|
600
|
+
chart: () => chart(pos.slice(1), opts),
|
|
601
|
+
icons: () => icons(pos.slice(1), opts),
|
|
602
|
+
review: () => review(pos.slice(1), opts),
|
|
603
|
+
"review-job": () => reviewJob(pos.slice(1), opts),
|
|
604
|
+
poster: () => poster(pos.slice(1), opts),
|
|
476
605
|
credits: () => credits(pos.slice(1), opts),
|
|
477
606
|
};
|
|
478
607
|
}
|
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)\s*=\s*(["'])([^"']+)\2/gi,
|
|
161
|
+
/\b(src|href|poster|data-presenter-image)\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
|
@@ -52,7 +52,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
52
52
|
const { deckDir, fields } = narrationRequest(pos, opts);
|
|
53
53
|
if (opts["dry-run"]) {
|
|
54
54
|
const preview = await ops.previewNarration(opts, fields);
|
|
55
|
-
|
|
55
|
+
if (preview.presenterStyle) log(`presenter style: ${preview.presenterStyle}`);
|
|
56
|
+
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}` : ""}`);
|
|
56
57
|
const render = preview.slides.filter((s) => s.render);
|
|
57
58
|
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`);
|
|
58
59
|
return preview;
|