ffmpeg-skill 0.12.0 → 0.16.12

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.
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import os
11
11
  import platform
12
+ import re
12
13
  import shutil
13
14
  import subprocess
14
15
  import sys
@@ -205,6 +206,10 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
205
206
  success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
206
207
  to write so a caller scanning the output directory after a failure never mistakes a partial
207
208
  artifact for a real (if unverified) one."""
209
+ # run() also executes ffprobe, whose last argument is an INPUT. Never
210
+ # interpret a read-only tool's failure as permission to remove that file.
211
+ if not _is_ffmpeg(cmd):
212
+ return
208
213
  output = cmd[-1]
209
214
  if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
210
215
  return
@@ -269,6 +274,20 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
269
274
  return _run_captured(list(cmd), check)
270
275
 
271
276
 
277
+ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
278
+ """Run an ffmpeg command that already maps its video/audio, trying first to also
279
+ stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
280
+ there are none). A source whose subtitle codec cannot be copied into the target container
281
+ (e.g. a container change) makes that first attempt fail; retry the same command without the
282
+ extra maps rather than let a tool that never touched subtitles start hard-failing because of
283
+ them. `cmd` is the full argv *without* the output path. Returns True only when the
284
+ retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
285
+ if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
286
+ return False
287
+ run(cmd + [output])
288
+ return True
289
+
290
+
272
291
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
273
292
  """Plain run with stdout/stderr captured."""
274
293
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
@@ -325,7 +344,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
325
344
 
326
345
 
327
346
  def shell_quote(s: str) -> str:
328
- if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
347
+ if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
329
348
  return "'" + s.replace("'", "'\\''") + "'"
330
349
  return s
331
350
 
@@ -387,7 +406,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
387
406
  return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
388
407
  "video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
389
408
  "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
390
- "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
409
+ "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
391
410
  die(f"input not found: {path}")
392
411
  ffprobe = require_tool("ffprobe")
393
412
  proc = run(
@@ -405,6 +424,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
405
424
  video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
406
425
  audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
407
426
  subs = [s for s in streams if s.get("codec_type") == "subtitle"]
427
+ data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
408
428
 
409
429
  duration = _to_float(fmt.get("duration"))
410
430
  if duration is None and video:
@@ -423,6 +443,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
423
443
  "video": None,
424
444
  "audio": None,
425
445
  "subtitle_streams": len(subs),
446
+ "data_streams": data_stream_count,
426
447
  # every subtitle stream in file order: index n here is `-map 0:s:n`
427
448
  "subtitle_stream_details": [{
428
449
  "index": n,
@@ -582,15 +603,84 @@ def escape_filter_path(path: str) -> str:
582
603
  return p
583
604
 
584
605
 
606
+ def default_font_file(font_name: str) -> Optional[str]:
607
+ """Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
608
+ `fontfile=<path>` instead of `font=<name>`, when possible.
609
+
610
+ On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
611
+ resolution crashes with an access violation whenever it has to resolve a font by family name
612
+ -- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
613
+ confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
614
+ ignored on Windows for that reason: a fixed, near-universally-present system font is used
615
+ instead of trying to resolve the requested family (which would crash the same way).
616
+
617
+ On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
618
+ same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
619
+ just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
620
+ sidesteps the same class of crash if it exists on some build there too, but the fallback below
621
+ (returning None) is exercised routinely there, not just on failure.
622
+
623
+ Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
624
+ Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
625
+ """
626
+ if platform.system() == "Windows":
627
+ windir = os.environ.get("WINDIR", "C:\\Windows")
628
+ candidate = Path(windir) / "Fonts" / "arial.ttf"
629
+ return str(candidate) if candidate.exists() else None
630
+ exe = shutil.which("fc-match")
631
+ if not exe:
632
+ return None
633
+ try:
634
+ proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
635
+ except (subprocess.TimeoutExpired, OSError):
636
+ return None
637
+ if proc.returncode != 0:
638
+ return None
639
+ path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
640
+ return path if path and os.path.exists(path) else None
641
+
642
+
585
643
  def escape_drawtext(text: str) -> str:
644
+ """Escape `text` for use as a single-quoted drawtext option value (`text='<this>'`).
645
+
646
+ Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash
647
+ escape regardless of the surrounding quotes -- the graph parser still splits on an
648
+ unescaped `,`/`;` or ends an option list on an unescaped `:`/`[`/`]` even while
649
+ "inside" a quoted value. The quote character itself has no reliable backslash
650
+ escape at all: `\\'` and the POSIX shell `'\\''` close-insert-reopen trick both
651
+ parse fine in a simple `-vf` chain, but silently corrupt a `-filter_complex` chain
652
+ that uses explicit `[label]` pads -- confirmed by rendering the result: the text
653
+ value doesn't end where the quote closes it, and trailing option names/values
654
+ (fontfile=..., fontsize=...) leak into the rendered picture as literal text
655
+ instead of being parsed as options. A quote is therefore dropped outright rather
656
+ than escaped -- losing one apostrophe from a label is a fine trade for "the
657
+ filter graph parses the way the code intends, on every call shape this codebase
658
+ uses it in".
659
+
660
+ `%` has the same problem the quote character did: `\%` is not a real escape as
661
+ far as drawtext's own text-expansion scanner (on by default, `expansion=normal`,
662
+ for `%{pts}`/`%{localtime}`/etc.) is concerned -- a bare backslash-escaped `%`
663
+ always logs "Stray % near ..." (confirmed with the minimal case
664
+ `text='100\%done'`), which is merely noisy on one ffmpeg
665
+ build (the warning is printed, the file still gets written) but a hard filtering
666
+ failure that writes no output at all on another. Every caller of this function
667
+ only ever wants a literal label, never `%{...}` expansion, so `%` is dropped
668
+ outright rather than chasing a per-build-safe escape (`expansion=none` on the
669
+ filter would also fix it, but needs touching every drawtext= call site instead
670
+ of the one shared helper). Control characters (newline, tab, ...) are dropped
671
+ for the same reason: none are meaningful in a one-line burnt-in label, and
672
+ unlike the graph-special characters above, ffmpeg's own text-expansion scanner
673
+ -- not just the graph parser -- is involved in whether they're actually safe."""
674
+ text = re.sub(r"[\x00-\x1f\x7f]", "", text)
586
675
  return (
587
- text.replace("\\", "\\\\")
676
+ text.replace("'", "")
677
+ .replace("%", "")
678
+ .replace("\\", "\\\\")
588
679
  .replace(":", "\\:")
589
- .replace("'", "\\\\\\'")
590
- .replace("%", "\\%")
591
680
  .replace(",", "\\,")
592
681
  .replace("[", "\\[")
593
682
  .replace("]", "\\]")
683
+ .replace(";", "\\;")
594
684
  )
595
685
 
596
686
 
@@ -762,6 +852,23 @@ def color_hex(value: str) -> str:
762
852
  return v.upper()
763
853
 
764
854
 
855
+ _COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@[0-9.]+)?$")
856
+
857
+
858
+ def validate_color(value: str, flag: str = "--color") -> str:
859
+ """Refuse a colour argument that isn't a plain ffmpeg colour token (named colour, 0xRRGGBB[AA],
860
+ #RRGGBB[AA], optionally with an @alpha suffix). Every caller that string-formats a colour flag
861
+ straight into a filter graph (color=c=..., tpad=...:color=..., rotate=...:fillcolor=...) must
862
+ validate it first -- ffmpeg filter options are comma/colon-delimited, so an unvalidated value
863
+ containing those characters lets a caller splice in an entirely different filter (a real,
864
+ demonstrated filter-graph injection: --color "black,drawtext=text=..." renders arbitrary burnt-in
865
+ text), not just an odd colour. This is the same "no filter graph accepted from the caller"
866
+ invariant every other typed flag in this codebase already holds to."""
867
+ if not _COLOR_TOKEN_RE.match(value):
868
+ die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
869
+ return value
870
+
871
+
765
872
  def print_json(obj: Any) -> None:
766
873
  sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
767
874
 
@@ -79,6 +79,42 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
79
79
  "crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
80
80
  required=FF + [X264, AAC], optional=[HDR_X265],
81
81
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
82
+ "deinterlace": dict(role="execution", inputs=["video asset"], outputs=["deinterlaced (progressive) video artifact"],
83
+ required=FF + [X264, AAC, "filter:yadif"], optional=[HDR_X265],
84
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
85
+ "denoise": dict(role="execution", inputs=["video asset"], outputs=["denoised video artifact"],
86
+ required=FF + [X264, AAC, "filter:hqdn3d"], optional=[HDR_X265],
87
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
88
+ "cropdetect": dict(role="analysis", inputs=["video asset"], outputs=["detected crop rectangle JSON on stdout (no file)"],
89
+ required=FF + ["filter:cropdetect"], optional=[],
90
+ video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=False, idempotency="environment_dependent", deterministic=False),
91
+ "redact": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the given pixel rectangle blurred or pixelated"],
92
+ required=FF + [X264, AAC, "filter:boxblur"], optional=[HDR_X265],
93
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
94
+ "waveform": dict(role="execution", inputs=["audio or video asset (audio track only)"], outputs=["generated waveform or spectrum visualization video artifact"],
95
+ required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"}],
96
+ video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
97
+ "sphere": dict(role="execution", inputs=["360/spherical video asset"], outputs=["flat rectilinear video artifact of the chosen viewport"],
98
+ required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
99
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
100
+ "grid": dict(role="execution", inputs=["cols*rows video assets, filled left-to-right top-to-bottom"], outputs=["composited grid video artifact"],
101
+ required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default)"}, {"capability": AAC, "when": "--audio-from"}],
102
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
103
+ "straighten": dict(role="execution", inputs=["video asset"], outputs=["video artifact rotated by the given angle (horizon correction)"],
104
+ required=FF + [X264, AAC, "filter:rotate"], optional=[HDR_X265],
105
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
106
+ "freeze": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a frame held for the given duration"],
107
+ required=FF + [X264, AAC, "filter:tpad"], optional=[HDR_X265],
108
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
109
+ "pad": dict(role="execution", inputs=["video asset"], outputs=["video artifact with black/silent padding added at the start/end"],
110
+ required=FF + [X264, AAC, "filter:tpad"], optional=[HDR_X265],
111
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
112
+ "speedramp": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a stepped speed ramp applied across segments"],
113
+ required=FF + [X264, AAC], optional=[HDR_X265],
114
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
115
+ "loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration"],
116
+ required=FF + [X264, AAC], optional=[],
117
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
82
118
  "insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
83
119
  required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
84
120
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
@@ -111,29 +147,32 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
111
147
  required=FF + [X264, AAC], optional=[HDR_X265],
112
148
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
113
149
  "audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
114
- required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
115
- {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"}] + AUDIO_OUT,
150
+ required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
151
+ {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"},
152
+ {"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
116
153
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
117
154
  "loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
118
- required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
155
+ required=FF + ["filter:loudnorm"], optional=[{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
119
156
  video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
120
157
  "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
121
158
  required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"}] + AUDIO_OUT,
122
159
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
123
160
  "join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
124
- required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265] + AUDIO_OUT,
161
+ required=FF + ["filter:xfade", "filter:acrossfade"],
162
+ optional=[{"capability": X264, "when": "joining video inputs"}, {"capability": AAC, "when": "joining video inputs, or an audio-only join whose output extension isn't .mp3/.opus/.ogg/.flac"}, HDR_X265] + AUDIO_OUT,
125
163
  video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
126
164
  "color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
127
165
  required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
128
166
  {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
129
167
  {"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
130
- {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
168
+ {"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
169
+ {"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
131
170
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
132
171
  "proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
133
172
  required=FF + [X264, AAC], optional=[HDR_X265],
134
173
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
135
174
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
136
- required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
175
+ required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
137
176
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
138
177
  {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
139
178
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
@@ -168,6 +207,7 @@ DRY_RUN_ANALYSIS = {
168
207
  "multicam": "audio is decoded to align the cameras; the switched output is not written",
169
208
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
170
209
  "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
210
+ "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
171
211
  }
172
212
  DRY_RUN_NOTES = {
173
213
  "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
@@ -188,6 +228,18 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
188
228
  "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, a VFR source, or a keyframe snap past --tolerance (see cut.py --json: mode, keyframe_snapped)"),
189
229
  "fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
190
230
  "crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
231
+ "deinterlace": dict(video="always", audio="always", note="the yadif filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
232
+ "denoise": dict(video="always", audio="always", note="the hqdn3d filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
233
+ "cropdetect": dict(video="never", audio="never", note="analysis only, no artifact"),
234
+ "redact": dict(video="always", audio="always", note="the boxblur/pixelate filter_complex always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
235
+ "waveform": dict(video="always", audio="always", note="always encodes a fresh generated visualization clip; the source audio is always re-encoded to AAC"),
236
+ "sphere": dict(video="always", audio="always", note="the v360 filter always forces a re-encode of the video stream; audio is passed through to AAC unchanged"),
237
+ "straighten": dict(video="always", audio="always", note="the rotate filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
238
+ "grid": dict(video="always", audio="conditional", note="the xstack composite always forces a re-encode of the video stream; there is no audio at all unless --audio-from picks one input's track, which is then re-encoded to AAC"),
239
+ "freeze": dict(video="always", audio="always", note="the tpad/concat filter graph always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
240
+ "pad": dict(video="always", audio="always", note="the tpad filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
241
+ "speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
242
+ "loop": dict(video="always", audio="always", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present"),
191
243
  "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
192
244
  "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
193
245
  "reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
@@ -482,6 +534,50 @@ def _default_font() -> str:
482
534
  return str(BRAND_DEFAULTS["font"])
483
535
 
484
536
 
537
+ def _drawtext_probe() -> Dict[str, Any]:
538
+ """Actually render one frame through drawtext, rather than trusting `-filters` alone.
539
+
540
+ `-filters` only reports whether this ffmpeg build was compiled with the filter; it never
541
+ proves drawtext can actually execute. On some real Windows ffmpeg builds (winget's gyan.dev
542
+ 9.x), drawtext crashes with an access violation whenever it has to resolve a font through
543
+ fontconfig -- with or without a valid fonts.conf -- so `-filters` correctly reports drawtext
544
+ present and doctor used to report the capability `available` anyway; every tool that actually
545
+ used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
546
+
547
+ This runs the cheapest real drawtext render there is: a one-frame synthetic clip, no font=
548
+ given at all (ffmpeg's own default resolution -- the same path that crashed). A clean exit
549
+ means drawtext genuinely works here. Anything that could not prove either way (no ffmpeg,
550
+ timeout, an ordinary nonzero exit with a real ffmpeg error) is `unknown`, same "unknown is not
551
+ missing" principle as every other capability here. A crash specifically -- killed by signal on
552
+ POSIX, or an unhandled access violation surfacing as a huge unsigned exit code on Windows -- is
553
+ the one case this function exists to catch, and folds into `missing`: the filter is present in
554
+ the build but cannot actually be used as ffmpeg's own default would use it.
555
+ """
556
+ exe = shutil.which("ffmpeg")
557
+ if not exe:
558
+ return {"status": "unknown", "detail": "ffmpeg not on PATH"}
559
+ try:
560
+ proc = subprocess.run(
561
+ [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
562
+ "-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
563
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT,
564
+ )
565
+ except subprocess.TimeoutExpired:
566
+ return {"status": "unknown", "detail": f"drawtext probe did not exit within {_DETECT_TIMEOUT}s"}
567
+ except OSError as e:
568
+ return {"status": "unknown", "detail": f"drawtext probe: {e}"}
569
+ if proc.returncode == 0:
570
+ return {"status": "available", "detail": "one-frame drawtext render succeeded"}
571
+ if proc.returncode < 0 or proc.returncode >= 0x80000000:
572
+ return {"status": "missing",
573
+ "detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
574
+ "the filter is present in this build but cannot be used as-is, likely a fontconfig "
575
+ "resolution crash (see https://github.com/kajisho5/ffmpeg-skill/issues/100); "
576
+ "pass an explicit --font-file to every drawtext tool as a workaround"}
577
+ tail = " ".join(proc.stderr.strip().splitlines()[-2:])
578
+ return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
579
+
580
+
485
581
  def _font_available(font_name: str) -> Dict[str, Any]:
486
582
  """Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
487
583
  installed, distinct from silently resolving to a substitute.
@@ -561,6 +657,7 @@ def doctor() -> Dict[str, Any]:
561
657
  sets = {k: set(v["names"]) for k, v in listings.items()}
562
658
  state: Dict[str, str] = {} # capability -> available | missing | unknown
563
659
  wanted = required_capabilities()
660
+ drawtext_probe: Optional[Dict[str, Any]] = None
564
661
 
565
662
  def _from(kind: str, name: str) -> str:
566
663
  lst = listings[kind]
@@ -577,6 +674,23 @@ def doctor() -> Dict[str, Any]:
577
674
  state[cap] = "available" if shutil.which("ffprobe") else "missing"
578
675
  elif cap.startswith("encoder:"):
579
676
  state[cap] = _from("encoders", cap[8:])
677
+ elif cap == "filter:drawtext":
678
+ listing_state = _from("filters", "drawtext")
679
+ if listing_state == "available":
680
+ # Only escalate an "available" listing to "missing" on an unambiguous crash --
681
+ # an ordinary nonzero exit (a real ffmpeg's own -h/-filters-only build variance, or
682
+ # in tests a fake ffmpeg shim that only implements -filters/-encoders/-bsfs/-version)
683
+ # proves nothing either way, so it leaves the listing-based result standing rather
684
+ # than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
685
+ # is the one case this exists to catch.
686
+ probe = _drawtext_probe()
687
+ if probe["status"] == "missing":
688
+ drawtext_probe = probe
689
+ state[cap] = "missing"
690
+ else:
691
+ state[cap] = "available"
692
+ else:
693
+ state[cap] = listing_state
580
694
  elif cap.startswith("filter:"):
581
695
  state[cap] = _from("filters", cap[7:])
582
696
  elif cap.startswith("bsf:"):
@@ -591,6 +705,8 @@ def doctor() -> Dict[str, Any]:
591
705
  unknown = sorted(c for c, st in state.items() if st == "unknown")
592
706
  unknown_required = [c for c in unknown if c in wanted["required"]]
593
707
  errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
708
+ if drawtext_probe is not None and drawtext_probe["status"] != "available":
709
+ errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
594
710
  return {
595
711
  "version": skill_version(),
596
712
  "python": ".".join(str(x) for x in sys.version_info[:3]),
@@ -630,6 +746,12 @@ def _capability_fix_hint(cap: str) -> str:
630
746
  full_hint = "install/build ffmpeg with it enabled"
631
747
  if cap.startswith("encoder:"):
632
748
  return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
749
+ if cap == "filter:drawtext":
750
+ return ("drawtext crashed instead of rendering a frame (see errors[] for the exit detail) -- "
751
+ "every drawtext tool already resolves a concrete font file automatically when one can "
752
+ "be found (#100); if it still crashes, use --no-timecode with look.py or scenes.py "
753
+ "--sheet to skip drawtext entirely, or pass --font-file explicitly to overlay.py/"
754
+ "graphics.py (the two that accept it) rather than relying on font= resolution")
633
755
  if cap.startswith("filter:"):
634
756
  return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
635
757
  if cap.startswith("bsf:"):
@@ -14,7 +14,7 @@ import argparse
14
14
  import math
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
17
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -49,11 +49,20 @@ def main() -> int:
49
49
  c0, c1 = args.gradient.split(":")
50
50
  except ValueError:
51
51
  die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
52
+ validate_color(c0, "--gradient")
53
+ validate_color(c1, "--gradient")
52
54
  rad = math.radians(args.angle)
53
55
  x1 = round(args.width * math.cos(rad))
54
56
  y1 = round(args.width * math.sin(rad))
55
- src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}"
57
+ # gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
58
+ # slow rotation applied every frame), so without pinning both, this "static" background
59
+ # was neither reproducible between runs nor actually static across its own duration --
60
+ # violating the bit_exact/deterministic contract _contract.py declares for this tool.
61
+ # speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
62
+ # motionless the filter allows.
63
+ src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}:seed=0:speed=1e-05"
56
64
  else:
65
+ validate_color(args.color, "--color")
57
66
  src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
58
67
 
59
68
  output = args.output
package/scripts/batch.py CHANGED
@@ -36,6 +36,13 @@ from _common import STATE, add_common, apply_common, die, emit, info
36
36
 
37
37
  HERE = Path(__file__).resolve().parent
38
38
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
39
+ # recipe steps name the script to run as plain, untrusted JSON -- run_step() joins it onto HERE
40
+ # with the `/` operator, which silently ignores the left side when the right side is itself an
41
+ # absolute path (Path("/scripts") / "/tmp/evil.py" == Path("/tmp/evil.py")), and does nothing to
42
+ # stop a "../" traversal either. Without this allowlist, a batch.json a caller didn't author
43
+ # themselves (from a template, a shared config, anywhere) could name any Python file on disk and
44
+ # have it executed with the caller's own privileges on every matching media file.
45
+ ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswith("_")}
39
46
 
40
47
 
41
48
  def file_key(path: Path) -> str:
@@ -51,11 +58,27 @@ def file_key(path: Path) -> str:
51
58
 
52
59
 
53
60
  def recipe_key(recipe: Dict[str, Any]) -> str:
54
- return hashlib.sha1(json.dumps(recipe, sort_keys=True).encode()).hexdigest()[:12]
61
+ # A "project" recipe is just {"project": "<path>", "clip_key": N} -- the actual settings
62
+ # (export preset, captions, everything) live in the file at that path, not in this dict.
63
+ # Hashing only `recipe` meant editing project.json's content (without touching batch.json
64
+ # itself) left the key, and so every cache hit, unchanged: a preset swapped from "copy" to
65
+ # "x" (a real re-encode) still served the old cached output. Fold the referenced file's own
66
+ # content into the key so a content change invalidates the cache like any other edit would.
67
+ project_content = ""
68
+ if recipe.get("project"):
69
+ try:
70
+ project_content = Path(recipe["project"]).read_text(encoding="utf-8")
71
+ except OSError:
72
+ pass
73
+ return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
55
74
 
56
75
 
57
76
  def run_step(argv: List[str]) -> bool:
58
- cmd = [sys.executable, str(HERE / argv[0])] + argv[1:]
77
+ script = argv[0]
78
+ if script not in ALLOWED_STEP_SCRIPTS:
79
+ die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
80
+ f"(must be a bare filename like 'silence.py', found in scripts/)")
81
+ cmd = [sys.executable, str(HERE / script)] + argv[1:]
59
82
  if STATE["fast"]:
60
83
  cmd.append("--fast")
61
84
  if STATE["dry_run"]:
@@ -68,10 +91,20 @@ def run_step(argv: List[str]) -> bool:
68
91
  return True
69
92
 
70
93
 
71
- def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict[str, Any]:
94
+ def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
72
95
  suffix = recipe.get("suffix", "_out")
96
+ # By default final_ext falls back to each source's OWN extension, so files that only differ
97
+ # by extension don't collide -- but a recipe that fixes "ext" (e.g. converting a folder of
98
+ # mixed .mp4/.mov masters to one format) makes every source with the same stem land on the
99
+ # same final path, e.g. clip.mp4 and clip.mov both -> clip_out.mp4. process() has no collision
100
+ # detection of its own; see one_pass()'s pre-flight check, which uses this same computation
101
+ # to catch that before any file is actually processed (and the earlier one silently clobbered).
73
102
  final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
74
- final = outdir / f"{src.stem}{suffix}.{final_ext}"
103
+ return outdir / f"{src.stem}{suffix}.{final_ext}"
104
+
105
+
106
+ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict[str, Any]:
107
+ final = final_path(src, recipe, outdir)
75
108
  t0 = time.time()
76
109
  if recipe.get("project"):
77
110
  proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
@@ -140,6 +173,18 @@ def main() -> int:
140
173
  def one_pass() -> List[Dict[str, Any]]:
141
174
  results = []
142
175
  files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
176
+ # Two different sources can compute the same final path (most often a fixed recipe "ext"
177
+ # collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
178
+ # processing anything, rather than letting the later one silently overwrite the earlier
179
+ # one's finished output with the cache still recording both as "ok".
180
+ by_final: Dict[Path, List[Path]] = {}
181
+ for src in files:
182
+ by_final.setdefault(final_path(src, recipe, outdir), []).append(src)
183
+ collisions = {dst: srcs for dst, srcs in by_final.items() if len(srcs) > 1}
184
+ if collisions:
185
+ detail = "; ".join(f"{dst.name} <- {', '.join(s.name for s in srcs)}" for dst, srcs in collisions.items())
186
+ die(f"{len(collisions)} output filename collision(s) in this batch -- rename the sources, "
187
+ f"or add a distinguishing \"suffix\"/\"ext\" per run, or split into separate globs: {detail}")
143
188
  for src in files:
144
189
  key = f"{file_key(src)}:{rkey}"
145
190
  hit = cache.get(key)
@@ -152,7 +197,15 @@ def main() -> int:
152
197
  results.append(r)
153
198
  if r["ok"] and not STATE["dry_run"]:
154
199
  cache[key] = r
155
- cache_path.write_text(json.dumps(cache, indent=2), encoding="utf-8")
200
+ # write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
201
+ # a concurrent manual run) could leave a truncated file that json.loads() above
202
+ # then silently treats as "no cache" (a ValueError -> {}), discarding every prior
203
+ # entry. Write to a sibling temp file and rename into place: same-directory
204
+ # renames are atomic on POSIX and os.replace() is atomic on Windows too, so a
205
+ # reader only ever sees the old complete file or the new complete file.
206
+ tmp = cache_path.parent / f"{cache_path.name}.tmp{os.getpid()}"
207
+ tmp.write_text(json.dumps(cache, indent=2), encoding="utf-8")
208
+ os.replace(tmp, cache_path)
156
209
  return results
157
210
 
158
211
  results = one_pass()