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