video-to-shorts · v2.0.0 · 2026-09-15 · sha256 6fb10f459dceb730
video-to-shorts v2.0.0A
Immutable. This exact content is served forever at /api/v1/blob/6fb10f459dceb730.
---
name: video-to-shorts
description: Use when turning a long video (interview, podcast, talk, lecture, webinar) into short vertical clips for YouTube Shorts / Reels / TikTok. Transcribes with word timings, scores the most engaging self-contained moments, then renders each short from a JSON edit list - hook first, a cut to whoever speaks or reacts, punch-ins, tightened pauses, word-timed captions with the active word highlighted, a name lower third, a held ending that fades to a logo end card with a ding, speed variants at -14 LUFS. Handles two speakers on one wide shot (mic-channel attribution). Cross-platform (macOS / Linux / Windows-WSL). Stops at a finished, upload-ready vertical mp4 - no account or upload CLI required.
user-invocable: true
argument-hint: "[path or URL to the long video, optional: a topic/quote to clip]"
metadata:
version: "2.0.0"
---
# Video → Shorts
Turn a long talking-head video into vertical shorts (9:16, 25-45 s, captions in the
picture). The skill transcribes the source, **scores the best moments**, plans an edit for
the moment you pick, and renders it with `scripts/build-short.py`.
The deliverable is a finished mp4, ready to upload by hand, in a few variants (speed and
caption style) so the owner can choose. No YouTube account, API key or upload CLI needed.
**v2 changed the approach.** v1 made a trimmed, cropped copy of the source with an SRT
burned in. A professional reviewer called that flat. v2 edits for retention: hook first,
cuts to each speaker and to reactions, captions from word timings, a held ending and an
end card. Read `references/retention-edit.md` before you plan a cut. For two people on
one wide shot, also read `references/two-speaker.md`.
## When to use
- "Make shorts/reels/clips out of this podcast / interview / talk"
- "Find the best moments in this video and cut vertical clips"
- "Make this short more lively / add captions / add an end card"
- Any long-form talking footage that needs to become short vertical content
Best for **static one- or two-person talking footage**. Fast-cut montages, music videos
or footage with a lot of on-screen graphics need a human editor. Say so and stop.
## One-time setup (cross-platform)
You need **ffmpeg with libass** (captions and text), **Whisper with word timestamps**
(`faster-whisper` or `stable-ts`), **Python 3**, and optionally **yt-dlp** (only if the
source is a URL). The renderer also needs a display font and a mono font as files
(defaults: Anton and IBM Plex Mono Medium, both OFL, from Google Fonts).
### ffmpeg — must include libass
Check first: `ffmpeg -version | grep -o libass`. If it prints `libass`, you're done.
| OS | Install |
|----|---------|
| **Linux** | `sudo apt install ffmpeg` (Debian/Ubuntu builds include libass), or `brew install ffmpeg` on Linuxbrew. |
| **Windows (WSL2)** | Inside Ubuntu-on-WSL: `sudo apt install ffmpeg`. Do everything from the WSL shell, not PowerShell. |
| **macOS** | ⚠️ Default Homebrew `ffmpeg` **omits libass**. Text filters fail with *"No option name near …"*. Install a static build to `~/.local/bin`: |
```bash
# macOS only — libass-enabled static ffmpeg
mkdir -p ~/.local/bin
curl -L https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip -o /tmp/ff.zip
unzip -o /tmp/ff.zip -d ~/.local/bin/
chmod +x ~/.local/bin/ffmpeg
~/.local/bin/ffmpeg -version | grep -E "libass|libfreetype" # both must appear
```
`build-short.py` uses `$FFMPEG` if set, then `~/.local/bin/ffmpeg`, then `ffmpeg` on the
PATH. It draws all text with libass (`ass=` filter), not `drawtext`, so a build without
drawtext works.
### Whisper — transcription with word timestamps
| OS / hardware | Recommended | Install |
|---------------|-------------|---------|
| **macOS (Apple Silicon)** | `mlx-whisper` (GPU via MLX) | `pip install mlx-whisper` |
| **Linux + NVIDIA** | `faster-whisper` (CUDA) | `pip install faster-whisper` |
| **Any (CPU)** | `faster-whisper` with `device="cpu", compute_type="int8"` | `pip install faster-whisper` |
| **Best word timings** | `stable-ts` (realigns words to the waveform) | `pip install stable-ts` |
Models download on first run (English `base` ≈ 140 MB, `medium.en` ≈ 1.5 GB, `large-v3`
≈ 3 GB). Use a venv. If faster-whisper on CUDA fails with `libcublas.so.12 not found`,
use the CPU settings above; they are fast enough for a clip.
### yt-dlp (only if source is a URL)
`pip install -U yt-dlp`. For unlisted or age-gated videos add `--cookies-from-browser chrome`.
## Workflow
### 1. Get the source video
- Local file → use it directly. Use the highest-resolution original you have (a 4K source
gives room for punch-ins). If the footage is log (D-Log, S-Log, V-Log), find its LUT;
the renderer applies it after the crop.
- URL → download:
```bash
yt-dlp -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]" "<URL>" -o "/tmp/source.mp4"
```
- Note the resolution and frame rate (`ffprobe -v error -select_streams v -show_entries
stream=width,height,r_frame_rate -of csv=p=0 source.mp4`). You need both for the crops
and the spec.
### 2. Transcribe the whole source, with word timings
The captions, the cut points and the speaker labels all come from word timings, so get
them once for the whole source:
```python
from faster_whisper import WhisperModel
import json
m = WhisperModel("medium.en", device="cuda", compute_type="float16") # or cpu/int8
segs, _ = m.transcribe("source.mp4", word_timestamps=True, vad_filter=True,
condition_on_previous_text=False)
json.dump({"segments": [{"start": s.start, "end": s.end, "text": s.text,
"words": [{"start": w.start, "end": w.end, "word": w.word} for w in s.words]}
for s in segs]}, open("words.json", "w"))
```
`stable-ts` writes the same shape with `result.save_as_json("words.json")`. Also write an
SRT for reading (`mlx_whisper … --output-format srt`, or build it from the segments).
**Quick clean-up:** Whisper mangles proper nouns and invents text over intro music. Do not
edit `words.json`. Record fixes in the edit list (`replace` for a misheard word, `patch`
for missing words), so the timings stay true.
### 3. Score the moments ← the core value
Read the full transcript and score each candidate with the rubric in
`references/retention-edit.md` §1: hook 0.30, standalone 0.25, emotion 0.20, value 0.15,
payoff 0.10. Skip anything under 60. Present the top 5-8 as a table:
| # | In–Out | Dur | Score | Hook (first line) | Payoff (last line) |
|---|--------|-----|-------|-------------------|--------------------|
| 1 | 20:05–20:54 | 41s | 78 | "I think I might have a past life in China." | the Jesuit joke |
**Never use chapter markers as cut points.** A chapter marks where a topic starts,
preamble included. Anchor in and out to the words where the line begins and ends.
If the user gave a topic or a quote, find that moment instead (or as well).
### 4. Frame each person — never guess the crop
Crop width = source height × 9 ÷ 16, rounded to an **even** number. Every crop number
must be even (libx264 with yuv420p rejects odd values).
| Source | Base crop | Punch-in (about 1.2x) |
|--------|-----------|-----------------------|
| 1920×1080 | 608×1080 | 506×900, y = 180 |
| 3840×2160 (4K) | 1216×2160 | 1012×1800, y = 360 |
1. Pull a frame from the middle of the moment and find each person's side of the frame.
2. For each person, render 3-5 candidate offsets within their side as one strip, at 4-6
times across the moment (people lean). Show the strip and let the user pick.
3. Give each person a name in the spec: `C` and `C+` (punch-in) for the guest, `A` and `A+`
for the host. Anchor the punch-in to the bottom of the base crop so the face grows
without moving up.
Details and the strip command: `references/two-speaker.md` § Framing.
### 5. Find who speaks when (two or more people)
A frame shows who is on screen, not who is talking. Take attribution from the audio: with
two lavalier mics on L and R, each speaker is 2-7 dB louder on their own channel. Label
every word. Then transcribe each channel on its own around any overlap, to recover short
replies that the mixed transcript dropped. Method and code: `references/two-speaker.md`.
### 6. Plan the edit
Write the plan as `<slug>.edl.json` (schema in the header of `scripts/build-short.py`).
Follow `references/retention-edit.md`:
1. **Hook first.** If the best line comes later, move it to the front as a cold open and
cut it from its original place.
2. **A cut to every speaker**, one-word interjections included.
3. **Reaction shots** of 1.5-2 s on the listener, only where the frames show a real smile
or laugh. The audio stays with the speaker.
4. **Punch-ins** at sentence boundaries, and over any jump where you trimmed a pause.
5. **Tighten:** trim pauses over 0.7 s to 0.3-0.5 s; cut repeats and false starts at word
gaps.
6. **Hold the end** 0.7-1.0 s after the payoff before the fade. Check the last frames for
a blink or an open mouth.
7. **Cut in quiet.** Each in and out point goes in a gap between words, at the quietest
point, not at the transcript's word boundary. Measure the RMS level on a 16 kHz mono
copy and move each point to the minimum inside the gap.
A minimal spec:
```json
{
"slug": "past-life-in-china",
"raw": "source.mp4", "words": "words.json", "fonts": "fonts/",
"crops": {"C": [200, 0, 1216, 2160], "C+": [240, 360, 1012, 1800],
"A": [2200, 0, 1216, 2160]},
"shots": [[1240.45, 1244.95, "C+"], [1205.25, 1209.95, "C"],
[1220.70, 1222.45, "A"], [1250.90, 1253.80, "C"]],
"replace": {"infinity": "affinity"},
"lower_third": {"name": "GUEST NAME", "sub": "Job title · Company", "at": 4.7, "dur": 3.6},
"endcard": {"logo": "logo.png", "cta": "SUBSCRIBE", "url": "example.com", "dur": 2.8},
"variants": {"hl-100": {"speed": 1.0, "captions": "highlight"},
"hl-115": {"speed": 1.15, "captions": "highlight"},
"hl-125": {"speed": 1.25, "captions": "highlight"},
"phrase-115": {"speed": 1.15, "captions": "phrase"},
"word-115": {"speed": 1.15, "captions": "word"}}
}
```
Add `"audio"` when the sound comes from a separate master on the same timeline, and
`"lut"` for log footage. Put the lower third where it does not cover the face in that
shot (it sits at the bottom left). Keep it off the hook.
### 7. Render
```bash
python3 skills/video-to-shorts/scripts/build-short.py past-life-in-china.edl.json # all variants
python3 skills/video-to-shorts/scripts/build-short.py past-life-in-china.edl.json hl-115 # one
```
The script cuts each shot from the source (crop, scale to 1080×1920, LUT), joins them,
measures the loudness, and makes the end card. For each variant it then does one pass:
speed-up with `atempo`, captions and lower third through libass, gain to -14 LUFS with a
limiter, and a fade to black. Then it appends the end card. It writes an SRT per variant
beside the `.ass` in the work folder.
Shots are cached by frame range and crop. After you change captions, the lower third or
the variants, a re-run costs one pass per variant. Delete `endcard.mov` to rebuild the
end card.
### 8. Verify, then let the owner pick
- Pull frames at the hook, at the lower third, at a caption with a highlight, and on the
end card (`ffmpeg -ss N -i out.mp4 -frames:v 1 check_N.png`). Look for text on a face,
text that overflows, and a cut in the middle of a word.
- Check the loudness: `ffmpeg -i out.mp4 -af ebur128=peak=true -f null -` should report
about -14 LUFS and a true peak under -1 dBFS.
- Watch the whole short once, as a stranger would (`references/retention-edit.md` § Final
check).
- Send the variants. Ask someone who has **not** heard the speaker before to choose the
speed.
## Output conventions
For each short, keep:
- `<slug>.edl.json` — the edit list; the short can be rebuilt from it and the source
- `<slug>-<variant>.mp4` — the renders; the chosen one is the deliverable
- `<workdir>/<variant>.srt` — the captions as a separate track, if the platform wants one
- `<slug>.md` — notes: source, score, what each shot is and why
`slug` = kebab-case summary of the moment, e.g. `past-life-in-china`.
## Common mistakes
| Mistake | Do instead |
|---------|------------|
| One locked crop on the guest while the host talks | Cut to each speaker, even for one word (step 6) |
| Deciding who speaks from a video frame | Use the mic channels (step 5) |
| Captions from cleaned-up sentences | Captions from word timings; fix only misheard words |
| Cutting on the last syllable | Hold 0.7-1.0 s, then fade; end card after |
| Starting on the setup | Move the hook to the front as a cold open |
| Using chapter timestamps as cut points | Anchor to the words where the line starts |
| Guessing the crop position | Show a strip of 3-5 offsets at several times; let the user pick |
| Odd crop numbers (e.g. 405) | Round to **even**; libx264 errors or shifts a pixel otherwise |
| Trusting `Fontsize` for the text height | Measure a frame; Anton at 170 gives about 80 px capitals |
| Burning text with default macOS Homebrew ffmpeg | Use the libass static build (`~/.local/bin/ffmpeg`) |
| Choosing the speed yourself | Render 1.0 / 1.15 / 1.25x; a stranger to the voice picks |
| Short over 60 s | Trim to 60 s at most; 25-45 s is best |