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/README.md +68 -29
- package/SKILL.md +44 -39
- package/bin/install.js +1 -1
- package/docs/contract.md +51 -9
- package/package.json +4 -2
- package/references/gotchas.md +15 -0
- package/references/scripts.md +98 -9
- package/scripts/_common.py +6 -3
- package/scripts/_contract.py +8 -5
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +17 -1
- package/scripts/check.py +13 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +93 -8
- package/scripts/look.py +35 -0
- package/scripts/overlay.py +37 -13
- 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/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),
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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=
|
|
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.
|
|
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 (
|
|
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")
|
package/scripts/graphics.py
CHANGED
|
@@ -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 -
|
|
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}+{
|
|
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"{
|
|
157
|
-
ye = f"{
|
|
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"]
|