ffmpeg-skill 2.7.0 → 2.8.0

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/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 concatenated with the concat demuxer.
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
- `requested_start`/`requested_end` (or `requested_segments` for --segments),
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 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, refuse_output_is_input, fmt_secs
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 video_args(meta, crf, preset) + cfr_args(meta) + aac_args()
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
- def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5, meta: dict = None) -> bool:
108
- """Cut one segment. Returns True if the result was re-encoded."""
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
- cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
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
- got = probe(dst).get("duration") or 0.0
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
- return reencode
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("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
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
- if meta.get("video", {}) and meta["video"].get("variable_frame_rate_suspected") and not args.accurate:
304
- info("source looks variable-frame-rate; lossless cuts on VFR are unreliable, switching to --accurate")
305
- args.accurate = True
306
- if STATE.codec and not args.accurate:
307
- # "cut this and make it HEVC": a stream copy keeps the source codec, so the request is a re-encode
308
- info(f"--codec {STATE.codec} asks for a re-encode; the lossless copy path keeps the source codec, switching to --accurate")
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
- reencoded = False
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
- reencoded = cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta)
348
- else:
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
- parts = []
351
- for i, (s, e) in enumerate(segments):
352
- part = os.path.join(tmp, f"part{i:03d}{ext}")
353
- reencoded |= cut_one(args.input, s, e, part, args.accurate, args.crf, args.preset, args.tolerance, meta)
354
- parts.append(part)
355
- listfile = os.path.join(tmp, "list.txt")
356
- with open(listfile, "w", encoding="utf-8") as fh:
357
- for p in parts:
358
- fh.write(concat_list_line(p) + "\n")
359
- cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy"]
360
- if ext in (".mp4", ".mov", ".m4v"):
361
- cmd += ["-movflags", "+faststart"]
362
- cmd += [output]
363
- proc = run(cmd, check=False)
364
- if proc.returncode != 0:
365
- info("concat with stream copy failed, re-encoding the join")
366
- cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + encode_args(meta, output, args.crf, args.preset) + [output]
367
- run(cmd)
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 segments)
371
- precision = precision_of(meta, output, reencoded)
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" (untouched lossless), "accurate" (--accurate was asked for), "hybrid" (asked for
375
- # lossless but the keyframe snap exceeded --tolerance so this segment silently re-encoded instead)
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
- keyframe_snapped = precision == "packet"
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(segments[0][0], 6) if len(segments) == 1 else None,
383
- requested_end=round(segments[0][1], 6) if len(segments) == 1 else None,
384
- requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
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: "