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/README.md +12 -13
- package/SKILL.md +4 -4
- package/bin/install.js +24 -29
- package/docs/contract.md +62 -34
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +7 -4
- package/references/devices.md +2 -2
- package/references/gotchas.md +11 -9
- package/references/scripts.md +21 -18
- package/scripts/_common/drawtext.py +6 -10
- package/scripts/_common/probe.py +54 -8
- package/scripts/_common/runner.py +21 -1
- package/scripts/_contract.py +69 -59
- package/scripts/check.py +23 -10
- package/scripts/cut.py +51 -30
- package/scripts/export.py +12 -2
- package/scripts/join.py +60 -9
- package/scripts/render.py +6 -1
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
|
|
395
|
-
#
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|