ffmpeg-skill 2.24.0 → 3.0.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/join.py CHANGED
@@ -345,6 +345,45 @@ def _video_packets(path: str, limit: "Optional[int]" = None) -> "List[str]":
345
345
  return (proc.stdout or "").split() if proc.returncode == 0 else []
346
346
 
347
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
+
348
387
  def join_copy(args, metas: List[Dict[str, Any]]) -> int:
349
388
  """--copy: the concat demuxer with -c copy, after checking that the clips can be joined that
350
389
  way, and measured afterwards (video packet count and length against the clips')."""
@@ -354,6 +393,11 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
354
393
  die("--copy keeps every clip's own frame and rate: --width, --height and --fps need a re-encode, so drop --copy")
355
394
  if args.sample_rate or args.channels:
356
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")
357
401
  output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
358
402
  guard_sidecar(output, *args.inputs)
359
403
  ext = os.path.splitext(output)[1].lower()
@@ -391,15 +435,19 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
391
435
  hint="drop --copy, or re-cut those clips with cut.py --accurate", clips=[{"path": p, "differs": {"edit_list": True}} for p in hidden])
392
436
  gaps = []
393
437
  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_):
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")):
398
441
  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):
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:
403
451
  gaps.append({"path": p, "video": [round(vb, 3), None if ve is None else round(ve, 3)],
404
452
  "audio": [round(ab, 3), None if ae is None else round(ae, 3)]})
405
453
  if gaps:
@@ -452,7 +500,8 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
452
500
  info(f"wrote {output} ({fmt_secs(r.get('duration'))}, {n} clips, stream copy)")
453
501
  emit(output, mode="copy", **reported(), clips=n, transition="none", reencoded=False,
454
502
  verification=verification, expect_streams={"video": 1, "audio": 1 if all(m.get("audio") for m in metas) else 0},
455
- stream_reference=metas)
503
+ stream_reference=metas,
504
+ dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
456
505
  return 0
457
506
 
458
507
 
@@ -492,6 +541,8 @@ def main() -> int:
492
541
  aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
493
542
  add_common(ap)
494
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:])
495
546
  apply_common(args)
496
547
  if args.fps is not None and args.fps <= 0:
497
548
  die(f"--fps must be positive, got {args.fps:g}")
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