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/README.md +72 -29
- package/SKILL.md +46 -41
- package/bin/install.js +1 -1
- package/docs/contract.md +72 -10
- package/package.json +4 -2
- package/references/gotchas.md +85 -1
- package/references/scripts.md +146 -15
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common.py +596 -37
- package/scripts/_contract.py +27 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +343 -93
- package/scripts/check.py +13 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +381 -20
- package/scripts/look.py +35 -0
- package/scripts/overlay.py +72 -15
- package/scripts/render.py +313 -29
- package/scripts/report.py +73 -1
- package/templates/facebook.json +47 -0
- package/templates/linkedin.json +47 -0
- package/templates/podcast.json +22 -0
- package/templates/reels.json +47 -0
- package/templates/shorts.json +47 -0
- package/templates/tiktok.json +47 -0
- package/templates/x.json +47 -0
- package/templates/youtube-shorts.json +47 -0
- package/templates/youtube.json +47 -0
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 (
|
|
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":
|
|
36
|
-
"youtube4k":
|
|
37
|
-
"reels":
|
|
38
|
-
"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
|
-
|
|
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=
|
|
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
|
|
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
|
|
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"
|
|
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")
|