wand-kit 0.1.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * __NAME__ — a storyboard on the Wand studio.
3
+ *
4
+ * `content.txt` is the script: one voiceover line a shot, verbatim, so
5
+ * coverage proves every word is on the board. Each shot names its act, its
6
+ * line (what the shot is for), the direction, and one to four frames; a frame
7
+ * is a `sketch`, the job's own `art` (SVG on brand roles), an `img`, or left
8
+ * to draw. `wpm` sets the scratch timing; `target` is the length to hit, in
9
+ * seconds. examples/storyboard/job.mjs is a finished one.
10
+ */
11
+ export default {
12
+ name: "__NAME__",
13
+ brand: "__BRAND__", // current | next
14
+ surface: "slide",
15
+ tag: "Wand · Storyboard",
16
+ footer: "Wand AI · Draft for review",
17
+ content: "content.txt",
18
+ edits: {},
19
+ cut: [],
20
+ css: ``,
21
+ pages: (c) => c.storyboard({
22
+ title: "__NAME__",
23
+ lede: "What the film is for, who sees it, and where.",
24
+ wpm: 160,
25
+ target: 30,
26
+ shots: [
27
+ { act: "Act one", name: "Opening", vo: c.L(1),
28
+ line: "What this shot is for, in one sentence.",
29
+ camera: "", motion: "", onscreen: "", sound: "", next: "Cut.",
30
+ frames: [{ sketch: { people: 4, apps: ["CRM"], label: "What the frame shows" }, caption: "" }] },
31
+ ],
32
+ }),
33
+ };
package/wand.js CHANGED
@@ -2,25 +2,33 @@
2
2
  /**
3
3
  * wand.js — the studio's front door.
4
4
  *
5
- * node wand.js doctor what works here
6
- * node wand.js new <dir> [--surface letter] [--brand current] [--name "Title"]
7
- * a job folder to compose in
8
- * node wand.js build <job.mjs> gates, then the PDF, a PNG per page, the sheet
5
+ * node wand.js doctor what works here, and the brand rule
6
+ * node wand.js new <dir> [--surface letter] [--brand current|next] [--name "Title"]
7
+ * a job folder to compose in, on the default brand
8
+ * unless --brand names the other
9
+ * node wand.js new <dir> --surface storyboard a storyboard from a script (shots, frames, timing)
10
+ * node wand.js build <job.mjs> [--brand next] gates, then the PDF, a PNG per page, the sheet
9
11
  * node wand.js check <job.mjs> gates only
10
- * node wand.js ship <job.mjs> [--pptx] [--no-html] everything: build, one-file HTML, PowerPoint
12
+ * node wand.js ship <job.mjs> [--pptx] [--no-html] everything: build, one-file HTML (a deck animates,
13
+ * a storyboard plays), PowerPoint; the HTML is driven
14
+ * like a presenter would before it ships
15
+ * node wand.js poster <demo.html> [--play 4] a demo's poster: the frame every static export shows
11
16
  * node wand.js watch <job.mjs> rebuild on save, the browser kept warm
12
17
  * node wand.js gallery [--brand current|next] every component, light and dark, on one surface each
13
18
  * node wand.js components the vocabulary
14
19
  * node wand.js brands the brands this kit can draw with
15
20
  *
16
21
  * A job is a folder with `job.mjs` (the composition) and, for a rebuild,
17
- * `content.txt` (the words, one per line). Outputs land in `<job>/out/`.
22
+ * `content.txt` (the words, one per line). Outputs land in `<job>/out/`;
23
+ * with `--brand` naming the other brand, in `<job>/out-<brand>/`.
18
24
  */
19
25
  import fs from "node:fs";
20
26
  import path from "node:path";
21
27
  import { fileURLToPath } from "node:url";
22
28
  import { createRequire } from "node:module";
29
+ import { pathToFileURL } from "node:url";
23
30
  import { execFileSync, spawnSync } from "node:child_process";
31
+ import { BRANDS, DEFAULT_BRAND, ASKED_AS, brandRule } from "./lib/brand.js";
24
32
 
25
33
  const require = createRequire(import.meta.url);
26
34
  const KIT = path.dirname(fileURLToPath(import.meta.url));
@@ -33,7 +41,7 @@ const rel = (p) => path.relative(process.cwd(), p) || ".";
33
41
 
34
42
  async function doctor() {
35
43
  const { findChrome, has } = await import("./lib/engine.js");
36
- const { BRANDS, loadBrand } = await import("./lib/brand.js");
44
+ const { loadBrand } = await import("./lib/brand.js");
37
45
  const rows = [];
38
46
  rows.push(["node", process.version, +process.version.slice(1).split(".")[0] >= 18 ? "ok" : "MISSING - Node 18 or newer"]);
39
47
  for (const d of ["playwright-core", "pptxgenjs", "jszip", "fontkit"]) { let s = "ok"; try { require.resolve(d); } catch { s = `MISSING - npm install in ${rel(KIT)}`; } rows.push([d, s === "ok" ? "bundled" : "-", s]); }
@@ -47,14 +55,16 @@ async function doctor() {
47
55
  console.log("");
48
56
  for (const [k, v, s] of rows) console.log(`${s === "ok" ? " " : "! "}${k.padEnd(w)} ${String(v).padEnd(36)}${s === "ok" ? "" : s}`);
49
57
  const blocked = rows.some((r) => r[2].startsWith("MISSING") || r[2].startsWith("no renderer"));
58
+ console.log(`\n brand rule ${brandRule()}`);
50
59
  console.log(blocked ? "\nBLOCKED - fix the rows marked !\n" : "\nready - node wand.js new <dir>, then build <dir>/job.mjs\n");
51
60
  return blocked ? 1 : 0;
52
61
  }
53
62
 
54
63
  function newJob() {
55
64
  const dir = args[1]; if (!dir) die("usage: node wand.js new <dir> [--surface letter|a4|slide|square|og] [--brand current|next] [--name \"Title\"]");
56
- const surface = flag("--surface", "letter"), brand = flag("--brand", "current"), name = flag("--name", path.basename(dir));
57
- const tpl = path.join(KIT, "templates", `${["slide", "square", "og"].includes(surface) ? (surface === "slide" ? "slide" : "card") : "page"}.mjs`);
65
+ const surface = flag("--surface", "letter"), brand = flag("--brand", DEFAULT_BRAND), name = flag("--name", path.basename(dir));
66
+ if (!BRANDS.includes(brand)) die(`unknown brand "${brand}" - one of ${BRANDS.join(", ")}`);
67
+ const tpl = path.join(KIT, "templates", `${surface === "storyboard" ? "storyboard" : ["slide", "square", "og"].includes(surface) ? (surface === "slide" ? "slide" : "card") : "page"}.mjs`);
58
68
  if (!fs.existsSync(tpl)) die(`no template for surface "${surface}"`);
59
69
  if (fs.existsSync(path.join(dir, "job.mjs"))) die(`${dir}/job.mjs already exists`);
60
70
  fs.mkdirSync(dir, { recursive: true });
@@ -64,24 +74,36 @@ function newJob() {
64
74
  console.log(`${dir}/\n job.mjs the composition — pages(c) and css\n content.txt the words, one source string per line (leave empty when authoring)\n\nnext: node ${rel(path.join(KIT, "wand.js"))} build ${path.join(dir, "job.mjs")}`);
65
75
  }
66
76
 
77
+ /** --brand names the brand to build on instead of the job's own. */
78
+ function brandFlag() {
79
+ const b = flag("--brand"); if (!b) return null;
80
+ if (!BRANDS.includes(b)) die(`unknown brand "${b}" - one of ${BRANDS.join(", ")}`);
81
+ return b;
82
+ }
83
+
67
84
  async function build(jobPath, opts) {
68
- if (!jobPath) die("usage: node wand.js build <job.mjs>");
85
+ if (!jobPath) die("usage: node wand.js build <job.mjs> [--brand current|next]");
69
86
  const { build } = await import("./lib/engine.js");
70
- return build(jobPath, opts);
87
+ return build(jobPath, { ...opts, brand: brandFlag() });
71
88
  }
72
89
 
73
90
  async function ship(jobPath) {
74
91
  if (!jobPath) die("usage: node wand.js ship <job.mjs> [--pptx] [--no-html]");
75
92
  const { build, closeBrowser } = await import("./lib/engine.js");
76
- const r = await build(jobPath, { quiet: true });
93
+ const r = await build(jobPath, { quiet: true, brand: brandFlag() });
77
94
  const { surface } = await import("./lib/surfaces.js");
78
95
  const s = surface(r.surface);
79
96
  if (!on("--no-html")) {
80
97
  const { singleFile } = await import("./lib/html_export.js");
81
98
  const out = path.join(path.dirname(r.outputs.pdf), `${r.name}.html`);
82
- const sf = singleFile(r.outputs.html, out, { kind: s.kind, w: s.w, h: s.h });
99
+ const sf = singleFile(r.outputs.html, out, { kind: s.kind, w: s.w, h: s.h, talk: r.talk, board: r.board, title: r.title });
83
100
  r.outputs.single = sf.out;
84
101
  if (sf.missing.length) r.rows.push({ ok: false, gate: "html", msg: `missing files: ${sf.missing.join(", ")}` });
102
+ if (sf.bytes > 60e6) r.rows.push({ ok: false, warn: true, gate: "html", msg: `${(sf.bytes / 1e6).toFixed(0)} MB: heavy to send; shorten or compress the clips` });
103
+ // the runtime, used the way a presenter uses it
104
+ const { checkTalk, checkBoard } = await import("./lib/runtime_check.js");
105
+ if (r.board) { const c = await checkBoard(out, r.board); r.rows.push({ ok: c.ok, gate: "player", msg: c.msg }); }
106
+ else if (s.kind === "slide") { const c = await checkTalk(out, { total: r.pages }); r.rows.push({ ok: c.ok, gate: "runtime", msg: c.msg }); }
85
107
  }
86
108
  if (on("--pptx")) {
87
109
  try {
@@ -119,8 +141,32 @@ async function watch(jobPath) {
119
141
  process.on("SIGINT", async () => { await closeBrowser(); process.exit(0); });
120
142
  }
121
143
 
144
+ /** A demo's poster: the page at the size it is shown, after it settles, optionally after it plays. */
145
+ async function poster(demoPath) {
146
+ if (!demoPath) die("usage: node wand.js poster <demo.html> [--brand current|next] [--size 1280x720] [--wait 1.5] [--play 0] [--out poster.png]");
147
+ const { browser, closeBrowser } = await import("./lib/engine.js");
148
+ const [w, h] = flag("--size", "1280x720").split("x").map(Number);
149
+ const out = flag("--out", demoPath.replace(/\.html?$/i, "") + ".poster.png");
150
+ const b = await browser();
151
+ const page = await b.newPage({ viewport: { width: w, height: h } });
152
+ const errors = []; page.on("pageerror", (e) => errors.push(String(e)));
153
+ // the same brand colours the deck hands a demo, so the poster matches what the room will see
154
+ const { loadBrand } = await import("./lib/brand.js");
155
+ const d = loadBrand(brandFlag() || DEFAULT_BRAND).roles.dark;
156
+ const brand = Object.fromEntries(Object.entries({ bg: d.track, card: d.paper, stroke: d.stroke, ink: d.ink, text: d.text, label: d.muted, accent: d.accent }).map(([k, v]) => [k, "#" + v]));
157
+ await page.addInitScript((b) => { window.wandEmbed = { motion: true, brand: b }; }, brand);
158
+ await page.goto(pathToFileURL(path.resolve(demoPath)).href, { waitUntil: "load" });
159
+ await page.waitForTimeout(+flag("--wait", "1.5") * 1000);
160
+ const play = +flag("--play", "0");
161
+ if (play) { await page.evaluate(() => (window.wandDemo?.play || window.bofaPlay)?.call(window.wandDemo)); await page.waitForTimeout(play * 1000); }
162
+ await page.screenshot({ path: out });
163
+ await closeBrowser();
164
+ console.log(`${rel(out)} (${w}x${h}${play ? `, after ${play}s of play` : ""})${errors.length ? `\n console: ${errors[0]}` : ""}`);
165
+ return errors.length ? 1 : 0;
166
+ }
167
+
122
168
  async function gallery() {
123
- const brand = flag("--brand", "current");
169
+ const brand = flag("--brand", DEFAULT_BRAND);
124
170
  const { build, closeBrowser } = await import("./lib/engine.js");
125
171
  process.env.WAND_GALLERY_BRAND = brand;
126
172
  const r = await build(path.join(KIT, "examples", "gallery", "job.mjs"), {});
@@ -148,9 +194,9 @@ async function components() {
148
194
  }
149
195
 
150
196
  async function brands() {
151
- const { BRANDS, loadBrand } = await import("./lib/brand.js");
152
- for (const b of BRANDS) { const br = loadBrand(b); console.log(`\n ${b.padEnd(8)} ${br.title}\n ${"".padEnd(8)} ${rel(br.dir)} · faces: ${br.families.join(", ")} · ${br.faceFiles.length} files · ${Object.keys(br.tokens.art).length} art roles`); }
153
- console.log("");
197
+ const { loadBrand } = await import("./lib/brand.js");
198
+ for (const b of BRANDS) { const br = loadBrand(b); console.log(`\n ${b.padEnd(8)} ${br.title}${b === DEFAULT_BRAND ? " (default)" : ""}\n ${"".padEnd(8)} ${rel(br.dir)} · faces: ${br.families.join(", ")} · ${br.faceFiles.length} files · ${Object.keys(br.tokens.art).length} art roles\n ${"".padEnd(8)} asked for as: ${ASKED_AS[b].join(", ")}`); }
199
+ console.log(`\n rule: ${brandRule()}\n`);
154
200
  }
155
201
 
156
202
  try {
@@ -158,10 +204,11 @@ try {
158
204
  case "doctor": process.exit(await doctor());
159
205
  case "new": newJob(); break;
160
206
  case "build": { const r = await build(args[1], {}); const { closeBrowser } = await import("./lib/engine.js"); await closeBrowser(); process.exit(r.ok ? 0 : 1); }
161
- case "check": { const r = await build(args[1], { outputs: false }); const { closeBrowser } = await import("./lib/engine.js"); await closeBrowser(); process.exit(r.ok ? 0 : 1); }
207
+ case "check": { const r = await build(args[1], { outputs: false, brand: brandFlag() }); const { closeBrowser } = await import("./lib/engine.js"); await closeBrowser(); process.exit(r.ok ? 0 : 1); }
162
208
  case "ship": process.exit(await ship(args[1]));
163
209
  case "watch": await watch(args[1]); break;
164
210
  case "gallery": process.exit(await gallery());
211
+ case "poster": process.exit(await poster(args[1]));
165
212
  case "components": await components(); break;
166
213
  case "brands": await brands(); break;
167
214
  default: