wand-kit 0.2.0 → 0.3.1
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 +61 -4
- package/START-HERE.md +6 -0
- package/brands/current/tokens.json +4 -2
- package/brands/next/tokens.json +7 -5
- package/examples/overview/job.mjs +2 -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 +157 -9
- 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/board.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/board.js — the runtime a storyboard carries in its one-file HTML.
|
|
3
|
+
*
|
|
4
|
+
* Generalised from the Wand OS film's board-o-matic
|
|
5
|
+
* (runs/2026-10-06-wand-os-video/storyboard/build.mjs): the shot pages stay
|
|
6
|
+
* readable as a scroll, and a player runs the frames against the timeline the
|
|
7
|
+
* storyboard component computed, cross-fading on each frame's cue, with the
|
|
8
|
+
* shot's voiceover as the caption and a track of every shot to scrub. With an
|
|
9
|
+
* audio file (a scratch or final voiceover) the sound leads and the frames
|
|
10
|
+
* follow it; without one, the clock does.
|
|
11
|
+
*
|
|
12
|
+
* Its controls read `--ui-*`, the brand's dark roles, so it dresses itself in
|
|
13
|
+
* whichever brand the board is on.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function boardShell(board) {
|
|
17
|
+
return {
|
|
18
|
+
before: `<button class="board-play" type="button" data-ui="play-board" aria-label="Play the storyboard (P)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.5v15l12-7.5z"/></svg>Play the board</button><div class="board-pages">`,
|
|
19
|
+
after: `</div>
|
|
20
|
+
<div class="player" role="dialog" aria-modal="true" aria-label="Storyboard player" hidden>
|
|
21
|
+
<div class="pl-stage"><div class="pl-frames"></div><div class="pl-shot"></div><div class="pl-safe" hidden></div></div>
|
|
22
|
+
<p class="pl-vo" aria-live="off"></p>
|
|
23
|
+
<div class="pl-bar">
|
|
24
|
+
<button class="pl-btn" type="button" data-pl="toggle" aria-label="Play or pause (Space)"></button>
|
|
25
|
+
<span class="pl-tc"><b>0:00.0</b> / <i></i></span>
|
|
26
|
+
<div class="pl-track" role="slider" aria-label="Timeline" tabindex="0"></div>
|
|
27
|
+
<button class="pl-btn txt" type="button" data-pl="safe" aria-pressed="false">Safe area</button>
|
|
28
|
+
<button class="pl-btn txt" type="button" data-pl="close">Close</button>
|
|
29
|
+
</div>
|
|
30
|
+
${board.audio ? `<audio class="pl-audio" preload="auto" src="${board.audio}"></audio>` : ""}
|
|
31
|
+
</div>`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const BOARD_CSS = `<style id="wand-board">
|
|
36
|
+
html,body{margin:0}
|
|
37
|
+
body.board-on{background:var(--ui-bg);color:var(--ui-text);font-family:var(--ui-face),system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
|
38
|
+
body.board-on button{font:inherit;color:inherit}
|
|
39
|
+
[hidden]{display:none!important}
|
|
40
|
+
.board-pages{padding:72px 0 32px}
|
|
41
|
+
.board-pages>.page{margin:0 auto 24px;transform-origin:top center;transform:scale(var(--k,1));box-shadow:0 2px 24px color-mix(in srgb,var(--ui-bg) 70%,transparent)}
|
|
42
|
+
.board-play{position:fixed;top:16px;right:20px;z-index:20;display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border:1px solid var(--ui-stroke);border-radius:10px;background:var(--ui-card);color:var(--ui-ink);cursor:pointer;font-weight:600;font-size:14px}
|
|
43
|
+
.board-play svg{width:14px;height:14px;fill:currentColor}
|
|
44
|
+
.board-play:hover{border-color:var(--ui-accent)}
|
|
45
|
+
.player{position:fixed;inset:0;z-index:50;display:grid;grid-template-rows:1fr auto auto;gap:14px;padding:24px 28px 18px;background:var(--ui-bg)}
|
|
46
|
+
.pl-stage{position:relative;container-type:size;min-height:0;display:grid;place-items:center}
|
|
47
|
+
.pl-frames{position:relative;width:min(100cqw,100cqh * 16 / 9);aspect-ratio:16/9;overflow:hidden;border-radius:12px;background:var(--ui-card);box-shadow:0 0 0 1px var(--ui-stroke)}
|
|
48
|
+
.pl-frames>.pf{position:absolute;inset:0;opacity:0;transition:opacity 380ms cubic-bezier(.65,0,.35,1)}
|
|
49
|
+
.pl-frames>.pf.on{opacity:1}
|
|
50
|
+
.pl-frames>.pf>.frame-art{position:absolute;inset:0;border:0;border-radius:0;aspect-ratio:auto}
|
|
51
|
+
.pl-shot{position:absolute;left:calc(50% - min(100cqw,100cqh * 16 / 9) / 2 + 16px);top:calc(50% - min(100cqw,100cqh * 16 / 9) * 9 / 32 + 14px);padding:5px 10px;border-radius:8px;background:color-mix(in srgb,var(--ui-bg) 78%,transparent);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--ui-label);font-variant-numeric:tabular-nums}
|
|
52
|
+
.pl-safe{position:absolute;width:min(100cqw,100cqh * 16 / 9);aspect-ratio:16/9;pointer-events:none;background:
|
|
53
|
+
linear-gradient(var(--ui-accent),var(--ui-accent)) 5% 5%/90% 1px no-repeat,linear-gradient(var(--ui-accent),var(--ui-accent)) 5% 95%/90% 1px no-repeat,
|
|
54
|
+
linear-gradient(var(--ui-accent),var(--ui-accent)) 5% 5%/1px 90% no-repeat,linear-gradient(var(--ui-accent),var(--ui-accent)) 95% 5%/1px 90% no-repeat;opacity:.7}
|
|
55
|
+
.pl-vo{margin:0 auto;max-width:min(1100px,92vw);min-height:3.2em;text-align:center;font-size:clamp(15px,1.6vw,22px);line-height:1.5;color:var(--ui-ink);transition:opacity 200ms}
|
|
56
|
+
.pl-vo.off{opacity:.35}
|
|
57
|
+
.pl-bar{display:flex;align-items:center;gap:12px}
|
|
58
|
+
.pl-btn{display:inline-grid;place-items:center;min-width:36px;height:36px;padding:0 10px;border:1px solid var(--ui-stroke);border-radius:9px;background:var(--ui-card);color:var(--ui-ink);cursor:pointer}
|
|
59
|
+
.pl-btn svg{width:14px;height:14px;fill:currentColor}
|
|
60
|
+
.pl-btn.txt{font-size:13px}
|
|
61
|
+
.pl-btn[aria-pressed="true"]{border-color:var(--ui-accent);color:var(--ui-accent)}
|
|
62
|
+
.pl-tc{font-size:13px;color:var(--ui-label);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
63
|
+
.pl-tc b{color:var(--ui-ink);font-weight:600}
|
|
64
|
+
.pl-tc i{font-style:normal}
|
|
65
|
+
.pl-track{position:relative;flex:1;display:flex;gap:2px;height:34px;cursor:pointer;outline-offset:3px}
|
|
66
|
+
.pl-track>.seg{position:relative;flex:var(--d) 1 0;min-width:2px;border-radius:4px;background:var(--ui-card);border:1px solid var(--ui-stroke);overflow:hidden}
|
|
67
|
+
.pl-track>.seg.act{border-left:2px solid var(--ui-accent)}
|
|
68
|
+
.pl-track>.seg>span{position:absolute;left:5px;top:50%;transform:translateY(-50%);font-size:10px;color:var(--ui-label);white-space:nowrap;pointer-events:none}
|
|
69
|
+
.pl-track>.seg.here{background:color-mix(in srgb,var(--ui-accent) 22%,var(--ui-card))}
|
|
70
|
+
.pl-track>.head{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--ui-ink);left:var(--x,0%);pointer-events:none}
|
|
71
|
+
.pl-track:focus-visible,.pl-btn:focus-visible,.board-play:focus-visible{outline:3px solid var(--ui-accent)}
|
|
72
|
+
@media (prefers-reduced-motion:reduce){.pl-frames>.pf{transition:none}}
|
|
73
|
+
@media print{.board-play,.player{display:none!important}.board-pages{padding:0}.board-pages>.page{margin:0;transform:none;box-shadow:none;break-after:page}body.board-on{background:none}}
|
|
74
|
+
</style>`;
|
|
75
|
+
|
|
76
|
+
export const BOARD_JS = String.raw`(() => {
|
|
77
|
+
"use strict";
|
|
78
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
79
|
+
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
|
80
|
+
const B = document.body, W = +B.dataset.w;
|
|
81
|
+
const D = JSON.parse($("#deck-data").textContent);
|
|
82
|
+
const shots = D.shots, total = D.total;
|
|
83
|
+
const pages = $$(".board-pages > .page");
|
|
84
|
+
B.classList.add("board-on");
|
|
85
|
+
const fit = () => { const k = Math.min(1, (innerWidth - 48) / W); pages.forEach((p) => { p.style.setProperty("--k", k); p.style.marginBottom = 24 - p.offsetHeight * (1 - k) + "px"; }); };
|
|
86
|
+
addEventListener("resize", fit); fit();
|
|
87
|
+
|
|
88
|
+
const tc = (t) => Math.floor(t / 60) + ":" + (t % 60).toFixed(1).padStart(4, "0");
|
|
89
|
+
const player = $(".player"), framesEl = $(".pl-frames"), voEl = $(".pl-vo"), shotEl = $(".pl-shot"), track = $(".pl-track");
|
|
90
|
+
const audio = $(".pl-audio");
|
|
91
|
+
const PLAY = '<svg viewBox="0 0 24 24"><path d="M7 4.5v15l12-7.5z"/></svg>', PAUSE = '<svg viewBox="0 0 24 24"><path d="M6 4.5h4v15H6zM14 4.5h4v15h-4z"/></svg>';
|
|
92
|
+
|
|
93
|
+
// every frame, in time order, cloned from its shot page
|
|
94
|
+
const FR = [];
|
|
95
|
+
shots.forEach((s, si) => s.frames.forEach((f) => {
|
|
96
|
+
const src = $('.frame[data-frame="' + f.id + '"] .frame-art');
|
|
97
|
+
const pf = document.createElement("div"); pf.className = "pf"; pf.dataset.id = f.id;
|
|
98
|
+
if (src) pf.append(src.cloneNode(true));
|
|
99
|
+
framesEl.append(pf); FR.push({ ...f, si, el: pf });
|
|
100
|
+
}));
|
|
101
|
+
FR.sort((a, b) => a.t - b.t);
|
|
102
|
+
$(".pl-tc i").textContent = tc(total);
|
|
103
|
+
shots.forEach((s, i) => {
|
|
104
|
+
const seg = document.createElement("div"); seg.className = "seg" + (i === 0 || s.act !== shots[i - 1].act ? " act" : "");
|
|
105
|
+
seg.style.setProperty("--d", Math.max(0.1, s.dur)); seg.title = String(s.n).padStart(2, "0") + " " + s.name;
|
|
106
|
+
seg.innerHTML = "<span>" + String(s.n).padStart(2, "0") + "</span>"; track.append(seg);
|
|
107
|
+
});
|
|
108
|
+
const head = document.createElement("i"); head.className = "head"; track.append(head);
|
|
109
|
+
|
|
110
|
+
let t = 0, playing = false, t0 = 0, last = -1;
|
|
111
|
+
const shotAt = (x) => { let i = shots.findIndex((s) => x < s.out); return i < 0 ? shots.length - 1 : i; };
|
|
112
|
+
function paint() {
|
|
113
|
+
const si = shotAt(t), s = shots[si];
|
|
114
|
+
let fi = -1; for (let i = 0; i < FR.length; i++) if (FR[i].t <= t + 1e-6) fi = i;
|
|
115
|
+
if (fi < 0) fi = 0;
|
|
116
|
+
if (fi !== last) { FR.forEach((f, i) => f.el.classList.toggle("on", i === fi)); last = fi; }
|
|
117
|
+
shotEl.textContent = "Shot " + String(s.n).padStart(2, "0") + (s.name ? " · " + s.name : "");
|
|
118
|
+
if (voEl.dataset.n !== String(s.n)) { voEl.textContent = s.vo || ""; voEl.dataset.n = s.n; }
|
|
119
|
+
voEl.classList.toggle("off", !(t >= s.voIn && t <= s.voOut));
|
|
120
|
+
$(".pl-tc b").textContent = tc(t);
|
|
121
|
+
head.style.setProperty("--x", (t / total) * 100 + "%");
|
|
122
|
+
$$(".seg", track).forEach((g, i) => g.classList.toggle("here", i === si));
|
|
123
|
+
}
|
|
124
|
+
function frame() {
|
|
125
|
+
if (!playing) return;
|
|
126
|
+
t = audio && !audio.paused ? audio.currentTime : (performance.now() - t0) / 1000;
|
|
127
|
+
if (t >= total) { t = total; pause(); }
|
|
128
|
+
paint(); if (playing) requestAnimationFrame(frame);
|
|
129
|
+
}
|
|
130
|
+
function play() { if (t >= total) t = 0; playing = true; t0 = performance.now() - t * 1000; if (audio) { audio.currentTime = t; audio.play().catch(() => {}); } $('[data-pl="toggle"]').innerHTML = PAUSE; requestAnimationFrame(frame); }
|
|
131
|
+
function pause() { playing = false; audio?.pause(); $('[data-pl="toggle"]').innerHTML = PLAY; paint(); }
|
|
132
|
+
function seek(x) { t = Math.max(0, Math.min(total, x)); t0 = performance.now() - t * 1000; if (audio) audio.currentTime = t; paint(); }
|
|
133
|
+
function open() { player.hidden = false; seek(t); $('[data-pl="toggle"]').innerHTML = PLAY; $('[data-pl="toggle"]').focus(); }
|
|
134
|
+
function close() { pause(); player.hidden = true; }
|
|
135
|
+
track.addEventListener("click", (e) => { const r = track.getBoundingClientRect(); seek(((e.clientX - r.left) / r.width) * total); });
|
|
136
|
+
document.addEventListener("click", (e) => {
|
|
137
|
+
if (e.target.closest('[data-ui="play-board"]')) { open(); play(); return; }
|
|
138
|
+
const b = e.target.closest("[data-pl]"); if (!b) return;
|
|
139
|
+
const a = b.dataset.pl;
|
|
140
|
+
if (a === "toggle") playing ? pause() : play();
|
|
141
|
+
if (a === "close") close();
|
|
142
|
+
if (a === "safe") { const s = $(".pl-safe"); s.hidden = !s.hidden; b.setAttribute("aria-pressed", String(!s.hidden)); }
|
|
143
|
+
});
|
|
144
|
+
addEventListener("keydown", (e) => {
|
|
145
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
146
|
+
if (player.hidden) { if (e.key === "p" || e.key === "P") { e.preventDefault(); open(); play(); } return; }
|
|
147
|
+
const si = shotAt(t);
|
|
148
|
+
const map = { " ": () => (playing ? pause() : play()), Escape: close, ArrowRight: () => seek(shots[Math.min(shots.length - 1, si + 1)].in), ArrowLeft: () => seek(t - shots[si].in > 0.6 ? shots[si].in : shots[Math.max(0, si - 1)].in), Home: () => seek(0), End: () => seek(total) };
|
|
149
|
+
const fn = map[e.key]; if (!fn) return; e.preventDefault(); fn();
|
|
150
|
+
});
|
|
151
|
+
window.wandBoard = { open, close, play, pause, seek, get t() { return t; }, get playing() { return playing; }, total, frames: FR.length };
|
|
152
|
+
})();`;
|
package/lib/brand.js
CHANGED
|
@@ -128,7 +128,10 @@ function brandCss(tokens, roles, fonts, extras = {}) {
|
|
|
128
128
|
`@font-face{font-family:"${f.family}";font-weight:${f.weight};font-style:${f.italic ? "italic" : "normal"};src:url("${url(path.join(fonts, f.file))}")}`).join("\n");
|
|
129
129
|
const F = tokens.faces, S = tokens.shape;
|
|
130
130
|
const face = (k) => `--face-${k}:"${F[k].family}";--w-${k}:${F[k].weight};--tr-${k}:${F[k].tracking}em;--lh-${k}:${F[k].leading}`;
|
|
131
|
+
// the talk and board runtimes' own controls, dressed in the brand's dark roles
|
|
132
|
+
const d = roles.dark, ui = { bg: d.track, card: d.paper, stroke: d.stroke, ink: d.ink, text: d.text, label: d.muted, accent: d.accent };
|
|
131
133
|
return `${faces}
|
|
134
|
+
:root{${Object.entries(ui).map(([k, v]) => `--ui-${k}:#${v}`).join(";")};--ui-face:"${F.body.family}"}
|
|
132
135
|
.page{${vars(roles.light)};${Object.keys(extras).length ? vars(extras) + ";" : ""}${face("display")};${face("body")};${face("label")};--caps-label:${F.label.caps ? "uppercase" : "none"};
|
|
133
136
|
--radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px}
|
|
134
137
|
.page.dark{${vars(roles.dark)}}`;
|
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
|
}
|
|
@@ -193,14 +291,16 @@ b,strong{font-weight:600}
|
|
|
193
291
|
.card.emph{background:var(--emph);border:var(--emph-stroke) solid var(--emph-accent);color:var(--emph-ink)}
|
|
194
292
|
.card.emph p,.card.emph .body,.card.emph .role,.card.emph .lead{color:var(--emph-text)}
|
|
195
293
|
.card.emph h3,.card.emph h4,.card.emph h5,.card.emph .name,.card.emph .lead-in,.card.emph .q p{color:var(--emph-ink)}
|
|
196
|
-
.card.emph
|
|
294
|
+
.card.emph em{color:var(--emph-accent)}
|
|
295
|
+
/* small text on an emphasis card takes its own role: gold reads at AA on one brand's card and not the other's */
|
|
296
|
+
.card.emph .tag,.card.emph .out,.card.emph .when{color:var(--emph-label,var(--emph-accent))}
|
|
197
297
|
.card.centred{display:flex;flex-direction:column;justify-content:center}
|
|
198
298
|
.card p{font-size:var(--t-sm);line-height:1.5;color:var(--text)}
|
|
199
299
|
.card .body{font-size:var(--t-body);line-height:var(--lh-body)}.card .body.sm{font-size:var(--t-sm);line-height:1.55}
|
|
200
300
|
.card .lead{font-size:var(--t-lead)}
|
|
201
301
|
.card h3{margin-bottom:.3em}.card h4{margin-bottom:.35em}.card h5{margin-bottom:.4em}
|
|
202
302
|
.card .sub{font-size:var(--t-sm);font-weight:500;color:var(--accent-text);margin-bottom:.5em}
|
|
203
|
-
.card.emph .sub{color:var(--emph-accent)}
|
|
303
|
+
.card.emph .sub{color:var(--emph-label,var(--emph-accent))}
|
|
204
304
|
.card .out{margin-top:auto;padding-top:.6em;font-weight:500;color:var(--accent-text)}
|
|
205
305
|
.card.fn,.card.flex{display:flex;flex-direction:column}
|
|
206
306
|
.card.fn h5{font-family:var(--face-label);font-weight:var(--w-label);font-size:calc(var(--t-tag) * 1.1);letter-spacing:.12em;text-transform:var(--caps-label);color:var(--ink);margin-bottom:.6em}
|
|
@@ -231,7 +331,7 @@ b,strong{font-weight:600}
|
|
|
231
331
|
.node{flex:1;display:flex;flex-direction:column;gap:.15em;padding:.7em 1em;border-radius:calc(var(--m-radius) * .7 * var(--radius-k));border:var(--m-stroke) solid var(--stroke);background:var(--paper)}
|
|
232
332
|
.node .who{font-family:var(--face-label);font-weight:var(--w-label);font-size:calc(var(--t-tag) * .92);letter-spacing:.16em;text-transform:var(--caps-label);color:var(--soft);display:block;border:0;padding:0}
|
|
233
333
|
.node .verb{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-sm);color:var(--ink);letter-spacing:var(--tr-display)}
|
|
234
|
-
.node.a{background:var(--emph);border-color:var(--emph)}.node.a .who{color:var(--emph-accent)}.node.a .verb{color:var(--emph-ink)}
|
|
334
|
+
.node.a{background:var(--emph);border-color:var(--emph)}.node.a .who{color:var(--emph-label,var(--emph-accent))}.node.a .verb{color:var(--emph-ink)}
|
|
235
335
|
.fnote{font-size:var(--t-sm);line-height:1.4;color:var(--text)}
|
|
236
336
|
/* tables */
|
|
237
337
|
table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
|
@@ -281,7 +381,7 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
281
381
|
.cover .top{display:flex;flex-direction:column;gap:1.3em}
|
|
282
382
|
.cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing:-.03em}
|
|
283
383
|
.lede{font-size:var(--t-lede);font-weight:500;line-height:1.45;color:var(--ink);max-width:88%}
|
|
284
|
-
.cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:80
|
|
384
|
+
.cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:min(80%,44em)}
|
|
285
385
|
.meta{font-size:var(--t-meta);color:var(--text);padding-top:1em;border-top:1px solid var(--rule);line-height:1.4}
|
|
286
386
|
.cover .bottom{display:flex;flex-direction:column;gap:1em}
|
|
287
387
|
.back .content{justify-content:center;gap:1.4em}
|
|
@@ -310,4 +410,52 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
310
410
|
.centre{text-align:center;align-items:center}
|
|
311
411
|
.grow{flex:1}
|
|
312
412
|
.spread{justify-content:space-between}
|
|
413
|
+
|
|
414
|
+
/* media: the poster is what a static export shows; the runtime plays the rest */
|
|
415
|
+
.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)}
|
|
416
|
+
.media>.poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
|
417
|
+
.media>video{display:none;position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
|
418
|
+
.media>.screen{position:absolute;inset:0;overflow:hidden}
|
|
419
|
+
|
|
420
|
+
/* the storyboard: a shot a page, frames left to right, the direction under them */
|
|
421
|
+
.shot .content{display:flex;flex-direction:column;gap:calc(var(--m-gap) * .8)}
|
|
422
|
+
.shot-head{display:flex;align-items:flex-end;gap:calc(var(--m-gap) * 1.2)}
|
|
423
|
+
.shot-id{display:flex;flex-direction:column;line-height:1}
|
|
424
|
+
.shot-id .tag{margin:0 0 .3em}
|
|
425
|
+
.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}
|
|
426
|
+
.shot-title h2{margin:0}
|
|
427
|
+
.shot-title .tc{margin:.35em 0 0;font-size:var(--t-sm);color:var(--soft);font-variant-numeric:tabular-nums}
|
|
428
|
+
.vo{margin:0;font-size:var(--t-lead);line-height:1.45;color:var(--ink);max-width:92%;quotes:"\\201C" "\\201D"}
|
|
429
|
+
.vo::before{content:open-quote;color:var(--accent)}
|
|
430
|
+
.vo::after{content:close-quote;color:var(--accent)}
|
|
431
|
+
.vo.none{color:var(--soft);font-size:var(--t-sm)}
|
|
432
|
+
.vo.none::before,.vo.none::after{content:none}
|
|
433
|
+
/* one or two frames: the direction beside them; three or four: under them */
|
|
434
|
+
.shot-body{display:grid;gap:calc(var(--m-gap) * .8) var(--m-col-gap);align-items:start}
|
|
435
|
+
.shot-body.side.n1{grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)}
|
|
436
|
+
.shot-body.side.n2{grid-template-columns:minmax(0,1.7fr) minmax(0,1fr)}
|
|
437
|
+
.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)}
|
|
438
|
+
.frames{display:grid;gap:var(--m-col-gap);align-items:start}
|
|
439
|
+
.frames.n1{grid-template-columns:minmax(0,1fr)}
|
|
440
|
+
.frames.n2{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
441
|
+
.frames.n3{grid-template-columns:repeat(3,minmax(0,1fr))}
|
|
442
|
+
.frames.n4{grid-template-columns:repeat(4,minmax(0,1fr))}
|
|
443
|
+
.frame{margin:0;display:flex;flex-direction:column;gap:.5em}
|
|
444
|
+
.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)}
|
|
445
|
+
.frame-art>svg,.frame-art>img{position:absolute;inset:0;width:100%;height:100%;display:block}
|
|
446
|
+
.frame-art>img{object-fit:cover}
|
|
447
|
+
.frame figcaption{font-size:var(--t-note);line-height:1.4;color:var(--soft);font-variant-numeric:tabular-nums;max-width:44em}
|
|
448
|
+
.frame figcaption b{color:var(--label);font-weight:600}
|
|
449
|
+
.frame figcaption span{display:block;color:var(--text);margin-top:.2em}
|
|
450
|
+
.frame .todo{position:absolute;inset:0;display:grid;place-items:center;border:2px dashed var(--rule);border-radius:inherit}
|
|
451
|
+
.frame .todo span{font-size:var(--t-sm);color:var(--soft)}
|
|
452
|
+
.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)}
|
|
453
|
+
.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}
|
|
454
|
+
.direction dd{margin:0;font-size:var(--t-sm);line-height:1.45;color:var(--text)}
|
|
455
|
+
.sk .sk-p{fill:var(--ink)}
|
|
456
|
+
.sk .sk-app rect{fill:var(--paper);stroke:var(--stroke);stroke-width:5}
|
|
457
|
+
.sk .sk-app text{fill:var(--ink);font-family:var(--face-body);font-size:52px}
|
|
458
|
+
.sk .sk-s{fill:var(--accent)}
|
|
459
|
+
.sk .sk-arrow{fill:var(--soft)}
|
|
460
|
+
.sk .sk-label{fill:var(--text);font-family:var(--face-body);font-size:62px}
|
|
313
461
|
`;
|
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
|
}
|