ffmpeg-skill 1.11.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/SKILL.md +27 -21
- package/docs/contract.md +43 -9
- package/package.json +1 -1
- package/references/gotchas.md +57 -7
- package/references/scripts.md +119 -10
- package/scripts/_common.py +378 -0
- package/scripts/_contract.py +60 -8
- package/scripts/audio.py +99 -5
- package/scripts/caption.py +449 -16
- package/scripts/check.py +15 -0
- package/scripts/graphics.py +18 -3
- package/scripts/loudness.py +3 -0
- package/scripts/overlay.py +9 -1
- package/scripts/render.py +69 -15
package/scripts/graphics.py
CHANGED
|
@@ -15,13 +15,14 @@ Examples:
|
|
|
15
15
|
python3 graphics.py talk.mp4 --template title --title "Episode 12" --subtitle "The math of video" --start 0 --end 4
|
|
16
16
|
python3 graphics.py talk.mp4 --template progress --brand brand.json
|
|
17
17
|
python3 graphics.py intro.mp4 --template countdown --from 5 --start 1 --end 6
|
|
18
|
+
python3 graphics.py talk.mp4 --template lower-third --name "김민준" --title "감독" --lang ko
|
|
18
19
|
python3 graphics.py clip.mp4 --template chapter --title "Part 2 — Setup" --position top-left --start 0 --end 5
|
|
19
20
|
"""
|
|
20
21
|
import argparse
|
|
21
22
|
import sys
|
|
22
23
|
from typing import List, Optional
|
|
23
24
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
|
|
25
|
+
from _common import aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
|
|
25
26
|
|
|
26
27
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
27
28
|
|
|
@@ -30,9 +31,11 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
|
|
|
30
31
|
return f"0x{color_hex(hex_rgb)}@{alpha:g}"
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
|
|
34
|
+
def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script_file: Optional[str] = None) -> str:
|
|
34
35
|
if font_file or brand.get("font_file"):
|
|
35
36
|
return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
|
|
37
|
+
if script_file: # a font picked by the script of the text itself (1.12)
|
|
38
|
+
return f"fontfile={escape_filter_path(script_file)}"
|
|
36
39
|
resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
|
|
37
40
|
if resolved:
|
|
38
41
|
return f"fontfile={escape_filter_path(resolved)}"
|
|
@@ -60,6 +63,8 @@ def main() -> int:
|
|
|
60
63
|
ap.add_argument("--text-color", help="override text colour RRGGBB")
|
|
61
64
|
ap.add_argument("--font")
|
|
62
65
|
ap.add_argument("--font-file")
|
|
66
|
+
ap.add_argument("--lang", help="language code of the text (e.g. ja, zh, ko): the hint that says whether Han-only "
|
|
67
|
+
"text is Chinese, Japanese or Korean when a font is picked by script")
|
|
63
68
|
ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
|
|
64
69
|
ap.add_argument("--crf", type=int, default=18)
|
|
65
70
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
@@ -72,7 +77,17 @@ def main() -> int:
|
|
|
72
77
|
text_c = color_hex(args.text_color or brand["colors"]["text"])
|
|
73
78
|
bg = color_hex(brand["colors"].get("background", "101418"))
|
|
74
79
|
margin = int(brand.get("safe_margin", 48))
|
|
75
|
-
|
|
80
|
+
style = brand_caption_style(brand) # styles.caption is shared with caption.py
|
|
81
|
+
if args.brand and style.get("font") and not args.font:
|
|
82
|
+
args.font = style["font"]
|
|
83
|
+
if args.brand and style.get("color") and not args.text_color:
|
|
84
|
+
text_c = color_hex(style["color"])
|
|
85
|
+
args.lang = args.lang or (brand.get("lang") if args.brand else None)
|
|
86
|
+
# a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
|
|
87
|
+
_script, script_file, _family = script_font_for_text(
|
|
88
|
+
" ".join(t for t in (args.name, args.title, args.subtitle) if t),
|
|
89
|
+
lang=args.lang, font=args.font, font_explicit=bool(args.font), font_file=args.font_file or brand.get("font_file"))
|
|
90
|
+
fo = font_opts(brand, args.font, args.font_file, script_file)
|
|
76
91
|
|
|
77
92
|
meta = probe(args.input)
|
|
78
93
|
if not meta.get("video"):
|
package/scripts/loudness.py
CHANGED
|
@@ -11,6 +11,7 @@ Examples:
|
|
|
11
11
|
python3 loudness.py input.mp4 # -14 LUFS, -1 dBTP
|
|
12
12
|
python3 loudness.py podcast.wav -I -16 --tp -1.5 -o podcast_norm.wav
|
|
13
13
|
python3 loudness.py input.mp4 --measure-only
|
|
14
|
+
python3 loudness.py music.wav --lra 7 # tighter loudness range target
|
|
14
15
|
"""
|
|
15
16
|
import argparse
|
|
16
17
|
import json
|
|
@@ -155,6 +156,8 @@ def main() -> int:
|
|
|
155
156
|
kind="verification", output=output, result=result,
|
|
156
157
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
157
158
|
emit(output, result=result, dropped_non_av_streams=dropped_streams,
|
|
159
|
+
measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
|
|
160
|
+
targets={"lufs": args.lufs, "tp": args.tp, "lra": args.lra},
|
|
158
161
|
verification=[{"step": "loudness",
|
|
159
162
|
"ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + 0.1),
|
|
160
163
|
"lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp}])
|
package/scripts/overlay.py
CHANGED
|
@@ -24,7 +24,7 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
-
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
27
|
+
from _common import STATE, script_font_for_text, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
28
28
|
|
|
29
29
|
POS = {
|
|
30
30
|
"top-left": ("{m}", "{m}"),
|
|
@@ -144,6 +144,14 @@ def main() -> int:
|
|
|
144
144
|
args.font = brand.get("font", args.font)
|
|
145
145
|
if not args.font_file and brand.get("font_file"):
|
|
146
146
|
args.font_file = brand["font_file"]
|
|
147
|
+
if args.text and not args.font_file:
|
|
148
|
+
# 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
|
|
149
|
+
# Arabic ... draws glyphs instead of boxes. drawtext does not shape or reorder RTL text --
|
|
150
|
+
# see references/gotchas.md#fonts-by-script.
|
|
151
|
+
_script, script_file, _family = script_font_for_text(
|
|
152
|
+
args.text, font=args.font, font_explicit=args.font != ap.get_default("font"), font_file=args.font_file)
|
|
153
|
+
if script_file:
|
|
154
|
+
args.font_file = script_file
|
|
147
155
|
if not args.font_file:
|
|
148
156
|
args.font_file = default_font_file(args.font)
|
|
149
157
|
meta = probe(args.input)
|
package/scripts/render.py
CHANGED
|
@@ -24,15 +24,17 @@ Project format (all keys optional except clips):
|
|
|
24
24
|
{"logo": true},
|
|
25
25
|
{"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
|
|
26
26
|
],
|
|
27
|
-
"audio": {"voice":
|
|
27
|
+
"audio": {"voice": "medium", "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2,
|
|
28
|
+
"effects": "sfx.wav", "stems": {"dialogue": 0, "music": -18, "effects": -22}},
|
|
28
29
|
"loudness": {"lufs": -14, "tp": -1},
|
|
29
30
|
"fit": {"duration": 60},
|
|
30
31
|
"export": {"preset": "reels", "normalize": true}, (default for platform presets; false opts out)
|
|
31
|
-
"check": {"platform": "reels"}
|
|
32
|
+
"check": {"platform": "reels"},
|
|
33
|
+
"chapters": "chapters.txt" (or [{"at": "0:00", "title": "Intro"}, ...])
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
Stages run in this order: clips (cut) → join → silence → fit → captions →
|
|
35
|
-
graphics → overlays → audio → loudness → export → check. Missing stages are
|
|
37
|
+
graphics → overlays → audio → loudness → export → chapters → check. Missing stages are
|
|
36
38
|
skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
|
|
37
39
|
colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
|
|
38
40
|
|
|
@@ -76,6 +78,7 @@ TEMPLATE = {
|
|
|
76
78
|
"loudness": {"lufs": -14, "tp": -1},
|
|
77
79
|
"fit": None,
|
|
78
80
|
"export": {"preset": "youtube", "normalize": True},
|
|
81
|
+
"chapters": None,
|
|
79
82
|
"check": {"platform": "youtube"},
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -85,27 +88,33 @@ TEMPLATE = {
|
|
|
85
88
|
# untrimmed, and a mistyped stage name dropped the stage -- both reported as a success (review 9).
|
|
86
89
|
OBJECT_KEYS: Dict[str, frozenset] = {
|
|
87
90
|
"project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
|
|
88
|
-
"graphics", "overlays", "audio", "loudness", "fit", "export", "check"}),
|
|
91
|
+
"graphics", "overlays", "audio", "loudness", "fit", "export", "check", "chapters"}),
|
|
89
92
|
"clips[]": frozenset({"src", "in", "out", "speed"}),
|
|
90
93
|
"frame": frozenset({"aspect", "width", "height", "fps"}),
|
|
91
94
|
"transition": frozenset({"type", "duration"}),
|
|
92
95
|
"silence": frozenset({"threshold", "min_silence", "margin"}),
|
|
93
96
|
"captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
|
|
94
|
-
"animate", "highlight_color", "outline", "karaoke", "bold", "box"
|
|
97
|
+
"animate", "highlight_color", "outline", "karaoke", "bold", "box",
|
|
98
|
+
"lang", "offset", "max_lines", "min_duration"}),
|
|
95
99
|
"graphics[]": frozenset({"template", "name", "title", "subtitle", "start", "end", "position",
|
|
96
|
-
"from", "scale", "primary", "text_color"}),
|
|
100
|
+
"from", "scale", "primary", "text_color", "lang"}),
|
|
97
101
|
"overlays[]": frozenset({"logo", "image", "text", "position", "start", "end", "fade", "opacity",
|
|
98
102
|
"scale", "font_size", "font", "font_file", "margin", "box"}),
|
|
99
103
|
"audio": frozenset({"music", "replace", "music_volume", "fade_in", "fade_out", "music_fade_out",
|
|
100
|
-
"gain", "duck_amount", "
|
|
101
|
-
"mono", "downmix"
|
|
104
|
+
"gain", "duck_amount", "duck_threshold", "duck_attack", "duck_release",
|
|
105
|
+
"voice", "denoise", "duck", "music_loop", "stereo", "mono", "downmix",
|
|
106
|
+
"stereo_widen", "effects", "effects_volume", "stems"}),
|
|
107
|
+
"audio.stems": frozenset({"dialogue", "music", "effects"}),
|
|
108
|
+
"chapters[]": frozenset({"at", "title"}),
|
|
102
109
|
"loudness": frozenset({"lufs", "tp"}),
|
|
103
110
|
"fit": frozenset({"duration", "method", "aspect", "fit", "width", "height", "fps", "smooth"}),
|
|
104
111
|
"export": frozenset({"preset", "fit", "crf", "normalize"}),
|
|
105
112
|
"check": frozenset({"platform"}),
|
|
106
113
|
}
|
|
107
114
|
# Typos difflib cannot see: a clip is trimmed with in/out, not the start/end that time a title.
|
|
108
|
-
NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out", "from": "in", "to": "out"}
|
|
115
|
+
NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out", "from": "in", "to": "out"},
|
|
116
|
+
"audio.stems": {"voice": "dialogue", "speech": "dialogue", "sfx": "effects", "bed": "music"},
|
|
117
|
+
"chapters[]": {"start": "at", "time": "at", "name": "title"}}
|
|
109
118
|
|
|
110
119
|
|
|
111
120
|
def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
@@ -125,6 +134,14 @@ def validate_project(proj: Dict[str, Any]) -> None:
|
|
|
125
134
|
check_keys(proj, "project", "project")
|
|
126
135
|
for name in ("frame", "transition", "silence", "captions", "audio", "loudness", "fit", "export", "check"):
|
|
127
136
|
check_keys(proj.get(name), name, name)
|
|
137
|
+
check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
|
|
138
|
+
if isinstance(proj.get("chapters"), list):
|
|
139
|
+
# Every other project error is raised here, before the first ffmpeg call; a chapter typo
|
|
140
|
+
# found inside the last stage costs a whole render and leaves an unchaptered file behind.
|
|
141
|
+
for i, item in enumerate(proj["chapters"]):
|
|
142
|
+
check_keys(item, "chapters[]", f"chapters[{i}]")
|
|
143
|
+
if not isinstance(item, dict) or item.get("at") is None or not str(item.get("title") or "").strip():
|
|
144
|
+
die(f'chapters[{i}]: needs {{"at": TIME, "title": STR}}')
|
|
128
145
|
for name in ("clips", "graphics", "overlays"):
|
|
129
146
|
items = proj.get(name)
|
|
130
147
|
if isinstance(items, list):
|
|
@@ -286,6 +303,9 @@ def main() -> int:
|
|
|
286
303
|
p = str(p)
|
|
287
304
|
return p if os.path.isabs(p) else str(base / p)
|
|
288
305
|
|
|
306
|
+
if isinstance(proj.get("chapters"), str) and not os.path.exists(rel(proj["chapters"])):
|
|
307
|
+
die(f"chapters file not found: {rel(proj['chapters'])}")
|
|
308
|
+
|
|
289
309
|
clips = proj.get("clips") or []
|
|
290
310
|
if not clips:
|
|
291
311
|
die("project.clips is empty")
|
|
@@ -418,7 +438,7 @@ def main() -> int:
|
|
|
418
438
|
argv += ["--ass", rel(cap["ass"])]
|
|
419
439
|
else:
|
|
420
440
|
die("captions needs text, srt or ass")
|
|
421
|
-
for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline")):
|
|
441
|
+
for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration")):
|
|
422
442
|
if cap.get(k) is not None:
|
|
423
443
|
argv += [flag, str(cap[k])]
|
|
424
444
|
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
@@ -437,7 +457,7 @@ def main() -> int:
|
|
|
437
457
|
if not g.get("template"):
|
|
438
458
|
die(f"graphics[{i}] needs a template")
|
|
439
459
|
argv = [current, "-o", nxt, "--template", g["template"]]
|
|
440
|
-
for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color")):
|
|
460
|
+
for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color"), ("lang", "--lang")):
|
|
441
461
|
if g.get(k) is not None:
|
|
442
462
|
argv += [flag, str(g[k])]
|
|
443
463
|
sh("graphics.py", *(argv + brand_args))
|
|
@@ -474,17 +494,33 @@ def main() -> int:
|
|
|
474
494
|
return 0
|
|
475
495
|
|
|
476
496
|
# ---- audio
|
|
477
|
-
au = proj.get("audio")
|
|
497
|
+
au = dict(proj.get("audio") or {})
|
|
478
498
|
if au:
|
|
499
|
+
# "stems": one level per element of the mix, the way a mixing desk names them. Each maps
|
|
500
|
+
# to the flag that already exists (dialogue = the main track's gain, music = the bed's
|
|
501
|
+
# level, effects = the third file's level), so a stems block is a vocabulary, not a
|
|
502
|
+
# second code path -- and an explicit flag next to it wins, since it is the more specific
|
|
503
|
+
# statement of the same thing.
|
|
504
|
+
stems = au.pop("stems", None) or {}
|
|
505
|
+
if stems.get("effects") is not None and not au.get("effects"):
|
|
506
|
+
die('audio.stems.effects sets the level of "audio": {"effects": "sfx.wav"}, which this project does not have')
|
|
507
|
+
if stems.get("music") is not None and not au.get("music"):
|
|
508
|
+
die('audio.stems.music sets the level of "audio": {"music": "bed.mp3"}, which this project does not have')
|
|
509
|
+
for stem, key in (("dialogue", "gain"), ("music", "music_volume"), ("effects", "effects_volume")):
|
|
510
|
+
if stems.get(stem) is not None:
|
|
511
|
+
au.setdefault(key, stems[stem])
|
|
479
512
|
nxt = str(work / "audio.mp4")
|
|
480
513
|
argv = [current, "-o", nxt]
|
|
481
|
-
for k, flag in (("music", "--music"), ("replace", "--replace")):
|
|
514
|
+
for k, flag in (("music", "--music"), ("replace", "--replace"), ("effects", "--effects")):
|
|
482
515
|
if au.get(k):
|
|
483
516
|
argv += [flag, rel(au[k])]
|
|
484
|
-
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
517
|
+
for k, flag in (("music_volume", "--music-volume"), ("effects_volume", "--effects-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount"), ("duck_threshold", "--duck-threshold"), ("duck_attack", "--duck-attack"), ("duck_release", "--duck-release"), ("stereo_widen", "--stereo-widen")):
|
|
485
518
|
if au.get(k) is not None:
|
|
486
519
|
argv += [flag, str(au[k])]
|
|
487
|
-
|
|
520
|
+
# "voice": true is the medium chain; "voice": "light"|"medium"|"strong" names one
|
|
521
|
+
if au.get("voice") is not None and au.get("voice") is not False:
|
|
522
|
+
argv += ["--voice"] + ([] if au["voice"] is True else [str(au["voice"])])
|
|
523
|
+
for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
488
524
|
if au.get(k):
|
|
489
525
|
argv.append(flag)
|
|
490
526
|
sh("audio.py", *argv)
|
|
@@ -535,6 +571,24 @@ def main() -> int:
|
|
|
535
571
|
info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
|
|
536
572
|
current = output
|
|
537
573
|
|
|
574
|
+
# ---- chapters (metadata.py on the delivered file: streams copied, markers written)
|
|
575
|
+
ch = proj.get("chapters")
|
|
576
|
+
if ch:
|
|
577
|
+
# Planned exactly like the audio stage: the metadata.py command names the export's output,
|
|
578
|
+
# which a dry run has not written either. The plan is the run, so --dry-run shows the
|
|
579
|
+
# command and lists the stage (the child's own --dry-run prints rather than writes).
|
|
580
|
+
if isinstance(ch, list):
|
|
581
|
+
chapter_file = str(work / "chapters.txt")
|
|
582
|
+
lines = [f"{entry['at']} {entry['title']}" for entry in ch]
|
|
583
|
+
Path(chapter_file).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
584
|
+
else:
|
|
585
|
+
chapter_file = rel(ch)
|
|
586
|
+
tagged = str(work / ("chapters" + Path(output).suffix))
|
|
587
|
+
sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged)
|
|
588
|
+
if not STATE.dry_run:
|
|
589
|
+
place_output(tagged, output)
|
|
590
|
+
stages_done.append("chapters")
|
|
591
|
+
|
|
538
592
|
# ---- check
|
|
539
593
|
ck = proj.get("check")
|
|
540
594
|
check_result = None
|