# Burn Subtitles

> Burn karaoke-style word-timed subtitles into a video. Takes a transcript JSON with word timestamps and a video, emits a new video with subtitles rendered in. Renders text as a PNG overlay sequence (the local ffmpeg build has no libass/drawtext).

- Skill: `jperrello/burn-subtitles` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add jperrello/burn-subtitles`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jperrello/burn-subtitles/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: jperrello (https://skillmd.com/u/jperrello)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jperrello/burn-subtitles

---


# burn-subtitles

Word-timed subtitle burn-in for shorts. Highlights the current word, no manual sync.

## Invoke

```
.claude/skills/burn-subtitles/burn-subtitles.sh <input> <transcript.json> <out> [style] [font_size]
```

- `input`: video path
- `transcript`: path to JSON. For `style=chunks` (default) this is a `chunks.json` from the `chunk-captions` skill. For other styles it's a `transcribe` output JSON.
- `out`: output video path
- `style` (optional): `chunks` (default) | `line` | `word-karaoke` | `selective`
- `font_size` (optional): default `72`

## Output

mp4 with subtitles burned in. Same dimensions, audio stream-copied. Prints the
output path to stdout. Idempotent: skips work if `out` is newer than both inputs.

## Styles

- `chunks` (default): consumes a `chunks.json` from the `chunk-captions`
  skill. Each chunk is shown as a complete phrase for its `[t0, t1]` window;
  the currently-spoken word is rendered cyan, all other words white. Chunks
  hard-cut between each other — no scrolling, no fade. Kills the per-word
  slide-up that made the legacy `word-karaoke` style hard to read.
- `line`: one plain line per transcript segment.
- `word-karaoke` (legacy): rolling window of up to 4 words, tied to word
  timestamps. Each word slides up 8px over 80ms at its own `t0` and hard-cuts
  off when bumped or after a >0.6s speech gap. Deprecated for shorts —
  feedback showed the constant scroll was hard to follow.
- `selective`: only burns segments overlapping high-RMS-energy seconds.

## Brand preset (deliberate, not default)

This is the project's caption identity — keep it consistent:

- **Font**: Impact (`/System/Library/Fonts/Supplemental/Impact.ttf`) — chunky
  condensed sans, distinct from the CapCut/Submagic/Opus default of Arial.
- **Accent (active word)**: Sapphire Glow `#2E6BFF` — the C0BALT CUT brand
  accent (brand/BRAND.md), chosen against the ubiquitous MrBeast-yellow
  `#FFD633`.
- **Context (earlier visible words)**: off-white `#F5F5F0`.
- **Position**: upper-third (top of first line at ~22% of frame height) — not
  the default lower-third.
- **Animation**: 8px slide-up over 80ms (linear) on entry; hard-cut on exit.
  No ease-out-back pop, no scale animation.
- **Stroke**: thick black outline (font_size / 10), for legibility over any
  background without falling back to white-on-black bars.

## How

The local ffmpeg build ships without `libass`, `subtitles`, or `drawtext`
filters. So `burn_subtitles.py` renders a transparent PNG per video frame with
PIL using the brand preset above, hardlinking duplicate frames for speed.
`burn-subtitles.sh` then composites the sequence onto the source with ffmpeg's
`overlay` filter and re-encodes video (libx264 veryfast crf18); audio is copied.

## Status

Works. Tested on a 6.7s 720x1280 clip (say-synthesized speech, 19 words):
- word-karaoke: 20 unique states, frame at t=3.2s shows 16130 white + 5616
  accent (highlighted-word) pixels in the lower third.
- line: 27049 white pixels, 0 accent. Dimensions and duration preserved,
  audio stream-copied (aac).

