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/_contract.py
CHANGED
|
@@ -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)"},
|
|
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
|
-
|
|
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)
|