Caption a finished video skill

Illustration · not a resultFrom the novoads/agent-skills README — shows the whole collection, not only this skill.source ↗
Burn timed, styled captions into a finished video on the local machine, without re-rendering the source.
by novoads·MIT license·★ 23 Stars on the repo·GitHub ↗
npx degit novoads/agent-skills/shared/skills/caption-video#main ~/.claude/skills/caption-videoChecked ·commit main

Illustration · not a resultFrom the novoads/agent-skills README — shows the whole collection, not only this skill.view on GitHub
Files of Caption a finished video
Show the full text157 lines
Caption a finished video
Takes a finished MP4 with speech and returns the same video with timed captions burned in. Nothing is regenerated: the source pixels and the source audio pass through untouched, and the captions are composited on top.
This skill makes no API calls. It is ffmpeg + Whisper + HyperFrames on a local file, so it costs no credits and needs no API key.
Check the API path first
POST /v1/captions is the default, and it is one call against a finished asset
with a set of preset styles and no local toolchain at all. Read the
novoads-api skill's SKILL.md (Burned-in captions) for the call, and
prompting/guide.md here for the full side by side. Offer
both paths rather than picking silently, because the API costs credits and this
skill costs a first-time setup.
REST key required. A Novoads MCP connector is not a substitute. If
NOVOADS_API_KEYis missing or still the placeholder, stop before any generation work and tell the user: "Before continuing, create an API key at https://novoads.ai/dashboard/settings?tab=api and paste it into.env." That holds even whenmcp__novoads__*tools are connected and authenticated in the session. Never callmcp__novoads__*tools from this repo's workflows: they are a different surface with different behavior, including the units they quote costs in. Repo installs verify with./scripts/check-novoads-env.sh; a solo install checksNOVOADS_API_KEYin the environment.
Pack version. Every /v1 response carries X-Novoads-Pack-Version; mention a newer pack at https://github.com/novoads/agent-skills only when that header names a version NEWER than this file's metadata.packVersion — equal or older is nothing to say, and it is never a reason to stop.
Come here instead when the user:
- wants a caption look the presets do not cover,
- has to hand-correct the words before they are burned in. Invented brand names are the usual reason: transcription mishears them, and the API gives you nowhere to fix it,
- is captioning enough footage that per-minute credits add up,
- has a line the API path burns wrong. Its renderer can truncate a word even when its own transcript is correct — see Safety rules below. There is nothing to fix on that path, so the local burn is the fix,
- or has a source rendered with
audioEnabled: false, which the API refuses with a409. This skill can still caption it if the user supplies the words.
When to use this skill
Trigger on phrases like:
- "add captions to this video" / "burn in subtitles"
- "TikTok-style captions" / "word-by-word captions"
- "caption this ad before I deploy it"
- the captions step of a video-ad pipeline, after the master is stitched
Do not use it to generate video — that's the novoads-api skill and the
style pipelines. Do not use it when the user wants a sidecar .srt/.vtt
subtitle file rather than pixels; Whisper alone covers that.
Read order
- This file — the pipeline shape, the gates, the hard-won rules.
- prompting/guide.md — the full recipe: Whisper model choice, the silence-offset transcription pattern, the word-grouping helper, the composition skeleton, the alpha composite, and the pitfall table.
- shared/references/craft.md § 3 — the trim-before-captioning doctrine step 1 below rests on, and § 1 for why the burned frames are the check rather than the transcript. Read it before you restate either rule anywhere; it holds one position per rule, and the copy is the failure mode.
Prerequisites
- ffmpeg on PATH. Homebrew's build is fine here — this skill deliberately
avoids the
subtitles/drawtextfilters, which Homebrew ships without. - Whisper —
pip install openai-whisper, or the bundlednpx hyperframes transcribe. - Node + npx for HyperFrames.
- The finished video on disk. Chat-pasted files are not accessible; ask for a real path.
Workflow
- Trim dead space first, if any. If the source has beats where VO ends and silent visual continues, tighten it before transcribing. Timestamps taken from an untightened master drift once the video is trimmed later.
- Initialize a captioning project beside the source video.
- Transcribe — pick the Whisper model by audio type (
medium.enfor the typical speech-over-music ad). Transcribe a silence-trimmed copy and offset the timestamps back; Whisper smears the first words across leading silence otherwise. The source file itself is never modified. - Group words into 3–5 word reading phrases, then eyeball
groups.json. - Render the captions over a transparent background at the source's frame rate, and composite with ffmpeg using the alpha channel.
- Verify the output: duration, resolution and frame rate match the source, audio is bit-identical, and the captions land on the words.
The guide carries the exact commands for each step.
Rules that cost a whole render to rediscover
- Composite with a real alpha channel, not a chroma key. Render
--format movand let ffmpegoverlayuse the alpha. A magenta key cannot cleanly remove a softtext-shadow— the shadow blends toward the key colour and leaves a purple halo on every glyph. Verified on a live clip 2026-08-03. - Never put
<video>or<audio>in the composition. HyperFrames wraps anyclass="clip"element in a managed timing wrapper that overrides your CSS and reserves an ~80px layout block — a hard black bar across the render. - Match the frame rate to the source. HyperFrames defaults to 30fps; Seedance
renders 24. Pass
--fps 24for Seedance output. Mismatched rates produce judder and creeping caption drift. - Match the composition size to the source resolution exactly, or the composite lands 1–2px off.
-c:a copyon the composite, always. The source's narration/BGM/ducking mix is already correct; re-encoding it is pure loss.
Safety rules
- Never overwrite the source video. Write to a new filename. The source is the only copy of an expensive render.
- Read the transcript before rendering. Whisper mis-hears brand names and
numbers. Correcting
groups.jsoncosts seconds; a wrong caption burned into a deployed ad costs a re-render. - Show the user the grouped phrases (or the first render) before treating the captions as final.
- Read the burned frames, not just the transcript. A correct transcript does
not prove a correct caption. On 2026-08-12 the API path's
hustlepreset burnedBOTT.forbottle.in every frame of a closing line while its own transcript had the word right —bottle.at 13.199–13.439s. The fault sat in the render layer, downstream of transcription, so every check that stopped at the words passed it. Compare the burned cards word for word against the approved script before shipping, and treat a truncated or garbled word as a failed run rather than a note. That take was charged and thrown away; the local burn is what shipped.
Hand-off
The captioned MP4 is a finished creative. To publish it as a Meta ad, that's the
separate meta-ad-builder skill — which opts out of Meta's frame-modifying
Advantage+ enhancements precisely because these captions are burned into the
pixels.
| 1 | |
| 2 | name caption-video |
| 3 | metadata {packVersion: 1.3.0} |
| 4 | description >- |
| 5 | Burn timed, styled captions into a finished video on the local machine, without |
| 6 | re-rendering the source. This is the manual fallback to the API's captioning |
| 7 | endpoint, POST /v1/captions, which stays the default path: come here when the |
| 8 | style the user wants is outside the API's presets, when the wording has to be |
| 9 | hand-corrected before it is burned into the pixels, or when the source was |
| 10 | rendered with audio disabled and the API refuses it. Transcribes the audio with |
| 11 | Whisper, groups words into reading phrases, renders the captions in a real |
| 12 | browser via HyperFrames, and composites them over the source with ffmpeg using |
| 13 | a real alpha channel. Use when the user has an MP4 with speech and asks to add |
| 14 | captions, subtitles, burned-in text, TikTok-style captions or word-by-word text |
| 15 | to it, and the API path has been ruled out. Runs out of band, so it costs no |
| 16 | credits and works on any video file whoever generated it. Not for generating |
| 17 | video (use the video skills) and not for a separate .srt/.vtt sidecar file. |
| 18 | |
| 19 | |
| 20 | # Caption a finished video |
| 21 | |
| 22 | Takes a finished MP4 with speech and returns the same video with timed captions |
| 23 | burned in. Nothing is regenerated: the source pixels and the source audio pass |
| 24 | through untouched, and the captions are composited on top. |
| 25 | |
| 26 | **This skill makes no API calls.** It is ffmpeg + Whisper + HyperFrames on a |
| 27 | local file, so it costs no credits and needs no API key. |
| 28 | |
| 29 | ## Check the API path first |
| 30 | |
| 31 | `POST /v1/captions` is the default, and it is one call against a finished asset |
| 32 | with a set of preset styles and no local toolchain at all. Read the |
| 33 | `novoads-api` skill's SKILL.md (*Burned-in captions*) for the call, and |
| 34 | [prompting/guide.md] here for the full side by side. **Offer |
| 35 | both paths rather than picking silently**, because the API costs credits and this |
| 36 | skill costs a first-time setup. |
| 37 | |
| 38 | > **REST key required. A Novoads MCP connector is not a substitute.** If |
| 39 | > `NOVOADS_API_KEY` is missing or still the placeholder, stop before any |
| 40 | > generation work and tell the user: "Before continuing, create an API key at |
| 41 | > <https://novoads.ai/dashboard/settings?tab=api> and paste it into `.env`." |
| 42 | > That holds even when `mcp__novoads__*` tools are connected and authenticated in |
| 43 | > the session. Never call `mcp__novoads__*` tools from this repo's workflows: they |
| 44 | > are a different surface with different behavior, including the units they quote |
| 45 | > costs in. Repo installs verify with `./scripts/check-novoads-env.sh`; a solo |
| 46 | > install checks `NOVOADS_API_KEY` in the environment. |
| 47 | |
| 48 | **Pack version.** Every `/v1` response carries `X-Novoads-Pack-Version`; mention a newer pack at <https://github.com/novoads/agent-skills> only when that header names a version NEWER than this file's `metadata.packVersion` — equal or older is nothing to say, and it is never a reason to stop. |
| 49 | |
| 50 | Come here instead when the user: |
| 51 | |
| 52 | wants a caption look the presets do not cover, |
| 53 | has to hand-correct the words before they are burned in. Invented brand names |
| 54 | are the usual reason: transcription mishears them, and the API gives you |
| 55 | nowhere to fix it, |
| 56 | is captioning enough footage that per-minute credits add up, |
| 57 | has a line the API path burns **wrong**. Its renderer can truncate a word even |
| 58 | when its own transcript is correct — see *Safety rules* below. There is nothing |
| 59 | to fix on that path, so the local burn is the fix, |
| 60 | or has a source rendered with `audioEnabled: false`, which the API refuses with |
| 61 | a `409`. This skill can still caption it if the user supplies the words. |
| 62 | |
| 63 | ## When to use this skill |
| 64 | |
| 65 | Trigger on phrases like: |
| 66 | "add captions to this video" / "burn in subtitles" |
| 67 | "TikTok-style captions" / "word-by-word captions" |
| 68 | "caption this ad before I deploy it" |
| 69 | the captions step of a video-ad pipeline, after the master is stitched |
| 70 | |
| 71 | Do **not** use it to *generate* video — that's the `novoads-api` skill and the |
| 72 | style pipelines. Do not use it when the user wants a sidecar `.srt`/`.vtt` |
| 73 | subtitle file rather than pixels; Whisper alone covers that. |
| 74 | |
| 75 | ## Read order |
| 76 | |
| 77 | **This file** — the pipeline shape, the gates, the hard-won rules. |
| 78 | **[prompting/guide.md]** — the full recipe: Whisper model |
| 79 | choice, the silence-offset transcription pattern, the word-grouping helper, |
| 80 | the composition skeleton, the alpha composite, and the pitfall table. |
| 81 | **[shared/references/craft.md]** § 3 — the |
| 82 | trim-before-captioning doctrine step 1 below rests on, and § 1 for why the |
| 83 | burned frames are the check rather than the transcript. Read it before you |
| 84 | restate either rule anywhere; it holds one position per rule, and the copy is |
| 85 | the failure mode. |
| 86 | |
| 87 | ## Prerequisites |
| 88 | |
| 89 | **ffmpeg** on PATH. Homebrew's build is fine here — this skill deliberately |
| 90 | avoids the `subtitles`/`drawtext` filters, which Homebrew ships without. |
| 91 | **Whisper** — `pip install openai-whisper`, or the bundled |
| 92 | `npx hyperframes transcribe`. |
| 93 | **Node + npx** for HyperFrames. |
| 94 | **The finished video on disk.** Chat-pasted files are not accessible; ask for |
| 95 | a real path. |
| 96 | |
| 97 | ## Workflow |
| 98 | |
| 99 | **Trim dead space first, if any.** If the source has beats where VO ends and |
| 100 | silent visual continues, tighten it *before* transcribing. Timestamps taken |
| 101 | from an untightened master drift once the video is trimmed later. |
| 102 | **Initialize** a captioning project beside the source video. |
| 103 | **Transcribe** — pick the Whisper model by audio type (`medium.en` for the |
| 104 | typical speech-over-music ad). **Transcribe a silence-trimmed copy and offset |
| 105 | the timestamps back**; Whisper smears the first words across leading silence |
| 106 | otherwise. The source file itself is never modified. |
| 107 | **Group** words into 3–5 word reading phrases, then eyeball `groups.json`. |
| 108 | **Render** the captions over a transparent background at the source's frame |
| 109 | rate, and **composite** with ffmpeg using the alpha channel. |
| 110 | **Verify** the output: duration, resolution and frame rate match the source, |
| 111 | audio is bit-identical, and the captions land on the words. |
| 112 | |
| 113 | The guide carries the exact commands for each step. |
| 114 | |
| 115 | ## Rules that cost a whole render to rediscover |
| 116 | |
| 117 | **Composite with a real alpha channel, not a chroma key.** Render |
| 118 | `--format mov` and let ffmpeg `overlay` use the alpha. A magenta key cannot |
| 119 | cleanly remove a soft `text-shadow` — the shadow blends toward the key colour |
| 120 | and leaves a purple halo on every glyph. Verified on a live clip 2026-08-03. |
| 121 | **Never put `<video>` or `<audio>` in the composition.** HyperFrames wraps any |
| 122 | `class="clip"` element in a managed timing wrapper that overrides your CSS and |
| 123 | reserves an ~80px layout block — a hard black bar across the render. |
| 124 | **Match the frame rate to the source.** HyperFrames defaults to 30fps; Seedance |
| 125 | renders 24. Pass `--fps 24` for Seedance output. Mismatched rates produce |
| 126 | judder and creeping caption drift. |
| 127 | **Match the composition size to the source resolution** exactly, or the |
| 128 | composite lands 1–2px off. |
| 129 | **`-c:a copy`** on the composite, always. The source's narration/BGM/ducking |
| 130 | mix is already correct; re-encoding it is pure loss. |
| 131 | |
| 132 | ## Safety rules |
| 133 | |
| 134 | **Never overwrite the source video.** Write to a new filename. The source is |
| 135 | the only copy of an expensive render. |
| 136 | **Read the transcript before rendering.** Whisper mis-hears brand names and |
| 137 | numbers. Correcting `groups.json` costs seconds; a wrong caption burned into a |
| 138 | deployed ad costs a re-render. |
| 139 | **Show the user the grouped phrases** (or the first render) before treating the |
| 140 | captions as final. |
| 141 | **Read the burned frames, not just the transcript.** A correct transcript does |
| 142 | not prove a correct caption. On 2026-08-12 the API path's `hustle` preset burned |
| 143 | `BOTT.` for `bottle.` in every frame of a closing line while its own transcript |
| 144 | had the word right — `bottle.` at 13.199–13.439s. The fault sat in the render |
| 145 | layer, downstream of transcription, so every check that stopped at the words |
| 146 | passed it. Compare the burned cards **word for word against the approved |
| 147 | script** before shipping, and treat a truncated or garbled word as a failed run |
| 148 | rather than a note. That take was charged and thrown away; the local burn is |
| 149 | what shipped. |
| 150 | |
| 151 | ## Hand-off |
| 152 | |
| 153 | The captioned MP4 is a finished creative. To publish it as a Meta ad, that's the |
| 154 | separate `meta-ad-builder` skill — which opts out of Meta's frame-modifying |
| 155 | Advantage+ enhancements precisely because these captions are burned into the |
| 156 | pixels. |
| 157 |
Discussion
Alternatives
Browse more free Claude skills or everything in Development.


