ffmpeg-skill 1.13.0 → 1.15.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/scripts/export.py CHANGED
@@ -6,7 +6,13 @@ sets BT.709 tags, and picks sensible codecs/bitrates.
6
6
  Presets:
7
7
  youtube 1920x1080 H.264 CRF 18 high profile, AAC 192k, 48 kHz, faststart
8
8
  youtube4k 3840x2160 H.264 CRF 18, AAC 192k
9
- reels 1080x1920 9:16 H.264 CRF 20, AAC 128k, max 90 s (also Shorts/TikTok)
9
+ reels 1080x1920 9:16 H.264 CRF 20, AAC 128k, max 90 s (Instagram Reels)
10
+ tiktok 1080x1920 9:16 H.264 CRF 20, AAC 128k, max 600 s
11
+ shorts 1080x1920 9:16 H.264 CRF 20, AAC 128k, max 180 s (YouTube Shorts)
12
+ linkedin 1080x1080 1:1 H.264 CRF 20, AAC 128k, max 600 s
13
+ facebook 1920x1080 16:9 H.264 CRF 21, AAC 128k
14
+ youtube-hdr HEVC Main10 keeping the source's HDR10/HLG tags (refuses an SDR source)
15
+ youtube-av1 1080p AV1 (libsvtav1, libaom fallback); missing_tool when neither is built
10
16
  x 1280x720 H.264 CRF 22, AAC 128k, max 140 s (Twitter/X)
11
17
  prores ProRes 422 HQ .mov, PCM 16-bit audio (editing master)
12
18
  h265 HEVC CRF 24 (libx265) with hvc1 tag for Apple compatibility
@@ -29,31 +35,82 @@ import sys
29
35
  from pathlib import Path
30
36
  from typing import Dict, List
31
37
 
32
- from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
38
+ from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
33
39
  from check import SPECS as PLATFORMS, measure_loudness
40
+ from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
41
+ # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
42
+ # from a literal restated here: before 1.14 they were typed twice and the facebook preset had
43
+ # already drifted (no duration cap against the table's 14400 s).
44
+ def _from_table(dest: str, **over) -> Dict:
45
+ """A preset's w/h/max read from PLATFORMS[dest], with the encoder settings given here.
46
+
47
+ `over` is for the two presets that are deliberately not the destination's own frame or cap:
48
+ `youtube4k` delivers to YouTube at 2160p, and neither youtube preset trims at YouTube's
49
+ 12-hour limit (check.py reports it; export.py has never cut a long upload and does not start).
50
+ """
51
+ frame = PLATFORM_TABLE[dest]["frame"] or {}
52
+ spec = PLATFORM_TABLE[dest]["spec"]
53
+ max_duration = spec.get("max_duration")
54
+ out = {"w": frame.get("w"), "h": frame.get("h"),
55
+ "max": float(max_duration) if max_duration is not None else None}
56
+ out.update(over)
57
+ return out
58
+
59
+
60
+ _H264_HIGH = ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"]
61
+ _AAC_192 = ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"]
62
+ _AAC_128 = ["-c:a", "aac", "-b:a", "128k", "-ar", "48000"]
63
+
64
+
65
+ def _social(crf: str = "20") -> List[str]:
66
+ return ["-c:v", "libx264", "-preset", "medium", "-crf", crf, "-profile:v", "high", "-pix_fmt", "yuv420p", "-r", "30"]
67
+
68
+
34
69
  PRESETS: Dict[str, Dict] = {
35
- "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
36
- "youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
37
- "reels": {"w": 1080, "h": 1920, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-profile:v", "high", "-pix_fmt", "yuv420p", "-r", "30"], "audio": ["-c:a", "aac", "-b:a", "128k", "-ar", "48000"], "max": 90.0, "desc": "9:16 1080x1920, 30fps, max 90s (Reels/Shorts/TikTok)"},
38
- "x": {"w": 1280, "h": 720, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "medium", "-crf", "22", "-profile:v", "high", "-pix_fmt", "yuv420p", "-r", "30"], "audio": ["-c:a", "aac", "-b:a", "128k", "-ar", "44100"], "max": 140.0, "desc": "720p H.264, max 140s (Twitter/X)"},
70
+ "youtube": dict(_from_table("youtube", max=None), ext="mp4", video=_H264_HIGH, audio=_AAC_192, desc="1080p H.264, AAC 192k"),
71
+ "youtube4k": dict(_from_table("youtube", w=3840, h=2160, max=None), ext="mp4", video=_H264_HIGH, audio=_AAC_192, desc="2160p H.264, AAC 192k"),
72
+ "reels": dict(_from_table("reels"), ext="mp4", video=_social(), audio=_AAC_128, desc="9:16 1080x1920, 30fps, max 90s (Instagram Reels)"),
73
+ "x": dict(_from_table("x"), ext="mp4", video=_social("22"), audio=["-c:a", "aac", "-b:a", "128k", "-ar", "44100"], desc="720p H.264, max 140s (Twitter/X)"),
39
74
  "prores": {"w": None, "h": None, "ext": "mov", "video": ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"], "audio": ["-c:a", "pcm_s16le"], "max": None, "desc": "ProRes 422 HQ master, PCM audio, source resolution"},
40
75
  "h265": {"w": None, "h": None, "ext": "mp4", "video": ["-c:v", "libx265", "-preset", "medium", "-crf", "24", "-pix_fmt", "yuv420p", "-tag:v", "hvc1"], "audio": ["-c:a", "aac", "-b:a", "160k"], "max": None, "desc": "HEVC CRF 24, hvc1 tag, source resolution"},
41
76
  "gif": {"w": 480, "h": None, "ext": "gif", "video": [], "audio": [], "max": None, "desc": "480px palette GIF, 12fps"},
42
77
  "copy": {"w": None, "h": None, "ext": None, "video": ["-c:v", "copy"], "audio": ["-c:a", "copy"], "max": None, "desc": "stream copy, no re-encode (source codecs/container/colour tags unchanged)"},
78
+ # 1.14: the destinations that used to be aliases of reels/youtube are their own presets, each
79
+ # sized and length-limited from the one platform table (scripts/_platforms.py) rather than from
80
+ # a comment. `reels` keeps its historical settings byte-for-byte so existing calls are unchanged.
81
+ "tiktok": dict(_from_table("tiktok"), ext="mp4", video=_social(), audio=_AAC_128, desc="9:16 1080x1920, 30fps, max 600s (TikTok)"),
82
+ "shorts": dict(_from_table("shorts"), ext="mp4", video=_social(), audio=_AAC_128, desc="9:16 1080x1920, 30fps, max 180s (YouTube Shorts)"),
83
+ "linkedin": dict(_from_table("linkedin"), ext="mp4", video=_social(), audio=_AAC_128, desc="1:1 1080x1080, 30fps, max 600s (LinkedIn)"),
84
+ "facebook": dict(_from_table("facebook"), ext="mp4", video=_social("21"), audio=_AAC_128, desc="16:9 1920x1080, 30fps, max 14400s (Facebook feed)"),
85
+ # HDR and AV1 deliveries: the encoder line comes from encoder_args() so the source's own
86
+ # HDR tags survive (hevc) and the AV1 encoder is chosen/refused in one place.
87
+ "youtube-hdr": {"w": None, "h": None, "ext": "mp4", "codec": "hevc", "video": [], "audio": _AAC_192, "max": None, "hdr_only": True, "desc": "HEVC Main10, source HDR10/HLG tags kept, AAC 192k (refuses an SDR source)"},
88
+ "youtube-av1": dict(_from_table("youtube", max=None), ext="mp4", codec="av1", video=[], audio=_AAC_192, desc="1080p AV1 (libsvtav1, libaom fallback), AAC 192k"),
43
89
  }
44
90
 
45
91
 
46
92
 
47
93
  # which check.py platform a preset targets (its loudness spec is measured after the write)
48
94
  HERE = Path(__file__).resolve().parent
49
- PLATFORM_OF = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}
95
+ # broadcast's "preset" is prores, an editing master rather than a delivery: nothing is measured
96
+ # against a loudness spec after writing it, exactly as before 1.14.
97
+ _NOT_A_DELIVERY = frozenset({"broadcast"})
98
+ # Derived from the same table: a destination whose "preset" is this one is the compliance target
99
+ # its loudness is measured against (youtube4k and the two youtube variants deliver to youtube).
100
+ PLATFORM_OF: Dict[str, str] = {PLATFORM_TABLE[n]["preset"]: PLATFORM_TABLE[n]["check"]
101
+ for n in sorted(PLATFORM_TABLE)
102
+ if PLATFORM_TABLE[n].get("preset") in PRESETS and PLATFORM_TABLE[n]["frame"]
103
+ and n not in _NOT_A_DELIVERY}
104
+ PLATFORM_OF["youtube4k"] = PLATFORM_TABLE["youtube"]["check"]
105
+ # Aliases people write for a destination ('youtube-shorts', 'ig', 'twitter') name the same preset.
106
+ PRESET_CHOICES: List[str] = sorted(set(PRESETS) | {a for a in _ALIASES if resolve_platform(a) in PRESETS})
50
107
 
51
108
 
52
109
  def main() -> int:
53
110
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
54
111
  ap.add_argument("input", nargs="?")
55
112
  ap.add_argument("-o", "--output", help="output file (default: <name>_<preset>.<ext>)")
56
- ap.add_argument("--preset", choices=sorted(PRESETS), help="delivery preset")
113
+ ap.add_argument("--preset", choices=PRESET_CHOICES, help="delivery preset (aliases: " + ", ".join(a for a in _ALIASES if resolve_platform(a) in PRESETS) + ")")
57
114
  ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how to reach the preset frame when aspect differs (default pad)")
58
115
  ap.add_argument("--pad-color", default="black")
59
116
  add_pad_fill_args(ap)
@@ -65,6 +122,8 @@ def main() -> int:
65
122
  add_common(ap, codec=False) # the preset decides the codec; --codec would only be refused
66
123
  args = ap.parse_args()
67
124
  apply_common(args)
125
+ if args.preset and args.preset not in PRESETS:
126
+ args.preset = resolve_platform(args.preset)
68
127
 
69
128
  if args.list:
70
129
  for name, p in PRESETS.items():
@@ -82,7 +141,13 @@ def main() -> int:
82
141
  if not meta.get("video"):
83
142
  die("input has no video stream")
84
143
  notes: List[str] = []
85
- if meta["video"].get("hdr") and args.preset not in ("prores", "copy"):
144
+ if p.get("hdr_only") and not meta["video"].get("hdr"):
145
+ # The point of this preset is that the delivery stays HDR. Running it on an SDR source
146
+ # would write a 10-bit HEVC file labelled with SDR tags and call it an HDR delivery.
147
+ die(f"--preset {args.preset} delivers HDR and this source is SDR ({meta['video'].get('codec')}, "
148
+ f"{meta['video'].get('color_transfer') or 'untagged'})",
149
+ hint="use --preset youtube for an SDR delivery; there is no way to invent HDR range from an SDR master")
150
+ if meta["video"].get("hdr") and args.preset not in ("prores", "copy", "youtube-hdr"):
86
151
  notes.append("source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
87
152
  info("warning: " + notes[-1])
88
153
  has_audio = bool(meta.get("audio"))
@@ -113,9 +178,20 @@ def main() -> int:
113
178
  if vf:
114
179
  cmd += ["-vf", ",".join(vf)]
115
180
  video = list(p["video"])
181
+ if p.get("codec"):
182
+ # encoder_args() is the one place that turns a codec name into encoder options: it keeps
183
+ # the source's HDR tags for hevc and refuses (kind: missing_tool) when no AV1 encoder is
184
+ # built, which is exactly what these two presets promise.
185
+ video = encoder_args(p["codec"], args.crf if args.crf is not None else (20 if p["codec"] == "hevc" else 32),
186
+ "veryfast" if STATE.fast else "medium", meta)
187
+ # encoder_args() already applied --fast (its own preset scale per encoder: SVT-AV1 counts
188
+ # 1..12, not x264's names) and appends +faststart, which this tool adds again for mp4
189
+ while "-movflags" in video:
190
+ i = video.index("-movflags")
191
+ del video[i:i + 2]
116
192
  if args.crf is not None and "-crf" in video:
117
193
  video[video.index("-crf") + 1] = str(args.crf)
118
- if STATE.fast and "-preset" in video:
194
+ if STATE.fast and "-preset" in video and not p.get("codec"):
119
195
  video[video.index("-preset") + 1] = "veryfast"
120
196
  cmd += video
121
197
  if args.preset != "copy":
@@ -123,7 +199,7 @@ def main() -> int:
123
199
  # no longer be a copy, and would silently mislabel colour the agent never actually looked at
124
200
  if "-r" not in video:
125
201
  cmd += cfr_args(meta)
126
- if args.preset not in ("prores",):
202
+ if args.preset not in ("prores",) and not p.get("codec"):
127
203
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
128
204
  if out_ext == "mp4":
129
205
  cmd += ["-movflags", "+faststart"]
package/scripts/fit.py CHANGED
@@ -4,7 +4,7 @@
4
4
  Duration: --duration N with --method speed (retime video+audio, pitch-preserving
5
5
  via atempo chaining) or --method trim (keep the first N seconds, or a centred
6
6
  window with --from-center). Aspect: --aspect 16:9|9:16|1:1|4:5|W:H with
7
- --fit pad (letterbox/pillarbox with --pad-color, default black, or --pad-fill blur\nfor a blurred copy of the frame behind the picture) or --fit crop.
7
+ --fit pad (letterbox/pillarbox with --pad-color, default black, or --pad-fill blur\nfor a blurred copy of the frame behind the picture), --fit crop, or --fit blur\n(the whole picture centred on a blurred, darkened copy of itself -- nothing\ncropped, no black bars).
8
8
  --width and/or --height set the output size: give one and the other follows
9
9
  the aspect (source aspect if --aspect is not also given); give both for an
10
10
  exact frame. --rotate 90|180|270 (clockwise) and --flip h|v apply a new
@@ -25,6 +25,7 @@ Examples:
25
25
  python3 fit.py input.mp4 --duration 30 --method trim
26
26
  python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
27
27
  python3 fit.py input.mp4 --aspect 9:16 --fit pad --pad-fill blur # the phone-editor look: blurred frame behind the bars
28
+ python3 fit.py input.mp4 --aspect 9:16 --fit blur # same look in one word (blurred + darkened fill)
28
29
  python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
29
30
  python3 fit.py input.mp4 --aspect 9:16 --fit crop --crop-x 1 # keep the right edge (e.g. product held stage-right)
30
31
  python3 fit.py input.mp4 --height 1080 # width follows the source aspect
@@ -39,6 +40,7 @@ from fractions import Fraction
39
40
  from typing import List
40
41
 
41
42
  from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs
43
+ BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
42
44
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
45
 
44
46
 
@@ -89,7 +91,11 @@ def main() -> int:
89
91
  help="slow-motion quality: blend (frame blending) or interpolate (motion-compensated, slow but fluid). default none = duplicate frames")
90
92
  a = ap.add_argument_group("aspect")
91
93
  a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
92
- a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
94
+ a.add_argument("--fit", choices=["pad", "crop", "blur"], default="pad",
95
+ help="how to reach the aspect: pad (letterbox), crop, or blur -- the whole picture centred on a "
96
+ "blurred, darkened copy of itself filling the rest (the phone-editor vertical, 1.14). "
97
+ "The darkening is applied to SDR sources only: on an HDR source the background is blurred "
98
+ "but left at its own levels, since an eq on PQ/HLG code values is not a -15%% perceptual dim")
93
99
  a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect); with --height also given, both are used directly")
94
100
  a.add_argument("--height", type=int, help="output height in px (default: keep source height or the height implied by the aspect); with --width also given, both are used directly")
95
101
  a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
@@ -214,6 +220,19 @@ def main() -> int:
214
220
  if args.fit == "crop":
215
221
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
216
222
  vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
223
+ elif args.fit == "blur":
224
+ # nothing is cropped and nothing is a black bar: the picture keeps its own aspect in
225
+ # the middle of a blurred, dimmed copy of itself (BLUR_DARKEN) filling the frame.
226
+ # The dimming is an eq on the code values, which is a -15 % perceptual dim on an SDR
227
+ # (gamma-encoded) signal and something else entirely on a PQ/HLG one -- so an HDR
228
+ # source keeps its blurred background undimmed rather than being silently altered
229
+ # (review 12). The blur itself is neutral either way, and no tone mapping happens.
230
+ darken = 0.0 if meta["video"].get("hdr") else BLUR_DARKEN
231
+ if not darken:
232
+ info("--fit blur: HDR source, so the blurred background is not dimmed "
233
+ "(an eq on PQ/HLG code values is not the -15%% dim it is on SDR); "
234
+ "run color.py --to-sdr first for the SDR look")
235
+ vf.append(pad_filters(out_w, out_h, "blur", args.pad_color, args.pad_blur, darken))
217
236
  else:
218
237
  vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur))
219
238
  vf.append("setsar=1")