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/README.md +17 -17
- package/SKILL.md +54 -56
- package/docs/contract.md +8 -6
- package/package.json +1 -1
- package/references/devices.md +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +48 -8
- package/scripts/_common/emit.py +14 -0
- package/scripts/_common/probe.py +94 -0
- package/scripts/_contract.py +10 -4
- package/scripts/check.py +93 -7
- package/scripts/color.py +4 -2
- package/scripts/cut.py +47 -49
- package/scripts/export.py +113 -5
- package/scripts/join.py +212 -2
- package/scripts/render.py +6 -1
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|