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.
@@ -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
- fo = font_opts(brand, args.font, args.font_file)
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"):
@@ -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}])
@@ -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": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
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", "voice", "denoise", "duck", "music_loop", "stereo",
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
- for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
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