ffmpeg-skill 3.1.8 → 3.2.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 +4 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +12 -2
- package/package.json +1 -1
- package/references/scripts.md +51 -7
- package/scripts/_common/emit.py +22 -0
- package/scripts/_contract.py +16 -7
- package/scripts/export.py +300 -10
- package/scripts/look.py +60 -5
- package/scripts/loop.py +182 -18
- package/scripts/loudness.py +136 -62
package/scripts/export.py
CHANGED
|
@@ -15,6 +15,13 @@ Presets:
|
|
|
15
15
|
youtube-av1 1080p AV1 (libsvtav1, libaom fallback); missing_tool when neither is built
|
|
16
16
|
x 1280x720 H.264 CRF 22, AAC 128k, max 140 s (Twitter/X)
|
|
17
17
|
prores ProRes 422 HQ .mov, PCM 16-bit audio (editing master)
|
|
18
|
+
dnxhr-lb / dnxhr-sq / dnxhr-hq DNxHR 8-bit 4:2:2 .mov, PCM 16-bit audio
|
|
19
|
+
dnxhr-hqx DNxHR HQX 10-bit 4:2:2; dnxhr-444 DNxHR 444 10-bit 4:4:4
|
|
20
|
+
(editing masters at the source's size and rate, like prores)
|
|
21
|
+
key-fill fill + key pair (<out>, <out stem>_key.mov) from an alpha source
|
|
22
|
+
prores4444 / prores4444xq / qtrle / vp9-alpha / hap-alpha
|
|
23
|
+
masters that keep the source's alpha (refused without one);
|
|
24
|
+
--alpha-mode straight|premultiplied; alpha read back after
|
|
18
25
|
h265 HEVC CRF 24 (libx265) with hvc1 tag for Apple compatibility
|
|
19
26
|
gif 480px wide palette-optimised GIF at 12 fps (short previews)
|
|
20
27
|
copy stream copy, no re-encode: same codecs, container and colour
|
|
@@ -25,6 +32,7 @@ Examples:
|
|
|
25
32
|
python3 export.py final.mp4 --preset reels --fit crop
|
|
26
33
|
python3 export.py final.mp4 --preset reels --normalize # meet the platform's loudness in the same call
|
|
27
34
|
python3 export.py final.mp4 --preset prores -o master.mov
|
|
35
|
+
python3 export.py final.mp4 --preset dnxhr-hq -o master.mov
|
|
28
36
|
python3 export.py final.mp4 --preset copy -o delivered.mp4
|
|
29
37
|
python3 export.py --list
|
|
30
38
|
"""
|
|
@@ -43,7 +51,8 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
|
|
|
43
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
44
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
45
53
|
from _common.probe import square_pixels, square_pixels_note
|
|
46
|
-
from _common.runner import subtitle_codec_for
|
|
54
|
+
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, run_analysis as _run_analysis, subtitle_codec_for
|
|
55
|
+
from _common.emit import file_identity
|
|
47
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
48
57
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
49
58
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
@@ -91,7 +100,44 @@ PRESETS: Dict[str, Dict] = {
|
|
|
91
100
|
"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"),
|
|
92
101
|
"reels": dict(_from_table("reels"), ext="mp4", video=_social(), audio=_AAC_128, desc="9:16 1080x1920, 30fps, max 90s (Instagram Reels)"),
|
|
93
102
|
"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)"),
|
|
94
|
-
"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, "
|
|
103
|
+
"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, "intermediate": True,
|
|
104
|
+
"expect": {"codec": "prores", "profile": "HQ", "pix_fmt": "yuv422p10le"}, "desc": "ProRes 422 HQ master, PCM audio, source resolution"},
|
|
105
|
+
# DNxHR (the dnxhd encoder's resolution-independent profiles): editing masters like prores, at
|
|
106
|
+
# the source's size and rate; the written profile and pixel format are checked after the write
|
|
107
|
+
**{f"dnxhr-{name}": {"w": None, "h": None, "ext": "mov",
|
|
108
|
+
"video": ["-c:v", "dnxhd", "-profile:v", f"dnxhr_{name}", "-pix_fmt", pix],
|
|
109
|
+
"audio": ["-c:a", "pcm_s16le"], "max": None, "intermediate": True,
|
|
110
|
+
"expect": {"codec": "dnxhd", "profile": f"DNXHR {name.upper()}", "pix_fmt": pix},
|
|
111
|
+
"desc": f"DNxHR {name.upper()} master ({desc}), PCM audio, source resolution"}
|
|
112
|
+
for name, pix, desc in (("lb", "yuv422p", "8-bit 4:2:2, low bandwidth"), ("sq", "yuv422p", "8-bit 4:2:2, standard"),
|
|
113
|
+
("hq", "yuv422p", "8-bit 4:2:2, high quality"), ("hqx", "yuv422p10le", "10-bit 4:2:2"),
|
|
114
|
+
("444", "yuv444p10le", "10-bit 4:4:4"))},
|
|
115
|
+
# Alpha masters (#417): editing masters that keep the source's alpha channel; the alpha is read
|
|
116
|
+
# back from a written frame and compared with the source's. A source without alpha is refused.
|
|
117
|
+
"prores4444": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
|
|
118
|
+
"video": ["-c:v", "prores_ks", "-profile:v", "4", "-vendor", "apl0", "-pix_fmt", "yuva444p10le", "-alpha_bits", "16"],
|
|
119
|
+
"audio": ["-c:a", "pcm_s16le"], "max": None, "expect": {"codec": "prores", "profile": "4444"},
|
|
120
|
+
"desc": "ProRes 4444 with alpha (16-bit alpha), PCM audio, source resolution"},
|
|
121
|
+
"prores4444xq": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
|
|
122
|
+
"video": ["-c:v", "prores_ks", "-profile:v", "5", "-vendor", "apl0", "-pix_fmt", "yuva444p10le", "-alpha_bits", "16"],
|
|
123
|
+
"audio": ["-c:a", "pcm_s16le"], "max": None, "expect": {"codec": "prores", "profile": "XQ"},
|
|
124
|
+
"desc": "ProRes 4444 XQ with alpha (16-bit alpha), PCM audio, source resolution"},
|
|
125
|
+
"qtrle": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
|
|
126
|
+
"video": ["-c:v", "qtrle", "-pix_fmt", "argb"], "audio": ["-c:a", "pcm_s16le"], "max": None,
|
|
127
|
+
"expect": {"codec": "qtrle", "pix_fmt": "argb"}, "desc": "QuickTime Animation (lossless RGB + alpha), PCM audio"},
|
|
128
|
+
"vp9-alpha": {"w": None, "h": None, "ext": "webm", "alpha": True, "intermediate": True,
|
|
129
|
+
"video": ["-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-crf", "20", "-b:v", "0", "-row-mt", "1"],
|
|
130
|
+
"audio": ["-c:a", "libopus", "-b:a", "128k"], "max": None, "expect": {"codec": "vp9"},
|
|
131
|
+
"desc": "WebM VP9 with alpha (CRF 20), Opus audio, source resolution"},
|
|
132
|
+
"hap-alpha": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True,
|
|
133
|
+
"video": ["-c:v", "hap", "-format", "hap_alpha"], "audio": ["-c:a", "pcm_s16le"], "max": None,
|
|
134
|
+
"expect": {"codec": "hap"}, "desc": "HAP Alpha (needs an ffmpeg built with snappy; size a multiple of 4), PCM audio"},
|
|
135
|
+
# Key and fill for a switcher's downstream keyer (#418): two ProRes 422 HQ files from one alpha
|
|
136
|
+
# source, the fill (colour) and the key (the alpha as a luma matte), frame for frame.
|
|
137
|
+
"key-fill": {"w": None, "h": None, "ext": "mov", "alpha": True, "intermediate": True, "keyfill": True,
|
|
138
|
+
"video": ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"],
|
|
139
|
+
"audio": ["-c:a", "pcm_s16le"], "max": None,
|
|
140
|
+
"desc": "fill + key pair (<out> and <out stem>_key.mov), ProRes 422 HQ, frame for frame, for a switcher's keyer"},
|
|
95
141
|
"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"},
|
|
96
142
|
"gif": {"w": 480, "h": None, "ext": "gif", "video": [], "audio": [], "max": None, "desc": "480px palette GIF, 12fps"},
|
|
97
143
|
"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)"},
|
|
@@ -126,6 +172,188 @@ PLATFORM_OF["youtube4k"] = PLATFORM_TABLE["youtube"]["check"]
|
|
|
126
172
|
PRESET_CHOICES: List[str] = sorted(set(PRESETS) | {a for a in _ALIASES if resolve_platform(a) in PRESETS})
|
|
127
173
|
|
|
128
174
|
|
|
175
|
+
# A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
|
|
176
|
+
_ALPHA_PIX = re.compile(r"^(yuva|gbrap|ya\d|rgba|bgra|argb|abgr|rgba64|bgra64)")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def vp9_decoder_args(codec: "str | None") -> List[str]:
|
|
180
|
+
"""The input option that decodes a VP9 stream with libvpx-vp9: FFmpeg's native vp9 decoder
|
|
181
|
+
drops the alpha layer (a WebM's yuva420p comes out yuv420p, fully opaque). [] for any other
|
|
182
|
+
codec, or when this build has no libvpx (its encoder list stands in for the decoder's)."""
|
|
183
|
+
return ["-c:v", "libvpx-vp9"] if codec == "vp9" and "libvpx-vp9" in ffmpeg_encoders() else []
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def vp9_alpha_tagged(path: str) -> bool:
|
|
187
|
+
"""True when the first video stream is VP9 stored with an alpha layer (Matroska/WebM's
|
|
188
|
+
ALPHA_MODE=1 stream tag): ffprobe reports such a stream as yuv420p, so the pixel format alone
|
|
189
|
+
does not show the alpha. A metadata read, so a dry run can decide without decoding."""
|
|
190
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
191
|
+
"stream=codec_name:stream_tags", "-of", "json", path], quiet=True)
|
|
192
|
+
try:
|
|
193
|
+
streams = json.loads(proc.stdout or "{}").get("streams") or []
|
|
194
|
+
except ValueError:
|
|
195
|
+
return False
|
|
196
|
+
s = streams[0] if streams else {}
|
|
197
|
+
return s.get("codec_name") == "vp9" and any(k.lower() == "alpha_mode" and str(v).strip() == "1"
|
|
198
|
+
for k, v in (s.get("tags") or {}).items())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_MEASURE_W, _MEASURE_H = 64, 36 # the measuring copy: small enough to read every frame, large enough for exact edges
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _frame_means(cmd_head: List[str], path: str, chain: str) -> "Dict[str, object] | None":
|
|
205
|
+
"""Every decoded frame of `path` through `chain` to a 64x36 grey copy: {mean (over every frame),
|
|
206
|
+
min (the lowest frame's mean), frames, per_frame (each frame's mean, in decode order)}. None
|
|
207
|
+
when no frame decodes."""
|
|
208
|
+
w, h = _MEASURE_W, _MEASURE_H
|
|
209
|
+
cmd = cmd_head + ["-i", path, "-map", "0:v:0", "-vf", f"{chain},scale={w}:{h}:flags=area,format=gray",
|
|
210
|
+
"-f", "rawvideo", "-"]
|
|
211
|
+
proc = _run_analysis(cmd, check=False, text=False)
|
|
212
|
+
data = proc.stdout or b""
|
|
213
|
+
size = w * h
|
|
214
|
+
if proc.returncode != 0 or len(data) < size:
|
|
215
|
+
return None
|
|
216
|
+
frames = [sum(data[i:i + size]) / size for i in range(0, len(data) - size + 1, size)]
|
|
217
|
+
return {"mean": sum(frames) / len(frames), "min": min(frames), "frames": len(frames), "per_frame": frames}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def alpha_stats(path: str, codec: "str | None" = None) -> "Dict[str, object] | None":
|
|
221
|
+
"""The alpha (0-255) of every decoded frame, on a 64x36 copy: {mean, min, frames, per_frame}.
|
|
222
|
+
255.0 is fully opaque, and a picture with no alpha plane reads 255 too (format=rgba fills it).
|
|
223
|
+
Every frame, not the first: a clip that opens fully opaque and turns transparent later would
|
|
224
|
+
read as opaque from its first frame, in the source and in a written file that lost its alpha.
|
|
225
|
+
A VP9 file is decoded with libvpx-vp9 (vp9_decoder_args). None when no frame decodes."""
|
|
226
|
+
head = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"] + vp9_decoder_args(codec)
|
|
227
|
+
return _frame_means(head, path, "format=rgba,alphaextract")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
ALPHA_TOLERANCE = 2.0 # of 255, per frame (or on the mean and the most transparent frame, below)
|
|
231
|
+
OPAQUE = 254.5 # a frame whose mean alpha is at least this is fully opaque
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _alpha_compare(written, source) -> Dict[str, object]:
|
|
235
|
+
"""The written file's alpha against the source's. With the same number of frames, frame by
|
|
236
|
+
frame (frame i against frame i, each within ALPHA_TOLERANCE): an aggregate let a loss confined
|
|
237
|
+
to a few frames through (one of 200 frames turned opaque moves the mean by ~1). With a
|
|
238
|
+
different count (a variable-rate source conformed to a constant rate repeats or drops frames,
|
|
239
|
+
so indices no longer pair), the mean and the most transparent frame, and `paired: false`
|
|
240
|
+
says so. {ok, paired, worst_delta, worst_frame}."""
|
|
241
|
+
if written is None or source is None:
|
|
242
|
+
return {"ok": False, "paired": None, "worst_delta": None, "worst_frame": None}
|
|
243
|
+
w, s = written.get("per_frame") or [], source.get("per_frame") or []
|
|
244
|
+
if w and len(w) == len(s):
|
|
245
|
+
deltas = [abs(a - b) for a, b in zip(w, s)]
|
|
246
|
+
worst = max(range(len(deltas)), key=deltas.__getitem__)
|
|
247
|
+
return {"ok": deltas[worst] <= ALPHA_TOLERANCE, "paired": True, "worst_delta": round(deltas[worst], 2),
|
|
248
|
+
"worst_frame": worst}
|
|
249
|
+
delta = max(abs(written["mean"] - source["mean"]), abs(written["min"] - source["min"]))
|
|
250
|
+
return {"ok": delta <= ALPHA_TOLERANCE, "paired": False, "worst_delta": round(delta, 2), "worst_frame": None}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _rounded(st) -> "Dict[str, float] | None":
|
|
254
|
+
return None if st is None else {"mean": round(st["mean"], 2), "min": round(st["min"], 2), "frames": st["frames"]}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def key_path(fill: str) -> str:
|
|
258
|
+
stem, _ = os.path.splitext(fill)
|
|
259
|
+
return f"{stem}_key.mov"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[str], has_audio: bool,
|
|
263
|
+
source_alpha, notes: List[str], subs: List[Dict], data: List[Dict]) -> int:
|
|
264
|
+
"""--preset key-fill: one decode split into the fill (the colour, straight or premultiplied)
|
|
265
|
+
and the key (alphaextract: alpha 255 is video white, 0 black), both ProRes 422 HQ in one ffmpeg
|
|
266
|
+
run so they hold the same frames; the source's start timecode is written on both. Checked
|
|
267
|
+
after: the same frame count, size, rate and length, the key's mean level against the source's
|
|
268
|
+
alpha, and both files hashed (output_identity, key.identity)."""
|
|
269
|
+
key = key_path(output)
|
|
270
|
+
# both files are this run's outputs, but ffmpeg's last argument (which run() guards and stages)
|
|
271
|
+
# is only the key: the fill is checked here the same way -- not the input under another
|
|
272
|
+
# spelling, not someone's file without --overwrite -- and written through a hidden temp file
|
|
273
|
+
guard_sidecar(output, args.input, siblings=[key])
|
|
274
|
+
guard_sidecar(key, args.input, siblings=[output])
|
|
275
|
+
if STATE.hw and "-c:v" in p["video"]:
|
|
276
|
+
note(f"--hw: --preset {args.preset} stays on {p['video'][p['video'].index('-c:v') + 1]}: one ffmpeg run writes "
|
|
277
|
+
"the fill and the key as a pair, and VideoToolbox's ProRes encoder is not used for it")
|
|
278
|
+
fill_chain = ",".join(vf + (["premultiply=inplace=1"] if args.alpha_mode == "premultiplied" else []) + ["format=yuv422p10le"])
|
|
279
|
+
key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
|
|
280
|
+
tc = ((meta.get("timecode") or {}).get("value"))
|
|
281
|
+
tc_args = ["-timecode", tc] if tc else []
|
|
282
|
+
cmd = cmd + ["-filter_complex", f"[0:v:0]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
|
|
283
|
+
cmd += ["-map", "[fill]"] + (["-map", "0:a:0"] if has_audio else []) + p["video"] + tc_args + cfr_args(meta)
|
|
284
|
+
# the fill is always written to a hidden sibling and moved into place only after the encode
|
|
285
|
+
# succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
|
|
286
|
+
# left a partial new fill (the retry was refused as output_exists) or truncated an existing one
|
|
287
|
+
fill_tmp = None
|
|
288
|
+
if not STATE.dry_run:
|
|
289
|
+
d, base = os.path.split(output)
|
|
290
|
+
stem, ext = os.path.splitext(base)
|
|
291
|
+
fill_tmp = os.path.join(d, f".{stem}.ffskill-fill-{os.getpid()}{ext}")
|
|
292
|
+
cmd += (p["audio"] if has_audio else ["-an"]) + [fill_tmp or output]
|
|
293
|
+
cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key]
|
|
294
|
+
try:
|
|
295
|
+
run(cmd)
|
|
296
|
+
if fill_tmp:
|
|
297
|
+
os.replace(fill_tmp, output)
|
|
298
|
+
finally:
|
|
299
|
+
if fill_tmp:
|
|
300
|
+
# the command is recorded with the path the caller asked for, not the temp name
|
|
301
|
+
STATE.commands[:] = [c.replace(fill_tmp, output) for c in STATE.commands]
|
|
302
|
+
if os.path.exists(fill_tmp):
|
|
303
|
+
os.remove(fill_tmp)
|
|
304
|
+
remember_written(output, key)
|
|
305
|
+
extra: Dict[str, object] = {"alpha_mode": args.alpha_mode or "straight", "key": {"output": key}}
|
|
306
|
+
# a fill and a key for a switcher carry the picture and the sound; the source's subtitle and
|
|
307
|
+
# data streams are not written, and the document says so (as for a GIF)
|
|
308
|
+
non_av = _non_av_report(subs, [], data, ["subtitle", "data"], output, "key_fill")
|
|
309
|
+
extra["non_av_streams"] = non_av
|
|
310
|
+
extra["dropped_non_av_streams"] = bool(non_av["dropped"])
|
|
311
|
+
if non_av["dropped"]:
|
|
312
|
+
notes.append(f"a key/fill pair holds the picture and the sound only: the source's {len(non_av['dropped'])} "
|
|
313
|
+
"subtitle/data stream(s) were not written; non_av_streams lists them")
|
|
314
|
+
if STATE.dry_run:
|
|
315
|
+
emit(output, **extra, **({"notes": notes} if notes else {}))
|
|
316
|
+
return 0
|
|
317
|
+
fill_m, key_m = probe(output, role="output"), probe(key, role="output")
|
|
318
|
+
fv, kv = fill_m["video"], key_m["video"]
|
|
319
|
+
pair = {k: [fv.get(k), kv.get(k)] for k in ("width", "height", "nb_frames", "r_frame_rate")}
|
|
320
|
+
# the video streams' lengths: the fill's container also counts its sound, which may run longer
|
|
321
|
+
# than the picture, and the key has none
|
|
322
|
+
pair["duration"] = [round(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
|
|
323
|
+
same = all(a == b and a not in (None, 0) for a, b in pair.values())
|
|
324
|
+
steps = [{"step": "key_fill_pair", "property": "frames_size_rate_length", "ok": same, "measured": pair,
|
|
325
|
+
"provenance": "output_measurement"}]
|
|
326
|
+
key_level = _luma_stats(key)
|
|
327
|
+
level = _alpha_compare(key_level, source_alpha)
|
|
328
|
+
steps.append({"step": "key_level", "property": "key_mean_vs_source_alpha", "ok": level["ok"], "comparison": level,
|
|
329
|
+
"expected": None if source_alpha is None else round(source_alpha["mean"], 2),
|
|
330
|
+
"measured": None if key_level is None else round(key_level["mean"], 2), "tolerance": ALPHA_TOLERANCE,
|
|
331
|
+
"frames": {"expected": _rounded(source_alpha), "measured": _rounded(key_level)},
|
|
332
|
+
"provenance": "input_premeasurement"})
|
|
333
|
+
extra["key"] = {"output": key, "identity": file_identity(key), "sha256": _sha256(key), "timecode": tc}
|
|
334
|
+
extra["fill_sha256"] = _sha256(output)
|
|
335
|
+
info(f"wrote {output} (fill) and {key} (key), {fv['width']}x{fv['height']}, {fv.get('nb_frames')} frames each")
|
|
336
|
+
emit(output, verification=steps, expect_streams={"video": 1, "audio": 1 if has_audio else 0}, **extra,
|
|
337
|
+
**({"notes": notes} if notes else {}))
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _sha256(path: str) -> str:
|
|
342
|
+
"""The whole file's SHA-256: a key/fill pair is handed to a playout system as two files that
|
|
343
|
+
must stay a pair, and the hashes let it check that neither was replaced."""
|
|
344
|
+
import hashlib
|
|
345
|
+
h = hashlib.sha256()
|
|
346
|
+
with open(path, "rb") as fh:
|
|
347
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
348
|
+
h.update(chunk)
|
|
349
|
+
return h.hexdigest()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _luma_stats(path: str) -> "Dict[str, float] | None":
|
|
353
|
+
"""The level (0-255) of every frame of a file, on a 64x36 grey copy: {mean, min, frames}."""
|
|
354
|
+
return _frame_means([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"], path, "format=gray")
|
|
355
|
+
|
|
356
|
+
|
|
129
357
|
def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False) -> None:
|
|
130
358
|
"""The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse): the
|
|
131
359
|
platform's loudness was not verified, and the document says so -- a `loudness` block with null
|
|
@@ -315,6 +543,9 @@ def main() -> int:
|
|
|
315
543
|
help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
|
|
316
544
|
"running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
|
|
317
545
|
ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
|
|
546
|
+
ap.add_argument("--alpha-mode", choices=["straight", "premultiplied"],
|
|
547
|
+
help="alpha presets (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): write the colour straight "
|
|
548
|
+
"(default, as decoded) or premultiplied by the alpha; written to the result as alpha_mode")
|
|
318
549
|
ap.add_argument("--list", action="store_true", help="list presets and exit")
|
|
319
550
|
add_common(ap, codec=False) # the preset decides the codec; --codec would only be refused
|
|
320
551
|
args = ap.parse_args()
|
|
@@ -344,13 +575,36 @@ def main() -> int:
|
|
|
344
575
|
die(f"--preset {args.preset} delivers HDR and this source is SDR ({meta['video'].get('codec')}, "
|
|
345
576
|
f"{meta['video'].get('color_transfer') or 'untagged'})",
|
|
346
577
|
hint="use --preset youtube for an SDR delivery; there is no way to invent HDR range from an SDR master")
|
|
347
|
-
if meta["video"].get("bt2020_or_hdr") and args.preset not in ("
|
|
578
|
+
if meta["video"].get("bt2020_or_hdr") and not p.get("intermediate") and args.preset not in ("copy", "youtube-hdr"):
|
|
348
579
|
# 2.0: `hdr` is a real PQ/HLG/DV signal; bt2020_or_hdr also covers BT.2020 SDR, which is
|
|
349
580
|
# not HDR but still needs converting before BT.709 tags describe it (eval 24 h1)
|
|
350
581
|
kind = "HDR" if meta["video"].get("hdr") else "wide-gamut SDR, not HDR"
|
|
351
582
|
notes.append("source is %s (%s). This preset outputs SDR BT.709 tags without converting it; run color.py --to-sdr first for correct colours." % (kind, meta["video"].get("hdr_format")))
|
|
352
583
|
warn(notes[-1], "colour_not_converted", preset=args.preset, hdr_format=meta["video"].get("hdr_format"))
|
|
353
584
|
has_audio = bool(meta.get("audio"))
|
|
585
|
+
source_alpha = None
|
|
586
|
+
if p.get("alpha"):
|
|
587
|
+
encoder = p["video"][p["video"].index("-c:v") + 1]
|
|
588
|
+
encoders = ffmpeg_encoders()
|
|
589
|
+
if encoders and encoder not in encoders:
|
|
590
|
+
die(f"--preset {args.preset} needs the {encoder} encoder and this ffmpeg build has none"
|
|
591
|
+
+ (" (HAP needs an ffmpeg built with snappy)" if encoder == "hap" else ""),
|
|
592
|
+
kind="missing_tool", error_extra={"reason": "encoder_missing", "details": {"name": encoder}},
|
|
593
|
+
hint="python3 scripts/_contract.py doctor lists the encoders this build has")
|
|
594
|
+
if encoder == "hap" and (meta["video"]["width"] % 4 or meta["video"]["height"] % 4):
|
|
595
|
+
die(f"--preset hap-alpha needs a width and height that are multiples of 4 (HAP's texture blocks); "
|
|
596
|
+
f"this source is {meta['video']['width']}x{meta['video']['height']}",
|
|
597
|
+
hint="crop or pad it first (crop.py, pad.py)")
|
|
598
|
+
# a pixel format with an alpha plane, or a VP9 stream stored with one (probed as yuv420p);
|
|
599
|
+
# a dry run decides on these metadata reads alone and decodes nothing
|
|
600
|
+
has_alpha_plane = bool(_ALPHA_PIX.match(meta["video"].get("pix_fmt") or "")) or vp9_alpha_tagged(args.input)
|
|
601
|
+
if not STATE.dry_run:
|
|
602
|
+
source_alpha = alpha_stats(args.input, meta["video"].get("codec"))
|
|
603
|
+
if not has_alpha_plane and (STATE.dry_run or source_alpha is None or source_alpha["min"] >= OPAQUE):
|
|
604
|
+
die(f"--preset {args.preset} keeps an alpha channel and this source has none "
|
|
605
|
+
f"({meta['video'].get('pix_fmt')}, fully opaque): use prores or dnxhr-* for an opaque master")
|
|
606
|
+
elif args.alpha_mode:
|
|
607
|
+
die("--alpha-mode applies only to the alpha presets (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha)")
|
|
354
608
|
# copy keeps the source's container, so its default name keeps the source's extension (as
|
|
355
609
|
# metadata.py's does): default_output()'s .webm -> .mp4 is for the re-encoding presets
|
|
356
610
|
output = args.output or default_output(args.input, args.preset, p["ext"] or Path(args.input).suffix.lstrip(".") or None)
|
|
@@ -382,7 +636,12 @@ def main() -> int:
|
|
|
382
636
|
if args.max_mb is not None and args.preset == "gif":
|
|
383
637
|
# the GIF branch below returns before the size is ever applied or measured
|
|
384
638
|
die("--max-mb runs a two-pass x264 encode; --preset gif does not encode with x264 (shorten or shrink the GIF instead)")
|
|
385
|
-
|
|
639
|
+
if args.max_mb is not None and p.get("keyfill"):
|
|
640
|
+
# the key/fill branch below writes ProRes and returns before the size is applied or measured
|
|
641
|
+
die("--max-mb runs a two-pass x264 encode; --preset key-fill writes ProRes (a fill and a key), which has no size target")
|
|
642
|
+
# an alpha source in VP9 is decoded with libvpx-vp9, or its alpha never reaches the encoder
|
|
643
|
+
dec_args = vp9_decoder_args(meta["video"].get("codec")) if p.get("alpha") else []
|
|
644
|
+
cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + dec_args + ["-i", args.input]
|
|
386
645
|
# Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
|
|
387
646
|
# kept where the container can hold them: ffmpeg's default stream selection takes one video
|
|
388
647
|
# and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
|
|
@@ -390,7 +649,7 @@ def main() -> int:
|
|
|
390
649
|
subs = meta.get("subtitle_stream_details") or []
|
|
391
650
|
data = _data_streams(args.input) if meta.get("data_streams") else []
|
|
392
651
|
sub_codecs = [_subtitle_codec(s.get("codec") or "", out_ext) for s in subs]
|
|
393
|
-
if args.preset != "gif" and (subs or data):
|
|
652
|
+
if args.preset != "gif" and not p.get("keyfill") and (subs or data):
|
|
394
653
|
cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
|
|
395
654
|
|
|
396
655
|
if args.preset == "gif":
|
|
@@ -405,6 +664,10 @@ def main() -> int:
|
|
|
405
664
|
emit(output, non_av_streams=non_av, dropped_non_av_streams=bool(non_av["dropped"]), **({"notes": notes} if notes else {}))
|
|
406
665
|
return 0
|
|
407
666
|
|
|
667
|
+
if p.get("keyfill"):
|
|
668
|
+
return _key_fill(args, p, meta, output, cmd, vf, has_audio, source_alpha, notes, subs, data)
|
|
669
|
+
if p.get("alpha") and args.alpha_mode == "premultiplied":
|
|
670
|
+
vf.append("premultiply=inplace=1")
|
|
408
671
|
if vf:
|
|
409
672
|
cmd += ["-vf", ",".join(vf)]
|
|
410
673
|
video = list(p["video"])
|
|
@@ -425,9 +688,14 @@ def main() -> int:
|
|
|
425
688
|
video[video.index("-crf") + 1] = str(args.crf)
|
|
426
689
|
if STATE.fast and "-preset" in video and not p.get("codec"):
|
|
427
690
|
video[video.index("-preset") + 1] = "veryfast"
|
|
428
|
-
# a fixed preset's line is tagged BT.709 (prores,
|
|
429
|
-
tag_bt709 =
|
|
430
|
-
if
|
|
691
|
+
# a fixed preset's line is tagged BT.709 (prores and dnxhr, editing masters, keep the source's tags)
|
|
692
|
+
tag_bt709 = not p.get("intermediate") and not p.get("codec")
|
|
693
|
+
if p.get("alpha") and STATE.hw and video and video[video.index("-c:v") + 1] == "prores_ks":
|
|
694
|
+
# VideoToolbox's ProRes line is 422 HQ: under --hw a 4444 alpha master would come out
|
|
695
|
+
# opaque, so these presets stay on prores_ks and say so
|
|
696
|
+
note(f"--hw: --preset {args.preset} stays on prores_ks: VideoToolbox's ProRes encoder writes 422 HQ, "
|
|
697
|
+
"without the alpha this preset keeps")
|
|
698
|
+
elif not p.get("codec") and video:
|
|
431
699
|
# --hw (never $FFMPEG_SKILL_HW here) puts a fixed preset on VideoToolbox: the same codec,
|
|
432
700
|
# its CRF mapped to -q:v, the preset's frame rate kept; tags come with the VT line itself.
|
|
433
701
|
# A build without libx264/libx265 has no other encoder, so it goes there without --hw too
|
|
@@ -443,8 +711,8 @@ def main() -> int:
|
|
|
443
711
|
cmd += cfr_args(meta)
|
|
444
712
|
if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
|
|
445
713
|
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
446
|
-
if
|
|
447
|
-
# every line but
|
|
714
|
+
if not p.get("intermediate"):
|
|
715
|
+
# every line but an editing master's (prores, dnxhr) says BT.709 (the fixed presets' tags above, an
|
|
448
716
|
# SDR hevc/av1 line's own): a BT.601 source's pixels are converted to match, in front
|
|
449
717
|
# of the -vf above (run() folds the two); an untagged, BT.709 or HDR source adds nothing
|
|
450
718
|
cmd += bt709_matrix_args(meta)
|
|
@@ -480,6 +748,28 @@ def main() -> int:
|
|
|
480
748
|
v = result["video"]
|
|
481
749
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
|
|
482
750
|
extra: Dict[str, object] = {"trimmed": trimmed} if trimmed else {}
|
|
751
|
+
encoder = video[video.index("-c:v") + 1] if "-c:v" in video else None
|
|
752
|
+
if p.get("expect") and not STATE.dry_run and encoder == p["video"][p["video"].index("-c:v") + 1]:
|
|
753
|
+
# an editing master is asked for by codec, profile and pixel format: read all three back
|
|
754
|
+
# (a VideoToolbox prores line under --hw writes its own pixel format, so it is not compared)
|
|
755
|
+
measured = {k: v.get(k) for k in p["expect"]}
|
|
756
|
+
extra.setdefault("verification", []).append(
|
|
757
|
+
{"step": "codec", "property": "codec_profile_pix_fmt", "ok": measured == p["expect"],
|
|
758
|
+
"expected": dict(p["expect"]), "measured": measured, "provenance": "specification"})
|
|
759
|
+
if p.get("alpha"):
|
|
760
|
+
extra["alpha_mode"] = args.alpha_mode or "straight"
|
|
761
|
+
if not STATE.dry_run:
|
|
762
|
+
# the alpha survived when every written frame's alpha matches the source frame's (an
|
|
763
|
+
# encoder, decoder or muxer that drops it reads fully opaque); premultiplying changes
|
|
764
|
+
# colour, not alpha
|
|
765
|
+
written_alpha = alpha_stats(output, v.get("codec"))
|
|
766
|
+
cmp = _alpha_compare(written_alpha, source_alpha)
|
|
767
|
+
extra.setdefault("verification", []).append(
|
|
768
|
+
{"step": "alpha", "property": "alpha_mean", "ok": cmp["ok"], "comparison": cmp,
|
|
769
|
+
"expected": None if source_alpha is None else round(source_alpha["mean"], 2),
|
|
770
|
+
"measured": None if written_alpha is None else round(written_alpha["mean"], 2), "tolerance": ALPHA_TOLERANCE,
|
|
771
|
+
"frames": {"expected": _rounded(source_alpha), "measured": _rounded(written_alpha)},
|
|
772
|
+
"provenance": "input_premeasurement"})
|
|
483
773
|
platform = PLATFORM_OF.get(args.preset)
|
|
484
774
|
if platform and has_audio and not STATE.dry_run:
|
|
485
775
|
# Every eval run that exported for a platform then had to come back with loudness.py: the
|
package/scripts/look.py
CHANGED
|
@@ -8,6 +8,7 @@ Examples:
|
|
|
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
10
|
python3 look.py reel.mp4 --safe tiktok --at 3 # shade what TikTok's own UI covers
|
|
11
|
+
python3 look.py show.mp4 --guides title,action,4:3 --at 10 # broadcast safe-area outlines
|
|
11
12
|
Then view the PNG (Read tool / image viewer) and verify before reporting.
|
|
12
13
|
"""
|
|
13
14
|
import argparse
|
|
@@ -25,6 +26,36 @@ from _common.emit import note, record_fallback # noqa: E402
|
|
|
25
26
|
from _common.color import bt709_label_filter # noqa: E402
|
|
26
27
|
from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
|
|
27
28
|
|
|
29
|
+
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
30
|
+
# Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
|
|
31
|
+
# is the 4:3 window of a 16:9 frame with SD's own 90 % action / 80 % title safe inside it.
|
|
32
|
+
GUIDES = {"action": 0.93, "title": 0.90}
|
|
33
|
+
GUIDE_43 = {"action": 0.90, "title": 0.80}
|
|
34
|
+
GUIDE_COLOURS = {"action": "yellow", "title": "cyan", "4:3": "white"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def guide_boxes(names) -> "tuple":
|
|
38
|
+
"""drawbox outlines for --guides and the fractions they stand for (the result's `guides`)."""
|
|
39
|
+
t = "max(1\\,ih/270)"
|
|
40
|
+
boxes, described = [], []
|
|
41
|
+
for name in names:
|
|
42
|
+
if name in GUIDES:
|
|
43
|
+
f = GUIDES[name]
|
|
44
|
+
m = (1 - f) / 2
|
|
45
|
+
boxes.append(f"drawbox=x=iw*{m:g}:y=ih*{m:g}:w=iw*{f:g}:h=ih*{f:g}:color={GUIDE_COLOURS[name]}@0.9:t={t}")
|
|
46
|
+
described.append({"guide": name, "fraction": f, "of": "frame"})
|
|
47
|
+
else: # "4:3": the centre-cut window, then its own action and title safe
|
|
48
|
+
w = "ih*4/3"
|
|
49
|
+
boxes.append(f"drawbox=x=(iw-{w})/2:y=0:w={w}:h=ih:color=white@0.9:t={t}")
|
|
50
|
+
for sub, f in GUIDE_43.items():
|
|
51
|
+
m = (1 - f) / 2
|
|
52
|
+
boxes.append(f"drawbox=x=(iw-{w})/2+{w}*{m:g}:y=ih*{m:g}:w={w}*{f:g}:h=ih*{f:g}:"
|
|
53
|
+
f"color={GUIDE_COLOURS[sub]}@0.6:t={t}")
|
|
54
|
+
described.append({"guide": "4:3", "window": "centre 4:3 of the frame", "action": GUIDE_43["action"],
|
|
55
|
+
"title": GUIDE_43["title"]})
|
|
56
|
+
return boxes, described
|
|
57
|
+
|
|
58
|
+
|
|
28
59
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
29
60
|
|
|
30
61
|
|
|
@@ -157,6 +188,10 @@ def main() -> int:
|
|
|
157
188
|
ap.add_argument("--no-timecode", action="store_true")
|
|
158
189
|
ap.add_argument("--safe", choices=PLATFORM_CHOICES, help="shade the zones this platform's UI covers (its description bar, "
|
|
159
190
|
"like column, status bar) so you can see whether anything readable is under them")
|
|
191
|
+
ap.add_argument("--guides", metavar="LIST",
|
|
192
|
+
help="draw broadcast safe-area outlines on the PNG (a preview only; nothing is written into a video): "
|
|
193
|
+
"a comma list of action (93%%), title (90%%) and 4:3 (the 4:3 centre cut of a 16:9 frame, "
|
|
194
|
+
"with its 90%% action and 80%% title safe)")
|
|
160
195
|
ap.add_argument("--ink", action="store_true", help="measure non-background pixels in each written PNG (bbox, "
|
|
161
196
|
"ink_fraction, row_bands per tile for a contact sheet) instead of "
|
|
162
197
|
"just eyeballing it -- a pixel-only signal, not a legibility judgement")
|
|
@@ -217,6 +252,17 @@ def main() -> int:
|
|
|
217
252
|
safe_filter = "," + ",".join(boxes) if boxes else ""
|
|
218
253
|
info(f"--safe {args.safe}: shaded top {z['top'] * 100:.0f}% / bottom {z['bottom'] * 100:.0f}% / "
|
|
219
254
|
f"left {z['left'] * 100:.0f}% / right {z['right'] * 100:.0f}% of the frame -- keep text out of those")
|
|
255
|
+
guides = None
|
|
256
|
+
if args.guides:
|
|
257
|
+
names = [g.strip() for g in args.guides.split(",") if g.strip()]
|
|
258
|
+
bad = [g for g in names if g not in ("action", "title", "4:3")]
|
|
259
|
+
if bad or not names:
|
|
260
|
+
die(f"--guides takes action, title and 4:3, got {args.guides!r}")
|
|
261
|
+
dw, dh = display_size(meta["video"])
|
|
262
|
+
if "4:3" in names and dw and dh and dw / dh < 4 / 3 + 0.01:
|
|
263
|
+
die(f"--guides 4:3 draws the 4:3 centre cut of a wider frame; this source is {dw}x{dh}")
|
|
264
|
+
boxes, guides = guide_boxes(list(dict.fromkeys(names)))
|
|
265
|
+
safe_filter += "," + ",".join(boxes)
|
|
220
266
|
tc = safe_filter + tc
|
|
221
267
|
# HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
|
|
222
268
|
if meta["video"].get("bt2020_or_hdr"):
|
|
@@ -242,6 +288,11 @@ def main() -> int:
|
|
|
242
288
|
if not args.at:
|
|
243
289
|
die("--compare needs --at TIME")
|
|
244
290
|
cmeta = probe(args.compare)
|
|
291
|
+
if guides and any(g.get("guide") == "4:3" for g in guides):
|
|
292
|
+
# the guides are drawn on both pictures: the second must be wider than 4:3 as well
|
|
293
|
+
cw, ch = display_size(cmeta.get("video") or {})
|
|
294
|
+
if cw and ch and cw / ch < 4 / 3 + 0.01:
|
|
295
|
+
die(f"--guides 4:3 draws the 4:3 centre cut of a wider frame; the --compare clip is {cw}x{ch}")
|
|
245
296
|
lab2 = bt709_label_filter(cmeta)
|
|
246
297
|
lab2 = lab2 + "," if lab2 else ""
|
|
247
298
|
csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
|
|
@@ -256,15 +307,18 @@ def main() -> int:
|
|
|
256
307
|
out = os.path.join(outdir, f"{stem_part}_vs_{Path(args.compare).stem}_{idx}_{sec:.3f}s.png")
|
|
257
308
|
half = args.width // 2
|
|
258
309
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
259
|
-
|
|
310
|
+
# the tone map and the --safe / --guides boxes go on each picture as scaled, before the
|
|
311
|
+
# pad: drawn after it, a picture letterboxed in the box got guides from the padded
|
|
312
|
+
# height (a 2:1 clip beside a 16:9 one); the timecode stamp is drawn on the box
|
|
313
|
+
pre = tc.replace("," + timecode_filter(font_prefix), "")
|
|
260
314
|
# each picture keeps its own aspect inside a half-width box as tall as the taller of the
|
|
261
315
|
# two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
|
|
262
316
|
# second's size, so a 4:3 beside a 16:9 came out squashed (#411)
|
|
263
317
|
box_h = max(_half_height(half, display_size(meta["video"])),
|
|
264
318
|
_half_height(half, display_size(cmeta.get("video"))))
|
|
265
|
-
fit = (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease,"
|
|
266
|
-
f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1")
|
|
267
|
-
fc = (f"[0:v]{lab}{sq}{fit}
|
|
319
|
+
fit = (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{pre},"
|
|
320
|
+
f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
|
|
321
|
+
fc = (f"[0:v]{lab}{sq}{fit}[a];[1:v]{lab2}{csq}{fit}[b];"
|
|
268
322
|
f"[a][b]hstack=inputs=2[out]")
|
|
269
323
|
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
|
|
270
324
|
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
|
@@ -341,7 +395,8 @@ def main() -> int:
|
|
|
341
395
|
else:
|
|
342
396
|
ink = {"frames": [measure_ink(o) for o in outputs]}
|
|
343
397
|
|
|
344
|
-
emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs, **({"ink": ink} if ink is not None else {})
|
|
398
|
+
emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs, **({"ink": ink} if ink is not None else {}),
|
|
399
|
+
**({"guides": guides} if guides else {}))
|
|
345
400
|
if len(outputs) > 1 and not args.json:
|
|
346
401
|
for o in outputs:
|
|
347
402
|
print(o)
|