| name | slidey-authoring |
| description | Author and iterate on declarative videos using the slidey pipeline (a JSON scene spec rendered to a narrated MP4). Use when the user asks to create a new video, add/edit a scene, tweak narration, adjust visuals, re-cut a video, or generate an MP4 from a JSON spec. Covers the iteration loop, scene-type vocabulary, narration budgeting, and the gotchas accumulated across many cuts. |
Slidey authoring
Slidey is a deterministic spec-driven Puppeteer + ffmpeg pipeline at the repo root. You describe a video as a JSON array of scenes; slidey renders each scene to PNG frames via headless Chrome, generates narration via edge-tts, and muxes everything into an MP4. Output is reproducible: same JSON spec + same template = byte-identical frames.
Slidey specs use the .slidey.json extension — this is what the file-tree sidebar, the VS Code preview extension, and the MCP workspace tree auto-discover. Specs live in examples/ alongside their output MP4s. The minimal starting point is examples/hello.slidey.json; examples/kitsoki-pitch.slidey.json is a full real-world sample of the legacy scene types, and examples/layout-gallery.slidey.json exercises every variant of the cards/code/table/chart content primitives (copy variants straight from it).
Starting a new video
Create a .slidey.json file in examples/ (or anywhere) with this shape:
{
"meta": {
"mode": "pitch",
"narration": { "voice": "en-AU-NatashaNeural" }
},
"scenes": [
{ "type": "title", "title": "My Feature" },
...
]
}
Then follow the iteration loop below. Name the output MP4 alongside the spec (examples/my-feature.mp4).
The iteration loop
A full MP4 render takes 7–12 minutes. Never trigger one to spot-check a single scene. Use PNG or PDF for all visual iteration — they share the same render bundle and run in seconds.
Build the render bundle first. The visuals are Vue components compiled to dist-render/render.html; all pipelines (MP4/PDF/PNG) load that file. After any change under web/ (or on a fresh checkout), run npm run build:render — otherwise rendering errors with "render bundle missing". --estimate/--list don't need it.
Four outputs, one spec. The output path picks the format:
| Output path | Format | Speed | Use for |
|---|
path/to/dir (no extension) | PNG directory | ~1–3s/scene | Visual spot-check; LLM can Read files directly |
.pdf | PDF (vector, one page per reveal step) | ~3s total | Full deck review, sharing |
.mp4 | Video with narration | 7–12 min | Final deliverable only |
--estimate / --list | Console table | ~50ms | Narration budget check |
--check | Console validation | ~instant | Diagram-svg sizing/overlap guard (CI-usable) |
Don't run two Puppeteer renders at once — concurrent headless Chrome instances can crash each other's screenshot capture.
VS Code preview is available. For local authoring in VS Code, install the
extension with make vscode-install-local. It previews .slidey.json, .json,
and .jsonl files in a read-only webview using the same viewer as slidey <file>. The extension auto-discovers .slidey.json specs and .jsonl traces;
plain .json files can be opened explicitly. If you change the JSON, edit the
source file and let the preview reload from disk.
npm run build:render
slidey examples/my-video.slidey.json --estimate
slidey examples/my-video.slidey.json out.mp4 --check
slidey examples/my-video.slidey.json .artifacts/check --scenes 5
slidey examples/my-video.slidey.json .artifacts/deck.pdf
slidey examples/my-video.slidey.json .artifacts/out.mp4 \
--frames-dir .artifacts/frames \
--keep-frames \
--skip-render
slidey examples/my-video.slidey.json examples/my-video.mp4
Always run --estimate first. It catches narration overruns (the #1 cause of wasted full renders) and prints scene start times so you don't have to count.
Use PNG for all visual iteration. The LLM can Read PNG files directly (multimodal) — this makes layout review a first-class part of the authoring loop without waiting 30s for an MP4 render per scene.
Scene types
All are declared in JSON; render handlers live in src/scenes/:
type | Use for | Key fields | Typical duration |
|---|
title | Cold open, chapter break (e.g. "vs.") | title, subtitle, eyebrow | 3s |
narrative | Eyebrow + body + lede text beats | eyebrow, body, lede, hold | 7–9s |
diagram | ASCII/code panel comparison (e.g. "before vs after") | panels: [{label, ascii}] | 10–14s |
diagram-svg | Proper SVG diagrams (the architecturally important visuals) | panels: [{viewBox, nodes, edges}] | 10–15s |
mermaid | Mermaid flowchart / sequence / state diagram rendered as themed SVG | source, scale, caption | 8–12s |
trace | Multi-layer lookup cascade with HIT/MISS badges | turns: [{user, layers, intent, no_llm}] | 12s |
thread | Mocked issue-tracker / review comment threads | panels: [{system, ref, stage, messages}] | 14–17s |
stat | Big gradient number + caption | value, label, detail | 7s |
cta | Wordmark + tagline + URL end card | wordmark, tagline, url | 8s |
terminal-gif | Embed a recorded gif in a fake-terminal chrome | gif, title, caption | 8–12s |
video | Embed a demo MP4, rrweb log, or captured tour — fullscreen or inset in a slide, with chapter captions, annotations, and synced narration | src | rrweb | capture, mode, annotations[], narration[] | = source length |
request | API request/response card (live/mock/playback) | see src/scenes/request.js | varies |
transcript | Full agent/chat session as per-turn cards | turns: [...] | varies |
cards | Peer items OR side-by-side contrast | variant, cards[] | left/right | question/answer | 8–14s |
objectives | Objective/status report with large visual status glyphs | items: [{label, status, detail}] | 8–12s |
evidence | Status-forward checks, artifacts, commands, paths, and logs | items: [{label, status, detail, refType, ref}] | 8–12s |
personas | Reusable cast intro or persona-attributed use-case rows | meta.personas, variant, personas[], cases[] | 8–12s |
code | Real text artifacts (source, diff, function I/O, tree, config, log) | variant, lang, code, highlight[], annotations[] | 8–12s |
table | Data / comparison / scorecard tables | variant, columns[], rows: [{cells[], highlight}], winner | 8–12s |
chart | Hand-built SVG charts (bar/line/area/pie/scatter/quadrant) | variant, series: [{name, color, points[]}], axes, unit | 8–12s |
image | Static screenshot / imported SVG / generated visual | src, fit, caption | 6–10s |
image-compare | Before/after screenshot comparison | left, right, fit, variant | 8–12s |
book | Book-cover bibliography / recommended reading | books[] with title, authors, cover, takeaway | 8–12s |
meme | Meme-template slide (200+ templates, each knows its caption boxes) | template, text[] or fields{}, style, caption | 6–10s |
Choosing a layout — semantic taxonomy
The full catalogue (45 layouts across 9 communicative families) and the design rationale live in docs/layout-taxonomy.md in the repo. Pick a scene by what you're communicating, not by how it looks — that's the whole point of the taxonomy. The four variant-driven primitives below are closed semantic menus: choose the variant whose intent matches, and the renderer handles the pixels.
| If you want to… | Use | variant |
|---|
| list N peer items (no flow) | cards | grid · list · numbered · icon-row · agenda |
| weigh X against Y / before↔after / claim↔rebuttal | cards | before-after · versus · point-counterpoint · pros-cons |
| stage a question + its answer | cards | qa |
| show objective state / done vs issue vs next | objectives | done · issue · blocked · next · progress |
| show evidence state plus rerun/inspect command or path | evidence | command · artifact · path · log · doc · test |
| show the literal text of an artifact | code | source · diff · function-io · tree · config · log |
| compare named options across criteria / show exact values | table | comparison · scorecard · data |
| show what the numbers say | chart | bar (compare) · line/area (trend) · pie (composition) · scatter/quadrant (relate) |
| show A→B→C flow, hierarchy, or how things connect | diagram-svg | (vary rankdir + edge style — see below) |
| render existing Mermaid source | mermaid | flowchart · sequenceDiagram · stateDiagram-v2 |
| show a real UI state or asset | image / image-compare | screenshot, SVG, before/after |
| embed a product walkthrough | video | src MP4 · rrweb log · capture tour |
| keep role identity consistent across a story | personas | cast · use-cases |
Disambiguation: list vs flow — if removing the arrows loses nothing, it's cards, not diagram-svg. compare vs quantify — comparing options on qualities → cards/table; comparing measured values → chart. diagram vs code — a conceptual relationship is diagram-svg; the literal text of a file is code.
Mermaid vs diagram-svg — use mermaid when the Mermaid text is already the
maintained artifact or when a standard sequence/state/flow diagram is enough; use
diagram-svg when Slidey needs exact layout control, custom node styling, or
--check geometry validation.
cards — peer items & contrast
- Set variants (
grid/list/numbered/icon-row/agenda): cards: [{label, sub, lines:[], style}], optional columns:N. One reveal step per card.
- Two-column variants (
before-after/versus/point-counterpoint/pros-cons): left: {label, lines:[]} and right: {label, lines:[]} — contrasting accents, centre divider. pros-cons auto-glyphs ✓/✗.
qa: question: "…", answer: ["…","…"] (string or array of lines).
- Common:
title, caption. Supersedes the legacy ASCII diagram comparison.
objectives — objective status with visual feedback
Use objectives for eval/report decks where the viewer needs to know whether
the work is done, in progress, blocked, or has issues. Do not use a generic
table for this; status needs a large glyph and color, not just text in a cell.
{
"type": "objectives",
"title": "Objective status",
"items": [
{ "label": "Harness objective", "status": "done",
"detail": "One local entrypoint and project catalog are in place." },
{ "label": "HTML preview", "status": "issue",
"detail": "Bundle render is blocked by sandbox write permissions." },
{ "label": "Product-site journey", "status": "next",
"detail": "Run production web build for A/B walkthroughs." }
],
"caption": "Core harness complete; runtime preview still needs a clean environment."
}
Statuses: done renders a large green checkmark; issue and blocked render a
large red exclamation mark; next, progress, pending, and skipped are
visually distinct but less final. Keep to six items or fewer.
evidence — checks, artifacts, commands, and paths
Use evidence for report decks where each row answers "what proof exists, what
state is it in, and where do I rerun or inspect it?" Do not use a wide table
for commands or artifact paths; the evidence layout gives each row a status
glyph and keeps the command/path in a monospace chip.
{
"type": "evidence",
"title": "Latest check state",
"items": [
{ "label": "PostgreSQL", "status": "validated",
"detail": "ALTER DOMAIN oracle proves baseline red and fix green.",
"refType": "command",
"ref": "bash tools/product-journey/checks/postgresql-oracle.sh" },
{ "label": "Run log", "status": "implemented",
"detail": "Chronological job state and lane validation record.",
"refType": "log",
"ref": ".context/product-journey-runlog.md" }
],
"caption": "Each evidence row has a visible state plus a concrete reference."
}
Statuses: done, validated, and implemented render green checkmarks;
issue and blocked render red exclamation marks; next, progress,
pending, and skipped are visually distinct but less final. refType may be
command, artifact, path, log, doc, or test. Keep to six rows or
fewer.
personas — reusable cast and who-does-what rows
Use personas when a deck has recurring people, roles, stakeholders, or user
types and the viewer needs to remember who performs each action. Define the cast
once in meta.personas; scenes can reference ids or provide inline persona
objects.
{
"meta": {
"personas": [
{ "id": "pm", "name": "Priya", "role": "Product manager",
"intro": "Turns a rough idea into a validated plan.",
"color": "#58a6ff", "glyph": "PM" },
{ "id": "dev", "name": "Devon", "role": "Engineer",
"intro": "Turns the plan into reviewable changes.",
"color": "#7ee787", "glyph": "DE" }
]
},
"scenes": [
{ "type": "personas", "variant": "cast",
"title": "The cast", "personas": ["pm", "dev"] },
{ "type": "personas", "variant": "use-cases",
"title": "Plan to patch",
"cases": [
{ "who": "pm", "action": "Frames the customer problem",
"detail": "keeps the deck anchored in a real user" },
{ "who": "dev", "action": "Splits the patch into reviewable pieces",
"detail": "small enough for agent and human review" }
] }
]
}
meta.personas[]: reusable registry. Each entry needs id; add name,
role, intro, color, and glyph for richer cards.
variant:"cast": personas is a list of ids or inline objects. Renders cards
with avatar, name, role, and intro. columns can override the grid.
variant:"use-cases": cases[] rows use who to resolve an avatar, then show
action and optional detail.
code — text artifacts
variant, title (renders as a filename chrome bar), lang, code (string with \n). Plus: highlight: [lineNos], annotations: [{line, text}]; function-io uses call/returns; diff colours +/- lines; config tuned for JSON/YAML; log/tree for console + directory listings.
table — tabular data
variant, columns: ["…"], rows: [{cells: ["…"], highlight: colIndex?}], optional winner: colIndex (scorecard crowns it). comparison/scorecard render ✓/✗ glyphs and a highlighted column. Caps: ~6 columns × ~8 rows (excess is clipped to keep reveal-step count in sync). One reveal step per body row.
chart — hand-built SVG charts (no chart lib, deterministic)
variant, title, caption, unit, axes: {x, y}, series: [{name, color, points: [{x, y}]}]. color is a token name (primary/secondary/green/orange/teal/red). One reveal step per series. Pie uses a single series (≤6 slices); quadrant places points by x/y for a 2×2. Guidance: bar for category comparison, line for time trend, pie only for simple composition.
diagram-svg — the workhorse for proper diagrams
The most-used type. Each panel has an array of nodes and edges. auto_layout: true is the default — let dagre compute all x/y positions and the renderer auto-size boxes to fit text. Hand-authored x/y/w/h is a deliberate EXCEPTION, only for layouts dagre can't express (e.g. uniform full-width list rows, or two-panel comparisons with specific spatial semantics). When you DO hand-author coordinates you MUST run --check and verify with a PNG render: the auto-size loop can only grow a box in place — it can't reposition neighbors — so an undersized hand-authored box overlaps the boxes and arrows around it.
Nodes
label (big text), sub (medium), lines: [] (smaller lines stacked below)
style: "primary" (blue accent) or "secondary" (purple) to highlight the focal node
- No coordinates needed —
auto_layout places them; auto-size expands boxes to fit text
Panels
{
"auto_layout": true,
"rankdir": "TB",
"nodes": [...],
"edges": [...]
}
auto_layout: true — dagre computes all x/y positions; viewBox is auto-computed
rankdir: "TB" (default) — top-to-bottom flow; use "LR" for hub-and-spoke diagrams where one node fans out to many children
- Terminal nodes (no outgoing edges, multiple predecessors) are automatically ranked last
Edges
label — text beside the arrow
gate: "gate · label text" — replaces the arrow with a dashed orange checkpoint bar
elbow: true — orthogonal Z-bend routing instead of straight diagonal. When multiple edges share the same source, bus routing kicks in automatically — all branches share a common trunk line extending from the source, producing a clean tree appearance. No extra flag needed.
side: "left"|"right" — offset for parallel bidirectional arrows
Layouts by topology
| Topology | rankdir | Edge style | Example |
|---|
| Pipeline (A→B→C) | TB | straight | Bugfix rooms |
| Hub-and-spoke (A→many) | LR | elbow: true | Story anatomy |
| Comparison | two panels | straight | Judge polymorphism |
| Decision tree | TB | straight | Room lifecycle |
Single-panel vs two-panel diagrams
Slidey's CSS treats diagram-svg panels differently based on count:
- Two panels (side-by-side comparison): smaller boxes, smaller fonts. Good for comparisons.
- Single panel (hero diagram): bigger boxes, bigger fonts, SVG height 680px. Good for standalone diagrams.
If you need both panels visually consistent, use TWO single-panel scenes back-to-back with a title scene transition between them.
mermaid — themed Mermaid diagrams
Use mermaid when you already have Mermaid source, or when the fastest path is a
standard Mermaid diagram rather than hand-authored node geometry. Slidey renders
the Mermaid output in the Vue bundle with the active deck theme, so PDF output
stays vector and the web viewer uses the same component as MP4/PNG rendering.
{ "type": "mermaid",
"title": "Request lifecycle",
"source": "flowchart LR\n browser[Browser] --> api[API]\n api --> db[(Database)]",
"scale": 1,
"caption": "Mermaid source remains the editable artifact." }
Fields:
source: Mermaid text. Common choices are flowchart, sequenceDiagram, and
stateDiagram-v2.
sourceFile: optional provenance path for import/bundling workflows; the
renderer uses source.
scale: shrink or enlarge dense diagrams without editing the Mermaid text.
title, caption, narration, hold: same role as other slide scenes.
Limitations: --check only validates diagram-svg, not Mermaid. If a Mermaid
diagram is cramped, split it into multiple scenes or reduce scale; if you need
precise edge labels, node sizing, or deterministic geometry checks, convert the
concept to diagram-svg.
Media embedding — images, GIFs, MP4, rrweb, and tour capture
Use media scenes when the artifact itself carries the point: screenshots,
screen-recorded demos, rrweb bug reports, terminal recordings, or visual diffs.
Static images and screenshot comparisons
{ "type": "image",
"title": "Architecture",
"src": "assets/architecture.svg",
"fit": "contain",
"caption": "Local assets resolve relative to the spec." }
image accepts local paths, absolute paths, URLs, and data URIs. Use
fit:"contain" for diagrams/screenshots that must stay uncropped; use
fit:"cover" for full-bleed visuals where cropping is acceptable.
{ "type": "image-compare",
"title": "Migration fidelity",
"left": { "label": "Before", "src": "compare/marp.png" },
"right": { "label": "After", "src": "compare/slidey.png" },
"variant": "qa",
"caption": "Use for visual reviews and before/after UI checks." }
image-compare is for old/new screenshots. variant:"qa" reduces chrome and
enlarges the previews for review decks.
Terminal GIFs
{ "type": "terminal-gif",
"gif": "assets/run.gif",
"title": "demo",
"caption": "A recorded terminal session, framed and captioned." }
Use this for VHS-style terminal recordings. Prefer video for browser/product
demos, especially when chapters, annotations, trimming, or narration cues matter.
Demo videos — the video scene + tour capture
Slidey can splice a recorded product demo into a deck. Two halves:
1. Capture a demo (the tour engine)
slidey capture <tour.json> <out.mp4> drives any live web app through a
time-based storyboard with headless Chrome and records a deterministic demo
MP4 + a <out>.mp4.chapters.json sidecar. It is the generalized, app-agnostic
successor to a per-app Playwright recording harness — overlays (a setup curtain,
a caption banner, a spotlight + dim) are injected as plain DOM styled from the
deck palette, so a captured demo already looks like the rest of the deck.
{
"target": { "launch": "myapp serve --addr 127.0.0.1:8123", "addr": "127.0.0.1:8123" },
"startPath": "/", "viewport": { "width": 1600, "height": 900 },
"curtain": "My App", "pace": 1,
"steps": [
{ "id": "home", "label": "The home screen", "caption": "Welcome",
"waitFor": "[data-testid=home]", "dwellMs": 4000, "kind": "explain" },
{ "id": "open", "label": "Open a project", "target": "[data-testid=go]",
"dwellMs": 2500, "kind": "action", "advance": "click-target" }
]
}
Steps: target is a CSS selector (targetText disambiguates by text);
kind:"action" clicks the target to advance (advance:"route-match" +
advanceUrl waits for a URL change); before:[…] runs off-camera setup actions
(goto/click/type/waitFor/wait/eval). Default capture is
freeze-frame (settle, screenshot, hold for the dwell) — byte-identical reruns.
rrweb capture (--format rrweb, or an *.rrweb.json output path) records a
real-time DOM session log instead: slidey capture <tour.json> out.rrweb.json --format rrweb → out.rrweb.json + out.rrweb.json.chapters.json. It captures
true motion as compact JSON (not pixels), needs no baked overlays (the log stays
a clean app capture), and marks each step with a slidey.chapter custom event.
The same log feeds both the baked seek-rasterizer and the live web-viewer player,
and it's the same artifact a kitsoki bug report captures (see the library
exports below).
2. Embed it (the video scene)
{ "type": "video",
"src": "demos/tour.mp4",
"rrweb": "demos/tour.rrweb.json",
"capture": "demos/tour.json",
"mode": "fullscreen",
"fit": "contain",
"start": 0, "end": null, "speed": 1,
"eyebrow": "Live demo", "title": "My App", "caption": "…",
"chapters": "auto",
"annotations": [ { "at": 4, "until": 7, "text": "Note this", "sub": "…" },
{ "chapter": "open", "text": "…" } ],
"narration": [ { "at": 0, "text": "We open on the home screen." },
{ "chapter": "open", "text": "Now we open a project." } ] }
- The MP4 is ffmpeg-extracted into the frame sequence (scaled/letterboxed to the
deck resolution, or inset for
embedded). One ffmpeg pass composites the
overlays. The scene's duration equals the video length — --estimate
probes it (or use a duration hint).
- Prefer
src for fast repeatable deck renders. It can be a Slidey-produced
capture, a Kitsoki demo MP4, or any MP4 with an optional sibling
<src>.chapters.json.
chapters: "auto" (default for src) derives a deck-styled lower-third per
chapter from the sidecar; default is OFF for capture (its captions are
already baked in). Set false to disable, or a path to an explicit sidecar.
annotations (deck-styled, timed by at/until seconds or a chapter id)
composite on top — for emphasis on a clean video.
narration may be the usual whole-scene string or an array of time-keyed
cues ({at|chapter, text}) so the voiceover tracks demo moments.
- PNG/PDF export shows a representative poster frame (the source video is
only made for video output) — so the normal PNG/PDF iteration loop still works.
- rrweb source (
"rrweb": "…rrweb.json"): baked output seek-rasterizes the
log via Replayer.goto(t) (real motion, but much slower — opt-in; freeze
frame stays default); the web viewer mounts a live, scrubbable, chapter-aware
player you can grab to interact with. Chapters come from in-log custom events
(or a sibling sidecar). See examples/rrweb-demo.slidey.json.
- capture source (
"capture": "…tour.json"): runs the tour during render.
Use this for one-command final production; during iteration, run slidey capture first and embed the resulting MP4 or rrweb log so layout checks do not
repeatedly drive the app.
Kitsoki demos already emit this exact chapter-sidecar shape, so an existing
…-demo.mp4 + .chapters.json drops straight into a video scene with
"chapters": "auto".
3. rrweb as a library for kitsoki
slidey owns the rrweb pieces; kitsoki consumes them via subpath exports (so a bug
report and a demo are the same artifact type):
slidey/rrweb-buffer — app-agnostic rolling-buffer recorder (the bug-report
capture engine; generalized from kitsoki's session-capture.ts).
slidey/rrweb-player — RrwebPlayer.vue, themeable (--rrp-* CSS vars)
scrubbable player with chapter markers + interactive toggle. Source SFC.
slidey/rrweb-format (node) / slidey/rrweb-chapters (browser) — the
*.rrweb.json envelope + chapter extraction.
Book-cover bibliography
{ "type": "book",
"title": "Further reading",
"books": [
{ "title": "Thinking in Systems",
"authors": "Donella H. Meadows",
"year": "2008",
"cover": "assets/books/thinking-in-systems.jpg",
"takeaway": "A practical vocabulary for feedback loops." }
],
"caption": "One to three books per scene." }
Use book for recommended reading or bibliography slides. Keep it to one to
three books. Each book needs title, authors, cover, and takeaway;
subtitle, publisher, year, isbn, and alt are optional. Covers are
resolved relative to the spec and validated for useful resolution.
Meme templates
{ "type": "meme",
"template": "db",
"title": "Multi-box · landscape",
"text": ["Old slide types", "Authors", "Meme layouts"],
"caption": "Distracted Boyfriend" }
meme turns a common meme template into a slide layout. There are 200+ templates
(sourced from the open memegen.link catalog); each template knows its own caption
boxes, their semantic fields, and its orientation (tall / wide /
square all letterbox cleanly onto the stage). Discover templates and build slides
through MCP:
slidey_meme_search — search by name / keyword / example caption, optionally
filter by orientation. Returns each template's id plus its fields and example
hints. The id is what you pass as template.
slidey_add_meme — insert a meme slide. Fill captions with text (positional,
in box order) or fields (keyed by the template's field names). Omit both to seed
the template's example captions.
Captions match the deck theme by default with a legibility outline; auto-fit sizes
each caption to its box. For the classic look, set "style": { "impact": true }
(bold uppercase white with a heavy black outline). Other style overrides:
color, stroke, font, uppercase. Blank template images are fetched from
memegen.link and cached locally on first render (~/.cache/slidey/memes).
Narration
Voice: en-AU-NatashaNeural (Australian female, set in meta.narration.voice). Speech rate calibrates to ~1.85 words/sec for budget estimation — measured across many cuts (real range 1.7–2.3 depending on punctuation and word length).
Budgeting
Audio must be shorter than its scene. Margin of >0.6s is comfortable; 0–0.6s is tight; negative is overrun (audio bleeds into the next scene). --estimate flags all three.
Per-scene word budget formula: max_words ≈ (scene_duration_seconds × 1.5). Conservative; gives ~70% scene utilisation. For a 10s scene, plan ~15 words.
Pronunciation tips
Edge TTS ignores SSML <phoneme> tags (the read-aloud endpoint strips custom markup), so the only reliable fix for a mispronounced word is to respell it phonetically. Two ways:
- Inline in the narration text for one-offs.
meta.narration.pronunciations — a { "term": "respelling" } map applied whole-word and case-insensitively to the spoken text only (the narration shown in specs/--list stays clean). Define a tricky term once and it's fixed in every scene. Longest terms win, so "API key" can override "API". Example:
"narration": { "voice": "en-AU-NatashaNeural",
"pronunciations": { "Anthropic": "an-THROP-ik", "SDLC": "S D L C", "kitsoki": "kit-SOH-kee" } }
--estimate accounts for respellings, so a multi-word expansion (e.g. SDLC → S D L C) correctly shows the extra duration.
- URLs read literally. Write them phonetically:
"github dot com slash org slash repo". Even then, URLs are slow (3–5s for a single short URL) — often better to drop the URL from narration and let it appear only on the visual.
- Em-dashes (
—) create a natural pause (~0.4s). Useful for pacing but eats into your budget.
- Numbers: write "seventy-eight percent" not "78%" — TTS reads it the same way but writing it out makes the budget more predictable.
Iteration
When narration overruns, two fixes:
- Trim the text (preferred — usually the script was over-detailed)
- Extend the scene's
hold (in frames; 30 frames = 1s)
If you only change narration text:
node src/index.js spec.slidey.json .artifacts/out.mp4 --frames-dir .artifacts/frames --keep-frames --skip-render
This regenerates audio + remuxes onto cached frames — ~10s instead of 7min.
Common gotchas (battle-tested)
Text width vs box width
For monospace at viewBox-unit font-size F, character width is approximately 0.6 × F (in the same viewBox units). For SVG nodes in diagram-svg:
| Mode | Label font | Char width | Margin needed (each side) |
|---|
| Two-panel | 30 | ~18 vbox | ~20 vbox |
| Single-panel | 44 | ~26 vbox | ~30 vbox |
So a 12-character label in single-panel mode needs box width ≥ 12 × 26 + 60 = 372 viewBox units.
The sub and lines[] fonts are SMALLER than the label, so the label usually dominates box width — but a long sub/line can still overflow: single-panel is sub 28 / line 26, two-panel is sub 19 / line 18. Note sub renders as a SINGLE line (it is NOT auto-wrapped on " · "); for stacked multi-line content use the lines: [] array, one entry per line.
Run --check to catch sizing/overlap automatically instead of eyeballing this.
Title/caption clipping
If a scene's content overflows the available stage height (1080px − 192px padding = 888px on screen), overflow: hidden clips top and bottom equally, pushing the title against the top edge and clipping the caption.
For two-panel diagrams: the SVG aspect-ratio is 1/0.7, so each panel SVG is ~70% as tall as it is wide. The CONTENT inside scales to fit via preserveAspectRatio="meet". Make panel content fit by either (a) reducing content lines per node, or (b) reducing SVG aspect via the panel's viewBox.
For single-panel diagrams: SVG is hard-capped at 680px height. Title (40px) + caption (32–36px wrapped to 2 lines) + gaps eats ~200px, leaving ~680px for the SVG itself. If content needs more, you've overpacked the scene.
"mode": "pitch" is required in meta
Without "mode": "pitch", the template keeps #pitch-stage hidden (display: none) — every page of every PDF and every video frame renders as a blank background. Always include it:
{ "meta": { "mode": "pitch", "narration": { "voice": "en-AU-NatashaNeural" } } }
The --scenes flag rewrites the output file
--scenes 4 scopes any output format to only scene 4. Works for PNG, PDF, and MP4. When spot-checking, prefer PNG (not MP4):
slidey spec.slidey.json .artifacts/check --scenes 4
slidey spec.slidey.json .artifacts/check.pdf --scenes 4
Edge label overflow on diagrams
Edge labels render TO THE SIDE of the line. If the label is wider than the gap to the panel edge, it gets clipped by the SVG viewBox boundary. Keep edge labels under ~16 characters, or move the content to a panel caption.
Headless Chrome doesn't render colour emoji
🟢 renders as a tofu box. Use plain Unicode (✓, ✗, ▸) or descriptive text instead.
Two render processes can race for the output file
Check ps aux | grep "node src/index" before launching a new render to avoid corrupt/truncated output.
File map
slidey/
├── src/
│ ├── index.js # CLI entry point — argv parsing, orchestration
│ ├── renderer.js # Per-scene dispatch; tracks scene start frames
│ ├── assembler.js # ffmpeg invocation; muxes audio if segments supplied
│ ├── narration.js # Calls edge-tts CLI per scene; bundles segments
│ ├── runner.js # Live-HTTP runner for `request` scenes
│ ├── timing.js # Frame counts per state name + estimateScene/estimateBoundaries
│ ├── video.js # ffmpeg helpers for the `video` scene (probe/extract/poster)
│ ├── overlay-render.js # Deck-styled transparent overlay PNGs (captions/chrome)
│ ├── rrweb-format.js # *.rrweb.json envelope + chaptersFromEvents (node; exported)
│ ├── rrweb-render.js # rrweb baked rasterizer (Replayer.goto→frames) + poster
│ ├── tour/ # Tour-capture engine for `slidey capture` (app-agnostic)
│ │ ├── index.js # capture → ffmpeg MP4 + chapter sidecar (+ rrweb log)
│ │ ├── capture.js # Puppeteer freeze-frame driver
│ │ ├── rrweb-capture.js # real-time rrweb capture driver (--format rrweb)
│ │ ├── overlays.js # portable curtain/caption/spotlight (deck theme)
│ │ ├── launch.js # optional spawn + health-poll of the target app
│ │ └── chapters.js # ChapterRecorder + producer-agnostic sidecar
│ └── scenes/
│ ├── title.js
│ ├── narrative.js
│ ├── diagram.js # ASCII (legacy comparison)
│ ├── diagram-svg.js # Proper SVG (the workhorse)
│ ├── mermaid.js # Mermaid source rendered as themed SVG
│ ├── terminal-gif.js
│ ├── trace.js
│ ├── thread.js
│ ├── stat.js
│ ├── cta.js
│ ├── request.js # API request/response card
│ ├── transcript.js
│ ├── cards.js # peer items / contrast (see web/components/CardsScene.vue)
│ ├── personas.js # reusable cast / persona-attributed use cases
│ ├── code.js # text artifacts (see web/components/CodeScene.vue)
│ ├── table.js # tabular data (see web/components/TableScene.vue)
│ ├── chart.js # SVG charts (see web/components/ChartScene.vue)
│ ├── image.js # static screenshot / imported image
│ ├── image-compare.js # side-by-side screenshot comparison
│ ├── book.js # book-cover bibliography
│ └── video.js # `video` scene (MP4 / rrweb source; overlays; cues)
├── web/rrweb/ # rrweb library — exported as slidey/rrweb-* subpaths
│ ├── buffer.js # rolling-buffer recorder (→ slidey/rrweb-buffer)
│ ├── RrwebPlayer.vue # themeable scrubbable player (→ slidey/rrweb-player)
│ └── chapters.js # browser chapter extractor (→ slidey/rrweb-chapters)
└── examples/
├── hello.slidey.json # Minimal starting spec
├── kitsoki-pitch.slidey.json # Full real-world sample (every legacy scene type; safe to delete)
├── layout-gallery.slidey.json # Exercises every cards/code/table/chart variant — copy from here
├── rrweb-demo.slidey.json # `video` scene with an rrweb source (live player + baked)
└── <name>.slidey.json # Additional specs live here
The reveal-driven visuals are Vue components in web/components/<Name>Scene.vue,
compiled to dist-render/render.html by npm run build:render. The src/scenes/<type>.js
module is the thin Puppeteer driver. See docs/layout-taxonomy.md for the full
layout catalogue and the primitive-vs-preset architecture.
To add a new scene type (the Vue components in web/ are the one renderer the
viewer and every headless export share):
- Add the visual as
web/components/<Name>Scene.vue (reads store.scene; mark
reveal targets with the reveal class + store.isRevealed('<id>'))
- Register it in
web/components/DeckHost.vue's scene-dispatch map
- Add the reveal-step list (
stepsForScene) and the slidey.show<Name> call
(applyShow) for the type in web/sceneSteps.mjs
- Add
show<Name>(scene) + any reveal ids to web/store.js (and the
PITCH_REVEALS step→id table)
- Add the thin Puppeteer driver
src/scenes/<name>.js ({ render(page, scene, ctx) }) and register it in src/renderer.js's SCENE_MODULES
- Add reveal state names + an
estimateScene() branch in src/timing.js so
--estimate knows the duration
npm run build:render to rebuild the headless bundle (or just run any export —
it auto-rebuilds when web/ is newer; see src/render-bundle.js)
Render pipeline (one-line summary)
spec.slidey.json → renderer.js (Puppeteer screenshots) → frames/*.png → [narration.js (edge-tts) → audio/*.mp3] → assembler.js (ffmpeg) → output.mp4
Determinism comes from: (1) viewport size fixed at 1920×1080, (2) timing in frame counts not seconds, (3) JSON spec — no LLM in the rendering loop, ever.
Iteration workflow
slidey examples/my-video.slidey.json --list — see what scenes exist and page counts.
- Edit the spec JSON.
slidey examples/my-video.slidey.json --estimate — verify no narration overrun.
slidey examples/my-video.slidey.json out.mp4 --check — validate diagram-svg sizing/overlap (~instant, no render; exits non-zero on violations, so it doubles as a CI gate).
slidey examples/my-video.slidey.json .artifacts/check --scenes N — PNG spot-check (~1-3s). Read the output files directly to review layout with vision.
- Repeat 2–5 until the scene looks right.
slidey examples/my-video.slidey.json .artifacts/deck.pdf — full pass as PDF (~3s) to review the whole deck in sequence.
slidey examples/my-video.slidey.json examples/my-video.mp4 — final MP4 render only when layout and narration are confirmed.
Never render MP4 to "see if it works." PNG or PDF first.