ffmpeg-skill 3.8.10 → 3.8.12
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 +1 -1
- package/docs/contract.md +13 -7
- package/package.json +1 -1
- package/references/scripts.md +13 -6
- package/scripts/_common/decision.py +108 -2
- package/scripts/_common/emit.py +8 -5
- package/scripts/_common/probe.py +5 -0
- package/scripts/_common/runner.py +42 -1
- package/scripts/_contract.py +3 -3
- package/scripts/background.py +30 -10
- package/scripts/broll.py +82 -18
- package/scripts/caption.py +2 -2
- package/scripts/color.py +8 -5
- package/scripts/crop.py +3 -1
- package/scripts/export.py +16 -7
- package/scripts/fit.py +35 -4
- package/scripts/freeze.py +10 -5
- package/scripts/graphics.py +20 -9
- package/scripts/grid.py +4 -2
- package/scripts/loop.py +24 -6
- package/scripts/metadata.py +4 -0
- package/scripts/overlay.py +12 -13
- package/scripts/pad.py +12 -4
- package/scripts/redact.py +14 -2
- package/scripts/render.py +64 -2
- package/scripts/reverse.py +20 -7
- package/scripts/straighten.py +14 -3
- package/scripts/sync.py +6 -2
package/scripts/broll.py
CHANGED
|
@@ -18,10 +18,12 @@ Examples:
|
|
|
18
18
|
python3 broll.py talk.mp4 --insert demo.mp4 --at 30 --duration 8 --audio mix
|
|
19
19
|
"""
|
|
20
20
|
import argparse
|
|
21
|
+
import math
|
|
21
22
|
import sys
|
|
22
23
|
from typing import Any, Dict, List
|
|
23
24
|
|
|
24
|
-
from _common.decision import av_trim, conform_output_t, conform_to_length,
|
|
25
|
+
from _common.decision import (av_trim, conform_output_t, conform_to_length, frame_durations, frame_rate, frame_window,
|
|
26
|
+
grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
|
|
25
27
|
from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
28
|
from _common.emit import note, record_fallback
|
|
27
29
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
@@ -77,6 +79,11 @@ def main() -> int:
|
|
|
77
79
|
a_chain = (a_chain + "," if a_chain else "") + f"scale={ew}:{eh},setsar=1"
|
|
78
80
|
w, h = ew, eh
|
|
79
81
|
fps = meta_a["video"].get("fps") or 30.0
|
|
82
|
+
# the A-roll's frame grid, when it has one (frame_window): its exact rate and where it starts
|
|
83
|
+
grid = frame_window(0.0, None, meta_a) is not None
|
|
84
|
+
rate_a, v0a = frame_rate(meta_a), stream_starts(meta_a)[0]
|
|
85
|
+
fps_filter = frame_durations(meta_a, rate_a) # fps=30000/1001; a whole rate reads as it did (fps=25)
|
|
86
|
+
fps_filter = fps_filter[:-2] if fps_filter.endswith("/1") else fps_filter
|
|
80
87
|
has_audio_a = bool(meta_a.get("audio"))
|
|
81
88
|
|
|
82
89
|
def per(values: List[str], i: int, default: str) -> str:
|
|
@@ -105,9 +112,27 @@ def main() -> int:
|
|
|
105
112
|
dur_b = input_length(meta_b)
|
|
106
113
|
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
107
114
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
# the cutaway in whole frames: `first` is B's first frame at or after --from, `on` the
|
|
116
|
+
# A-roll's first frame at or after --at, and `span` runs to its first at or after the end
|
|
117
|
+
v0b, rate_b = stream_starts(meta_b)[0], frame_rate(meta_b)
|
|
118
|
+
first = max(v0b, grid_at_or_after(start_b, v0b, rate_b))
|
|
119
|
+
on = span = None
|
|
120
|
+
if grid:
|
|
121
|
+
on = grid_at_or_after(at, v0a, rate_a)
|
|
122
|
+
span = grid_at_or_after(at + length, v0a, rate_a) - on
|
|
123
|
+
if round(span * rate_a) < 1:
|
|
124
|
+
die(f"cutaway {i + 1}: no frame of the A-roll starts between {at:g}s and {at + length:g}s "
|
|
125
|
+
f"(its frames are {1 / rate_a:.4f}s apart): nothing would be shown")
|
|
126
|
+
cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b,
|
|
127
|
+
"first": first, "rate": rate_b, "on": on, "span": span})
|
|
128
|
+
# overlap is a property of the windows in time, not of the order they were given in: compared
|
|
129
|
+
# against the one given before it, a later window listed first was called overlapping (A179)
|
|
130
|
+
order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
|
|
131
|
+
for prev, k in zip(order, order[1:]):
|
|
132
|
+
end_prev = cutaways[prev]["at"] + cutaways[prev]["length"]
|
|
133
|
+
if cutaways[k]["at"] < end_prev:
|
|
134
|
+
die(f"cutaway {k + 1} at {cutaways[k]['at']:g}s overlaps cutaway {prev + 1} "
|
|
135
|
+
f"({cutaways[prev]['at']:g}s to {end_prev:g}s)")
|
|
111
136
|
if args.audio != "a" and not all(c["has_audio"] for c in cutaways):
|
|
112
137
|
die("--audio b/mix needs audio on every B-roll clip")
|
|
113
138
|
|
|
@@ -117,7 +142,7 @@ def main() -> int:
|
|
|
117
142
|
cmd += ["-i", c["path"]]
|
|
118
143
|
|
|
119
144
|
# Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
|
|
120
|
-
# --at time, and overlaid on A;
|
|
145
|
+
# --at time, and overlaid on A; outside the window the overlay is placed off the picture and
|
|
121
146
|
# A shows through unchanged, so A's timeline is never touched.
|
|
122
147
|
parts: List[str] = []
|
|
123
148
|
cur = "[0:v]"
|
|
@@ -138,9 +163,32 @@ def main() -> int:
|
|
|
138
163
|
+ drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
|
|
139
164
|
tag=f"pad{i}", labelled=True))
|
|
140
165
|
b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
166
|
+
pix = 'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'
|
|
167
|
+
if c["on"] is None:
|
|
168
|
+
# no grid to count the A-roll's frames on (a dry run's pending A-roll, a variable
|
|
169
|
+
# rate): the window as it was written before, to the millisecond
|
|
170
|
+
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
171
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={pix}[b{i}]")
|
|
172
|
+
window = window_expr(c["at"], c["at"] + c["length"], None)
|
|
173
|
+
else:
|
|
174
|
+
# The window is whole frames of the A-roll: from its first frame at or after --at to
|
|
175
|
+
# the frame before its first at or after the end. B is cut half a frame of its own
|
|
176
|
+
# before its first frame at or after --from, for as long as the window lasts, and
|
|
177
|
+
# that frame is put on the tick of the `fps` grid at or before the window's first
|
|
178
|
+
# frame, at the A-roll's exact rate (30000/1001, not probe()'s 29.97: frame 37
|
|
179
|
+
# counted at 29.97 comes 1.2 microseconds after the A-roll's and was shown a frame on)
|
|
180
|
+
# The cut never ends before B's first frame: a window shorter than half a frame of B
|
|
181
|
+
# (one 60 fps frame of the A-roll under a 24 fps cutaway) cut that frame off too
|
|
182
|
+
half_b = 0.5 / c["rate"] if c["rate"] else 0.0
|
|
183
|
+
parts.append(f"[{i + 1}:v]trim=start={max(0.0, c['first'] - half_b):.6f}:end={max(c['first'] + c['span'], c['first'] + 2 * half_b) - half_b:.6f},"
|
|
184
|
+
f"setpts=PTS-STARTPTS+{math.floor(c['on'] * rate_a + 1e-6) / rate_a:.6f}/TB,"
|
|
185
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,{fps_filter},format={pix}[b{i}]")
|
|
186
|
+
window = window_expr(c["on"], c["on"] + c["span"], meta_a, half_open=True)
|
|
187
|
+
# The window is in the cutaway's position: overlay's enable is evaluated on B's frames
|
|
188
|
+
# too. B's last frame is held when B ends (eof_action=repeat) and the position takes it
|
|
189
|
+
# off the picture: with eof_action=pass the window's last frame depended on where B's
|
|
190
|
+
# end fell against it, and an A-roll whose picture starts 13 ms late lost that frame.
|
|
191
|
+
parts.append(f"{cur}[b{i}]overlay={windowed_overlay_x(0, window)}:0{overlay_format(meta_a)}:eof_action=repeat[v{i}]")
|
|
144
192
|
cur = f"[v{i}]"
|
|
145
193
|
vout = cur
|
|
146
194
|
|
|
@@ -152,24 +200,40 @@ def main() -> int:
|
|
|
152
200
|
aout = "0:a:0" if has_audio_a else None
|
|
153
201
|
else:
|
|
154
202
|
layers: List[str] = []
|
|
203
|
+
# the cutaway's sound follows its picture: it starts with the window's first frame and
|
|
204
|
+
# lasts as long as the window, counted in samples of the mix (without a frame grid:
|
|
205
|
+
# --at and the length asked)
|
|
206
|
+
for c in cutaways:
|
|
207
|
+
c["s0"] = round((c["at"] if c["on"] is None else c["on"]) * MIX_RATE)
|
|
208
|
+
c["sn"] = round((c["length"] if c["on"] is None else c["span"]) * MIX_RATE)
|
|
155
209
|
if has_audio_a:
|
|
156
|
-
|
|
210
|
+
# `b` mutes the A-roll over exactly the samples B's sound takes, on the A-roll's
|
|
211
|
+
# own clock: aeval's `t` is each sample's time, and the bounds sit half a sample
|
|
212
|
+
# outside the first and the last. `volume=0:enable='between(t,...)'` is evaluated
|
|
213
|
+
# once per audio frame and muted 48128-96255 where 48000-95999 was asked
|
|
214
|
+
# (1024-sample frames; FFmpeg 6.1.1)
|
|
215
|
+
gate = ""
|
|
216
|
+
if args.audio == "b":
|
|
217
|
+
gate = ",aeval='val(ch)*not(" + "+".join(
|
|
218
|
+
f"between(t,{(c['s0'] - 0.5) / MIX_RATE:.9f},{(c['s0'] + c['sn'] - 0.5) / MIX_RATE:.9f})" for c in cutaways) + ")':c=same"
|
|
157
219
|
parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
|
|
158
220
|
layers.append("[a0]")
|
|
159
221
|
for i, c in enumerate(cutaways):
|
|
160
222
|
# B's sound is shifted by the same constant as its picture, whose first frame (the
|
|
161
|
-
# first at or after --from) lands on
|
|
162
|
-
# sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
# the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
|
|
223
|
+
# first at or after --from) lands on the window's first frame; rebasing the sound
|
|
224
|
+
# to its own first sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
225
|
+
first, length = c["first"], c["length"] if c["on"] is None else c["span"]
|
|
226
|
+
# the delay is to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
|
|
166
227
|
# 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
|
|
167
228
|
# early. A whole millisecond keeps the text it had, rounded (silence_filter)
|
|
168
|
-
|
|
229
|
+
lead = c["s0"] / MIX_RATE
|
|
230
|
+
delay = silence_filter(MIX_RATE, lead=lead)
|
|
169
231
|
if "S:" not in delay:
|
|
170
|
-
delay = f"adelay={round(
|
|
171
|
-
|
|
172
|
-
|
|
232
|
+
delay = f"adelay={round(lead * 1000)}|{round(lead * 1000)}"
|
|
233
|
+
# under `b` the layer is as many samples as the gate mutes, padded or cut
|
|
234
|
+
exact = f"apad=whole_len={c['sn']},atrim=end_sample={c['sn']}," if args.audio == "b" else ""
|
|
235
|
+
parts.append(f"[{i + 1}:a:0]{av_trim(first, first + length)[1]},"
|
|
236
|
+
f"aformat=sample_rates=48000:channel_layouts=stereo,{exact}{delay}[ab{i}]")
|
|
173
237
|
layers.append(f"[ab{i}]")
|
|
174
238
|
# the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
|
|
175
239
|
# is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
|
package/scripts/caption.py
CHANGED
|
@@ -56,7 +56,7 @@ from _common import asr as _asr
|
|
|
56
56
|
from _common.asr import ENGINE_CHOICES
|
|
57
57
|
from _common.emit import warn
|
|
58
58
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
59
|
-
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
59
|
+
from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
|
|
60
60
|
from _common.fonts import font_choice, record_font_choice
|
|
61
61
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
62
62
|
write_srt)
|
|
@@ -1787,7 +1787,7 @@ def main() -> int:
|
|
|
1787
1787
|
if meta.get("audio"):
|
|
1788
1788
|
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
1789
1789
|
if existing_subs:
|
|
1790
|
-
maps += ["-map", "0:s?"]
|
|
1790
|
+
maps += ["-map", "0:s?"] + carried_stream_maps(args.input, output, chapters=False) # their fonts (A256)
|
|
1791
1791
|
for n in range(len(added)):
|
|
1792
1792
|
maps += ["-map", f"{n + 1}:0"]
|
|
1793
1793
|
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
package/scripts/color.py
CHANGED
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
from typing import List, Optional
|
|
34
34
|
|
|
35
|
-
from _common.runner import fail_if_classified
|
|
35
|
+
from _common.runner import carried_stream_maps, fail_if_classified
|
|
36
36
|
from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
37
37
|
from _common.emit import note, warn
|
|
38
38
|
from _common.color import is_rgb, rgb_to_bt709_filter
|
|
@@ -329,7 +329,8 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
|
|
|
329
329
|
ext = os.path.splitext(output)[1].lower()
|
|
330
330
|
tags = ["-colorspace", names[0], "-color_primaries", names[1], "-color_trc", names[2]]
|
|
331
331
|
bsf = retag_bsf(meta, args.retag)
|
|
332
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0"
|
|
332
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
|
|
333
|
+
+ ["-c", "copy"] + bsf + tags)
|
|
333
334
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
334
335
|
cmd += ["-movflags", "+faststart"]
|
|
335
336
|
cmd.append(output)
|
|
@@ -357,8 +358,9 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
|
|
|
357
358
|
info("re-encoding video/audio with the new tags (subtitles/data streams kept)")
|
|
358
359
|
label = ["-vf", f"setparams=color_primaries={names[1]}:color_trc={names[2]}:colorspace={names[0]}"]
|
|
359
360
|
enc = x264_args(args.crf, args.preset, keep_bt709=False) + (aac_args() if has_audio else [])
|
|
360
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
361
|
-
"-map", "0:s?", "-map", "0:d?"] +
|
|
361
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
362
|
+
"-map", "0:s?", "-map", "0:d?"] + carried_stream_maps(args.input, output) + label + enc
|
|
363
|
+
+ ["-c:s", "copy", "-c:d", "copy", output])
|
|
362
364
|
proc2 = run(cmd, check=False)
|
|
363
365
|
fail_if_classified(proc2)
|
|
364
366
|
if proc2.returncode != 0:
|
|
@@ -552,7 +554,8 @@ def main() -> int:
|
|
|
552
554
|
die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
|
|
553
555
|
if not v.get("dolby_vision"):
|
|
554
556
|
warn("no Dolby Vision metadata detected; removing unregistered SEI anyway", "dovi_not_detected")
|
|
555
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0"
|
|
557
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
|
|
558
|
+
+ ["-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"])
|
|
556
559
|
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
557
560
|
cmd += ["-movflags", "+faststart"]
|
|
558
561
|
cmd.append(output)
|
package/scripts/crop.py
CHANGED
|
@@ -62,7 +62,9 @@ def main() -> int:
|
|
|
62
62
|
has_audio = bool(meta.get("audio"))
|
|
63
63
|
|
|
64
64
|
output = args.output or default_output(args.input, "crop")
|
|
65
|
-
|
|
65
|
+
# exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
|
|
66
|
+
# and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
|
|
67
|
+
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
|
|
66
68
|
if square_pixels(meta["video"]):
|
|
67
69
|
vf.insert(0, square_pixels(meta["video"]))
|
|
68
70
|
note(square_pixels_note(meta["video"]))
|
package/scripts/export.py
CHANGED
|
@@ -46,7 +46,7 @@ import tempfile
|
|
|
46
46
|
from pathlib import Path
|
|
47
47
|
from typing import Dict, List, Optional
|
|
48
48
|
|
|
49
|
-
from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
49
|
+
from _common.runner import carried_stream_maps, child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
50
50
|
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
51
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
@@ -321,15 +321,19 @@ _subtitle_codec = subtitle_codec_for
|
|
|
321
321
|
|
|
322
322
|
def _data_streams(path: str) -> List[Dict[str, object]]:
|
|
323
323
|
"""The data streams of a file ({index, codec}), without its attachments (probe()'s
|
|
324
|
-
data_streams counts both, and -map 0:d maps only the data streams)
|
|
324
|
+
data_streams counts both, and -map 0:d maps only the data streams) and without an MP4/MOV
|
|
325
|
+
chapter track, which the muxer writes again from the chapters (carried_stream_maps, A173)."""
|
|
325
326
|
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "d", "-show_entries",
|
|
326
|
-
"stream=codec_name,codec_tag_string", "-of", "json", path], quiet=True)
|
|
327
|
+
"stream=codec_name,codec_tag_string:chapter=id", "-of", "json", path], quiet=True)
|
|
327
328
|
try:
|
|
328
|
-
|
|
329
|
+
doc = json.loads(proc.stdout or "{}")
|
|
329
330
|
except ValueError:
|
|
330
331
|
# an unread list would map nothing and report nothing dropped
|
|
331
332
|
die(f"ffprobe's data-stream list for {path} did not parse", kind="ffmpeg")
|
|
332
|
-
|
|
333
|
+
streams = doc.get("streams") or []
|
|
334
|
+
chapter_track = bool(doc.get("chapters"))
|
|
335
|
+
return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)
|
|
336
|
+
if not (chapter_track and str(s.get("codec_tag_string") or "").lower() == "text")]
|
|
333
337
|
|
|
334
338
|
|
|
335
339
|
def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data: int) -> List[str]:
|
|
@@ -338,10 +342,13 @@ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data
|
|
|
338
342
|
subtitles + data, subtitles alone, neither; each retry is a `non_av_streams_dropped` fallback
|
|
339
343
|
with the attempt's error. `cmd` already maps the picture and the sound and has no output.
|
|
340
344
|
Returns the stream types dropped ([] when everything mapped was written)."""
|
|
345
|
+
src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
|
|
341
346
|
sub_args: List[str] = ["-map", "0:s"] if sub_codecs else []
|
|
342
347
|
for n, codec in enumerate(sub_codecs):
|
|
343
348
|
sub_args += [f"-c:s:{n}", codec]
|
|
344
|
-
|
|
349
|
+
if sub_codecs:
|
|
350
|
+
sub_args += carried_stream_maps(src, output, chapters=False) # the fonts the subtitles are drawn with (A256)
|
|
351
|
+
data_args = (["-map", "0:d"] + carried_stream_maps(src, output, attachments=False) + ["-c:d", "copy"]) if data else []
|
|
345
352
|
rungs = [(sub_args + data_args, [])]
|
|
346
353
|
if sub_codecs and data:
|
|
347
354
|
rungs.append((sub_args, ["data"]))
|
|
@@ -389,7 +396,9 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
|
|
|
389
396
|
written_data = _data_streams(output)
|
|
390
397
|
for kind, streams, written in (("subtitle", subs, written_subs), ("data", data, written_data)):
|
|
391
398
|
for n, s in enumerate(streams):
|
|
392
|
-
row
|
|
399
|
+
# a data row keeps its n-th-of-type index in the source: the chapter text track is left
|
|
400
|
+
# out of the list, so a timecode track after it is data stream 1, not 0
|
|
401
|
+
row = {"type": kind, "index": s.get("index", n) if kind == "data" else n, "codec": s.get("codec")}
|
|
393
402
|
if kind == "subtitle":
|
|
394
403
|
row["language"] = s.get("language")
|
|
395
404
|
if STATE.dry_run and kind not in dropped:
|
package/scripts/fit.py
CHANGED
|
@@ -68,6 +68,24 @@ def atempo_chain(factor: float) -> str:
|
|
|
68
68
|
return ",".join(parts)
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def picture_output_t(target: float, meta: dict) -> str:
|
|
72
|
+
"""The output `-t` of a trim from the start that has no sound to end: `{target:.3f}` as it
|
|
73
|
+
always was, unless that text keeps another number of frames than `target` does. The frames
|
|
74
|
+
kept are those that start before `target`. 37 frames of a 30000/1001 picture are 1.2345667 s:
|
|
75
|
+
`-t 1.235` is past the 38th frame's start and wrote it, and `-t 1.001` for 1.0014 s stopped
|
|
76
|
+
before the 31st. The limit is then written half a frame before the first frame left out.
|
|
77
|
+
Without a frame grid (no rate: a dry run's pending input; a variable rate, which the output's
|
|
78
|
+
-r regrids) the millisecond text stays."""
|
|
79
|
+
text = f"{target:.3f}"
|
|
80
|
+
rate = frame_rate(meta)
|
|
81
|
+
if not rate or (meta.get("video") or {}).get("variable_frame_rate_suspected"):
|
|
82
|
+
return text
|
|
83
|
+
out = grid_at_or_after(target, stream_starts(meta)[0], rate) # the first frame left out
|
|
84
|
+
if out - 1 / rate + 1e-6 < float(text) <= out + 1e-9:
|
|
85
|
+
return text
|
|
86
|
+
return f"{out - 0.5 / rate:.6f}"
|
|
87
|
+
|
|
88
|
+
|
|
71
89
|
def main() -> int:
|
|
72
90
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
73
91
|
ap.add_argument("input")
|
|
@@ -189,9 +207,17 @@ def main() -> int:
|
|
|
189
207
|
vf.append("tblend=all_mode=average")
|
|
190
208
|
if has_audio:
|
|
191
209
|
af.append(atempo_chain(factor))
|
|
192
|
-
# atempo's sound
|
|
193
|
-
# millisecond
|
|
194
|
-
|
|
210
|
+
# atempo's sound is not the input's length over the factor (it comes out some
|
|
211
|
+
# milliseconds short or long), and the millisecond -t stays on a retimed track: padding
|
|
212
|
+
# it to the target's samples (apad after atempo in -af) never ended on FFmpeg 8.0.1
|
|
213
|
+
# (seen in CI: killed at the time limit), so the track keeps the length atempo gives.
|
|
214
|
+
# Where the sound is not retimed (a factor within 1e-4 of 1) the source's runs on and
|
|
215
|
+
# the -t ends it to the sample. A picture without sound that is not retimed either is
|
|
216
|
+
# a trim, counted in frames; one that is retimed, or regridded by --fps, keeps the
|
|
217
|
+
# millisecond
|
|
218
|
+
retimed = abs(factor - 1.0) > 1e-4
|
|
219
|
+
post += ["-t", f"{target:.3f}" if af else length_output_t(target, rate, runs_on=True) if rate
|
|
220
|
+
else f"{target:.3f}" if retimed or args.fps or has_audio else picture_output_t(target, meta)]
|
|
195
221
|
STATE.duration_hint = target
|
|
196
222
|
else:
|
|
197
223
|
if target < src_dur:
|
|
@@ -202,7 +228,12 @@ def main() -> int:
|
|
|
202
228
|
# picture would not be the one the start timecode below names
|
|
203
229
|
m = min(SEEK_MARGIN, round(start, 3))
|
|
204
230
|
pre_input += ["-ss", f"{start - m:.3f}"]
|
|
205
|
-
|
|
231
|
+
# without sound the -t only counts frames (picture_output_t). Not from the centre:
|
|
232
|
+
# there the output's clock starts at the seek, between two frames, and the encoder
|
|
233
|
+
# rounds each frame to its own tick
|
|
234
|
+
keep = (picture_output_t(target, meta) if not rate and not args.fps and not args.from_center
|
|
235
|
+
else length_output_t(target, rate, runs_on=True))
|
|
236
|
+
post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", keep]
|
|
206
237
|
# the output starts on the first frame at or after the seek (a seek before a
|
|
207
238
|
# picture that starts after the file's origin: on its first frame): its start
|
|
208
239
|
# timecode is that frame's, not the source's first frame's
|
package/scripts/freeze.py
CHANGED
|
@@ -19,7 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts
|
|
22
|
+
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
|
|
23
23
|
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, MissingFpsError, time_arg, fmt_secs, run
|
|
24
24
|
from _common.emit import input_length
|
|
25
25
|
|
|
@@ -67,7 +67,8 @@ def main() -> int:
|
|
|
67
67
|
# no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
|
|
68
68
|
# and the hold lost, so hold the last frame as --mode extend does (#416 review)
|
|
69
69
|
vend = v0 + (meta["video"].get("duration") or dur - v0)
|
|
70
|
-
|
|
70
|
+
holds_end = args.mode == "extend" or cut_at >= vend - 0.5 / rate
|
|
71
|
+
if holds_end:
|
|
71
72
|
# tpad's stop_duration clones the last frame for the given duration; no PTS surgery
|
|
72
73
|
# needed since it only ever appends past the real end.
|
|
73
74
|
vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
|
|
@@ -82,7 +83,9 @@ def main() -> int:
|
|
|
82
83
|
vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
|
|
83
84
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
84
85
|
if has_audio:
|
|
85
|
-
|
|
86
|
+
# the sound waits as long as the picture is held: whole frames (tpad_length), as the
|
|
87
|
+
# hold in the middle of a clip pads by the held frames' length
|
|
88
|
+
cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=tpad_length(args.hold, meta))]
|
|
86
89
|
else:
|
|
87
90
|
# freeze N frames at `at` by holding on that one source frame for --hold seconds, then
|
|
88
91
|
# resuming the rest of the clip: split the timeline at `at` and hold the remainder's
|
|
@@ -127,10 +130,12 @@ def main() -> int:
|
|
|
127
130
|
cmd += aac_args()
|
|
128
131
|
else:
|
|
129
132
|
cmd += ["-an"]
|
|
130
|
-
if
|
|
133
|
+
if not holds_end:
|
|
131
134
|
# the picture gains `hold` seconds at `at`; a stream-copied subtitle track would keep its
|
|
132
135
|
# old timestamps and every cue after the freeze would fire early (same reasoning as
|
|
133
|
-
# fit.py --method speed). Drop it rather than ship captions that lie.
|
|
136
|
+
# fit.py --method speed). Drop it rather than ship captions that lie. A hold of the last
|
|
137
|
+
# frame (the default with no --at, or an --at at the end) moves nothing, so the track is
|
|
138
|
+
# kept as --mode extend keeps it (A180)
|
|
134
139
|
run(cmd + [output])
|
|
135
140
|
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
136
141
|
else:
|
package/scripts/graphics.py
CHANGED
|
@@ -51,6 +51,7 @@ from _common.fonts import font_choice, record_font_choice
|
|
|
51
51
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
52
52
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
53
53
|
from _common import drawtext_fontfile # noqa: E402
|
|
54
|
+
from _common.decision import window_expr, windowed_overlay_x # noqa: E402
|
|
54
55
|
from _common.emit import input_length # noqa: E402
|
|
55
56
|
|
|
56
57
|
# where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
|
|
@@ -232,7 +233,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
232
233
|
hint="give a --start inside the clip")
|
|
233
234
|
if e <= s:
|
|
234
235
|
die("--end must be after --start")
|
|
235
|
-
|
|
236
|
+
# the window's bounds sit half a frame from the frames they select (window_expr): `{:.3f}`
|
|
237
|
+
# put the frame at 1.234567 s of a 30000/1001 picture after a --start of "1.235"
|
|
238
|
+
en = f"enable='{window_expr(s, e, meta)}'"
|
|
236
239
|
base = min(W, H) * args.scale # scale everything from the short side
|
|
237
240
|
filters: List[str] = []
|
|
238
241
|
rate = f"{meta['video'].get('fps') or 30:g}"
|
|
@@ -253,6 +256,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
253
256
|
filters.append(f"{BOX}{len(boxes)}")
|
|
254
257
|
boxes.append((colour, alpha, drawbox_geo, x, y, w, h, enable, x_expr))
|
|
255
258
|
|
|
259
|
+
def layer_at(x, y, enable: str) -> str:
|
|
260
|
+
"""An overlay's `x=...:y=...` for a layer shown inside `enable`'s window: the window is in
|
|
261
|
+
the layer's position, which is evaluated on the picture's frames, where overlay's own
|
|
262
|
+
enable is evaluated on the layer's frames too (windowed_overlay_x)."""
|
|
263
|
+
return f"x={windowed_overlay_x(str(x).strip(chr(39)), enable[len('enable='):].strip(chr(39)))}:y={y}"
|
|
264
|
+
|
|
256
265
|
def layer_src(colour: str, alpha: float, w: int, h: int, left: int = 0, top: int = 0) -> str:
|
|
257
266
|
"""A colour source to overlay (a box, the lower-third bar, the progress fill), converted when it
|
|
258
267
|
has chroma. overlay places a 4:2:0 picture on even rows and columns only: a box that starts on
|
|
@@ -450,8 +459,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
450
459
|
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
|
|
451
460
|
fc.append(f"{layer_src(bg, 0.85, bar_w, bar_h)}[bar]")
|
|
452
461
|
fc.append(f"{layer_src(primary, 1.0, int(base * 0.012), bar_h)}[acc]")
|
|
453
|
-
fc.append(f"[0:v][bar]overlay=
|
|
454
|
-
fc.append(f"[v1][acc]overlay=
|
|
462
|
+
fc.append(f"[0:v][bar]overlay={layer_at(x_expr, y0, en)}:eof_action=pass[v1]")
|
|
463
|
+
fc.append(f"[v1][acc]overlay={layer_at(x_expr, y0, en)}:eof_action=pass[v2]")
|
|
455
464
|
tx = f"({x_expr})+{tx_pad}"
|
|
456
465
|
x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
|
|
457
466
|
draws = []
|
|
@@ -546,10 +555,10 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
546
555
|
# a layer (overlay composites the alpha too; drawbox leaves it as it was, and takes a
|
|
547
556
|
# 10-bit picture to 8 bits)
|
|
548
557
|
fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
|
|
549
|
-
fc.append(f"[0:v][pbg]overlay=
|
|
558
|
+
fc.append(f"[0:v][pbg]overlay={layer_at(0, (H - h) // 2 * 2, en)}:eof_action=pass[v1]")
|
|
550
559
|
else:
|
|
551
560
|
fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
|
|
552
|
-
fc.append(f"[v1][pb]overlay=
|
|
561
|
+
fc.append(f"[v1][pb]overlay={layer_at(f'-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))', H - h, en)}:eof_action=pass[vout]")
|
|
553
562
|
|
|
554
563
|
elif args.template == "sticker":
|
|
555
564
|
# A social sticker: a filled chip of text that pops in. drawtext's box gives the chip
|
|
@@ -564,7 +573,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
564
573
|
rise = int(fs * 0.5)
|
|
565
574
|
pop = f"min(1,(t-{s:.3f})/0.25)"
|
|
566
575
|
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
567
|
-
|
|
576
|
+
# every position rises into place, on both routes: a bottom chip started `rise` px above
|
|
577
|
+
# its rest on drawtext and dropped, where the ASS \move below rises (A177)
|
|
578
|
+
ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}+{rise}*(1-{pop})")
|
|
568
579
|
alpha = f"min({pop},{fade_a})"
|
|
569
580
|
align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
|
|
570
581
|
y_rest = m_top if "top" in pos else H - m_bottom
|
|
@@ -591,7 +602,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
591
602
|
if args.duration <= 0:
|
|
592
603
|
die(f"--duration must be > 0, got {args.duration:g}")
|
|
593
604
|
he = min(e, s + args.duration)
|
|
594
|
-
hen = f"enable='
|
|
605
|
+
hen = f"enable='{window_expr(s, he, meta)}'"
|
|
595
606
|
h1 = int(base * 0.085)
|
|
596
607
|
bar_h = max(3, int(base * 0.01))
|
|
597
608
|
hook_title = wrapped(args.title, h1)
|
|
@@ -640,7 +651,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
640
651
|
ke = ks + seg
|
|
641
652
|
pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
|
|
642
653
|
add_text(str(k),
|
|
643
|
-
f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='
|
|
654
|
+
f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='{window_expr(ks, ke, meta, half_open=k > 0)}'",
|
|
644
655
|
size=fs, color=primary, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
|
|
645
656
|
outline=float(max(2, fs // 40)), outline_color=bg, start=ks, end=ke,
|
|
646
657
|
scale_t=(0, seg * 500, 115))
|
|
@@ -764,7 +775,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
764
775
|
drawboxes = []
|
|
765
776
|
for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
|
|
766
777
|
if as_layers:
|
|
767
|
-
layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2),
|
|
778
|
+
layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2), layer_at(x_expr or x - x % 2, y - y % 2, enable)))
|
|
768
779
|
drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
|
|
769
780
|
filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
|
|
770
781
|
filters = [f for f in filters if f]
|
package/scripts/grid.py
CHANGED
|
@@ -26,7 +26,7 @@ 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
|
-
as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
29
|
+
as_limited, draws_chroma, drawn_chain, drawtext_text_opts, 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
|
|
@@ -173,7 +173,9 @@ def main() -> int:
|
|
|
173
173
|
if args.label == "auto":
|
|
174
174
|
font_file = label_fonts[i]["file"]
|
|
175
175
|
font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
176
|
-
drawn
|
|
176
|
+
# the textfile= route every other drawn text takes: escape_drawtext() is for a font name,
|
|
177
|
+
# and dropped an apostrophe or a percent sign from a file name's label (A178)
|
|
178
|
+
drawn.append((f"drawtext={drawtext_text_opts(labels[i])}:{font_opt}:fontsize={args.font_size}:"
|
|
177
179
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
178
180
|
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
|
179
181
|
gap_r = args.gap if i % args.cols < args.cols - 1 else 0
|
package/scripts/loop.py
CHANGED
|
@@ -34,7 +34,7 @@ import tempfile
|
|
|
34
34
|
|
|
35
35
|
from _common import STATE, 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_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
36
36
|
from _common.emit import warn
|
|
37
|
-
from _common.decision import av_trim, length_output_t
|
|
37
|
+
from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
|
39
39
|
from _common.emit import input_length
|
|
40
40
|
|
|
@@ -60,9 +60,8 @@ def _boomerang_graph(meta, src_dur: float, times) -> str:
|
|
|
60
60
|
f"-- cut the clip shorter first (cut.py)")
|
|
61
61
|
# reverse holds the clip's decoded frames; split, trim and loop pass references to the same
|
|
62
62
|
# frames, so the peak is about one clip (measured: the same max RSS as a plain reverse)
|
|
63
|
-
|
|
64
|
-
held
|
|
65
|
-
if held > 2 * 1024 ** 3:
|
|
63
|
+
held = held_frames_bytes(v, frames)
|
|
64
|
+
if held > HELD_FRAMES_WARN_BYTES:
|
|
66
65
|
warn(f"--boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
|
|
67
66
|
f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight",
|
|
68
67
|
"memory_heavy", bytes=int(held))
|
|
@@ -160,7 +159,12 @@ def _seamless_unit_cmd(meta, src: str, unit: str, rate: str, frames: int, fade_f
|
|
|
160
159
|
if has_audio:
|
|
161
160
|
# acrossfade writes nothing when its first input is exactly the fade long (FFmpeg 6.1 and
|
|
162
161
|
# 7.1, measured), so the fade is built by hand: tail faded out + head faded in, summed
|
|
163
|
-
|
|
162
|
+
# the sound is shifted by the picture's start, the constant setpts=PTS-STARTPTS takes off the
|
|
163
|
+
# picture: rebased to its own start (aresample's first_pts=0 alone), a picture that starts
|
|
164
|
+
# 0.2 s after its sound came out 200 ms behind it in every repeat (A168)
|
|
165
|
+
v0 = stream_starts(meta)[0]
|
|
166
|
+
graph += (f";[0:a:0]atrim=start={v0:.6f},asetpts=PTS-{v0:.6f}/TB,aresample=async=1:first_pts=0,"
|
|
167
|
+
f"apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
|
|
164
168
|
f"[at]atrim=start={cut:.6f},asetpts=PTS-STARTPTS,afade=t=out:d={fade:.6f}:curve=tri[ta];"
|
|
165
169
|
f"[ah]atrim=end={fade:.6f},asetpts=PTS-STARTPTS,afade=t=in:d={fade:.6f}:curve=tri[ha];"
|
|
166
170
|
f"[ta][ha]amix=inputs=2:normalize=0:duration=longest[xa];"
|
|
@@ -292,6 +296,8 @@ def main() -> int:
|
|
|
292
296
|
die(f"--duration ({target:g}s) must be longer than one repeat ({unit:.3f}s) -- use cut.py to trim instead")
|
|
293
297
|
stream_loop = math.ceil(target / unit) - 1
|
|
294
298
|
|
|
299
|
+
# a variable-rate source is conformed by the output's own -fps_mode cfr -r (cfr_args)
|
|
300
|
+
forced_rate = cfr_args(meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
|
|
295
301
|
if args.boomerang:
|
|
296
302
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
|
|
297
303
|
if meta.get("audio"):
|
|
@@ -314,6 +320,18 @@ def main() -> int:
|
|
|
314
320
|
fh.write((concat_list_line(repeat) + "\n") * (stream_loop + 1))
|
|
315
321
|
vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
|
|
316
322
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
323
|
+
v_end = stream_starts(meta)[0] + (v.get("duration") or 0.0)
|
|
324
|
+
if has_audio and not forced_rate and not fade_frames and src_dur - v_end > 0.5 / fps:
|
|
325
|
+
# A repeat is as long as the file, so where the sound outlasts the picture by more
|
|
326
|
+
# than half a frame the next repeat's first frame comes later than one frame after
|
|
327
|
+
# the last. What stood in that gap was left to the output's frame-rate handling:
|
|
328
|
+
# FFmpeg 6.1 repeated the last frame and showed the next repeat's first frame twice,
|
|
329
|
+
# from a frame before its time (a 1 s 25 fps clip with 1.23425 s of sound: frame 0 of
|
|
330
|
+
# the second repeat at 1.20 s and 1.24 s, its sound from 1.23425 s); FFmpeg 7.1 and 8
|
|
331
|
+
# held the last frame (50 packets for 56). The graph ends on the clip's own rate, so
|
|
332
|
+
# the gap is the last frame repeated and each later repeat's frames sit on the grid
|
|
333
|
+
# once. A clip without that gap has nothing to fill and keeps the command it had.
|
|
334
|
+
vtrim += f",fps={rate}"
|
|
317
335
|
if has_audio:
|
|
318
336
|
cmd += ["-filter_complex", f"[0:v:0]{vtrim}[v];[0:a:0]{atrim}[a]",
|
|
319
337
|
"-map", "[v]", "-map", "[a]"]
|
|
@@ -325,7 +343,7 @@ def main() -> int:
|
|
|
325
343
|
cmd += video_args(meta, args.crf, args.preset)
|
|
326
344
|
# a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
|
|
327
345
|
# not a variable-rate source's average, which re-timed every repeat by a frame or two
|
|
328
|
-
cmd += ["-fps_mode", "passthrough"] if args.boomerang else
|
|
346
|
+
cmd += ["-fps_mode", "passthrough"] if args.boomerang else forced_rate
|
|
329
347
|
if has_audio:
|
|
330
348
|
cmd += aac_args()
|
|
331
349
|
else:
|
package/scripts/metadata.py
CHANGED
|
@@ -35,6 +35,7 @@ from _common import (guard_sidecar, add_common, apply_common, default_output, de
|
|
|
35
35
|
die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
|
|
36
36
|
STATE, read_text_or_die)
|
|
37
37
|
from _common.emit import input_length
|
|
38
|
+
from _common.runner import carried_stream_maps
|
|
38
39
|
|
|
39
40
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
40
41
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -267,6 +268,9 @@ def main() -> int:
|
|
|
267
268
|
cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "-1"]
|
|
268
269
|
else:
|
|
269
270
|
cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
|
|
271
|
+
# not the source's MP4/MOV chapter track: the muxer writes one from the chapters it is given,
|
|
272
|
+
# and the copy was a stray data track holding the old titles (A173)
|
|
273
|
+
cmd += carried_stream_maps(args.input, output, attachments=False)
|
|
270
274
|
for key, value in tags.items():
|
|
271
275
|
cmd += ["-metadata", f"{key}={value}"]
|
|
272
276
|
cmd += ["-c", "copy"]
|