ffmpeg-skill 2.23.1 → 2.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,64 @@ 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
+ # repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
253
+ # N timestamp warnings and was itself counted as a decode error
254
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path, "-f", "null", "-"],
255
+ check=False, record=True)
256
+ lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
257
+ # "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
258
+ stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln) and "corrupt" not in ln.lower())
259
+ return {"timestamp_warnings": stamps, "decode_errors": len(lines) - stamps}
260
+
261
+
262
+ MIB = 1024 * 1024
263
+ SIZE_HEADROOM = 0.97 # of the byte budget given to the streams; the rest is container overhead
264
+ SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; refused instead
265
+ SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
266
+
267
+
268
+ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
269
+ """--max-mb: the preset's x264 line with its CRF replaced by the bitrate that fits the size,
270
+ for a two-pass encode. Refused where that cannot be done honestly: another codec, --crf, --hw,
271
+ a source with no duration, or a size too small for a watchable picture."""
272
+ if args.crf is not None:
273
+ die("--max-mb sets the bitrate from the size, --crf sets a quality: give one of them")
274
+ if "-c:v" not in video or video[video.index("-c:v") + 1] != "libx264" or "-crf" not in video:
275
+ die(f"--max-mb runs a two-pass x264 encode; --preset {args.preset} does not encode with x264"
276
+ + (" (--hw puts it on VideoToolbox, which has no two-pass mode)" if STATE.hw else ""))
277
+ if not math.isfinite(args.max_mb) or args.max_mb <= 0:
278
+ die(f"--max-mb must be a positive number of MiB, got {args.max_mb:g}")
279
+ dur = float(meta.get("duration") or 0.0)
280
+ if p["max"] and not args.allow_long:
281
+ dur = min(dur, float(p["max"]))
282
+ if dur <= 0:
283
+ die("--max-mb needs the source's duration, which it does not report")
284
+ audio_kbps = 0.0
285
+ if has_audio and "-b:a" in p["audio"]:
286
+ audio_kbps = float(p["audio"][p["audio"].index("-b:a") + 1].rstrip("k"))
287
+ video_kbps = args.max_mb * MIB * 8 * SIZE_HEADROOM / dur / 1000 - audio_kbps
288
+ if video_kbps > SIZE_MAX_VIDEO_KBPS:
289
+ die(f"--max-mb {args.max_mb:g} allows {video_kbps / 1000:.0f} Mb/s over {dur:.1f}s, beyond any delivery bitrate; "
290
+ "a limit that large constrains nothing", hint="drop --max-mb (the preset's quality setting applies)")
291
+ if video_kbps < SIZE_MIN_VIDEO_KBPS:
292
+ die(f"--max-mb {args.max_mb:g} leaves {max(video_kbps, 0):.0f} kb/s for the picture over {dur:.1f}s "
293
+ f"(after {audio_kbps:.0f} kb/s of audio); under {SIZE_MIN_VIDEO_KBPS} kb/s it is not watchable",
294
+ hint="raise --max-mb, or cut the video shorter first")
295
+ out = list(video)
296
+ i = out.index("-crf")
297
+ out[i:i + 2] = ["-b:v", f"{video_kbps:.0f}k"]
298
+ 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")
299
+ return out
300
+
301
+
242
302
  def main() -> int:
243
303
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
244
304
  ap.add_argument("input", nargs="?")
@@ -250,6 +310,13 @@ def main() -> int:
250
310
  ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
251
311
  ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
252
312
  ap.add_argument("--crf", type=int, help="override CRF")
313
+ ap.add_argument("--repair", action="store_true",
314
+ help="--preset copy only: remux a damaged file -- timestamps regenerated, corrupt packets dropped, "
315
+ "negative timestamps shifted to zero -- and count the decode errors and timestamp warnings "
316
+ "before and after; the input is never touched")
317
+ ap.add_argument("--max-mb", type=float, metavar="MB",
318
+ help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
319
+ "running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
253
320
  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
321
  ap.add_argument("--list", action="store_true", help="list presets and exit")
255
322
  add_common(ap, codec=False) # the preset decides the codec; --codec would only be refused
@@ -303,7 +370,12 @@ def main() -> int:
303
370
  vf.append("setsar=1")
304
371
  else:
305
372
  vf.append(f"scale={p['w']}:-2")
306
- cmd = ffmpeg_base() + ["-i", args.input]
373
+ if args.repair and args.preset != "copy":
374
+ die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
375
+ if args.max_mb is not None and args.preset == "gif":
376
+ # the GIF branch below returns before the size is ever applied or measured
377
+ die("--max-mb runs a two-pass x264 encode; --preset gif does not encode with x264 (shorten or shrink the GIF instead)")
378
+ cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + ["-i", args.input]
307
379
  # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
308
380
  # kept where the container can hold them: ffmpeg's default stream selection takes one video
309
381
  # and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
@@ -354,6 +426,8 @@ def main() -> int:
354
426
  # A build without libx264/libx265 has no other encoder, so it goes there without --hw too
355
427
  # (hw_preset_video returns the line untouched in every other case)
356
428
  video = hw_preset_video(video, meta, bt709=tag_bt709)
429
+ if args.max_mb is not None:
430
+ video = size_video_args(args, video, p, meta, has_audio)
357
431
  cmd += video
358
432
  if args.preset != "copy":
359
433
  # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
@@ -369,6 +443,8 @@ def main() -> int:
369
443
  cmd += bt709_matrix_args(meta)
370
444
  if out_ext == "mp4":
371
445
  cmd += ["-movflags", "+faststart"]
446
+ if args.repair:
447
+ cmd += ["-avoid_negative_ts", "make_zero"]
372
448
  cmd += (p["audio"] if has_audio else ["-an"])
373
449
  trimmed = None
374
450
  if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
@@ -379,9 +455,16 @@ def main() -> int:
379
455
  note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
380
456
  "(use --allow-long to keep full length)")
381
457
  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])
458
+ damage_before = media_damage(args.input) if args.repair and not STATE.dry_run else None
459
+ with tempfile.TemporaryDirectory(prefix="ffskill_2pass_") as passdir:
460
+ if args.max_mb is not None:
461
+ # pass 1 only writes x264's statistics; pass 2 encodes against them
462
+ log = os.path.join(passdir, "x264")
463
+ run(cmd + ["-pass", "1", "-passlogfile", log, "-an", "-sn", "-dn", "-f", "null", "-"])
464
+ cmd += ["-pass", "2", "-passlogfile", log]
465
+ dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
466
+ if not (subs or data):
467
+ run(cmd + [output])
385
468
  result = probe(output, role="output")
386
469
  v = result["video"]
387
470
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
@@ -463,6 +546,31 @@ def main() -> int:
463
546
  step = {"step": "duration", "property": "duration", "ok": False, "expected": {"max": p["max"]},
464
547
  "reason": "not_measured: the written file reports no duration"}
465
548
  extra.setdefault("verification", []).append(step)
549
+ if damage_before is not None:
550
+ after = media_damage(output)
551
+ extra["repair"] = {"before": damage_before, "after": after}
552
+ # judged on timestamp warnings, the part a remux rewrites: decode errors are counted in log
553
+ # lines, and the same damaged frame logs a different number of them demuxed from another
554
+ # container, so their before/after is reported, not compared
555
+ stamps = after["timestamp_warnings"]
556
+ extra.setdefault("verification", []).append(
557
+ {"step": "repair", "property": "timestamp_warnings", "ok": stamps <= damage_before["timestamp_warnings"],
558
+ "expected": {"max": damage_before["timestamp_warnings"]}, "measured": stamps,
559
+ "provenance": "input_premeasurement"})
560
+ if after["decode_errors"]:
561
+ notes.append(f"--repair: {after['decode_errors']} decode error(s) remain: a remux drops corrupt packets and "
562
+ "rewrites timestamps but cannot rebuild a damaged frame; a re-encoding preset decodes past them")
563
+ warn(notes[-1], "repair_incomplete", **after)
564
+ if args.max_mb is not None and not STATE.dry_run:
565
+ limit = int(args.max_mb * MIB)
566
+ size = os.path.getsize(output)
567
+ extra.setdefault("verification", []).append(
568
+ {"step": "file_size", "property": "size_bytes", "ok": size <= limit, "expected": {"max": limit},
569
+ "measured": size, "delta": size - limit, "tolerance": 0, "provenance": "user_specified"})
570
+ if size > limit:
571
+ notes.append(f"--max-mb {args.max_mb:g}: the file is {size / MIB:.2f} MiB, over the limit; the encoder overshot "
572
+ f"its target bitrate on this content: export again with a lower --max-mb")
573
+ warn(notes[-1], "size_over_limit", size=size, limit=limit)
466
574
  # read back after --normalize, which rewrote the file
467
575
  non_av = _non_av_report(subs, sub_codecs, data, dropped, output, "write_failed")
468
576
  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,204 @@ 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
+ SPAN_HEAD_PACKETS = 64 # enough to see the earliest presentation time past B-frame reordering
349
+ SPAN_TAIL_SECONDS = 10.0 # read from this far before the end, so a seek that lands early still reaches it
350
+
351
+
352
+ def _stream_span(path: str, kind: str, duration: float = 0.0, start: float = 0.0) -> "Optional[Tuple[float, float]]":
353
+ """First packet's time and last packet's time plus its duration for the first `kind` stream
354
+ (V or a): measured, because Matroska/WebM state no stream length and MPEG-TS only an estimate.
355
+ None when the packets cannot be read or one has no time: a partial span would invent a gap."""
356
+ # JSON, not csv: older ffprobe builds (5.1) print side-data rows between the packet rows in csv.
357
+ # A long clip is read at its head and its tail only: every packet of a two-hour file is
358
+ # hundreds of thousands of entries for two numbers
359
+ cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", f"{kind}:0", "-show_entries",
360
+ "packet=pts_time,dts_time,duration_time", "-of", "json"]
361
+ if duration > 3 * SPAN_TAIL_SECONDS:
362
+ # read_intervals takes timestamps, so the tail starts from the file's own start time
363
+ # (an MPEG-TS clock can start hours in; an edit list can start it below zero)
364
+ cmd += ["-read_intervals", f"%+#{SPAN_HEAD_PACKETS},{start + duration - SPAN_TAIL_SECONDS:.3f}%"]
365
+ proc = run(cmd + [path], quiet=True, check=False)
366
+ try:
367
+ packets = json.loads(proc.stdout or "{}").get("packets") or [] if proc.returncode == 0 else []
368
+ except ValueError:
369
+ return None
370
+ times = []
371
+ for pkt in packets:
372
+ try:
373
+ # a packet ffprobe gives no pts (some muxers leave it on B-frames) still has its dts
374
+ t = float(pkt.get("pts_time", pkt.get("dts_time")))
375
+ except (TypeError, ValueError):
376
+ return None
377
+ try:
378
+ d = float(pkt.get("duration_time") or 0.0)
379
+ except (TypeError, ValueError):
380
+ d = 0.0
381
+ times.append((t, t + d))
382
+ if not times:
383
+ return None
384
+ return min(t for t, _ in times), max(e for _, e in times)
385
+
386
+
387
+ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
388
+ """--copy: the concat demuxer with -c copy, after checking that the clips can be joined that
389
+ way, and measured afterwards (video packet count and length against the clips')."""
390
+ if args.transition != "none":
391
+ die("--copy joins without a transition: pass --transition none (a transition re-encodes the frames it blends)")
392
+ if args.width or args.height or args.fps:
393
+ die("--copy keeps every clip's own frame and rate: --width, --height and --fps need a re-encode, so drop --copy")
394
+ if args.sample_rate or args.channels:
395
+ die("--copy keeps every clip's own sound: --sample-rate and --channels need a re-encode, so drop --copy")
396
+ encoder = [f for f, on in (("--codec", STATE.codec), ("--hw", STATE.hw and STATE.hw_source == "flag"),
397
+ ("--quality", getattr(args, "quality_given", False)))
398
+ if on]
399
+ if encoder:
400
+ die(f"--copy keeps every clip's own encoding: {', '.join(encoder)} choose(s) an encode, so drop --copy")
401
+ output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
402
+ guard_sidecar(output, *args.inputs)
403
+ ext = os.path.splitext(output)[1].lower()
404
+ n = len(args.inputs)
405
+ pending = any(m.get("dry_run") for m in metas)
406
+ if pending:
407
+ STATE_NOTES.append("--copy: an input does not exist yet, so whether the clips match is checked when a real run has them")
408
+ else:
409
+ docs = [_copy_doc(p) for p in args.inputs]
410
+ unreadable = [p for p, d in zip(args.inputs, docs) if d is None]
411
+ if unreadable:
412
+ die(f"--copy: could not read the streams of {', '.join(unreadable)}")
413
+ moved = [{"index": i, "path": p, "differs": {"stream_layout": [docs[0]["layout"], d["layout"]]}}
414
+ for i, (p, d) in enumerate(zip(args.inputs, docs)) if i and d["layout"] != docs[0]["layout"]]
415
+ if moved:
416
+ refuse("input", "join_copy_mismatch",
417
+ f"--copy: {', '.join(m['path'] for m in moved)} lay(s) out their streams differently from "
418
+ f"{args.inputs[0]} (count or order, cover art included); the concat demuxer maps streams by position",
419
+ hint="drop --copy to re-encode them to one layout", clips=moved)
420
+ sigs = [stream_signature(d) for d in docs]
421
+ mismatched = [{"index": i, "path": p, "differs": {k: [a.get(k), b.get(k)] for a, b in zip(sigs[0], sig)
422
+ for k in a if k != "extradata_hash" and a.get(k) != b.get(k)}
423
+ if len(sig) == len(sigs[0]) else {"streams": [len(sigs[0]), len(sig)]}}
424
+ for i, (p, sig) in enumerate(zip(args.inputs, sigs)) if i and not signatures_match([sigs[0], sig], ext)]
425
+ if mismatched:
426
+ listing = "; ".join(f"#{m['index'] + 1} {m['path']}: " + (", ".join(f"{k} {v[1]} (not {v[0]})" for k, v in m["differs"].items())
427
+ or "codec configuration (extradata) differs")
428
+ for m in mismatched)
429
+ refuse("input", "join_copy_mismatch", f"--copy: {len(mismatched)} clip(s) do not match the first one: {listing}",
430
+ hint="drop --copy to re-encode them to one format", clips=mismatched)
431
+ hidden = [p for p in args.inputs if "D" in "".join(_video_packets(p, COPY_PREROLL_PACKETS))]
432
+ if hidden:
433
+ refuse("input", "join_copy_mismatch",
434
+ f"--copy: {', '.join(hidden)} hide(s) frames before their start (an edit list), which a copy join would show",
435
+ hint="drop --copy, or re-cut those clips with cut.py --accurate", clips=[{"path": p, "differs": {"edit_list": True}} for p in hidden])
436
+ gaps = []
437
+ for p, m in zip(args.inputs, metas):
438
+ # each stream's span from its packets: Matroska/WebM state no stream length and an
439
+ # MPEG-TS stream length is an estimate, so a stated duration missed or invented gaps
440
+ if not (m.get("video") and m.get("audio")):
441
+ continue
442
+ length, origin = float(m.get("duration") or 0.0), float(m.get("start_time") or 0.0)
443
+ v_span, a_span = _stream_span(p, "V", length, origin), _stream_span(p, "a", length, origin)
444
+ if v_span is None or a_span is None:
445
+ refuse("input", "join_copy_mismatch",
446
+ f"--copy: could not measure where picture and sound start and end in {p} (a packet without a "
447
+ "time), so a gap at the join could not be ruled out", hint="drop --copy to re-encode the join",
448
+ clips=[{"path": p, "differs": {"av_span": "unmeasured"}}])
449
+ (vb, ve), (ab, ae) = v_span, a_span
450
+ if abs(vb - ab) > COPY_AV_GAP or abs(ve - ae) > COPY_AV_GAP:
451
+ gaps.append({"path": p, "video": [round(vb, 3), None if ve is None else round(ve, 3)],
452
+ "audio": [round(ab, 3), None if ae is None else round(ae, 3)]})
453
+ if gaps:
454
+ def span(x):
455
+ return f"{x[0]:.3f}-" + ("?" if x[1] is None else f"{x[1]:.3f}") + "s"
456
+ listing = "; ".join(f"{g['path']} (video {span(g['video'])}, audio {span(g['audio'])})" for g in gaps)
457
+ refuse("input", "join_copy_mismatch",
458
+ f"--copy: picture and sound start or end more than {COPY_AV_GAP * 1000:.0f} ms apart in {listing}; a copy join "
459
+ "would leave that gap at the next clip", hint="drop --copy, which trims both streams to one length",
460
+ clips=[{"path": g["path"], "differs": {"video_span": g["video"], "audio_span": g["audio"]}} for g in gaps])
461
+ fps = (metas[0].get("video") or {}).get("fps") or 30.0
462
+ note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
463
+ place_silent([m.get("duration") or 0.0 for m in metas], 0.0)
464
+ with tempfile.TemporaryDirectory(prefix="ffskill_join_") as tmp:
465
+ listfile = os.path.join(tmp, "list.txt")
466
+ with open(listfile, "w", encoding="utf-8") as fh:
467
+ for p in args.inputs:
468
+ fh.write(concat_list_line(os.path.abspath(p)) + "\n")
469
+ joined = sibling_temp(output, "join") if not STATE.dry_run else output
470
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-map", "0:V:0", "-map", "0:a:0?", "-c", "copy"]
471
+ if ext in (".mp4", ".mov", ".m4v"):
472
+ cmd += ["-movflags", "+faststart"]
473
+ try:
474
+ run(cmd + [joined])
475
+ verification: List[Dict[str, Any]] = []
476
+ if not STATE.dry_run:
477
+ want = sum(len(_video_packets(p)) for p in args.inputs)
478
+ got = len(_video_packets(joined))
479
+ # nothing counted on either side is a failed read, not a match
480
+ verification.append({"step": "join", "property": "video_packets", "ok": got == want and want > 0,
481
+ "measured": got, "expected": want, "provenance": "input_premeasurement"})
482
+ r = probe(joined, role="output")
483
+ measured = (r.get("video") or {}).get("duration") or r.get("duration")
484
+ expected = round(sum(m.get("duration") or 0.0 for m in metas), 3)
485
+ tol = round(n * COPY_AV_GAP + 1.0 / fps, 3)
486
+ verification.append({"step": "join", "property": "video_duration",
487
+ "ok": measured is not None and abs(measured - expected) <= tol,
488
+ "measured": None if measured is None else round(measured, 3), "expected": expected,
489
+ "tolerance": tol, "provenance": "input_premeasurement"})
490
+ bad = [v for v in verification if not v["ok"]]
491
+ if bad:
492
+ die("--copy: the joined file does not hold the clips it was given: "
493
+ + "; ".join(f"{v['property']} {v['measured']} (expected {v['expected']})" for v in bad),
494
+ kind="verification", hint="drop --copy to re-encode the join", verification=verification)
495
+ place_output(joined, output, move=True)
496
+ finally:
497
+ if joined != output and os.path.exists(joined):
498
+ os.remove(joined)
499
+ r = probe(output, role="output")
500
+ info(f"wrote {output} ({fmt_secs(r.get('duration'))}, {n} clips, stream copy)")
501
+ emit(output, mode="copy", **reported(), clips=n, transition="none", reencoded=False,
502
+ verification=verification, expect_streams={"video": 1, "audio": 1 if all(m.get("audio") for m in metas) else 0},
503
+ stream_reference=metas,
504
+ dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
505
+ return 0
506
+
507
+
308
508
  def main() -> int:
309
509
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
310
510
  ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
@@ -329,6 +529,10 @@ def main() -> int:
329
529
  ap.add_argument("--height", type=int, help="output height (default: first clip)")
330
530
  ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
331
531
  ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
532
+ ap.add_argument("--copy", action="store_true",
533
+ help="join by stream copy, no re-encode (needs --transition none): every clip must carry the same "
534
+ "video and audio parameters (codec, size, rate, pixel format, sample rate, channels); a clip "
535
+ "that does not is refused, by name, with what differs")
332
536
  ap.add_argument("--pad-color", default="black")
333
537
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
334
538
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
@@ -337,6 +541,8 @@ def main() -> int:
337
541
  aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
338
542
  add_common(ap)
339
543
  args = ap.parse_args()
544
+ # given at all, not only different from the default: --quality 18 asks for an encode as much as 23 does
545
+ args.quality_given = any(a == "--quality" or a.startswith("--quality=") for a in sys.argv[1:])
340
546
  apply_common(args)
341
547
  if args.fps is not None and args.fps <= 0:
342
548
  die(f"--fps must be positive, got {args.fps:g}")
@@ -433,6 +639,8 @@ def main() -> int:
433
639
  for p, m in zip(args.inputs, metas):
434
640
  if not m.get("audio"):
435
641
  die(f"{p} has neither a video nor an audio stream")
642
+ if args.copy:
643
+ die("--copy joins video clips; an audio-only join resamples every clip to one format, which a copy cannot do")
436
644
  return join_audio(args, metas)
437
645
  for p, m, pic in zip(args.inputs, metas, pictured):
438
646
  if not pic:
@@ -444,6 +652,8 @@ def main() -> int:
444
652
  die((f"{p} does not exist yet, and its audio extension says it will have no video stream" if m.get("dry_run")
445
653
  else f"{p} has no video stream")
446
654
  + (f" while {other}; join audio with audio or give every clip a picture" if other else ""))
655
+ if args.copy:
656
+ return join_copy(args, metas)
447
657
  first = metas[0]["video"]
448
658
  fw, fh = first["width"], first["height"]
449
659
  if first.get("rotation") in (90, -90, 270, -270):
package/scripts/render.py CHANGED
@@ -1192,7 +1192,12 @@ def main() -> int:
1192
1192
  work.mkdir(parents=True, exist_ok=True)
1193
1193
  except OSError as e:
1194
1194
  die(f"cannot create the work directory {work}: {e}")
1195
- if not args.keep and not args.work:
1195
+ # --stop-after before the export reports a stage file inside the work directory as its output:
1196
+ # removing the directory at exit deleted the file the result named (and its output_identity)
1197
+ previewing = bool(args.stop_after) and args.stop_after != "export" and not STATE.dry_run
1198
+ if previewing and not args.keep and not args.work:
1199
+ info(f"--stop-after {args.stop_after}: keeping the work directory {work}, since the result's output is in it")
1200
+ if not args.keep and not args.work and not previewing:
1196
1201
  # a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
1197
1202
  # auto-named directory is ours alone, so remove it on every exit path
1198
1203
  import atexit