kaizen-agent 1.19.3 → 1.20.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.
Files changed (36) hide show
  1. package/README.md +11 -0
  2. package/cli/install.ts +10 -4
  3. package/package.json +1 -1
  4. package/skills/story-deck/SKILL.md +63 -0
  5. package/skills/story-deck/examples/build-example.js +29 -0
  6. package/skills/story-deck/references/rules.md +62 -0
  7. package/skills/story-deck/references/story-arc.md +35 -0
  8. package/skills/story-deck/scripts/check.sh +12 -0
  9. package/skills/story-deck/scripts/install_fonts.py +42 -0
  10. package/skills/story-deck/scripts/kit.js +174 -0
  11. package/skills/story-deck/scripts/prep_images.py +70 -0
  12. package/skills/story-deck/scripts/qa.sh +48 -0
  13. package/skills/story-video/SKILL.md +108 -0
  14. package/skills/story-video/engine/package.json +12 -0
  15. package/skills/story-video/engine/src/Root.tsx +24 -0
  16. package/skills/story-video/engine/src/engine/Film.tsx +405 -0
  17. package/skills/story-video/engine/src/engine/presets.json +58 -0
  18. package/skills/story-video/engine/src/index.ts +3 -0
  19. package/skills/story-video/engine/tsconfig.json +1 -0
  20. package/skills/story-video/examples/sample-film/README.md +12 -0
  21. package/skills/story-video/examples/sample-film/film.json +16 -0
  22. package/skills/story-video/examples/sample-film/lines.json +7 -0
  23. package/skills/story-video/references/licensing.md +14 -0
  24. package/skills/story-video/references/presets.md +21 -0
  25. package/skills/story-video/references/production.md +29 -0
  26. package/skills/story-video/references/revisions.md +25 -0
  27. package/skills/story-video/references/script.md +30 -0
  28. package/skills/story-video/scripts/check.sh +19 -0
  29. package/skills/story-video/scripts/intake.py +73 -0
  30. package/skills/story-video/scripts/master.sh +26 -0
  31. package/skills/story-video/scripts/music.sh +10 -0
  32. package/skills/story-video/scripts/setup.sh +25 -0
  33. package/skills/story-video/scripts/sfx.py +64 -0
  34. package/skills/story-video/scripts/srt.py +30 -0
  35. package/skills/story-video/scripts/still.sh +10 -0
  36. package/skills/story-video/scripts/vo.py +99 -0
package/README.md CHANGED
@@ -232,6 +232,17 @@ In a terminal: `kaizen` (dashboard), `kaizen web` (board), `kaizen settings`, `k
232
232
 
233
233
  Stuck on 1.18.0 or older? If `kaizen upgrade` says *could not pull* and stays on the old version, run this once: `bunx kaizen-agent@latest`. Your old install is kept as `~/kaizen.old`; from then on `kaizen upgrade` works by itself.
234
234
 
235
+ ## Bundled skills
236
+
237
+ Installed alongside kaizen; your agent uses them when a request fits. Everything they make lives in `~/.kaizen/`.
238
+
239
+ | Skill | What it makes |
240
+ |---|---|
241
+ | `story-deck` | A cinematic storytelling pitch deck (A5 `.pptx`): story first, the product last. Output in `~/.kaizen/decks/`. |
242
+ | `story-video` | A publish-ready video from a prompt, photos, screenshots, a deck or footage: narration, captions, sound design, music, mastering, 16:9 and 9:16, revisable. Output in `~/.kaizen/videos/`. Works with no accounts (free voice, synthesized sounds); an ElevenLabs key unlocks natural voices. |
243
+
244
+ Each skill has a `scripts/check.sh` that lists what it needs (Node, Python, ffmpeg, LibreOffice).
245
+
235
246
  ## Learn more
236
247
 
237
248
  - **[The guide](https://github.com/hfadhlullah/kaizen/blob/main/docs/guide.md)**: the board, request sources, notebook, settings, installer flags, uninstall
package/cli/install.ts CHANGED
@@ -46,6 +46,9 @@ const c = {
46
46
  green: (s: string) => `\x1b[32m${s}\x1b[0m`,
47
47
  };
48
48
 
49
+ // Skill folders shipped in skills/ and linked into every agent.
50
+ const SKILLS = ["kaizen", "kaizen-help", "story-deck", "story-video"];
51
+
49
52
  // Where each agent reads skills from. The skill folders follow the open Agent
50
53
  // Skills standard, so every one of these can run the workflow; agents/ and
51
54
  // commands/ are Claude Code's own formats and go nowhere else.
@@ -177,7 +180,7 @@ async function removeEverything() {
177
180
 
178
181
  for (const root of roots) {
179
182
  for (const a of AGENTS) {
180
- for (const n of ["kaizen", "kaizen-help"]) drop(join(root, a.dir, "skills", n));
183
+ for (const n of SKILLS) drop(join(root, a.dir, "skills", n));
181
184
  for (const sub of ["agents", "commands"]) {
182
185
  const dir = join(root, a.dir, sub);
183
186
  try {
@@ -386,7 +389,7 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
386
389
  if (!stale) {
387
390
  const here = found(home);
388
391
  const linked = here.length > 0 && here.every((a) =>
389
- ["kaizen", "kaizen-help"].every((n) => existsSync(join(home, a.dir, "skills", n))));
392
+ SKILLS.every((n) => existsSync(join(home, a.dir, "skills", n))));
390
393
  if (linked) {
391
394
  const { dashboard } = await import("./dashboard.ts");
392
395
  await dashboard(repoDir, here.map((a) => a.name), async (action) => {
@@ -432,8 +435,11 @@ if (upgrade) {
432
435
  // failure is printed rather than swallowed.
433
436
  const r = await Bun.$`${process.execPath} x --force kaizen-agent@${latest} --yes`.quiet().nothrow();
434
437
  const out = clean(r.stdout.toString() + r.stderr.toString()).filter((l) => l.trim());
435
- for (const l of out) console.log(l.startsWith(" ") ? l : ` ${l}`);
436
438
  const after = versionOf(dest);
439
+ // The new installer's banner and first-install "Next" steps are noise here: show
440
+ // its step lines, or everything when it failed so the error is not hidden.
441
+ const shown = after === latest ? out.filter((l) => /^\s*(\+|skip)\s/.test(l)) : out;
442
+ for (const l of shown) console.log(l.startsWith(" ") ? l : ` ${l}`);
437
443
  // The installer exits 1 when it skipped a link (an agent file the user owns), which
438
444
  // is not a failed upgrade: the version on disk is what decides.
439
445
  if (after !== latest) {
@@ -676,7 +682,7 @@ function dirEntries(sub: string) {
676
682
  }
677
683
 
678
684
  const links = targets.flatMap((a) => [
679
- ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
685
+ ...SKILLS.map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
680
686
  ...(a.full ? [
681
687
  ...dirEntries("agents").map((f) => [f, join(a.dir, "agents")] as const),
682
688
  ...dirEntries("commands").map((f) => [f, join(a.dir, "commands")] as const),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.19.3",
3
+ "version": "1.20.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: story-deck
3
+ description: Build a cinematic, minimalist storytelling pitch deck (A5 landscape .pptx) in the style of a senior agency — story first, problem, a single "what if" question, then the product reveal and feature scenes, ending in a demo CTA. Dark ink + gold, Playfair Display + Inter, full-bleed graded photos. Use when the user asks for a pitch deck / sales deck / product showcase deck "that tells a story", "cinematic", or a new deck for a product or audience in that style. Do NOT use for editing an existing .pptx, plain bullet decks, or data reports — use the pptx skill for those.
4
+ ---
5
+
6
+ # Story deck
7
+
8
+ A deck that tells a story before it sells. Worked example: [`examples/build-example.js`](examples/build-example.js)
9
+ (every helper on a fictional product, no images needed).
10
+
11
+ Read before building:
12
+ - [`references/story-arc.md`](references/story-arc.md) — the acts, slide recipes, copy rules, layout facts.
13
+ - [`references/rules.md`](references/rules.md) — truth, images, fonts, files, QA. Every rule there cost a redo.
14
+
15
+ If the Anthropic `pptx` skill is available, load it once (pptxgenjs gotchas, validator); `qa.sh` works without it.
16
+ Run `scripts/check.sh` first on a new machine.
17
+
18
+ ## Workflow
19
+
20
+ 1. **Brainstorm first.** Propose 2–3 story concepts + an outline (one line per slide) and
21
+ ask: audience, language, data available, photos, the CTA. Build only after the outline is agreed.
22
+ 2. **Deck folder:** `~/.kaizen/decks/<slug>/` — everything a deck needs and produces lives there
23
+ (never in the user's project folders). Shared dependencies live once in `~/.kaizen/decks/`:
24
+ `node_modules` (pptxgenjs, resolved upward by Node) and `.venv` (Pillow, defusedxml, lxml,
25
+ fonttools, qrcode). First time on a machine:
26
+ `cd ~/.kaizen/decks && npm init -y && npm i pptxgenjs && python3 -m venv .venv && .venv/bin/pip install pillow defusedxml lxml fonttools "qrcode[pil]"`.
27
+ Copy in `scripts/kit.js`, `scripts/prep_images.py`, `scripts/qa.sh`.
28
+ 3. **Research the truth.** Features from the product repos (shipped only — one sub-agent per
29
+ repo when there are several, if your agent has sub-agents). Stats from official sources with citations.
30
+ 4. **Photos.** Find on Unsplash with a web-fetch tool (or ask the user for photos), download to `img/`, contact-sheet, choose.
31
+ Write `plan.json` (backgrounds + panels), run `python3 prep_images.py plan.json` → `bg/`.
32
+ 5. **Fonts.** Run `scripts/install_fonts.py` once per machine (static instances; see rules.md).
33
+ 6. **Write `build-<name>.js`** with `kit.js`:
34
+
35
+ ```js
36
+ const { makeDeck } = require("./kit");
37
+ const d = makeDeck("Deck title", { logo: "logo.png", logoRatio: 199 / 880 }); // branding optional
38
+ d.story("s01", { kick, title, sub, notes, credit, top }); // photo story beat
39
+ d.pause(line, sub, notes); // black silence slide
40
+ d.question(line, notes); // gold "what if" slide
41
+ d.reveal(name, tagline, line, notes); // product name reveal
42
+ d.scene("f03", { kick, title, features: [[name, desc]], notes, credit, shot, shotRatio }); // shot = UI screenshot, framed right
43
+ d.cards(kick, title, [[label, name, desc] x3], notes, footnote);
44
+ d.cta("f16", { title, sub, facts, notes, credit, contact, site, qr }); // qr: PNG path (make with python qrcode), else a [QR] box
45
+ d.pres.writeFile({ fileName: "Deck.pptx" }).then(console.log);
46
+ ```
47
+
48
+ Branding options on `makeDeck(title, brand)`: `INK, CREAM, MUTED, GOLD, GOLD_BG, cardBg`
49
+ (hex, no `#`), `SERIF, SANS` (font names), `logo` (light-on-dark PNG path; none = no logo),
50
+ `logoRatio` (height/width). Defaults are the neutral ink + gold look with no logo.
51
+ For anything the helpers do not cover (triptych, recap timeline, stat rows) use
52
+ `d.slide()`, `d.kicker()`, `d.headline()`, `d.body()` and see build.js / build-features.js
53
+ in the reference project.
54
+ 7. **QA:** `./qa.sh build-<name>.js`, look at every printed sheet (or send them to the user), fix, repeat. Clean `qa/` after.
55
+ 8. **Report:** file path, the story in one line per slide, every placeholder left, every
56
+ claim the user must verify, and what was deliberately left out.
57
+
58
+ ## Agent notes (Claude Code, Codex, Gemini CLI, OpenCode, Cursor…)
59
+ Steps name capabilities, not tools. Where your agent lacks one:
60
+ - **web fetch** (finding stock photos, sources): ask the user for photos/links instead.
61
+ - **viewing images** (checking contact sheets / frames): send the image paths to the user and ask them to look.
62
+ - **sub-agents** (reading several repos at once): read them one after another.
63
+ - **structured questions**: ask in plain text with numbered options.
@@ -0,0 +1,29 @@
1
+ // Worked example: a story deck for a fictional product ("Ledgerly", bookkeeping for small cafés).
2
+ // No photos needed — it uses the image-free helpers. Copy into ~/.kaizen/decks/<slug>/ next to kit.js
3
+ // and run: node build-example.js (then ./qa.sh build-example.js to check it).
4
+ // In a real deck most beats are photo stories: d.story("bg01", {...}) after prep_images.py made bg/bg01.jpg.
5
+ const { makeDeck } = require("./kit");
6
+
7
+ const d = makeDeck("Ledgerly — The Closing Hour", {
8
+ GOLD: "D9A441", GOLD_BG: "D9A441", // brand accent; colours are hex without '#'
9
+ });
10
+
11
+ d.pause("Every café closes at ten.", "The owner's real work starts after that.",
12
+ "Open on a feeling, not the product. Pause for two seconds.");
13
+ d.cards("Act II — The pieces", "Three notebooks, one business.", [
14
+ ["Morning", "Supplier invoices", "Paid from the owner's phone, noted somewhere."],
15
+ ["Afternoon", "Cash drawer", "Counted twice, written on the back of a receipt."],
16
+ ["Night", "Spreadsheet", "Retyped from both, after closing."],
17
+ ], "Speaker note: numbers only with a source on the slide.", "Each piece is true. None of them talk to each other.");
18
+ d.question("What if the books closed themselves?", "The turn. Say it slowly.");
19
+ d.reveal("Ledgerly", "Bookkeeping that closes with the café.", "Invoices · Cash · Reports",
20
+ "First time the product is named.");
21
+ d.cta(null, {
22
+ kick: "Next step", label: "Book a demo",
23
+ title: "See your own numbers in 20 minutes.",
24
+ sub: "A demo with a sample of your last month's receipts.",
25
+ facts: "Web and mobile · English and Bahasa Indonesia",
26
+ contact: "[Name] · [WhatsApp] · [Email]", site: "[website]",
27
+ notes: "Fill the contact placeholders before presenting.",
28
+ });
29
+ d.pres.writeFile({ fileName: "Ledgerly-example.pptx" }).then((f) => console.log(f));
@@ -0,0 +1,62 @@
1
+ # Rules learned the hard way
2
+
3
+ ## Truth
4
+ - Features: only what has shipped. Verify in the product repo: CHANGELOG released
5
+ versions + existing pages/routes. "Unreleased", proposals, SPH/quotation docs = not shipped.
6
+ Note what is missing (e.g. no iOS, no payment gateway) and keep it out of the copy;
7
+ warn in speaker notes.
8
+ - A requirements/spec doc is not proof of shipping: confirm each feature in the CHANGELOG or in
9
+ the code itself (content files, pages). Re-read the top of every CHANGELOG right before
10
+ delivering — products ship while decks are being made (a product changed its login model
11
+ mid-project once and two slides were already wrong).
12
+ - Stats: never invent. Use official/scientific sources (e.g. Dapodik, BPS, peer-reviewed
13
+ journals); put a full `Sumber: …` line on the slide and details in notes. If no source
14
+ exists for a planned stat, swap the stat — do not leave a fake number.
15
+ - Narrative numbers ("140 pesan belum dibaca") are fine but tell the user they are story, not data.
16
+
17
+ ## Images
18
+ - Unsplash blocks curl/napi (bot wall). Use a web-fetch tool on `https://unsplash.com/s/photos/<query>`
19
+ asking for non-premium image URLs, then download
20
+ `https://images.unsplash.com/photo-<id>?w=1600&q=80&fm=jpg` with curl (that CDN works).
21
+ - Prefer real local scenes (for Indonesia: query `indonesia-school`, `sekolah`, `pesantren`,
22
+ `indonesian-teacher`, `indonesia-mechanic`). Build a contact sheet and LOOK before choosing.
23
+ - Never use pptxgenjs `sizing: cover` — Google Slides ignores it and stretches. Crop each
24
+ image to the exact box ratio in `prep_images.py` (panels: `[w_px, h_px]` = box ratio).
25
+ - Every photo must show the slide's own sentence. Problem slides show the real-world pain
26
+ (the workshop, the office, the WhatsApp phone); product and feature slides show people
27
+ USING the kind of tech the product is (students at computers / circuit simulation for a
28
+ simulator, adults in a training room or on laptops for an LMS). A pretty but unrelated
29
+ photo (a camera crew, a sermon, kids on a teacher-training slide) gets swapped.
30
+ - Show modern equipment (flat screens, laptops) — never CRT monitors or dated labs when
31
+ selling software. Mix genders in group shots of students; avoid all-one-gender rooms.
32
+ - For product slides, the product's REAL interface beats any stock photo: look in the repo for
33
+ screenshots/renders (`public/landing/`, `.preview/`, docs) and use them — framed via
34
+ `scene({shot, shotRatio})`, or graded as a background if it is a dark render. Never stock
35
+ photos of program code on screen for a non-coding product.
36
+ - Candid only: people absorbed in what they are doing (teaching, reading, working). No posed
37
+ portraits — nobody looking into the camera and smiling; it reads as stock and breaks the film.
38
+ Hands, backs, profiles and wide scenes work best.
39
+ - Photos with readable foreign text (e.g. English survey paper) break the illusion — swap.
40
+
41
+ ## Fonts
42
+ - Default Playfair Display + Inter (both in the Google Slides font menu). Install them locally
43
+ with `~/.kaizen/decks/.venv/bin/python scripts/install_fonts.py` — it makes STATIC instances. Never install the variable
44
+ `[wght]` files: some viewers (LibreOffice desktop) draw big headlines greyed out, as if
45
+ transparent, while the XML colour is correct. A missing font (e.g. Cambria) renders with
46
+ ugly letter spacing.
47
+ - If a user reports "text not seen": first check the slide XML colour (unzip, look for
48
+ `srgbClr`/`alpha` on that run). Solid colour there = viewer/font problem, not the deck.
49
+ - Playfair numbers are old-style (low digits); mention it, offer Lora if unwanted.
50
+
51
+ ## Files and tools
52
+ - Output decks as local `.pptx` (user imports into Google Slides). No Artifacts.
53
+ - Everything the skill generates lives in `~/.kaizen/decks/<slug>/`; never write inside the user's
54
+ project folders. Reading assets (logos, screenshots) from repos is fine.
55
+ - zsh does not word-split `$var` in `for x in $ids` — wrap in `bash -c` or use arrays.
56
+ - `qa.sh` uses the shared `~/.kaizen/decks/.venv` and creates it when missing.
57
+
58
+ ## QA loop
59
+ Run `qa.sh build-x.js`, look at every sheet, fix: text overflowing into the next block
60
+ (headline wrapped to 3 lines), kicker wrapping, labels wrapping in cards, low contrast on
61
+ bright photos (raise gradient strength), page number collisions. Re-run until clean.
62
+ Delete `qa/` and PDFs at the end.
@@ -0,0 +1,35 @@
1
+ # Story arc and slide recipes
2
+
3
+ The deck sells a feeling first and the product last. Never open with features.
4
+
5
+ ## The five acts (12–16 slides)
6
+
7
+ | Act | Slides | Helper | What it does |
8
+ |---|---|---|---|
9
+ | I. World | 1–2 | `story` | Cover line about the audience's world, not the product. Scale or context (sourced stat, or none). |
10
+ | II. One scene | 1–2 | `story`, `pause` | Shrink to one person at one time ("06.45 — Gerbang sekolah"). A black `pause` slide for silence. |
11
+ | III. The pieces | 2–4 | `story`, triptych | Time-stamped scenes of the problem from each viewpoint (admin, teacher, parent…). |
12
+ | IV. The cost | 1–2 | `story` + stats | What is lost (trust, time). Stats only with a source line on the slide. |
13
+ | V. The turn | 1 | `question` | Gold slide, one italic question: "Bagaimana jika …?" |
14
+ | Reveal | 1 | `reveal` | Product name appears for the first time. |
15
+ | Features | 3–6 | `scene`, `cards` | Each scene = kicker (time/place · product), headline, 2–4 feature rows. |
16
+ | CTA | 1 | `cta` | One action (demo), contact placeholders, QR placeholder. |
17
+
18
+ Follow-up decks (shown after a story deck) skip acts I–V: open with a callback
19
+ cover ("Hari yang sama. Cerita yang berbeda."), revisit the same scenes with a
20
+ `Dulu: "<old headline>"` line (see build-features.js in the reference project), end
21
+ with a recap timeline and the CTA.
22
+
23
+ ## Copy rules
24
+ - One idea per slide. Headline ≤ 2 lines at its size; if it wraps to 3, shorten the words.
25
+ - Kicker = TIME — PLACE or ACT — NAME, uppercase via helper.
26
+ - Body line is the human detail, not a feature list.
27
+ - Name the product only from the reveal onwards.
28
+ - Bahasa Indonesia by default (audience: kepala sekolah/yayasan); English if asked.
29
+ - Speaker notes: delivery cue + caveats + `Foto: <name> / Unsplash.` credit.
30
+ - Placeholders in brackets `[__]`, `[Nama]`, `[QR]` — list them in the final report.
31
+
32
+ ## Layout facts (A5 landscape 8.268 × 5.827 in, margin 0.6)
33
+ - Photo backgrounds: gradient side must match where the text sits (`left` for `scene`, `bottom` for `story`).
34
+ - `scene` rows: 0.7 in pitch, 4 rows max, column width 4.3 in.
35
+ - Source lines: 8 pt at `H - 0.75`, width ≤ 6.2 in (clear of the page number).
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # Requirements for story-deck. Exit 1 if anything required is missing.
3
+ miss=0
4
+ need(){ command -v "$1" >/dev/null 2>&1 && echo "ok $1" || { echo "MISSING $1 — $2"; miss=1; }; }
5
+ need node "Node.js 18+ (https://nodejs.org or your package manager)"
6
+ need npm "comes with Node.js"
7
+ need python3 "Python 3.9+"
8
+ need soffice "LibreOffice (renders slides for the visual check)"
9
+ need pdftoppm "poppler-utils (brew install poppler / apt install poppler-utils / pacman -S poppler)"
10
+ [ -d "$HOME/.kaizen/decks/node_modules/pptxgenjs" ] && echo "ok pptxgenjs" || echo "todo pptxgenjs — cd ~/.kaizen/decks && npm init -y && npm i pptxgenjs"
11
+ fc-list 2>/dev/null | grep -qi "Playfair Display" && echo "ok fonts" || echo "todo fonts — ~/.kaizen/decks/.venv/bin/python scripts/install_fonts.py"
12
+ exit $miss
@@ -0,0 +1,42 @@
1
+ """Install STATIC Playfair Display + Inter into ~/.local/share/fonts/story-deck/.
2
+
3
+ Variable-font builds (the [wght] files on Google Fonts) render large headlines greyed /
4
+ semi-transparent in some desktop viewers (LibreOffice). Static instances do not.
5
+ Needs: pip install fonttools (qa.sh's .venv works: .venv/bin/pip install fonttools).
6
+ Usage: python3 install_fonts.py
7
+ """
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ import urllib.request
12
+
13
+ from fontTools.ttLib import TTFont
14
+ from fontTools.varLib.instancer import instantiateVariableFont
15
+
16
+ RAW = "https://github.com/google/fonts/raw/main/ofl/"
17
+ SRC = {
18
+ "pf": "playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
19
+ "pfi": "playfairdisplay/PlayfairDisplay-Italic%5Bwght%5D.ttf",
20
+ "in": "inter/Inter%5Bopsz,wght%5D.ttf",
21
+ "ini": "inter/Inter-Italic%5Bopsz,wght%5D.ttf",
22
+ }
23
+ JOBS = [
24
+ ("pf", "PlayfairDisplay-Regular", {"wght": 400}),
25
+ ("pf", "PlayfairDisplay-Bold", {"wght": 700}),
26
+ ("pfi", "PlayfairDisplay-Italic", {"wght": 400}),
27
+ ("in", "Inter-Regular", {"wght": 400, "opsz": 14}),
28
+ ("in", "Inter-Bold", {"wght": 700, "opsz": 14}),
29
+ ("ini", "Inter-Italic", {"wght": 400, "opsz": 14}),
30
+ ]
31
+
32
+ dest = os.path.expanduser("~/.local/share/fonts/story-deck/")
33
+ os.makedirs(dest, exist_ok=True)
34
+ with tempfile.TemporaryDirectory() as tmp:
35
+ for key, path in SRC.items():
36
+ urllib.request.urlretrieve(RAW + path, f"{tmp}/{key}.ttf")
37
+ for key, out, loc in JOBS:
38
+ font = instantiateVariableFont(TTFont(f"{tmp}/{key}.ttf"), loc, updateFontNames=True, static=True)
39
+ assert "fvar" not in font
40
+ font.save(dest + out + ".ttf")
41
+ print("installed", out)
42
+ subprocess.run(["fc-cache", "-f"], check=False)
@@ -0,0 +1,174 @@
1
+ // Cinematic story deck helpers: A5 landscape, dark ink + one accent, serif display + sans body.
2
+ // Branding is adjustable: makeDeck(title, { INK, CREAM, MUTED, GOLD, GOLD_BG, SERIF, SANS, logo, logoRatio, cardBg }).
3
+ const pptxgen = require("pptxgenjs");
4
+
5
+ const W = 8.268, H = 5.827, M = 0.6;
6
+ const DEFAULTS = {
7
+ INK: "14110F", CREAM: "F3EDE3", MUTED: "B8AFA3", GOLD: "C9A24E", GOLD_BG: "C9A96E", cardBg: "1E1A17",
8
+ SERIF: "Playfair Display", SANS: "Inter",
9
+ logo: null, // path to a light-on-dark logo PNG; null = no logo drawn
10
+ logoRatio: 0.226, // logo height / width
11
+ };
12
+
13
+ function makeDeck(title, brand = {}) {
14
+ const { INK, CREAM, MUTED, GOLD, GOLD_BG, SERIF, SANS, logo: logoPath, logoRatio, cardBg } = { ...DEFAULTS, ...brand };
15
+ const pres = new pptxgen();
16
+ pres.defineLayout({ name: "A5L", width: W, height: H });
17
+ pres.layout = "A5L";
18
+ pres.title = title;
19
+ let page = 0;
20
+
21
+ const notesWith = (notes, credit) => notes + (credit ? `\n\nFoto: ${credit} / Unsplash.` : "");
22
+
23
+ function slide(bgFile, notes, dark = true) {
24
+ const s = pres.addSlide();
25
+ page++;
26
+ s.background = { color: dark ? INK : GOLD_BG };
27
+ if (bgFile) s.addImage({ path: `bg/${bgFile}.jpg`, x: 0, y: 0, w: W, h: H });
28
+ s.addText(String(page).padStart(2, "0"), {
29
+ x: W - M - 0.6, y: H - 0.45, w: 0.6, h: 0.25, align: "right", margin: 0,
30
+ fontFace: SANS, fontSize: 8, color: dark ? MUTED : INK, charSpacing: 2, isTextBox: true,
31
+ });
32
+ s.addNotes(notes);
33
+ return s;
34
+ }
35
+
36
+ function kicker(s, text, y, x = M) {
37
+ s.addText(text.toUpperCase(), {
38
+ x, y, w: W - x - M, h: 0.3, margin: 0, fontFace: SANS, fontSize: 9, bold: true,
39
+ color: GOLD, charSpacing: 4, isTextBox: true,
40
+ });
41
+ }
42
+
43
+ function headline(s, text, y, h, opts = {}) {
44
+ s.addText(text, {
45
+ x: opts.x || M, y, w: opts.w || 5.6, h, margin: 0, fontFace: SERIF, fontSize: opts.size || 30,
46
+ color: opts.color || CREAM, valign: "top", lineSpacingMultiple: 0.95, isTextBox: true, ...opts.extra,
47
+ });
48
+ }
49
+
50
+ function body(s, text, y, h, opts = {}) {
51
+ s.addText(text, {
52
+ x: opts.x || M, y, w: opts.w || 4.6, h, margin: 0, fontFace: SANS, fontSize: opts.size || 12,
53
+ color: opts.color || MUTED, valign: "top", lineSpacingMultiple: 1.2, isTextBox: true,
54
+ });
55
+ }
56
+
57
+ function logo(s, x, y, w) {
58
+ if (logoPath) s.addImage({ path: logoPath, x, y, w, h: w * logoRatio });
59
+ }
60
+
61
+ // Photo story beat: text sits low on the slide (photo gradient is at the bottom or left).
62
+ function story(bgFile, { kick, title, sub, notes, credit, top = 3.2 }) {
63
+ const s = slide(bgFile, notesWith(notes, credit));
64
+ kicker(s, kick, top);
65
+ headline(s, title, top + 0.35, 1.1, { size: 28, w: 6.2 });
66
+ if (sub) body(s, sub, top + 1.6, 0.6, { w: 5.2 });
67
+ return s;
68
+ }
69
+
70
+ // Black pause slide.
71
+ function pause(line, sub, notes) {
72
+ const s = slide(null, notes);
73
+ s.addText(line, {
74
+ x: 0.8, y: 2.1, w: W - 1.6, h: 0.9, align: "center", valign: "middle", margin: 0,
75
+ fontFace: SERIF, fontSize: 28, italic: true, color: CREAM, isTextBox: true,
76
+ });
77
+ if (sub) s.addText(sub, {
78
+ x: 0.8, y: 3.1, w: W - 1.6, h: 0.5, align: "center", margin: 0, fontFace: SANS, fontSize: 12,
79
+ color: MUTED, isTextBox: true,
80
+ });
81
+ return s;
82
+ }
83
+
84
+ // Gold statement slide.
85
+ function question(line, notes) {
86
+ const s = slide(null, notes, false);
87
+ s.addText(line, {
88
+ x: 1.0, y: 1.9, w: W - 2.0, h: 1.8, align: "center", valign: "middle", margin: 0,
89
+ fontFace: SERIF, fontSize: 32, italic: true, color: INK, isTextBox: true,
90
+ });
91
+ return s;
92
+ }
93
+
94
+ // Product name reveal.
95
+ function reveal(name, tagline, line, notes) {
96
+ const s = slide(null, notes);
97
+ s.addText(name, {
98
+ x: 0, y: 1.75, w: W, h: 1.0, align: "center", margin: 0, fontFace: SERIF, fontSize: 54,
99
+ color: CREAM, isTextBox: true,
100
+ });
101
+ s.addText(tagline, {
102
+ x: 0, y: 2.85, w: W, h: 0.5, align: "center", margin: 0, fontFace: SERIF, fontSize: 17,
103
+ italic: true, color: CREAM, isTextBox: true,
104
+ });
105
+ s.addText(line.toUpperCase(), {
106
+ x: 0, y: 3.45, w: W, h: 0.3, align: "center", margin: 0, fontFace: SANS, fontSize: 9,
107
+ bold: true, color: GOLD, charSpacing: 4, isTextBox: true,
108
+ });
109
+ return s;
110
+ }
111
+
112
+ // Feature slide: kicker, headline, feature rows in a left text column.
113
+ // shot: product screenshot path shown framed on the right (bgFile may be null); shotRatio = w/h.
114
+ function scene(bgFile, { kick, title, features, notes, credit, shot, shotRatio = 1.6 }) {
115
+ const s = slide(bgFile, notesWith(notes, credit));
116
+ if (shot) {
117
+ const sw = 3.0, sh = sw / shotRatio, sx = W - M - sw, sy = (H - sh) / 2;
118
+ s.addShape(pres.shapes.RECTANGLE, { x: sx - 0.06, y: sy - 0.06, w: sw + 0.12, h: sh + 0.12, fill: { color: cardBg }, line: { color: GOLD, width: 0.75 } });
119
+ s.addImage({ path: shot, x: sx, y: sy, w: sw, h: sh });
120
+ }
121
+ kicker(s, kick, M);
122
+ headline(s, title, 0.95, 1.0, { size: 24, w: 4.3 });
123
+ let y = 2.1;
124
+ features.forEach(([name, desc]) => {
125
+ s.addText([
126
+ { text: name, options: { fontFace: SANS, fontSize: 11, bold: true, color: CREAM, breakLine: true } },
127
+ { text: desc, options: { fontFace: SANS, fontSize: 9.5, color: MUTED } },
128
+ ], { x: M, y, w: shot ? 3.85 : 4.3, h: 0.62, margin: 0, valign: "top", lineSpacingMultiple: 1.1, isTextBox: true });
129
+ y += 0.7;
130
+ });
131
+ return s;
132
+ }
133
+
134
+ // Three dark cards.
135
+ function cards(kick, title, items, notes, footnote) {
136
+ const s = slide(null, notes);
137
+ kicker(s, kick, M);
138
+ headline(s, title, 0.95, 0.9, { size: 26, w: 6.8 });
139
+ const cw = 2.2, gap = (W - 2 * M - 3 * cw) / 2, cy = 2.3;
140
+ items.forEach(([label, name, desc], i) => {
141
+ const x = M + i * (cw + gap);
142
+ s.addShape(pres.shapes.RECTANGLE, { x, y: cy, w: cw, h: 2.0, fill: { color: cardBg } });
143
+ s.addText([
144
+ { text: label.toUpperCase(), options: { fontFace: SANS, fontSize: 8, bold: true, color: GOLD, charSpacing: 1, breakLine: true } },
145
+ { text: name, options: { fontFace: SERIF, fontSize: 17, color: CREAM, breakLine: true } },
146
+ { text: desc, options: { fontFace: SANS, fontSize: 10, color: MUTED } },
147
+ ], { x: x + 0.2, y: cy + 0.2, w: cw - 0.4, h: 1.6, margin: 0, valign: "top", paraSpaceAfter: 6, isTextBox: true });
148
+ });
149
+ if (footnote) body(s, footnote, 4.6, 0.3, { size: 10, w: 7.0 });
150
+ return s;
151
+ }
152
+
153
+ function cta(bgFile, { title, sub, facts, notes, credit, kick = "Langkah berikutnya", label = "Jadwalkan demo",
154
+ contact = "[Nama] · [WhatsApp] · [Email]", site = "[website]", qr = null }) {
155
+ const s = slide(bgFile, notesWith(notes, credit));
156
+ kicker(s, kick, 1.5);
157
+ headline(s, title, 1.85, 1.0, { size: 30, w: 6.2 }); // room for two lines
158
+ body(s, sub, 2.95, 0.5, { w: 4.4 });
159
+ s.addText([
160
+ { text: label.toUpperCase(), options: { fontFace: SANS, fontSize: 9, bold: true, color: GOLD, charSpacing: 4, breakLine: true } },
161
+ { text: contact, options: { fontFace: SANS, fontSize: 12, color: CREAM, breakLine: true } },
162
+ { text: site, options: { fontFace: SANS, fontSize: 12, color: MUTED } },
163
+ ], { x: M, y: 3.55, w: 4.4, h: 0.9, margin: 0, valign: "top", paraSpaceAfter: 4, isTextBox: true });
164
+ if (qr) s.addImage({ path: qr, x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3 });
165
+ else { s.addShape(pres.shapes.RECTANGLE, { x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3, fill: { color: CREAM } }); s.addText("[QR]", { x: W - M - 1.3, y: 3.2, w: 1.3, h: 1.3, align: "center", valign: "middle", margin: 0, fontFace: SANS, fontSize: 12, color: INK, isTextBox: true }); }
166
+ if (facts) body(s, facts, 4.45, 0.3, { size: 9 });
167
+ logo(s, M, H - 0.55, 1.6);
168
+ return s;
169
+ }
170
+
171
+ return { pres, slide, kicker, headline, body, logo, story, pause, question, reveal, scene, cards, cta };
172
+ }
173
+
174
+ module.exports = { makeDeck, W, H, M };
@@ -0,0 +1,70 @@
1
+ """Crop photos to the slide and bake in the dark cinematic grade.
2
+
3
+ Usage: python3 prep_images.py plan.json (run from the deck folder; reads img/, writes bg/)
4
+
5
+ plan.json:
6
+ {"backgrounds": {"s01": ["<photo id>", focusY, base, "left|right|bottom", strength]},
7
+ "panels": {"p1": ["<photo id>", focusY, [w_px, h_px]]}}
8
+ photo id = file name in img/ without .jpg. focusY 0..1 = vertical crop focus.
9
+ base = overall darkening 0..1. strength = gradient darkness on the text side 0..1.
10
+ Panel size must have the SAME ratio as its box on the slide (w_in/h_in); pptxgenjs
11
+ "sizing" is ignored by Google Slides and stretches the photo.
12
+
13
+ pptxgenjs has no gradient fills, so the text-side shadow lives in the image.
14
+ """
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from PIL import Image, ImageEnhance
20
+
21
+ W, H = 1800, 1273 # A5 landscape (210:148)
22
+ INK = (20, 17, 15)
23
+
24
+
25
+ def crop(im, w, h, focus_y):
26
+ r = w / h
27
+ if im.width / im.height > r: # too wide: crop sides
28
+ nw = int(im.height * r)
29
+ x = (im.width - nw) // 2
30
+ im = im.crop((x, 0, x + nw, im.height))
31
+ else: # too tall: crop around focus
32
+ nh = int(im.width / r)
33
+ y = int((im.height - nh) * focus_y)
34
+ im = im.crop((0, y, im.width, y + nh))
35
+ out = im.resize((w, h), Image.LANCZOS)
36
+ assert abs(out.width / out.height - r) < 0.01
37
+ return out
38
+
39
+
40
+ def grade(im):
41
+ im = ImageEnhance.Color(im).enhance(0.75) # mute colours, film look
42
+ return Image.blend(im, Image.new("RGB", im.size, (60, 40, 20)), 0.08)
43
+
44
+
45
+ def overlay(im, base, side, strength):
46
+ ink = Image.new("RGB", im.size, INK)
47
+ im = Image.blend(im, ink, base)
48
+ g = Image.linear_gradient("L") # black top -> white bottom
49
+ if side == "left":
50
+ g = g.rotate(-90)
51
+ elif side == "right":
52
+ g = g.rotate(90)
53
+ g = g.resize(im.size).point(lambda v: int(v * strength))
54
+ return Image.composite(ink, im, g)
55
+
56
+
57
+ def main(plan_path):
58
+ plan = json.loads(Path(plan_path).read_text())
59
+ Path("bg").mkdir(exist_ok=True)
60
+ for sid, (pid, fy, base, side, s) in plan.get("backgrounds", {}).items():
61
+ im = Image.open(f"img/{pid}.jpg").convert("RGB")
62
+ overlay(grade(crop(im, W, H, fy)), base, side, s).save(f"bg/{sid}.jpg", quality=85)
63
+ for sid, (pid, fy, (pw, ph)) in plan.get("panels", {}).items():
64
+ im = grade(crop(Image.open(f"img/{pid}.jpg").convert("RGB"), pw, ph, fy))
65
+ Image.blend(im, Image.new("RGB", im.size, INK), 0.35).save(f"bg/{sid}.jpg", quality=85)
66
+ print("ok")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main(sys.argv[1] if len(sys.argv) > 1 else "plan.json")
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # Build a deck, check the file, render every slide, and write 2x4 contact sheets to qa/.
3
+ # Usage (from the deck folder): qa.sh build-x.js -> prints the qa/sheet*.jpg paths to look at
4
+ # Uses the Anthropic pptx skill's validator when installed; otherwise a built-in structural check.
5
+ set -euo pipefail
6
+ BUILD=${1:?usage: qa.sh build-script.js}
7
+ "$(dirname "$0")/check.sh" >/dev/null 2>&1 || { "$(dirname "$0")/check.sh" 2>/dev/null || true; }
8
+ V=$HOME/.kaizen/decks/.venv
9
+ [ -x "$V/bin/python" ] || { python3 -m venv "$V" && "$V/bin/pip" -q install defusedxml lxml pillow fonttools "qrcode[pil]"; }
10
+ PY=$V/bin/python
11
+ F=$(node "$BUILD")
12
+ PPTX_SKILL=$(ls -d ~/.claude/skills/*/pptx/scripts ~/.claude/skills/synced/*/pptx/scripts 2>/dev/null | head -1 || true)
13
+ if [ -n "$PPTX_SKILL" ] && [ -f "$PPTX_SKILL/office/validate.py" ]; then
14
+ $PY "$PPTX_SKILL/office/validate.py" "$F"
15
+ $PY "$PPTX_SKILL/office/soffice.py" --headless --convert-to pdf "$F" >/dev/null
16
+ else
17
+ # structural check: valid zip, every XML part parses, every slide relationship target exists
18
+ $PY - "$F" <<'PYEOF'
19
+ import sys, zipfile, posixpath
20
+ from lxml import etree
21
+ z = zipfile.ZipFile(sys.argv[1]); bad = z.testzip()
22
+ assert bad is None, f"corrupt part: {bad}"
23
+ names = {n for n in z.namelist() if not n.endswith("/")} # skip directory entries
24
+ for n in names:
25
+ if n.endswith(".xml") or n.endswith(".rels"):
26
+ etree.fromstring(z.read(n))
27
+ for n in names:
28
+ if n.startswith("ppt/slides/_rels/"):
29
+ for r in etree.fromstring(z.read(n)):
30
+ t = r.get("Target")
31
+ if r.get("TargetMode") == "External" or not t: continue
32
+ p = posixpath.normpath(posixpath.join("ppt/slides", t))
33
+ assert p in names, f"{n}: missing {p}"
34
+ print("structure OK:", sum(1 for n in names if n.startswith("ppt/slides/slide")), "slides")
35
+ PYEOF
36
+ soffice --headless --convert-to pdf --outdir "$(dirname "$F")" "$F" >/dev/null 2>&1
37
+ fi
38
+ rm -rf qa && mkdir qa
39
+ pdftoppm -jpeg -r 70 "${F%.pptx}.pdf" qa/s
40
+ rm -f "${F%.pptx}.pdf"
41
+ $PY - <<'PYEOF'
42
+ from PIL import Image; import glob, os
43
+ ims=[Image.open(f) for f in sorted(glob.glob('qa/s-*.jpg'))]; w,h=ims[0].size
44
+ for p in range(0,len(ims),8):
45
+ g=ims[p:p+8]; sh=Image.new('RGB',(w*2,h*4),'white')
46
+ for i,im in enumerate(g): sh.paste(im,((i%2)*w,(i//2)*h))
47
+ sh.save(f'qa/sheet{p//8}.jpg'); print(f'{os.getcwd()}/qa/sheet{p//8}.jpg')
48
+ PYEOF