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.
@@ -79,7 +79,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
79
79
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
80
80
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
81
81
  required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
82
- {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
82
+ {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
83
+ {"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"}],
83
84
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
84
85
  "crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
85
86
  required=FF + [X264, AAC], optional=[HDR_X265],
@@ -187,7 +188,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
187
188
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
188
189
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
189
190
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
190
- {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"},
191
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
192
+ {"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"},
191
193
  {"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
192
194
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
193
195
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
@@ -197,10 +199,11 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
197
199
  required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"}],
198
200
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
199
201
  "look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
200
- required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
202
+ required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"},
203
+ {"capability": "filter:drawbox", "when": "--safe NAME (the platform's occluded zones)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
201
204
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
202
- "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)"], outputs=["final video artifact", "work directory of stage outputs (--keep / --work)"],
203
- required=FF, optional=[{"capability": "delegated", "when": "each stage runs cut / join / fit / caption / overlay / audio / loudness / export / check with their capabilities"}],
205
+ "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)"],
206
+ required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
204
207
  video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
205
208
  "batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
206
209
  required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
@@ -765,11 +768,11 @@ def doctor() -> Dict[str, Any]:
765
768
  "ok": not missing_required and not unknown_required,
766
769
  "tools": _tool_usability(state),
767
770
  "gpu_encoders": _gpu_encoders(listings["encoders"]),
768
- "fonts": _fonts_capability(),
771
+ "fonts": _fonts_capability(probe=True),
769
772
  }
770
773
 
771
774
 
772
- def _fonts_capability() -> Dict[str, Any]:
775
+ def _fonts_capability(probe: bool = True) -> Dict[str, Any]:
773
776
  """The default drawtext family (issue #66) plus, since 1.12, one entry per script the tools
774
777
  can detect: which languages this machine can actually RENDER, not just which filters exist.
775
778
 
@@ -778,7 +781,7 @@ def _fonts_capability() -> Dict[str, Any]:
778
781
  here follows). Informational like the default font and gpu_encoders: a machine with no Thai
779
782
  font is not a broken install, it is a machine that must not be asked to burn Thai captions.
780
783
  """
781
- from _common import SCRIPTS, font_for_script, script_font_status
784
+ from _common import SCRIPTS, font_for_script, script_font_status, emoji_support
782
785
 
783
786
  font = _default_font()
784
787
  result = _font_available(font)
@@ -791,7 +794,12 @@ def _fonts_capability() -> Dict[str, Any]:
791
794
  # distinction the tools refuse or continue on.
792
795
  status = script_font_status(script)
793
796
  scripts[script] = {"status": status, "file": font_for_script(script) if status == "available" else None}
794
- return {"default_font": font, "status": result["status"], "detail": result["detail"], "scripts": scripts}
797
+ # 1.15: emoji are a separate question from the writing system, and the only honest answer is a
798
+ # render (an installed colour emoji font proves nothing -- libass on this build may still draw
799
+ # it monochrome). `probe=False` -- `contract --json --static`, and every static/JSON-only path -- skips
800
+ # that render, exactly as it skips the rest of the environment detection.
801
+ return {"default_font": font, "status": result["status"], "detail": result["detail"],
802
+ "scripts": scripts, "emoji": emoji_support(probe=probe)}
795
803
 
796
804
 
797
805
  def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
@@ -816,6 +824,16 @@ def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
816
824
  parts.append("no font for " + " ".join(by_state["missing"]))
817
825
  if by_state["unknown"]:
818
826
  parts.append("unknown (no fontconfig) " + " ".join(by_state["unknown"]))
827
+ emoji = fonts.get("emoji") or {}
828
+ mode = emoji.get("mode")
829
+ if mode == "color":
830
+ parts.append("emoji colour")
831
+ elif mode == "png":
832
+ parts.append("emoji colour (assets)")
833
+ elif mode == "mono":
834
+ parts.append("emoji monochrome -- --emoji-assets DIR for colour")
835
+ elif mode:
836
+ parts.append("emoji none")
819
837
  return "; ".join(parts)
820
838
 
821
839
 
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env python3
2
+ """One table per delivery destination (internal module, not a tool).
3
+
4
+ Before 1.14 the same destination was described three times: check.py held the
5
+ compliance spec (duration, aspect, codecs, loudness), export.py held the frame and
6
+ encoder settings, and nothing at all held the part of the frame the app's own UI
7
+ covers. A TikTok export therefore passed every check while its captions sat under
8
+ the description bar. This module is the single table the delivery tools read:
9
+
10
+ PLATFORMS[name] = {
11
+ "frame": {"w", "h", "aspect"} or None (audio-only destinations),
12
+ "fps": the frame rate a delivery is conformed to (None = leave alone),
13
+ "spec": check.py's compliance row values (max_duration, aspects,
14
+ min_height, fps_max, codecs, max_bytes, lufs, lufs_tol, tp,
15
+ sdr_only) -- the keys check.py's SPECS has always had,
16
+ "safe": the fraction of the frame each edge's UI covers
17
+ (top/bottom/left/right, 0..1) -- nothing readable goes there,
18
+ "caption": the caption defaults a template uses (size as a fraction of the
19
+ frame height, position, box, outline, animate),
20
+ "preset": the export.py preset that writes this destination's file,
21
+ "check": the check.py platform name a delivery is verified against.
22
+ }
23
+
24
+ Read by check.py (SPECS), export.py (PRESETS/PLATFORM_OF), render.py (templates),
25
+ caption.py and graphics.py (--platform margins) and look.py (--safe).
26
+
27
+ Safe zones are the app's own overlay, measured from each platform's published
28
+ design guidance: TikTok's caption/description block and the like/comment column,
29
+ Instagram's Reels UI, the Shorts player. They are deliberately generous -- a
30
+ caption 2 % too high is readable, a caption under the share button is not. The
31
+ feed destinations (YouTube, X, LinkedIn, Facebook) have no persistent overlay, so
32
+ they carry the conventional 5 % title-safe border instead.
33
+
34
+ ASS note: caption.py's --size and --margin are in the 288-line ASS script grid, so
35
+ a fraction of the frame height is that fraction * 288 (ass_units() below); the
36
+ burn scales it back to the real frame. graphics.py and look.py work in pixels.
37
+ """
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ ASS_SCRIPT_HEIGHT = 288 # caption.py's --size/--margin reference grid
41
+
42
+
43
+ def ass_units(fraction: float) -> int:
44
+ """A fraction of the frame height as a caption.py --size / --margin value."""
45
+ return int(round(fraction * ASS_SCRIPT_HEIGHT))
46
+
47
+
48
+ # The edges a feed destination reserves: no app chrome, just the conventional title-safe border.
49
+ _SAFE_5 = {"top": 0.05, "bottom": 0.05, "left": 0.05, "right": 0.05}
50
+ _NO_SAFE = {"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}
51
+
52
+ # caption defaults: size is a fraction of the frame height (0.0833 = the 24 that every
53
+ # vertical job in this repo has used since 1.2), position/box/outline/animate as the
54
+ # caption.py flags of the same name.
55
+ _CAP_VERTICAL = {"size": 0.0833, "position": "bottom", "box": False, "outline": 2, "animate": "pop"}
56
+ _CAP_WIDE = {"size": 0.0694, "position": "bottom", "box": False, "outline": 2, "animate": "none"}
57
+
58
+ PLATFORMS: Dict[str, Dict[str, Any]] = {
59
+ "tiktok": {
60
+ "title": "TikTok",
61
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
62
+ "fps": 30,
63
+ "spec": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60,
64
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
65
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
66
+ # the description/caption block along the bottom, the like/comment/share column on the
67
+ # right, the status bar and the "Following | For You" tabs at the top
68
+ "safe": {"top": 0.10, "bottom": 0.22, "left": 0.05, "right": 0.14},
69
+ "caption": _CAP_VERTICAL,
70
+ "preset": "tiktok", "check": "tiktok",
71
+ },
72
+ "reels": {
73
+ "title": "Instagram Reels",
74
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
75
+ "fps": 30,
76
+ "spec": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60,
77
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
78
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
79
+ "safe": {"top": 0.08, "bottom": 0.20, "left": 0.05, "right": 0.12},
80
+ "caption": _CAP_VERTICAL,
81
+ "preset": "reels", "check": "reels",
82
+ },
83
+ "shorts": {
84
+ "title": "YouTube Shorts",
85
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
86
+ "fps": 30,
87
+ "spec": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60,
88
+ "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3,
89
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
90
+ "safe": {"top": 0.06, "bottom": 0.18, "left": 0.05, "right": 0.12},
91
+ "caption": _CAP_VERTICAL,
92
+ "preset": "shorts", "check": "shorts",
93
+ },
94
+ "youtube": {
95
+ "title": "YouTube",
96
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
97
+ "fps": None,
98
+ "spec": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
99
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
100
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
101
+ "safe": _SAFE_5,
102
+ "caption": _CAP_WIDE,
103
+ "preset": "youtube", "check": "youtube",
104
+ },
105
+ "youtube-hdr": {
106
+ "title": "YouTube (HDR10)",
107
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
108
+ "fps": None,
109
+ "spec": dict({"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
110
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
111
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False}),
112
+ "safe": _SAFE_5,
113
+ "caption": _CAP_WIDE,
114
+ "preset": "youtube-hdr", "check": "youtube",
115
+ },
116
+ "youtube-av1": {
117
+ "title": "YouTube (AV1)",
118
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
119
+ "fps": None,
120
+ "spec": dict({"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
121
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
122
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False}),
123
+ "safe": _SAFE_5,
124
+ "caption": _CAP_WIDE,
125
+ "preset": "youtube-av1", "check": "youtube",
126
+ },
127
+ "x": {
128
+ "title": "X (Twitter)",
129
+ "frame": {"w": 1280, "h": 720, "aspect": "16:9"},
130
+ "fps": 30,
131
+ "spec": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60,
132
+ "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2,
133
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
134
+ "safe": _SAFE_5,
135
+ "caption": _CAP_WIDE,
136
+ "preset": "x", "check": "x",
137
+ },
138
+ "linkedin": {
139
+ "title": "LinkedIn",
140
+ "frame": {"w": 1080, "h": 1080, "aspect": "1:1"},
141
+ "fps": 30,
142
+ "spec": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60,
143
+ "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3,
144
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
145
+ "safe": _SAFE_5,
146
+ "caption": _CAP_WIDE,
147
+ "preset": "linkedin", "check": "linkedin",
148
+ },
149
+ "facebook": {
150
+ "title": "Facebook",
151
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
152
+ "fps": 30,
153
+ "spec": {"max_duration": 240 * 60, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60,
154
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
155
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
156
+ "safe": _SAFE_5,
157
+ "caption": _CAP_WIDE,
158
+ "preset": "facebook", "check": "facebook",
159
+ },
160
+ "podcast": {
161
+ "title": "Podcast (audio)",
162
+ "frame": None,
163
+ "fps": None,
164
+ "spec": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None,
165
+ "codecs": None, "max_bytes": None,
166
+ "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
167
+ "safe": _NO_SAFE,
168
+ "caption": _CAP_WIDE,
169
+ "preset": None, "check": "podcast",
170
+ },
171
+ # Not destinations an app owns, but compliance targets check.py has always had.
172
+ "broadcast": {
173
+ "title": "Broadcast (EBU R128)",
174
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
175
+ "fps": None,
176
+ "spec": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60,
177
+ "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None,
178
+ "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
179
+ "safe": _SAFE_5,
180
+ "caption": _CAP_WIDE,
181
+ "preset": "prores", "check": "broadcast",
182
+ },
183
+ "custom": {
184
+ "title": "Custom",
185
+ "frame": None,
186
+ "fps": None,
187
+ "spec": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None,
188
+ "codecs": None, "max_bytes": None,
189
+ "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
190
+ "safe": _NO_SAFE,
191
+ "caption": _CAP_WIDE,
192
+ "preset": None, "check": "custom",
193
+ },
194
+ }
195
+
196
+ # The canonical destination names.
197
+ PLATFORM_NAMES: List[str] = sorted(PLATFORMS)
198
+ # The spellings people actually write for those destinations. resolve() maps them onto the
199
+ # canonical name, and every tool's --platform/--safe/--preset accepts both, so
200
+ # `--platform youtube-shorts` and `--platform shorts` are the same request everywhere.
201
+ ALIASES: Dict[str, str] = {"youtube-shorts": "shorts", "yt-shorts": "shorts", "yt": "youtube",
202
+ "instagram": "reels", "ig": "reels", "twitter": "x", "fb": "facebook"}
203
+ # One vocabulary for the word "platform": check.py, caption.py, graphics.py and look.py all
204
+ # offer this list (review 12 found three different ones). It is the compliance targets -- the
205
+ # destinations a delivery is checked against -- plus every alias; youtube-hdr and youtube-av1
206
+ # are export presets of the youtube target, not separate destinations, so they are not in it.
207
+ PLATFORM_CHOICES: List[str] = sorted({n for n in PLATFORMS if PLATFORMS[n]["check"] == n} | set(ALIASES))
208
+
209
+
210
+ def has_frame(name: str) -> bool:
211
+ """True when this destination has a frame, and therefore a safe zone to place text inside."""
212
+ return bool(PLATFORMS.get(resolve(name) or "", {}).get("frame"))
213
+
214
+
215
+ def spec_of(name: str) -> Dict[str, Any]:
216
+ """check.py's compliance row values for a destination."""
217
+ return dict(PLATFORMS[name]["spec"])
218
+
219
+
220
+ def safe_of(name: str) -> Dict[str, float]:
221
+ return dict(PLATFORMS[name]["safe"])
222
+
223
+
224
+ def loudness_of(name: str) -> Dict[str, float]:
225
+ s = PLATFORMS[name]["spec"]
226
+ return {"lufs": s["lufs"], "lufs_tol": s["lufs_tol"], "tp": s["tp"]}
227
+
228
+
229
+ def safe_margins_px(name: str, width: int, height: int) -> Dict[str, int]:
230
+ """The safe zone in pixels for a frame of this size, as whole pixels per edge."""
231
+ s = PLATFORMS[name]["safe"]
232
+ return {"top": int(round(s["top"] * height)), "bottom": int(round(s["bottom"] * height)),
233
+ "left": int(round(s["left"] * width)), "right": int(round(s["right"] * width))}
234
+
235
+
236
+ def caption_defaults(name: str) -> Dict[str, Any]:
237
+ """caption.py flag values for this destination: --size/--margin in ASS units."""
238
+ cap = dict(PLATFORMS[name]["caption"])
239
+ safe = PLATFORMS[name]["safe"]
240
+ edge = safe["top"] if cap["position"].startswith("top") else safe["bottom"]
241
+ cap["size"] = ass_units(cap["size"])
242
+ cap["margin"] = ass_units(edge)
243
+ return cap
244
+
245
+
246
+ def resolve(name: Optional[str]) -> Optional[str]:
247
+ """Accept the spellings people write for a destination ('youtube-shorts', 'ig')."""
248
+ if not name:
249
+ return None
250
+ key = str(name).strip().lower()
251
+ return ALIASES.get(key, key)