1 # Short-Form Video Clips (Opus Clips Style)
2
3 Extract, trim, reformat, and caption short-form video clips from podcast episodes for YouTube Shorts, TikTok, and Instagram Reels.
4
5 ## Table of Contents
6
7 - [Overview](#overview)
8 - [Clip Selection](#clip-selection)
9
10 - [Video Download](#video-download)
11 - [Trimming and Reformatting](#trimming-and-reformatting)
12
13 - [Animated Captions](#animated-captions)
14 - [Progress Bar and Lower Thirds](#progress-bar-and-lower-thirds)
15
16 - [Full Pipeline Script](#full-pipeline-script)
17 - [Audio-Only Fallback](#audio-only-fallback)
18
19 ## Overview
20
21 The goal is to replicate the Opus Clips workflow:
22
23 1. Identify the 3-5 most engaging 30-90 second moments from an episode
24 2. Trim those segments from the source video
25
26 3. Reformat to 9:16 vertical (1080x1920)
27 4. Add word-by-word animated captions
28
29 5. Add a progress bar and speaker identification
30
31 ### Requirements
32
33 - **Tier 3 (basic clips):** ffmpeg, Python 3.10+, yt-dlp, source video (YouTube download or local file). Produces trimmed, vertical-reformatted clips with static captions and progress bar.
34 - **Tier 4 (animated captions):** All of Tier 3 + OpenAI API key (for Whisper word-level timestamps). Produces Opus Clips-style word-by-word highlighted captions.
35
36 When Tier 4 is unavailable, the pipeline gracefully falls back to segment-level static captions using the transcript data. The clips are still high-quality — they just don't have the word-highlighting animation.
37
38 ## Clip Selection
39
40 Use the scored content atoms to identify clip-worthy moments. The best clips come from:
41
42 1. **Controversial takes** (highest engagement) — moments where the guest says something polarizing
43 2. **Stories with payoffs** — narrative arcs with a clear setup → surprise → resolution
44
45 3. **Data points with reactions** — a surprising stat followed by the host's genuine reaction
46 4. **Frameworks** — step-by-step advice that viewers can screenshot
47
48 ### Selection Criteria
49
50 - Must have timestamps (start and end) from the transcript
51 - Duration: 30-90 seconds (ideal: 45-60 seconds)
52
53 - Must start with a strong hook (first 3 seconds determine if viewer stays)
54 - Must end on a complete thought (no mid-sentence cuts)
55
56 - Prefer segments where the speaker is animated or passionate
57
58 ### Adjusting Clip Boundaries
59
60 Raw atom timestamps often need adjustment:
61
62 - **Start**: Rewind 1-2 seconds before the atom starts to catch the speaker's intake breath or lead-in word
63 - **End**: Extend 1-2 seconds after the atom ends for a natural pause, or cut right after the punchline for impact
64
65 - **Avoid**: Starting mid-sentence, ending on filler words, cutting during laughter
66
67 ## Video Download
68
69 For YouTube sources, download the video (not just audio) using yt-dlp:
70
71 ```python
72
73 import yt_dlp
74
75 import os
76
77 def download_youtube_video(video_id: str, output_dir: str = "/tmp") -> str:
78
79 """Download YouTube video in a format suitable for clip extraction."""
80
81 os.makedirs(output_dir, exist_ok=True)
82
83 ydl_opts = {
84
85 "format": "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080][ext=mp4]",
86
87 "outtmpl": os.path.join(output_dir, f"{video_id}.%(ext)s"),
88
89 "merge_output_format": "mp4",
90
91 "quiet": True,
92
93 "no_warnings": True,
94
95 }
96
97 with yt_dlp.YoutubeDL(ydl_opts) as ydl:
98
99 ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
100
101 return os.path.join(output_dir, f"{video_id}.mp4")
102
103 ```
104
105 ## Trimming and Reformatting
106
107 ### Trim a Clip
108
109 ```python
110
111 import subprocess
112
113 def trim_clip(
114
115 input_path: str,
116
117 output_path: str,
118
119 start_seconds: float,
120
121 end_seconds: float,
122
123 fade_duration: float = 0.5,
124
125 ) -> str:
126
127 """Trim a segment from the source video with fade in/out."""
128
129 duration = end_seconds - start_seconds
130
131 cmd = [
132
133 "ffmpeg", "-y",
134
135 "-ss", str(start_seconds),
136
137 "-i", input_path,
138
139 "-t", str(duration),
140
141 "-vf", f"fade=t=in:st=0:d={fade_duration},fade=t=out:st={duration - fade_duration}:d={fade_duration}",
142
143 "-af", f"afade=t=in:st=0:d={fade_duration},afade=t=out:st={duration - fade_duration}:d={fade_duration}",
144
145 "-c:v", "libx264",
146
147 "-preset", "fast",
148
149 "-crf", "23",
150
151 "-c:a", "aac",
152
153 "-b:a", "128k",
154
155 output_path,
156
157 ]
158
159 subprocess.run(cmd, check=True, capture_output=True)
160
161 return output_path
162
163 ```
164
165 ### Reformat to Vertical (9:16)
166
167 ```python
168
169 def reformat_vertical(
170
171 input_path: str,
172
173 output_path: str,
174
175 width: int = 1080,
176
177 height: int = 1920,
178
179 ) -> str:
180
181 """Reformat video to 9:16 vertical with center crop.
182
183 For podcast videos (usually 16:9 talking heads), center-crop
184
185 focuses on the speaker. For side-by-side layouts, this may need
186
187 manual adjustment.
188
189 """
190
191 cmd = [
192
193 "ffmpeg", "-y",
194
195 "-i", input_path,
196
197 "-vf", f"crop=ih*{width}/{height}:ih,scale={width}:{height}",
198
199 "-c:v", "libx264",
200
201 "-preset", "fast",
202
203 "-crf", "23",
204
205 "-c:a", "copy",
206
207 output_path,
208
209 ]
210
211 subprocess.run(cmd, check=True, capture_output=True)
212
213 return output_path
214
215 ```
216
217 ## Animated Captions
218
219 The signature Opus Clips look: word-by-word text that highlights the current word as it's spoken. This requires word-level timestamps from the transcript.
220
221 ### Getting Word-Level Timestamps
222
223 If the transcript only has segment-level timestamps, re-transcribe the clip with Whisper for word-level timing:
224
225 ```python
226
227 from openai import OpenAI
228
229 import os
230
231 def get_word_timestamps(audio_path: str) -> list[dict]:
232
233 """Get word-level timestamps using Whisper."""
234
235 client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
236
237 with open(audio_path, "rb") as f:
238
239 result = client.audio.transcriptions.create(
240
241 model="whisper-1",
242
243 file=f,
244
245 response_format="verbose_json",
246
247 timestamp_granularities=["word"],
248
249 )
250
251 return [
252
253 {"word": w.word.strip(), "start": w.start, "end": w.end}
254
255 for w in result.words
256
257 ]
258
259 ```
260
261 ### Generating Caption Frames
262
263 ```python
264
265 from PIL import Image, ImageDraw, ImageFont
266
267 import os
268
269 def generate_caption_frame(
270
271 words: list[dict],
272
273 current_time: float,
274
275 width: int = 1080,
276
277 height: int = 1920,
278
279 words_per_group: int = 5,
280
281 font_size: int = 64,
282
283 highlight_color: str = "#FFD700",
284
285 normal_color: str = "#FFFFFF",
286
287 shadow_color: str = "#000000",
288
289 bg_color: str = None,
290
291 ) -> Image.Image:
292
293 """Generate a single caption frame with the current word highlighted."""
294
295 img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
296
297 draw = ImageDraw.Draw(img)
298
299 try:
300
301 font = ImageFont.truetype(
302
303 "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size
304
305 )
306
307 except OSError:
308
309 font = ImageFont.load_default()
310
311 active_idx = None
312
313 for i, w in enumerate(words):
314
315 if w["start"] <= current_time < w["end"]:
316
317 active_idx = i
318
319 break
320
321 if active_idx is None:
322
323 return img
324
325 group_start = (active_idx // words_per_group) * words_per_group
326
327 group_end = min(group_start + words_per_group, len(words))
328
329 group_words = words[group_start:group_end]
330
331 caption_y = int(height * 0.70)
332
333 margin = 60
334
335 line_text = " ".join(w["word"] for w in group_words)
336
337 bbox = draw.textbbox((0, 0), line_text, font=font)
338
339 text_width = bbox[2] - bbox[0]
340
341 if text_width > width - 2 * margin:
342
343 mid = len(group_words) // 2
344
345 lines = [
346
347 group_words[:mid],
348
349 group_words[mid:],
350
351 ]
352
353 else:
354
355 lines = [group_words]
356
357 for line_idx, line_words in enumerate(lines):
358
359 x = margin
360
361 y = caption_y + line_idx * (font_size + 16)
362
363 full_line = " ".join(w["word"] for w in line_words)
364
365 bbox = draw.textbbox((0, 0), full_line, font=font)
366
367 line_width = bbox[2] - bbox[0]
368
369 x = (width - line_width) // 2
370
371 for w in line_words:
372
373 is_active = (
374
375 group_start + sum(len(l) for l in lines[:line_idx])
376
377 \+ line_words.index(w)
378
379 == active_idx
380
381 )
382
383 color = highlight_color if is_active else normal_color
384
385 draw.text((x + 2, y + 2), w["word"], fill=shadow_color, font=font)
386
387 draw.text((x, y), w["word"], fill=color, font=font)
388
389 word_bbox = draw.textbbox((0, 0), w["word"] + " ", font=font)
390
391 x += word_bbox[2] - word_bbox[0]
392
393 return img
394
395 ```
396
397 ### Burning Captions onto Video
398
399 Use ffmpeg to composite the caption frames onto the video. For efficiency, generate captions as an ASS subtitle file with styled formatting rather than frame-by-frame image overlay:
400
401 ```python
402
403 def generate_ass_subtitles(
404
405 words: list[dict],
406
407 output_path: str,
408
409 words_per_group: int = 5,
410
411 highlight_color: str = "&H00D7FF&",
412
413 normal_color: str = "&HFFFFFF&",
414
415 font_size: int = 18,
416
417 ) -> str:
418
419 """Generate ASS subtitle file with word-level highlighting."""
420
421 header = f"""[Script Info]
422
423 ScriptType: v4.00+
424
425 PlayResX: 1080
426
427 PlayResY: 1920
428
429 [V4+ Styles]
430
431 Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
432
433 Style: Default,DejaVu Sans,{font_size},{normal_color},&H000000FF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,3,1,2,60,60,400,1
434
435 Style: Highlight,DejaVu Sans,{font_size},{highlight_color},&H000000FF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,3,1,2,60,60,400,1
436
437 [Events]
438
439 Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
440
441 """
442
443 events = []
444
445 for i in range(0, len(words), words_per_group):
446
447 group = words[i : i + words_per_group]
448
449 group_start = group[0]["start"]
450
451 group_end = group[-1]["end"]
452
453 for active_idx in range(len(group)):
454
455 word = group[active_idx]
456
457 parts = []
458
459 for j, w in enumerate(group):
460
461 if j == active_idx:
462
463 parts.append(f"{{\\rHighlight}}{w['word']}{{\\rDefault}}")
464
465 else:
466
467 parts.append(w["word"])
468
469 text = " ".join(parts)
470
471 start_ts = _seconds_to_ass(word["start"])
472
473 end_ts = _seconds_to_ass(word["end"])
474
475 events.append(
476
477 f"Dialogue: 0,{start_ts},{end_ts},Default,,0,0,0,,{text}"
478
479 )
480
481 with open(output_path, "w") as f:
482
483 f.write(header + "\n".join(events) + "\n")
484
485 return output_path
486
487 def _seconds_to_ass(seconds: float) -> str:
488
489 h = int(seconds // 3600)
490
491 m = int((seconds % 3600) // 60)
492
493 s = int(seconds % 60)
494
495 cs = int((seconds % 1) * 100)
496
497 return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
498
499 ```
500
501 ## Progress Bar and Lower Thirds
502
503 ### Progress Bar
504
505 Add a thin colored bar at the bottom that fills as the clip plays:
506
507 ```text
508
509 ffmpeg -i clip.mp4 -vf "drawbox=x=0:y=ih-8:w=iw*t/{duration}:h=8:color=#e94560:t=fill" output.mp4
510
511 ```
512
513 ### Speaker Lower Third
514
515 Add a name tag in the lower portion:
516
517 ```text
518
519 ffmpeg -i clip.mp4 -vf "drawtext=text='Speaker Name':fontsize=28:fontcolor=white:x=(w-text_w)/2:y=h-120:box=1:boxcolor=black@0.6:boxborderw=8" output.mp4
520
521 ```
522
523 ## Critical: Caption Rendering Approach
524
525 **DO NOT use the `subtitles`SRT filter** — YouTube transcript segments overlap heavily and produce garbled text with SRT-based rendering. Use`drawtext` per-segment instead.
526
527 ### YouTube Transcript Overlap Problem
528
529 YouTube auto-generated transcript segments have overlapping time ranges. Two segments starting at t=5.0 and t=6.5 might both end at t=8.0. If you try to merge or interleave words from overlapping segments, you get garbled captions like "tech know it today, SHA feat".
530
531 ### Solution: Sequential Caption Timeline
532
533 Treat segments as **sequential text** (representing continuous speech) and use each segment's **START time** as the anchor. Assign end time = next segment's start time. This eliminates all overlaps.
534
535 ```python
536
537 def build_captions(clip_start, clip_end, segments, actual_dur):
538
539 clip_segs = []
540
541 for seg in segments:
542
543 if seg['start'] >= clip_end or seg['end'] <= clip_start:
544
545 continue
546
547 rs = max(0, seg['start'] - clip_start)
548
549 text = seg['text'].strip()
550
551 if text:
552
553 clip_segs.append((rs, text))
554
555 clip_segs.sort(key=lambda x: x[0])
556
557 deduped = []
558
559 for rs, text in clip_segs:
560
561 if deduped and abs(rs - deduped[-1][0]) < 0.3:
562
563 if len(text) > len(deduped[-1][1]):
564
565 deduped[-1] = (rs, text)
566
567 continue
568
569 deduped.append((rs, text))
570
571 timed = []
572
573 for i, (rs, text) in enumerate(deduped):
574
575 end = deduped[i+1][0] if i+1 < len(deduped) else min(rs + 3.0, actual_dur)
576
577 if end - rs < 0.3:
578
579 continue
580
581 timed.append((rs, end, text))
582
583 display = []
584
585 wpc = 4
586
587 for ms, me, mt in timed:
588
589 words = mt.split()
590
591 dur = me - ms
592
593 if len(words) <= wpc + 1:
594
595 display.append((ms, me, mt))
596
597 else:
598
599 nc = max(1, (len(words) + wpc - 1) // wpc)
600
601 cd = dur / nc
602
603 for ci in range(nc):
604
605 w0 = ci * wpc
606
607 w1 = min((ci+1) * wpc, len(words))
608
609 chunk = ' '.join(words[w0:w1])
610
611 t0 = ms + ci * cd
612
613 t1 = ms + (ci+1) * cd
614
615 display.append((t0, min(t1, actual_dur), chunk))
616
617 return display
618
619 ```
620
621 ### drawtext Filter Chain
622
623 Build one `drawtext`filter per caption chunk with`enable='between(t,start,end)'`:
624
625 ```text
626
627 drawtext=text='caption text':fontsize=44:fontcolor=white
628
629 :fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
630
631 :x=(w-text_w)/2:y=h*0.72
632
633 :box=1:boxcolor=black@0.50:boxborderw=14
634
635 :enable='between(t\\0.000\\1.500)'
636
637 ```
638
639 ### Text Escaping for drawtext
640
641 - Replace `\\` with `\\\\`
642 - Replace `'` with `\u2019` (right single quote)
643
644 - Replace`:`with`\\`
645 - Replace `%`with`%%`
646
647 ## Full Pipeline Script
648
649 See `scripts/generate_video_clips.py`for the complete pipeline that chains: clip selection → trim → vertical reformat → caption generation → progress bar → output.
650
651 ## Audio-Only Fallback
652
653 When the source is audio-only (RSS feed download, local MP3), generate enhanced audiogram clips instead of video clips:
654
655 1. Use the same clip selection logic (content atoms with timestamps)
656 2. Generate clips using`scripts/generate_audiogram.py`with the quote text overlaid
657
658 3. Output as MP4 with waveform animation
659
660 The audiogram approach is described in`references/audio-processing.md`.