ffmpeg-skill 2.23.1 → 2.24.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/export.py CHANGED
@@ -31,12 +31,14 @@ Examples:
31
31
  import argparse
32
32
  import json
33
33
  import math
34
+ import re
34
35
  import os
35
36
  import sys
37
+ import tempfile
36
38
  from pathlib import Path
37
39
  from typing import Dict, List
38
40
 
39
- from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
41
+ from _common import STATE, run_analysis, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
40
42
  from _common.decision import hw_preset_video, restate_last_swap
41
43
  from _common.emit import note, record_fallback, record_skip, warn
42
44
  from check import SPECS as PLATFORMS, measure_loudness
@@ -239,6 +241,57 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
239
241
  return {"kept": kept, "dropped": gone}
240
242
 
241
243
 
244
+ # --repair (2.24): regenerate missing timestamps, drop packets flagged corrupt, keep demuxing past errors
245
+ REPAIR_INPUT_ARGS = ["-fflags", "+genpts+discardcorrupt", "-err_detect", "ignore_err"]
246
+ _TIMESTAMP_WARNING = re.compile(r"(?i)\b(dts|pts)\b|timestamp")
247
+
248
+
249
+ def media_damage(path: str) -> Dict[str, int]:
250
+ """One full decode of `path` at -v warning: lines about timestamps (non-monotonic DTS, invalid
251
+ PTS) and every other error line, counted. What --repair is measured by, before and after."""
252
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "warning", "-i", path, "-f", "null", "-"],
253
+ check=False, record=True)
254
+ lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
255
+ stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln))
256
+ return {"timestamp_warnings": stamps, "decode_errors": len(lines) - stamps}
257
+
258
+
259
+ MIB = 1024 * 1024
260
+ SIZE_HEADROOM = 0.97 # of the byte budget given to the streams; the rest is container overhead
261
+ SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; refused instead
262
+
263
+
264
+ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
265
+ """--max-mb: the preset's x264 line with its CRF replaced by the bitrate that fits the size,
266
+ for a two-pass encode. Refused where that cannot be done honestly: another codec, --crf, --hw,
267
+ a source with no duration, or a size too small for a watchable picture."""
268
+ if args.crf is not None:
269
+ die("--max-mb sets the bitrate from the size, --crf sets a quality: give one of them")
270
+ if "-c:v" not in video or video[video.index("-c:v") + 1] != "libx264" or "-crf" not in video:
271
+ die(f"--max-mb runs a two-pass x264 encode; --preset {args.preset} does not encode with x264"
272
+ + (" (--hw puts it on VideoToolbox, which has no two-pass mode)" if STATE.hw else ""))
273
+ if not math.isfinite(args.max_mb) or args.max_mb <= 0:
274
+ die(f"--max-mb must be a positive number of MiB, got {args.max_mb:g}")
275
+ dur = float(meta.get("duration") or 0.0)
276
+ if p["max"] and not args.allow_long:
277
+ dur = min(dur, float(p["max"]))
278
+ if dur <= 0:
279
+ die("--max-mb needs the source's duration, which it does not report")
280
+ audio_kbps = 0.0
281
+ if has_audio and "-b:a" in p["audio"]:
282
+ audio_kbps = float(p["audio"][p["audio"].index("-b:a") + 1].rstrip("k"))
283
+ video_kbps = args.max_mb * MIB * 8 * SIZE_HEADROOM / dur / 1000 - audio_kbps
284
+ if video_kbps < SIZE_MIN_VIDEO_KBPS:
285
+ die(f"--max-mb {args.max_mb:g} leaves {max(video_kbps, 0):.0f} kb/s for the picture over {dur:.1f}s "
286
+ f"(after {audio_kbps:.0f} kb/s of audio); under {SIZE_MIN_VIDEO_KBPS} kb/s it is not watchable",
287
+ hint="raise --max-mb, or cut the video shorter first")
288
+ out = list(video)
289
+ i = out.index("-crf")
290
+ out[i:i + 2] = ["-b:v", f"{video_kbps:.0f}k"]
291
+ info(f"--max-mb {args.max_mb:g}: {video_kbps:.0f} kb/s video + {audio_kbps:.0f} kb/s audio over {dur:.1f}s, two-pass")
292
+ return out
293
+
294
+
242
295
  def main() -> int:
243
296
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
244
297
  ap.add_argument("input", nargs="?")
@@ -250,6 +303,13 @@ def main() -> int:
250
303
  ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
251
304
  ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
252
305
  ap.add_argument("--crf", type=int, help="override CRF")
306
+ ap.add_argument("--repair", action="store_true",
307
+ help="--preset copy only: remux a damaged file -- timestamps regenerated, corrupt packets dropped, "
308
+ "negative timestamps shifted to zero -- and count the decode errors and timestamp warnings "
309
+ "before and after; the input is never touched")
310
+ ap.add_argument("--max-mb", type=float, metavar="MB",
311
+ help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
312
+ "running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
253
313
  ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
254
314
  ap.add_argument("--list", action="store_true", help="list presets and exit")
255
315
  add_common(ap, codec=False) # the preset decides the codec; --codec would only be refused
@@ -303,7 +363,9 @@ def main() -> int:
303
363
  vf.append("setsar=1")
304
364
  else:
305
365
  vf.append(f"scale={p['w']}:-2")
306
- cmd = ffmpeg_base() + ["-i", args.input]
366
+ if args.repair and args.preset != "copy":
367
+ die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
368
+ cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + ["-i", args.input]
307
369
  # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
308
370
  # kept where the container can hold them: ffmpeg's default stream selection takes one video
309
371
  # and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
@@ -354,6 +416,8 @@ def main() -> int:
354
416
  # A build without libx264/libx265 has no other encoder, so it goes there without --hw too
355
417
  # (hw_preset_video returns the line untouched in every other case)
356
418
  video = hw_preset_video(video, meta, bt709=tag_bt709)
419
+ if args.max_mb is not None:
420
+ video = size_video_args(args, video, p, meta, has_audio)
357
421
  cmd += video
358
422
  if args.preset != "copy":
359
423
  # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
@@ -369,6 +433,8 @@ def main() -> int:
369
433
  cmd += bt709_matrix_args(meta)
370
434
  if out_ext == "mp4":
371
435
  cmd += ["-movflags", "+faststart"]
436
+ if args.repair:
437
+ cmd += ["-avoid_negative_ts", "make_zero"]
372
438
  cmd += (p["audio"] if has_audio else ["-an"])
373
439
  trimmed = None
374
440
  if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
@@ -379,9 +445,16 @@ def main() -> int:
379
445
  note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
380
446
  "(use --allow-long to keep full length)")
381
447
  cmd += ["-t", f"{p['max']:.3f}"]
382
- dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
383
- if not (subs or data):
384
- run(cmd + [output])
448
+ damage_before = media_damage(args.input) if args.repair and not STATE.dry_run else None
449
+ with tempfile.TemporaryDirectory(prefix="ffskill_2pass_") as passdir:
450
+ if args.max_mb is not None:
451
+ # pass 1 only writes x264's statistics; pass 2 encodes against them
452
+ log = os.path.join(passdir, "x264")
453
+ run(cmd + ["-pass", "1", "-passlogfile", log, "-an", "-sn", "-dn", "-f", "null", "-"])
454
+ cmd += ["-pass", "2", "-passlogfile", log]
455
+ dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
456
+ if not (subs or data):
457
+ run(cmd + [output])
385
458
  result = probe(output, role="output")
386
459
  v = result["video"]
387
460
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
@@ -463,6 +536,31 @@ def main() -> int:
463
536
  step = {"step": "duration", "property": "duration", "ok": False, "expected": {"max": p["max"]},
464
537
  "reason": "not_measured: the written file reports no duration"}
465
538
  extra.setdefault("verification", []).append(step)
539
+ if damage_before is not None:
540
+ after = media_damage(output)
541
+ extra["repair"] = {"before": damage_before, "after": after}
542
+ # judged on timestamp warnings, the part a remux rewrites: decode errors are counted in log
543
+ # lines, and the same damaged frame logs a different number of them demuxed from another
544
+ # container, so their before/after is reported, not compared
545
+ stamps = after["timestamp_warnings"]
546
+ extra.setdefault("verification", []).append(
547
+ {"step": "repair", "property": "timestamp_warnings", "ok": stamps <= damage_before["timestamp_warnings"],
548
+ "expected": {"max": damage_before["timestamp_warnings"]}, "measured": stamps,
549
+ "provenance": "input_premeasurement"})
550
+ if after["decode_errors"]:
551
+ notes.append(f"--repair: {after['decode_errors']} decode error(s) remain: a remux drops corrupt packets and "
552
+ "rewrites timestamps but cannot rebuild a damaged frame; a re-encoding preset decodes past them")
553
+ warn(notes[-1], "repair_incomplete", **after)
554
+ if args.max_mb is not None and not STATE.dry_run:
555
+ limit = int(args.max_mb * MIB)
556
+ size = os.path.getsize(output)
557
+ extra.setdefault("verification", []).append(
558
+ {"step": "file_size", "property": "size_bytes", "ok": size <= limit, "expected": {"max": limit},
559
+ "measured": size, "delta": size - limit, "tolerance": 0, "provenance": "user_specified"})
560
+ if size > limit:
561
+ notes.append(f"--max-mb {args.max_mb:g}: the file is {size / MIB:.2f} MiB, over the limit; the encoder overshot "
562
+ f"its target bitrate on this content: export again with a lower --max-mb")
563
+ warn(notes[-1], "size_over_limit", size=size, limit=limit)
466
564
  # read back after --normalize, which rewrote the file
467
565
  non_av = _non_av_report(subs, sub_codecs, data, dropped, output, "write_failed")
468
566
  if non_av["dropped"]:
package/scripts/join.py CHANGED
@@ -35,12 +35,14 @@ fail refuses it with the other input problems; skip drops it into `skipped`. A c
35
35
  audio stream is never silent.
36
36
  """
37
37
  import argparse
38
+ import json
38
39
  import os
40
+ import tempfile
39
41
  import sys
40
42
  from typing import Any, Dict, List, Optional, Tuple
41
43
 
42
- from _common.probe import SILENT_PEAK_DBFS
43
- from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
44
+ from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
45
+ from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
44
46
  from _common.emit import warn
45
47
 
46
48
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
@@ -305,6 +307,155 @@ def parse_allow_silent(value: Optional[str], n: int) -> List[int]:
305
307
  return out
306
308
 
307
309
 
310
+ # join.py --copy (2.24). The concat demuxer starts each clip where the previous one's container
311
+ # ends, so a stream that ends early leaves a gap at the join: a held frame for the picture, a hole
312
+ # for the sound. A clip whose picture and sound end further apart than this is refused.
313
+ COPY_AV_GAP = 0.05
314
+ COPY_PREROLL_PACKETS = 300 # packets read per clip to find an edit list's hidden pre-roll
315
+
316
+
317
+ def _copy_doc(path: str) -> "Optional[Dict[str, Any]]":
318
+ """ffprobe's first video and first audio stream of `path`, with extradata hashes, as the doc
319
+ stream_signature() reads; None when it cannot be read."""
320
+ proc = run([require_tool("ffprobe"), "-v", "error", "-show_data_hash", "sha256", "-show_streams", "-show_format",
321
+ "-of", "json", path], quiet=True, check=False)
322
+ try:
323
+ doc = json.loads(proc.stdout or "") if proc.returncode == 0 else None
324
+ except ValueError:
325
+ return None
326
+ if not isinstance(doc, dict):
327
+ return None
328
+ # attached pictures (cover art) are not the picture being joined, as V:0 skips them
329
+ every = doc.get("streams") or []
330
+ streams = [st for st in every if not (st.get("disposition") or {}).get("attached_pic")]
331
+ keep = [next((st for st in streams if st.get("codec_type") == kind), None) for kind in ("video", "audio")]
332
+ doc["streams"] = [st for st in keep if st]
333
+ # the concat demuxer maps every clip's packets by stream index, laid out as the first clip's
334
+ doc["layout"] = {"streams": len(every), "indices": [st.get("index") for st in doc["streams"]]}
335
+ return doc
336
+
337
+
338
+ def _video_packets(path: str, limit: "Optional[int]" = None) -> "List[str]":
339
+ """The flags of the first video stream's packets (all of them, or the first `limit`); cover art
340
+ is not that stream."""
341
+ cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", "V:0", "-show_entries", "packet=flags", "-of", "csv=p=0"]
342
+ if limit:
343
+ cmd += ["-read_intervals", f"%+#{limit}"]
344
+ proc = run(cmd + [path], quiet=True, check=False)
345
+ return (proc.stdout or "").split() if proc.returncode == 0 else []
346
+
347
+
348
+ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
349
+ """--copy: the concat demuxer with -c copy, after checking that the clips can be joined that
350
+ way, and measured afterwards (video packet count and length against the clips')."""
351
+ if args.transition != "none":
352
+ die("--copy joins without a transition: pass --transition none (a transition re-encodes the frames it blends)")
353
+ if args.width or args.height or args.fps:
354
+ die("--copy keeps every clip's own frame and rate: --width, --height and --fps need a re-encode, so drop --copy")
355
+ if args.sample_rate or args.channels:
356
+ die("--copy keeps every clip's own sound: --sample-rate and --channels need a re-encode, so drop --copy")
357
+ output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
358
+ guard_sidecar(output, *args.inputs)
359
+ ext = os.path.splitext(output)[1].lower()
360
+ n = len(args.inputs)
361
+ pending = any(m.get("dry_run") for m in metas)
362
+ if pending:
363
+ STATE_NOTES.append("--copy: an input does not exist yet, so whether the clips match is checked when a real run has them")
364
+ else:
365
+ docs = [_copy_doc(p) for p in args.inputs]
366
+ unreadable = [p for p, d in zip(args.inputs, docs) if d is None]
367
+ if unreadable:
368
+ die(f"--copy: could not read the streams of {', '.join(unreadable)}")
369
+ moved = [{"index": i, "path": p, "differs": {"stream_layout": [docs[0]["layout"], d["layout"]]}}
370
+ for i, (p, d) in enumerate(zip(args.inputs, docs)) if i and d["layout"] != docs[0]["layout"]]
371
+ if moved:
372
+ refuse("input", "join_copy_mismatch",
373
+ f"--copy: {', '.join(m['path'] for m in moved)} lay(s) out their streams differently from "
374
+ f"{args.inputs[0]} (count or order, cover art included); the concat demuxer maps streams by position",
375
+ hint="drop --copy to re-encode them to one layout", clips=moved)
376
+ sigs = [stream_signature(d) for d in docs]
377
+ mismatched = [{"index": i, "path": p, "differs": {k: [a.get(k), b.get(k)] for a, b in zip(sigs[0], sig)
378
+ for k in a if k != "extradata_hash" and a.get(k) != b.get(k)}
379
+ if len(sig) == len(sigs[0]) else {"streams": [len(sigs[0]), len(sig)]}}
380
+ for i, (p, sig) in enumerate(zip(args.inputs, sigs)) if i and not signatures_match([sigs[0], sig], ext)]
381
+ if mismatched:
382
+ listing = "; ".join(f"#{m['index'] + 1} {m['path']}: " + (", ".join(f"{k} {v[1]} (not {v[0]})" for k, v in m["differs"].items())
383
+ or "codec configuration (extradata) differs")
384
+ for m in mismatched)
385
+ refuse("input", "join_copy_mismatch", f"--copy: {len(mismatched)} clip(s) do not match the first one: {listing}",
386
+ hint="drop --copy to re-encode them to one format", clips=mismatched)
387
+ hidden = [p for p in args.inputs if "D" in "".join(_video_packets(p, COPY_PREROLL_PACKETS))]
388
+ if hidden:
389
+ refuse("input", "join_copy_mismatch",
390
+ f"--copy: {', '.join(hidden)} hide(s) frames before their start (an edit list), which a copy join would show",
391
+ hint="drop --copy, or re-cut those clips with cut.py --accurate", clips=[{"path": p, "differs": {"edit_list": True}} for p in hidden])
392
+ gaps = []
393
+ for p, m in zip(args.inputs, metas):
394
+ # each stream's start, and its end (start plus length) where the container states one;
395
+ # Matroska/WebM give no stream length, but the starts still have to agree
396
+ vs, as_ = m.get("video") or {}, m.get("audio") or {}
397
+ if not (vs and as_):
398
+ continue
399
+ vb, ab = float(vs.get("start_time") or 0.0), float(as_.get("start_time") or 0.0)
400
+ vd, ad = vs.get("duration"), as_.get("duration")
401
+ ve, ae = (vb + vd if vd else None), (ab + ad if ad else None)
402
+ if abs(vb - ab) > COPY_AV_GAP or (ve is not None and ae is not None and abs(ve - ae) > COPY_AV_GAP):
403
+ gaps.append({"path": p, "video": [round(vb, 3), None if ve is None else round(ve, 3)],
404
+ "audio": [round(ab, 3), None if ae is None else round(ae, 3)]})
405
+ if gaps:
406
+ def span(x):
407
+ return f"{x[0]:.3f}-" + ("?" if x[1] is None else f"{x[1]:.3f}") + "s"
408
+ listing = "; ".join(f"{g['path']} (video {span(g['video'])}, audio {span(g['audio'])})" for g in gaps)
409
+ refuse("input", "join_copy_mismatch",
410
+ f"--copy: picture and sound start or end more than {COPY_AV_GAP * 1000:.0f} ms apart in {listing}; a copy join "
411
+ "would leave that gap at the next clip", hint="drop --copy, which trims both streams to one length",
412
+ clips=[{"path": g["path"], "differs": {"video_span": g["video"], "audio_span": g["audio"]}} for g in gaps])
413
+ fps = (metas[0].get("video") or {}).get("fps") or 30.0
414
+ note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
415
+ place_silent([m.get("duration") or 0.0 for m in metas], 0.0)
416
+ with tempfile.TemporaryDirectory(prefix="ffskill_join_") as tmp:
417
+ listfile = os.path.join(tmp, "list.txt")
418
+ with open(listfile, "w", encoding="utf-8") as fh:
419
+ for p in args.inputs:
420
+ fh.write(concat_list_line(os.path.abspath(p)) + "\n")
421
+ joined = sibling_temp(output, "join") if not STATE.dry_run else output
422
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-map", "0:V:0", "-map", "0:a:0?", "-c", "copy"]
423
+ if ext in (".mp4", ".mov", ".m4v"):
424
+ cmd += ["-movflags", "+faststart"]
425
+ try:
426
+ run(cmd + [joined])
427
+ verification: List[Dict[str, Any]] = []
428
+ if not STATE.dry_run:
429
+ want = sum(len(_video_packets(p)) for p in args.inputs)
430
+ got = len(_video_packets(joined))
431
+ # nothing counted on either side is a failed read, not a match
432
+ verification.append({"step": "join", "property": "video_packets", "ok": got == want and want > 0,
433
+ "measured": got, "expected": want, "provenance": "input_premeasurement"})
434
+ r = probe(joined, role="output")
435
+ measured = (r.get("video") or {}).get("duration") or r.get("duration")
436
+ expected = round(sum(m.get("duration") or 0.0 for m in metas), 3)
437
+ tol = round(n * COPY_AV_GAP + 1.0 / fps, 3)
438
+ verification.append({"step": "join", "property": "video_duration",
439
+ "ok": measured is not None and abs(measured - expected) <= tol,
440
+ "measured": None if measured is None else round(measured, 3), "expected": expected,
441
+ "tolerance": tol, "provenance": "input_premeasurement"})
442
+ bad = [v for v in verification if not v["ok"]]
443
+ if bad:
444
+ die("--copy: the joined file does not hold the clips it was given: "
445
+ + "; ".join(f"{v['property']} {v['measured']} (expected {v['expected']})" for v in bad),
446
+ kind="verification", hint="drop --copy to re-encode the join", verification=verification)
447
+ place_output(joined, output, move=True)
448
+ finally:
449
+ if joined != output and os.path.exists(joined):
450
+ os.remove(joined)
451
+ r = probe(output, role="output")
452
+ info(f"wrote {output} ({fmt_secs(r.get('duration'))}, {n} clips, stream copy)")
453
+ emit(output, mode="copy", **reported(), clips=n, transition="none", reencoded=False,
454
+ verification=verification, expect_streams={"video": 1, "audio": 1 if all(m.get("audio") for m in metas) else 0},
455
+ stream_reference=metas)
456
+ return 0
457
+
458
+
308
459
  def main() -> int:
309
460
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
310
461
  ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
@@ -329,6 +480,10 @@ def main() -> int:
329
480
  ap.add_argument("--height", type=int, help="output height (default: first clip)")
330
481
  ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
331
482
  ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
483
+ ap.add_argument("--copy", action="store_true",
484
+ help="join by stream copy, no re-encode (needs --transition none): every clip must carry the same "
485
+ "video and audio parameters (codec, size, rate, pixel format, sample rate, channels); a clip "
486
+ "that does not is refused, by name, with what differs")
332
487
  ap.add_argument("--pad-color", default="black")
333
488
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
334
489
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
@@ -433,6 +588,8 @@ def main() -> int:
433
588
  for p, m in zip(args.inputs, metas):
434
589
  if not m.get("audio"):
435
590
  die(f"{p} has neither a video nor an audio stream")
591
+ if args.copy:
592
+ die("--copy joins video clips; an audio-only join resamples every clip to one format, which a copy cannot do")
436
593
  return join_audio(args, metas)
437
594
  for p, m, pic in zip(args.inputs, metas, pictured):
438
595
  if not pic:
@@ -444,6 +601,8 @@ def main() -> int:
444
601
  die((f"{p} does not exist yet, and its audio extension says it will have no video stream" if m.get("dry_run")
445
602
  else f"{p} has no video stream")
446
603
  + (f" while {other}; join audio with audio or give every clip a picture" if other else ""))
604
+ if args.copy:
605
+ return join_copy(args, metas)
447
606
  first = metas[0]["video"]
448
607
  fw, fh = first["width"], first["height"]
449
608
  if first.get("rotation") in (90, -90, 270, -270):