ffmpeg-skill 2.7.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/SKILL.md +5 -5
- package/docs/contract.md +15 -6
- package/package.json +1 -1
- package/references/gotchas.md +27 -5
- package/references/scripts.md +79 -5
- package/scripts/_common/__init__.py +6 -6
- package/scripts/_common/asr.py +53 -9
- package/scripts/_common/decision.py +13 -0
- package/scripts/_common/drawtext.py +4 -1
- package/scripts/_common/emit.py +12 -3
- package/scripts/_common/probe.py +92 -0
- package/scripts/_common/runner.py +54 -15
- package/scripts/_contract.py +39 -14
- package/scripts/batch.py +28 -11
- package/scripts/cut.py +1311 -53
- package/scripts/graphics.py +30 -7
- package/scripts/silence.py +32 -3
package/scripts/cut.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
Lossless stream copy (-c copy) is preferred. Cuts snap to keyframes in that
|
|
5
5
|
mode, so if frame accuracy matters pass --accurate to re-encode. Multiple
|
|
6
|
-
segments are cut individually and
|
|
6
|
+
segments are cut individually and joined by stream copy when the parts match in their codec
|
|
7
|
+
parameters; otherwise every segment is re-cut from the source into one re-encode.
|
|
7
8
|
|
|
8
9
|
Audio: a stream copy lands on a packet boundary (about 21 ms for AAC, one
|
|
9
10
|
demuxer block for WAV); --accurate decodes and trims to the sample. The output
|
|
@@ -12,11 +13,25 @@ WAV), -o out.m4a writes AAC; an audio extension on a video input drops the
|
|
|
12
13
|
picture (mp4 -> wav extraction). The result reports `precision`
|
|
13
14
|
(packet / sample / codec_frame / frame) and the measured duration error, plus
|
|
14
15
|
`mode` (copy / accurate / hybrid -- "hybrid" means a lossless cut silently
|
|
15
|
-
re-encoded because the keyframe snap exceeded --tolerance), `keyframe_snapped
|
|
16
|
-
`
|
|
16
|
+
re-encoded because the keyframe snap exceeded --tolerance), `keyframe_snapped`
|
|
17
|
+
(a stream copy end to end), `start_snapped` (measured: a copied start moved past a frame),
|
|
18
|
+
`reencode_reason`, `requested_start`/`requested_end` (or `requested_segments` for --segments),
|
|
17
19
|
`requested_duration`, `output_duration` and `duration_delta_seconds` -- so a
|
|
18
20
|
caller never has to trust "it probably cut where I asked" on faith.
|
|
19
21
|
|
|
22
|
+
A single-segment .mp4/.mov copy shifts its timestamps to zero (-avoid_negative_ts
|
|
23
|
+
make_zero), so the picture starts at the keyframe; --edit-list keeps the MP4 edit
|
|
24
|
+
list instead, which hides the keyframe's pre-roll so the picture starts at --start.
|
|
25
|
+
|
|
26
|
+
A re-encode uses x264 for an SDR source and x265 Main10 for an HDR or BT.2020 one, as every
|
|
27
|
+
editing tool does; --keep-hevc re-encodes an SDR HEVC source as HEVC (x265 8-bit BT.709, or
|
|
28
|
+
VideoToolbox under --hw), so a trimmed phone clip keeps its codec. --codec overrides both.
|
|
29
|
+
|
|
30
|
+
A source whose nominal and average frame rates differ is taken for variable-frame-rate and
|
|
31
|
+
re-encoded, as --accurate (--vfr-guard average, the default); its sampled packet timing is
|
|
32
|
+
reported in `vfr_check`. --vfr-guard sampled re-encodes only when that sampled timing is
|
|
33
|
+
variable or too short to judge, and --vfr-guard off keeps the copy with a note.
|
|
34
|
+
|
|
20
35
|
Examples:
|
|
21
36
|
python3 cut.py input.mp4 --start 00:00:10 --end 00:00:25
|
|
22
37
|
python3 cut.py input.mp4 --segments 0:05-0:12,1:00-1:20 -o highlights.mp4
|
|
@@ -25,20 +40,25 @@ Examples:
|
|
|
25
40
|
python3 cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav # audio extraction
|
|
26
41
|
"""
|
|
27
42
|
import argparse
|
|
43
|
+
import contextlib
|
|
28
44
|
import json
|
|
29
45
|
import os
|
|
30
46
|
import sys
|
|
31
47
|
import tempfile
|
|
48
|
+
import zlib
|
|
32
49
|
from typing import List, Tuple
|
|
33
50
|
|
|
34
51
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
35
|
-
from _common import
|
|
52
|
+
from _common import require_tool
|
|
53
|
+
from _common import sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
36
54
|
|
|
37
55
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
38
56
|
DROPPED_STREAMS: List[str] = []
|
|
39
57
|
# keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
|
|
40
58
|
# (reported so the caller can choose a lossless cut at one of them next time)
|
|
41
59
|
NEAREST_KEYFRAMES: list = []
|
|
60
|
+
# --keep-hevc: an SDR HEVC source re-encodes as HEVC instead of H.264 (set by main())
|
|
61
|
+
KEEP_HEVC = False
|
|
42
62
|
|
|
43
63
|
|
|
44
64
|
def _t(value: str, fps, flag: str = "--segments") -> float:
|
|
@@ -71,7 +91,11 @@ def encode_args(meta: dict, dst: str, crf: int, preset: str) -> List[str]:
|
|
|
71
91
|
the audio extension names (PCM for .wav, FLAC, MP3, AAC...) with no video stream."""
|
|
72
92
|
if is_audio_output(dst) or not meta.get("video"):
|
|
73
93
|
return ["-vn"] + audio_codec_for(dst)
|
|
74
|
-
return
|
|
94
|
+
return video_encode_args(meta, crf, preset) + aac_args()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def video_encode_args(meta: dict, crf: int, preset: str) -> List[str]:
|
|
98
|
+
return source_codec_video_args(meta, crf, preset, keep_hevc=KEEP_HEVC) + cfr_args(meta)
|
|
75
99
|
|
|
76
100
|
|
|
77
101
|
def copy_args(meta: dict, dst: str) -> List[str]:
|
|
@@ -104,20 +128,695 @@ def precision_of(meta: dict, dst: str, reencoded: bool) -> str:
|
|
|
104
128
|
return "frame"
|
|
105
129
|
|
|
106
130
|
|
|
107
|
-
|
|
108
|
-
|
|
131
|
+
# The order `precision` values rank in, least exact first. It is a conservative order for this
|
|
132
|
+
# tool's segments (all cut from one source into one container), not a universal scale.
|
|
133
|
+
PRECISION_ORDER = ("packet", "codec_frame", "frame", "sample")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def least_exact(precisions: List[str]) -> str:
|
|
137
|
+
return min(precisions, key=PRECISION_ORDER.index)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def report_precision(meta: dict, dst: str, reencoded: bool, outcomes: List[dict]) -> dict:
|
|
141
|
+
"""The run's precision keys from its segments' outcomes.
|
|
142
|
+
|
|
143
|
+
precision main's formula, unchanged: precision_of() for the whole run, so `packet` only
|
|
144
|
+
when nothing re-encoded (the join included)
|
|
145
|
+
keyframe_snapped precision == "packet": the run is a stream copy end to end, which lands on
|
|
146
|
+
keyframes and packets. Kept with its 2.x meaning; whether a copied START
|
|
147
|
+
really moved is start_snapped
|
|
148
|
+
start_snapped measured: true when a copied segment's picture starts more than a frame from
|
|
149
|
+
its requested start; false when every start is where asked (an edit list, a
|
|
150
|
+
start on a keyframe, an audio-only copy's packet seek) or everything
|
|
151
|
+
re-encoded; null when a copied start was not measured (--dry-run)
|
|
152
|
+
least_exact_precision the least exact segment's precision; differs from `precision` only for
|
|
153
|
+
an audio-only --segments join of copied and re-encoded parts (a video join
|
|
154
|
+
re-cuts every segment when any part re-encodes)
|
|
155
|
+
segment_precision each segment's, for --segments (null for one segment)"""
|
|
156
|
+
precision = precision_of(meta, dst, reencoded)
|
|
157
|
+
snaps = [o.get("start_snapped") for o in outcomes]
|
|
158
|
+
if any(s is True for s in snaps):
|
|
159
|
+
start_snapped = True
|
|
160
|
+
elif any(s is None for s in snaps):
|
|
161
|
+
start_snapped = None
|
|
162
|
+
else:
|
|
163
|
+
start_snapped = False
|
|
164
|
+
return {"precision": precision, "keyframe_snapped": precision == "packet", "start_snapped": start_snapped,
|
|
165
|
+
"least_exact_precision": least_exact([o["precision"] for o in outcomes]),
|
|
166
|
+
"segment_precision": [o["precision"] for o in outcomes] if len(outcomes) > 1 else None}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _outcome(meta: dict, dst: str, reencoded: bool, reasons: List[str], **copy) -> dict:
|
|
170
|
+
"""One segment's outcome. start_snapped defaults to false: a re-encode starts where asked, and
|
|
171
|
+
an audio-only copy seeks on the output side, to the packet; a video copy passes the measured
|
|
172
|
+
value (copy_presentation), a --dry-run copy None."""
|
|
173
|
+
out = {"reencoded": reencoded, "reasons": reasons, "precision": precision_of(meta, dst, reencoded),
|
|
174
|
+
"start_snapped": False, "edit_list": False, "stored_preroll_seconds": None}
|
|
175
|
+
out.update(copy)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
EDIT_LIST_EXTS = (".mp4", ".mov", ".m4v")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _with_notes(outcome: dict, notes: List[str]) -> dict:
|
|
183
|
+
"""An outcome with the notes of the attempt that led to it put first."""
|
|
184
|
+
outcome["notes"] = list(notes) + list(outcome.get("notes") or [])
|
|
185
|
+
return outcome
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_ORIGINS: dict = {}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def file_origin(src: str) -> float:
|
|
192
|
+
"""The file's format start_time: the zero that -ss and segment times count from. Stream
|
|
193
|
+
start_times are absolute, so subtract this to put them on the cut's clock. One probe per source."""
|
|
194
|
+
if src not in _ORIGINS:
|
|
195
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-show_entries", "format=start_time", "-of", "csv=p=0", src],
|
|
196
|
+
quiet=True, check=False)
|
|
197
|
+
try:
|
|
198
|
+
_ORIGINS[src] = float((proc.stdout or "").strip() or 0.0)
|
|
199
|
+
except ValueError:
|
|
200
|
+
_ORIGINS[src] = 0.0
|
|
201
|
+
return _ORIGINS[src]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def source_skew(src: str, meta: dict, t: float) -> float:
|
|
205
|
+
"""How far the source's audio starts after its video at `t` on the cut's clock: a cut from
|
|
206
|
+
`t` that keeps both streams where they were starts them this far apart (0 once both run)."""
|
|
207
|
+
v, a = meta.get("video") or {}, meta.get("audio") or {}
|
|
208
|
+
if v.get("start_time") is None or a.get("start_time") is None:
|
|
209
|
+
return 0.0
|
|
210
|
+
origin = file_origin(src)
|
|
211
|
+
return max(a["start_time"] - origin, t) - max(v["start_time"] - origin, t)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def video_end(src: str, meta: dict):
|
|
215
|
+
"""Where the video stream ends on the cut's clock (seconds from the file's start), or None when
|
|
216
|
+
the stream's length is unknown."""
|
|
217
|
+
v = meta.get("video") or {}
|
|
218
|
+
if not v.get("duration"):
|
|
219
|
+
return None
|
|
220
|
+
return (v.get("start_time") or 0.0) + v["duration"] - file_origin(src)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_SEEK_SHIFTS: dict = {}
|
|
224
|
+
# ffmpeg's dts heuristic: before a demuxer that does not seek by presentation time, an input -ss
|
|
225
|
+
# seeks this much earlier when a stream has a decode delay (B-frames); fftools ffmpeg_demux.c
|
|
226
|
+
DTS_HEURISTIC = 3.0 / 23
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def seek_shift(src: str) -> float:
|
|
230
|
+
"""How much earlier than `-ss t` ffmpeg's input seek asks the demuxer for: DTS_HEURISTIC for a
|
|
231
|
+
demuxer that does not seek by pts (Matroska, MPEG-TS...) when a stream has a decode delay, 0
|
|
232
|
+
for MP4/MOV (AVFMT_SEEK_TO_PTS) or a source with no delay. One probe per source."""
|
|
233
|
+
if src not in _SEEK_SHIFTS:
|
|
234
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-show_entries", "format=format_name:stream=has_b_frames",
|
|
235
|
+
"-of", "json", src], quiet=True, check=False)
|
|
236
|
+
try:
|
|
237
|
+
doc = json.loads(proc.stdout or "") if proc.returncode == 0 else {}
|
|
238
|
+
name = str((doc.get("format") or {}).get("format_name") or "")
|
|
239
|
+
delay = any(int(st.get("has_b_frames") or 0) > 0 for st in doc.get("streams") or [])
|
|
240
|
+
except (ValueError, TypeError, AttributeError):
|
|
241
|
+
name, delay = "", False
|
|
242
|
+
_SEEK_SHIFTS[src] = 0.0 if ("mov" in name.split(",") or not delay) else DTS_HEURISTIC
|
|
243
|
+
return _SEEK_SHIFTS[src]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_LANDINGS: dict = {}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def seek_keyframe(src: str, t: float):
|
|
250
|
+
"""The keyframe a stream copy starting at `-ss t` begins from, as (pts, dts, first presented
|
|
251
|
+
pts at or after t) in seconds on the cut's clock, or None when it cannot be read.
|
|
252
|
+
|
|
253
|
+
Measured, not modelled: ffprobe -read_intervals seeks with the same avformat_seek_file call as
|
|
254
|
+
ffmpeg's input -ss (to the file's start_time + t, less seek_shift()), and the copy starts at
|
|
255
|
+
the first keyframe read after it, as ffmpeg drops the packets before it. A model ("the
|
|
256
|
+
keyframe with the largest dts at or before t") was wrong both ways: the MP4 demuxer seeks
|
|
257
|
+
over its raw decode times, ffprobe's dts plus the reorder delay, so on B-frame H.264 -ss 3.99
|
|
258
|
+
landed on the keyframe at 2.0, not on the one at 4.0 (dts 3.933), while on a keyint-60 HEVC
|
|
259
|
+
file -ss 9.9 began at the keyframe at 10.0 (dts 9.833). The read does not seek earlier than
|
|
260
|
+
ffmpeg does: a seek before the file's start skips an edit-listed MP4's negative-pts keyframe.
|
|
261
|
+
Cached on (src, t)."""
|
|
262
|
+
key = (src, round(t, 6))
|
|
263
|
+
if key in _LANDINGS:
|
|
264
|
+
return _LANDINGS[key]
|
|
265
|
+
origin = file_origin(src)
|
|
266
|
+
target = origin + t - seek_shift(src)
|
|
267
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
268
|
+
"packet=pts_time,dts_time,flags", "-of", "json", "-read_intervals",
|
|
269
|
+
f"{target:.6f}%{origin + t + 1.0:.6f}", src], quiet=True, check=False)
|
|
270
|
+
try:
|
|
271
|
+
packets = json.loads(proc.stdout or "")["packets"] if proc.returncode == 0 else []
|
|
272
|
+
except (ValueError, KeyError, TypeError):
|
|
273
|
+
packets = []
|
|
274
|
+
landed, pts_all = None, []
|
|
275
|
+
for p in packets if isinstance(packets, list) else []:
|
|
276
|
+
try:
|
|
277
|
+
pts = float(p["pts_time"]) - origin
|
|
278
|
+
except (KeyError, TypeError, ValueError):
|
|
279
|
+
continue
|
|
280
|
+
if landed is None:
|
|
281
|
+
if "K" not in str(p.get("flags", "")):
|
|
282
|
+
continue # ffmpeg's copy drops what comes before the first keyframe
|
|
283
|
+
try:
|
|
284
|
+
landed = (pts, float(p.get("dts_time", p["pts_time"])) - origin)
|
|
285
|
+
except (TypeError, ValueError):
|
|
286
|
+
landed = (pts, pts)
|
|
287
|
+
pts_all.append(pts)
|
|
288
|
+
if landed is None:
|
|
289
|
+
_LANDINGS[key] = None
|
|
290
|
+
return None
|
|
291
|
+
# the first picture an edit list starting at t presents: the first frame at or after t
|
|
292
|
+
later = [x for x in pts_all if x >= t - 1e-6]
|
|
293
|
+
_LANDINGS[key] = (landed[0], landed[1], min(later) if later else None)
|
|
294
|
+
return _LANDINGS[key]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def copy_presentation(t: float, key, edit_list: bool, fps) -> dict:
|
|
298
|
+
"""What a stream copy starting at `t` presents, from the keyframe it began at (seek_keyframe).
|
|
299
|
+
With an MP4 edit list a keyframe at or before t is decoded but hidden (stored pre-roll) and the
|
|
300
|
+
picture starts at t; a keyframe after t starts the picture late. Without one (Matroska, or a
|
|
301
|
+
concat part cut with make_zero) the picture starts at the keyframe. start_snapped: the
|
|
302
|
+
presented start is more than a frame from t (an unmeasured start counts as snapped)."""
|
|
303
|
+
frame = 1.0 / fps if fps else 0.0
|
|
304
|
+
if key is None:
|
|
305
|
+
return {"start_snapped": True, "stored_preroll_seconds": None}
|
|
306
|
+
k_pts, first = key[0], key[2]
|
|
307
|
+
if first is None:
|
|
308
|
+
# no packet at or after t in the probed window: where the picture starts is unmeasured
|
|
309
|
+
return {"start_snapped": True, "stored_preroll_seconds": None}
|
|
310
|
+
if edit_list and k_pts <= t + 1e-6:
|
|
311
|
+
# hidden: the pictures decoded from the keyframe up to the first one presented
|
|
312
|
+
return {"start_snapped": False, "stored_preroll_seconds": round(max(0.0, first - k_pts), 6)}
|
|
313
|
+
return {"start_snapped": abs(k_pts - t) > frame + 1e-6, "stored_preroll_seconds": None}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def av_skew(out_meta: dict, edit_list_remedy: bool = False, expected: float = 0.0):
|
|
317
|
+
"""(seconds audio starts after video, warning note or None); None when either is missing.
|
|
318
|
+
A lossless cut can start its streams apart -- Core Media HEVC once gave 3.7 s of sound with no
|
|
319
|
+
picture from a run that exited 0 -- so a skew more than max(2 frames, 0.1 s) away from
|
|
320
|
+
`expected` (the source's own skew at the cut's start: a track that starts late there, by
|
|
321
|
+
design, is not a defect of the cut) is named. It is reported, not repaired:
|
|
322
|
+
`edit_list_remedy` (a single MP4/MOV copy whose start snapped, cut without --edit-list)
|
|
323
|
+
names --edit-list as the lossless way to start both streams at --start."""
|
|
324
|
+
v, a = out_meta.get("video") or {}, out_meta.get("audio") or {}
|
|
325
|
+
if v.get("start_time") is None or a.get("start_time") is None:
|
|
326
|
+
return None, None
|
|
327
|
+
skew = round(a["start_time"] - v["start_time"], 6)
|
|
328
|
+
fps = v.get("fps") or 0
|
|
329
|
+
limit = max(2.0 / fps if fps else 0.0, 0.1)
|
|
330
|
+
if abs(skew - expected) <= limit:
|
|
331
|
+
return skew, None
|
|
332
|
+
first = "audio" if skew > 0 else "video"
|
|
333
|
+
return skew, (f"the {'video' if first == 'audio' else 'audio'} starts {abs(skew):.3f}s before the {first}"
|
|
334
|
+
+ (f" ({abs(expected):.3f}s in the source there)" if abs(expected) > 1e-3 else "")
|
|
335
|
+
+ ": a stream copy began on packets the two streams do not share; re-run with --accurate "
|
|
336
|
+
"for a cut whose picture and sound start together"
|
|
337
|
+
+ (", or with --edit-list to keep the lossless copy and hide the keyframe's pre-roll"
|
|
338
|
+
if edit_list_remedy else ""))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
_PACKETS: dict = {}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _packet_time(value):
|
|
345
|
+
try:
|
|
346
|
+
return float(value)
|
|
347
|
+
except (TypeError, ValueError):
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class _Packets(list):
|
|
352
|
+
"""video_packets' result: (pts, dts, keyframe) in decode order, plus `breaks`, the indices of
|
|
353
|
+
packets after which a read window ended before the end of the file (what follows is a later
|
|
354
|
+
window, not the next packet), and `spans`, each window's (lo, hi) with the range of packets
|
|
355
|
+
read in it."""
|
|
356
|
+
breaks: frozenset = frozenset()
|
|
357
|
+
spans: tuple = ()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# video_packets reads [start - M, end + M] around each segment, M = max(PACKET_MARGIN, 2 x
|
|
361
|
+
# --tolerance), and widens M x4 up to PACKET_WIDENINGS times before reading the whole file
|
|
362
|
+
PACKET_MARGIN = 10.0
|
|
363
|
+
PACKET_WIDENINGS = 3
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def packet_windows(segments, margin: float, total=None) -> tuple:
|
|
367
|
+
"""The merged read windows around `segments`, as (lo, hi) on the cut's clock: lo None from the
|
|
368
|
+
file's start (never a seek: a seek to it skips an edit-listed MP4's negative-pts keyframe, as
|
|
369
|
+
measure_frame_timing found), hi None to its end (`total`, the media duration)."""
|
|
370
|
+
spans = sorted((s - margin, e + margin) for s, e in segments)
|
|
371
|
+
merged = []
|
|
372
|
+
for lo, hi in spans:
|
|
373
|
+
if merged and lo <= merged[-1][1]:
|
|
374
|
+
merged[-1][1] = max(merged[-1][1], hi)
|
|
375
|
+
else:
|
|
376
|
+
merged.append([lo, hi])
|
|
377
|
+
return tuple((None if lo <= 0 else round(lo, 6), None if total and hi >= total else round(hi, 6))
|
|
378
|
+
for lo, hi in merged)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _read_packets(src: str, windows: tuple):
|
|
382
|
+
"""The video packets of `src` in `windows` (packet_windows; ((None, None),) is the whole file),
|
|
383
|
+
one ffprobe per window, or None when ffprobe fails. A window's seek lands on the keyframe
|
|
384
|
+
before its start, so packets a previous window already read (dts not after its last) are
|
|
385
|
+
dropped. Cached on (src, windows)."""
|
|
386
|
+
key = (src, windows)
|
|
387
|
+
if key in _PACKETS:
|
|
388
|
+
return _PACKETS[key]
|
|
389
|
+
origin = file_origin(src)
|
|
390
|
+
packets, breaks, spans, last_dts = _Packets(), set(), [], None
|
|
391
|
+
for lo, hi in windows:
|
|
392
|
+
first, before = len(packets), last_dts
|
|
393
|
+
cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
394
|
+
"packet=pts_time,dts_time,flags", "-of", "csv=p=0"]
|
|
395
|
+
if lo is not None or hi is not None:
|
|
396
|
+
cmd += ["-read_intervals", ("" if lo is None else f"{origin + lo:.6f}") + "%"
|
|
397
|
+
+ ("" if hi is None else f"{origin + hi:.6f}")]
|
|
398
|
+
proc = run(cmd + [src], quiet=True, check=False)
|
|
399
|
+
if proc.returncode != 0:
|
|
400
|
+
_PACKETS[key] = None
|
|
401
|
+
return None
|
|
402
|
+
for line in (proc.stdout or "").split():
|
|
403
|
+
fields = line.split(",")
|
|
404
|
+
if len(fields) < 3:
|
|
405
|
+
continue
|
|
406
|
+
pts, dts = _packet_time(fields[0]), _packet_time(fields[1])
|
|
407
|
+
pts, dts = (None if pts is None else pts - origin), (None if dts is None else dts - origin)
|
|
408
|
+
if before is not None and dts is not None and dts <= before + 1e-9:
|
|
409
|
+
continue
|
|
410
|
+
packets.append((pts, dts, "K" in fields[2]))
|
|
411
|
+
if dts is not None:
|
|
412
|
+
last_dts = dts
|
|
413
|
+
if hi is not None and len(packets) > first:
|
|
414
|
+
breaks.add(len(packets) - 1)
|
|
415
|
+
spans.append((lo, hi, first, len(packets)))
|
|
416
|
+
packets.breaks, packets.spans = frozenset(breaks), tuple(spans)
|
|
417
|
+
_PACKETS[key] = packets or None
|
|
418
|
+
return _PACKETS[key]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _windows_cover(packets, segments) -> bool:
|
|
422
|
+
"""True when the packets read around each segment hold what the plan needs from them: the
|
|
423
|
+
keyframe the copy lands on (the largest dts at or before the start; none at all only counts
|
|
424
|
+
from the file's start), a whole GOP after it, and the first keyframe at or after the end
|
|
425
|
+
with a whole GOP after that too (gop_is_open judges both) -- or the end of the file."""
|
|
426
|
+
runs = [(lo, hi, packets[i0:i1]) for lo, hi, i0, i1 in packets.spans]
|
|
427
|
+
for s, e in segments:
|
|
428
|
+
run_ = next(((lo, hi, p) for lo, hi, p in runs if (lo is None or lo <= s) and (hi is None or hi >= e)), None)
|
|
429
|
+
if run_ is None:
|
|
430
|
+
return False
|
|
431
|
+
lo, hi, part = run_
|
|
432
|
+
keys = [(p[0], p[1]) for p in part if p[2] and p[0] is not None and p[1] is not None]
|
|
433
|
+
landed = [k for k in keys if k[1] <= s + 1e-6]
|
|
434
|
+
if not landed:
|
|
435
|
+
if lo is None:
|
|
436
|
+
continue
|
|
437
|
+
return False
|
|
438
|
+
k_dts = max(k[1] for k in landed)
|
|
439
|
+
later = [k for k in keys if k[1] > k_dts + 1e-9]
|
|
440
|
+
if not later:
|
|
441
|
+
if hi is None:
|
|
442
|
+
continue
|
|
443
|
+
return False
|
|
444
|
+
after = [k for k in later if k[0] >= e - 1e-6]
|
|
445
|
+
if hi is None:
|
|
446
|
+
continue
|
|
447
|
+
if not after or not [k for k in after if k[1] > min(after)[1] + 1e-9]:
|
|
448
|
+
return False
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def video_packets(src: str, segments=None, tolerance: float = 0.5, total=None):
|
|
453
|
+
"""The video packets of `src` a --segments copy join is planned and checked against, in decode
|
|
454
|
+
order, as (pts, dts, keyframe) on the cut's clock (a missing timestamp is None), or None
|
|
455
|
+
when ffprobe cannot read them. Only windows around the segments are read (packet_windows),
|
|
456
|
+
widened x4 up to PACKET_WIDENINGS times while one lacks what the plan needs (_windows_cover),
|
|
457
|
+
and the whole file after that, so the cost follows the segments asked for, not the source's
|
|
458
|
+
length; with no `segments`, the whole file."""
|
|
459
|
+
if segments:
|
|
460
|
+
margin = max(PACKET_MARGIN, 2 * tolerance)
|
|
461
|
+
for _ in range(PACKET_WIDENINGS + 1):
|
|
462
|
+
windows = packet_windows(segments, margin, total)
|
|
463
|
+
if windows == ((None, None),):
|
|
464
|
+
break
|
|
465
|
+
packets = _read_packets(src, windows)
|
|
466
|
+
if packets is None:
|
|
467
|
+
return None
|
|
468
|
+
if _windows_cover(packets, segments):
|
|
469
|
+
return packets
|
|
470
|
+
margin *= 4
|
|
471
|
+
return _read_packets(src, ((None, None),))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def gop_is_open(packets, i: int) -> bool:
|
|
475
|
+
"""True when keyframe packets[i] starts an open GOP: a packet decoded after it, before the next
|
|
476
|
+
keyframe, is presented before it (a leading picture, which needs the GOP before to decode). A
|
|
477
|
+
missing timestamp there counts as open, and so does a GOP whose read window ended before its
|
|
478
|
+
next keyframe (video_packets widens its windows so that the GOPs a plan judges are whole). A
|
|
479
|
+
GOP the file ends in has been read to its last packet and is judged on those."""
|
|
480
|
+
key = packets[i][0]
|
|
481
|
+
breaks = getattr(packets, "breaks", ())
|
|
482
|
+
if key is None or i in breaks:
|
|
483
|
+
return True
|
|
484
|
+
for j in range(i + 1, len(packets)):
|
|
485
|
+
pts, dts, is_key = packets[j]
|
|
486
|
+
if is_key:
|
|
487
|
+
return False
|
|
488
|
+
if pts is None or dts is None or pts < key - 1e-6 or j in breaks:
|
|
489
|
+
return True
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# a copied part stops before its end keyframe's packet by this much: -t compares decode
|
|
494
|
+
# timestamps, and ffprobe prints them rounded to the microsecond
|
|
495
|
+
_DTS_MARGIN = 1e-4
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def reorders(packets) -> bool:
|
|
499
|
+
"""True when the source's frames reorder: some packet is presented before one decoded ahead of
|
|
500
|
+
it (B-frames). A source that never reorders cuts exactly in decode order, so its --segments
|
|
501
|
+
parts are cut as in every 2.x release (main()). Judged within each window video_packets read:
|
|
502
|
+
the gap between two windows is not a reorder."""
|
|
503
|
+
breaks = getattr(packets, "breaks", ())
|
|
504
|
+
top = None
|
|
505
|
+
for i, (pts, _dts, _key) in enumerate(packets):
|
|
506
|
+
if pts is not None:
|
|
507
|
+
if top is not None and pts < top - 1e-6:
|
|
508
|
+
return True
|
|
509
|
+
top = pts if top is None else max(top, pts)
|
|
510
|
+
if i in breaks:
|
|
511
|
+
top = None
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def plan_part(packets, start: float, end: float, tolerance: float, vend, fps, snap_end: bool = True,
|
|
516
|
+
landing=None) -> dict:
|
|
517
|
+
"""How the --segments part start-end is stream-copied for a concat-demuxer join.
|
|
518
|
+
|
|
519
|
+
The copy begins at the keyframe `-ss start` lands on: `landing`, seek_keyframe()'s measurement,
|
|
520
|
+
when it is one of `packets`' keyframes, otherwise the one with the largest dts at or before
|
|
521
|
+
`start` (which the MP4 demuxer's seek over raw decode times does not always take: the join
|
|
522
|
+
check is the backstop). The join presents from that keyframe: the concat demuxer does not apply the part's edit
|
|
523
|
+
list start. The part ends at its end keyframe's DTS: an input -t stops in decode order, so
|
|
524
|
+
ending at the keyframe's pts would carry that keyframe and the P-frame after it into the part.
|
|
525
|
+
The end keyframe is the first one presented at or after `end` among those decoded after the
|
|
526
|
+
landing (an earlier one would drop requested frames, and any decoded before the landing would
|
|
527
|
+
give -t <= 0); one past `tolerance` re-encodes, reason "tolerance". With no keyframe after
|
|
528
|
+
`end` (the end is in the video's last GOP) the end stays where asked and check_join judges
|
|
529
|
+
the copy. A part that reaches the end of the video keeps -t end-start: nothing follows it to
|
|
530
|
+
leak in. Without `snap_end` (a source whose frames do not reorder, or Matroska/MPEG-TS
|
|
531
|
+
parts) the end is left where asked, as in every 2.x release; only the start is judged."""
|
|
532
|
+
frame = 1.0 / fps if fps else 0.001
|
|
533
|
+
keys = [(i, p[0], p[1]) for i, p in enumerate(packets) if p[2] and p[0] is not None and p[1] is not None]
|
|
534
|
+
measured = ([k for k in keys if abs(k[1] - landing[0]) < 1e-5] if landing else [])
|
|
535
|
+
landed = measured[:1] or [k for k in keys if k[2] <= start + 1e-6]
|
|
536
|
+
if not landed and keys and (not getattr(packets, "spans", None) or packets.spans[0][0] is None):
|
|
537
|
+
# a video stream that starts after `start` (its sound leads it): the seek lands on its
|
|
538
|
+
# first keyframe, which the tolerance judges like any other
|
|
539
|
+
landed = keys[:1]
|
|
540
|
+
plan = {"reason": None, "start": start, "t": end - start, "start_index": None, "start_pts": None,
|
|
541
|
+
"end_index": None, "end_pts": None, "end_bound": end}
|
|
542
|
+
if not landed:
|
|
543
|
+
plan["reason"] = "tolerance"
|
|
544
|
+
return plan
|
|
545
|
+
si, s_pts, s_dts = max(landed, key=lambda k: k[2])
|
|
546
|
+
plan.update(start_index=si, start_pts=s_pts)
|
|
547
|
+
within = (lambda off: True) if tolerance < 0 else (lambda off: off < tolerance)
|
|
548
|
+
if not within(abs(s_pts - start)):
|
|
549
|
+
plan["reason"] = "tolerance"
|
|
550
|
+
return plan
|
|
551
|
+
if vend is not None and end >= vend - frame / 2:
|
|
552
|
+
plan["end_bound"] = None
|
|
553
|
+
return plan
|
|
554
|
+
if not snap_end:
|
|
555
|
+
return plan
|
|
556
|
+
after = [k for k in keys if k[2] > s_dts + 1e-9 and k[1] >= end - 1e-6]
|
|
557
|
+
if not after:
|
|
558
|
+
# the end is in the video's last GOP: no keyframe to snap it to, so the end stays where
|
|
559
|
+
# asked and check_join judges the copy (a B-frame part cut mid-GOP in decode order usually
|
|
560
|
+
# fails it, and the join is re-cut) -- not a tolerance miss, even under --tolerance -1
|
|
561
|
+
return plan
|
|
562
|
+
ei, e_pts, e_dts = min(after, key=lambda k: k[1])
|
|
563
|
+
if not within(e_pts - end):
|
|
564
|
+
plan["reason"] = "tolerance"
|
|
565
|
+
return plan
|
|
566
|
+
t = e_dts - start - _DTS_MARGIN
|
|
567
|
+
if t < 0.001:
|
|
568
|
+
# the start sits within a millisecond of the end keyframe's dts: no packet to copy
|
|
569
|
+
plan["reason"] = "tolerance"
|
|
570
|
+
return plan
|
|
571
|
+
plan.update(t=t, end_index=ei, end_pts=e_pts, end_bound=e_pts)
|
|
572
|
+
return plan
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def hidden_preroll_start(packets, plans, edit_listed: bool):
|
|
576
|
+
"""The 1-based number of the first part after the first whose start is not on a keyframe
|
|
577
|
+
presented at or before it, for edit-listed (.mp4/.mov) parts of a source whose frames reorder;
|
|
578
|
+
None otherwise. Such a part stores the frames from its keyframe to its start as hidden
|
|
579
|
+
pre-roll, which the concat demuxer crushes into sub-millisecond steps at the join (measured on
|
|
580
|
+
every source shape tried), so the join is re-cut without trying the copy."""
|
|
581
|
+
if not edit_listed:
|
|
582
|
+
return None
|
|
583
|
+
for i, plan in enumerate(plans):
|
|
584
|
+
if i and not plan["reason"] and plan["start_pts"] is not None and plan["start_pts"] < plan["start"] - _DTS_MARGIN:
|
|
585
|
+
return i + 1
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def open_join_key(packets, plans):
|
|
590
|
+
"""The pts of the first keyframe a stream-copy join of the planned parts cannot cross, or None.
|
|
591
|
+
An open END keyframe: its leading pictures belong to the part but decode after the keyframe,
|
|
592
|
+
so a part cut in decode order loses them. An open START keyframe after the first part: the
|
|
593
|
+
decoder drops leading pictures only at the start of a stream, so after a join they would decode
|
|
594
|
+
against the previous part's frames. Parts that re-encode anyway are skipped."""
|
|
595
|
+
for i, plan in enumerate(plans):
|
|
596
|
+
if plan["reason"]:
|
|
597
|
+
continue
|
|
598
|
+
if i and gop_is_open(packets, plan["start_index"]):
|
|
599
|
+
return plan["start_pts"]
|
|
600
|
+
if plan["end_index"] is not None and gop_is_open(packets, plan["end_index"]):
|
|
601
|
+
return plan["end_pts"]
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def stream_packet_times(path: str, stream: str, entry: str, limit: int = None) -> list:
|
|
606
|
+
"""One timestamp field (pts_time, duration_time) of every packet of the first `stream` ("v" or
|
|
607
|
+
"a") in `path`, or of its first `limit` packets; a missing value is None. [] when there is no
|
|
608
|
+
such stream or ffprobe fails."""
|
|
609
|
+
cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", f"{stream}:0", "-show_entries", f"packet={entry}",
|
|
610
|
+
"-of", "csv=p=0"] + (["-read_intervals", f"%+#{limit}"] if limit else []) + [path]
|
|
611
|
+
proc = run(cmd, quiet=True, check=False)
|
|
612
|
+
if proc.returncode != 0:
|
|
613
|
+
return []
|
|
614
|
+
return [_packet_time(line.split(",")[0]) for line in (proc.stdout or "").split()]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def expected_packets(packets, ranges) -> int:
|
|
618
|
+
"""How many source video packets the join's parts present: those with lo <= pts < hi in each
|
|
619
|
+
(lo, hi) range, hi None for a part that runs to the end of the video."""
|
|
620
|
+
return sum(1 for lo, hi in ranges for pts, _, _ in packets
|
|
621
|
+
if pts is not None and pts >= lo - 1e-6 and (hi is None or pts < hi - 1e-6))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def check_join(out_pts, counts, fps, cfr: bool, predicted=None) -> dict:
|
|
625
|
+
"""The joined file measured, not predicted: its video packet count against the source's
|
|
626
|
+
(`counts`, one per part, from expected_packets), and with constant frame timing every
|
|
627
|
+
presentation step. A step inside a part is a frame (more than half, less than one and a half).
|
|
628
|
+
The step at a join is the one `predicted` from the parts (predicted_join_steps), within a
|
|
629
|
+
millisecond: the frame before a join is shown for its frame plus the time the concat
|
|
630
|
+
demuxer leaves between the parts' pictures, which is not a fixed bound -- 56.7 ms at 30 fps
|
|
631
|
+
for an exact no-B-frame join, one audio frame or more past a frame for PCM -- and a bound
|
|
632
|
+
loose enough for every source let a hole of one audio packet through. Without a prediction
|
|
633
|
+
a join step is held to the interior bound."""
|
|
634
|
+
expected = sum(counts)
|
|
635
|
+
pts = sorted(p for p in out_pts if p is not None)
|
|
636
|
+
ok = len(out_pts) == expected and len(pts) == len(out_pts)
|
|
637
|
+
max_step = None
|
|
638
|
+
if cfr and fps:
|
|
639
|
+
frame = 1.0 / fps
|
|
640
|
+
gaps = [b - a for a, b in zip(pts, pts[1:])]
|
|
641
|
+
if gaps:
|
|
642
|
+
max_step = round(max(gaps), 6)
|
|
643
|
+
joins = {}
|
|
644
|
+
if ok and predicted:
|
|
645
|
+
at = 0
|
|
646
|
+
for j, n in enumerate(counts[:-1]):
|
|
647
|
+
at += n
|
|
648
|
+
if 0 < at < len(pts) and j < len(predicted) and predicted[j] is not None:
|
|
649
|
+
joins[at - 1] = predicted[j]
|
|
650
|
+
ok = ok and all(abs(g - joins[i]) <= JOIN_STEP_SLACK and g > frame / 2 if i in joins
|
|
651
|
+
else frame / 2 < g < 1.5 * frame for i, g in enumerate(gaps))
|
|
652
|
+
return {"packets": len(out_pts), "expected_packets": expected, "max_step_seconds": max_step, "ok": ok}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _adts_payload(data: str) -> bytes:
|
|
656
|
+
"""A packet's bytes from ffprobe's -show_data hex dump, without the ADTS header an MPEG-TS AAC
|
|
657
|
+
packet carries (7 bytes, 9 with a CRC): what aac_adtstoasc leaves when the packet is copied
|
|
658
|
+
into MP4, MOV or Matroska."""
|
|
659
|
+
raw = bytearray()
|
|
660
|
+
for line in str(data or "").strip().splitlines():
|
|
661
|
+
_, _, rest = line.partition(":")
|
|
662
|
+
raw += bytes.fromhex(rest.strip().split(" ")[0].replace(" ", ""))
|
|
663
|
+
if len(raw) >= 7 and raw[0] == 0xFF and (raw[1] & 0xF6) == 0xF0:
|
|
664
|
+
return bytes(raw[7 if raw[1] & 1 else 9:])
|
|
665
|
+
return bytes(raw)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def audio_packet_hashes(path: str, lo=None, hi=None, stream: str = "a:0", strip_adts: bool = False) -> list:
|
|
669
|
+
"""(pts, CRC32) of the `stream` audio packets of `path`, on its own raw clock, from `lo` to `hi`
|
|
670
|
+
(absolute seconds; None = the file's start or end: a read from the start never seeks). With
|
|
671
|
+
`strip_adts` each packet is hashed without its ADTS header (_adts_payload; zlib's CRC32 is
|
|
672
|
+
ffprobe's), so an MPEG-TS source's AAC matches the same packets copied out of it. [] when
|
|
673
|
+
there is no such stream or ffprobe fails."""
|
|
674
|
+
cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", stream]
|
|
675
|
+
if strip_adts:
|
|
676
|
+
cmd += ["-show_entries", "packet=pts_time,data", "-show_data", "-of", "json"]
|
|
677
|
+
else:
|
|
678
|
+
cmd += ["-show_entries", "packet=pts_time,data_hash", "-show_data_hash", "CRC32", "-of", "csv=p=0"]
|
|
679
|
+
if lo is not None or hi is not None:
|
|
680
|
+
cmd += ["-read_intervals", ("" if lo is None else f"{lo:.6f}") + "%" + ("" if hi is None else f"{hi:.6f}")]
|
|
681
|
+
proc = run(cmd + [path], quiet=True, check=False)
|
|
682
|
+
out = []
|
|
683
|
+
if proc.returncode != 0:
|
|
684
|
+
return out
|
|
685
|
+
if strip_adts:
|
|
686
|
+
try:
|
|
687
|
+
packets = json.loads(proc.stdout or "").get("packets") or []
|
|
688
|
+
except (ValueError, AttributeError):
|
|
689
|
+
return out
|
|
690
|
+
for p in packets:
|
|
691
|
+
pts = _packet_time(p.get("pts_time")) if isinstance(p, dict) else None
|
|
692
|
+
if pts is not None and p.get("data"):
|
|
693
|
+
out.append((pts, "CRC32:%08x" % zlib.crc32(_adts_payload(p["data"]))))
|
|
694
|
+
return out
|
|
695
|
+
for line in (proc.stdout or "").split():
|
|
696
|
+
fields = line.split(",")
|
|
697
|
+
pts = _packet_time(fields[0])
|
|
698
|
+
crc = next((f for f in fields[1:] if f.startswith("CRC32:")), None)
|
|
699
|
+
if pts is not None and crc:
|
|
700
|
+
out.append((pts, crc))
|
|
701
|
+
return out
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def copied_audio_streams(meta: dict, joined: str) -> List[str]:
|
|
705
|
+
"""The source audio streams (`a:N`) a join's sound may have been copied from: those with the
|
|
706
|
+
codec, channel count and sample rate of the joined file's audio. FFmpeg's default selection
|
|
707
|
+
copies the stream with the most channels, which need not be a:0 (a stereo AAC a:0 beside a
|
|
708
|
+
6-channel PCM a:1 copied a:1), so the check must hash the stream that was copied."""
|
|
709
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
710
|
+
"stream=codec_name,channels,sample_rate", "-of", "json", joined], quiet=True, check=False)
|
|
711
|
+
try:
|
|
712
|
+
st = (json.loads(proc.stdout or "").get("streams") or [{}])[0] if proc.returncode == 0 else {}
|
|
713
|
+
shape = (st.get("codec_name"), int(st.get("channels") or 0), int(st.get("sample_rate") or 0))
|
|
714
|
+
except (ValueError, TypeError, AttributeError, IndexError):
|
|
715
|
+
return ["a:0"]
|
|
716
|
+
found = [f"a:{a['index']}" for a in meta.get("audio_streams") or []
|
|
717
|
+
if (a.get("codec"), a.get("channels") or 0, a.get("sample_rate") or 0) == shape]
|
|
718
|
+
return found or ["a:0"]
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# check_join_audio: a run of AUDIO_RUN packets, from AUDIO_SKIP seconds into a part and starting
|
|
722
|
+
# within AUDIO_REACH of it, is looked for within AUDIO_SEARCH of where it belongs in the source; a
|
|
723
|
+
# part whose sound is more than AUDIO_SYNC_LIMIT off its picture fails the join
|
|
724
|
+
AUDIO_RUN, AUDIO_SKIP, AUDIO_REACH, AUDIO_SEARCH, AUDIO_SYNC_LIMIT = 8, 0.25, 2.0, 0.5, 0.005
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def check_join_audio(src: str, joined: str, starts, counts, out_pts=None, streams=("a:0",),
|
|
728
|
+
strip_adts: bool = False) -> dict:
|
|
729
|
+
"""Each copied part's sound against its picture in the joined file, by matching audio packets
|
|
730
|
+
with the source's (CRC32). A part's video moved by (its first frame in the join - its start
|
|
731
|
+
keyframe in the source), and its sound by (an audio packet's time in the join - the same
|
|
732
|
+
packet's time in the source); the two must agree within AUDIO_SYNC_LIMIT. The packet is the
|
|
733
|
+
first of AUDIO_RUN consecutive ones, from AUDIO_SKIP into the part, whose run appears exactly
|
|
734
|
+
once within AUDIO_SEARCH of where the video's move puts it. A part with no such run (digital
|
|
735
|
+
silence, a periodic tone, a part too short) is unmeasured, not failed. `starts` are the
|
|
736
|
+
parts' start keyframes (pts on the cut's clock), `counts` their video packets in the join,
|
|
737
|
+
`out_pts` the join's video packet times when the caller has read them. `streams` are the
|
|
738
|
+
source streams the sound may have come from (copied_audio_streams), tried in turn until one
|
|
739
|
+
measures a part; `strip_adts` hashes the source's packets without their ADTS headers.
|
|
740
|
+
Returns {audio_offset_ms: per part, sound minus picture (+ = sound late, null = unmeasured),
|
|
741
|
+
audio_parts_checked, audio_ok}. A B-frame H.264 + PCM .mov join measured 0 / +16.0 / +53.3 ms
|
|
742
|
+
and passed the video check; clean joins measure under 1 ms."""
|
|
743
|
+
out_a = audio_packet_hashes(joined)
|
|
744
|
+
if out_pts is None:
|
|
745
|
+
out_pts = stream_packet_times(joined, "v", "pts_time")
|
|
746
|
+
vpts = sorted(p for p in out_pts if p is not None)
|
|
747
|
+
if not out_a or len(vpts) != sum(counts):
|
|
748
|
+
return {"audio_offset_ms": None, "audio_parts_checked": 0, "audio_ok": True}
|
|
749
|
+
origin = file_origin(src)
|
|
750
|
+
offsets: list = []
|
|
751
|
+
for stream in streams:
|
|
752
|
+
offsets, first = [], 0
|
|
753
|
+
for k, n in zip(starts, counts):
|
|
754
|
+
v_out = vpts[first]
|
|
755
|
+
nxt = vpts[first + n] if first + n < len(vpts) else float("inf")
|
|
756
|
+
first += n
|
|
757
|
+
vshift = v_out - (k + origin)
|
|
758
|
+
lo = k + origin - 1.0
|
|
759
|
+
src_a = audio_packet_hashes(src, None if lo <= origin else lo, k + origin + AUDIO_REACH + 2 * AUDIO_SEARCH,
|
|
760
|
+
stream=stream, strip_adts=strip_adts)
|
|
761
|
+
by_hash: dict = {}
|
|
762
|
+
for j, (_, crc) in enumerate(src_a):
|
|
763
|
+
by_hash.setdefault(crc, []).append(j)
|
|
764
|
+
found = None
|
|
765
|
+
for m, (p, crc) in enumerate(out_a):
|
|
766
|
+
if p < v_out + AUDIO_SKIP:
|
|
767
|
+
continue
|
|
768
|
+
if p > v_out + AUDIO_REACH or m + AUDIO_RUN > len(out_a) or out_a[m + AUDIO_RUN - 1][0] >= nxt:
|
|
769
|
+
break
|
|
770
|
+
run_ = [h for _, h in out_a[m:m + AUDIO_RUN]]
|
|
771
|
+
hits = [j for j in by_hash.get(crc, ()) if abs(src_a[j][0] - (p - vshift)) <= AUDIO_SEARCH
|
|
772
|
+
and [h for _, h in src_a[j:j + AUDIO_RUN]] == run_]
|
|
773
|
+
if len(hits) == 1:
|
|
774
|
+
found = (p - src_a[hits[0]][0]) - vshift
|
|
775
|
+
break
|
|
776
|
+
offsets.append(None if found is None else round(found * 1000, 2) + 0.0)
|
|
777
|
+
if any(o is not None for o in offsets):
|
|
778
|
+
break
|
|
779
|
+
measured = [o for o in offsets if o is not None]
|
|
780
|
+
return {"audio_offset_ms": offsets, "audio_parts_checked": len(measured),
|
|
781
|
+
"audio_ok": all(abs(o) <= AUDIO_SYNC_LIMIT * 1000 for o in measured)}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5, meta: dict = None,
|
|
785
|
+
_reasons: List[str] = None, edit_list_ok: bool = False, copy_t: float = None) -> dict:
|
|
786
|
+
"""Cut one segment. Returns its outcome: {reencoded, reasons, precision, start_snapped, ...}, where
|
|
787
|
+
`reasons` lists why THIS segment re-encoded on its own (pcm_container / copy_failed / tolerance);
|
|
788
|
+
the caller adds the reasons that forced every segment (requested, codec, vfr).
|
|
789
|
+
|
|
790
|
+
`edit_list_ok` (--edit-list) keeps the edit list a single MP4/MOV copy writes instead of
|
|
791
|
+
-avoid_negative_ts make_zero; off, the copy is cut and judged as in every 2.x release.
|
|
792
|
+
|
|
793
|
+
`copy_t` is a --segments part planned by plan_part: copied for -t copy_t (to its end
|
|
794
|
+
keyframe's dts), keeping the edit list an MP4/MOV copy writes, and not judged by its length
|
|
795
|
+
here, since plan_part judged each of its ends."""
|
|
796
|
+
reasons = list(_reasons or [])
|
|
109
797
|
dur = end - start
|
|
110
798
|
meta = meta or probe(src)
|
|
111
799
|
audio_only = is_audio_output(dst) or not meta.get("video")
|
|
112
800
|
if not reencode and audio_only and audio_codec_for(dst)[1].startswith("pcm") and not str((meta.get("audio") or {}).get("codec", "")).startswith("pcm"):
|
|
113
801
|
info(f"{(meta.get('audio') or {}).get('codec')} packets cannot be copied into a PCM container; decoding to PCM")
|
|
114
802
|
reencode = True
|
|
803
|
+
reasons.append("pcm_container")
|
|
804
|
+
edit_list = False
|
|
805
|
+
notes: List[str] = []
|
|
115
806
|
if reencode:
|
|
116
|
-
# -ss before -i seeks, then decoding discards samples up to the exact start (accurate_seek);
|
|
117
|
-
# atrim bounds the decoded stream to the requested length at sample resolution.
|
|
118
|
-
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
|
|
119
807
|
if is_audio_output(dst) or not meta.get("video"):
|
|
808
|
+
# -ss before -i seeks, then decoding discards samples up to the exact start
|
|
809
|
+
# (accurate_seek); atrim bounds the decoded stream to the requested length at sample
|
|
810
|
+
# resolution.
|
|
811
|
+
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
|
|
120
812
|
cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
|
|
813
|
+
else:
|
|
814
|
+
# Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
|
|
815
|
+
# discards it from both streams alike. An input seek straight to `start` can land (the
|
|
816
|
+
# MP4 demuxer seeks over its raw decode times) on a keyframe whose picture comes after
|
|
817
|
+
# `start` and lose the frames before it (-ss 9.9 on a B-frame HEVC file began at 10.0).
|
|
818
|
+
m = min(SEEK_MARGIN, start)
|
|
819
|
+
cmd = ffmpeg_base() + ["-ss", f"{start - m:.6f}", "-i", src, "-ss", f"{m:.6f}", "-t", f"{dur:.6f}"]
|
|
121
820
|
# ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
|
|
122
821
|
# trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
|
|
123
822
|
# sweep F2), so the re-encode carries video/audio only and the result says so
|
|
@@ -130,15 +829,40 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
130
829
|
# FLAC/MP3 on a coarse index; reading from the start and dropping packets is exact to the packet
|
|
131
830
|
cmd = ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
|
|
132
831
|
else:
|
|
133
|
-
|
|
832
|
+
# With --edit-list a single MP4/MOV output keeps the edit list the plain copy writes: the
|
|
833
|
+
# keyframe's pre-roll is stored but hidden, so the picture and the sound start at `start`.
|
|
834
|
+
# make_zero (the default, as in every 2.x release) shifts the timestamps instead and shows
|
|
835
|
+
# the pre-roll -- on Core Media HEVC, 3.7 s of sound with no picture from a run that exited
|
|
836
|
+
# 0, which av_start_skew_seconds and a note report. A planned concat part keeps it: the concat
|
|
837
|
+
# demuxer ignores where the edit list starts (it shows the pre-roll) but places the next
|
|
838
|
+
# part by its length, which make_zero parts got wrong by the B-frame reorder delay.
|
|
839
|
+
# Other concat parts (Matroska, MPEG-TS) keep make_zero.
|
|
840
|
+
edit_list = (edit_list_ok or copy_t is not None) and os.path.splitext(dst)[1].lower() in EDIT_LIST_EXTS
|
|
841
|
+
cmd = (ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur if copy_t is None else copy_t:.6f}"]
|
|
842
|
+
+ copy_args(meta, dst) + ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
|
|
134
843
|
proc = run(cmd, check=False)
|
|
135
844
|
if proc.returncode != 0:
|
|
136
845
|
if not reencode:
|
|
137
846
|
info("stream copy failed, falling back to re-encode")
|
|
138
|
-
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
847
|
+
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["copy_failed"], edit_list_ok)
|
|
139
848
|
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
140
|
-
if not reencode and tolerance >= 0 and not STATE.dry_run:
|
|
141
|
-
|
|
849
|
+
if not reencode and tolerance >= 0 and not STATE.dry_run and copy_t is None:
|
|
850
|
+
out_meta = probe(dst)
|
|
851
|
+
got = out_meta.get("duration") or 0.0
|
|
852
|
+
vdur, fps = (out_meta.get("video") or {}).get("duration"), (meta.get("video") or {}).get("fps")
|
|
853
|
+
if edit_list and not audio_only and vdur and fps and abs(got - vdur) > 1.0 / fps:
|
|
854
|
+
# the container's length counts the longer track; the tolerance is about the picture.
|
|
855
|
+
# Only under --edit-list: the default judges the container, as every 2.x release did
|
|
856
|
+
msg = f"the container is {got:.3f}s but its video {vdur:.3f}s; the cut was judged by the video"
|
|
857
|
+
info(msg)
|
|
858
|
+
notes.append(msg)
|
|
859
|
+
got = vdur
|
|
860
|
+
if abs(got - dur) >= tolerance and edit_list and (copy_presentation(start, seek_keyframe(src, start), True, fps)["stored_preroll_seconds"] is not None):
|
|
861
|
+
# the edit list started the picture at `start`: only the END is off (it lands on a
|
|
862
|
+
# packet boundary), and no other --start would change that
|
|
863
|
+
info(f"the stream copy starts where asked but its end landed {got - dur:+.2f}s from the requested "
|
|
864
|
+
f"length (> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy")
|
|
865
|
+
return _with_notes(cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["tolerance"], edit_list_ok), notes)
|
|
142
866
|
if abs(got - dur) >= tolerance: # a snap of exactly the tolerance is not "within" it (sweep F20)
|
|
143
867
|
near = keyframes_near(src, start)
|
|
144
868
|
alt = ""
|
|
@@ -149,8 +873,260 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
149
873
|
NEAREST_KEYFRAMES.extend(k for k in near if k not in NEAREST_KEYFRAMES)
|
|
150
874
|
info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
|
|
151
875
|
f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy{alt}")
|
|
152
|
-
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
153
|
-
|
|
876
|
+
return _with_notes(cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["tolerance"], edit_list_ok), notes)
|
|
877
|
+
if reencode or audio_only:
|
|
878
|
+
return _outcome(meta, dst, reencode, reasons)
|
|
879
|
+
if STATE.dry_run:
|
|
880
|
+
# the planned copy: whether it keeps an edit list is known; where it lands is not
|
|
881
|
+
return _outcome(meta, dst, False, reasons, edit_list=edit_list and edit_list_ok, start_snapped=None)
|
|
882
|
+
fps = (meta.get("video") or {}).get("fps")
|
|
883
|
+
# a concat part's edit list does not hide its pre-roll in the join, so it presents from the keyframe
|
|
884
|
+
shown = edit_list and edit_list_ok
|
|
885
|
+
key = seek_keyframe(src, start)
|
|
886
|
+
return _outcome(meta, dst, False, reasons, edit_list=shown, notes=notes, landed_pts=key[0] if key else None,
|
|
887
|
+
**copy_presentation(start, key, shown, fps))
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
# The most segments one fallback ffmpeg call opens: every segment is its own input (a file handle,
|
|
891
|
+
# a demuxer and a decoder each), and macOS shells default to 256 open files.
|
|
892
|
+
JOIN_CHUNK = 32
|
|
893
|
+
# how far before a segment the fallback seeks, so a start inside the B-frame reorder delay before a
|
|
894
|
+
# keyframe still decodes from the keyframe BEFORE it
|
|
895
|
+
SEEK_MARGIN = 1.0
|
|
896
|
+
# codecs whose configuration travels in the packets, so a part with no extradata is not missing any
|
|
897
|
+
IN_BAND_CONFIG = ("mp3", "mp2")
|
|
898
|
+
TS_EXTS = (".ts", ".m2ts", ".mts")
|
|
899
|
+
_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
|
|
900
|
+
"sample_aspect_ratio", "color_transfer", "color_primaries"),
|
|
901
|
+
"audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
_PARTS: dict = {}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _part_probe(path: str):
|
|
908
|
+
"""ffprobe's streams and format of a cut part, with extradata hashes; None when it cannot be
|
|
909
|
+
read. One probe per part serves join_signature and predicted_join_steps."""
|
|
910
|
+
if path not in _PARTS:
|
|
911
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-show_data_hash", "sha256", "-show_streams", "-show_format",
|
|
912
|
+
"-of", "json", path], quiet=True, check=False)
|
|
913
|
+
try:
|
|
914
|
+
doc = json.loads(proc.stdout or "") if proc.returncode == 0 else None
|
|
915
|
+
except ValueError:
|
|
916
|
+
doc = None
|
|
917
|
+
_PARTS[path] = doc if isinstance(doc, dict) and isinstance(doc.get("streams"), list) else None
|
|
918
|
+
return _PARTS[path]
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def join_signature(path: str):
|
|
922
|
+
"""What a stream-copy join needs to be identical across parts: each stream's type, codec
|
|
923
|
+
parameters, rotation and extradata hash. None when ffprobe cannot read the part."""
|
|
924
|
+
doc = _part_probe(path)
|
|
925
|
+
if doc is None:
|
|
926
|
+
return None
|
|
927
|
+
sig = []
|
|
928
|
+
for st in doc["streams"]:
|
|
929
|
+
kind = st.get("codec_type")
|
|
930
|
+
entry = {"type": kind, "extradata_hash": st.get("extradata_hash")}
|
|
931
|
+
entry.update({f: st.get(f) for f in _SIG_FIELDS.get(kind, ("codec_name",))})
|
|
932
|
+
if kind == "video":
|
|
933
|
+
entry["rotation"] = next((sd.get("rotation") for sd in st.get("side_data_list") or []
|
|
934
|
+
if "rotation" in sd), None)
|
|
935
|
+
sig.append(entry)
|
|
936
|
+
return sig
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
# how far a measured join step may be from the predicted one: ffprobe prints the parts' times
|
|
940
|
+
# to the microsecond, and a time base coarser than that (Matroska's 1 ms) rounds each of them
|
|
941
|
+
JOIN_STEP_SLACK = 0.001
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def predicted_join_steps(parts) -> list:
|
|
945
|
+
"""For each join of `parts`, the presentation step from the last frame of one part to the
|
|
946
|
+
first of the next, as the concat demuxer places them: the next part starts where this one's
|
|
947
|
+
container ends, so the step is (this part's container end - its last frame's pts) + (the next
|
|
948
|
+
part's first frame's pts - its container start), read from the parts' own packets (Matroska
|
|
949
|
+
stores no stream durations). None for a join whose parts' times are unknown. On an exact
|
|
950
|
+
no-B-frame join (0-1.8,4-6 at 30 fps) that is 33.3 + 13.3 + 10.0 = 56.7 ms, what the joined
|
|
951
|
+
file showed."""
|
|
952
|
+
def times(path):
|
|
953
|
+
doc = _part_probe(path)
|
|
954
|
+
pts = [p for p in stream_packet_times(path, "v", "pts_time") if p is not None]
|
|
955
|
+
try:
|
|
956
|
+
fmt = (doc or {}).get("format") or {}
|
|
957
|
+
c_start, c_dur = float(fmt["start_time"]), float(fmt["duration"])
|
|
958
|
+
except (KeyError, TypeError, ValueError):
|
|
959
|
+
return None
|
|
960
|
+
return (c_start, c_start + c_dur, min(pts), max(pts)) if pts else None
|
|
961
|
+
spans = [times(p) for p in parts]
|
|
962
|
+
return [None if a is None or b is None else (a[1] - a[3]) + (b[2] - b[0]) for a, b in zip(spans, spans[1:])]
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def signatures_match(sigs: list, ext: str) -> bool:
|
|
966
|
+
"""True when every part can be joined by stream copy: same streams in the same order with the
|
|
967
|
+
same parameters. A missing extradata hash only matches another missing one where the codec (or
|
|
968
|
+
an MPEG-TS output) carries its configuration in-band -- PCM has none at all."""
|
|
969
|
+
if not sigs or any(sig is None for sig in sigs):
|
|
970
|
+
return False
|
|
971
|
+
first = sigs[0]
|
|
972
|
+
for sig in sigs[1:]:
|
|
973
|
+
if len(sig) != len(first):
|
|
974
|
+
return False
|
|
975
|
+
for a, b in zip(first, sig):
|
|
976
|
+
if {k: v for k, v in a.items() if k != "extradata_hash"} != {k: v for k, v in b.items() if k != "extradata_hash"}:
|
|
977
|
+
return False
|
|
978
|
+
ha, hb = a.get("extradata_hash"), b.get("extradata_hash")
|
|
979
|
+
if ha or hb:
|
|
980
|
+
if ha != hb:
|
|
981
|
+
return False
|
|
982
|
+
else:
|
|
983
|
+
codec = str(a.get("codec_name") or "")
|
|
984
|
+
if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
|
|
985
|
+
return False
|
|
986
|
+
return True
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _fraction_rate(text: str) -> float:
|
|
990
|
+
""""30000/1001" or "30" as a number."""
|
|
991
|
+
num, _, den = str(text).partition("/")
|
|
992
|
+
return float(num) / float(den or 1)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _grid_at_or_after(t: float, v0: float, rate: float) -> float:
|
|
996
|
+
"""The time of the first frame at or after `t` on a constant-rate grid starting at `v0`."""
|
|
997
|
+
import math
|
|
998
|
+
return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: dict, crf: int, preset: str,
|
|
1002
|
+
has_v: bool, intermediate: bool = False, vend=None, hold=frozenset(), first: int = 0) -> List[str]:
|
|
1003
|
+
"""Re-cut `segments` from the source into one file through the concat filter, returning the codec
|
|
1004
|
+
arguments it encoded with. Each segment is
|
|
1005
|
+
its own seeked input, so both of its streams start at the segment's origin (0); neither is
|
|
1006
|
+
rebased on its own, which would drop a real A/V offset -- the audio is padded to the origin
|
|
1007
|
+
instead. Both streams are cut to the same length so the next segment starts where this ends.
|
|
1008
|
+
|
|
1009
|
+
An `intermediate` chunk keeps its audio as PCM, so the chunks join with no encoder priming
|
|
1010
|
+
between them and the audio is encoded once, for the final file."""
|
|
1011
|
+
has_a = bool(meta.get("audio"))
|
|
1012
|
+
cmd = ffmpeg_base()
|
|
1013
|
+
graph, pads = [], ""
|
|
1014
|
+
video = meta.get("video") or {}
|
|
1015
|
+
rate_arg = video.get("r_frame_rate") if has_v else None
|
|
1016
|
+
try:
|
|
1017
|
+
rate = _fraction_rate(rate_arg) if rate_arg else (video.get("fps") or 0.0)
|
|
1018
|
+
except (ValueError, ZeroDivisionError):
|
|
1019
|
+
rate = video.get("fps") or 0.0
|
|
1020
|
+
v0 = (video.get("start_time") or 0.0) - file_origin(src) if has_v and rate else 0.0
|
|
1021
|
+
for i, (s, e) in enumerate(segments):
|
|
1022
|
+
d = e - s
|
|
1023
|
+
# Seek SEEK_MARGIN early and trim to the exact start. The MP4 demuxer seeks over its raw
|
|
1024
|
+
# decode times, so a start just before a keyframe (within the B-frame reorder delay) can
|
|
1025
|
+
# land on that keyframe and lose its leading frames -- measured: -ss 9.9 on a keyint-60
|
|
1026
|
+
# HEVC file began at 10.0 (seek_keyframe measures where a copy lands). Likewise an input -t stops reading at s+d in decode order and would drop
|
|
1027
|
+
# B-frames that display inside the segment, so it reads a second past the end.
|
|
1028
|
+
m = min(SEEK_MARGIN, s)
|
|
1029
|
+
cmd += ["-ss", f"{s - m:.6f}", "-t", f"{m + d + 1.0:.6f}", "-i", src]
|
|
1030
|
+
a, b, half = m, m + d, 0.0
|
|
1031
|
+
if has_v and rate:
|
|
1032
|
+
# Both streams are cut on the source's frame grid: from the first frame at or after the
|
|
1033
|
+
# start to the first at or after the end, which are the frames the segment holds. A
|
|
1034
|
+
# segment starting between frames otherwise placed its pictures half a frame off the
|
|
1035
|
+
# output's grid, and the constant-rate output duplicated one (65 frames for 64 on
|
|
1036
|
+
# FFmpeg 6.1). The video trim sits half a frame before each grid point, so no FFmpeg
|
|
1037
|
+
# version rounds a frame on the boundary in or out.
|
|
1038
|
+
a = m + (_grid_at_or_after(s, v0, rate) - s)
|
|
1039
|
+
b = m + (_grid_at_or_after(e, v0, rate) - s)
|
|
1040
|
+
half = 0.5 / rate
|
|
1041
|
+
# both streams shift by the same constant, so an offset between them (audio that starts
|
|
1042
|
+
# late) survives; the audio is then padded to the segment's origin
|
|
1043
|
+
if has_v:
|
|
1044
|
+
# a segment that runs past the video's end holds its last frame for the sound (main()
|
|
1045
|
+
# has already ended one that ran less than a frame past it at the video's end): the
|
|
1046
|
+
# concat filter starts the next segment after the longer stream, so a short picture
|
|
1047
|
+
# would leave a hole in the video there
|
|
1048
|
+
pad = f"tpad=stop_mode=clone:stop_duration={e - vend + 1.0:.6f}," if first + i in hold else ""
|
|
1049
|
+
graph.append(f"[{i}:v:0]{pad}trim=start={a - half:.6f}:end={b - half:.6f},setpts=PTS-{a:.6f}/TB[v{i}]")
|
|
1050
|
+
pads += f"[v{i}]"
|
|
1051
|
+
if has_a:
|
|
1052
|
+
graph.append(f"[{i}:a:0]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
|
|
1053
|
+
f"aresample=async=1:first_pts=0[a{i}]")
|
|
1054
|
+
pads += f"[a{i}]"
|
|
1055
|
+
outs = ("[v]" if has_v else "") + ("[a]" if has_a else "")
|
|
1056
|
+
graph.append(f"{pads}concat=n={len(segments)}:v={int(has_v)}:a={int(has_a)}{outs}")
|
|
1057
|
+
cmd += ["-filter_complex", ";".join(graph)]
|
|
1058
|
+
for pad in ("[v]" if has_v else None, "[a]" if has_a else None):
|
|
1059
|
+
if pad:
|
|
1060
|
+
cmd += ["-map", pad]
|
|
1061
|
+
if intermediate:
|
|
1062
|
+
codec = (video_encode_args(meta, crf, preset) if has_v else ["-vn"]) + ["-c:a", "pcm_s24le"]
|
|
1063
|
+
else:
|
|
1064
|
+
codec = encode_args(meta, dst, crf, preset)
|
|
1065
|
+
# the source's rate, stated: FFmpeg 7.0 encoded the trimmed, concatenated pictures at 25 fps
|
|
1066
|
+
# and dropped frames to fit (98 frames for 117 at 30 fps)
|
|
1067
|
+
rate_out = ["-r", rate_arg] if has_v and rate_arg and rate and "-r" not in codec else []
|
|
1068
|
+
cmd += ["-sn", "-dn"] + codec + rate_out + [dst]
|
|
1069
|
+
run(cmd)
|
|
1070
|
+
return codec
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, meta: dict, crf: int, preset: str, tmp: str) -> None:
|
|
1074
|
+
"""The join fallback: every segment re-cut from the source and encoded once. The parts are not
|
|
1075
|
+
reused -- a copied part carries keyframe pre-roll and an audio tail the concat filter would
|
|
1076
|
+
turn into a gap, and a re-encode keeps nothing lossless anyway."""
|
|
1077
|
+
if meta.get("subtitle_streams") or meta.get("data_streams"):
|
|
1078
|
+
DROPPED_STREAMS.append(dst)
|
|
1079
|
+
has_v = bool(meta.get("video")) and not is_audio_output(dst)
|
|
1080
|
+
vend = video_end(src, meta) if has_v else None
|
|
1081
|
+
# the segments whose picture ends before their sound; the last one is left as the source had it,
|
|
1082
|
+
# since no join follows it
|
|
1083
|
+
# (by position: a repeat of a held segment in last place is still the last one)
|
|
1084
|
+
hold = frozenset(k for k, (_, e) in enumerate(segments[:-1]) if vend is not None and e > vend + 1e-6)
|
|
1085
|
+
if len(segments) <= JOIN_CHUNK:
|
|
1086
|
+
_join_chunk(src, segments, dst, meta, crf, preset, has_v, vend=vend, hold=hold)
|
|
1087
|
+
return
|
|
1088
|
+
ext = os.path.splitext(dst)[1] or ".mp4"
|
|
1089
|
+
# Matroska chunks with PCM audio: no edit lists and no AAC priming to carry into the join (MP4
|
|
1090
|
+
# chunks measured the video 23 ms late against the audio after a copy concat)
|
|
1091
|
+
# the chunks' codec tag (hvc1), which a stream copy out of Matroska does not carry into an MP4
|
|
1092
|
+
# (it writes hev1, which Apple players refuse)
|
|
1093
|
+
tag: List[str] = []
|
|
1094
|
+
|
|
1095
|
+
def encode_chunks() -> List[str]:
|
|
1096
|
+
out = []
|
|
1097
|
+
for k in range(0, len(segments), JOIN_CHUNK):
|
|
1098
|
+
chunk = os.path.join(tmp, f"chunk{k // JOIN_CHUNK:03d}.mkv")
|
|
1099
|
+
args = _join_chunk(src, segments[k:k + JOIN_CHUNK], chunk, meta, crf, preset, has_v, intermediate=True,
|
|
1100
|
+
vend=vend, hold=hold, first=k) or []
|
|
1101
|
+
if "-tag:v" in args:
|
|
1102
|
+
tag[:] = args[args.index("-tag:v"):args.index("-tag:v") + 2]
|
|
1103
|
+
out.append(chunk)
|
|
1104
|
+
return out
|
|
1105
|
+
|
|
1106
|
+
chunks = encode_chunks()
|
|
1107
|
+
if not STATE.dry_run and STATE.hw and not signatures_match([join_signature(c) for c in chunks], ".mkv"):
|
|
1108
|
+
# VideoToolbox refused some chunk and run() re-encoded that one on the CPU, so the chunks
|
|
1109
|
+
# no longer match: encode them all on the CPU rather than refuse a join that can be made
|
|
1110
|
+
info("the GPU refused part of this join and the chunks came out mixed; re-encoding every chunk on the CPU")
|
|
1111
|
+
STATE.hw_notes.append("the --segments join fell back to the CPU for every chunk after VideoToolbox refused one")
|
|
1112
|
+
# off for these encodes only: the result still reports that the GPU was asked for
|
|
1113
|
+
STATE.hw = False
|
|
1114
|
+
try:
|
|
1115
|
+
chunks = encode_chunks()
|
|
1116
|
+
finally:
|
|
1117
|
+
STATE.hw = True
|
|
1118
|
+
if not STATE.dry_run and not signatures_match([join_signature(c) for c in chunks], ".mkv"):
|
|
1119
|
+
die("the re-encoded chunks of this join came out with different stream parameters, so they "
|
|
1120
|
+
f"cannot be joined safely; cut at most {JOIN_CHUNK} segments per run and join the results "
|
|
1121
|
+
"with join.py", kind="ffmpeg")
|
|
1122
|
+
listfile = os.path.join(tmp, "chunks.txt")
|
|
1123
|
+
with open(listfile, "w", encoding="utf-8") as fh:
|
|
1124
|
+
for c in chunks:
|
|
1125
|
+
fh.write(concat_list_line(c) + "\n")
|
|
1126
|
+
audio = [] if not meta.get("audio") else (aac_args() if has_v else audio_codec_for(dst))
|
|
1127
|
+
run(ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
1128
|
+
+ (["-c:v", "copy"] + (tag if ext in (".mp4", ".mov", ".m4v") else []) if has_v else ["-vn"]) + audio
|
|
1129
|
+
+ (["-movflags", "+faststart"] if ext in (".mp4", ".mov", ".m4v") and has_v else []) + [dst])
|
|
154
1130
|
|
|
155
1131
|
|
|
156
1132
|
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
@@ -270,6 +1246,23 @@ def snap_segments(args, segments, meta, total):
|
|
|
270
1246
|
out_segments)
|
|
271
1247
|
|
|
272
1248
|
|
|
1249
|
+
@contextlib.contextmanager
|
|
1250
|
+
def _join_workspace():
|
|
1251
|
+
"""A --segments copy join's workspace: a temp directory for its parts, and a list of the files
|
|
1252
|
+
it writes beside the output (the checked join, renamed into place when it passes), removed
|
|
1253
|
+
whatever happens."""
|
|
1254
|
+
temps: List[str] = []
|
|
1255
|
+
try:
|
|
1256
|
+
with tempfile.TemporaryDirectory(prefix="ffskill_cut_") as tmp:
|
|
1257
|
+
yield tmp, temps
|
|
1258
|
+
finally:
|
|
1259
|
+
for path in temps:
|
|
1260
|
+
try:
|
|
1261
|
+
os.remove(path)
|
|
1262
|
+
except OSError:
|
|
1263
|
+
pass
|
|
1264
|
+
|
|
1265
|
+
|
|
273
1266
|
def main() -> int:
|
|
274
1267
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
275
1268
|
ap.add_argument("input")
|
|
@@ -280,7 +1273,16 @@ def main() -> int:
|
|
|
280
1273
|
g.add_argument("--duration", help="duration instead of --end")
|
|
281
1274
|
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
|
|
282
1275
|
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
283
|
-
ap.add_argument("--
|
|
1276
|
+
ap.add_argument("--edit-list", action="store_true", help="a single-segment .mp4/.mov stream copy keeps the MP4 edit list that hides the keyframe's pre-roll, so the picture starts at --start (default: -avoid_negative_ts make_zero, the picture starts at the keyframe); a player that ignores edit lists shows the pre-roll")
|
|
1277
|
+
ap.add_argument("--keep-hevc", action="store_true", help="a re-encode of an SDR HEVC source stays HEVC (x265 8-bit BT.709; VideoToolbox under --hw) instead of x264 (default: x264 for SDR, x265 Main10 for HDR); --codec overrides it")
|
|
1278
|
+
ap.add_argument("--vfr-guard", choices=["average", "sampled", "off"], default="average",
|
|
1279
|
+
help="what keeps a variable-frame-rate source from a stream copy (default average: a nominal rate "
|
|
1280
|
+
"that differs from the average re-encodes, as --accurate, and the sampled frame timing is "
|
|
1281
|
+
"reported; sampled: re-encode only when sampled packet timestamps are variable or too few "
|
|
1282
|
+
"to judge; off: measure and keep the copy, with a note)")
|
|
1283
|
+
ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never for a "
|
|
1284
|
+
"keyframe snap; a --segments join is still re-cut for open GOPs, a later B-frame .mp4/.mov "
|
|
1285
|
+
"segment starting between keyframes, or a failed join check)")
|
|
284
1286
|
snap = ap.add_argument_group("beat snapping")
|
|
285
1287
|
snap.add_argument("--snap", choices=["none", "beats"], default="none",
|
|
286
1288
|
help="move each in/out point to the nearest measured beat (default none)")
|
|
@@ -297,16 +1299,21 @@ def main() -> int:
|
|
|
297
1299
|
add_common(ap)
|
|
298
1300
|
args = ap.parse_args()
|
|
299
1301
|
apply_common(args)
|
|
1302
|
+
global KEEP_HEVC
|
|
1303
|
+
KEEP_HEVC = args.keep_hevc
|
|
300
1304
|
|
|
301
1305
|
meta = probe(args.input)
|
|
302
1306
|
total = meta.get("duration") or 0.0
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if STATE.codec
|
|
307
|
-
# "cut this and make it HEVC": a stream copy keeps the source codec, so the request is a
|
|
308
|
-
|
|
1307
|
+
# why every segment re-encodes, if one of these forces it; `requested` is read before the
|
|
1308
|
+
# guards below overwrite args.accurate, so a forced re-encode is never reported as asked for
|
|
1309
|
+
forced: List[str] = ["requested"] if args.accurate else []
|
|
1310
|
+
if STATE.codec:
|
|
1311
|
+
# "cut this and make it HEVC": a stream copy keeps the source codec, so the request is a
|
|
1312
|
+
# re-encode -- and a cause of it even when --accurate or the VFR guard already forced one
|
|
1313
|
+
if not args.accurate:
|
|
1314
|
+
info(f"--codec {STATE.codec} asks for a re-encode; the lossless copy path keeps the source codec, switching to --accurate")
|
|
309
1315
|
args.accurate = True
|
|
1316
|
+
forced.append("codec")
|
|
310
1317
|
|
|
311
1318
|
fps = (meta.get("video") or {}).get("fps")
|
|
312
1319
|
if args.segments:
|
|
@@ -337,54 +1344,305 @@ def main() -> int:
|
|
|
337
1344
|
if total and s >= total:
|
|
338
1345
|
die(f"segment start {s:.3f}s is beyond the media duration {total:.3f}s")
|
|
339
1346
|
segments = [(s, min(e, total) if total else e) for s, e in segments]
|
|
1347
|
+
# what was asked for, clamped to the media duration as in every 2.x release: requested_* and
|
|
1348
|
+
# expected_duration report this, and a segment the join below ends with the video (less than a
|
|
1349
|
+
# frame of sound trimmed) shows that trim in duration_delta_seconds and a note instead
|
|
1350
|
+
requested = list(segments)
|
|
1351
|
+
video = meta.get("video") or {}
|
|
1352
|
+
if video.get("fps") and not is_audio_output(args.output or ""):
|
|
1353
|
+
# a video segment shorter than one frame has no picture to cut: a copy lands on a whole
|
|
1354
|
+
# GOP and a re-encode on one frame or none, so the result would not be what was asked
|
|
1355
|
+
frame = 1.0 / video["fps"]
|
|
1356
|
+
# where the video ends on the cut's clock (video_end), not the stream's length: video that
|
|
1357
|
+
# starts after the container does (MPEG-TS) ends later than its length says. A pending
|
|
1358
|
+
# --dry-run input has nothing to probe, so it falls back to the media duration.
|
|
1359
|
+
vend = (None if dry_run_input_pending(args.input) else video_end(args.input, meta)) or total
|
|
1360
|
+
for s, e in segments:
|
|
1361
|
+
if (min(e, vend) if vend else e) - s < frame - 1e-6:
|
|
1362
|
+
die(f"segment {s:.3f}-{e:.3f}s is shorter than one frame ({frame:.4f}s at {video['fps']:g} fps)"
|
|
1363
|
+
+ (" inside the video stream" if vend and e > vend else "")
|
|
1364
|
+
+ "; give every segment at least one frame", kind="input")
|
|
340
1365
|
|
|
341
1366
|
output = args.output or default_output(args.input, "cut")
|
|
342
1367
|
refuse_output_is_input(output, args.input)
|
|
343
1368
|
ext = os.path.splitext(output)[1] or ".mp4"
|
|
344
1369
|
|
|
345
|
-
|
|
1370
|
+
# A segment that runs past the end of the video, with a join after it: whatever joins it (the
|
|
1371
|
+
# concat demuxer or the concat filter) starts the next segment after its LONGER stream, so the
|
|
1372
|
+
# sound with no picture left a hole in the video (0.355 s for 0.3 s of sound, reported as a
|
|
1373
|
+
# clean copy). clip_length's rule, as join.py uses it: less than a frame past -> end the
|
|
1374
|
+
# segment with its picture; more -> hold the last frame for the sound, which only a re-cut
|
|
1375
|
+
# can do. The last segment is left as the source had it: nothing is placed after it.
|
|
1376
|
+
join_notes: List[str] = []
|
|
1377
|
+
hold_needed = False
|
|
1378
|
+
vend = (video_end(args.input, meta) if len(segments) > 1 and video.get("fps") and not is_audio_output(output)
|
|
1379
|
+
and not dry_run_input_pending(args.input) else None)
|
|
1380
|
+
if vend is not None:
|
|
1381
|
+
frame = 1.0 / video["fps"]
|
|
1382
|
+
for i, (s, e) in enumerate(segments[:-1]):
|
|
1383
|
+
if e <= vend + 1e-6:
|
|
1384
|
+
continue
|
|
1385
|
+
if e - vend <= frame + 1e-6:
|
|
1386
|
+
segments[i] = (s, vend)
|
|
1387
|
+
join_notes.append(f"segment {i + 1} ends with the video at {vend:.3f}s: the {(e - vend) * 1000:.0f} ms of "
|
|
1388
|
+
"sound after its last frame is trimmed")
|
|
1389
|
+
else:
|
|
1390
|
+
hold_needed = True
|
|
1391
|
+
join_notes.append(f"segment {i + 1} runs {e - vend:.3f}s past the end of the video; its last frame is held "
|
|
1392
|
+
"for the sound, which a stream-copy join cannot do, so every segment was re-cut")
|
|
1393
|
+
for n in join_notes:
|
|
1394
|
+
info(n)
|
|
1395
|
+
|
|
1396
|
+
# the VFR guard: a stream copy of variable-frame-rate video cuts unreliably. --vfr-guard average
|
|
1397
|
+
# (the default, as in every 2.x release) trusts probe's nominal-vs-average rate check and
|
|
1398
|
+
# re-encodes on it; the sampled packet timing is then measured only to report whether that
|
|
1399
|
+
# check was right. sampled re-encodes on the sampled timing instead (an iPhone's 30 vs 29.98
|
|
1400
|
+
# fps is not taken for VFR), and off measures and keeps the copy.
|
|
1401
|
+
vfr_check = None
|
|
1402
|
+
vfr_notes: List[str] = []
|
|
1403
|
+
could_copy = (bool(video) and not args.accurate and not hold_needed and not is_audio_output(output)
|
|
1404
|
+
and not dry_run_input_pending(args.input))
|
|
1405
|
+
|
|
1406
|
+
def sample_timing() -> dict:
|
|
1407
|
+
return {"heuristic": True, **measure_frame_timing(args.input, total, video.get("start_time") or 0.0),
|
|
1408
|
+
"method": "sampled", "guard": args.vfr_guard}
|
|
1409
|
+
|
|
1410
|
+
if args.vfr_guard == "average":
|
|
1411
|
+
if video.get("variable_frame_rate_suspected") and not args.accurate:
|
|
1412
|
+
info("source looks variable-frame-rate; lossless cuts on VFR are unreliable, switching to --accurate")
|
|
1413
|
+
if could_copy:
|
|
1414
|
+
vfr_check = sample_timing()
|
|
1415
|
+
if vfr_check["measured"] == "sampled_cfr":
|
|
1416
|
+
vfr_notes.append("the nominal and average frame rates differ, so the source was taken for VFR and "
|
|
1417
|
+
"re-encoded, but its sampled frame timing is constant: --vfr-guard sampled keeps "
|
|
1418
|
+
"the lossless copy")
|
|
1419
|
+
info(vfr_notes[-1])
|
|
1420
|
+
args.accurate = True
|
|
1421
|
+
forced.append("vfr")
|
|
1422
|
+
elif could_copy:
|
|
1423
|
+
vfr_check = sample_timing()
|
|
1424
|
+
measured = vfr_check["measured"]
|
|
1425
|
+
what = ("the sampled frame timing is variable" if measured == "vfr"
|
|
1426
|
+
else "the frame timing could not be measured")
|
|
1427
|
+
if measured != "sampled_cfr" and args.vfr_guard == "off":
|
|
1428
|
+
vfr_notes.append(f"{what}; --vfr-guard off skipped the re-encode that forces, so a stream copy of this "
|
|
1429
|
+
"source may land off its frames")
|
|
1430
|
+
info(vfr_notes[-1])
|
|
1431
|
+
elif measured != "sampled_cfr":
|
|
1432
|
+
info(f"{what}; lossless cuts on VFR are unreliable, switching to --accurate (--vfr-guard off keeps the copy)")
|
|
1433
|
+
args.accurate = True
|
|
1434
|
+
forced.append("vfr" if measured == "vfr" else "vfr_inconclusive")
|
|
1435
|
+
|
|
1436
|
+
outcomes: List[dict] = []
|
|
1437
|
+
join_reencoded = False
|
|
1438
|
+
join_check = None
|
|
346
1439
|
if len(segments) == 1:
|
|
347
|
-
|
|
348
|
-
|
|
1440
|
+
outcomes.append(cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta,
|
|
1441
|
+
edit_list_ok=args.edit_list))
|
|
1442
|
+
elif hold_needed or args.accurate:
|
|
1443
|
+
# Straight to one encode through the concat filter. A held frame needs the re-cut (no part
|
|
1444
|
+
# can carry it into a copy join); and when every part re-encodes anyway, encoding each on
|
|
1445
|
+
# its own and copy-joining them left a hole of one AAC frame at every join (each part's
|
|
1446
|
+
# audio runs an encoder frame past its picture, and the concat demuxer places the next
|
|
1447
|
+
# part after it: measured 23 ms per join).
|
|
349
1448
|
with tempfile.TemporaryDirectory(prefix="ffskill_cut_") as tmp:
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
1449
|
+
join_from_source(args.input, segments, output, meta, args.crf, args.preset, tmp)
|
|
1450
|
+
join_reencoded = hold_needed # only the hold is a fallback; --accurate asked for this
|
|
1451
|
+
outcomes = [_outcome(meta, output, True, []) for _ in segments]
|
|
1452
|
+
else:
|
|
1453
|
+
with _join_workspace() as (tmp, temps):
|
|
1454
|
+
# A video join is planned from the source's own packets and measured against them once
|
|
1455
|
+
# written (check_join). A source whose frames do not reorder (no B-frames) cuts its
|
|
1456
|
+
# parts as every 2.x release did (make_zero, -t end-start, each part judged against
|
|
1457
|
+
# --tolerance by its length): in decode order that is already exact. One whose frames
|
|
1458
|
+
# reorder keeps .mp4/.mov parts' edit lists and cuts each from its start keyframe to
|
|
1459
|
+
# its end keyframe's dts (plan_part). An audio-only join keeps the plain cut.
|
|
1460
|
+
join_video = bool(video) and not is_audio_output(output) and not dry_run_input_pending(args.input)
|
|
1461
|
+
# where each part's -ss lands, measured (seek_keyframe); the packets read around the
|
|
1462
|
+
# segments reach back to it
|
|
1463
|
+
landings = [seek_keyframe(args.input, s) for s, _ in segments] if join_video else None
|
|
1464
|
+
packets = (video_packets(args.input, [(min(s, k[0]) if k else s, e) for (s, e), k in zip(segments, landings)],
|
|
1465
|
+
args.tolerance, total) if join_video else None)
|
|
1466
|
+
snap_end = bool(packets) and reorders(packets) and ext.lower() in EDIT_LIST_EXTS
|
|
1467
|
+
plans = ([plan_part(packets, s, e, args.tolerance, video_end(args.input, meta), fps, snap_end, k)
|
|
1468
|
+
for (s, e), k in zip(segments, landings)] if packets else None)
|
|
1469
|
+
open_key = open_join_key(packets, plans) if plans else None
|
|
1470
|
+
preroll = hidden_preroll_start(packets, plans, snap_end) if plans else None
|
|
1471
|
+
recut = None # why the parts are not joined by stream copy, when that is known before cutting them
|
|
1472
|
+
if join_video and not packets:
|
|
1473
|
+
recut = ("the source's video packets could not be read, so a stream-copy join could not be checked; "
|
|
1474
|
+
"every segment was re-cut from the source")
|
|
1475
|
+
elif open_key is not None:
|
|
1476
|
+
recut = (f"the source uses open GOPs: frames before the keyframe at {open_key:.3f}s decode after it, so a "
|
|
1477
|
+
"stream-copy join would drop them; every segment was re-cut from the source")
|
|
1478
|
+
elif preroll is not None and not any(p["reason"] for p in plans):
|
|
1479
|
+
recut = (f"segment {preroll} starts between keyframes of a source whose frames reorder: its copy would "
|
|
1480
|
+
"hide the frames before its start, which a stream-copy join cannot do; every segment was re-cut "
|
|
1481
|
+
"from the source")
|
|
1482
|
+
compatible = False
|
|
1483
|
+
if recut:
|
|
1484
|
+
info(recut)
|
|
1485
|
+
join_notes.append(recut)
|
|
1486
|
+
elif plans and any(p["reason"] for p in plans):
|
|
1487
|
+
# a segment with no keyframe near its start or end re-encodes, and only copied parts are
|
|
1488
|
+
# joined by copy (parts encoded one by one leave an AAC frame's hole at every join), so
|
|
1489
|
+
# every segment is re-cut into one encode, without encoding the parts first
|
|
1490
|
+
late = [str(i + 1) for i, p in enumerate(plans) if p["reason"]]
|
|
1491
|
+
# the keyframes near each such segment's start, as a part judged on its own named them
|
|
1492
|
+
# in every 2.x release, so the caller can move the cut instead
|
|
1493
|
+
for (s, _), p in zip(segments, plans):
|
|
1494
|
+
if p["reason"]:
|
|
1495
|
+
NEAREST_KEYFRAMES.extend(k for k in keyframes_near(args.input, s) if k not in NEAREST_KEYFRAMES)
|
|
1496
|
+
info(f"segment{'s' if len(late) > 1 else ''} {', '.join(late)} {'have' if len(late) > 1 else 'has'} no keyframe "
|
|
1497
|
+
f"within {args.tolerance:.2f}s of {'their' if len(late) > 1 else 'its'} start or end to copy from; "
|
|
1498
|
+
"re-cutting every segment from the source into one re-encode")
|
|
1499
|
+
recut = "tolerance"
|
|
1500
|
+
if recut:
|
|
1501
|
+
outcomes = [_outcome(meta, output, True, ["tolerance"] if plans and plans[i]["reason"] else [])
|
|
1502
|
+
for i in range(len(segments))]
|
|
1503
|
+
else:
|
|
1504
|
+
parts = []
|
|
1505
|
+
for i, (s, e) in enumerate(segments):
|
|
1506
|
+
part = os.path.join(tmp, f"part{i:03d}{ext}")
|
|
1507
|
+
plan = plans[i] if plans else None
|
|
1508
|
+
outcomes.append(cut_one(args.input, s, e, part, False, args.crf, args.preset, args.tolerance, meta,
|
|
1509
|
+
edit_list_ok=False, copy_t=plan["t"] if plan and snap_end else None))
|
|
1510
|
+
parts.append(part)
|
|
1511
|
+
# a stream-copy join is only safe between identical copied parts: the concat demuxer
|
|
1512
|
+
# takes the first part's parameters for all of them, and a mismatch (a copied HEVC part
|
|
1513
|
+
# next to a re-encoded one, or H.264 next to HEVC) decodes with errors from a run that
|
|
1514
|
+
# exited 0; video parts that all re-encoded match, but leave an AAC frame's hole at each
|
|
1515
|
+
# join (audio decoded to PCM has no encoder priming, so those parts still join)
|
|
1516
|
+
compatible = (not (join_video and any(o["reencoded"] for o in outcomes))
|
|
1517
|
+
and (STATE.dry_run or signatures_match([join_signature(p) for p in parts], ext)))
|
|
1518
|
+
if compatible:
|
|
1519
|
+
listfile = os.path.join(tmp, "list.txt")
|
|
1520
|
+
with open(listfile, "w", encoding="utf-8") as fh:
|
|
1521
|
+
for p in parts:
|
|
1522
|
+
fh.write(concat_list_line(p) + "\n")
|
|
1523
|
+
# a join that is checked is written to a hidden file beside the output and renamed
|
|
1524
|
+
# into place only once it passes (copying it out of the temp directory cost about 2 s
|
|
1525
|
+
# for 240 MB): a failed check re-cuts, and a re-cut that then failed must not cost
|
|
1526
|
+
# the caller the file --overwrite would have replaced
|
|
1527
|
+
checked = bool(plans) and not STATE.dry_run
|
|
1528
|
+
joined = sibling_temp(output, "join") if checked else output
|
|
1529
|
+
if checked:
|
|
1530
|
+
temps.append(joined)
|
|
1531
|
+
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy"]
|
|
1532
|
+
if ext in (".mp4", ".mov", ".m4v"):
|
|
1533
|
+
cmd += ["-movflags", "+faststart"]
|
|
1534
|
+
cmd += [joined]
|
|
1535
|
+
proc = run(cmd, check=False)
|
|
1536
|
+
if proc.returncode != 0:
|
|
1537
|
+
info("concat with stream copy failed; re-cutting every segment from the source into one re-encode")
|
|
1538
|
+
compatible = False
|
|
1539
|
+
elif checked:
|
|
1540
|
+
# measured, not predicted: the joined file against the source packets its parts hold
|
|
1541
|
+
# constant frame timing: measured by the sampled guards, and under the default
|
|
1542
|
+
# average guard a copy is only reached when probe's rate check found it constant
|
|
1543
|
+
cfr = (vfr_check.get("measured") == "sampled_cfr" if vfr_check
|
|
1544
|
+
else args.vfr_guard == "average")
|
|
1545
|
+
counts = [expected_packets(packets, [(p["start_pts"], p["end_bound"])]) for p in plans]
|
|
1546
|
+
out_pts = stream_packet_times(joined, "v", "pts_time")
|
|
1547
|
+
join_check = check_join(out_pts, counts, fps, cfr, predicted_join_steps(parts))
|
|
1548
|
+
# the sound of each part against its picture (only once the pictures are right: the
|
|
1549
|
+
# parts are located in the join by their packet counts)
|
|
1550
|
+
video_ok = join_check["ok"]
|
|
1551
|
+
audio = (check_join_audio(args.input, joined, [p["start_pts"] for p in plans], counts, out_pts,
|
|
1552
|
+
copied_audio_streams(meta, joined),
|
|
1553
|
+
strip_adts=("mpegts" in str(meta.get("format") or "").split(",")
|
|
1554
|
+
and ext.lower() not in TS_EXTS))
|
|
1555
|
+
if video_ok and meta.get("audio") else
|
|
1556
|
+
{"audio_offset_ms": None, "audio_parts_checked": 0, "audio_ok": True})
|
|
1557
|
+
audio_ok = audio.pop("audio_ok")
|
|
1558
|
+
join_check["ok"] = video_ok and audio_ok
|
|
1559
|
+
join_check.update(audio)
|
|
1560
|
+
if not join_check["ok"]:
|
|
1561
|
+
step = join_check["max_step_seconds"]
|
|
1562
|
+
if video_ok:
|
|
1563
|
+
off = max((o for o in join_check["audio_offset_ms"] if o is not None), key=abs)
|
|
1564
|
+
why = (f"segment {join_check['audio_offset_ms'].index(off) + 1}'s sound is {abs(off):.1f} ms "
|
|
1565
|
+
f"{'late' if off > 0 else 'early'} against its picture")
|
|
1566
|
+
else:
|
|
1567
|
+
why = (f"{join_check['packets']} video packets where the segments hold "
|
|
1568
|
+
f"{join_check['expected_packets']} in the source"
|
|
1569
|
+
+ (f", largest step {step:.3f}s" if step is not None else ""))
|
|
1570
|
+
msg = f"the stream-copy join was checked and failed: {why}; every segment was re-cut from the source"
|
|
1571
|
+
info(msg)
|
|
1572
|
+
join_notes.append(msg)
|
|
1573
|
+
compatible = False
|
|
1574
|
+
else:
|
|
1575
|
+
place_output(joined, output, move=True)
|
|
1576
|
+
else:
|
|
1577
|
+
info("the cut parts cannot be joined by stream copy (a segment re-encoded, or the parts differ in "
|
|
1578
|
+
"codec parameters); re-cutting every segment from the source into one re-encode")
|
|
1579
|
+
if not compatible:
|
|
1580
|
+
join_from_source(args.input, segments, output, meta, args.crf, args.preset, tmp)
|
|
1581
|
+
join_reencoded = True
|
|
1582
|
+
# every segment was re-cut and re-encoded: none of them keeps its copy precision
|
|
1583
|
+
outcomes = [_outcome(meta, output, True, o["reasons"]) for o in outcomes]
|
|
368
1584
|
|
|
369
1585
|
result = probe(output, role="output")
|
|
370
|
-
expected = sum(e - s for s, e in
|
|
371
|
-
|
|
1586
|
+
expected = sum(e - s for s, e in requested)
|
|
1587
|
+
# the join counts: a copy join that fell back to a re-encode is not a stream copy (2.x reported
|
|
1588
|
+
# it as mode copy, precision packet)
|
|
1589
|
+
reencoded = join_reencoded or any(o["reencoded"] for o in outcomes)
|
|
1590
|
+
report = report_precision(meta, output, reencoded, outcomes)
|
|
1591
|
+
precision = report["precision"]
|
|
1592
|
+
reencode_reason: List[str] = []
|
|
1593
|
+
if reencoded:
|
|
1594
|
+
for r in forced + [r for o in outcomes for r in o["reasons"]] + (["concat_fallback"] if join_reencoded else []):
|
|
1595
|
+
if r not in reencode_reason:
|
|
1596
|
+
reencode_reason.append(r)
|
|
372
1597
|
got = result.get("duration")
|
|
373
1598
|
error_ms = round((got - expected) * 1000, 3) if got is not None and not STATE.dry_run else None
|
|
374
|
-
# mode: "copy" (
|
|
375
|
-
#
|
|
1599
|
+
# mode: "copy" (nothing re-encoded at any stage, the join included), "accurate" (--accurate was
|
|
1600
|
+
# asked for or forced), "hybrid" (asked for lossless but a segment or the join re-encoded anyway;
|
|
1601
|
+
# reencode_reason says why)
|
|
376
1602
|
mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
|
|
377
|
-
|
|
1603
|
+
notes: List[str] = vfr_notes + join_notes + [n for o in outcomes for n in o.get("notes") or []]
|
|
1604
|
+
single = outcomes[0] if len(outcomes) == 1 else {}
|
|
1605
|
+
stored = single.get("stored_preroll_seconds")
|
|
1606
|
+
if single.get("edit_list") and stored:
|
|
1607
|
+
notes.append(f"{stored:.3f}s of pre-roll is stored, hidden by the MP4 edit list; a player or "
|
|
1608
|
+
"tool that ignores edit lists will show it")
|
|
1609
|
+
# where each copied part's end moved to its keyframe; only for a join that is those parts
|
|
1610
|
+
end_snaps = None
|
|
1611
|
+
if join_check and join_check["ok"] and not join_reencoded:
|
|
1612
|
+
end_snaps = [None if o["reencoded"] or p["end_pts"] is None else round(p["end_pts"] - e, 3) + 0.0
|
|
1613
|
+
for o, p, (_, e) in zip(outcomes, plans, segments)]
|
|
1614
|
+
# a single MP4/MOV copy without --edit-list shows the pre-roll the edit list would hide
|
|
1615
|
+
remedy = (len(outcomes) == 1 and not reencoded and not single.get("edit_list") and single.get("start_snapped")
|
|
1616
|
+
and ext.lower() in EDIT_LIST_EXTS and not is_audio_output(output))
|
|
1617
|
+
# the source's own skew where the output's picture starts: at the keyframe the first copy
|
|
1618
|
+
# landed on, or at the start when an edit list hides the pre-roll (or a re-encode starts there)
|
|
1619
|
+
first = outcomes[0] if outcomes else {}
|
|
1620
|
+
at = (first["landed_pts"] if not reencoded and not first.get("edit_list") and first.get("landed_pts") is not None
|
|
1621
|
+
else segments[0][0])
|
|
1622
|
+
skew, skew_note = ((None, None) if STATE.dry_run
|
|
1623
|
+
else av_skew(result, bool(remedy), source_skew(args.input, meta, at)))
|
|
1624
|
+
if reencoded:
|
|
1625
|
+
# a re-encode starts both streams where the source had them at the start (the re-cut pads the
|
|
1626
|
+
# audio to each segment's origin): the skew is reported, but no copy began on unshared packets
|
|
1627
|
+
skew_note = None
|
|
1628
|
+
if skew_note:
|
|
1629
|
+
info(skew_note)
|
|
1630
|
+
notes.append(skew_note)
|
|
378
1631
|
info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
|
|
379
1632
|
+ ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
|
|
380
1633
|
emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
|
|
381
1634
|
dropped_non_av_streams=bool(DROPPED_STREAMS),
|
|
382
|
-
requested_start=round(
|
|
383
|
-
requested_end=round(
|
|
384
|
-
requested_segments=[[round(s, 6), round(e, 6)] for s, e in
|
|
1635
|
+
requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
|
|
1636
|
+
requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
|
|
1637
|
+
requested_segments=[[round(s, 6), round(e, 6)] for s, e in requested] if len(requested) > 1 else None,
|
|
385
1638
|
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
|
|
386
1639
|
duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
|
|
387
|
-
mode=mode, keyframe_snapped=keyframe_snapped,
|
|
1640
|
+
mode=mode, keyframe_snapped=report["keyframe_snapped"], start_snapped=report["start_snapped"],
|
|
1641
|
+
reencode_reason=reencode_reason, least_exact_precision=report["least_exact_precision"],
|
|
1642
|
+
segment_precision=report["segment_precision"],
|
|
1643
|
+
edit_list=bool(single.get("edit_list")), stored_preroll_seconds=stored, av_start_skew_seconds=skew,
|
|
1644
|
+
join_check=join_check, segment_end_snap_seconds=end_snaps,
|
|
1645
|
+
notes=notes, **({"vfr_check": vfr_check} if vfr_check else {}),
|
|
388
1646
|
nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None,
|
|
389
1647
|
# the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
|
|
390
1648
|
lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
|