ffmpeg-skill 3.1.5 → 3.1.7
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/docs/contract.md +11 -7
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +12 -6
- package/scripts/_common/__init__.py +7 -5
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +177 -10
- package/scripts/_common/emit.py +2 -0
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +115 -5
- package/scripts/_common/runner.py +38 -1
- package/scripts/_common/timeline.py +123 -27
- package/scripts/_contract.py +34 -3
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +105 -19
- package/scripts/color.py +37 -8
- package/scripts/crop.py +8 -3
- package/scripts/cropdetect.py +13 -9
- package/scripts/cut.py +4 -4
- package/scripts/export.py +16 -15
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +104 -67
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +9 -1
- package/scripts/join.py +29 -19
- package/scripts/look.py +41 -12
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +68 -14
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +91 -35
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/waveform.py +8 -3
package/scripts/insert.py
CHANGED
|
@@ -29,6 +29,8 @@ import sys
|
|
|
29
29
|
|
|
30
30
|
from _common import still_input_args # noqa: E402
|
|
31
31
|
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
32
|
+
from _common.emit import note
|
|
33
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
def even(n: float) -> int:
|
|
@@ -69,7 +71,11 @@ def main() -> int:
|
|
|
69
71
|
if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
|
|
70
72
|
die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
|
|
71
73
|
f"For a clip use broll.py (cutaway) or cut.py/join.py")
|
|
72
|
-
|
|
74
|
+
# the displayed picture: ffmpeg turns an EXIF-rotated still upright on decode (probe reads
|
|
75
|
+
# the Orientation off the decoded frame), and non-square pixels are squared (#411)
|
|
76
|
+
sw, sh = display_size(meta["video"])
|
|
77
|
+
if square_pixels(meta["video"]):
|
|
78
|
+
note(square_pixels_note(meta["video"]))
|
|
73
79
|
ratio = sw / sh
|
|
74
80
|
|
|
75
81
|
if args.width and args.height:
|
|
@@ -115,6 +121,8 @@ def main() -> int:
|
|
|
115
121
|
f"fps={args.fps:g}",
|
|
116
122
|
]
|
|
117
123
|
|
|
124
|
+
if square_pixels(meta["video"]):
|
|
125
|
+
vf.insert(0, square_pixels(meta["video"])) # the scale and crop above size the squared picture
|
|
118
126
|
output = args.output or default_output(args.input, "insert", "mp4")
|
|
119
127
|
cmd = ffmpeg_base() + ["-loop", "1", *still_input_args(args.input), "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
|
|
120
128
|
# the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
|
package/scripts/join.py
CHANGED
|
@@ -42,8 +42,10 @@ import sys
|
|
|
42
42
|
from typing import Any, Dict, List, Optional, Tuple
|
|
43
43
|
|
|
44
44
|
from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
|
|
45
|
-
from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
46
|
-
from _common.emit import warn
|
|
45
|
+
from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
46
|
+
from _common.emit import note, warn
|
|
47
|
+
from _common.decision import av_clip_length, av_trim, stream_starts
|
|
48
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
47
49
|
|
|
48
50
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
49
51
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -103,16 +105,11 @@ def overlap_report(durs: List[float], expected: Optional[float]) -> Dict[str, An
|
|
|
103
105
|
|
|
104
106
|
def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
105
107
|
"""The one length a clip holds in a join, for its picture and its sound alike: the
|
|
106
|
-
video stream's, or the
|
|
107
|
-
(narration is never cut; an AAC tail adds no frame). The container
|
|
108
|
-
a longer track) is only the fallback when a stream length is unknown
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if not v:
|
|
112
|
-
return meta.get("duration") or 0.0
|
|
113
|
-
if a and fps and a > v + 1.0 / fps:
|
|
114
|
-
return a
|
|
115
|
-
return v
|
|
108
|
+
video stream's, or up to where the sound ends when it runs more than a frame past the picture
|
|
109
|
+
(narration is never cut; an AAC tail adds no frame), counted from the first frame. The container
|
|
110
|
+
duration (audio priming, a longer track) is only the fallback when a stream length is unknown
|
|
111
|
+
(a pending input). Shared with reverse.py as av_clip_length."""
|
|
112
|
+
return av_clip_length(meta, fps)
|
|
116
113
|
|
|
117
114
|
|
|
118
115
|
def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
|
|
@@ -655,9 +652,8 @@ def main() -> int:
|
|
|
655
652
|
if args.copy:
|
|
656
653
|
return join_copy(args, metas)
|
|
657
654
|
first = metas[0]["video"]
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
fw, fh = fh, fw
|
|
655
|
+
# the first clip's displayed frame: rotation applied, non-square pixels squared (#411)
|
|
656
|
+
fw, fh = display_size(first)
|
|
661
657
|
if args.width and args.height:
|
|
662
658
|
w, h = args.width, args.height
|
|
663
659
|
elif args.width:
|
|
@@ -721,13 +717,22 @@ def main() -> int:
|
|
|
721
717
|
chroma_pad = args.fit != "crop" and draws_chroma([args.pad_color], line)
|
|
722
718
|
if any(convert) or chroma_pad:
|
|
723
719
|
convert = [c or label_chain(m, line) for c, m in zip(convert, metas)]
|
|
720
|
+
# concat/xfade mix samples: a full-range clip (VP9/AV1 full range, 10-bit pc) beside a limited
|
|
721
|
+
# one is taken to limited range first, or it was shown with the other's range (#410)
|
|
722
|
+
convert = [c + range_chain(m, line) for c, m in zip(convert, metas)]
|
|
723
|
+
|
|
724
|
+
for p, m in zip(args.inputs, metas):
|
|
725
|
+
if square_pixels(m.get("video")):
|
|
726
|
+
note(square_pixels_note(m["video"], p))
|
|
724
727
|
|
|
725
728
|
def geo(i: int) -> str:
|
|
729
|
+
# every clip is squared at its display aspect before it is fitted (#411)
|
|
730
|
+
sq = (square_pixels(metas[i].get("video")) + ",") if square_pixels(metas[i].get("video")) else ""
|
|
726
731
|
if args.fit == "crop":
|
|
727
|
-
return f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
732
|
+
return f"{sq}scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
728
733
|
pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
729
|
-
return (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
730
|
-
+ drawn_chain(pad + args.pad_color, metas[i], [args.pad_color], pad + "black", line_meta=line,
|
|
734
|
+
return (f"{sq}scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
735
|
+
+ drawn_chain(pad + args.pad_color, as_limited(metas[i]), [args.pad_color], pad + "black", line_meta=line,
|
|
731
736
|
tag=f"pad{i}", labelled=True))
|
|
732
737
|
# the audio-only join keeps the widest layout; the video join used to force stereo and
|
|
733
738
|
# silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
|
|
@@ -741,7 +746,12 @@ def main() -> int:
|
|
|
741
746
|
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
742
747
|
for i in range(n):
|
|
743
748
|
parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
|
|
744
|
-
|
|
749
|
+
# the sound is shifted by the picture's start, not to its own (#409): #338 started each
|
|
750
|
+
# clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
|
|
751
|
+
# after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
|
|
752
|
+
# is padded, as cut.py's re-cut does; a generated silent track starts at 0 already.
|
|
753
|
+
shift = (av_trim(stream_starts(metas[i])[0], None)[1] + ",") if metas[i].get("audio") else ""
|
|
754
|
+
parts.append(f"[{audio_src[i]}]{shift}aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
|
|
745
755
|
|
|
746
756
|
# pad the shorter stream to the clip's length: the audio with silence, the picture by holding
|
|
747
757
|
# its last frame, then cut both there -- each clip boundary is then the same for both chains
|
package/scripts/look.py
CHANGED
|
@@ -22,6 +22,8 @@ from _common import STATE, require_tool, add_common, apply_common, default_font_
|
|
|
22
22
|
from _common import drawtext_fontfile # noqa: E402
|
|
23
23
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
24
24
|
from _common.emit import note, record_fallback # noqa: E402
|
|
25
|
+
from _common.color import bt709_label_filter # noqa: E402
|
|
26
|
+
from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
|
|
25
27
|
|
|
26
28
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
27
29
|
|
|
@@ -41,6 +43,12 @@ def timecode_filter(font_prefix: str, offset: float = 0.0) -> str:
|
|
|
41
43
|
return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
def _half_height(half: int, size: "tuple") -> int:
|
|
47
|
+
"""The even height a `half`-wide picture of displayed `size` scales to (16:9 when unknown)."""
|
|
48
|
+
w, h = size
|
|
49
|
+
return max(2, int(round(half * (h / w if w and h else 9 / 16) / 2)) * 2)
|
|
50
|
+
|
|
51
|
+
|
|
44
52
|
def _png_size(png_path: str) -> "Optional[tuple]":
|
|
45
53
|
# run_analysis(): the same wall-clock ceiling and #234 UTF-8 decoding as every other
|
|
46
54
|
# ffprobe/ffmpeg measurement in this codebase, check=False since a probe failure here is
|
|
@@ -189,10 +197,7 @@ def main() -> int:
|
|
|
189
197
|
if args.safe:
|
|
190
198
|
z = PLATFORMS[args.safe]["safe"]
|
|
191
199
|
frame = PLATFORMS[args.safe]["frame"]
|
|
192
|
-
|
|
193
|
-
dw, dh = src.get("width") or 0, src.get("height") or 0
|
|
194
|
-
if src.get("rotation") in (90, -90, 270, -270):
|
|
195
|
-
dw, dh = dh, dw
|
|
200
|
+
dw, dh = display_size(meta["video"])
|
|
196
201
|
if dw and dh and abs(dw / dh - frame["w"] / frame["h"]) > 0.02:
|
|
197
202
|
info(f"--safe {args.safe}: this source is {dw}x{dh}, not {args.safe}'s "
|
|
198
203
|
f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
|
|
@@ -221,11 +226,25 @@ def main() -> int:
|
|
|
221
226
|
tc = "," + tm.rstrip(",") + tc
|
|
222
227
|
info("HDR source: frames are tone-mapped to SDR for display")
|
|
223
228
|
outputs: List[str] = []
|
|
229
|
+
# An untagged SDR source is BT.709 (what players and every encoder line here take it as), but the
|
|
230
|
+
# conversion to the PNG's RGB reads "unknown" as BT.601: C03028 came out B31F28 (#410). Its
|
|
231
|
+
# frames are labelled first (tags only), so that conversion reads them as BT.709.
|
|
232
|
+
lab = bt709_label_filter(meta)
|
|
233
|
+
lab = lab + "," if lab else ""
|
|
234
|
+
# every picture is squared at its display aspect before it is scaled (#411): a DV or HDV
|
|
235
|
+
# frame's stored size is not the picture a player shows
|
|
236
|
+
square = square_pixels(meta["video"])
|
|
237
|
+
sq = square + "," if square else ""
|
|
238
|
+
if square:
|
|
239
|
+
note(square_pixels_note(meta["video"]))
|
|
224
240
|
|
|
225
241
|
if args.compare:
|
|
226
242
|
if not args.at:
|
|
227
243
|
die("--compare needs --at TIME")
|
|
228
|
-
probe(args.compare)
|
|
244
|
+
cmeta = probe(args.compare)
|
|
245
|
+
lab2 = bt709_label_filter(cmeta)
|
|
246
|
+
lab2 = lab2 + "," if lab2 else ""
|
|
247
|
+
csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
|
|
229
248
|
for idx, t in enumerate(args.at):
|
|
230
249
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
231
250
|
if args.output and len(args.at) == 1:
|
|
@@ -238,8 +257,15 @@ def main() -> int:
|
|
|
238
257
|
half = args.width // 2
|
|
239
258
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
240
259
|
tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
# each picture keeps its own aspect inside a half-width box as tall as the taller of the
|
|
261
|
+
# two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
|
|
262
|
+
# second's size, so a 4:3 beside a 16:9 came out squashed (#411)
|
|
263
|
+
box_h = max(_half_height(half, display_size(meta["video"])),
|
|
264
|
+
_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}{tcs}[a];[1:v]{lab2}{csq}{fit}{tcs}[b];"
|
|
268
|
+
f"[a][b]hstack=inputs=2[out]")
|
|
243
269
|
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,
|
|
244
270
|
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
|
245
271
|
run(cmd)
|
|
@@ -255,7 +281,7 @@ def main() -> int:
|
|
|
255
281
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
256
282
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
257
283
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
258
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
284
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
259
285
|
run(cmd)
|
|
260
286
|
outputs.append(out)
|
|
261
287
|
else:
|
|
@@ -278,11 +304,14 @@ def main() -> int:
|
|
|
278
304
|
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
279
305
|
# with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
|
|
280
306
|
seek = 0.0 if frames_avail < 4 else step / 2
|
|
281
|
-
#
|
|
282
|
-
|
|
283
|
-
|
|
307
|
+
# -copyts -start_at_zero keeps each frame's own timestamp through the seek, counted from
|
|
308
|
+
# the file's start, so the stamp is the frame's pts as it is. What an input -ss alone
|
|
309
|
+
# resets pts to differs by container: an MPEG-TS (which starts at 1.4 s) came out
|
|
310
|
+
# labelled one seek late when the seek was added back (#411)
|
|
311
|
+
tc_sheet = tc
|
|
312
|
+
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',{lab}{sq}scale={tile_w}:-2{tc_sheet},"
|
|
284
313
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
285
|
-
cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
314
|
+
cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
286
315
|
run(cmd)
|
|
287
316
|
outputs.append(out)
|
|
288
317
|
info(f"contact sheet: {n} frames every {step:.2f}s")
|
package/scripts/loop.py
CHANGED
|
@@ -21,11 +21,15 @@ Examples:
|
|
|
21
21
|
python3 loop.py wave.mp4 --boomerang --times 4
|
|
22
22
|
"""
|
|
23
23
|
import argparse
|
|
24
|
+
import json
|
|
24
25
|
import math
|
|
26
|
+
import os
|
|
25
27
|
import sys
|
|
28
|
+
import tempfile
|
|
26
29
|
|
|
27
|
-
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
30
|
+
from _common import concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
28
31
|
from _common.emit import warn
|
|
32
|
+
from _common.decision import av_trim
|
|
29
33
|
|
|
30
34
|
|
|
31
35
|
# The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
|
|
@@ -95,6 +99,21 @@ def _nominal_rate(v) -> str:
|
|
|
95
99
|
return f"{counted:g}" if 0 < counted <= MAX_PLAUSIBLE_FPS else "30"
|
|
96
100
|
|
|
97
101
|
|
|
102
|
+
def concat_first_frame(listfile: str) -> float:
|
|
103
|
+
"""Where the first frame sits on the clock ffmpeg reads a concat list on: the list's first video
|
|
104
|
+
start minus its start_time. The concat demuxer counts an MP4's hidden AAC priming packet into its
|
|
105
|
+
start (-0.021 s) where the MP4 demuxer does not, so ffmpeg moves both streams 21 ms later, and a
|
|
106
|
+
frame-rate time base then rounded the picture's move to 33 ms (12 ms off). 0.0 when unmeasured."""
|
|
107
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-f", "concat", "-safe", "0", "-i", listfile,
|
|
108
|
+
"-select_streams", "v:0", "-show_entries", "stream=start_time:format=start_time", "-of", "json"],
|
|
109
|
+
quiet=True, check=False)
|
|
110
|
+
try:
|
|
111
|
+
doc = json.loads(proc.stdout or "")
|
|
112
|
+
return float(doc["streams"][0]["start_time"]) - float(doc["format"]["start_time"])
|
|
113
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
114
|
+
return 0.0
|
|
115
|
+
|
|
116
|
+
|
|
98
117
|
def main() -> int:
|
|
99
118
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
100
119
|
ap.add_argument("input")
|
|
@@ -119,6 +138,8 @@ def main() -> int:
|
|
|
119
138
|
has_audio = bool(meta.get("audio")) and not args.boomerang
|
|
120
139
|
notes = []
|
|
121
140
|
output = args.output or default_output(args.input, "loop")
|
|
141
|
+
# the concat list is the only -i, so run() never sees the source next to -o (#416 review)
|
|
142
|
+
refuse_output_is_input(output, args.input)
|
|
122
143
|
|
|
123
144
|
if args.times is not None:
|
|
124
145
|
if args.times < (1 if args.boomerang else 2):
|
|
@@ -137,10 +158,24 @@ def main() -> int:
|
|
|
137
158
|
if meta.get("audio"):
|
|
138
159
|
notes.append("audio dropped: a boomerang plays half its sound backwards -- add a bed with audio.py")
|
|
139
160
|
else:
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
|
|
161
|
+
# The concat demuxer reads the clip once per repeat and starts each repeat at the previous
|
|
162
|
+
# one's start plus the file's duration (#409): both streams keep their offset from the
|
|
163
|
+
# file's start in every repeat. -stream_loop took the repeat's length from its streams'
|
|
164
|
+
# spans without the last video frame, so a sound that starts 0.3 s late shortened every
|
|
165
|
+
# repeat by a frame and slid against the picture (346 frames for 360, 68 ms off).
|
|
166
|
+
# Both streams start at the first frame, shifted by one constant (av_trim), and the gap a
|
|
167
|
+
# late sound leaves at each repeat's start is filled with silence, at any length.
|
|
168
|
+
workdir = tempfile.TemporaryDirectory(prefix="ffskill_loop_")
|
|
169
|
+
listfile = os.path.join(workdir.name, "loop.txt")
|
|
170
|
+
with open(listfile, "w", encoding="utf-8") as fh:
|
|
171
|
+
fh.write((concat_list_line(os.path.abspath(args.input)) + "\n") * (stream_loop + 1))
|
|
172
|
+
vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
|
|
173
|
+
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
174
|
+
if has_audio:
|
|
175
|
+
cmd += ["-filter_complex", f"[0:v:0]{vtrim}[v];[0:a:0]{atrim}[a]",
|
|
176
|
+
"-map", "[v]", "-map", "[a]"]
|
|
177
|
+
else:
|
|
178
|
+
cmd += ["-map", "0:v:0", "-vf", vtrim]
|
|
144
179
|
if target is not None:
|
|
145
180
|
cmd += ["-t", f"{target:.3f}"]
|
|
146
181
|
cmd += video_args(meta, args.crf, args.preset)
|
|
@@ -150,7 +185,11 @@ def main() -> int:
|
|
|
150
185
|
else:
|
|
151
186
|
cmd += ["-an"]
|
|
152
187
|
cmd.append(output)
|
|
153
|
-
|
|
188
|
+
try:
|
|
189
|
+
run(cmd)
|
|
190
|
+
finally:
|
|
191
|
+
if not args.boomerang:
|
|
192
|
+
workdir.cleanup()
|
|
154
193
|
|
|
155
194
|
result = probe(output, role="output")
|
|
156
195
|
v = result["video"]
|
package/scripts/multicam.py
CHANGED
|
@@ -31,8 +31,10 @@ import math
|
|
|
31
31
|
import sys
|
|
32
32
|
from typing import Dict, List, Tuple
|
|
33
33
|
|
|
34
|
-
from _common import guard_sidecar, label_chain, matrix_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
|
-
from _common.emit import record_fallback, warn
|
|
34
|
+
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
|
+
from _common.emit import note, record_fallback, warn
|
|
36
|
+
from _common.decision import av_trim
|
|
37
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
36
38
|
from sync import measure_offset
|
|
37
39
|
|
|
38
40
|
ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
|
|
@@ -263,14 +265,16 @@ def main() -> int:
|
|
|
263
265
|
info(f"wrote {args.edl}")
|
|
264
266
|
|
|
265
267
|
v0 = metas[0]["video"]
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
w, h = h, w
|
|
268
|
+
# camera 0's displayed frame: rotation applied, non-square pixels squared (#411)
|
|
269
|
+
w, h = args.width or display_size(v0)[0], args.height or display_size(v0)[1]
|
|
269
270
|
fps = args.fps or v0.get("fps") or 30.0
|
|
270
271
|
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
271
272
|
pixfmt = "yuv420p10le" if v0.get("bt2020_or_hdr") else "yuv420p"
|
|
272
273
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
|
|
273
274
|
|
|
275
|
+
for p, m in zip(args.inputs, metas):
|
|
276
|
+
if square_pixels(m.get("video")):
|
|
277
|
+
note(square_pixels_note(m["video"], p))
|
|
274
278
|
cmd = ffmpeg_base()
|
|
275
279
|
for p in args.inputs:
|
|
276
280
|
cmd += ["-i", p]
|
|
@@ -283,6 +287,8 @@ def main() -> int:
|
|
|
283
287
|
convert = [matrix_chain(m, metas[0]) for m in metas]
|
|
284
288
|
if any(convert):
|
|
285
289
|
convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
|
|
290
|
+
# concat mixes samples: a full-range camera beside a limited one is taken to limited range (#410)
|
|
291
|
+
convert = [c + range_chain(m, metas[0]) for c, m in zip(convert, metas)]
|
|
286
292
|
# what is actually rendered: a range whose camera has not started yet plays camera 0, and the
|
|
287
293
|
# result says so (`cuts` names the camera on screen, diagnostics.fallbacks the one asked for)
|
|
288
294
|
rendered: List[Tuple[float, float, int]] = []
|
|
@@ -296,7 +302,8 @@ def main() -> int:
|
|
|
296
302
|
requested_camera=c, camera=0, starts_at=round(offsets[c], 3))
|
|
297
303
|
c, src_s, src_e = 0, s, e
|
|
298
304
|
rendered.append((s, e, c))
|
|
299
|
-
|
|
305
|
+
sq = (square_pixels(metas[c]["video"]) + ",") if square_pixels(metas[c]["video"]) else ""
|
|
306
|
+
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{sq}{geo}[v{i}]")
|
|
300
307
|
labels.append(f"[v{i}]")
|
|
301
308
|
project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
|
|
302
309
|
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
|
@@ -306,7 +313,9 @@ def main() -> int:
|
|
|
306
313
|
# applied before asetrate/aresample rescale that axis -- otherwise the trim lands at the wrong
|
|
307
314
|
# point once the stream's timebase has already been stretched/compressed by the drift ratio
|
|
308
315
|
# (mirrors sync.py, which seeks with -ss, an input-level operation, before its drift_af filters).
|
|
309
|
-
|
|
316
|
+
# shifted by a_start, not to its own first sample: a sound that starts after its file's start
|
|
317
|
+
# keeps that offset (padded with silence), as the offset was measured on the file's clock (#409)
|
|
318
|
+
afx = [av_trim(a_start, None)[1]]
|
|
310
319
|
if abs(ratios[a] - 1.0) > 1e-7:
|
|
311
320
|
sr = metas[a]["audio"].get("sample_rate") or 48000
|
|
312
321
|
afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
|
package/scripts/overlay.py
CHANGED
|
@@ -31,8 +31,11 @@ from typing import List, Optional
|
|
|
31
31
|
|
|
32
32
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
33
33
|
from _common import still_input_args # noqa: E402
|
|
34
|
-
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, matrix_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
|
|
35
|
-
from _common.emit import warn
|
|
34
|
+
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
|
|
35
|
+
from _common.emit import note, warn
|
|
36
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
37
|
+
from _common.wrap import wrap_text
|
|
38
|
+
from _common import text_width_em
|
|
36
39
|
from _common.fonts import font_choice, record_font_choice
|
|
37
40
|
from _common import drawtext_fontfile # noqa: E402
|
|
38
41
|
|
|
@@ -74,6 +77,48 @@ def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dic
|
|
|
74
77
|
return x, y
|
|
75
78
|
|
|
76
79
|
|
|
80
|
+
def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0) -> str:
|
|
81
|
+
"""--text broken into lines that fit between the frame's side margins at --font-size, by the
|
|
82
|
+
same phrase rules graphics.py and caption.py wrap with; text that fits comes back unchanged.
|
|
83
|
+
drawtext never wraps, so through 3.1.3 a long line ran off the frame with no word (#411). The
|
|
84
|
+
width is the averaged advance table's estimate, so a wrapped run says so in its notes."""
|
|
85
|
+
text = args.text or ""
|
|
86
|
+
if not (frame_w and text and args.font_size > 0):
|
|
87
|
+
return text
|
|
88
|
+
m = {edge: int((margins or {}).get(edge, args.margin)) for edge in ("left", "right")}
|
|
89
|
+
left, right = m["left"], m["right"]
|
|
90
|
+
if args.position not in POS:
|
|
91
|
+
try:
|
|
92
|
+
xv = int(args.position.split(",")[0])
|
|
93
|
+
left, right = (xv, right) if xv >= 0 else (0, -xv)
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
inset = 2 * args.border + (24 if args.box else 0) # the border on both sides, boxborderw=12
|
|
97
|
+
room = frame_w - left - right - inset
|
|
98
|
+
if room <= args.font_size:
|
|
99
|
+
# a position this close to the edge leaves no room to wrap in: say the text runs off
|
|
100
|
+
# rather than draw it past the edge without a word (#416 review)
|
|
101
|
+
if max(text_width_em(ln) for ln in text.split("\n")) * args.font_size > max(room, 0):
|
|
102
|
+
note(f"--text does not fit: --position {args.position} leaves {max(room, 0)} px to the frame's edge "
|
|
103
|
+
f"at --font-size {args.font_size}, so the text runs past it")
|
|
104
|
+
return text
|
|
105
|
+
lines: List[str] = []
|
|
106
|
+
for para in text.split("\n"):
|
|
107
|
+
lines.extend(wrap_text(para, room / float(args.font_size), lang=getattr(args, "lang", None),
|
|
108
|
+
slice_overlong=True) if para.strip() else [para])
|
|
109
|
+
wrapped = "\n".join(lines)
|
|
110
|
+
if wrapped != text:
|
|
111
|
+
note(f"--text is wider than the frame at --font-size {args.font_size} "
|
|
112
|
+
f"({frame_w} px, {left}/{right} px margins): wrapped to {len(lines)} lines")
|
|
113
|
+
# the wrap only measures width: a block taller than the frame loses the lines past its
|
|
114
|
+
# edge (at a bottom position, the first ones), so that is said too (#416 review)
|
|
115
|
+
tall = args.font_size + (len(lines) - 1) * int(args.font_size * 1.2)
|
|
116
|
+
if frame_h and tall > frame_h:
|
|
117
|
+
note(f"the {len(lines)} wrapped lines are about {tall} px tall, taller than the {frame_h} px frame: "
|
|
118
|
+
f"the lines past its edge are not drawn -- lower --font-size or shorten --text")
|
|
119
|
+
return wrapped
|
|
120
|
+
|
|
121
|
+
|
|
77
122
|
def enable_expr(start: Optional[float], end: Optional[float]) -> str:
|
|
78
123
|
if start is None and end is None:
|
|
79
124
|
return ""
|
|
@@ -151,7 +196,8 @@ def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: arg
|
|
|
151
196
|
_PLACE_TIME = r"[^@\-]+(?:@[0-9.]+)?"
|
|
152
197
|
|
|
153
198
|
|
|
154
|
-
def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float]
|
|
199
|
+
def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float],
|
|
200
|
+
layer_meta: Optional[dict] = None, main_meta: Optional[dict] = None) -> List[tuple]:
|
|
155
201
|
"""--place SRC_START-SRC_END@AT -> [(src_start, src_end, at)], refusing empty, negative or
|
|
156
202
|
overlapping placements (two pieces on screen at once would stack in an order nobody asked for).
|
|
157
203
|
SRC_START/SRC_END are positions in the layer, so an hh:mm:ss:ff timecode there counts the
|
|
@@ -161,9 +207,9 @@ def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Opt
|
|
|
161
207
|
m = re.fullmatch(rf"\s*({_PLACE_TIME})-({_PLACE_TIME})@({_PLACE_TIME})\s*", spec)
|
|
162
208
|
if not m:
|
|
163
209
|
die(f"--place {spec!r}: use SRC_START-SRC_END@AT, e.g. 0-2.5@12 (times are not negative)")
|
|
164
|
-
a = time_arg(m.group(1), "--place", layer_fps)
|
|
165
|
-
b = time_arg(m.group(2), "--place", layer_fps)
|
|
166
|
-
at = time_arg(m.group(3), "--place", main_fps)
|
|
210
|
+
a = time_arg(m.group(1), "--place", layer_fps, source=layer_meta)
|
|
211
|
+
b = time_arg(m.group(2), "--place", layer_fps, source=layer_meta)
|
|
212
|
+
at = time_arg(m.group(3), "--place", main_fps, source=main_meta)
|
|
167
213
|
if b <= a:
|
|
168
214
|
die(f"--place {spec!r}: the layer range ends before it starts")
|
|
169
215
|
out.append((a, b, at))
|
|
@@ -304,12 +350,14 @@ def main() -> int:
|
|
|
304
350
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
305
351
|
if args.audio_stream and not audio_streams:
|
|
306
352
|
die("--audio-stream needs an input with audio streams")
|
|
307
|
-
vw = meta["video"]["width"] or 0
|
|
308
|
-
vh = meta["video"].get("height") or 0
|
|
309
353
|
# ffmpeg applies display rotation before the filter graph, so the frame this overlay is
|
|
310
354
|
# composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
|
|
311
|
-
|
|
312
|
-
|
|
355
|
+
# A non-square-pixel source is made square at its display aspect first (#411), so a logo or
|
|
356
|
+
# a line of text is not drawn stretched by the player's SAR.
|
|
357
|
+
vw, vh = display_size(meta["video"])
|
|
358
|
+
square = square_pixels(meta["video"])
|
|
359
|
+
if square:
|
|
360
|
+
note(square_pixels_note(meta["video"]))
|
|
313
361
|
# --platform: the edges this destination's own UI covers, in pixels of this frame. An
|
|
314
362
|
# explicit --margin (or a brand safe_margin, applied above) is the more specific statement
|
|
315
363
|
# and wins; without either, the historical 24 px default is unchanged.
|
|
@@ -377,7 +425,7 @@ def main() -> int:
|
|
|
377
425
|
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
|
378
426
|
# RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
|
|
379
427
|
# 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
|
|
380
|
-
base =
|
|
428
|
+
base = layer_base_chain(meta)
|
|
381
429
|
fc = (f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS{rgb_chain_to_bt709(meta)}[ov];"
|
|
382
430
|
+ (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
|
|
383
431
|
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
@@ -398,7 +446,7 @@ def main() -> int:
|
|
|
398
446
|
if not pip_meta.get("video"):
|
|
399
447
|
die(f"--video {args.video} has no video stream")
|
|
400
448
|
layer_fps = pip_meta["video"].get("fps")
|
|
401
|
-
placements = parse_placements(args.place, fps, layer_fps) if args.place else []
|
|
449
|
+
placements = parse_placements(args.place, fps, layer_fps, pip_meta, meta) if args.place else []
|
|
402
450
|
# bounds from the video streams (a longer audio track would let a range run past the last
|
|
403
451
|
# picture); the container duration only when a stream has none
|
|
404
452
|
layer_dur = pip_meta["video"].get("duration") or pip_meta.get("duration")
|
|
@@ -415,7 +463,7 @@ def main() -> int:
|
|
|
415
463
|
die(f"--place {a:g}-{b:g}@{at:g}: the layer is only {layer_dur:.3f} s long")
|
|
416
464
|
if main_dur and at >= main_dur:
|
|
417
465
|
die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
|
|
418
|
-
chain = []
|
|
466
|
+
chain = [square_pixels(pip_meta["video"])] if square_pixels(pip_meta["video"]) else []
|
|
419
467
|
if args.scale_percent:
|
|
420
468
|
chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
|
|
421
469
|
elif args.scale:
|
|
@@ -436,6 +484,8 @@ def main() -> int:
|
|
|
436
484
|
base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
|
|
437
485
|
if base or pip:
|
|
438
486
|
base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
|
|
487
|
+
# overlay mixes samples: a full-range picture is taken to limited range (#410)
|
|
488
|
+
base, pip = base + range_chain(meta, meta), pip + range_chain(pip_meta, meta)
|
|
439
489
|
if placements:
|
|
440
490
|
# each placed piece: its range of the layer, re-timed to start at AT; eof_action=pass shows
|
|
441
491
|
# the main picture untouched once a piece ends (the default would freeze its last frame)
|
|
@@ -464,6 +514,7 @@ def main() -> int:
|
|
|
464
514
|
else:
|
|
465
515
|
cmd += ["-shortest"]
|
|
466
516
|
else:
|
|
517
|
+
args.text = _wrap_to_frame(args, vw, safe_margins, vh)
|
|
467
518
|
x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
|
|
468
519
|
# 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
|
|
469
520
|
# (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
|
|
@@ -483,10 +534,13 @@ def main() -> int:
|
|
|
483
534
|
drawing = "drawtext=" + ":".join(opts)
|
|
484
535
|
# a font/border/box colour with chroma lands in the BT.709 the output is tagged with
|
|
485
536
|
colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
|
|
486
|
-
cmd += ["-vf", drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
|
|
537
|
+
cmd += ["-vf", (square + "," if square else "") + drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
|
|
487
538
|
if meta.get("audio"):
|
|
488
539
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
489
540
|
|
|
541
|
+
if square and "-filter_complex" in cmd:
|
|
542
|
+
i = cmd.index("-filter_complex") + 1
|
|
543
|
+
cmd[i] = f"[0:v]{square}[sq];" + cmd[i].replace("[0:v]", "[sq]")
|
|
490
544
|
cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
|
|
491
545
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
492
546
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
package/scripts/proxy.py
CHANGED
|
@@ -26,6 +26,8 @@ import argparse
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
29
|
+
from _common.emit import note
|
|
30
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def even(n: float) -> int:
|
|
@@ -56,15 +58,17 @@ def main() -> int:
|
|
|
56
58
|
meta = probe(args.input)
|
|
57
59
|
if not meta.get("video"):
|
|
58
60
|
die("input has no video stream")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
# the displayed width: rotation applied, non-square pixels squared first (#411)
|
|
62
|
+
sw = display_size(meta["video"])[0]
|
|
63
|
+
square = square_pixels(meta["video"])
|
|
64
|
+
if square:
|
|
65
|
+
note(square_pixels_note(meta["video"]))
|
|
62
66
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
63
67
|
|
|
64
68
|
out_w = even(sw * args.scale) if args.scale is not None else even(args.width)
|
|
65
69
|
output = args.output or default_output(args.input, "proxy")
|
|
66
70
|
|
|
67
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
|
|
71
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
|
|
68
72
|
cmd += video_args(meta, args.crf, "veryfast")
|
|
69
73
|
cmd += cfr_args(meta, args.fps)
|
|
70
74
|
cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
|
package/scripts/redact.py
CHANGED
|
@@ -28,6 +28,8 @@ import argparse
|
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
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
|
|
31
|
+
from _common.emit import note
|
|
32
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def main() -> int:
|
|
@@ -67,9 +69,12 @@ def main() -> int:
|
|
|
67
69
|
meta = probe(args.input)
|
|
68
70
|
if not meta.get("video"):
|
|
69
71
|
die("input has no video stream")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
# the rectangle is in the displayed frame: rotation applied, and a non-square-pixel source
|
|
73
|
+
# squared at its display aspect first (#411), the frame look.py and cropdetect.py show
|
|
74
|
+
sw, sh = display_size(meta["video"])
|
|
75
|
+
square = square_pixels(meta["video"])
|
|
76
|
+
if square:
|
|
77
|
+
note(square_pixels_note(meta["video"]))
|
|
73
78
|
if args.x + args.width > sw or args.y + args.height > sh:
|
|
74
79
|
die(f"redaction rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
|
|
75
80
|
if args.mode == "delogo":
|
|
@@ -93,7 +98,7 @@ def main() -> int:
|
|
|
93
98
|
output = args.output or default_output(args.input, "redact")
|
|
94
99
|
if args.mode == "delogo":
|
|
95
100
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf",
|
|
96
|
-
f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
101
|
+
(square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
97
102
|
"-map", "0:v:0"]
|
|
98
103
|
else:
|
|
99
104
|
crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
|
|
@@ -105,7 +110,7 @@ def main() -> int:
|
|
|
105
110
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
106
111
|
else:
|
|
107
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"
|
|
108
|
-
fc = f"[0:v]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}[out]"
|
|
109
114
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
110
115
|
if has_audio:
|
|
111
116
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|