wand-kit 0.2.0 → 0.3.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/PROMPT.md +86 -5
- package/SKILL.md +41 -4
- package/START-HERE.md +6 -0
- package/examples/storyboard/job.mjs +72 -0
- package/examples/storyboard/script.txt +6 -0
- package/examples/talk/agents-demo.html +69 -0
- package/examples/talk/agents-demo.poster-next.png +0 -0
- package/examples/talk/agents-demo.poster.png +0 -0
- package/examples/talk/job.mjs +85 -0
- package/lib/board.js +152 -0
- package/lib/brand.js +3 -0
- package/lib/components.js +151 -5
- package/lib/engine.js +32 -6
- package/lib/html_export.js +35 -4
- package/lib/runtime_check.js +108 -0
- package/lib/talk.js +620 -0
- package/package.json +2 -2
- package/templates/storyboard.mjs +33 -0
- package/wand.js +52 -9
package/lib/components.js
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
* content lines. A job's `pages(c)` composes with them and may add its own
|
|
19
19
|
* CSS on top; nothing stops it writing raw markup where no component fits.
|
|
20
20
|
*/
|
|
21
|
+
import path from "node:path";
|
|
21
22
|
import { esc } from "./content.js";
|
|
22
23
|
import { url } from "./brand.js";
|
|
23
24
|
|
|
24
25
|
export const ARROW = `<svg class="arrow deco" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 1 L9 5 L2 9 Z"/></svg>`;
|
|
25
26
|
|
|
26
|
-
export function bind({ brand, surface, job, content }) {
|
|
27
|
+
export function bind({ brand, surface, job, content, dir: jobDir = process.cwd() }) {
|
|
27
28
|
let n = 0;
|
|
28
29
|
const L = content.L;
|
|
29
30
|
const cls = (c) => (c ? ` ${c}` : "");
|
|
@@ -47,13 +48,15 @@ export function bind({ brand, surface, job, content }) {
|
|
|
47
48
|
* A page (a slide, a sheet, a card) with the chrome: the tag and eyebrow top
|
|
48
49
|
* left, the mark top right, the footer with the running line and the page
|
|
49
50
|
* number. `art` is a brand art role ("cover", "overlay", "closing") or a
|
|
50
|
-
* file path; `notes` become speaker notes in the .pptx
|
|
51
|
+
* file path; `notes` become speaker notes in the .pptx and the presenter
|
|
52
|
+
* view; `seconds` is the slide's share of the talk, for the presenter's
|
|
53
|
+
* pace; `motion: "none"` keeps a slide still when the deck animates.
|
|
51
54
|
*/
|
|
52
|
-
const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true } = {}) => {
|
|
55
|
+
const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "" } = {}) => {
|
|
53
56
|
n += 1;
|
|
54
57
|
const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(art)) : null;
|
|
55
58
|
const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
|
|
56
|
-
return `<section class="page ${dark ? "dark" : "light"}${cls(k)}" data-n="${n}"${notes ? ` data-notes="${esc(notes)}"` : ""}>
|
|
59
|
+
return `<section class="page ${dark ? "dark" : "light"}${cls(k)}" data-n="${n}"${notes ? ` data-notes="${esc(notes)}"` : ""}${seconds ? ` data-seconds="${seconds}"` : ""}${motion ? ` data-motion="${esc(motion)}"` : ""}>
|
|
57
60
|
${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
|
|
58
61
|
${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
|
|
59
62
|
<div class="content">${body}</div>
|
|
@@ -104,7 +107,8 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
|
|
|
104
107
|
return `<div class="card q${emph ? " emph" : ""}${cls(k)}"><p>${esc(text)}</p>${who(name, role)}</div>`;
|
|
105
108
|
};
|
|
106
109
|
|
|
107
|
-
|
|
110
|
+
/** A figure and its key; `count` makes the figure count up as its slide arrives in a talk. */
|
|
111
|
+
const stat = ({ v, k, hero = false, count = false }) => `<div class="stat${hero ? " hero" : ""}"><div class="v"${count ? ' data-a="count"' : ""}>${esc(v)}</div><div class="k">${esc(k)}</div></div>`;
|
|
108
112
|
const stats = (items) => `<div class="stats">${items.map(stat).join("")}</div>`;
|
|
109
113
|
const delta = (from, to) => `<div class="delta"><span class="from">${esc(from)}</span>${ARROW}<span class="to">${esc(to)}</span></div>`;
|
|
110
114
|
|
|
@@ -130,6 +134,99 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
|
|
|
130
134
|
const icon = (name, k = "") => brand.glyph(name).replace("<svg", `<svg class="icon deco${cls(k)}" aria-hidden="true"`);
|
|
131
135
|
const tile = (name) => `<div class="tile deco">${icon(name)}</div>`;
|
|
132
136
|
|
|
137
|
+
/* ---------------------------------------------------------- motion */
|
|
138
|
+
|
|
139
|
+
/** An entrance, as attributes for raw markup: `<div${c.enter("pop", 2)}>`. Kinds: rise (default), fade, pop, grow (bars, up), widen (bars, across), draw (an SVG path with pathLength="1"), count. */
|
|
140
|
+
const enter = (kind = "", i = null) => ` data-a="${esc(kind)}"${i != null ? ` style="--i:${+i}"` : ""}`;
|
|
141
|
+
/** Held back until the presenter moves on; steps of one number come in together. */
|
|
142
|
+
const step = (nth, html, { a = "", tag: t = "div", cls: k = "" } = {}) => `<${t} data-step="${+nth}"${a ? ` data-a="${esc(a)}"` : ""}${k ? ` class="${k}"` : ""}>${html}</${t}>`;
|
|
143
|
+
/** A figure that counts up from zero as its slide arrives. */
|
|
144
|
+
const countup = (text, k = "") => `<span data-a="count"${k ? ` class="${k}"` : ""}>${esc(text)}</span>`;
|
|
145
|
+
|
|
146
|
+
const fileUrl = (f) => (/^(file|https?|data):/.test(f) ? f : url(path.resolve(jobDir, f)));
|
|
147
|
+
/** A clip that plays while its slide is on (muted, looping by default); the poster is what the PDF, the PNGs and the .pptx show. */
|
|
148
|
+
const video = ({ src, poster, autoplay = true, loop = true, label = "", cls: k = "" }) => {
|
|
149
|
+
if (!poster) throw new Error(`video ${src}: a poster is required, it is the frame every static export shows`);
|
|
150
|
+
return `<figure class="media video${cls(k)}"${autoplay ? ' data-autoplay="1"' : ""}${label ? ` aria-label="${esc(label)}"` : ""}><img class="poster" src="${fileUrl(poster)}" alt=""><video src="${fileUrl(src)}" muted playsinline preload="none"${loop ? " loop" : ""}></video></figure>`;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* A live page (a simulation, a product walk-through) in a frame on the slide.
|
|
154
|
+
* `src` is one self-contained HTML file; it travels inside the deck. It may
|
|
155
|
+
* expose window.wandDemo = { play, pause, reset } for the D and R keys.
|
|
156
|
+
* `min` is the size it is laid out at before being scaled into the frame.
|
|
157
|
+
*/
|
|
158
|
+
const demo = ({ src, poster, min = [1280, 720], label = "Live demo", cls: k = "" }) => {
|
|
159
|
+
if (!poster) throw new Error(`demo ${src}: a poster is required (node wand.js poster ${src}), it is the frame every static export shows`);
|
|
160
|
+
return `<figure class="media demo${cls(k)}" data-demo-src="${fileUrl(src)}" data-min="${min[0]}x${min[1]}" aria-label="${esc(label)}"><img class="poster" src="${fileUrl(poster)}" alt=""><div class="screen"></div></figure>`;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------- storyboard */
|
|
164
|
+
|
|
165
|
+
const tc = (t) => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
|
|
166
|
+
/**
|
|
167
|
+
* A quick frame in the brand's colours, for a shot with no artwork yet:
|
|
168
|
+
* people as dots, software as labelled boxes, agents as sparks, left to
|
|
169
|
+
* right with arrows between, and a line under them.
|
|
170
|
+
*/
|
|
171
|
+
const sketch = ({ people = 0, apps = [], agents = 0, label = "", arrows = true } = {}) => {
|
|
172
|
+
const groups = [];
|
|
173
|
+
if (people) groups.push((x) => { const n = Math.min(people, 12), cols = Math.ceil(Math.sqrt(n)), r = 34, g = 96, rows = Math.ceil(n / cols);
|
|
174
|
+
return Array.from({ length: n }, (_, i) => `<circle class="sk-p" cx="${x + (i % cols - (cols - 1) / 2) * g}" cy="${470 + (Math.floor(i / cols) - (rows - 1) / 2) * g}" r="${r}"/>`).join(""); });
|
|
175
|
+
if (apps.length) groups.push((x) => apps.slice(0, 4).map((a, i, all) => { const h = 104, y = 470 + (i - (all.length - 1) / 2) * (h + 26);
|
|
176
|
+
return `<g class="sk-app"><rect x="${x - 190}" y="${y - h / 2}" width="380" height="${h}" rx="18"/><text x="${x}" y="${y + 20}" text-anchor="middle">${esc(a)}</text></g>`; }).join(""));
|
|
177
|
+
if (agents) groups.push((x) => { const n = Math.min(agents, 6); return Array.from({ length: n }, (_, i) => { const cx = x + ((i % 3) - (Math.min(n, 3) - 1) / 2) * 150, cy = 470 + (Math.floor(i / 3) - (n > 3 ? .5 : 0)) * 150, R = 58, r = 16;
|
|
178
|
+
return `<path class="sk-s" d="M${cx} ${cy - R}Q${cx + r} ${cy - r} ${cx + R} ${cy}Q${cx + r} ${cy + r} ${cx} ${cy + R}Q${cx - r} ${cy + r} ${cx - R} ${cy}Q${cx - r} ${cy - r} ${cx} ${cy - R}Z"/>`; }).join(""); });
|
|
179
|
+
const xs = groups.length === 1 ? [960] : groups.length === 2 ? [600, 1320] : [400, 960, 1520];
|
|
180
|
+
const arrow = (x) => `<path class="sk-arrow" d="M${x - 40} 452h56v-22l40 40-40 40v-22h-56z"/>`;
|
|
181
|
+
const art = groups.map((g, i) => g(xs[i])).join("") + (arrows ? xs.slice(1).map((x, i) => arrow((xs[i] + x) / 2)).join("") : "");
|
|
182
|
+
return `<svg class="sk" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid meet" aria-hidden="true">${art}${label ? `<text class="sk-label" x="960" y="900" text-anchor="middle">${esc(label)}</text>` : ""}</svg>`;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A storyboard from a script: the cover, the run of show, one page per shot.
|
|
187
|
+
* Timing is scratch timing: each voiceover line lasts its words at `wpm`,
|
|
188
|
+
* with `lead` before and `tail` after; a shot without voiceover holds for
|
|
189
|
+
* `hold` seconds; `seconds` on a shot overrides the estimate. A frame lands
|
|
190
|
+
* on its `cue` word. The timeline is kept for the CSV, the captions and the
|
|
191
|
+
* player in the HTML.
|
|
192
|
+
* A shot: { act, name, vo, line, camera, motion, onscreen, sound, next,
|
|
193
|
+
* lead, tail, hold, seconds, frames: [{ cue, caption, art | img | sketch }] }
|
|
194
|
+
*/
|
|
195
|
+
const storyboard = ({ title, lede = "", wpm = 150, target = 0, shots = [], dark = true, cover: withCover = true, index = true, audio = "" }) => {
|
|
196
|
+
let t = 0;
|
|
197
|
+
const timeline = shots.map((s, i) => {
|
|
198
|
+
const vo = String(s.vo ?? "").trim(), words = vo ? vo.split(/\s+/).length : 0;
|
|
199
|
+
const voDur = vo ? +(s.seconds ?? words / (wpm / 60)).toFixed(1) : 0;
|
|
200
|
+
const lead = vo ? s.lead ?? 0.5 : 0, tail = vo ? s.tail ?? 0.5 : 0;
|
|
201
|
+
const inT = t, voIn = inT + lead, voOut = voIn + voDur, out = vo ? voOut + tail : inT + (s.hold ?? s.seconds ?? 3);
|
|
202
|
+
t = +out.toFixed(1);
|
|
203
|
+
const fr = (s.frames && s.frames.length ? s.frames : [{}]).slice(0, 4).map((f, j, all) => {
|
|
204
|
+
const at = f.cue && vo ? vo.toLowerCase().indexOf(String(f.cue).toLowerCase()) : -1;
|
|
205
|
+
// the first frame is on screen from the cut; the others land on their cue words
|
|
206
|
+
const ft = j === 0 ? inT : at >= 0 ? voIn + voDur * (at / vo.length) : voIn + (voDur * j) / all.length;
|
|
207
|
+
return { ...f, id: `${String(i + 1).padStart(2, "0")}${"ABCD"[j]}`, t: +ft.toFixed(1), placeholder: !(f.art || f.img || f.sketch) };
|
|
208
|
+
});
|
|
209
|
+
return { ...s, n: i + 1, vo, in: +inT.toFixed(1), voIn: +voIn.toFixed(1), voOut: +voOut.toFixed(1), out: t, dur: +(t - inT).toFixed(1), frames: fr };
|
|
210
|
+
});
|
|
211
|
+
const total = t;
|
|
212
|
+
job.__board = { wpm, target, total, audio: audio ? path.resolve(jobDir, audio) : "", shots: timeline.map(({ n, act, name, vo, in: i, voIn, voOut, out, dur, frames }) => ({ n, act: act || "", name: name || "", vo, in: i, voIn, voOut, out, dur, frames: frames.map(({ id, t: ft, cue, placeholder }) => ({ id, t: ft, cue: cue || "", placeholder })) })) };
|
|
213
|
+
|
|
214
|
+
const art = (f) => f.art ? f.art : f.img ? `<img src="${fileUrl(f.img)}" alt="">` : f.sketch ? sketch(f.sketch) : `<div class="todo"><span>${esc(f.cue ? `on ‘${f.cue}’` : "frame")}</span></div>`;
|
|
215
|
+
const DIR = [["line", "The line"], ["camera", "Camera"], ["motion", "Motion"], ["onscreen", "On screen"], ["sound", "Sound"], ["next", "Into next"]];
|
|
216
|
+
const shotPage = (s) => page({ eyebrow: s.act || "", dark, cls: "shot", notes: s.vo, body: `
|
|
217
|
+
<div class="shot-head"><div class="shot-id"><span class="tag">Shot</span><span class="no">${String(s.n).padStart(2, "0")}</span></div>
|
|
218
|
+
<div class="shot-title"><h2>${esc(s.name || "")}</h2><p class="tc">${tc(s.in)} → ${tc(s.out)} · ${s.dur.toFixed(1)} s${s.vo ? ` · voice ${(s.voOut - s.voIn).toFixed(1)} s` : ""}</p></div></div>
|
|
219
|
+
${s.vo ? `<blockquote class="vo">${esc(s.vo)}</blockquote>` : `<p class="vo none">No voiceover; holds ${s.dur.toFixed(1)} s</p>`}
|
|
220
|
+
<div class="shot-body n${s.frames.length} ${s.frames.length <= 2 ? "side" : "stack"}"><div class="frames n${s.frames.length}">${s.frames.map((f) => `<figure class="frame${f.placeholder ? " placeholder" : ""}" data-frame="${f.id}"><div class="frame-art">${art(f)}</div><figcaption><b>${f.id}</b> · ${tc(f.t)}${f.cue ? ` · on ‘${esc(f.cue)}’` : ""}${f.caption ? `<span>${esc(f.caption)}</span>` : ""}</figcaption></figure>`).join("")}</div>
|
|
221
|
+
${DIR.some(([k]) => s[k]) ? `<dl class="direction">${DIR.filter(([k]) => s[k]).map(([k, l]) => `<div><dt>${l}</dt><dd>${esc(s[k])}</dd></div>`).join("")}</dl>` : ""}</div>` });
|
|
222
|
+
|
|
223
|
+
const out = [];
|
|
224
|
+
if (withCover) out.push(cover({ label: "Storyboard", title: esc(title), lede, meta: `${timeline.length} shots · ${tc(total)}${target ? ` of ${tc(target)}` : ""} · scratch timing at ${wpm} words a minute` }));
|
|
225
|
+
if (index) for (let i = 0; i < timeline.length; i += 9) out.push(page({ eyebrow: "Run of show", dark, cls: "run", body: `${h("h2")(i ? "Run of show, continued" : "Run of show")}${table({ head: ["Shot", "Act", "Name", "In", "Length"], rows: timeline.slice(i, i + 9).map((s) => [String(s.n).padStart(2, "0"), s.act || "", s.name || "", tc(s.in), `${s.dur.toFixed(1)} s`]) })}` }));
|
|
226
|
+
timeline.forEach((s) => out.push(shotPage(s)));
|
|
227
|
+
return out;
|
|
228
|
+
};
|
|
229
|
+
|
|
133
230
|
return {
|
|
134
231
|
L, lines: content.lines, esc, accent, split, cap, raw: (s) => s, ARROW, arrow: ARROW,
|
|
135
232
|
page, sec, h1: h("h1"), h2: h("h2"), h3: h("h3"), h4: h("h4"), h5: h("h5"),
|
|
@@ -137,6 +234,7 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
|
|
|
137
234
|
cols, card, chip, strip, phase, flow, table, who, quote, stat, stats, delta, steps, callout, list: listOf,
|
|
138
235
|
cover, closing, chapter, img, icon, tile, mark, wordmark,
|
|
139
236
|
asset: brand.assetUrl, art: brand.artUrl,
|
|
237
|
+
enter, step, countup, video, demo, sketch, storyboard,
|
|
140
238
|
brand: brand.name, surface: surface.name, count: () => n,
|
|
141
239
|
};
|
|
142
240
|
}
|
|
@@ -310,4 +408,52 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
310
408
|
.centre{text-align:center;align-items:center}
|
|
311
409
|
.grow{flex:1}
|
|
312
410
|
.spread{justify-content:space-between}
|
|
411
|
+
|
|
412
|
+
/* media: the poster is what a static export shows; the runtime plays the rest */
|
|
413
|
+
.media{position:relative;margin:0;overflow:hidden;border-radius:calc(var(--m-radius) * var(--radius-k));aspect-ratio:16/9;background:var(--track);border:var(--card-stroke) solid var(--stroke)}
|
|
414
|
+
.media>.poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
|
415
|
+
.media>video{display:none;position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
|
416
|
+
.media>.screen{position:absolute;inset:0;overflow:hidden}
|
|
417
|
+
|
|
418
|
+
/* the storyboard: a shot a page, frames left to right, the direction under them */
|
|
419
|
+
.shot .content{display:flex;flex-direction:column;gap:calc(var(--m-gap) * .8)}
|
|
420
|
+
.shot-head{display:flex;align-items:flex-end;gap:calc(var(--m-gap) * 1.2)}
|
|
421
|
+
.shot-id{display:flex;flex-direction:column;line-height:1}
|
|
422
|
+
.shot-id .tag{margin:0 0 .3em}
|
|
423
|
+
.shot-id .no{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-stat);line-height:.9;color:var(--accent);font-variant-numeric:tabular-nums}
|
|
424
|
+
.shot-title h2{margin:0}
|
|
425
|
+
.shot-title .tc{margin:.35em 0 0;font-size:var(--t-sm);color:var(--soft);font-variant-numeric:tabular-nums}
|
|
426
|
+
.vo{margin:0;font-size:var(--t-lead);line-height:1.45;color:var(--ink);max-width:92%;quotes:"\\201C" "\\201D"}
|
|
427
|
+
.vo::before{content:open-quote;color:var(--accent)}
|
|
428
|
+
.vo::after{content:close-quote;color:var(--accent)}
|
|
429
|
+
.vo.none{color:var(--soft);font-size:var(--t-sm)}
|
|
430
|
+
.vo.none::before,.vo.none::after{content:none}
|
|
431
|
+
/* one or two frames: the direction beside them; three or four: under them */
|
|
432
|
+
.shot-body{display:grid;gap:calc(var(--m-gap) * .8) var(--m-col-gap);align-items:start}
|
|
433
|
+
.shot-body.side.n1{grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)}
|
|
434
|
+
.shot-body.side.n2{grid-template-columns:minmax(0,1.7fr) minmax(0,1fr)}
|
|
435
|
+
.shot-body.side .direction{grid-template-columns:minmax(0,1fr);border-top:0;padding-top:0;border-left:var(--card-stroke) solid var(--rule);padding-left:var(--m-col-gap)}
|
|
436
|
+
.frames{display:grid;gap:var(--m-col-gap);align-items:start}
|
|
437
|
+
.frames.n1{grid-template-columns:minmax(0,1fr)}
|
|
438
|
+
.frames.n2{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
439
|
+
.frames.n3{grid-template-columns:repeat(3,minmax(0,1fr))}
|
|
440
|
+
.frames.n4{grid-template-columns:repeat(4,minmax(0,1fr))}
|
|
441
|
+
.frame{margin:0;display:flex;flex-direction:column;gap:.5em}
|
|
442
|
+
.frame-art{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:calc(var(--m-radius) * .7 * var(--radius-k));background:var(--track);border:var(--card-stroke) solid var(--stroke)}
|
|
443
|
+
.frame-art>svg,.frame-art>img{position:absolute;inset:0;width:100%;height:100%;display:block}
|
|
444
|
+
.frame-art>img{object-fit:cover}
|
|
445
|
+
.frame figcaption{font-size:var(--t-note);line-height:1.4;color:var(--soft);font-variant-numeric:tabular-nums}
|
|
446
|
+
.frame figcaption b{color:var(--label);font-weight:600}
|
|
447
|
+
.frame figcaption span{display:block;color:var(--text);margin-top:.2em}
|
|
448
|
+
.frame .todo{position:absolute;inset:0;display:grid;place-items:center;border:2px dashed var(--rule);border-radius:inherit}
|
|
449
|
+
.frame .todo span{font-size:var(--t-sm);color:var(--soft)}
|
|
450
|
+
.direction{margin:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:calc(var(--m-gap) * .5) var(--m-col-gap);border-top:var(--card-stroke) solid var(--rule);padding-top:calc(var(--m-gap) * .6)}
|
|
451
|
+
.direction dt{font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-tag);letter-spacing:.14em;text-transform:var(--caps-label);color:var(--label);margin-bottom:.3em}
|
|
452
|
+
.direction dd{margin:0;font-size:var(--t-sm);line-height:1.45;color:var(--text)}
|
|
453
|
+
.sk .sk-p{fill:var(--ink)}
|
|
454
|
+
.sk .sk-app rect{fill:var(--paper);stroke:var(--stroke);stroke-width:5}
|
|
455
|
+
.sk .sk-app text{fill:var(--ink);font-family:var(--face-body);font-size:52px}
|
|
456
|
+
.sk .sk-s{fill:var(--accent)}
|
|
457
|
+
.sk .sk-arrow{fill:var(--soft)}
|
|
458
|
+
.sk .sk-label{fill:var(--text);font-family:var(--face-body);font-size:62px}
|
|
313
459
|
`;
|
package/lib/engine.js
CHANGED
|
@@ -109,7 +109,9 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
109
109
|
const lum = (h) => { const c = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
|
|
110
110
|
const contrast = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
|
111
111
|
const bgOf = (el) => { for (let e = el; e; e = e.parentElement) { const h = toHex(getComputedStyle(e).backgroundColor); if (h) return h; if (e.classList.contains("page")) break; } return null; };
|
|
112
|
-
const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], dash: [], texts: [], notes: []
|
|
112
|
+
const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], dash: [], texts: [], notes: [], broken: [],
|
|
113
|
+
motion: { a: document.querySelectorAll("[data-a]").length, steps: document.querySelectorAll("[data-step]").length, video: document.querySelectorAll(".media.video").length, demo: document.querySelectorAll(".media.demo").length, placeholders: document.querySelectorAll(".frame.placeholder").length } };
|
|
114
|
+
document.querySelectorAll("img").forEach((im) => { if (im.getAttribute("src") && !(im.complete && im.naturalWidth)) res.broken.push(decodeURIComponent(im.getAttribute("src").replace(/^file:\/\//, "")).split("/").slice(-2).join("/")); });
|
|
113
115
|
const seen = new Set();
|
|
114
116
|
pages.forEach((pg, pi) => {
|
|
115
117
|
const n = pi + 1;
|
|
@@ -176,16 +178,21 @@ function GATES({ min, chars, tokens, families, base }) {
|
|
|
176
178
|
* Build a job: gates, and unless `outputs` is false, the PDF, the PNGs and the
|
|
177
179
|
* sheet in `<job dir>/out/`. Returns { ok, rows, outputs, html, texts }.
|
|
178
180
|
*/
|
|
179
|
-
export async function build(jobPath, { outputs: wantOutputs = true, outDir = null, quiet = false } = {}) {
|
|
181
|
+
export async function build(jobPath, { outputs: wantOutputs = true, outDir = null, quiet = false, brand: brandOverride = null } = {}) {
|
|
180
182
|
const t0 = Date.now();
|
|
181
183
|
const { job, file, dir } = await loadJob(jobPath);
|
|
182
|
-
|
|
184
|
+
// --brand builds the same job on the other brand, beside the first, in out-<brand>/
|
|
185
|
+
const brandName = brandOverride || job.brand || DEFAULT_BRAND;
|
|
186
|
+
const brand = loadBrand(brandName, job.theme || null);
|
|
183
187
|
const surface = surfaceOf(job.surface || "letter");
|
|
184
188
|
const content = loadContent(dir, job);
|
|
185
|
-
|
|
189
|
+
delete job.__board;
|
|
190
|
+
const c = bind({ brand, surface, job, content, dir });
|
|
186
191
|
const pages = job.pages(c);
|
|
187
192
|
const html = assemble({ brand, surface, job, pages });
|
|
188
|
-
const out = outDir || path.join(dir, "out");
|
|
193
|
+
const out = outDir || path.join(dir, brandOverride && brandOverride !== (job.brand || DEFAULT_BRAND) ? `out-${brandOverride}` : "out");
|
|
194
|
+
// every file a demo or a clip names must exist before anything is drawn from it
|
|
195
|
+
const absent = [...html.matchAll(/(?:data-demo-src|<video src)="(file:\/\/[^"]+)"/g)].map((m) => fileURLToPath(m[1])).filter((p) => !fs.existsSync(p));
|
|
189
196
|
const work = path.join(out, "work");
|
|
190
197
|
fs.mkdirSync(work, { recursive: true });
|
|
191
198
|
const name = stem(job, file);
|
|
@@ -224,6 +231,16 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
|
|
|
224
231
|
row(!deny.length, "names", job.denylist?.length ? (deny.length ? `found ${deny.join(", ")}` : `none of ${job.denylist.length} denied names`) : "no denylist", !job.denylist?.length);
|
|
225
232
|
row(!g.contrast.length, "contrast", g.contrast.length ? `${g.contrast.length} low: ` + g.contrast.slice(0, 3).map((o) => `p${o.page} ${o.el} ${o.ratio}:1 (needs ${o.need})`).join("; ") : "every text passes WCAG AA", true);
|
|
226
233
|
row(!g.measure.length, "measure", g.measure.length ? `${g.measure.length} long: ` + g.measure.slice(0, 3).map((o) => `p${o.page} ${o.el} ~${o.chars} chars`).join("; ") : `no line over ${surface.chars} characters`, true);
|
|
234
|
+
row(!g.broken.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}` : "every image loaded");
|
|
235
|
+
if (absent.length || g.motion.demo || g.motion.video) row(!absent.length, "media", absent.length ? `missing: ${absent.map((p) => path.basename(p)).join(", ")}` : `${g.motion.demo} demo${g.motion.demo === 1 ? "" : "s"}, ${g.motion.video} clip${g.motion.video === 1 ? "" : "s"}, each with its poster`);
|
|
236
|
+
const board = job.__board || null;
|
|
237
|
+
if (surface.kind === "slide" && !board) row(true, "motion", `${job.talk?.motion === "none" ? "still" : "entrances on every slide"}${g.motion.a ? `, ${g.motion.a} marked` : ""}${g.motion.steps ? `, ${g.motion.steps} held for steps` : ""} (the HTML; static exports show the end state)`);
|
|
238
|
+
if (board) {
|
|
239
|
+
const off = board.target ? Math.abs(board.total - board.target) / board.target : 0;
|
|
240
|
+
const mmss = (t) => `${Math.floor(t / 60)}:${String(Math.round(t % 60)).padStart(2, "0")}`;
|
|
241
|
+
row(off <= 0.1, "timing", `${board.shots.length} shots, ${mmss(board.total)}${board.target ? ` against ${mmss(board.target)} (${board.total > board.target ? "+" : "-"}${Math.round(off * 100)}%)` : ""}, scratch timing at ${board.wpm} wpm`, true);
|
|
242
|
+
row(!g.motion.placeholders, "frames", g.motion.placeholders ? `${g.motion.placeholders} frame${g.motion.placeholders === 1 ? "" : "s"} still to draw` : `${board.shots.reduce((a, s) => a + s.frames.length, 0)} frames, every one drawn`, true);
|
|
243
|
+
}
|
|
227
244
|
row(!errors.length, "console", errors.length ? errors.slice(0, 2).join(" | ") : "no errors");
|
|
228
245
|
|
|
229
246
|
const outputs = { html: htmlPath };
|
|
@@ -244,11 +261,20 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
|
|
|
244
261
|
row(!(pc && pc.miss.length) && !dash, "pdf text", dash ? "em dash in the PDF text" : pc ? (pc.miss.length ? `missing lines ${pc.miss.join(", ")}` : `${pc.found}/${pc.total} lines read back from the PDF`) : "readable");
|
|
245
262
|
}
|
|
246
263
|
row(true, "renders", `${path.relative(process.cwd(), shots)}/p01…${String(els.length).padStart(2, "0")}.png, ${path.relative(process.cwd(), outputs.sheet)}`);
|
|
264
|
+
if (board) {
|
|
265
|
+
// the timeline for an editor and a captioner: one row a shot, one caption a line
|
|
266
|
+
const q = (v) => `"${String(v).replace(/"/g, '""')}"`;
|
|
267
|
+
const csv = ["shot,act,name,in,out,seconds,vo_in,vo_out,voiceover", ...board.shots.map((s) => [s.n, q(s.act), q(s.name), s.in, s.out, s.dur, s.voIn, s.voOut, q(s.vo)].join(","))].join("\n") + "\n";
|
|
268
|
+
fs.writeFileSync(outputs.csv = path.join(out, "timing.csv"), csv);
|
|
269
|
+
const srt = (t) => { const ms = Math.round(t * 1000), h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60, s = Math.floor(ms / 1000) % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(ms % 1000).padStart(3, "0")}`; };
|
|
270
|
+
const cues = board.shots.filter((s) => s.vo);
|
|
271
|
+
fs.writeFileSync(outputs.srt = path.join(out, "captions.srt"), cues.map((s, i) => `${i + 1}\n${srt(s.voIn)} --> ${srt(s.voOut)}\n${s.vo}\n`).join("\n"));
|
|
272
|
+
}
|
|
247
273
|
}
|
|
248
274
|
await page.close();
|
|
249
275
|
|
|
250
276
|
const ok = rows.every((r) => r.ok || r.warn);
|
|
251
|
-
const report = { ok, rows, outputs, pages: pageCount, brand: brand.name, surface: surface.name, ms: Date.now() - t0, texts: g.texts, notes: g.notes, name };
|
|
277
|
+
const report = { ok, rows, outputs, pages: pageCount, brand: brand.name, surface: surface.name, ms: Date.now() - t0, texts: g.texts, notes: g.notes, name, title: job.name || name, talk: job.talk || null, board };
|
|
252
278
|
fs.writeFileSync(path.join(work, "report.json"), JSON.stringify({ ...report, texts: undefined }, null, 1));
|
|
253
279
|
if (!quiet) printReport(report);
|
|
254
280
|
return report;
|
package/lib/html_export.js
CHANGED
|
@@ -6,17 +6,35 @@
|
|
|
6
6
|
* runtime: on a slide or card surface, one page at a time scaled to the
|
|
7
7
|
* window, arrow keys and clicks to move, G for the grid, print gives one
|
|
8
8
|
* sheet per page; on a page surface, the pages stacked and scaled to fit.
|
|
9
|
+
*
|
|
10
|
+
* A slide deck gets the talk runtime instead (lib/talk.js): crossfades,
|
|
11
|
+
* entrances and steps, media, the presenter view. A storyboard gets the
|
|
12
|
+
* board runtime (lib/board.js): the shot pages to read, and a player that
|
|
13
|
+
* runs the frames against the clock. A demo named by `data-demo-src` travels
|
|
14
|
+
* gzipped inside the file and mounts in a frame when its slide comes up.
|
|
9
15
|
*/
|
|
10
16
|
import fs from "node:fs";
|
|
11
17
|
import path from "node:path";
|
|
18
|
+
import zlib from "node:zlib";
|
|
12
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { talkShell, TALK_CSS, TALK_JS } from "./talk.js";
|
|
21
|
+
import { BOARD_CSS, BOARD_JS, boardShell } from "./board.js";
|
|
13
22
|
|
|
14
23
|
const MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
15
|
-
ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2"
|
|
24
|
+
ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2",
|
|
25
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", ogg: "audio/ogg" };
|
|
16
26
|
|
|
17
|
-
export function singleFile(htmlPath, outPath, { kind = "page", w, h }) {
|
|
27
|
+
export function singleFile(htmlPath, outPath, { kind = "page", w, h, talk = null, board = null, title = "" }) {
|
|
18
28
|
let html = fs.readFileSync(htmlPath, "utf8");
|
|
19
29
|
const cache = new Map(), missing = [];
|
|
30
|
+
// demos first: each page travels gzipped, once, however many slides use it
|
|
31
|
+
const demos = new Map();
|
|
32
|
+
html = html.replace(/data-demo-src="(file:\/\/[^"]+)"/g, (m, u) => {
|
|
33
|
+
let p; try { p = fileURLToPath(u); } catch { return m; }
|
|
34
|
+
if (!fs.existsSync(p)) { missing.push(p); return m; }
|
|
35
|
+
if (!demos.has(p)) demos.set(p, { id: demos.size + 1, b64: zlib.gzipSync(fs.readFileSync(p)).toString("base64") });
|
|
36
|
+
return `data-demo="${demos.get(p).id}"`;
|
|
37
|
+
});
|
|
20
38
|
html = html.replace(/file:\/\/[^"')\s]+/g, (u) => {
|
|
21
39
|
if (cache.has(u)) return cache.get(u);
|
|
22
40
|
let p; try { p = fileURLToPath(u); } catch { return u; }
|
|
@@ -25,8 +43,21 @@ export function singleFile(htmlPath, outPath, { kind = "page", w, h }) {
|
|
|
25
43
|
const d = `data:${MIME[ext] || "application/octet-stream"};base64,${fs.readFileSync(p).toString("base64")}`;
|
|
26
44
|
cache.set(u, d); return d;
|
|
27
45
|
});
|
|
28
|
-
|
|
29
|
-
|
|
46
|
+
const demoTags = [...demos.values()].map((d) => `<script type="application/octet-stream" id="wand-demo-${d.id}">${d.b64}</script>`).join("");
|
|
47
|
+
const data = (o) => `<script id="deck-data" type="application/json">${JSON.stringify(o).replace(/</g, "\\u003c")}</script>`;
|
|
48
|
+
if (board) {
|
|
49
|
+
const a = board.audio && fs.existsSync(board.audio) ? board.audio : "";
|
|
50
|
+
if (board.audio && !a) missing.push(board.audio);
|
|
51
|
+
const sh = boardShell({ ...board, audio: a ? `data:${MIME[path.extname(a).slice(1).toLowerCase()] || "audio/mpeg"};base64,${fs.readFileSync(a).toString("base64")}` : "" });
|
|
52
|
+
html = html.replace(/(<body[^>]*>)([\s\S]*)<\/body>/, (m, open, inner) => `${open}${sh.before}${inner}${sh.after}${BOARD_CSS}${data({ title, ...board, audio: !!a })}<script>${BOARD_JS}</script></body>`);
|
|
53
|
+
} else if (kind === "slide") {
|
|
54
|
+
const total = (html.match(/<section class="page /g) || []).length;
|
|
55
|
+
const sh = talkShell(total);
|
|
56
|
+
html = html.replace(/(<body[^>]*>)([\s\S]*)<\/body>/, (m, open, inner) => `${open}${sh.before}${inner}${sh.after}${TALK_CSS}${data({ title, motion: "auto", minutes: 0, ...(talk || {}) })}${demoTags}<script>${TALK_JS}</script></body>`);
|
|
57
|
+
} else {
|
|
58
|
+
html = html.replace("</body>", `${RUNTIME_CSS}<script>${RUNTIME_JS}</script></body>`);
|
|
59
|
+
}
|
|
60
|
+
html = html.replace("<body", `<body data-kind="${kind}" data-w="${w}" data-h="${h}"`);
|
|
30
61
|
fs.writeFileSync(outPath, html);
|
|
31
62
|
return { out: outPath, missing, bytes: fs.statSync(outPath).size };
|
|
32
63
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/runtime_check.js — the one-file HTML, driven the way a presenter drives it.
|
|
3
|
+
*
|
|
4
|
+
* The gates in engine.js read the static render, which is all the PDF, the
|
|
5
|
+
* PNGs and the .pptx ever see. A talk or a board also has a runtime, and a
|
|
6
|
+
* runtime can break where no still frame shows it. This opens the exported
|
|
7
|
+
* file in Chromium and uses it: every slide and step by keyboard, the
|
|
8
|
+
* overview, the notes, the presenter view kept in step both ways, every demo
|
|
9
|
+
* mounted; or, for a board, play, scrub and the frames on their cues.
|
|
10
|
+
* Ported from the BofA talk's qa.mjs feature checks.
|
|
11
|
+
*/
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { browser } from "./engine.js";
|
|
14
|
+
|
|
15
|
+
const watch = (page, errors) => {
|
|
16
|
+
page.on("pageerror", (e) => errors.push(String(e).split("\n")[0]));
|
|
17
|
+
page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function checkTalk(file, { total }) {
|
|
21
|
+
const b = await browser();
|
|
22
|
+
const ctx = await b.newContext({ viewport: { width: 1440, height: 810 } });
|
|
23
|
+
const page = await ctx.newPage();
|
|
24
|
+
const errors = [], fail = [];
|
|
25
|
+
watch(page, errors);
|
|
26
|
+
try {
|
|
27
|
+
await page.goto(pathToFileURL(file).href, { waitUntil: "load" });
|
|
28
|
+
await page.waitForFunction(() => window.wandTalk, null, { timeout: 8000 });
|
|
29
|
+
const st = (p = page) => p.evaluate(() => ({ cur: wandTalk.cur, shown: wandTalk.shown, total: wandTalk.total, on: document.querySelectorAll(".stage > .page.is-current").length }));
|
|
30
|
+
const s0 = await st();
|
|
31
|
+
if (s0.total !== total) fail.push(`the runtime sees ${s0.total} slides, the build ${total}`);
|
|
32
|
+
if (s0.on !== 1) fail.push(`${s0.on} slides on screen at once`);
|
|
33
|
+
|
|
34
|
+
// every slide and every step, forward, by keyboard
|
|
35
|
+
let presses = 0, steps = 0;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const a = await st(); await page.keyboard.press("ArrowRight"); const z = await st();
|
|
38
|
+
if (z.cur === a.cur && z.shown === a.shown) break;
|
|
39
|
+
presses++; if (z.cur === a.cur) steps++;
|
|
40
|
+
if (presses > 600) { fail.push("ArrowRight never reaches the end"); break; }
|
|
41
|
+
}
|
|
42
|
+
if ((await st()).cur !== total - 1) fail.push("the keyboard does not reach the last slide");
|
|
43
|
+
await page.keyboard.press("Home");
|
|
44
|
+
|
|
45
|
+
await page.keyboard.press("g");
|
|
46
|
+
const thumbs = await page.$$eval(".overview:not([hidden]) .thumb", (t) => t.length);
|
|
47
|
+
if (thumbs !== total) fail.push(`the overview shows ${thumbs} of ${total}`);
|
|
48
|
+
await page.keyboard.press("Escape");
|
|
49
|
+
await page.keyboard.press("n");
|
|
50
|
+
if (!(await page.$eval(".notes-panel", (e) => !e.hidden))) fail.push("N does not open the notes");
|
|
51
|
+
await page.keyboard.press("Escape");
|
|
52
|
+
|
|
53
|
+
// the presenter's window, kept in step from either side
|
|
54
|
+
const [pop] = await Promise.all([ctx.waitForEvent("page", { timeout: 8000 }), page.keyboard.press("p")]);
|
|
55
|
+
watch(pop, errors);
|
|
56
|
+
await pop.waitForLoadState("load");
|
|
57
|
+
await pop.waitForFunction(() => window.wandTalk, null, { timeout: 8000 });
|
|
58
|
+
await page.keyboard.press("ArrowRight"); await page.waitForTimeout(250);
|
|
59
|
+
let r = await st(), q = await st(pop);
|
|
60
|
+
if (r.cur !== q.cur || r.shown !== q.shown) fail.push(`room ${r.cur + 1}/${r.shown} and presenter ${q.cur + 1}/${q.shown} disagree`);
|
|
61
|
+
await pop.keyboard.press("ArrowRight"); await page.waitForTimeout(250);
|
|
62
|
+
r = await st(); q = await st(pop);
|
|
63
|
+
if (r.cur !== q.cur || r.shown !== q.shown) fail.push("the presenter's keys do not move the room");
|
|
64
|
+
await pop.close();
|
|
65
|
+
|
|
66
|
+
// every demo mounts and loads
|
|
67
|
+
const demoSlides = await page.$$eval(".stage > .page", (ps) => ps.map((p, i) => (p.querySelector(".media.demo") ? i : -1)).filter((i) => i >= 0));
|
|
68
|
+
for (const i of demoSlides) {
|
|
69
|
+
await page.evaluate((n) => wandTalk.go(n), i);
|
|
70
|
+
try { await page.waitForSelector(".stage > .page.is-current .media.demo.is-ready", { timeout: 10000 }); }
|
|
71
|
+
catch { fail.push(`the demo on slide ${i + 1} never loaded`); }
|
|
72
|
+
}
|
|
73
|
+
const facts = `${total} slides${steps ? ` and ${steps} steps` : ""} by keyboard, overview, notes, presenter in step both ways${demoSlides.length ? `, ${demoSlides.length} demo${demoSlides.length === 1 ? "" : "s"} mounted` : ""}`;
|
|
74
|
+
const all = [...fail, ...errors.map((e) => `console: ${e}`)];
|
|
75
|
+
return { ok: !all.length, msg: all.length ? all.slice(0, 3).join("; ") : facts };
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, msg: `${String(e.message || e).split("\n")[0]}` };
|
|
78
|
+
} finally { await ctx.close(); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function checkBoard(file, { shots, total }) {
|
|
82
|
+
const b = await browser();
|
|
83
|
+
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
|
84
|
+
const page = await ctx.newPage();
|
|
85
|
+
const errors = [], fail = [];
|
|
86
|
+
watch(page, errors);
|
|
87
|
+
try {
|
|
88
|
+
await page.goto(pathToFileURL(file).href, { waitUntil: "load" });
|
|
89
|
+
await page.waitForFunction(() => window.wandBoard, null, { timeout: 8000 });
|
|
90
|
+
await page.click('[data-ui="play-board"]');
|
|
91
|
+
await page.waitForTimeout(1200);
|
|
92
|
+
const a = await page.evaluate(() => ({ t: wandBoard.t, playing: wandBoard.playing, on: document.querySelectorAll(".pl-frames .pf.on").length, art: !!document.querySelector(".pl-frames .pf.on .frame-art") }));
|
|
93
|
+
if (!a.playing || a.t < 0.6) fail.push(`the player did not run (t=${a.t.toFixed(1)})`);
|
|
94
|
+
if (a.on !== 1 || !a.art) fail.push("no frame on the player's screen");
|
|
95
|
+
// scrub to the last shot: its frame and its words come up
|
|
96
|
+
const last = shots[shots.length - 1];
|
|
97
|
+
await page.evaluate((t) => { wandBoard.pause(); wandBoard.seek(t); }, last.in + 0.05);
|
|
98
|
+
const z = await page.evaluate(() => ({ id: document.querySelector(".pl-frames .pf.on")?.dataset.id, vo: document.querySelector(".pl-vo").dataset.n }));
|
|
99
|
+
if (z.vo !== String(last.n)) fail.push(`seeking to shot ${last.n} shows shot ${z.vo}`);
|
|
100
|
+
if (!z.id || !z.id.startsWith(String(last.n).padStart(2, "0"))) fail.push(`seeking to shot ${last.n} shows frame ${z.id}`);
|
|
101
|
+
await page.keyboard.press("Escape");
|
|
102
|
+
const all = [...fail, ...errors.map((e) => `console: ${e}`)];
|
|
103
|
+
const frames = shots.reduce((s, x) => s + x.frames.length, 0);
|
|
104
|
+
return { ok: !all.length, msg: all.length ? all.slice(0, 3).join("; ") : `the player runs ${frames} frames over ${Math.floor(total / 60)}:${String(Math.round(total % 60)).padStart(2, "0")}, scrubs to the last shot, captions follow` };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ok: false, msg: `${String(e.message || e).split("\n")[0]}` };
|
|
107
|
+
} finally { await ctx.close(); }
|
|
108
|
+
}
|