ffmpeg-skill 2.23.0 → 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/README.md +17 -17
- package/SKILL.md +54 -56
- package/docs/contract.md +7 -5
- 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/drawtext.py +1 -1
- package/scripts/_common/emit.py +14 -0
- package/scripts/_common/probe.py +86 -0
- package/scripts/_common/timeline.py +31 -2
- package/scripts/_contract.py +10 -4
- package/scripts/batch.py +2 -2
- package/scripts/check.py +80 -7
- package/scripts/color.py +4 -2
- package/scripts/cut.py +43 -49
- package/scripts/export.py +103 -5
- package/scripts/join.py +161 -2
- package/scripts/render.py +1 -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,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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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):
|
package/scripts/render.py
CHANGED
|
@@ -724,7 +724,7 @@ def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[
|
|
|
724
724
|
"hw": [os.environ.get(HW_ENV, "").strip().lower() in ("1", "true", "yes", "on"), os.environ.get(HW_FORCED_ENV) == "1"],
|
|
725
725
|
"ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION, "contract": CONTRACT_VERSION,
|
|
726
726
|
}
|
|
727
|
-
return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
727
|
+
return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode(), usedforsecurity=False).hexdigest()
|
|
728
728
|
|
|
729
729
|
|
|
730
730
|
def _cache_miss(stage: str, reason: str, **facts: Any) -> bool:
|