ffmpeg-skill 3.1.4 → 3.1.6
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 +4 -3
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +5 -4
- package/scripts/_common/__init__.py +9 -7
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +199 -14
- package/scripts/_common/emit.py +12 -1
- package/scripts/_common/probe.py +98 -4
- package/scripts/_common/runner.py +38 -3
- package/scripts/_common/timeline.py +5 -4
- package/scripts/_contract.py +2 -2
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +49 -22
- 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 +11 -2
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +109 -68
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +11 -2
- package/scripts/join.py +29 -19
- package/scripts/look.py +44 -14
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +70 -15
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/render.py +8 -0
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +61 -30
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/verify.py +7 -2
- package/scripts/waveform.py +22 -10
package/scripts/grid.py
CHANGED
|
@@ -26,15 +26,34 @@ import os
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
|
-
draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
29
|
+
as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
30
30
|
from _common.fonts import font_choice, record_font_choice
|
|
31
31
|
from _common import drawtext_fontfile # noqa: E402
|
|
32
32
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
33
33
|
from _common.emit import note, record_fallback # noqa: E402
|
|
34
|
+
from _common.fonts import script_font_for_text # noqa: E402
|
|
35
|
+
from _common.probe import square_pixels # noqa: E402
|
|
36
|
+
from _common.wrap import text_width_em # noqa: E402
|
|
34
37
|
|
|
35
38
|
LABEL_MARGIN = 10
|
|
36
39
|
|
|
37
40
|
|
|
41
|
+
def _fit_label(stem: str, cell_width: int, font_size: int) -> str:
|
|
42
|
+
"""The label as drawn: `stem` when it fits the cell (less the margin and the box's border on
|
|
43
|
+
each side), else its head with an ellipsis. It is right-aligned, so a label wider than the
|
|
44
|
+
cell used to lose its start off the left edge and show only its tail (#411). The width is the
|
|
45
|
+
averaged advance table's estimate."""
|
|
46
|
+
room = (cell_width - 2 * LABEL_MARGIN - 8) / float(max(1, font_size))
|
|
47
|
+
if text_width_em(stem) <= room:
|
|
48
|
+
return stem
|
|
49
|
+
if room <= 1:
|
|
50
|
+
return stem[:1] # no room for an ellipsis: keep the start, as a shortened label does (#416 review)
|
|
51
|
+
head = stem
|
|
52
|
+
while head and text_width_em(head + "\u2026") > room:
|
|
53
|
+
head = head[:-1]
|
|
54
|
+
return (head.rstrip() + "\u2026") if head else stem[:1]
|
|
55
|
+
|
|
56
|
+
|
|
38
57
|
def main() -> int:
|
|
39
58
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
40
59
|
ap.add_argument("inputs", nargs="+", help="video clips, filled into the grid left-to-right, top-to-bottom")
|
|
@@ -69,6 +88,9 @@ def main() -> int:
|
|
|
69
88
|
n = args.cols * args.rows
|
|
70
89
|
if args.cols <= 0 or args.rows <= 0:
|
|
71
90
|
die(f"--cols/--rows must be > 0, got cols={args.cols} rows={args.rows}")
|
|
91
|
+
if n == 1:
|
|
92
|
+
# xstack takes two inputs or more: a 1x1 grid used to reach ffmpeg and fail there (exit 222)
|
|
93
|
+
die("--cols 1 --rows 1 is a single cell; a grid needs at least two (fit.py letterboxes one clip)")
|
|
72
94
|
if len(args.inputs) != n:
|
|
73
95
|
die(f"--cols {args.cols} --rows {args.rows} needs exactly {n} inputs, got {len(args.inputs)}")
|
|
74
96
|
if args.cell_width <= 0 or args.cell_height <= 0:
|
|
@@ -95,10 +117,26 @@ def main() -> int:
|
|
|
95
117
|
durations = [m.get("duration") or 0.0 for m in metas]
|
|
96
118
|
target_duration = max(durations) if args.pad else min(durations)
|
|
97
119
|
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
# each label is drawn with a font that covers its own script, as every other text-drawing tool
|
|
121
|
+
# picks one (through 3.1.3 a Japanese or Thai filename came out as tofu, #411); `font_used` is
|
|
122
|
+
# the first label's choice, and a grid whose labels needed different fonts says so in a note
|
|
123
|
+
stems = [os.path.splitext(os.path.basename(p))[0] for p in args.inputs]
|
|
124
|
+
font_explicit = args.font != ap.get_default("font")
|
|
125
|
+
label_fonts = []
|
|
126
|
+
for stem in (stems if args.label == "auto" else []):
|
|
127
|
+
_sc, script_file, script_family = script_font_for_text(stem, font=args.font, font_explicit=font_explicit)
|
|
128
|
+
label_fonts.append(font_choice(args.font, script_file=script_file, script_family=script_family))
|
|
129
|
+
font_used = label_fonts[0] if label_fonts else font_choice(args.font)
|
|
100
130
|
if args.label == "auto":
|
|
101
131
|
record_font_choice(font_used)
|
|
132
|
+
if len({f.get("file") for f in label_fonts}) > 1:
|
|
133
|
+
note("cell labels drawn with more than one font, by script: "
|
|
134
|
+
+ "; ".join(sorted({str(f.get("family") or f.get("file")) for f in label_fonts})))
|
|
135
|
+
labels = [_fit_label(stem, args.cell_width, args.font_size) for stem in stems]
|
|
136
|
+
cut = [stem for stem, lab in zip(stems, labels) if lab != stem]
|
|
137
|
+
if args.label == "auto" and cut:
|
|
138
|
+
note(f"{len(cut)} cell label(s) wider than the {args.cell_width} px cell at --font-size "
|
|
139
|
+
f"{args.font_size} were shortened from the end with an ellipsis")
|
|
102
140
|
output = args.output or default_output(args.inputs[0], "grid")
|
|
103
141
|
|
|
104
142
|
cmd = ffmpeg_base()
|
|
@@ -117,6 +155,8 @@ def main() -> int:
|
|
|
117
155
|
convert = [matrix_chain(m, None) for m in metas]
|
|
118
156
|
if any(convert) or chroma:
|
|
119
157
|
convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
|
|
158
|
+
# xstack mixes samples: a full-range cell beside a limited one is taken to limited range (#410)
|
|
159
|
+
convert = [c + range_chain(m, None) for c, m in zip(convert, metas)]
|
|
120
160
|
parts = []
|
|
121
161
|
for i, p in enumerate(args.inputs):
|
|
122
162
|
drawn = [ # (with this colour, with black)
|
|
@@ -129,17 +169,19 @@ def main() -> int:
|
|
|
129
169
|
elif not args.pad:
|
|
130
170
|
drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
|
|
131
171
|
if args.label == "auto":
|
|
132
|
-
|
|
172
|
+
font_file = label_fonts[i]["file"]
|
|
133
173
|
font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
134
|
-
drawn.append((f"drawtext=text='{escape_drawtext(
|
|
174
|
+
drawn.append((f"drawtext=text='{escape_drawtext(labels[i])}':{font_opt}:fontsize={args.font_size}:"
|
|
135
175
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
136
176
|
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
|
137
177
|
gap_r = args.gap if i % args.cols < args.cols - 1 else 0
|
|
138
178
|
gap_b = args.gap if i // args.cols < args.rows - 1 else 0
|
|
139
179
|
if chroma and (gap_r or gap_b):
|
|
140
180
|
drawn.append((f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color={args.background}", f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color=black"))
|
|
141
|
-
|
|
142
|
-
|
|
181
|
+
# a non-square-pixel clip is squared at its display aspect before it is fitted (#411)
|
|
182
|
+
sq = (square_pixels(metas[i]["video"]) + ",") if square_pixels(metas[i]["video"]) else ""
|
|
183
|
+
chain = f"fps={args.fps:g},{sq}scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
|
|
184
|
+
chain += drawn_chain(",".join(c for c, _n in drawn), as_limited(metas[i]), colours, ",".join(n for _c, n in drawn),
|
|
143
185
|
line_meta=None, tag=f"c{i}", labelled=True)
|
|
144
186
|
parts.append(f"[{i}:v]{convert[i]}{chain}[v{i}]")
|
|
145
187
|
|
package/scripts/insert.py
CHANGED
|
@@ -27,7 +27,10 @@ import argparse
|
|
|
27
27
|
import math
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
+
from _common import still_input_args # noqa: E402
|
|
30
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
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
def even(n: float) -> int:
|
|
@@ -68,7 +71,11 @@ def main() -> int:
|
|
|
68
71
|
if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
|
|
69
72
|
die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
|
|
70
73
|
f"For a clip use broll.py (cutaway) or cut.py/join.py")
|
|
71
|
-
|
|
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"]))
|
|
72
79
|
ratio = sw / sh
|
|
73
80
|
|
|
74
81
|
if args.width and args.height:
|
|
@@ -114,8 +121,10 @@ def main() -> int:
|
|
|
114
121
|
f"fps={args.fps:g}",
|
|
115
122
|
]
|
|
116
123
|
|
|
124
|
+
if square_pixels(meta["video"]):
|
|
125
|
+
vf.insert(0, square_pixels(meta["video"])) # the scale and crop above size the squared picture
|
|
117
126
|
output = args.output or default_output(args.input, "insert", "mp4")
|
|
118
|
-
cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
|
|
127
|
+
cmd = ffmpeg_base() + ["-loop", "1", *still_input_args(args.input), "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
|
|
119
128
|
# the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
|
|
120
129
|
# the BT.709 that line is tagged with
|
|
121
130
|
cmd += video_args(None, args.crf, args.preset, source=meta)
|
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
|
@@ -17,10 +17,13 @@ from pathlib import Path
|
|
|
17
17
|
from typing import List, Optional
|
|
18
18
|
|
|
19
19
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
20
|
+
from _common import still_input_args # noqa: E402
|
|
20
21
|
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
|
|
21
22
|
from _common import drawtext_fontfile # noqa: E402
|
|
22
23
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
23
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
|
|
24
27
|
|
|
25
28
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
26
29
|
|
|
@@ -40,6 +43,12 @@ def timecode_filter(font_prefix: str, offset: float = 0.0) -> str:
|
|
|
40
43
|
return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
|
|
41
44
|
|
|
42
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
|
+
|
|
43
52
|
def _png_size(png_path: str) -> "Optional[tuple]":
|
|
44
53
|
# run_analysis(): the same wall-clock ceiling and #234 UTF-8 decoding as every other
|
|
45
54
|
# ffprobe/ffmpeg measurement in this codebase, check=False since a probe failure here is
|
|
@@ -188,10 +197,7 @@ def main() -> int:
|
|
|
188
197
|
if args.safe:
|
|
189
198
|
z = PLATFORMS[args.safe]["safe"]
|
|
190
199
|
frame = PLATFORMS[args.safe]["frame"]
|
|
191
|
-
|
|
192
|
-
dw, dh = src.get("width") or 0, src.get("height") or 0
|
|
193
|
-
if src.get("rotation") in (90, -90, 270, -270):
|
|
194
|
-
dw, dh = dh, dw
|
|
200
|
+
dw, dh = display_size(meta["video"])
|
|
195
201
|
if dw and dh and abs(dw / dh - frame["w"] / frame["h"]) > 0.02:
|
|
196
202
|
info(f"--safe {args.safe}: this source is {dw}x{dh}, not {args.safe}'s "
|
|
197
203
|
f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
|
|
@@ -220,11 +226,25 @@ def main() -> int:
|
|
|
220
226
|
tc = "," + tm.rstrip(",") + tc
|
|
221
227
|
info("HDR source: frames are tone-mapped to SDR for display")
|
|
222
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"]))
|
|
223
240
|
|
|
224
241
|
if args.compare:
|
|
225
242
|
if not args.at:
|
|
226
243
|
die("--compare needs --at TIME")
|
|
227
|
-
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 ""
|
|
228
248
|
for idx, t in enumerate(args.at):
|
|
229
249
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
230
250
|
if args.output and len(args.at) == 1:
|
|
@@ -237,10 +257,17 @@ def main() -> int:
|
|
|
237
257
|
half = args.width // 2
|
|
238
258
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
239
259
|
tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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]")
|
|
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,
|
|
270
|
+
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
|
244
271
|
run(cmd)
|
|
245
272
|
outputs.append(out)
|
|
246
273
|
elif args.at:
|
|
@@ -254,7 +281,7 @@ def main() -> int:
|
|
|
254
281
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
255
282
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
256
283
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
257
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "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]
|
|
258
285
|
run(cmd)
|
|
259
286
|
outputs.append(out)
|
|
260
287
|
else:
|
|
@@ -277,11 +304,14 @@ def main() -> int:
|
|
|
277
304
|
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
278
305
|
# with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
|
|
279
306
|
seek = 0.0 if frames_avail < 4 else step / 2
|
|
280
|
-
#
|
|
281
|
-
|
|
282
|
-
|
|
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},"
|
|
283
313
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
284
|
-
cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "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]
|
|
285
315
|
run(cmd)
|
|
286
316
|
outputs.append(out)
|
|
287
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}"]
|