ffmpeg-skill 1.13.0 → 1.14.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/check.py CHANGED
@@ -10,7 +10,8 @@ row's `fix` is the command that resolves it; a few of the less obvious FAILs
10
10
  not a restatement of the spec value -- for a caller reporting this to someone
11
11
  who doesn't already know why the spec says what it says.
12
12
 
13
- Platforms: youtube, shorts, reels, tiktok, x, linkedin, broadcast (EBU R128), podcast, custom
13
+ Platforms: youtube, shorts, reels, tiktok, x, linkedin, facebook, broadcast (EBU R128),
14
+ podcast, custom -- one table, shared with export.py and the render.py templates
14
15
 
15
16
  Examples:
16
17
  python3 check.py final.mp4 --platform youtube
@@ -25,19 +26,15 @@ import sys
25
26
  from fractions import Fraction
26
27
  from typing import Any, Dict, List
27
28
 
29
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, spec_of, resolve as resolve_platform
28
30
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
29
31
 
30
- SPECS: Dict[str, Dict[str, Any]] = {
31
- "youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
32
- "shorts": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
33
- "reels": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
34
- "tiktok": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
35
- "x": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
36
- "linkedin": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
37
- "broadcast": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60, "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None, "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
38
- "podcast": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
39
- "custom": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
40
- }
32
+ # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
33
+ # render.py templates all read it, so a platform's loudness spec is stated once. Only the
34
+ # destinations that are compliance targets appear here; youtube-hdr / youtube-av1 are export
35
+ # presets of the youtube target, not separate specs.
36
+ SPECS: Dict[str, Dict[str, Any]] = {name: spec_of(name) for name in sorted(PLATFORMS)
37
+ if PLATFORMS[name]["check"] == name}
41
38
 
42
39
 
43
40
  def measure_loudness(path: str) -> Dict[str, float]:
@@ -66,7 +63,7 @@ def aspect_name(w: int, h: int) -> str:
66
63
  def main() -> int:
67
64
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
68
65
  ap.add_argument("input")
69
- ap.add_argument("--platform", choices=sorted(SPECS), default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
66
+ ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
70
67
  ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
71
68
  ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
72
69
  ap.add_argument("--lufs", type=float, help="override loudness target")
@@ -81,7 +78,7 @@ def main() -> int:
81
78
  # spent a paragraph explaining why they left them alone. Without a named platform the
82
79
  # judgement rows are advisory: WARN, not FAIL, and not counted as failed.
83
80
  named = args.platform is not None
84
- args.platform = args.platform or "youtube"
81
+ args.platform = resolve_platform(args.platform) or "youtube"
85
82
  spec = dict(SPECS[args.platform])
86
83
  if args.max_duration is not None:
87
84
  spec["max_duration"] = args.max_duration
@@ -134,10 +131,10 @@ def main() -> int:
134
131
  row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
135
132
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
136
133
  if spec["codecs"]:
137
- row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube").replace("broadcast", "prores"),
134
+ row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + (PLATFORMS[args.platform].get("preset") or "youtube"),
138
135
  reason="the platform's player may refuse to decode this codec at all, not just look worse")
139
136
  pf = v.get("pix_fmt") or ""
140
- if args.platform in ("reels", "tiktok", "x", "linkedin"):
137
+ if args.platform in ("reels", "tiktok", "x", "linkedin", "facebook"):
141
138
  row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p",
142
139
  reason="QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0")
143
140
  if spec["sdr_only"] and v.get("hdr"):
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")
@@ -9,6 +9,10 @@ Templates:
9
9
  progress thin progress bar along the bottom that fills over the clip (or --start/--end)
10
10
  countdown big numbers counting down from --from to 0 (--start/--end define the window)
11
11
  bug persistent text bug (--title) in a corner, e.g. "@handle" or "LIVE"
12
+ sticker rounded filled chip of --text that pops in at --position (the social sticker)
13
+ hook full-width opening title card (--title) for --duration seconds with a thin
14
+ progress bar along the top -- the TikTok/Shorts opener
15
+ meme white upper-case --top / --bottom lines with a heavy black outline
12
16
 
13
17
  Examples:
14
18
  python3 graphics.py talk.mp4 --template lower-third --name "Ada Lovelace" --title "Analyst" --start 2 --end 8
@@ -17,14 +21,18 @@ Examples:
17
21
  python3 graphics.py intro.mp4 --template countdown --from 5 --start 1 --end 6
18
22
  python3 graphics.py talk.mp4 --template lower-third --name "김민준" --title "감독" --lang ko
19
23
  python3 graphics.py clip.mp4 --template chapter --title "Part 2 — Setup" --position top-left --start 0 --end 5
24
+ python3 graphics.py reel.mp4 --template sticker --text "NEW" --position top-right --platform tiktok
25
+ python3 graphics.py reel.mp4 --template hook --title "How I cut this in one command" --duration 3
26
+ python3 graphics.py clip.mp4 --template meme --top "when the render" --bottom "finally finishes"
20
27
  """
21
28
  import argparse
22
29
  import sys
23
30
  from typing import List, Optional
24
31
 
32
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
25
33
  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
26
34
 
27
- TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
35
+ TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
28
36
 
29
37
 
30
38
  def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
@@ -53,12 +61,20 @@ def main() -> int:
53
61
  ap.add_argument("--template", choices=TEMPLATES, required=True)
54
62
  ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
55
63
  ap.add_argument("--name", help="lower-third: name line")
56
- ap.add_argument("--title", help="title / chapter / bug text, or lower-third second line")
64
+ ap.add_argument("--title", help="title / chapter / bug / hook text, or lower-third second line")
65
+ ap.add_argument("--text", help="sticker: the chip's text")
66
+ ap.add_argument("--top", help="meme: upper line")
67
+ ap.add_argument("--bottom", help="meme: lower line")
68
+ ap.add_argument("--duration", type=float, default=3.0, help="hook: seconds the opening card stays up (default 3)")
57
69
  ap.add_argument("--subtitle", help="title: smaller second line")
58
70
  ap.add_argument("--from", dest="count_from", type=int, default=5, help="countdown start number (default 5)")
59
71
  ap.add_argument("--start", help="show from (default 0)")
60
72
  ap.add_argument("--end", help="hide after (default end of clip)")
61
- ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug (default bottom-left / top-right)")
73
+ ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug/sticker (default bottom-left / top-right / top-right)")
74
+ ap.add_argument("--margin", type=int, default=None, help="distance from the frame edge in px (default: brand safe_margin, or the --platform safe zone)")
75
+ ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
76
+ help="keep the graphic out of this destination's UI: margins become the platform's safe zone "
77
+ "(TikTok's description bar and like column, the Reels/Shorts chrome). An explicit --margin wins")
62
78
  ap.add_argument("--primary", help="override brand primary colour RRGGBB")
63
79
  ap.add_argument("--text-color", help="override text colour RRGGBB")
64
80
  ap.add_argument("--font")
@@ -72,6 +88,10 @@ def main() -> int:
72
88
  args = ap.parse_args()
73
89
  apply_common(args)
74
90
 
91
+ args.platform = resolve_platform(args.platform)
92
+ if args.platform and not PLATFORMS[args.platform].get("frame"):
93
+ info(f"--platform {args.platform}: this destination has no frame and no app chrome; margins unchanged")
94
+ args.platform = None
75
95
  brand = load_brand(args.brand)
76
96
  primary = color_hex(args.primary or brand["colors"]["primary"])
77
97
  text_c = color_hex(args.text_color or brand["colors"]["text"])
@@ -85,7 +105,7 @@ def main() -> int:
85
105
  args.lang = args.lang or (brand.get("lang") if args.brand else None)
86
106
  # a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
87
107
  _script, script_file, _family = script_font_for_text(
88
- " ".join(t for t in (args.name, args.title, args.subtitle) if t),
108
+ " ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
89
109
  lang=args.lang, font=args.font, font_explicit=bool(args.font), font_file=args.font_file or brand.get("font_file"))
90
110
  fo = font_opts(brand, args.font, args.font_file, script_file)
91
111
 
@@ -101,6 +121,19 @@ def main() -> int:
101
121
  if meta["video"].get("rotation") in (90, -90, 270, -270):
102
122
  W, H = H, W
103
123
  dur = meta.get("duration") or 0.0
124
+ # Per-edge margins. Without --margin/--platform every edge is the brand safe margin, exactly
125
+ # as before; --margin sets all four; --platform takes each edge from the destination's safe
126
+ # zone (scripts/_platforms.py), which is what keeps a sticker off TikTok's like column.
127
+ m_top = m_bottom = m_left = m_right = margin
128
+ if args.margin is not None:
129
+ if args.margin < 0:
130
+ die(f"--margin must be >= 0, got {args.margin}")
131
+ m_top = m_bottom = m_left = m_right = margin = args.margin
132
+ elif args.platform:
133
+ px = safe_margins_px(args.platform, W, H)
134
+ m_top, m_bottom, m_left, m_right = px["top"], px["bottom"], px["left"], px["right"]
135
+ margin = m_left
136
+ info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
104
137
  fps = meta["video"].get("fps")
105
138
  s = time_arg(args.start, "--start", fps) if args.start else 0.0
106
139
  e = time_arg(args.end, "--end", fps) if args.end else dur
@@ -123,9 +156,9 @@ def main() -> int:
123
156
  pad = int(base * 0.02)
124
157
  bar_h = h1 + (h2 + pad if args.title else 0) + pad * 2
125
158
  bar_w = int(base * 0.62)
126
- y0 = H - margin - bar_h
159
+ y0 = H - m_bottom - bar_h
127
160
  # slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
128
- x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{margin})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{margin}-({bar_w}+{margin})*(1-({e:.3f}-t)/0.3),{margin}))"
161
+ x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
129
162
  fc.append(f"color=c=0x{bg}@0.85:s={bar_w}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[bar]")
130
163
  fc.append(f"color=c=0x{primary}:s={int(base * 0.012)}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[acc]")
131
164
  fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
@@ -153,8 +186,8 @@ def main() -> int:
153
186
  pos = args.position or ("bottom-left" if args.template == "chapter" else "top-right")
154
187
  fs = int(base * (0.04 if args.template == "chapter" else 0.032))
155
188
  padx, pady = int(fs * 0.6), int(fs * 0.35)
156
- xe = f"{margin}" if "left" in pos else f"w-text_w-{margin}"
157
- ye = f"{margin}" if "top" in pos else f"h-text_h-{margin}"
189
+ xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
190
+ ye = f"{m_top}" if "top" in pos else f"h-text_h-{m_bottom}"
158
191
  box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
159
192
  txt_color = ff_color(bg if args.template == "chapter" else text_c)
160
193
  filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}")
@@ -166,6 +199,58 @@ def main() -> int:
166
199
  fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
167
200
  fc.append(f"[v1][pb]overlay=x='-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))':y={H - h}:{en}:eof_action=pass[vout]")
168
201
 
202
+ elif args.template == "sticker":
203
+ # A social sticker: a filled chip of text that pops in. drawtext's box gives the chip
204
+ # (its corners are square -- drawtext has no rounded box), and the pop is the two things
205
+ # drawtext *can* animate per frame: alpha and position, so the chip fades up while
206
+ # rising the last few pixels into place over 0.25 s.
207
+ if not args.text:
208
+ die("sticker needs --text")
209
+ pos = args.position or "top-right"
210
+ fs = int(base * 0.05)
211
+ padx, pady = int(fs * 0.7), int(fs * 0.45)
212
+ rise = int(fs * 0.5)
213
+ pop = f"min(1,(t-{s:.3f})/0.25)"
214
+ xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
215
+ ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}-{rise}*(1-{pop})")
216
+ alpha = f"min({pop},{fade_a})"
217
+ filters.append(f"drawtext=text='{escape_drawtext(args.text)}':{fo}:fontsize={fs}:fontcolor={ff_color(bg)}:"
218
+ f"x={xe}:y='{ye}':box=1:boxcolor={ff_color(primary, 0.95)}:boxborderw={drawtext_boxborderw(pady, padx)}:"
219
+ f"alpha='{alpha}':{en}")
220
+
221
+ elif args.template == "hook":
222
+ # The opener: a full-width card over the first --duration seconds with a thin bar along
223
+ # the top that empties as the card's time runs out, so the viewer sees how long it lasts.
224
+ if not args.title:
225
+ die("hook needs --title")
226
+ if args.duration <= 0:
227
+ die(f"--duration must be > 0, got {args.duration:g}")
228
+ he = min(e, s + args.duration)
229
+ hen = f"enable='between(t,{s:.3f},{he:.3f})'"
230
+ h1 = int(base * 0.085)
231
+ bar_h = max(3, int(base * 0.01))
232
+ band_h = int(base * 0.30)
233
+ y0 = (H - band_h) // 2
234
+ filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
235
+ filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:"
236
+ f"x=(w-text_w)/2:y=(h-text_h)/2:{hen}")
237
+ filters.append(f"drawbox=x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}:"
238
+ f"color={ff_color(primary)}:t=fill:{hen}")
239
+
240
+ elif args.template == "meme":
241
+ # The classic layout: heavy white upper-case lines with a black outline, top and bottom,
242
+ # sized so a short line fills the frame's width without wrapping (drawtext never wraps).
243
+ if not (args.top or args.bottom):
244
+ die("meme needs --top and/or --bottom")
245
+ fs = int(base * 0.09)
246
+ bw = max(2, int(fs / 12))
247
+ white, black = ff_color("FFFFFF"), ff_color("000000")
248
+ for text, y in ((args.top, f"{m_top}"), (args.bottom, f"h-text_h-{m_bottom}")):
249
+ if not text:
250
+ continue
251
+ filters.append(f"drawtext=text='{escape_drawtext(text.upper())}':{fo}:fontsize={fs}:fontcolor={white}:"
252
+ f"borderw={bw}:bordercolor={black}:x=(w-text_w)/2:y={y}:{en}")
253
+
169
254
  elif args.template == "countdown":
170
255
  n = args.count_from
171
256
  seg = (e - s) / (n + 1)
package/scripts/look.py CHANGED
@@ -7,6 +7,7 @@ Examples:
7
7
  python3 look.py final.mp4 --tiles 4x5 --width 1600
8
8
  python3 look.py final.mp4 --at 2.5 --at 7 # single frames -> final_2.500s.png, final_7.000s.png
9
9
  python3 look.py before.mp4 --compare after.mp4 --at 4 # side-by-side frame
10
+ python3 look.py reel.mp4 --safe tiktok --at 3 # shade what TikTok's own UI covers
10
11
  Then view the PNG (Read tool / image viewer) and verify before reporting.
11
12
  """
12
13
  import argparse
@@ -15,6 +16,7 @@ import sys
15
16
  from pathlib import Path
16
17
  from typing import List
17
18
 
19
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
18
20
  from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
19
21
 
20
22
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
@@ -39,6 +41,8 @@ def main() -> int:
39
41
  ap.add_argument("--width", type=int, default=1280, help="total width of the sheet / compare image (default 1280)")
40
42
  ap.add_argument("--compare", help="second video: place its frame next to the first (needs --at)")
41
43
  ap.add_argument("--no-timecode", action="store_true")
44
+ ap.add_argument("--safe", choices=PLATFORM_CHOICES, help="shade the zones this platform's UI covers (its description bar, "
45
+ "like column, status bar) so you can see whether anything readable is under them")
42
46
  add_common(ap)
43
47
  args = ap.parse_args()
44
48
  apply_common(args)
@@ -55,6 +59,37 @@ def main() -> int:
55
59
  default_font = default_font_file("DejaVu Sans")
56
60
  font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
57
61
  tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
62
+ # --safe: the platform's occluded zones, drawn as shaded boxes in fractions of the frame so
63
+ # the same filter is right at any scale (a tile of a contact sheet as much as a full frame).
64
+ safe_filter = ""
65
+ args.safe = resolve_platform(args.safe)
66
+ if args.safe and not PLATFORMS[args.safe].get("frame"):
67
+ info(f"--safe {args.safe}: this destination has no frame and no app chrome; nothing to shade")
68
+ args.safe = None
69
+ if args.safe:
70
+ z = PLATFORMS[args.safe]["safe"]
71
+ frame = PLATFORMS[args.safe]["frame"]
72
+ src = meta["video"]
73
+ if src.get("width") and src.get("height") and abs(src["width"] / src["height"] - frame["w"] / frame["h"]) > 0.02:
74
+ info(f"--safe {args.safe}: this source is {src['width']}x{src['height']}, not {args.safe}'s "
75
+ f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
76
+ f"so reframe first (fit.py --aspect) to see what the app really covers")
77
+ boxes = []
78
+ for edge, frac in (("top", z["top"]), ("bottom", z["bottom"]), ("left", z["left"]), ("right", z["right"])):
79
+ if frac <= 0:
80
+ continue
81
+ if edge == "top":
82
+ boxes.append(f"drawbox=x=0:y=0:w=iw:h=ih*{frac:g}:color=red@0.35:t=fill")
83
+ elif edge == "bottom":
84
+ boxes.append(f"drawbox=x=0:y=ih*(1-{frac:g}):w=iw:h=ih*{frac:g}:color=red@0.35:t=fill")
85
+ elif edge == "left":
86
+ boxes.append(f"drawbox=x=0:y=0:w=iw*{frac:g}:h=ih:color=red@0.20:t=fill")
87
+ else:
88
+ boxes.append(f"drawbox=x=iw*(1-{frac:g}):y=0:w=iw*{frac:g}:h=ih:color=red@0.20:t=fill")
89
+ safe_filter = "," + ",".join(boxes) if boxes else ""
90
+ info(f"--safe {args.safe}: shaded top {z['top'] * 100:.0f}% / bottom {z['bottom'] * 100:.0f}% / "
91
+ f"left {z['left'] * 100:.0f}% / right {z['right'] * 100:.0f}% of the frame -- keep text out of those")
92
+ tc = safe_filter + tc
58
93
  # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
59
94
  if meta["video"].get("hdr"):
60
95
  v = meta["video"]