naiasno-video · diff
git:20260809.008d046 to git:20260809.e777606
55 added, 4 removed. Audit A to A.
---
name: naiasno-video
description: Draft a Наясно explainer video and save a reviewable draft (never auto-publishes). Two formats — EXPLAINER (the default: 16:9, one accreting chart, 10–15 min for a multi-part subject or 60–120 s for a single finding, for YouTube + on-site) and SHORT (25–50 s, 9:16, a cutdown for Reels). Grounds every figure in data/, writes a conversational Bulgarian scene script with numbers spelled out for the voice track, synthesizes per-scene TTS, renders with Remotion and burns BG captions. Use when the user asks to "make a video", "create an explainer video", "turn this post into a video", "видео за <тема>", "направи видео", "запиши обяснение", to script a walkthrough of a site feature, or to turn a data finding / article / watcher report into a video.
allowed-tools:
- Read
- Grep
- Glob
- Bash
- Write
- Edit
- WebSearch
- WebFetch
---
# Наясно — explainer video composer
Turns one grounded fact (or one article) into a publish-ready Bulgarian explainer
video: a scene script, a per-scene voice track, a Remotion render, burned BG
captions, a thumbnail and a draft note. Output is a **draft for the operator to
review and publish by hand** — this skill never uploads to YouTube or Facebook.
Sibling of `naiasno-post`. Same brand, same discipline, same refusal to publish.
Plan and full rationale: `docs/plans/explainer-video-v1.md`.
## Build state — the whole chain runs
The `video/` Remotion project exists and T1 renders end to end (phase 1, 2026-08-08).
```bash
npm run video:voice -- <spec> # per-scene TTS into video/public/voiceover/
npm run video:vtt -- <spec> # .vtt sidecar + transcript
- npm run video:render -- <id> <out> # e.g. 2026-08-08-cost-per-vote--reel
+ npm run video:render -- <id> brand/videos/<slug>/yt.mp4
npm run video:check # typecheck video/ (NOT covered by `npm run build`)
npm run video:studio # Remotion Studio, for retiming by hand
npm run video:fonts # re-mirror Inter after scripts/fonts/fetch-fonts.mjs
npm run video:screens # capture real app pages (needs `npm run dev`)
npm run video:data-inflation # rebuild a spec's data layer + assert its claims
npm run video:data-risk # ditto, for the election-risk explainer
npm run video:gate1 -- <spec> # ⛔ GATE 1 — print the script + check what is checkable
```
A new video means a new spec in `video/src/specs/`, registered in
`video/src/Root.tsx` and in the `SPECS` maps of `scripts/video/synthesize.ts`,
`emit_vtt.ts` and `gate1.ts`.
**16:9 (1920×1080) is the explainer, via `Stage16x9` — its own layout, not a
rescale.** The shorts use 9:16 (1080×1920) and 4:5 (1080×1350) via `Frame`. Never
try to serve landscape by rescaling a portrait scene; see `references/publish.md`.
Measured on T1: **~2 min per cut** to render, 885 frames, ~2 MB. Render cost scales
with frame count, so budget a long-form cut at roughly its ratio — a 10-min video
is ~20× T1's frames. Synthesis scales with SCENE COUNT (one request each), and at
~50 scenes the transient-failure retries stop being theoretical.
+ ## Where everything lives
+
+ **One folder per video, `brand/videos/<slug>/`**, holding the draft, the cuts, the
+ thumbnail, the captions and the transcript. The folder already carries the slug, so
+ the files inside are named by ROLE and never repeat it:
+
+ ```
+ brand/videos/
+ index.json the registry — dup-check reads this
+ <slug>/
+ draft.md the operator's review sheet
+ yt.mp4 16:9 cut (reel.mp4 / feed.mp4 for portrait)
+ thumb.png 1280×720
+ captions.vtt YouTube sidecar
+ transcript.txt for the page under the embed
+ ```
+
+ **`brand/videos/` is gitignored in full**, exactly like `brand/posts/` and for the
+ same reason: these are drafts until an operator publishes them by hand, and a
+ 13-minute cut is ~49 MB against a repo that is already fighting a file-count ceiling.
+ Everything in there regenerates from the spec plus the voice-over.
+
+ Three things deliberately live OUTSIDE that folder, because a build step reads them
+ from a fixed place:
+
+ | Path | Why not in the video folder |
+ |---|---|
+ | `video/public/voiceover/<slug>/NN.wav` | Remotion's `staticFile()` resolves against `video/public` |
+ | `video/src/specs/<id>.ts` + `generated/<id>.json` | the composition imports them |
+ | `raw_data/video/tts_bakeoff/` | provider research, not a deliverable |
+
## Two formats — the EXPLAINER is the flagship
| | **`explainer`** ⭐ | **`short`** |
|---|---|---|
| Length | **10–15 min** (or 60–120 s for a single finding) | 25–50 s |
| Aspect | **16:9** (1920×1080) | 9:16 · also 4:5 for FB feed |
| Layout | `Stage16x9` — persistent chrome, chart + rail | `Frame` — one full-bleed visual |
| Visual | **ONE canvas that accretes** across scenes | a new visual per scene |
| Scenes | 40–60 long-form · 8–12 short-form | 4–6 |
**Length is declared per spec (`runtimeSeconds`), not inferred from `kind`**, and
gate 1 enforces the declared window. An explainer covering a topic with SEVERAL
components — an index built from five signals, a budget with eight lines — should
run long-form: at 90 s each component gets one sentence and the video becomes a
list of numbers, which is the failure the explainer format exists to fix. Reserve
the 60–120 s form for a single finding with one thread through it (E1).
**Default to `explainer`.** Decided 2026-08-08 after the shorts were judged too
simplistic: the card style is right for a thumb-scroll and wrong for a video, where
the audience has given you a minute and expects to be shown the working.
A `short` is now best understood as a **cutdown** — pick three scenes of the
explainer and re-cut them 9:16 — rather than as its own production.
### What makes an explainer not a slideshow
- **One canvas, mounted for the whole video**, which each scene only *changes* (a
series fades in, the window widens, a marker drops). Information accretes. This
forces the canvas OUTSIDE every `<Sequence>` — see `references/remotion.md`.
- **Chrome the social cards strip**: axis units, gridlines, year labels, a
benchmark line, and the dataset id on screen throughout. That is what lets a
viewer *check* the claim rather than take it.
- **Depth.** The shorts used six numbers where the dataset held 86 quarters. If the
data has history, the history is usually the story.
**Never read an article aloud.** At ~135 wpm the median BG article (~3,400 words)
is 25 minutes. An `explainer` is a *derived* script that picks one thread.
## Non-negotiable rules
Rules 1–5 are inherited from `naiasno-post` unchanged. 6–9 are video-specific.
1. **Grounded in our data.** Every figure comes from a real file under `data/` or
`public/` and links to the matching on-site page. Never invent or estimate.
2. **Confirmed against public information.** Verified against the primary register
(АОП, Сметна палата/bulnao, ЦИК, parliament.bg, data.egov.bg, Eurostat, НСИ) or a
reputable report, via WebSearch/WebFetch. URL recorded in `sources`. If it cannot
be confirmed, **do not draft** — report the discrepancy.
*Exception:* when the topic comes from an existing entry in `brand/posts/index.json`,
that post's `sources` already carry the confirmation. Reuse them and say so; do
not re-confirm what is already recorded.
3. **No duplicates.** Dup-check before composing (step 1).
4. **Non-partisan, no emojis, conversational Bulgarian.** Let the number be the
point — no adjectives, no outrage, no party-side framing. This binds the
voice-over and the on-screen text equally. Register table in step 3.
5. **Draft only.** Never upload, never post, never publish.
6. **Spoken ≡ shown.** Every scene's `voiceOver` figure must be the Bulgarian
verbalization of the same scene's `onScreen` figure. A video that narrates one
number while displaying another is the most damaging error this brand can ship.
Enforced at gate 1 by a printed side-by-side — see step 4.
7. **Every number in the voice track is spelled out in Bulgarian words.** Digits and
symbols stay on screen only. This is not a style preference; `bg-BG` has no
pronunciation override, so rewriting is the only lever on WHICH SOUNDS come out.
Pacing is a separate lever (`voice.direction`) — do not conflate them.
→ `references/voice.md`
8. **Caption everything.** Facebook autoplays muted; an uncaptioned Bulgarian voice
track reaches nobody there. Burned-in for social, `.vtt` sidecar for YouTube.
9. **Check the artefact, not the preview.** Extract frames from the rendered MP4 and
Read them before showing the operator. The Studio preview lies — and a missing
glyph renders as a silent tofu box, since nothing throws.
## Pipeline
```
topic ─▶ dup-check ─▶ ground in data/ ─▶ confirm (or reuse a post's sources)
─▶ write the scene script (BG; numbers spelled out for VO)
─▶ ⛔ GATE 1 — operator reads the script (nothing spent yet)
─▶ per-scene TTS ─▶ trim silence ─▶ render ─▶ captions ─▶ thumbnail
─▶ ⛔ GATE 2 — operator watches the render (before any publish)
- ─▶ draft in brand/videos/drafts/<slug>.md
+ ─▶ draft in brand/videos/<slug>/draft.md
```
Both gates are mandatory. Gate 1 is the cheap one and catches most errors — nothing
is synthesized or rendered until a human has read the Bulgarian.
## Step 1 — Topic, format, dup-check
Three ways in:
- **From the user's request** — a named topic, post, or article.
- **From an existing post** (preferred for the first videos): pick an entry from
`brand/posts/index.json` — its figure is already grounded *and* already confirmed,
which removes a day's work and rule 2's research.
- **From fresh data**: `data-reports/latest.md`, same vein table as `naiasno-post`
step 3.
Then dup-check. A video *of* an existing post is the intent, not a duplicate — what
must not happen is two videos of the same fact:
```bash
node_modules/.bin/tsx scripts/posts/post_tool.ts check "<entity, metric, year>"
```
Also check `brand/videos/index.json` if it exists. On high overlap with an existing
**video**, pick another angle.
## Step 2 — Ground every figure
Find the exact value and the on-site deep link. Data homes are the same table as
`naiasno-post` step 3 — read it there rather than duplicating it here.
Record per figure: exact value, dataset path, JSON path, deep link. These become the
scene's `grounding` block, and step 4 asserts against them.
**Reproduce the figure, do not copy it.** Both test videos found something by
recomputing from source rather than trusting the card:
- **T1** — the card called ПрБ the cheap pole at €0,58/vote. ГЕРБ-СДС is cheaper
(€0,54). The scene now states the ratio to ИТН, which holds.
- **T2** — three attempts before the number matched. See the trap below.
**⚠️ Entity identity across time is the trap to expect.** `partyNum` in the election
data is a **ballot position, reassigned every election** — number 1 was ДОСТ in 2024
and ИТН in 2026. Comparing it across cycles compares different parties and reported
that ~100% of municipalities changed hands. Compare on party NAME, then fold renames
(`ДПС-НН → ДПС`, `БСП → БСП-ОЛ`) or the answer is 256 instead of 236.
**Check the denominator too.** The obshtina code list contains 24 Sofia районa and 6
abroad "continents"; Bulgaria has **265** municipalities, and Sofia's winner is the
sum of its районa. A denominator that is not 265 means the filter is wrong. Assert it
in the build script and refuse to write — `build_map_t2.ts` does.
When a published card exists, matching its figure **and its breakdown** is the
strongest available check that the recomputation is right.
## Step 3 — Write the scene script
The core of the skill, and where every avoidable error lives.
For each scene write four things:
- `onScreen` — the figure as a reader sees it: digits, decimal comma, €, `млн.`/`млрд.`
- `voiceOver` — the same fact in natural spoken Bulgarian, **every number as words**
- `visual` — the renderer and its data (→ `references/scenes.md`)
- `grounding` — file + path the `onScreen` value came from
**Read `references/voice.md` before writing any `voiceOver`.** It carries the
verbalization rules (gender agreement on 1/2, count forms, ordinal years, ЕИК
digit-by-digit, acronym handling) and the reason they cannot be fixed after the fact.
`scripts/video/passage.ts` holds six worked `raw`→`spoken` pairs built from real
published facts — the canonical examples, not invented ones.
**Budget the length here, not after the render.** Measured on the chosen voice:
**13,5 chars/s bare, 11,0 chars/s with a delivery note** — and every video should
carry a note, so budget at the directed rate. Declare the window as
`runtimeSeconds` on the spec and let `video:gate1` enforce it:
| Target | `runtimeSeconds` | `voiceOver` total | scenes | per scene |
|---|---|---|---|---|
| 40 s short | `[25, 50]` | ~440 chars | 5 | ~90 |
| 90 s explainer | `[60, 120]` | ~1 000 chars | 10 | ~100 |
| **12 min explainer** | `[600, 900]` | **~7 900 chars** | 50–60 | ~135 |
Budget from the spelled-out text — it runs substantially longer than the on-screen
figure it replaces (716 vs 433 chars on the reference passage), so counting the
digits under-estimates every time.
**Set `voice.direction`.** A natural-language delivery note ("read this as a calm
documentary narrator…") is the difference between a read that sounds like a person
and one that sounds like a machine — measured at **22% slower** across E2's 59
clips, which is exactly the rushing `references/voice.md` identifies as the core
defect. E2 shipped without one for a day and the operator's first note on the
render was that it sounded AI-generated. It is not optional polish.
**Structure.** A `short` is hook → context → twist → CTA. A **90 s explainer** is
one thread through the finding. A **long-form explainer is parts, not scenes** —
name each part, and inside a part say what the thing MEASURES before you say what
it scored. The failure mode long-form removes is a video that recites numbers; the
failure mode it introduces is a video that lists sections. What prevents the second
is that each part has to *earn* its place by changing what the viewer believes.
Two things long-form buys that are worth spending scenes on, because at 90 s they
are the first casualties:
- **The confound in the same breath as the finding.** E2's concentration drop
(592 → 145 settlements) is only publishable alongside the turnout rise that
partly explains it. Three scenes: the drop, the confound, the refusal. At 90 s
this had to be cut entirely rather than shipped half-stated.
- **The lesson, not just the number.** E2 spends a whole scene on «висока оценка не
значи голямо число» — the thing a viewer needs in order to read the NEXT figure
without you.
Writing rules: no emojis; non-partisan; **conversational** Bulgarian, never a
word-for-word translation from English and never compressed newspaper register.
No share line — a video ends on the finding and the deep link. (Sharing is a
platform button; asking for it out loud reads as begging and costs a beat.)
**Conversational means the phrasing a person would use out loud**, which is not
the phrasing that fits a chart caption. Prefer the plain verb and the full noun
phrase over the compact one, and drop metaphors that read fine but sound
writerly:
| Not this | This |
|---|---|
| индексът **показва** 47 | индексът **е** 47 |
| до него **пише** «висок» | до него **е отбелязано** «висок» |
| опират в **таван** от 0,2 | са близо до **границата** от 0,2 |
| **другаде** се задейства преброяване | **в някои държави** се задейства преброяване |
| средното от петте **прави** 47 | средното от петте **е** 47 |
| седем избора са **мерени** | седем избора са **оценявани** |
| **средното им** е 54 | **средната стойност** е 54 |
| а **върхът** — 77 | а **най-голямата** — 77 |
| последният **цикъл** | последните **избори** |
+ ### Two editorial patterns worth reusing
+
+ **When the subject is an ABUSABLE number, turn it on itself.** A risk index over an
+ election is one number a reader can screenshot as proof of fraud. E2 is built so
+ nobody can use it that way — the scariest-looking component (90/100) turns out to be
+ under three thousand votes in one and a half million; the alarming label turns out to
+ be a fixed threshold that knows nothing about the past; and the one reading actually
+ maxed out is not an integrity signal at all. The video is *more* interesting for it,
+ not less, and that is the point: the defensive framing IS the story.
+
+ **Comparability across time is a claim, and usually the fragile one.** E2's index has
+ 13 elections of history but only 7 are comparable, because the headline averages the
+ AVAILABLE signals and availability changed. A mean over three signals and a mean over
+ five are different statistics wearing the same number. Whenever a video puts a figure
+ next to its own past, ask what changed in the denominator, the coverage or the method
+ — and assert the comparable set in the build script so a refresh cannot widen it
+ silently.
+
## Step 4 — ⛔ GATE 1: the operator reads the script
```bash
npm run video:gate1 -- <spec>
```
`scripts/video/gate1.ts` prints every scene as `onScreen` · numeric tokens ·
number-word spans · `voiceOver` · `grounding`, and **fails** on: a digit anywhere in
a `voiceOver` (rule 7), a shown figure that does not resolve at its `grounding`
path, a shown figure with no `grounding` block at all, a scene over the char
ceiling, and a total outside the spec's `runtimeSeconds`.
Do **not** claim to check `voiceOver` against `onScreen` mechanically — once numbers
are Bulgarian words, comparing them by machine needs the very verbalizer this design
avoids. The gate prints the two columns side by side and the **operator** signs off
on rule 6.
**Read those two columns yourself before handing over.** The recurring defect is a
scene that DISPLAYS a figure the narration never speaks — it passes every
mechanical check, because the figure is grounded and the voice track is clean; the
two just never meet. Ten of E2's fifty-nine scenes had it on first draft. Either
speak the figure or move it out of `onScreen` into `body`, where it reads as
chart detail rather than as the scene's claim.
**Stop here and wait.** Nothing is spent yet.
## Step 5 — Voice
**The voice is already decided: `gemini` · `Rasalgethi` ·
`gemini-3.1-flash-tts-preview`** (`CHOSEN_VOICE` in `scripts/video/tts_bakeoff.ts`).
Put it in the spec's `voice` block and do not vary it between videos. It needs no
GCP setup — it runs on the `GEMINI_API_KEY` already in `.env.local`.
Per scene, one clip. Not one clip for the whole video — `bg-BG` has no SSML break
tag on any provider we use, so the pause BETWEEN scenes comes from the edit, and
per-scene clips are also what the composition measures its own length from. Pauses
WITHIN a scene come from `voice.direction`, which is a different lever and the one
that was missed.
Three things that are not optional here, all learned the hard way — full detail in
`references/voice.md`:
- **Retry the transients and then assert one file per scene.** A 200 can carry no
audio, and a connection can drop mid-generation; both lose a scene's narration
while the run still reports success.
- **Trim leading/trailing silence adaptively** (`loudnorm` → `silencedetect`), or the
cuts sag.
- **Budget the script by the measured rate — 13.0 chars/s, 137 wpm.** The table is
in step 3; `video:gate1` enforces the spec's declared `runtimeSeconds`. Check it
at step 3, not after the render.
## Step 6 — Render, caption, thumbnail
Needs the `video/` Remotion project. If absent, stop after step 5 and say so.
`references/remotion.md` carries what must not be re-derived: the
`calculateMetadata` + audio-duration pattern, per-frame render stability, the
timing model, the markup gotchas, layout minimums, and why we do **not** use GSAP.
`references/publish.md` carries caption forms, thumbnail spec and aspect cuts.
## Step 7 — ⛔ GATE 2 and the draft
Extract 3–4 frames per render and **Read them** — look for tofu boxes, clipped text,
and any figure that disagrees with the script. Then write the draft:
- `brand/videos/drafts/<slug>.md` + an entry in `brand/videos/index.json`, carrying the
- `postSlug` cross-link when the finding also shipped as a card.
+ `brand/videos/<slug>/draft.md` + an entry in `brand/videos/index.json`, carrying the
+ `postSlug` cross-link when the finding also shipped as a card. Paths inside the draft
+ are relative to its own folder — the point of the layout is that "where is the MP4"
+ has one answer.
Show the operator: a frame or two, the BG script, the deep link, the sources, and
where the file is. Remind them it is unpublished.
## Spec shape
The authority is `ExplainerSpec` in `video/src/lib/spec.ts` — read it rather than
this sketch if the two disagree.
```jsonc
{
"slug": "2026-08-09-election-risk-explainer",
"kind": "explainer",
"runtimeSeconds": [600, 900], // gate 1 enforces this window
"title": "…",
"topic": "Изборен риск", // persistent header chrome
"period": "юли 2021 — април 2026 · 7 сравними избора",
"sourceLine": "Източник: ЦИК … · naiasno.bg",
"link": "https://electionsbg.com/risk-analysis?elections=2026_04_19",
"postSlug": "…", // when a card carries the same finding
"sources": ["data/…json", "https://results.cik.bg/…"],
"voice": { "provider": "gemini", "voiceId": "Rasalgethi" },
"scenes": [
{
"id": 16,
"kicker": "Защо тогава 90", // rail copy for this beat
"stat": "90",
"headline": "Границата е 0,2%",
"body": "Прагът, при който в някои държави\nсе задейства преброяване.",
"onScreen": "0,18% спрямо 0,2%", // the scene's CLAIM — must be spoken
"voiceOver": "Защо тогава деветдесет? Защото нула цяло осемнайсет процента …",
"grounding": { "file": "video/src/generated/risk.json", "path": "$.facts…" },
"canvas": { /* what this scene CHANGES about the one persistent visual */ }
}
]
}
```
`onScreen` is the scene's declared claim and gate 1 checks it both ways. `body` is
supporting detail — an exact denominator too long to say aloud belongs there, not in
`onScreen`. Scenes do not own a visual: they declare a `canvas` delta.
→ `references/scenes.md`
**Ground every figure in a generated data layer, not in `data/` directly.** A build
script (`scripts/video/build_risk.ts`, `build_inflation.ts`) recomputes the story
from source, **asserts every claim the narration makes, and refuses to write** when
one moves. Where the site already computes the figure, call the site's own function
rather than re-deriving it — a video explaining a number must not own a second
definition of that number.
## References — load on demand
Each is self-contained; load only what the current step needs.
| Load | When |
|---|---|
| [`references/voice.md`](references/voice.md) | writing any `voiceOver`, or choosing/configuring TTS |
| [`references/scenes.md`](references/scenes.md) | choosing a `visual.type`, animating a map, or scripting a walkthrough |
| [`references/remotion.md`](references/remotion.md) | writing or debugging composition code |
| [`references/publish.md`](references/publish.md) | captions, thumbnail, aspect cuts, YouTube/on-site, SEO |
## Preserve operator changes
This repo has a concurrent auto-committer and parallel sessions. If a spec,
composition or draft changed unexpectedly mid-task, assume it was deliberate —
ask before overwriting. Commit by explicit pathspec, never sweep the index.