ffmpeg-skill 3.2.2 → 3.3.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.
@@ -163,6 +163,74 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
163
163
  ctx.notes.append(note_line)
164
164
 
165
165
 
166
+ # source_carry: the items compared, in report order. The first group is content a viewer or
167
+ # listener notices missing and raises the warning; the second is tags, listed but never warned
168
+ # about (every camera file has a creation_time, so a warning for it would fire on every run).
169
+ CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
170
+ CARRY_TAGS = ("creation_time", "audio_language", "timecode")
171
+ _CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
172
+ "audio_channels": "audio channels"}
173
+
174
+
175
+ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
176
+ """What the first input carried besides its picture and sound, and whether the written file
177
+ still has it (#401): {"kept": [...], "dropped": [...]}, or None when the source had none of
178
+ it or the two files are not comparable. Measured on the written file, so the tool, encoder or
179
+ FFmpeg version that dropped it does not matter. A report, not a check: `verified` is not
180
+ touched. Not compared: two or more picture or sound sources (which one's items the output
181
+ should carry is the tool's choice), an output that is not a media file, and items bound to a
182
+ stream the output does not have at all (a sound-only extract has no subtitle track or
183
+ timecode to lose; a picture-only output has no audio channels). Audio channels are an item
184
+ only for a surround source (more than two)."""
185
+ from .probe import MEDIA_EXT, _STILL_EXT, carry_inventory
186
+ first = getattr(STATE, "first_input", None)
187
+ if not first or not output:
188
+ return None
189
+ # a tool that reads its own output back (cut.py measures the cut it wrote) probed it as an
190
+ # input too: that is not a second source
191
+ own = os.path.realpath(output)
192
+ if any(len(set(getattr(STATE, name, None) or ()) - {own}) > 1 for name in ("video_inputs", "audio_inputs")):
193
+ return None
194
+ ext = os.path.splitext(output)[1].lower()
195
+ if ext not in MEDIA_EXT or ext in _STILL_EXT:
196
+ return None # a still, a contact sheet or an animated GIF holds none of these
197
+ src, out = carry_inventory(first[0]), carry_inventory(output)
198
+ if not src or not out or os.path.realpath(first[0]) == os.path.realpath(output):
199
+ return None
200
+ kept: List[str] = []
201
+ dropped: List[str] = []
202
+ for item in CARRY_CONTENT + CARRY_TAGS:
203
+ if not src.get(item):
204
+ continue
205
+ if item == "audio_channels" and src[item] <= 2:
206
+ continue # mono and stereo are what every tool writes; only a surround layout can be folded down
207
+ if item in ("subtitles", "timecode") and src["has_video"] and not out["has_video"]:
208
+ continue
209
+ if item in ("audio_channels", "audio_language") and not out["has_audio"]:
210
+ continue
211
+ (kept if out.get(item, 0) >= src[item] else dropped).append(item)
212
+ if not kept and not dropped:
213
+ return None
214
+ said = set()
215
+ if extra.get("dropped_non_av_streams") or any(w.get("code") == "non_av_streams_dropped" for w in ctx.fallbacks):
216
+ said.add("subtitles") # the tool's own field already says the track went
217
+ loud = [i for i in dropped if i in CARRY_CONTENT and i not in said]
218
+ if loud:
219
+ parts = []
220
+ for i in loud:
221
+ if i == "audio_channels":
222
+ parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} audio channels")
223
+ else:
224
+ parts.append("its " + _CARRY_NAMES[i])
225
+ warn("the source carried " + ", ".join(parts) + " that this output does not have "
226
+ "(source_carry lists everything kept and dropped)",
227
+ "source_items_dropped", ctx=ctx, dropped=loud)
228
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
229
+ if note_line and note_line not in ctx.notes:
230
+ ctx.notes.append(note_line)
231
+ return {"kept": kept, "dropped": dropped}
232
+
233
+
166
234
  def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
167
235
  ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
168
236
  """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
@@ -710,6 +778,9 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
710
778
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
711
779
  _report_dropped_audio_tracks(meta, ctx)
712
780
  _report_dropped_hdr10_metadata(meta, ctx)
781
+ carry = source_carry(output, ctx, extra)
782
+ if carry and "source_carry" not in extra:
783
+ extra["source_carry"] = carry
713
784
  if ctx.json:
714
785
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
715
786
  if meta:
@@ -402,9 +402,30 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
402
402
  (out.get("video") or {}).get("hdr10_static"))
403
403
  if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
404
404
  STATE.source_timecode = (out["timecode"]["value"], duration)
405
+ # what a source carries besides its picture and sound, for emit()'s source_carry comparison:
406
+ # kept beside the probe, not in it, so no probe document changes
407
+ lang = (audio.get("tags") or {}).get("language") if audio else None
408
+ _CARRY[os.path.realpath(path)] = {
409
+ "subtitles": len(subs),
410
+ "chapters": len(raw.get("chapters") or []),
411
+ "cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
412
+ "creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
413
+ "audio_language": 1 if lang and lang.lower() != "und" else 0,
414
+ "timecode": 1 if out["timecode"].get("value") else 0,
415
+ "audio_channels": (_to_int(audio.get("channels")) or 0) if audio else 0,
416
+ "has_video": bool(video), "has_audio": bool(audio),
417
+ }
405
418
  return out
406
419
 
407
420
 
421
+ _CARRY: Dict[str, Dict[str, Any]] = {}
422
+
423
+
424
+ def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
425
+ """The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
426
+ return _CARRY.get(os.path.realpath(path)) if path else None
427
+
428
+
408
429
  def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
409
430
  """A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
410
431
  a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
@@ -478,25 +499,35 @@ def _bit_depth(pix_fmt: Optional[str]) -> int:
478
499
  return n if 8 <= n <= 16 else 8
479
500
 
480
501
 
481
- def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
482
- """Video keyframe timestamps within +-window seconds of t, ascending. Read with
483
- -read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
484
- ffprobe = require_tool("ffprobe")
485
- lo = max(0.0, t - window)
486
- proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
487
- "-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
502
+ def keyframe_times(path: str, lo: float, hi: float, stream: str = "v:0") -> Optional[List[float]]:
503
+ """Keyframe timestamps of `stream` read from lo to hi seconds (file time), ascending and as
504
+ ffprobe prints them (microseconds). Read with -read_intervals so a long file is not scanned end
505
+ to end; the read starts at the keyframe at or before lo, so the first may lie before it. None
506
+ when ffprobe fails (unmeasured, not "no keyframes")."""
507
+ # an interval's end is exclusive (6.1: a keyframe at exactly hi is not read), so read a
508
+ # little past it and drop what lies beyond hi
509
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", stream, "-skip_frame", "nokey",
510
+ "-read_intervals", f"{max(0.0, lo):.6f}%{hi + 0.01:.6f}", "-show_entries", "frame=pts_time",
488
511
  "-of", "csv=p=0", path], quiet=True, check=False)
489
512
  if proc.returncode != 0:
490
- return []
513
+ return None
491
514
  out: List[float] = []
492
515
  for line in proc.stdout.splitlines():
493
516
  try:
494
- out.append(round(float(line.strip().rstrip(",")), 3))
517
+ k = float(line.strip().rstrip(","))
495
518
  except ValueError:
496
519
  continue
520
+ if k <= hi + 1e-6:
521
+ out.append(k)
497
522
  return sorted(set(out))
498
523
 
499
524
 
525
+ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
526
+ """Video keyframe timestamps within +-window seconds of t (to the ms), ascending; empty when
527
+ ffprobe cannot say (cut.py's best-effort hints; probe.py --keyframes-near uses keyframe_times)."""
528
+ return sorted(set(round(k, 3) for k in keyframe_times(path, t - window, t + window) or []))
529
+
530
+
500
531
  # frame timing: is a stream's frame rate really variable? (cut.py's VFR guard)
501
532
  TIMING_WINDOWS = 5 # sampled windows per file, fewer on a short one
502
533
  TIMING_WINDOW_SECONDS = 6
@@ -609,6 +640,162 @@ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
609
640
  return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
610
641
 
611
642
 
643
+ # Legal signal range of limited-range Y'CbCr (check.py --levels, color.py --legalize). Each standard
644
+ # is stated in percent of the nominal swing, so the code limits follow from the bit depth: 0 % luma is
645
+ # code 16 << (depth - 8), 100 % is 235 << (depth - 8); chroma's nominal swing is 16..240 (scaled the
646
+ # same way) around the midpoint. A sample is legal when its code lies inside [lo, hi] inclusive.
647
+ # bt709 ITU-R BT.709 nominal range: luma 0..100 %, chroma 16..240 (8-bit) -- no excursion.
648
+ # ebu-r103 EBU R 103 tolerance: luma -1 %..103 %, RGB components -5 %..105 %. Chroma is held to the
649
+ # widest Cb/Cr an RGB triple inside -5 %..105 % can produce (|C| <= 0.55 of the RGB span,
650
+ # i.e. 8-bit 5..251); RGB gamut itself is not measured here (a per-plane Y'CbCr count).
651
+ LEGAL_RANGES: Dict[str, Dict[str, Any]] = {
652
+ "bt709": {"luma_pct": (0.0, 100.0), "chroma_half_swing": 0.5,
653
+ "reference": "ITU-R BT.709 nominal range: luma 16-235, chroma 16-240 (8-bit; 10-bit 64-940 / 64-960)"},
654
+ "ebu-r103": {"luma_pct": (-1.0, 103.0), "chroma_half_swing": 0.55,
655
+ "reference": "EBU R 103 tolerance: luma -1 %..103 % (8-bit 14-241), chroma as far as RGB -5 %..105 % "
656
+ "reaches (8-bit 5-251)"},
657
+ }
658
+ LEGAL_RANGE_CHOICES = sorted(LEGAL_RANGES)
659
+
660
+
661
+ def legal_limits(standard: str, bit_depth: int = 8) -> Dict[str, Tuple[int, int]]:
662
+ """{"y": (lo, hi), "c": (lo, hi)}: the inclusive legal code range of a limited-range sample at
663
+ `bit_depth`. A limit that falls between two codes is rounded inward (the code past it is out)."""
664
+ spec = LEGAL_RANGES[standard]
665
+ scale = 1 << (max(8, int(bit_depth)) - 8)
666
+ black, white = 16 * scale, 235 * scale
667
+ span = white - black
668
+ p_lo, p_hi = spec["luma_pct"]
669
+ y = (math.ceil(black + span * p_lo / 100.0 - 1e-9), math.floor(white + span * (p_hi - 100.0) / 100.0 + 1e-9))
670
+ mid, cspan = 128 * scale, 224 * scale
671
+ half = spec["chroma_half_swing"] * cspan
672
+ c = (max(0, math.ceil(mid - half - 1e-9)), min((256 * scale) - 1, math.floor(mid + half + 1e-9)))
673
+ return {"y": y, "c": c}
674
+
675
+
676
+ _COMPONENT_DEPTHS: Dict[str, int] = {}
677
+
678
+
679
+ def component_depth(pix_fmt: Optional[str]) -> int:
680
+ """Bits per luma sample of `pix_fmt`, from ffmpeg's own pixel-format table (`-pix_fmts`,
681
+ BIT_DEPTHS column): the depth the planes really hold, which the name does not always say --
682
+ nv12 is 8-bit 4:2:0 (the 12 is bits per pixel), p010le is 10-bit. Falls back to _bit_depth()
683
+ when ffmpeg does not list the format."""
684
+ name = str(pix_fmt or "")
685
+ if not _COMPONENT_DEPTHS:
686
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-pix_fmts"], check=False)
687
+ for line in (proc.stdout or "").splitlines():
688
+ cols = line.split()
689
+ if len(cols) == 5 and re.fullmatch(r"\d+(?:-\d+)*", cols[4]):
690
+ _COMPONENT_DEPTHS[cols[1]] = int(cols[4].split("-")[0])
691
+ return _COMPONENT_DEPTHS.get(name) or _bit_depth(name)
692
+
693
+
694
+ def _metadata_frames(text: str, keys: Optional[frozenset] = None) -> List[Tuple[float, Dict[str, float]]]:
695
+ """metadata=print's output: one (pts_time, {key: value}) per `frame:` block, signalstats keys only
696
+ (only those in `keys` when given: signalstats prints ~25 per frame and an hour is 100k frames)."""
697
+ frames: List[Tuple[float, Dict[str, float]]] = []
698
+ for line in text.splitlines():
699
+ if line.startswith("frame:"):
700
+ m = re.search(r"pts_time:\s*(-?[0-9.]+|nan)", line)
701
+ t = float(m.group(1)) if m and m.group(1) != "nan" else float("nan")
702
+ frames.append((t, {}))
703
+ elif "lavfi.signalstats." in line and "=" in line and frames:
704
+ key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
705
+ key = key.strip()
706
+ if keys is not None and key not in keys:
707
+ continue
708
+ try:
709
+ frames[-1][1][key] = float(val)
710
+ except ValueError:
711
+ pass
712
+ return frames
713
+
714
+
715
+ # the signalstats keys measure_signal_range reads: the frame's own extremes and BRNG, and the mask's means
716
+ _OWN_KEYS = frozenset({"BRNG", "YMIN", "YMAX", "UMIN", "UMAX", "VMIN", "VMAX"})
717
+ _MASK_KEYS = frozenset({"YAVG", "UAVG", "VAVG"})
718
+
719
+
720
+ def measure_signal_range(path: str, standard: str, window: float = 10.0,
721
+ bit_depth: Optional[int] = None, pix_fmt: Optional[str] = None) -> Optional[Dict[str, Any]]:
722
+ """Share of luma and chroma samples outside `standard`'s legal range, per clip and per `window`
723
+ seconds, plus min/max Y/U/V, from one decode of every frame of the first video stream.
724
+
725
+ The count is exact, not sampled from a histogram: each plane goes through lutyuv, which maps an
726
+ out-of-range code to the depth's top code (255 at 8 bits) and every other code to 0, and
727
+ signalstats' mean of that mask is the top code x the out-of-range share of that plane in that frame. Chroma planes are
728
+ counted at their own (subsampled) resolution, so "chroma" is the share of Cb and Cr samples. A
729
+ second signalstats ahead of the mask reads the frame's own MIN/MAX and BRNG (the share of pixels
730
+ with any component outside the BT.601/709 nominal range, signalstats' fixed limits). Every frame
731
+ holds the same number of samples, so a clip's share is the mean of its frames' shares.
732
+
733
+ The limits and the mask use `bit_depth`, or else the depth of `pix_fmt`'s planes
734
+ (component_depth(): nv12 is 8-bit, not 12). None when the measurement is not whole: nothing
735
+ decoded, ffmpeg failed part-way (a share from the frames read before the failure would grade a
736
+ file nobody measured to the end), or the two passes counted different frames."""
737
+ if standard not in LEGAL_RANGES:
738
+ die(f"unknown legal range {standard!r}; choose from {', '.join(LEGAL_RANGE_CHOICES)}")
739
+ import tempfile
740
+ from _common.decision import escape_filter_path
741
+ ffmpeg = require_tool("ffmpeg")
742
+ depth = int(bit_depth or component_depth(pix_fmt))
743
+ lim = legal_limits(standard, depth)
744
+ (ylo, yhi), (clo, chi) = lim["y"], lim["c"]
745
+ top = (1 << depth) - 1 # a literal: lutyuv's own maxval is 235 for limited-range luma, 240 for chroma
746
+ out = str(top) + "*(lt(val\\,{lo})+gt(val\\,{hi}))"
747
+ mask = (f"lutyuv=y='{out.format(lo=ylo, hi=yhi)}':u='{out.format(lo=clo, hi=chi)}'"
748
+ f":v='{out.format(lo=clo, hi=chi)}'")
749
+ with tempfile.TemporaryDirectory(prefix="ffskill_levels_") as tmp:
750
+ stats_file = os.path.join(tmp, "stats.txt")
751
+ vf = (f"signalstats=stat=brng,metadata=print:file={escape_filter_path(stats_file)},"
752
+ f"{mask},signalstats,metadata=print:file=-")
753
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
754
+ proc = run_analysis(cmd, check=False, record=True)
755
+ try:
756
+ with open(stats_file, encoding="utf-8", errors="replace") as f:
757
+ own = _metadata_frames(f.read(), _OWN_KEYS)
758
+ except OSError:
759
+ own = []
760
+ masks = _metadata_frames(proc.stdout or "", _MASK_KEYS)
761
+ if proc.returncode != 0 or not own or len(own) != len(masks):
762
+ return None
763
+ t0 = next((t for t, _ in own if t == t), 0.0)
764
+ rows: List[Dict[str, Any]] = []
765
+ for (t, s), (_, m) in zip(own, masks, strict=True):
766
+ y, u, v = (m.get(k, 0.0) / top * 100.0 for k in ("YAVG", "UAVG", "VAVG"))
767
+ rows.append({"t": (t - t0) if t == t else None, "y": y, "u": u, "v": v, "brng": s.get("BRNG", 0.0) * 100.0,
768
+ "min": {k: s.get(k.upper() + "MIN") for k in "yuv"}, "max": {k: s.get(k.upper() + "MAX") for k in "yuv"}})
769
+
770
+ def summary(frames: List[Dict[str, Any]]) -> Dict[str, Any]:
771
+ k = len(frames)
772
+ share = {p: sum(f[p] for f in frames) / k for p in ("y", "u", "v", "brng")}
773
+ worst = max(frames, key=lambda f: max(f["y"], (f["u"] + f["v"]) / 2))
774
+ def ext(which: str, fn: Any) -> Dict[str, Any]:
775
+ vals = {p: [f[which][p] for f in frames if f[which][p] is not None] for p in "yuv"}
776
+ return {p: (int(fn(v)) if v else None) for p, v in vals.items()}
777
+ return {"frames": k,
778
+ "out_of_range_pct": {"y": round(share["y"], 4), "u": round(share["u"], 4), "v": round(share["v"], 4),
779
+ "chroma": round((share["u"] + share["v"]) / 2, 4)},
780
+ "brng_pct": round(share["brng"], 4),
781
+ "worst_frame": {"time": None if worst["t"] is None else round(worst["t"], 3),
782
+ "y_pct": round(worst["y"], 4), "chroma_pct": round((worst["u"] + worst["v"]) / 2, 4)},
783
+ "min": ext("min", min), "max": ext("max", max)}
784
+
785
+ doc = {"standard": standard, "reference": LEGAL_RANGES[standard]["reference"], "bit_depth": depth,
786
+ "legal": {"y": list(lim["y"]), "c": list(lim["c"])}, "window_seconds": window, **summary(rows)}
787
+ windows: List[Dict[str, Any]] = []
788
+ if window and window > 0:
789
+ buckets: Dict[int, List[Dict[str, Any]]] = {}
790
+ for i, f in enumerate(rows):
791
+ buckets.setdefault(int((f["t"] if f["t"] is not None else i) // window), []).append(f)
792
+ for idx in sorted(buckets):
793
+ w = summary(buckets[idx])
794
+ windows.append({"start": round(idx * window, 3), "end": round((idx + 1) * window, 3), **w})
795
+ doc["windows"] = windows
796
+ return doc
797
+
798
+
612
799
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
613
800
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
614
801
 
@@ -329,7 +329,7 @@ class Context:
329
329
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
330
330
  """
331
331
 
332
- __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
332
+ __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
333
333
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
334
334
  "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
335
335
  "source_timecode", "smpte_hint", "delivered")
@@ -359,6 +359,7 @@ class Context:
359
359
  self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
360
360
  self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
361
361
  self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
362
+ self.alpha_line: Optional[str] = None # --keep-alpha / --transparent: the alpha line written (_common.alpha.ALPHA_CODECS)
362
363
  self.hw = False # --hw / $FFMPEG_SKILL_HW: VideoToolbox encoders where they apply
363
364
  self.hw_source: Optional[str] = None # "flag" (--hw/--no-hw given) or "env" (FFMPEG_SKILL_HW), None = off by default
364
365
  self.hw_notes: List[str] = [] # why an encode stayed on (or fell back to) the CPU, for the result document
@@ -1066,9 +1067,14 @@ class _OutputLock:
1066
1067
  def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
1067
1068
  """An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
1068
1069
  encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
1069
- with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
1070
+ with an even-dimension scale appended to its -vf chain (or a new -vf when the command had
1070
1071
  none); None when the failure is something else or the graph is a -filter_complex the
1071
- caller has to fix itself."""
1072
+ caller has to fix itself.
1073
+
1074
+ Appended, not prepended: what the encoder refuses is the frame the chain hands it, and a
1075
+ chain can make an even frame odd again (squaring non-square pixels to 636x1401, a conversion
1076
+ that restores the source's own size). Through 3.2.3 the scale went first, so caption.py on an
1077
+ odd-sized clip retried and failed the same way (#438)."""
1072
1078
  if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
1073
1079
  return None
1074
1080
  if "-filter_complex" in cmd:
@@ -1076,7 +1082,7 @@ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
1076
1082
  new = list(cmd)
1077
1083
  if "-vf" in new:
1078
1084
  i = new.index("-vf") + 1
1079
- new[i] = EVEN_SCALE + "," + new[i]
1085
+ new[i] = new[i] + "," + EVEN_SCALE
1080
1086
  return new
1081
1087
  if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
1082
1088
  return None
@@ -2025,7 +2031,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
2025
2031
  why = f"{tool} {flag} could not run: {exc}"
2026
2032
  if why:
2027
2033
  _LIST_ERRORS[flag] = why
2028
- what = {"-encoders": "encoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
2034
+ what = {"-encoders": "encoder", "-decoders": "decoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
2029
2035
  warn(f"could not read this ffmpeg build's {what} list ({why}); nothing that needs it can be confirmed, which is not the same as the build lacking it",
2030
2036
  "ffmpeg_list_unreadable", list=flag.lstrip("-"), tool=tool, error=why)
2031
2037
  return names