ffmpeg-skill 2.24.1 → 3.1.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 +74 -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/asr.py +5 -3
- package/scripts/_common/drawtext.py +6 -10
- package/scripts/_common/probe.py +41 -3
- package/scripts/_common/runner.py +98 -4
- package/scripts/_contract.py +75 -60
- package/scripts/cut.py +46 -29
- package/scripts/render.py +21 -0
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
|
package/scripts/_common/asr.py
CHANGED
|
@@ -447,11 +447,13 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
447
447
|
def die_no_engine(alternative: str, flag: str = "--transcribe") -> None:
|
|
448
448
|
"""The one "no local speech-to-text engine" refusal, in the one set of words.
|
|
449
449
|
|
|
450
|
-
kind
|
|
451
|
-
|
|
450
|
+
kind missing_tool (3.1.0; it was kind input, which read as a bad argument), `reason:
|
|
451
|
+
"asr_engine_missing"`, exit 1, and the three install lines -- caption.py and silence.py both
|
|
452
|
+
land here rather than each spelling out its own version of the same missing dependency.
|
|
452
453
|
"""
|
|
453
454
|
die(f"no local speech-to-text engine found for {flag}.\n" + ASR_INSTALL_HINT + "\n" + alternative,
|
|
454
|
-
kind="
|
|
455
|
+
kind="missing_tool", error_extra={"reason": "asr_engine_missing", "details": {}},
|
|
456
|
+
hint="install one of the engines above; `python3 scripts/_contract.py doctor` shows which are found")
|
|
455
457
|
|
|
456
458
|
|
|
457
459
|
def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
|
|
@@ -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
|
|
|
@@ -578,6 +598,35 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
578
598
|
pass
|
|
579
599
|
|
|
580
600
|
|
|
601
|
+
# ffmpeg's own words for the failures a caller can act on. Each pattern gives `error.reason` and a
|
|
602
|
+
# hint; the kind stays "ffmpeg" and the exit code 1 (docs/design-decisions.md), so a caller that
|
|
603
|
+
# keyed on those sees no change. Matched against the stderr tail, the first hit wins.
|
|
604
|
+
FFMPEG_FAILURE_CLASSES = (
|
|
605
|
+
(re.compile(r"No space left on device"), "disk_full",
|
|
606
|
+
"the disk the output is written to is full: free space there (or write elsewhere with -o) and run again"),
|
|
607
|
+
(re.compile(r"Unknown encoder '([^']+)'|Encoder '?([\w-]+)'? not found|Requested encoder '?([\w-]+)'? not found"),
|
|
608
|
+
"encoder_missing", "this ffmpeg build has no {0} encoder: `python3 scripts/_contract.py doctor` lists what it has and how to get a fuller build"),
|
|
609
|
+
(re.compile(r"No such filter: '([^']+)'|Filter '?([\w-]+)'? not found"),
|
|
610
|
+
"filter_missing", "this ffmpeg build has no {0} filter: `python3 scripts/_contract.py doctor` lists what it has and how to get a fuller build"),
|
|
611
|
+
(re.compile(r"moov atom not found"), "input_incomplete",
|
|
612
|
+
"the input has no index (moov atom): a recording that was cut off or a copy that has not finished; "
|
|
613
|
+
"copy it again from its source. `export --preset copy --repair` cannot rebuild a missing index"),
|
|
614
|
+
(re.compile(r"Invalid data found when processing input"), "input_unreadable",
|
|
615
|
+
"ffmpeg cannot read the input as media: check that it plays and finished copying; `check.py --content` names damaged spans in a file that does"),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def classify_ffmpeg_failure(stderr: str) -> "Optional[Tuple[str, str, Dict[str, str]]]":
|
|
620
|
+
"""(reason, hint, details) for an ffmpeg stderr that names an actionable cause, else None."""
|
|
621
|
+
for pattern, reason, hint in FFMPEG_FAILURE_CLASSES:
|
|
622
|
+
m = pattern.search(stderr or "")
|
|
623
|
+
if m:
|
|
624
|
+
name = next((g for g in m.groups() if g), "")
|
|
625
|
+
details = {"name": name} if name else {}
|
|
626
|
+
return reason, hint.format(name or "that"), details
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
|
|
581
630
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Context]" = None, retried: bool = False) -> None:
|
|
582
631
|
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
583
632
|
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
@@ -610,7 +659,11 @@ def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Conte
|
|
|
610
659
|
("ffmpeg reported no encoder-open or compression-session error, so the cause is not the GPU (see the message)"
|
|
611
660
|
if why is None else f"VideoToolbox refused it ({why}) and no CPU encoder line was recorded to retry with"))
|
|
612
661
|
extra = {"encoder": enc, "hw": report}
|
|
613
|
-
|
|
662
|
+
cls = classify_ffmpeg_failure(stderr)
|
|
663
|
+
if cls:
|
|
664
|
+
extra["hint"] = cls[1]
|
|
665
|
+
die(f"command failed ({returncode}): {what}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode,
|
|
666
|
+
error_extra={"reason": cls[0], "details": cls[2]} if cls else None, **extra)
|
|
614
667
|
|
|
615
668
|
|
|
616
669
|
def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
@@ -1399,6 +1452,22 @@ def child_args() -> List[str]:
|
|
|
1399
1452
|
return args
|
|
1400
1453
|
|
|
1401
1454
|
|
|
1455
|
+
def _has_non_av_streams(cmd: Sequence[str]) -> bool:
|
|
1456
|
+
"""Whether the command's first input has a subtitle or data stream (True when it cannot be
|
|
1457
|
+
told: the retry then runs as before)."""
|
|
1458
|
+
src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
|
|
1459
|
+
if not src or not os.path.isfile(src):
|
|
1460
|
+
return True
|
|
1461
|
+
try:
|
|
1462
|
+
out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", src],
|
|
1463
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
|
|
1464
|
+
timeout=PROBE_TIMEOUT).stdout
|
|
1465
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1466
|
+
return True
|
|
1467
|
+
kinds = {ln.strip() for ln in out.splitlines() if ln.strip()}
|
|
1468
|
+
return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
|
|
1469
|
+
|
|
1470
|
+
|
|
1402
1471
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
1403
1472
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
1404
1473
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -1415,6 +1484,11 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
|
1415
1484
|
first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
|
|
1416
1485
|
if first.returncode == 0:
|
|
1417
1486
|
return False
|
|
1487
|
+
if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
|
|
1488
|
+
# a full disk, a missing encoder or an unreadable input fails the second encode the same
|
|
1489
|
+
# way: report the first failure rather than run the whole encode again and blame streams
|
|
1490
|
+
# the source may not even have
|
|
1491
|
+
_fail(first.args, first.returncode, first.stderr or "")
|
|
1418
1492
|
error = " | ".join([ln.strip() for ln in (first.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
|
|
1419
1493
|
record_fallback(f"the subtitle/data streams could not be copied into {os.path.basename(output)} "
|
|
1420
1494
|
f"({error or 'ffmpeg exit ' + str(first.returncode)}); encoding again without them",
|
|
@@ -1431,10 +1505,29 @@ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
|
|
|
1431
1505
|
return STATE.timeout or None
|
|
1432
1506
|
|
|
1433
1507
|
|
|
1508
|
+
def _die_with_parent() -> None:
|
|
1509
|
+
"""Linux: have the kernel SIGKILL this child when the tool that started it dies, even by
|
|
1510
|
+
SIGKILL (which no handler can catch). Without it a tool killed by an agent's time limit left
|
|
1511
|
+
ffmpeg running orphaned, writing the output under its final name after the caller had been
|
|
1512
|
+
told the run failed. macOS and Windows have no equivalent; SIGTERM/SIGINT are handled there
|
|
1513
|
+
as everywhere (`kind: interrupted`, the child stopped)."""
|
|
1514
|
+
try:
|
|
1515
|
+
import ctypes
|
|
1516
|
+
ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, 9) # PR_SET_PDEATHSIG, SIGKILL
|
|
1517
|
+
except Exception: # noqa: BLE001 - best effort; a child without it is what every other OS gets
|
|
1518
|
+
pass
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def child_kwargs() -> Dict[str, Any]:
|
|
1522
|
+
"""Extra Popen arguments for an ffmpeg/ffprobe child (see _die_with_parent)."""
|
|
1523
|
+
return {"preexec_fn": _die_with_parent} if sys.platform.startswith("linux") else {}
|
|
1524
|
+
|
|
1525
|
+
|
|
1434
1526
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
1435
1527
|
"""Plain run with stdout/stderr captured."""
|
|
1436
1528
|
limit = _limit_for(cmd)
|
|
1437
|
-
child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace"
|
|
1529
|
+
child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
|
|
1530
|
+
**child_kwargs())
|
|
1438
1531
|
_watch(child, cmd)
|
|
1439
1532
|
try:
|
|
1440
1533
|
out, err = child.communicate(timeout=limit)
|
|
@@ -1484,7 +1577,8 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
1484
1577
|
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
1485
1578
|
t0 = time.time()
|
|
1486
1579
|
limit = _limit_for(cmd)
|
|
1487
|
-
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace"
|
|
1580
|
+
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
|
|
1581
|
+
**child_kwargs())
|
|
1488
1582
|
_watch(proc, cmd)
|
|
1489
1583
|
assert proc.stdout is not None and proc.stderr is not None
|
|
1490
1584
|
lines: "queue.Queue[Optional[str]]" = queue.Queue()
|