ffmpeg-skill 2.24.1 → 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 +61 -33
- 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 +41 -3
- package/scripts/_common/runner.py +21 -1
- package/scripts/_contract.py +69 -59
- package/scripts/cut.py +46 -29
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
|
|
|
@@ -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
|
|
package/scripts/_contract.py
CHANGED
|
@@ -33,7 +33,7 @@ from typing import Any, Dict, List, Optional
|
|
|
33
33
|
HERE = Path(__file__).resolve().parent
|
|
34
34
|
ROOT = HERE.parent
|
|
35
35
|
SKILL_ID = "ffmpeg-skill"
|
|
36
|
-
CONTRACT_VERSION = "
|
|
36
|
+
CONTRACT_VERSION = "2.0"
|
|
37
37
|
# `doctor`'s own introspection calls (-filters/-encoders/-bsfs/-version) are meant to be fast,
|
|
38
38
|
# bounded, non-media operations; a hang here would silently freeze the one tool meant to report
|
|
39
39
|
# whether the machine is broken. Media-processing scripts (cut, fit, ...) are NOT bounded this
|
|
@@ -79,7 +79,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
79
79
|
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
|
|
80
80
|
"cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family, or audio extracted when -o has an audio extension)"],
|
|
81
81
|
required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"},
|
|
82
|
-
dict(HDR_X265, when=HDR_X265["when"] + ";
|
|
82
|
+
dict(HDR_X265, when=HDR_X265["when"] + "; on an SDR HEVC source (the default since 3.0.0; --no-keep-hevc uses x264)"), {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
|
|
83
83
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
84
84
|
"fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
|
|
85
85
|
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
|
|
@@ -412,6 +412,7 @@ DIAGNOSTICS_SCHEMA = {
|
|
|
412
412
|
# error.reason: a stable code for why a refusal happened, beside `kind` (which says what class of
|
|
413
413
|
# failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
|
|
414
414
|
ERROR_REASONS = {
|
|
415
|
+
"ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
|
|
415
416
|
"output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
|
|
416
417
|
"output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
|
|
417
418
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
@@ -641,21 +642,21 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
641
642
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
642
643
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
643
644
|
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
644
|
-
"description": "the whole run's
|
|
645
|
+
"description": "since 3.0.0 the least exact segment's (as least_exact_precision; 2.x: the whole run's, packet only when nothing re-encoded): packet: stream copy on a packet/keyframe boundary; sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
|
|
645
646
|
"least_exact_precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
646
647
|
"description": "the least exact segment's precision (the order packet, codec_frame, frame, sample); differs from precision only for an audio-only --segments join of copied and re-encoded parts"},
|
|
647
|
-
"keyframe_snapped": {"type": "boolean", "description": "
|
|
648
|
+
"keyframe_snapped": {"type": ["boolean", "null"], "description": "since 3.0.0 equal to start_snapped: a copied start really moved (2.x: precision was packet, the run a stream copy end to end)"},
|
|
648
649
|
"start_snapped": {"type": ["boolean", "null"], "description": "measured: true when a copied segment's picture starts more than one frame from its requested start (the keyframe the copy's seek lands on, measured with the seek ffmpeg makes); false when every start is where asked (an MP4 edit list hides the pre-roll, the start is a keyframe, an audio-only copy, or everything re-encoded); null when a copied start was not measured (--dry-run)"},
|
|
649
650
|
"reencoded": {"type": "boolean"},
|
|
650
651
|
"reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback"]},
|
|
651
652
|
"description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
|
|
652
653
|
"segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
|
|
653
654
|
"description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
|
|
654
|
-
"edit_list": {"type": "boolean", "description": "
|
|
655
|
+
"edit_list": {"type": "boolean", "description": "a single MP4/MOV stream copy kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (the default since 3.0.0; false under --no-edit-list and for every other path)"},
|
|
655
656
|
"stored_preroll_seconds": {"type": ["number", "null"], "description": "with edit_list: seconds decoded from the keyframe the copy's seek lands on up to the requested start and hidden by the edit list (a player that ignores edit lists shows them); null otherwise"},
|
|
656
|
-
"av_start_skew_seconds": {"type": ["number", "null"], "description": "the output's audio start minus its video start (null when either is missing or under --dry-run); reported, never repaired: on a stream copy only, a warning in notes when it is more than max(2 frames, 0.1 s) away from the source's own skew where the copy's picture starts (the keyframe it began from; the requested start
|
|
657
|
-
"notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an
|
|
658
|
-
"vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (
|
|
657
|
+
"av_start_skew_seconds": {"type": ["number", "null"], "description": "the output's audio start minus its video start (null when either is missing or under --dry-run); reported, never repaired: on a stream copy only, a warning in notes when it is more than max(2 frames, 0.1 s) away from the source's own skew where the copy's picture starts (the keyframe it began from; the requested start with an edit list), naming --accurate (and the edit list for a single MP4/MOV copy whose start snapped under --no-edit-list)"},
|
|
658
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an edit list, or an edit-list copy judged by its video; an A/V start skew with its remedies; a source --vfr-guard average took for VFR whose sampled timing is constant (naming --vfr-guard sampled), or a sampled measurement that forced or (--vfr-guard off) skipped a re-encode; a --segments segment ended with the video, or held on its last frame for its sound; a --segments join re-cut because the source uses open GOPs, because a later B-frame .mp4/.mov segment starts between keyframes, or because its join check failed"},
|
|
659
|
+
"vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (2.x's default) only when its nominal and average frame rates differ, which re-encodes it (reason vfr) whatever the sample says; under sampled (the default since 3.0.0) or off on every such input (absent for audio-only input or output, --accurate, --codec, or a --dry-run input a plan has not written yet): {heuristic: true, measured, method: \"sampled\", guard, windows, deltas}. measured is sampled_cfr (every packet interval in up to five 6 s windows within a tick, at least 1 ms and at most half a frame, of its window's median, and the windows agree; the file-start window's first interval is ignored, and a seeked window is read from its first keyframe on, because a seek lands mid-GOP and the frames before it are not a full run), vfr (under sampled, forces a re-encode, reason vfr) or inconclusive (fewer than 20 intervals or ffprobe failed; under sampled, reason vfr_inconclusive). --vfr-guard off keeps the copy either way, with a note. Variable timing between the windows is not seen"},
|
|
659
660
|
"lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"},
|
|
660
661
|
"join_check": {"type": ["object", "null"], "description": "--segments video stream-copy join: the written file measured by demuxing, {packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}. expected_packets counts the source's video packets from each part's start keyframe to its end (or the end of the video); max_step_seconds is the largest presentation step (null when vfr_check measured anything but sampled_cfr, e.g. a --vfr-guard off join of variable timing, which checks the count only), and each step at a join must be the step its parts predict within 1 ms; audio_offset_ms is, per part, its sound's shift minus its picture's (+ = sound late) from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than a:0; an MPEG-TS source's AAC is hashed without its ADTS headers) (null for a part with no unique match, e.g. digital silence; null as a whole when the source has no audio or the video check failed), and audio_parts_checked counts the measured parts; ok is the conjunction (counts, steps, every measured part within 5 ms), and false means the join was re-cut from the source (concat_fallback) and this is the failed measurement. null for a single segment, audio, --dry-run, --accurate/--codec/a forced re-encode, a segment that re-encoded, or a join that fell back before a copy join was attempted (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error)"},
|
|
661
662
|
"declick_ms": {"type": ["number", "null"], "description": "--segments joined by the re-encode (--accurate, a forced re-encode or concat_fallback): the fade in ms asked for on each side of every join, so butted cuts do not click (20 by default, --declick-ms; 0 under --no-declick; the file's own start and end are not faded). Each fade is capped at half its segment, so a segment shorter than twice this is faded for half its length. null when there is no join to fade: a single segment, a source with no audio, or a stream-copy join, whose sound is copied and not faded"},
|
|
@@ -1007,30 +1008,29 @@ def ffmpeg_security(versions: Dict[str, Optional[str]], sources: Optional[Dict[s
|
|
|
1007
1008
|
return out
|
|
1008
1009
|
|
|
1009
1010
|
|
|
1010
|
-
#
|
|
1011
|
-
#
|
|
1012
|
-
|
|
1013
|
-
|
|
1011
|
+
# 3.0.0 removed Python 3.9 (deprecated in 2.10.9) and FFmpeg 5.x (deprecated in 2.14.0): REMOVED
|
|
1012
|
+
# below. doctor reports a floor it is below in `unsupported` and as `ok: false`; bin/install.js
|
|
1013
|
+
# refuses Python below 3.10 and prints the same FFmpeg line (a test keeps the lines equal); every
|
|
1014
|
+
# tool refuses an FFmpeg it can read as older than 6.1 (runner.require_tool).
|
|
1015
|
+
MIN_PYTHON = (3, 10)
|
|
1016
|
+
MIN_FFMPEG = (6, 1)
|
|
1017
|
+
PYTHON_UNSUPPORTED = ("error: Python {v} is not supported since ffmpeg-skill 3.0.0 (3.9 was deprecated in 2.10.9; "
|
|
1018
|
+
"CPython 3.9 is end of life): use Python 3.10+.")
|
|
1019
|
+
FFMPEG_UNSUPPORTED = ("error: FFmpeg {v} is not supported since ffmpeg-skill 3.0.0 (5.x was deprecated in 2.14.0): "
|
|
1020
|
+
"use FFmpeg 6.1+.")
|
|
1014
1021
|
|
|
1015
1022
|
|
|
1016
|
-
def
|
|
1017
|
-
"""The
|
|
1023
|
+
def python_unsupported(version_info: Optional[tuple] = None) -> Optional[str]:
|
|
1024
|
+
"""The unsupported-interpreter line for this Python, or None when it is 3.10 or later."""
|
|
1018
1025
|
vi = tuple(version_info if version_info is not None else sys.version_info)[:2]
|
|
1019
|
-
return
|
|
1026
|
+
return PYTHON_UNSUPPORTED.format(v=f"{vi[0]}.{vi[1]}") if vi < MIN_PYTHON else None
|
|
1020
1027
|
|
|
1021
1028
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
FFMPEG5_DEPRECATION = ("warning: FFmpeg 5.x is deprecated since ffmpeg-skill 2.14.0 (the 5.x branches date from 2022; "
|
|
1026
|
-
"Ubuntu 24.04 ships 6.1); it still works in 2.x and support ends in 3.0.0: use FFmpeg 6.1+.")
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
def ffmpeg_deprecation(version: Optional[str]) -> Optional[str]:
|
|
1030
|
-
"""The deprecation line for this ffmpeg's version token, or None (not 5.x, or not a release
|
|
1031
|
-
number: a git snapshot gets no line, as with the security advisory)."""
|
|
1029
|
+
def ffmpeg_unsupported(version: Optional[str]) -> Optional[str]:
|
|
1030
|
+
"""The unsupported-FFmpeg line for this version token, or None (6.1 or later, or not a release
|
|
1031
|
+
number: a git snapshot is not refused, as with the security advisory)."""
|
|
1032
1032
|
v = _parse_ffmpeg_version(version)
|
|
1033
|
-
return
|
|
1033
|
+
return FFMPEG_UNSUPPORTED.format(v=f"{v[0]}.{v[1]}") if v and tuple(v[:2]) < MIN_FFMPEG else None
|
|
1034
1034
|
|
|
1035
1035
|
|
|
1036
1036
|
def _exe_report() -> Dict[str, Any]:
|
|
@@ -1289,6 +1289,13 @@ def doctor() -> Dict[str, Any]:
|
|
|
1289
1289
|
"not CRF-equivalent (the result's `encoder`/`hw` say so)" for cap, alt in sorted(covered.items())]
|
|
1290
1290
|
versions = {"ffmpeg": _version_line("ffmpeg"), "ffprobe": _version_line("ffprobe")}
|
|
1291
1291
|
exes = _exe_report()
|
|
1292
|
+
# 3.0.0: below the requirements floor is a broken install, not an advisory
|
|
1293
|
+
unsupported = [line for line in (python_unsupported(), ffmpeg_unsupported(versions["ffmpeg"])) if line]
|
|
1294
|
+
probe_line = ffmpeg_unsupported(versions["ffprobe"])
|
|
1295
|
+
if probe_line:
|
|
1296
|
+
probe_line = probe_line.replace("error: FFmpeg ", "error: ffprobe ", 1)
|
|
1297
|
+
if probe_line not in unsupported:
|
|
1298
|
+
unsupported.append(probe_line)
|
|
1292
1299
|
return {
|
|
1293
1300
|
"version": skill_version(),
|
|
1294
1301
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
@@ -1304,8 +1311,9 @@ def doctor() -> Dict[str, Any]:
|
|
|
1304
1311
|
"detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
|
|
1305
1312
|
"errors": errors,
|
|
1306
1313
|
**({"notes": notes} if notes else {}),
|
|
1307
|
-
"
|
|
1308
|
-
"
|
|
1314
|
+
"unsupported": unsupported,
|
|
1315
|
+
"ok": not missing_required and not unknown_required and not unsupported,
|
|
1316
|
+
"tools": _tool_usability(state, drawtext_crashed, covered, unsupported),
|
|
1309
1317
|
"covered_by": covered,
|
|
1310
1318
|
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
1311
1319
|
"hw": _hw_default(),
|
|
@@ -1428,22 +1436,31 @@ def _capability_fix_hint(cap: str, drawtext_crashed: bool = False) -> str:
|
|
|
1428
1436
|
return f"'{cap}' is not available; see docs/contract.md"
|
|
1429
1437
|
|
|
1430
1438
|
|
|
1431
|
-
def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False, covered: Optional[Dict[str, str]] = None
|
|
1439
|
+
def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False, covered: Optional[Dict[str, str]] = None,
|
|
1440
|
+
unsupported: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]:
|
|
1432
1441
|
"""Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
|
|
1433
1442
|
computed. Answers "can I run this tool on this machine today", not just "what capabilities
|
|
1434
1443
|
exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
|
|
1435
1444
|
own required-capability list by hand to answer that. A required capability in `covered`
|
|
1436
|
-
(libx264 replaced by VideoToolbox on this machine) blocks nothing; the tool lists it in `covered_by`.
|
|
1445
|
+
(libx264 replaced by VideoToolbox on this machine) blocks nothing; the tool lists it in `covered_by`.
|
|
1446
|
+
A Python or FFmpeg below the 3.0.0 floor (`unsupported`) makes every tool unusable: each one
|
|
1447
|
+
refuses to start on it."""
|
|
1437
1448
|
covered = covered or {}
|
|
1438
1449
|
tools: Dict[str, Dict[str, Any]] = {}
|
|
1439
1450
|
for name, meta in TOOL_META.items():
|
|
1440
1451
|
required = list(meta["required"])
|
|
1441
1452
|
missing = [c for c in required if state.get(c) == "missing" and c not in covered]
|
|
1442
1453
|
unknown = [c for c in required if state.get(c) == "unknown"]
|
|
1443
|
-
entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
|
|
1454
|
+
entry: Dict[str, Any] = {"usable": "no" if missing or unsupported else ("unknown" if unknown else "yes")}
|
|
1455
|
+
fixes = []
|
|
1444
1456
|
if missing:
|
|
1445
1457
|
entry["missing"] = missing
|
|
1446
|
-
|
|
1458
|
+
fixes += [_capability_fix_hint(c, drawtext_crashed) for c in missing]
|
|
1459
|
+
if unsupported:
|
|
1460
|
+
entry["unsupported"] = list(unsupported)
|
|
1461
|
+
fixes.append("install Python 3.10+ and FFmpeg 6.1+ (ffmpeg and ffprobe from one build)")
|
|
1462
|
+
if fixes:
|
|
1463
|
+
entry["fix"] = "; ".join(dict.fromkeys(fixes))
|
|
1447
1464
|
if unknown:
|
|
1448
1465
|
entry["unknown"] = unknown
|
|
1449
1466
|
via = {c: covered[c] for c in required if c in covered}
|
|
@@ -1592,25 +1609,13 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1592
1609
|
# ----------------------------------------------------------------------------- deprecations
|
|
1593
1610
|
# What the next major removes, announced here per docs/contract.md's three-step deprecation policy:
|
|
1594
1611
|
# step 1 (this list, --help text and the CHANGELOG) in a minor, step 2 keeps it working, step 3
|
|
1595
|
-
# removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0
|
|
1596
|
-
#
|
|
1597
|
-
DEPRECATED: List[Dict[str, str]] = [
|
|
1598
|
-
# CPython 3.9 reached end of life in October 2025. Raising the floor is a break, so 2.x keeps
|
|
1599
|
-
# running on it (CI still tests 3.9) and doctor / bin/install.js print PY39_DEPRECATION.
|
|
1600
|
-
{"what": "running on Python 3.9 (the requirements.python floor)",
|
|
1601
|
-
"since": "2.10.9", "removed_in": "3.0.0", "where": "behaviour",
|
|
1602
|
-
"replacement": "Python 3.10 or later; on 3.9, doctor and the npx installer print a one-line warning"},
|
|
1603
|
-
# FFmpeg 5.x: the 5.x branches date from 2022 and keep version-specific workarounds alive.
|
|
1604
|
-
# 2.x keeps running on 5.0+ (CI still tests 5.1.1) and doctor / bin/install.js print
|
|
1605
|
-
# FFMPEG5_DEPRECATION; 3.0.0 raises requirements.ffmpeg to 6.1.
|
|
1606
|
-
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)",
|
|
1607
|
-
"since": "2.14.0", "removed_in": "3.0.0", "where": "behaviour",
|
|
1608
|
-
"replacement": "FFmpeg 6.1 or later; on 5.x, doctor and the npx installer print a one-line warning"},
|
|
1609
|
-
]
|
|
1612
|
+
# removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0 and at
|
|
1613
|
+
# 3.0.0: every deprecation announced in 1.x and 2.x was settled by its major (REMOVED below).
|
|
1614
|
+
DEPRECATED: List[Dict[str, str]] = []
|
|
1610
1615
|
|
|
1611
|
-
# What
|
|
1612
|
-
# policy's step 3, kept in the contract so a caller migrating
|
|
1613
|
-
#
|
|
1616
|
+
# What each major removed or changed, with the version that deprecated each: the "Removed" half of
|
|
1617
|
+
# the policy's step 3, kept in the contract so a caller migrating can diff it instead of reading a
|
|
1618
|
+
# CHANGELOG. docs/contract.md's "What 2.0 changed" and "What 3.0 changed" tables are written from it.
|
|
1614
1619
|
REMOVED: List[Dict[str, str]] = [
|
|
1615
1620
|
{"what": "--crf as an alias of --quality on every re-encoding tool that takes --quality (export.py keeps --crf: its preset chooses the encoder)",
|
|
1616
1621
|
"since": "1.10.0", "removed_in": "2.0.0", "where": "cli",
|
|
@@ -1627,6 +1632,12 @@ REMOVED: List[Dict[str, str]] = [
|
|
|
1627
1632
|
{"what": "the result_v2 preview key and FFMPEG_SKILL_RESULT_V2 (the v1 top-level keys it was to replace were un-deprecated: they are the 2.0 shape)",
|
|
1628
1633
|
"since": "1.10.0", "removed_in": "2.0.0", "where": "json",
|
|
1629
1634
|
"replacement": "the top-level keys, unchanged from 1.x; --json-brief for a short document"},
|
|
1635
|
+
{"what": "running on Python 3.9 (the requirements.python floor)",
|
|
1636
|
+
"since": "2.10.9", "removed_in": "3.0.0", "where": "behaviour",
|
|
1637
|
+
"replacement": "Python 3.10 or later; the npx installer skips an older interpreter and doctor reports it as unsupported (ok: false)"},
|
|
1638
|
+
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)",
|
|
1639
|
+
"since": "2.14.0", "removed_in": "3.0.0", "where": "behaviour",
|
|
1640
|
+
"replacement": "FFmpeg 6.1 or later; every tool refuses an older one it can read (kind: missing_tool), and doctor reports it as unsupported"},
|
|
1630
1641
|
]
|
|
1631
1642
|
|
|
1632
1643
|
|
|
@@ -1844,7 +1855,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1844
1855
|
},
|
|
1845
1856
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
|
|
1846
1857
|
},
|
|
1847
|
-
"requirements": {"python": ">=3.
|
|
1858
|
+
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1", "node": ">=16 (npx installer only)"},
|
|
1848
1859
|
"execution": {
|
|
1849
1860
|
"shell": False,
|
|
1850
1861
|
"arbitrary_executables": False,
|
|
@@ -1908,7 +1919,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1908
1919
|
"hidden .<output>.ffskill-audit.jsonl beside the output, and a cache event's .ffskill-audit.jsonl in the "
|
|
1909
1920
|
"cache directory (render.py --cache DIR) or the batch's output_dir. Never written under --dry-run; a write that "
|
|
1910
1921
|
"fails is a diagnostics.warnings entry (audit_log_unwritable), never a failure"},
|
|
1911
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
1922
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported)", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
1912
1923
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
1913
1924
|
},
|
|
1914
1925
|
"capabilities": caps,
|
|
@@ -1926,12 +1937,10 @@ def main() -> int:
|
|
|
1926
1937
|
args = ap.parse_args()
|
|
1927
1938
|
if args.command == "doctor":
|
|
1928
1939
|
d = doctor()
|
|
1929
|
-
#
|
|
1930
|
-
#
|
|
1931
|
-
#
|
|
1932
|
-
|
|
1933
|
-
# so is the FFmpeg 5.x one.
|
|
1934
|
-
for line in [python_deprecation(), ffmpeg_deprecation(d.get("ffmpeg"))] + d["ffmpeg_security"]["warnings"]:
|
|
1940
|
+
# On stderr in both modes, so `doctor --json`'s stdout stays one JSON document: an
|
|
1941
|
+
# unsupported Python or FFmpeg (also in the document as `unsupported`, and part of `ok`)
|
|
1942
|
+
# and the FFmpeg security advisories (`ffmpeg_security.warnings`, never part of `ok`).
|
|
1943
|
+
for line in d["unsupported"] + d["ffmpeg_security"]["warnings"]:
|
|
1935
1944
|
if line:
|
|
1936
1945
|
print(line, file=sys.stderr)
|
|
1937
1946
|
if args.json:
|
|
@@ -1969,7 +1978,8 @@ def main() -> int:
|
|
|
1969
1978
|
if d["ok"]:
|
|
1970
1979
|
return 0
|
|
1971
1980
|
# 1: something required is missing; 2: nothing proven missing but a required capability is unknown
|
|
1972
|
-
|
|
1981
|
+
# an unsupported Python/FFmpeg is a known failure, like a missing capability (not "unknown")
|
|
1982
|
+
return 1 if d["missing"] or d.get("unsupported") else 2
|
|
1973
1983
|
print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
|
|
1974
1984
|
return 0
|
|
1975
1985
|
|