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/references/gotchas.md
CHANGED
|
@@ -46,10 +46,11 @@ S-Log, V-Log and C-Log look grey and low-contrast but are tagged SDR. Run
|
|
|
46
46
|
### Keyframe cuts
|
|
47
47
|
A lossless `cut.py` result may start up to one GOP (often 1–10 s) earlier than
|
|
48
48
|
requested (`start_snapped: true`); the script re-encodes automatically when the
|
|
49
|
-
deviation exceeds 0.5 s.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
deviation exceeds 0.5 s. A single-segment `.mp4`/`.m4v`/`.mov` copy keeps the MP4
|
|
50
|
+
edit list by default (since 3.0.0; `--no-edit-list` starts the file at the keyframe
|
|
51
|
+
instead): the pre-roll back to the keyframe is stored but hidden, so the picture
|
|
52
|
+
starts at the requested time (`edit_list`, `stored_preroll_seconds`, and
|
|
53
|
+
`start_snapped: false`). A player or tool that ignores edit lists shows that
|
|
53
54
|
pre-roll. The **end** still lands on a packet boundary, a few frames long, and
|
|
54
55
|
those extra frames can skip. `.mkv` output has no edit list, and a `--segments`
|
|
55
56
|
join shows each part from its keyframe. On B-frame video an `.mp4`/`.mov`
|
|
@@ -65,7 +66,7 @@ a start a few frames before a keyframe can begin at that keyframe or one GOP
|
|
|
65
66
|
earlier (`start_snapped: true` either way).
|
|
66
67
|
`av_start_skew_seconds` warns when a copy's sound and picture start apart
|
|
67
68
|
(Core Media HEVC once gave 3.7 s of sound with no picture); it is reported, not
|
|
68
|
-
repaired: `--accurate` is the fix, or `--edit-list` for a lossless `.mp4`/`.m4v`/`.mov`. A `-c copy` cut on VFR or a
|
|
69
|
+
repaired: `--accurate` is the fix, or the default edit list (not `--no-edit-list`) for a lossless `.mp4`/`.m4v`/`.mov`. A `-c copy` cut on VFR or a
|
|
69
70
|
non-keyframe boundary produces a file that "works" but starts on a frozen or
|
|
70
71
|
wrong frame — respect the automatic re-encode rather than forcing the copy.
|
|
71
72
|
|
|
@@ -74,10 +75,11 @@ wrong frame — respect the automatic re-encode rather than forcing the copy.
|
|
|
74
75
|
`avg_frame_rate` disagree (phone and screen recordings). Every re-encoding
|
|
75
76
|
script then adds `-fps_mode cfr` at the source's average rate. That flag is a
|
|
76
77
|
whole-file average, and a phone clip at 29.98 against a nominal 30 trips it, so
|
|
77
|
-
`cut.py`
|
|
78
|
-
|
|
79
|
-
`--
|
|
80
|
-
|
|
78
|
+
`cut.py` decides on packet timestamps sampled in up to five windows (`vfr_check`)
|
|
79
|
+
instead: by default (`--vfr-guard sampled`, since 3.0.0) it switches to
|
|
80
|
+
`--accurate` only when they are irregular or too few to judge;
|
|
81
|
+
`--vfr-guard average` restores 2.x's re-encode on the flag alone, and
|
|
82
|
+
`--vfr-guard off` keeps the copy anyway. Pick the rate
|
|
81
83
|
explicitly with `fit.py --fps 30|60` when the average is odd (e.g. 23.4 fps from
|
|
82
84
|
dropped frames).
|
|
83
85
|
|
package/references/scripts.md
CHANGED
|
@@ -127,14 +127,15 @@ own clock, `null` when unknown). `--compact` gives one line per file.
|
|
|
127
127
|
|
|
128
128
|
### cut.py — cut / join segments
|
|
129
129
|
```
|
|
130
|
-
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,... | --remove A-B,C-D,...] [--accurate] [--declick-ms 20 | --no-declick] [--edit-list] [--keep-hevc] [--vfr-guard average|
|
|
130
|
+
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,... | --remove A-B,C-D,...] [--accurate] [--declick-ms 20 | --no-declick] [--edit-list | --no-edit-list] [--keep-hevc | --no-keep-hevc] [--vfr-guard sampled|average|off] [-o OUT]
|
|
131
131
|
cut.py INPUT --start T --end T --snap beats [--snap-tolerance 0.12] [--snap-source FILE] [--min-confidence 0.5]
|
|
132
132
|
```
|
|
133
133
|
Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
|
|
134
134
|
keyframes, instant, lossless); if the snapped result deviates more than
|
|
135
135
|
`--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
|
|
136
136
|
(CRF 18: x264, or x265 Main10 with the source's own tags for an HDR or BT.2020 source;
|
|
137
|
-
|
|
137
|
+
an SDR HEVC source (not BT.2020: that keeps its Main10 line and colour tags) stays HEVC as x265 8-bit BT.709, on VideoToolbox under `--hw`,
|
|
138
|
+
by default since 3.0.0 (`--keep-hevc`; `--no-keep-hevc` uses x264 as 2.x did);
|
|
138
139
|
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
|
|
139
140
|
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
|
|
140
141
|
between keyframes, or one that fails its check, still re-cuts).
|
|
@@ -142,16 +143,16 @@ Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24)
|
|
|
142
143
|
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
|
|
143
144
|
re-encode, the result's `lossless_alternative` names the nearest keyframe
|
|
144
145
|
`--start` that would stream-copy instead, so the trade can be offered.
|
|
145
|
-
A single `.mp4`/`.mov` stream copy
|
|
146
|
-
(
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
A single `.mp4`/`.mov` stream copy keeps its MP4 edit list by default since
|
|
147
|
+
3.0.0 (`--edit-list`), so the picture starts at `--start`; `--no-edit-list`
|
|
148
|
+
shifts its timestamps to zero instead (`-avoid_negative_ts make_zero`, 2.x's
|
|
149
|
+
default), so the picture starts at the keyframe. With the edit list: `edit_list` and `stored_preroll_seconds` say so, `notes` warns that a
|
|
149
150
|
player which ignores edit lists shows the pre-roll, and the copy's length is
|
|
150
151
|
judged by its video (a copy that starts where asked but ends past `--tolerance`
|
|
151
152
|
re-encodes without offering another `--start`). `av_start_skew_seconds` is
|
|
152
153
|
audio start minus video start; a stream copy gets a note when it is more than
|
|
153
154
|
max(2 frames, 0.1 s) away from the source's own offset where the picture starts
|
|
154
|
-
(the keyframe the copy began from; `--start`
|
|
155
|
+
(the keyframe the copy began from; `--start` with an edit list). It is
|
|
155
156
|
reported, not repaired. `-ss`/`-t` are passed to the microsecond; `--accurate`
|
|
156
157
|
seeks a second early and trims on the output side, so frames just before a
|
|
157
158
|
keyframe are kept. An `--accurate` re-encode of a video does not pass
|
|
@@ -163,26 +164,28 @@ the next source frame and may keep up to one frame of timestamp. A `.mp4`/`.mov`
|
|
|
163
164
|
edit list hides the priming. A source whose audio starts after its picture keeps
|
|
164
165
|
that offset. An audio-only `--accurate` cut
|
|
165
166
|
still uses `make_zero`; that priming is what `precision: codec_frame` counts
|
|
166
|
-
in the length. `keyframe_snapped`
|
|
167
|
-
`packet`); `start_snapped` is measured: true when a copied picture starts more
|
|
167
|
+
in the length. Since 3.0.0 `keyframe_snapped` equals `start_snapped` (2.x: true
|
|
168
|
+
for every stream copy, `precision` `packet`); `start_snapped` is measured: true when a copied picture starts more
|
|
168
169
|
than a frame from `--start`, or when its start could not be measured (an unknown start is
|
|
169
170
|
never claimed exact); `null` under `--dry-run`.
|
|
170
171
|
`reencode_reason` lists every cause of a re-encode (`requested`, `codec`, `vfr`,
|
|
171
172
|
`vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost`, `tolerance`, `concat_fallback`);
|
|
172
|
-
`--segments` adds `segment_precision
|
|
173
|
+
`--segments` adds `segment_precision`; `precision` and `least_exact_precision` are the least exact one
|
|
174
|
+
(since 3.0.0; 2.x's `precision` was the whole run's, `packet` only when nothing re-encoded).
|
|
173
175
|
A `--segments` video copy join adds `join_check` (`packets`, `expected_packets`,
|
|
174
176
|
`max_step_seconds`, `audio_offset_ms` per part, `audio_parts_checked`, `ok`: the
|
|
175
177
|
written file measured against the source) and `segment_end_snap_seconds` (where
|
|
176
178
|
each part's end moved to its keyframe).
|
|
177
179
|
|
|
178
|
-
**VFR guard (`--vfr-guard`).** `
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
**VFR guard (`--vfr-guard`).** `sampled` (default since 3.0.0): `cut.py` samples
|
|
181
|
+
the packet timestamps before every copy (up to five 6 s windows; no decoding) into
|
|
182
|
+
`vfr_check` (`measured`: `sampled_cfr`, `vfr` or `inconclusive`, plus `guard`) and
|
|
183
|
+
re-encodes only on `vfr` / `inconclusive` (reasons `vfr` / `vfr_inconclusive`), so a
|
|
184
|
+
phone clip at 29.98 against a nominal 30 copies. `average` (2.x's default): a source
|
|
185
|
+
whose nominal and average frame rates differ (`variable_frame_rate_suspected`)
|
|
186
|
+
re-encodes as `--accurate` (reason `vfr`), and is then sampled into `vfr_check`; a
|
|
187
|
+
`sampled_cfr` there adds a note naming `--vfr-guard sampled`. `off` samples and
|
|
188
|
+
keeps the copy with a note.
|
|
186
189
|
Irregular timing between the windows is not seen; `--accurate` is always available.
|
|
187
190
|
|
|
188
191
|
A source with no B-frames (iPhone "Most Compatible" H.264) cuts its parts as
|
|
@@ -10,7 +10,7 @@ import shutil
|
|
|
10
10
|
import subprocess
|
|
11
11
|
from typing import Dict, List, Optional
|
|
12
12
|
from _common.decision import escape_filter_path
|
|
13
|
-
from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release,
|
|
13
|
+
from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release, require_tool
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def _on_windows() -> bool:
|
|
@@ -79,12 +79,9 @@ def drawtext_fontfile(path: str) -> str:
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
|
|
82
|
-
"""drawtext's per-side `boxborderw
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if ffmpeg_version() >= (6, 1):
|
|
86
|
-
return f"{vertical}|{horizontal}"
|
|
87
|
-
return str(max(vertical, horizontal))
|
|
82
|
+
"""drawtext's per-side `boxborderw` in its two-value `v|h` form (FFmpeg 6.1+, the floor since
|
|
83
|
+
3.0.0; 2.x gave 5.x and 6.0 the larger single value, which they needed)."""
|
|
84
|
+
return f"{vertical}|{horizontal}"
|
|
88
85
|
|
|
89
86
|
|
|
90
87
|
# --------------------------------------------------------------------------- shaping (1.15)
|
|
@@ -184,9 +181,8 @@ def is_multiline(text: "Optional[str]") -> bool:
|
|
|
184
181
|
|
|
185
182
|
def drawtext_center_align() -> str:
|
|
186
183
|
"""`:text_align=C` for a centred drawtext, so each line of a multi-line label is centred on
|
|
187
|
-
its own
|
|
188
|
-
|
|
189
|
-
return ":text_align=C" if ffmpeg_version() >= (6, 1) else ""
|
|
184
|
+
its own (FFmpeg 6.1+, the floor since 3.0.0)."""
|
|
185
|
+
return ":text_align=C"
|
|
190
186
|
|
|
191
187
|
|
|
192
188
|
def drawtext_clean(text: "Optional[str]") -> str:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -135,6 +135,34 @@ def verify_output(path: str) -> Dict[str, Any]:
|
|
|
135
135
|
return meta
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
_TIMECODE = re.compile(r"^(\d{2}):([0-5]\d):([0-5]\d)([:;.])(\d{2,3})$")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _tag(entry: Dict[str, Any], key: str) -> Optional[str]:
|
|
142
|
+
return next((v for k, v in (entry.get("tags") or {}).items() if k.lower() == key), None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def start_timecode(streams: List[Dict[str, Any]], fmt: Dict[str, Any],
|
|
146
|
+
video: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
147
|
+
"""The first well-formed `timecode` tag, from the video stream, then a `tmcd` data stream,
|
|
148
|
+
then the container: {value, drop_frame, source}. `drop_frame` is the `;` (or `.`) separator
|
|
149
|
+
before the frames field; `source` is `stream:<ffprobe index>` or `format`. None when no
|
|
150
|
+
source carries one (all three null). A tag that is not HH:MM:SS[:;.]FF is skipped, not
|
|
151
|
+
repaired."""
|
|
152
|
+
candidates = []
|
|
153
|
+
if video is not None:
|
|
154
|
+
candidates.append((video, f"stream:{video.get('index')}"))
|
|
155
|
+
candidates += [(s, f"stream:{s.get('index')}") for s in streams
|
|
156
|
+
if s.get("codec_type") == "data" and str(s.get("codec_tag_string", "")).lower() == "tmcd"]
|
|
157
|
+
candidates.append((fmt, "format"))
|
|
158
|
+
for entry, source in candidates:
|
|
159
|
+
value = _tag(entry, "timecode")
|
|
160
|
+
m = _TIMECODE.match(value.strip()) if isinstance(value, str) else None
|
|
161
|
+
if m:
|
|
162
|
+
return {"value": value.strip(), "drop_frame": m.group(4) in ";.", "source": source}
|
|
163
|
+
return {"value": None, "drop_frame": None, "source": None}
|
|
164
|
+
|
|
165
|
+
|
|
138
166
|
def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
139
167
|
"""Return a compact, script-friendly description of a media file.
|
|
140
168
|
|
|
@@ -230,6 +258,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
230
258
|
"title": (ch.get("tags") or {}).get("title"),
|
|
231
259
|
} for n, ch in enumerate(raw.get("chapters") or [])],
|
|
232
260
|
"tags": {k.lower(): v for k, v in (fmt.get("tags") or {}).items() if k.lower() in ("title", "artist", "album", "comment", "date", "genre")},
|
|
261
|
+
# the source's start timecode (a camera's 10:00:00:00), for an edit list or an NLE relink
|
|
262
|
+
"timecode": start_timecode(streams, fmt, video),
|
|
233
263
|
# every subtitle stream in file order: index n here is `-map 0:s:n`
|
|
234
264
|
"subtitle_stream_details": [{
|
|
235
265
|
"index": n,
|
|
@@ -434,7 +464,7 @@ def measure_frame_timing(path: str, duration: Optional[float], start: float = 0.
|
|
|
434
464
|
time_base = None
|
|
435
465
|
for i, t in enumerate(timing_window_starts(duration or 0.0, start)):
|
|
436
466
|
proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-show_packets",
|
|
437
|
-
"-show_entries", "packet=pts,dts:stream=time_base", "-of", "json",
|
|
467
|
+
"-show_entries", "packet=pts,dts,flags:stream=time_base", "-of", "json",
|
|
438
468
|
"-read_intervals", f"{t if i else ''}%{t + TIMING_WINDOW_SECONDS:.3f}", path], quiet=True, check=False)
|
|
439
469
|
try:
|
|
440
470
|
doc = json.loads(proc.stdout) if proc.returncode == 0 else None
|
|
@@ -444,8 +474,16 @@ def measure_frame_timing(path: str, duration: Optional[float], start: float = 0.
|
|
|
444
474
|
return {"measured": "inconclusive", "windows": len(windows), "deltas": 0}
|
|
445
475
|
time_base = time_base or ((doc.get("streams") or [{}])[0] or {}).get("time_base")
|
|
446
476
|
tick = float(_fraction(str(time_base)) or 0) if time_base else 0.0
|
|
447
|
-
|
|
448
|
-
|
|
477
|
+
packets = doc.get("packets") or []
|
|
478
|
+
floor = t - 1e-6
|
|
479
|
+
if i:
|
|
480
|
+
# a seek into MPEG-TS lands mid-GOP, not on a keyframe: a frame stored before the
|
|
481
|
+
# landing point is missing, which read as a dropped frame (a constant TS measured
|
|
482
|
+
# `vfr`). Judge a seeked window from its first keyframe's presentation on.
|
|
483
|
+
key = next((p.get("pts") for p in packets if "K" in str(p.get("flags", "")) and isinstance(p.get("pts"), int)), None)
|
|
484
|
+
if key is not None:
|
|
485
|
+
floor = max(floor, key * tick - 1e-6)
|
|
486
|
+
windows.append([p for p in complete_pts(packets) if isinstance(p, int) and p * tick >= floor])
|
|
449
487
|
return classify_frame_timing(windows, time_base)
|
|
450
488
|
|
|
451
489
|
|
|
@@ -830,7 +868,9 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
830
868
|
|
|
831
869
|
|
|
832
870
|
# What a stream-copy join compares (cut.py --segments, join.py --copy).
|
|
833
|
-
|
|
871
|
+
# codecs that carry no out-of-band configuration in these containers, so no extradata on
|
|
872
|
+
# either side is a match, not a difference
|
|
873
|
+
IN_BAND_CONFIG = ("mp3", "mp2", "vp8", "vp9")
|
|
834
874
|
TS_EXTS = (".ts", ".m2ts", ".mts")
|
|
835
875
|
_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
|
|
836
876
|
"sample_aspect_ratio", "color_space", "color_range", "color_transfer", "color_primaries"),
|
|
@@ -879,8 +919,9 @@ def signatures_match(sigs: list, ext: str) -> bool:
|
|
|
879
919
|
|
|
880
920
|
# check.py --compare (2.24): the output's picture against a reference, frame by frame.
|
|
881
921
|
COMPARE_SSIM_WARN = 0.98 # mean SSIM below this: a visible difference somewhere is likely
|
|
882
|
-
|
|
883
|
-
|
|
922
|
+
# the channel letters follow the negotiated format: Y U V (or y u v) for YUV, R G B for RGB
|
|
923
|
+
_SSIM_SUMMARY = re.compile(r"SSIM [A-Za-z]:[0-9.]+ .*All:([0-9.]+)")
|
|
924
|
+
_PSNR_SUMMARY = re.compile(r"PSNR [a-z]:\S+ .*average:(\S+)")
|
|
884
925
|
_SSIM_FRAME = re.compile(r"n:(\d+) .*All:([0-9.]+)")
|
|
885
926
|
|
|
886
927
|
|
|
@@ -894,8 +935,13 @@ def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
|
|
|
894
935
|
stats = os.path.join(tmp, "ssim.log")
|
|
895
936
|
# frames paired by index (settb/setpts=N): ssim and psnr otherwise pair by timestamp, so a
|
|
896
937
|
# shifted or irregular timestamp would meet the wrong reference frame
|
|
897
|
-
|
|
898
|
-
|
|
938
|
+
# shortest=1: by default the shorter file's last frame is repeated against the rest of
|
|
939
|
+
# the longer one, which measures pairs that do not exist. format=yuv444p on both: the
|
|
940
|
+
# filters otherwise take the first input's format, so an RGB file against a YUV one
|
|
941
|
+
# measured differently by argument order
|
|
942
|
+
graph = ("[0:V:0]settb=1/1000,setpts=N,format=yuv444p,split[a0][a1];"
|
|
943
|
+
"[1:V:0]settb=1/1000,setpts=N,format=yuv444p,split[b0][b1];"
|
|
944
|
+
f"[a0][b0]ssim=shortest=1:stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr=shortest=1")
|
|
899
945
|
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-i", reference,
|
|
900
946
|
"-filter_complex", graph, "-f", "null", "-"], check=False, record=True)
|
|
901
947
|
if proc.returncode != 0:
|
|
@@ -105,6 +105,7 @@ ERROR_RETRYABLE = False
|
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
_FFMPEG_VERSION: "Optional[Tuple[int, int]]" = None
|
|
108
|
+
_FFMPEG_VERSION_IS_RELEASE = False # ffmpeg_version() read a release number, not the libavutil major
|
|
108
109
|
|
|
109
110
|
|
|
110
111
|
def ffmpeg_version() -> "Tuple[int, int]":
|
|
@@ -114,7 +115,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
|
|
|
114
115
|
ship from the same build. Used only to pick between two spellings of an option where FFmpeg
|
|
115
116
|
changed behaviour between releases (the tools otherwise never branch on the version: doctor's
|
|
116
117
|
capability listing is the source of truth for what a build can do)."""
|
|
117
|
-
global _FFMPEG_VERSION
|
|
118
|
+
global _FFMPEG_VERSION, _FFMPEG_VERSION_IS_RELEASE
|
|
118
119
|
if _FFMPEG_VERSION is None:
|
|
119
120
|
_FFMPEG_VERSION = (0, 0)
|
|
120
121
|
try:
|
|
@@ -123,6 +124,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
|
|
|
123
124
|
m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
|
|
124
125
|
if m:
|
|
125
126
|
_FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
|
|
127
|
+
_FFMPEG_VERSION_IS_RELEASE = True
|
|
126
128
|
else:
|
|
127
129
|
# git / vendor builds print "N-115000-g..." or a date, never major.minor; the
|
|
128
130
|
# libavutil major is still there and maps one-to-one onto the FFmpeg major
|
|
@@ -271,6 +273,21 @@ def found_exe(name: str) -> str:
|
|
|
271
273
|
def require_tool(name: str) -> str:
|
|
272
274
|
"""Return the absolute path of ffmpeg/ffprobe or exit with install steps."""
|
|
273
275
|
path = ffmpeg_exe(name)[0] if name in ("ffmpeg", "ffprobe") else safe_which(name)
|
|
276
|
+
if path and name in ("ffmpeg", "ffprobe"):
|
|
277
|
+
# 3.0.0 removed FFmpeg 5.x (deprecated in 2.14.0) and the workarounds that kept it running:
|
|
278
|
+
# an older build that reports its version is refused before it can fail some other way.
|
|
279
|
+
# (0, 0) is an unreadable version and is not refused; a git/vendor build gives only the
|
|
280
|
+
# libavutil major, which cannot tell 6.0 from 6.1, so it is refused only below 6. The
|
|
281
|
+
# version is ffprobe's (ffmpeg is never run for it: --dry-run promises no ffmpeg run);
|
|
282
|
+
# both ship from one build, and doctor reads each binary's own.
|
|
283
|
+
found = ffmpeg_version()
|
|
284
|
+
if found != (0, 0) and (found[0] < MIN_FFMPEG[0] or (_FFMPEG_VERSION_IS_RELEASE and found < MIN_FFMPEG)):
|
|
285
|
+
system = platform.system()
|
|
286
|
+
hint = INSTALL_HINTS.get(system, " See https://ffmpeg.org/download.html")
|
|
287
|
+
refuse("missing_tool", "ffmpeg_unsupported",
|
|
288
|
+
f"FFmpeg {found[0]}.{found[1]} (read from ffprobe -version) is not supported since ffmpeg-skill 3.0.0 (5.x was deprecated in "
|
|
289
|
+
f"2.14.0): use FFmpeg 6.1+. Install it for {system}:\n{hint}",
|
|
290
|
+
code=127, version=f"{found[0]}.{found[1]}", required="6.1")
|
|
274
291
|
if path:
|
|
275
292
|
return path
|
|
276
293
|
system = platform.system()
|
|
@@ -283,6 +300,9 @@ def require_tool(name: str) -> str:
|
|
|
283
300
|
return "" # unreachable
|
|
284
301
|
|
|
285
302
|
|
|
303
|
+
MIN_FFMPEG = (6, 1) # requirements.ffmpeg since 3.0.0 (_contract.MIN_FFMPEG is the same floor)
|
|
304
|
+
|
|
305
|
+
|
|
286
306
|
X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
|
|
287
307
|
|
|
288
308
|
|