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.
@@ -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. With `--edit-list`, a single-segment `.mp4`/`.m4v`/`.mov` copy
50
- keeps the MP4 edit list instead: the pre-roll back to the keyframe is stored but
51
- hidden, so the picture starts at the requested time (`edit_list`,
52
- `stored_preroll_seconds`). A player or tool that ignores edit lists shows that
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` re-encodes on it by default and reports packet timestamps sampled in up
78
- to five windows (`vfr_check`), with a note when they are constant;
79
- `--vfr-guard sampled` switches to `--accurate` only when they are irregular or too
80
- few to judge, and `--vfr-guard off` keeps the copy anyway. Pick the rate
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
 
@@ -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|sampled|off] [-o OUT]
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
- `--keep-hevc` re-encodes an SDR HEVC source (not BT.2020: that keeps its Main10 line and colour tags) as x265 8-bit BT.709, on VideoToolbox under `--hw`;
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 shifts its timestamps to zero
146
- (`-avoid_negative_ts make_zero`), so the picture starts at the keyframe.
147
- `--edit-list` keeps its MP4 edit list instead, so the picture starts at
148
- `--start`: `edit_list` and `stored_preroll_seconds` say so, `notes` warns that a
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` under `--edit-list`). It is
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` is true for every stream copy (`precision`
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`, and `least_exact_precision` is the least exact one.
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`).** `average` (default): a source whose nominal and
179
- average frame rates differ (`variable_frame_rate_suspected`) re-encodes as
180
- `--accurate` (reason `vfr`), and `cut.py` then samples its packet timestamps (up
181
- to five 6 s windows; no decoding) into `vfr_check` (`measured`: `sampled_cfr`,
182
- `vfr` or `inconclusive`, plus `guard`); a `sampled_cfr` there adds a note naming
183
- `--vfr-guard sampled`. `sampled` samples before every copy and re-encodes only on
184
- `vfr` / `inconclusive` (reasons `vfr` / `vfr_inconclusive`), so a phone clip at
185
- 29.98 against a nominal 30 copies; `off` samples and keeps the copy with a note.
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
@@ -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: input, exit 1, and the three install lines -- caption.py and silence.py both land here
451
- rather than each spelling out its own version of the same missing dependency.
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="input")
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, ffmpeg_version, require_tool
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=top|right|bottom|left` (and the two-value `v|h` form)
83
- arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
84
- (found by the FFmpeg 5.1.1 CI job, #146). Older builds get the larger single value."""
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. The option arrived in FFmpeg 6.1 (5.x and 6.0 reject it); there the block is still
188
- centred as a whole by its x expression, with its lines left-aligned inside it."""
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:
@@ -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
- windows.append([p for p in complete_pts(doc.get("packets") or [])
448
- if isinstance(p, int) and p * tick >= t - 1e-6])
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
- die(f"command failed ({returncode}): {what}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode, **extra)
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()