naiasno-video · git:20260809.008d046 · 2026-08-09 · sha256 0d6b9f000127bb1b

naiasno-video git:20260809.008d046A

Immutable. This exact content never changes and is served at /api/v1/blob/0d6b9f000127bb1b.

---
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: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.

## 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
```

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 |
| последният **цикъл** | последните **избори** |

## 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.

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.