ffmpeg-skill 3.6.0 → 3.6.2
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 +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -9
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +235 -2
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +65 -3
- package/scripts/_common/decision.py +89 -1
- package/scripts/_common/emit.py +31 -1
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +280 -22
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +45 -16
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +158 -18
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +46 -21
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/export.py +2 -2
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +101 -25
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/cut.py
CHANGED
|
@@ -62,7 +62,8 @@ from _common.runner import fail_if_classified
|
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
63
63
|
from _common import require_tool
|
|
64
64
|
from _common.probe import TS_EXTS, signatures_match, stream_signature
|
|
65
|
-
from _common.decision import grid_at_or_after
|
|
65
|
+
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
66
|
+
from _common.timeline import output_timecode_args
|
|
66
67
|
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
67
68
|
|
|
68
69
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
@@ -352,6 +353,28 @@ def copy_presentation(t: float, key, edit_list: bool, fps) -> dict:
|
|
|
352
353
|
return {"start_snapped": abs(k_pts - t) > frame + 1e-6, "stored_preroll_seconds": None}
|
|
353
354
|
|
|
354
355
|
|
|
356
|
+
def first_frame_from(src: str, meta: dict, start: float, reencode: bool, edit_list: bool):
|
|
357
|
+
"""Seconds from the source picture's first frame to the frame a cut from `start` begins on,
|
|
358
|
+
for its start timecode: the first frame at or after `start` for a re-encode and an edit-listed
|
|
359
|
+
copy that hides its keyframe's pre-roll, the keyframe for any other copy (it shows the
|
|
360
|
+
pre-roll). None when where a copy lands cannot be read, and for an MXF source, whose demuxer
|
|
361
|
+
lands a seek on another keyframe than seek_keyframe's read (-ss 1.5: the one at 1.44 s, read as
|
|
362
|
+
the one at 0.96 s), so no label would be known to be right."""
|
|
363
|
+
v0, rate = stream_starts(meta)[0], frame_rate(meta)
|
|
364
|
+
if reencode:
|
|
365
|
+
# a start before the picture's first frame (a video that starts after the file's origin)
|
|
366
|
+
# begins on that first frame, not on a grid frame before it
|
|
367
|
+
return grid_at_or_after(max(start, v0), v0, rate) - v0
|
|
368
|
+
if "mxf" in str(meta.get("format") or "").split(","):
|
|
369
|
+
return None
|
|
370
|
+
key = seek_keyframe(src, start)
|
|
371
|
+
if key is None:
|
|
372
|
+
return None
|
|
373
|
+
if edit_list and key[0] <= start + 1e-6:
|
|
374
|
+
return None if key[2] is None else key[2] - v0
|
|
375
|
+
return key[0] - v0
|
|
376
|
+
|
|
377
|
+
|
|
355
378
|
def av_skew(out_meta: dict, edit_list_remedy: bool = False, expected: float = 0.0):
|
|
356
379
|
"""(seconds audio starts after video, warning note or None); None when either is missing.
|
|
357
380
|
A lossless cut can start its streams apart -- Core Media HEVC once gave 3.7 s of sound with no
|
|
@@ -891,6 +914,9 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
891
914
|
edit_list = (edit_list_ok or copy_t is not None) and os.path.splitext(dst)[1].lower() in EDIT_LIST_EXTS
|
|
892
915
|
cmd = (ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur if copy_t is None else copy_t:.6f}"]
|
|
893
916
|
+ copy_args(meta, dst) + ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
|
|
917
|
+
if not audio_only and (meta.get("timecode") or {}).get("value"):
|
|
918
|
+
cmd[-1:-1] = output_timecode_args(meta, first_frame_from(src, meta, start, reencode, edit_list and copy_t is None),
|
|
919
|
+
delivered_rate(meta) if reencode else None)
|
|
894
920
|
proc = run(cmd, check=False)
|
|
895
921
|
if proc.returncode != 0:
|
|
896
922
|
if not reencode:
|
|
@@ -956,9 +982,6 @@ JOIN_CHUNK = 32
|
|
|
956
982
|
# plays as a click: measured on a 440 Hz tone, the step at the join was 14x the tone's own largest
|
|
957
983
|
# sample-to-sample change. A 20 ms fade is too short to hear as a dip and long enough to remove it.
|
|
958
984
|
DECLICK_S = 0.02
|
|
959
|
-
# how far before a segment the fallback seeks, so a start inside the B-frame reorder delay before a
|
|
960
|
-
# keyframe still decodes from the keyframe BEFORE it
|
|
961
|
-
SEEK_MARGIN = 1.0
|
|
962
985
|
# codecs whose configuration travels in the packets, so a part with no extradata is not missing any
|
|
963
986
|
_PARTS: dict = {}
|
|
964
987
|
|
|
@@ -1102,7 +1125,9 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1102
1125
|
# the source's rate, stated: FFmpeg 7.0 encoded the trimmed, concatenated pictures at 25 fps
|
|
1103
1126
|
# and dropped frames to fit (98 frames for 117 at 30 fps)
|
|
1104
1127
|
rate_out = ["-r", rate_arg] if has_v and rate_arg and rate and "-r" not in codec else []
|
|
1105
|
-
|
|
1128
|
+
# a join's frames after the first range are not the source's next frames: no start timecode
|
|
1129
|
+
# labels them, so the output carries none (a single range's cut moves it to its first frame)
|
|
1130
|
+
cmd += ["-sn", "-dn"] + codec + rate_out + ([] if intermediate or not has_v else output_timecode_args(meta, None)) + [dst]
|
|
1106
1131
|
run(cmd)
|
|
1107
1132
|
return codec
|
|
1108
1133
|
|
|
@@ -1122,7 +1147,7 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
|
|
|
1122
1147
|
if len(segments) <= JOIN_CHUNK:
|
|
1123
1148
|
_join_chunk(src, segments, dst, meta, crf, preset, has_v, vend=vend, hold=hold)
|
|
1124
1149
|
return
|
|
1125
|
-
ext = os.path.splitext(dst)[1] or ".mp4"
|
|
1150
|
+
ext = os.path.splitext(dst)[1].lower() or ".mp4" # IMG_0001.MOV is a .mov
|
|
1126
1151
|
# Matroska chunks with PCM audio: no edit lists and no AAC priming to carry into the join (MP4
|
|
1127
1152
|
# chunks measured the video 23 ms late against the audio after a copy concat)
|
|
1128
1153
|
# the chunks' codec tag (hvc1), which a stream copy out of Matroska does not carry into an MP4
|
|
@@ -1163,7 +1188,8 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
|
|
|
1163
1188
|
audio = [] if not meta.get("audio") else (aac_args() if has_v else audio_codec_for(dst))
|
|
1164
1189
|
run(ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
1165
1190
|
+ (["-c:v", "copy"] + (tag if ext in (".mp4", ".mov", ".m4v") else []) if has_v else ["-vn"]) + audio
|
|
1166
|
-
+ (["-movflags", "+faststart"] if ext in (".mp4", ".mov", ".m4v") and has_v else [])
|
|
1191
|
+
+ (["-movflags", "+faststart"] if ext in (".mp4", ".mov", ".m4v") and has_v else [])
|
|
1192
|
+
+ (output_timecode_args(meta, None) if has_v else []) + [dst])
|
|
1167
1193
|
|
|
1168
1194
|
|
|
1169
1195
|
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
@@ -1333,7 +1359,7 @@ def run_smart(args, segments, output: str, meta: dict, forced: List[str], hold_n
|
|
|
1333
1359
|
# line can outgrow Windows' limit, and the full re-encode joins in chunks of this size
|
|
1334
1360
|
return fallback(f"{len(segments)} segments with sound: the smart join takes at most {JOIN_CHUNK}, the "
|
|
1335
1361
|
"full re-encode joins them in chunks", "segments")
|
|
1336
|
-
ext = os.path.splitext(output)[1] or ".mp4"
|
|
1362
|
+
ext = os.path.splitext(output)[1].lower() or ".mp4"
|
|
1337
1363
|
params = _smartcut.stream_params(args.input)
|
|
1338
1364
|
bad = _smartcut.ineligible(params, meta, ext)
|
|
1339
1365
|
if bad:
|
|
@@ -1510,7 +1536,7 @@ def main() -> int:
|
|
|
1510
1536
|
|
|
1511
1537
|
output = args.output or default_output(args.input, "cut")
|
|
1512
1538
|
refuse_output_is_input(output, args.input)
|
|
1513
|
-
ext = os.path.splitext(output)[1] or ".mp4"
|
|
1539
|
+
ext = os.path.splitext(output)[1].lower() or ".mp4" # an upper-case .MP4 is still faststart
|
|
1514
1540
|
|
|
1515
1541
|
# A segment that runs past the end of the video, with a join after it: whatever joins it (the
|
|
1516
1542
|
# concat demuxer or the concat filter) starts the next segment after its LONGER stream, so the
|
|
@@ -1693,6 +1719,8 @@ def main() -> int:
|
|
|
1693
1719
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy"]
|
|
1694
1720
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
1695
1721
|
cmd += ["-movflags", "+faststart"]
|
|
1722
|
+
if not is_audio_output(output):
|
|
1723
|
+
cmd += output_timecode_args(meta, None) # a join: no start timecode (_join_chunk)
|
|
1696
1724
|
cmd += [joined]
|
|
1697
1725
|
proc = run(cmd, check=False)
|
|
1698
1726
|
fail_if_classified(proc) # #415: a full disk is not a reason to re-cut every segment
|
package/scripts/export.py
CHANGED
|
@@ -51,7 +51,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
|
|
|
51
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
53
53
|
from _common.probe import square_pixels, square_pixels_note
|
|
54
|
-
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, run_analysis as _run_analysis, subtitle_codec_for
|
|
54
|
+
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
|
|
55
55
|
from _common.emit import file_identity
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
57
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
@@ -213,7 +213,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
213
213
|
if not STATE.dry_run:
|
|
214
214
|
d, base = os.path.split(output)
|
|
215
215
|
stem, ext = os.path.splitext(base)
|
|
216
|
-
fill_tmp =
|
|
216
|
+
fill_tmp = reserve_sibling(d, stem, ext, "fill") # created exclusively: never through a planted link
|
|
217
217
|
cmd += (p["audio"] if has_audio else ["-an"]) + [fill_tmp or output]
|
|
218
218
|
cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key]
|
|
219
219
|
try:
|
package/scripts/fit.py
CHANGED
|
@@ -39,6 +39,8 @@ import sys
|
|
|
39
39
|
from fractions import Fraction
|
|
40
40
|
from typing import List
|
|
41
41
|
|
|
42
|
+
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
43
|
+
from _common.timeline import exact_rate, output_timecode_args
|
|
42
44
|
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, 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, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
43
45
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
44
46
|
|
|
@@ -136,6 +138,7 @@ def main() -> int:
|
|
|
136
138
|
pre_input: List[str] = []
|
|
137
139
|
post: List[str] = []
|
|
138
140
|
factor = 1.0
|
|
141
|
+
tc_first = None # seconds from the source's first frame to the output's, when a trim moves it
|
|
139
142
|
|
|
140
143
|
# ---- rotate / flip
|
|
141
144
|
if args.rotate == 90:
|
|
@@ -175,8 +178,18 @@ def main() -> int:
|
|
|
175
178
|
else:
|
|
176
179
|
if target < src_dur:
|
|
177
180
|
start = (src_dur - target) / 2 if args.from_center else 0.0
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
# seek SEEK_MARGIN early and drop the margin on the output side, as cut.py does:
|
|
182
|
+
# an input seek straight to `start` can land on a keyframe whose picture comes
|
|
183
|
+
# after it (a B-frame HEVC source) and lose the frames before it, so the first
|
|
184
|
+
# picture would not be the one the start timecode below names
|
|
185
|
+
m = min(SEEK_MARGIN, round(start, 3))
|
|
186
|
+
pre_input += ["-ss", f"{start - m:.3f}"]
|
|
187
|
+
post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", f"{target:.3f}"]
|
|
188
|
+
# the output starts on the first frame at or after the seek (a seek before a
|
|
189
|
+
# picture that starts after the file's origin: on its first frame): its start
|
|
190
|
+
# timecode is that frame's, not the source's first frame's
|
|
191
|
+
v0, rate = stream_starts(meta)[0], frame_rate(meta)
|
|
192
|
+
tc_first = grid_at_or_after(max(round(start, 3), v0), v0, rate) - v0
|
|
180
193
|
else:
|
|
181
194
|
info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
|
|
182
195
|
|
|
@@ -213,6 +226,15 @@ def main() -> int:
|
|
|
213
226
|
vf.append(f"fps={args.fps:g}")
|
|
214
227
|
elif meta["video"].get("variable_frame_rate_suspected"):
|
|
215
228
|
info("source looks variable-frame-rate; conforming to constant fps automatically")
|
|
229
|
+
# the start timecode is counted at the rate the output is delivered at: a --fps that changes
|
|
230
|
+
# the rate relabels it (01:00:00:12 at 25 fps is 01:00:00:24 at 50) even with no trim, which
|
|
231
|
+
# is also how a render.py clip cut at its own rate reaches the project's frame.fps
|
|
232
|
+
out_rate = args.fps or delivered_rate(meta)
|
|
233
|
+
src_fps = meta["video"].get("fps")
|
|
234
|
+
rate_changed = bool(out_rate and src_fps and exact_rate(float(out_rate)) != exact_rate(float(src_fps)))
|
|
235
|
+
relabel = tc_first is not None or rate_changed
|
|
236
|
+
if relabel:
|
|
237
|
+
post += output_timecode_args(meta, tc_first or 0.0, out_rate)
|
|
216
238
|
|
|
217
239
|
output = args.output or default_output(args.input, "fit")
|
|
218
240
|
cmd = ffmpeg_base() + pre_input + ["-i", args.input]
|
|
@@ -240,7 +262,7 @@ def main() -> int:
|
|
|
240
262
|
run(cmd + [output])
|
|
241
263
|
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
242
264
|
else:
|
|
243
|
-
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
265
|
+
dropped_streams = run_keeping_subtitles(cmd, output, relabel_timecode=relabel)
|
|
244
266
|
|
|
245
267
|
result = probe(output, role="output")
|
|
246
268
|
msg = f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})"
|
package/scripts/graphics.py
CHANGED
|
@@ -36,7 +36,7 @@ from typing import List, Optional, Tuple
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
38
38
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
39
|
-
ffmpeg_base, info, layer_base_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
39
|
+
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
40
40
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
41
41
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
42
42
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
@@ -497,8 +497,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
497
497
|
h = max(3, int(base * 0.008))
|
|
498
498
|
fps = meta['video'].get('fps') or 30
|
|
499
499
|
fc.append(f"{layer_src(primary, 1.0, W, h)}[pb]")
|
|
500
|
-
if draws_chroma([bg], meta) or alpha_codec:
|
|
501
|
-
# a layer (overlay composites the alpha too; drawbox leaves it as it was
|
|
500
|
+
if draws_chroma([bg], meta) or alpha_codec or overlay_format(meta):
|
|
501
|
+
# a layer (overlay composites the alpha too; drawbox leaves it as it was, and takes a
|
|
502
|
+
# 10-bit picture to 8 bits)
|
|
502
503
|
fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
|
|
503
504
|
fc.append(f"[0:v][pbg]overlay=x=0:y={(H - h) // 2 * 2}:{en}:eof_action=pass[v1]")
|
|
504
505
|
else:
|
|
@@ -712,8 +713,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
712
713
|
# converts neither picture to the other's matrix. The text is drawn in BT.709 (drawn_chain).
|
|
713
714
|
# On an alpha line every box is a layer: overlay composites the box's alpha over the picture's,
|
|
714
715
|
# while drawbox paints the colour and leaves the alpha as it was (a box on a transparent canvas
|
|
715
|
-
# stayed invisible)
|
|
716
|
-
|
|
716
|
+
# stayed invisible). On a 10-bit picture too: drawbox is 8-bit only on 6.1, so FFmpeg took the
|
|
717
|
+
# whole picture to 8 bits around it (overlay_format)
|
|
718
|
+
as_layers = bool(alpha_codec) or bool(overlay_format(meta)) or any(draws_chroma([b[0]], meta) for b in boxes)
|
|
717
719
|
drawboxes = []
|
|
718
720
|
for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
|
|
719
721
|
if as_layers:
|
|
@@ -743,6 +745,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
743
745
|
if fc:
|
|
744
746
|
fc = ["[0:v]format=yuva444p[ka0]"] + [c.replace("[0:v]", "[ka0]") for c in fc]
|
|
745
747
|
square = "format=yuva444p" + ("," + square if square else "")
|
|
748
|
+
elif overlay_format(meta):
|
|
749
|
+
# a 10-bit picture on a 10-bit line is composited at 10 bits (overlay's default is 8-bit)
|
|
750
|
+
fc = [c.replace(":eof_action=pass", ":eof_action=pass" + overlay_format(meta)) for c in fc]
|
|
746
751
|
finish = finish_chain(alpha_codec, args.alpha_mode, meta) if alpha_codec else ""
|
|
747
752
|
|
|
748
753
|
cmd = ffmpeg_base() + (decoder_args_for(meta, args.input) if alpha_codec else []) + ["-i", args.input]
|
|
@@ -762,7 +767,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
762
767
|
last = "vtxt"
|
|
763
768
|
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
|
|
764
769
|
to_yuv=rgb_chain_to_bt709(meta),
|
|
765
|
-
base_chain=layer_base_chain(meta))
|
|
770
|
+
base_chain=layer_base_chain(meta), overlay_opts=overlay_format(meta))
|
|
766
771
|
for spec in emoji_inputs:
|
|
767
772
|
cmd += spec
|
|
768
773
|
asset = spec[-1]
|
package/scripts/look.py
CHANGED
|
@@ -23,7 +23,7 @@ from _common import STATE, require_tool, add_common, apply_common, default_font_
|
|
|
23
23
|
from _common import drawtext_fontfile # noqa: E402
|
|
24
24
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
25
25
|
from _common.emit import note, record_fallback # noqa: E402
|
|
26
|
-
from _common.color import bt709_label_filter # noqa: E402
|
|
26
|
+
from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
|
|
27
27
|
from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
|
|
28
28
|
|
|
29
29
|
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
@@ -177,6 +177,23 @@ def measure_ink(png_path: str, *, box=None, margin_frac: float = 0.03, threshold
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
|
|
180
|
+
def display_conversion(meta: dict, what: str = "source") -> str:
|
|
181
|
+
"""The filter that shows `meta`'s frames on an SDR BT.709 display, or "" for a picture that is
|
|
182
|
+
already one. HDR is tone-mapped so the agent judges representative colours, not raw HLG/PQ."""
|
|
183
|
+
v = meta.get("video") or {}
|
|
184
|
+
if not v.get("bt2020_or_hdr"):
|
|
185
|
+
return ""
|
|
186
|
+
# color.py --to-sdr's own chains at its defaults (hable, a 1000-nit PQ peak), so the sheet
|
|
187
|
+
# shows what that conversion gives. Through 3.6.0 this tone map linearised at npl=1000 and
|
|
188
|
+
# a sheet's white came out at about Y 151 of 235; a BT.2020 SDR source was tone-mapped
|
|
189
|
+
# too, where color.py only converts its gamut.
|
|
190
|
+
if is_bt2020_sdr(v):
|
|
191
|
+
info(f"BT.2020 SDR {what}: frames are converted to BT.709 for display")
|
|
192
|
+
return bt2020_sdr_to_bt709_chain(meta)
|
|
193
|
+
info(f"HDR {what}: frames are tone-mapped to SDR for display")
|
|
194
|
+
return hdr_to_sdr_chain({"video": dict(v, color_transfer=v.get("color_transfer") or "arib-std-b67")}, "hable")
|
|
195
|
+
|
|
196
|
+
|
|
180
197
|
def main() -> int:
|
|
181
198
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
182
199
|
ap.add_argument("input")
|
|
@@ -264,13 +281,10 @@ def main() -> int:
|
|
|
264
281
|
boxes, guides = guide_boxes(list(dict.fromkeys(names)))
|
|
265
282
|
safe_filter += "," + ",".join(boxes)
|
|
266
283
|
tc = safe_filter + tc
|
|
267
|
-
#
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
"format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,")
|
|
272
|
-
tc = "," + tm.rstrip(",") + tc
|
|
273
|
-
info("HDR source: frames are tone-mapped to SDR for display")
|
|
284
|
+
marks = tc # the boxes and the stamp, without the display conversion (each --compare input has its own)
|
|
285
|
+
tm = display_conversion(meta)
|
|
286
|
+
if tm:
|
|
287
|
+
tc = "," + tm + tc
|
|
274
288
|
outputs: List[str] = []
|
|
275
289
|
# An untagged SDR source is BT.709 (what players and every encoder line here take it as), but the
|
|
276
290
|
# conversion to the PNG's RGB reads "unknown" as BT.601: C03028 came out B31F28 (#410). Its
|
|
@@ -293,6 +307,9 @@ def main() -> int:
|
|
|
293
307
|
cw, ch = display_size(cmeta.get("video") or {})
|
|
294
308
|
if cw and ch and cw / ch < 4 / 3 + 0.01:
|
|
295
309
|
die(f"--guides 4:3 draws the 4:3 centre cut of a wider frame; the --compare clip is {cw}x{ch}")
|
|
310
|
+
# the second picture is converted for display by its own tags: through 3.6.1 the first
|
|
311
|
+
# input's tone map ran on both, so an SDR clip beside a PQ one was read as PQ
|
|
312
|
+
tm2 = display_conversion(cmeta, "--compare clip")
|
|
296
313
|
lab2 = bt709_label_filter(cmeta)
|
|
297
314
|
lab2 = lab2 + "," if lab2 else ""
|
|
298
315
|
csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
|
|
@@ -310,15 +327,17 @@ def main() -> int:
|
|
|
310
327
|
# the tone map and the --safe / --guides boxes go on each picture as scaled, before the
|
|
311
328
|
# pad: drawn after it, a picture letterboxed in the box got guides from the padded
|
|
312
329
|
# height (a 2:1 clip beside a 16:9 one); the timecode stamp is drawn on the box
|
|
313
|
-
pre =
|
|
330
|
+
pre = marks.replace("," + timecode_filter(font_prefix), "")
|
|
314
331
|
# each picture keeps its own aspect inside a half-width box as tall as the taller of the
|
|
315
332
|
# two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
|
|
316
333
|
# second's size, so a 4:3 beside a 16:9 came out squashed (#411)
|
|
317
334
|
box_h = max(_half_height(half, display_size(meta["video"])),
|
|
318
335
|
_half_height(half, display_size(cmeta.get("video"))))
|
|
319
|
-
fit
|
|
320
|
-
|
|
321
|
-
|
|
336
|
+
def fit(conv):
|
|
337
|
+
conv = "," + conv if conv else ""
|
|
338
|
+
return (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{conv}{pre},"
|
|
339
|
+
f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
|
|
340
|
+
fc = (f"[0:v]{lab}{sq}{fit(tm)}[a];[1:v]{lab2}{csq}{fit(tm2)}[b];"
|
|
322
341
|
f"[a][b]hstack=inputs=2[out]")
|
|
323
342
|
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,
|
|
324
343
|
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
package/scripts/multicam.py
CHANGED
|
@@ -28,12 +28,13 @@ Examples:
|
|
|
28
28
|
import argparse
|
|
29
29
|
import json
|
|
30
30
|
import math
|
|
31
|
+
import os
|
|
31
32
|
import sys
|
|
32
33
|
from typing import Dict, List, Tuple
|
|
33
34
|
|
|
34
35
|
from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
|
|
35
36
|
from _common.emit import note, record_fallback, warn
|
|
36
|
-
from _common.decision import av_trim
|
|
37
|
+
from _common.decision import av_trim, rebase_project_paths
|
|
37
38
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
38
39
|
from sync import measure_offset
|
|
39
40
|
|
|
@@ -335,6 +336,10 @@ def main() -> int:
|
|
|
335
336
|
# a replacement: the combined output below is still written as usual.
|
|
336
337
|
project = {"output": default_output(args.inputs[0], "multicam_project", "mp4"),
|
|
337
338
|
"clips": project_clips}
|
|
339
|
+
# the inputs are named as given, relative to the working directory, and render.py reads a
|
|
340
|
+
# project's relative paths against the project file's directory: written elsewhere
|
|
341
|
+
# (--write-project out/cut.json), they named files that are not there
|
|
342
|
+
project = rebase_project_paths(project, os.getcwd())
|
|
338
343
|
if not STATE.dry_run:
|
|
339
344
|
with open(args.write_project, "w", encoding="utf-8") as fh:
|
|
340
345
|
fh.write(json.dumps(project, indent=2) + "\n")
|
package/scripts/overlay.py
CHANGED
|
@@ -31,11 +31,11 @@ with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 dec
|
|
|
31
31
|
import argparse
|
|
32
32
|
import re
|
|
33
33
|
import sys
|
|
34
|
-
from typing import List, Optional
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
35
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common import still_input_args # noqa: E402
|
|
38
|
-
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
38
|
+
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
39
39
|
from _common.emit import note, warn
|
|
40
40
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
41
41
|
has_alpha_plane, report as alpha_report, source_alpha, tag_args, video_line)
|
|
@@ -428,7 +428,9 @@ def main() -> int:
|
|
|
428
428
|
if end is not None and end > args.fade:
|
|
429
429
|
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
430
430
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
431
|
-
|
|
431
|
+
# a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
|
|
432
|
+
# still took the whole picture through 8-bit RGB (overlay_format)
|
|
433
|
+
ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
432
434
|
if enable:
|
|
433
435
|
ov += f":enable='{enable}'"
|
|
434
436
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
@@ -480,13 +482,18 @@ def main() -> int:
|
|
|
480
482
|
elif args.scale:
|
|
481
483
|
chain.append(f"scale={args.scale}:-2")
|
|
482
484
|
# an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
|
|
483
|
-
|
|
485
|
+
# a 10-bit layer over a main overlay_format() composites at 10 bits stays 10-bit
|
|
486
|
+
deep = bool(overlay_format(meta)) and (pip_meta["video"].get("bit_depth") or 8) > 8
|
|
487
|
+
chain.append("format=yuva444p" if alpha_codec else "format=yuva420p10le" if deep else "format=yuva420p")
|
|
484
488
|
if args.chromakey:
|
|
485
489
|
chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
|
|
486
490
|
if args.opacity < 1:
|
|
487
|
-
|
|
491
|
+
# the alpha is scaled in YUV: colorchannelmixer takes RGB only, so FFmpeg converted the
|
|
492
|
+
# layer to RGB and overlay=format=auto then composited the whole main picture in RGB,
|
|
493
|
+
# which came back BT.601 under the BT.709 tag (#333's defect, on this path)
|
|
494
|
+
chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
|
|
488
495
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
489
|
-
ov = f"overlay={x}:{y}:format=auto"
|
|
496
|
+
ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
490
497
|
if enable:
|
|
491
498
|
ov += f":enable='{enable}'"
|
|
492
499
|
# a VP9 layer stored with an alpha layer is decoded with libvpx-vp9: FFmpeg's own decoder
|
|
@@ -501,7 +508,13 @@ def main() -> int:
|
|
|
501
508
|
# converted to BT.709 YUV with its alpha plane: the 4:4:4 the conversion ends in had none,
|
|
502
509
|
# and the layer covered the picture as an opaque rectangle
|
|
503
510
|
pip = pip.replace("format=yuv444p", "format=yuva444p")
|
|
504
|
-
|
|
511
|
+
# an untagged picture is labelled beside a converted one, and beside a tagged one too: FFmpeg
|
|
512
|
+
# 8.0's overlay negotiates one colorspace for both inputs, and a BT.709-tagged main under an
|
|
513
|
+
# untagged PiP was converted to the PiP's "unknown" (BT.601) matrix, away from the PiP too
|
|
514
|
+
# (C03028 at 80/110/192 came back 88/103/192 on 8.0.1). Two untagged pictures agree as they are.
|
|
515
|
+
def tagged(m: Dict[str, Any]) -> bool:
|
|
516
|
+
return ((m.get("video") or {}).get("color_space") or "unknown") not in ("unknown", "unspecified")
|
|
517
|
+
if base or pip or tagged(meta) != tagged(pip_meta):
|
|
505
518
|
base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
|
|
506
519
|
# overlay mixes samples: a full-range picture is taken to limited range (#410)
|
|
507
520
|
base, pip = base + range_chain(meta, meta), pip + range_chain(pip_meta, meta)
|
package/scripts/redact.py
CHANGED
|
@@ -27,7 +27,7 @@ Examples:
|
|
|
27
27
|
import argparse
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
30
|
+
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
31
31
|
from _common.emit import note
|
|
32
32
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
33
33
|
|
|
@@ -110,7 +110,7 @@ def main() -> int:
|
|
|
110
110
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
111
111
|
else:
|
|
112
112
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
113
|
-
fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
|
113
|
+
fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}{overlay_format(meta)}[out]"
|
|
114
114
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
115
115
|
if has_audio:
|
|
116
116
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|