ffmpeg-skill 3.8.18 → 3.8.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/contract.md CHANGED
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
26
26
  | Field | Meaning | Changes when |
27
27
  |---|---|---|
28
28
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
29
- | `skill.version` | the npm / package.json version (`3.8.18`) | any release |
29
+ | `skill.version` | the npm / package.json version (`3.8.20`) | any release |
30
30
 
31
31
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
32
32
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
154
154
  "deprecated": [],
155
155
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
156
156
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
157
- "skill": {"id": "ffmpeg-skill", "version": "3.8.18", "execution_mode": "local", "kind": "execution",
157
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.20", "execution_mode": "local", "kind": "execution",
158
158
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
159
159
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
160
160
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -956,7 +956,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
956
956
  `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
957
957
  `output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
958
958
  `media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
959
- `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `still_unreadable` (a file with an image name that ffmpeg cannot decode as one), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
959
+ `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `still_unreadable` (a file with an image name that ffmpeg cannot decode as one; for `sequence.py`, a frame of the sequence that does not decode, when its decode check completes; when the check cannot be made a note says so and the encode runs), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
960
960
  `render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
961
961
  `encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
962
962
  and `english_only`), and `error.details`, the facts the message is written from, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.18",
3
+ "version": "3.8.20",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
6
6
  "keywords": [
@@ -311,7 +311,8 @@ fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
311
311
  `speed` retimes video and audio together (pitch-preserving `atempo`); it
312
312
  refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
313
313
  (frame blending, fast) or `--smooth interpolate` (motion-compensated
314
- `minterpolate`, fluid but roughly 10-20x slower than realtime). `trim` keeps
314
+ `minterpolate`, fluid but roughly 10-20x slower than realtime; its frames are
315
+ made at `--fps` when given, else at the source's rate). `trim` keeps
315
316
  the head (or the middle with `--from-center`). `--width`/`--height` set the
316
317
  output size: give one and the other follows the aspect (source aspect if
317
318
  `--aspect` isn't also given); give both for an exact frame. `--rotate` applies
@@ -522,7 +523,12 @@ sequence.py --dir DIR --pattern "frame_%04d.png"|"*.png" --fps N
522
523
  Turns a numbered or glob-matched set of still images into a video. Glob
523
524
  matches are ordered naturally (`img2` before `img10`). The match
524
525
  is checked on disk before ffmpeg runs (an empty match or a missing first
525
- frame is refused here, not discovered from an opaque ffmpeg error).
526
+ frame is refused here, not discovered from an opaque ffmpeg error), and every
527
+ frame is decoded once (ffprobe) before the encode. When that check completes, a
528
+ frame that is not an image it can decode (a text file named `.png`, a truncated
529
+ PNG) is refused, `kind: input`, `reason: still_unreadable`, with the frames
530
+ named. When ffprobe fails on the frame list or reads back no frame, the frames
531
+ are not checked: a note says so and the encode runs.
526
532
 
527
533
  ### waveform.py — audio waveform/spectrum visualization video
528
534
  ```
@@ -360,12 +360,16 @@ def color_hex(value: str) -> str:
360
360
  return v.upper()
361
361
 
362
362
 
363
- _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(?:\.\d+)?|1(?:\.0+)?|\.\d+))?$") # alpha is 0..1; "red@2" used to reach ffmpeg
363
+ # Six or eight hex digits, with 0x, with # or bare (#535: bare ones starting with a digit, 0B1D2A,
364
+ # were refused, and seven passed with a prefix and failed inside ffmpeg), or a word that starts
365
+ # with a letter; alpha is 0..1 ("red@2" used to reach ffmpeg). Nothing else reaches the graph.
366
+ _HEX_DIGITS = r"(?:[0-9A-Fa-f]{8}|[0-9A-Fa-f]{6})"
367
+ _COLOR_TOKEN_RE = re.compile(r"^((?:0[xX]|#)?%s|[A-Za-z][A-Za-z0-9]*)(@(?:0(?:\.\d+)?|1(?:\.0+)?|\.\d+))?$" % _HEX_DIGITS)
364
368
 
365
369
 
366
370
  def validate_color(value: str, flag: str = "--color") -> str:
367
- """Refuse a colour argument that isn't a plain ffmpeg colour token (named colour, 0xRRGGBB[AA],
368
- #RRGGBB[AA], optionally with an @alpha suffix). Every caller that string-formats a colour flag
371
+ """Refuse a colour argument that isn't a plain ffmpeg colour token (named colour, RRGGBB[AA],
372
+ 0xRRGGBB[AA], #RRGGBB[AA], optionally with an @alpha suffix). Every caller that string-formats a colour flag
369
373
  straight into a filter graph (color=c=..., tpad=...:color=..., rotate=...:fillcolor=...) must
370
374
  validate it first -- ffmpeg filter options are comma/colon-delimited, so an unvalidated value
371
375
  containing those characters lets a caller splice in an entirely different filter (a real,
@@ -374,9 +378,10 @@ def validate_color(value: str, flag: str = "--color") -> str:
374
378
  invariant every other typed flag in this codebase already holds to."""
375
379
  m = _COLOR_TOKEN_RE.match(value)
376
380
  if not m:
377
- die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
381
+ die(f"{flag} must be a plain colour (a name, or six or eight hex digits RRGGBB[AA], bare or after 0x or #, "
382
+ f"optionally @alpha), got '{value}'")
378
383
  word = m.group(1)
379
- if word[0] not in "#0" and word.lower() not in FFMPEG_COLOR_NAMES and not _BARE_HEX.match(word):
384
+ if word[0].isalpha() and word.lower() not in FFMPEG_COLOR_NAMES and not _BARE_HEX.match(word):
380
385
  # A189 (#465): any word passed, and an unknown name failed inside ffmpeg ("Cannot find
381
386
  # color") as kind ffmpeg, after a dry run that had said completed. ffmpeg reads a word as
382
387
  # one of its names, or else as bare hex digits.
@@ -21,6 +21,16 @@ from _common.runner import read_json
21
21
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
22
22
 
23
23
 
24
+ def boxblur_chroma_radius(want: int) -> str:
25
+ """boxblur's chroma radius as an expression boxblur evaluates on the chroma planes' own size
26
+ (cw x ch, so a 4:1:1 or 4:1:0 frame's quarter-width planes are what is measured, not an assumed
27
+ 4:2:0): `want`, or the largest radius that plane holds, floor((min(cw, ch) - 1) / 2), or 0 on a
28
+ plane too small for 1. On 4:2:0 that is redact.py's earlier (min(w, h) // 2 - 1) // 2. boxblur
29
+ refuses a radius above half the plane (6.1); 7.1 also refuses 1 on a 2 px plane, which this
30
+ bound leaves out (#527)."""
31
+ return f"max(0\\,min({int(want)}\\,floor((min(cw\\,ch)-1)/2)))"
32
+
33
+
24
34
  def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0,
25
35
  meta: Optional[Dict[str, Any]] = None) -> str:
26
36
  """The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
@@ -41,9 +51,12 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken
41
51
  # boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
42
52
  # <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
43
53
  radius = max(1, min(int(blur), max(1, min(out_w, out_h) // 2 - 1)))
54
+ # left unset, the chroma radius was the luma one, and a 4:2:0 68x120 frame's 34x60 chroma
55
+ # planes failed at 20 ("must be <= 17", #527). On a larger frame it is still the luma one
56
+ chroma = boxblur_chroma_radius(radius)
44
57
  return (f"split[__fitfg][__fitbg];"
45
58
  f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
46
- f"boxblur={radius}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
59
+ f"boxblur={radius}:2:{chroma}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
47
60
  f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
48
61
  f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
49
62
  pad = f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color="
@@ -1374,6 +1387,10 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
1374
1387
  for key in ("logo", "font_file"):
1375
1388
  if brand.get(key) and not os.path.isabs(brand[key]):
1376
1389
  brand[key] = str(base / brand[key])
1390
+ # a file the run reads: a --plan binds it and is never written over it (review of #534's
1391
+ # sweep: caption.py hands libass the font's folder, so its plan named neither)
1392
+ if brand.get(key) and os.path.isfile(brand[key]) and brand[key] not in STATE.plan_inputs:
1393
+ STATE.plan_inputs.append(brand[key])
1377
1394
  brand["_path"] = str(path)
1378
1395
  # What the FILE said, separate from BRAND_DEFAULTS' filler: a brand.json that never mentions
1379
1396
  # a font must not read as "the caller chose a font" (which would switch font-by-script off).
@@ -261,6 +261,7 @@ DRY_RUN_ANALYSIS = {
261
261
  }
262
262
  DRY_RUN_NOTES = {
263
263
  "verify": "not supported: the flag is accepted but the steps run and outputs are written",
264
+ "sequence": "no ffmpeg runs; ffprobe decodes every frame of the sequence once, and when that check completes a frame that does not decode as an image is refused (still_unreadable) as in a real run; when ffprobe fails on the frame list or reads back no frame, a note says the frames were not checked and the dry run completes; the video is not written",
264
265
  }
265
266
 
266
267
  # Whether a tool re-encodes each stream *when that stream is present in the input* -- not whether
@@ -436,7 +437,7 @@ ERROR_REASONS = {
436
437
  "output_is_directory": "the output path is a directory; details: {output}",
437
438
  "media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
438
439
  "media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
439
- "still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}",
440
+ "still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}. sequence.py: a frame of the sequence that does not decode (every frame is decoded once before the encode; when that check cannot be made, a note says so and nothing is refused); details: {path (the first), frames, frames_unreadable, frames_total}",
440
441
  "media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
441
442
  "join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
442
443
  "output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
@@ -1574,8 +1574,11 @@ def main() -> int:
1574
1574
  refuse_existing_outputs(todo)
1575
1575
  # and the rest of the side-file guards (#408): never an input, the video, a missing
1576
1576
  # directory or an option-shaped name, even under --overwrite
1577
+ # a brand file's font_file and logo are read too (review of #534's sweep: --write-srt or
1578
+ # --write-ass naming the brand's font replaced it)
1577
1579
  side_inputs = [q for q in (args.input, getattr(args, "text", None), getattr(args, "ass", None),
1578
- getattr(args, "brand", None), *(t[0] for t in srt_tracks)) if isinstance(q, str)]
1580
+ getattr(args, "brand", None), brand.get("font_file"), brand.get("logo"),
1581
+ *(t[0] for t in srt_tracks)) if isinstance(q, str) and q]
1579
1582
  for path in todo:
1580
1583
  guard_sidecar(path, *side_inputs)
1581
1584
 
package/scripts/cut.py CHANGED
@@ -1618,7 +1618,8 @@ def main() -> int:
1618
1618
  + "; give every segment at least one frame", kind="input")
1619
1619
 
1620
1620
  output = args.output or default_output(args.input, "cut")
1621
- refuse_output_is_input(output, args.input)
1621
+ # --snap-source is read for its beat grid and is never an -i of the cut (#522's sweep)
1622
+ refuse_output_is_input(output, args.input, *([args.snap_source] if args.snap_source else []))
1622
1623
  ext = os.path.splitext(output)[1].lower() or ".mp4" # an upper-case .MP4 is still faststart
1623
1624
 
1624
1625
  # A segment that runs past the end of the video, with a join after it: whatever joins it (the
package/scripts/fit.py CHANGED
@@ -201,7 +201,10 @@ def main() -> int:
201
201
  vf.append(f"setpts={1/factor:.8f}*PTS")
202
202
  src_fps = meta["video"].get("fps") or 30.0
203
203
  if factor < 1.0 and args.smooth == "interpolate":
204
- vf.append(f"minterpolate=fps={src_fps:g}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1")
204
+ # the new frames are made at the rate delivered: made at the source's 30 and
205
+ # then put on --fps 60 by the fps filter below, every other frame of the slow
206
+ # motion was a repeat (#527). Without --fps it is the source's rate, as before
207
+ vf.append(f"minterpolate=fps={args.fps or src_fps:g}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1")
205
208
  elif factor < 1.0 and args.smooth == "blend":
206
209
  vf.append(f"fps={src_fps:g}")
207
210
  vf.append("tblend=all_mode=average")
@@ -259,11 +262,14 @@ def main() -> int:
259
262
  # A speed change without --fps has already been put on the delivered grid above
260
263
  if args.fps:
261
264
  vf.append(f"fps={args.fps:g}")
262
- elif meta["video"].get("variable_frame_rate_suspected") and not regridded:
265
+ elif meta["video"].get("variable_frame_rate_suspected"):
266
+ # said whichever filter conforms it: a speed change's regrid above is that conform, and the
267
+ # line was missing there (#527)
263
268
  info("source looks variable-frame-rate; conforming to constant fps automatically")
264
- # the conform is the fps filter, as --fps is: -fps_mode cfr alone let a frame run up to
265
- # two frames late against the sound (#512)
266
- vf.append(cfr_filter(meta))
269
+ if not regridded:
270
+ # the conform is the fps filter, as --fps is: -fps_mode cfr alone let a frame run up to
271
+ # two frames late against the sound (#512)
272
+ vf.append(cfr_filter(meta))
267
273
 
268
274
  # ---- aspect / size
269
275
  if args.aspect or args.width or args.height:
@@ -36,7 +36,7 @@ from typing import List, Optional, Tuple
36
36
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
37
  from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
38
38
  from _common.drawtext import shaping_refusal
39
- from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, cfr_at_input, with_cfr,
39
+ from _common import (guard_sidecar, refuse_output_is_input, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, cfr_at_input, with_cfr,
40
40
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
41
41
  ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, probe, rgb_chain_to_bt709, run_keeping_subtitles, video_args,
42
42
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
@@ -673,6 +673,9 @@ def main(force_route: Optional[str] = None) -> int:
673
673
  "through libass, or upgrade FFmpeg", "drawtext_newline_glyph", text_render="drawtext")
674
674
 
675
675
  output = args.output or default_output(args.input, "gfx", default_ext(alpha_codec) if alpha_codec else None)
676
+ # the brand file and the font file are read here and by drawtext's fontfile=, never as an -i, so
677
+ # run()'s guard did not see them (#522's sweep)
678
+ refuse_output_is_input(output, *[q for q in (args.brand, args.font_file or brand.get("font_file")) if isinstance(q, str) and q])
676
679
  ass_path = None
677
680
  emoji_result = None
678
681
  if route == "ass":
@@ -754,8 +757,10 @@ def main(force_route: Optional[str] = None) -> int:
754
757
  require_filter("ass", "drawing this text through libass") # before the sidecar is written
755
758
  # a file this run writes itself: never the input or the video, a missing directory or an
756
759
  # existing file without --overwrite is refused before anything is written (#408)
757
- guard_sidecar(ass_path, *[q for q in (args.input, getattr(args, "brand", None), getattr(args, "font_file", None))
758
- if isinstance(q, str)], siblings=(output,))
760
+ # the font the text is drawn with, a brand file's font_file included (review of #534: only
761
+ # --font-file was here, and --write-ass naming the brand's font replaced it with ASS text)
762
+ guard_sidecar(ass_path, *[q for q in (args.input, getattr(args, "brand", None), args.font_file or brand.get("font_file"))
763
+ if isinstance(q, str) and q], siblings=(output,))
759
764
  if STATE.dry_run:
760
765
  info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
761
766
  else:
package/scripts/join.py CHANGED
@@ -45,7 +45,7 @@ from typing import Any, Dict, List, Optional, Tuple
45
45
 
46
46
  from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
47
47
  from _common.runner import read_text_or_die
48
- from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
48
+ from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, refuse_output_is_input, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
49
49
  from _common.emit import note, warn
50
50
  from _common.decision import av_clip_length, av_trim, stream_starts
51
51
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -245,6 +245,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
245
245
  if len(set(chans)) > 1:
246
246
  info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
247
247
  output = args.output or default_output(args.inputs[0], "joined")
248
+ refuse_list_as_output(args, output)
248
249
  if not is_audio_output(output):
249
250
  die(f"audio-only inputs cannot fill a video container: give -o an audio extension (.wav, .flac, .mp3, .m4a, .ogg, .opus), not {output}")
250
251
 
@@ -283,6 +284,13 @@ CONCAT_DIRECTIVES = frozenset({"inpoint", "outpoint", "duration", "stream", "exa
283
284
  "file_packet_meta", "option", "stream_meta", "stream_codec", "stream_extradata", "chapter"})
284
285
 
285
286
 
287
+ def refuse_list_as_output(args: argparse.Namespace, output: str) -> None:
288
+ """The --list file is read here and is never an -i of the join, so run()'s guard did not see
289
+ an output named like it (#522's sweep): refused once the output is known, before any write."""
290
+ if args.list:
291
+ refuse_output_is_input(output, args.list, why="the clip list would be replaced by the join")
292
+
293
+
286
294
  def read_list(path: str) -> List[str]:
287
295
  """--list FILE: one segment per line, in order, relative to the list file's own folder.
288
296
  Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
@@ -494,6 +502,7 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
494
502
  die(f"--copy keeps every clip's own encoding: {', '.join(encoder)} choose(s) an encode, so drop --copy")
495
503
  output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
496
504
  guard_sidecar(output, *args.inputs)
505
+ refuse_list_as_output(args, output)
497
506
  ext = os.path.splitext(output)[1].lower()
498
507
  n = len(args.inputs)
499
508
  pending = any(m.get("dry_run") for m in metas)
@@ -929,6 +938,7 @@ def main() -> int:
929
938
  STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
930
939
  "or holds it to its measured length")
931
940
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
941
+ refuse_list_as_output(args, output)
932
942
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
933
943
  # the fps filter already made the picture constant-rate, and the per-clip trims (then xfade or
934
944
  # concat) place every frame: FFmpeg 5.1's default cfr output mode still duplicated the last one
@@ -35,7 +35,7 @@ from _common import (guard_sidecar, add_common, apply_common, default_output, de
35
35
  die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
36
36
  STATE, read_text_or_die)
37
37
  from _common.emit import input_length
38
- from _common.runner import carried_stream_maps
38
+ from _common.runner import carried_stream_maps, refuse_output_is_input
39
39
 
40
40
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
41
41
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -247,6 +247,9 @@ def main() -> int:
247
247
  output = args.output or default_output(args.input, "meta", Path(args.input).suffix.lstrip(".") or "mp4")
248
248
  if os.path.abspath(output) == os.path.abspath(args.input):
249
249
  die("output must differ from the input (metadata.py never rewrites a file in place)")
250
+ if args.chapters:
251
+ # the chapter list is read here and handed to ffmpeg as a metadata file of its own (#522's sweep)
252
+ refuse_output_is_input(output, args.chapters, why="the chapter file would be replaced by the output")
250
253
  out_ext = Path(output).suffix.lower()
251
254
  if (args.chapters or args.clear_chapters or args.auto_chapters) and out_ext not in CHAPTER_CONTAINERS:
252
255
  die(f"{out_ext or 'this'} container cannot hold chapter markers; write to one of "
@@ -45,7 +45,7 @@ from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder
45
45
  from _common.probe import display_size, refuse_not_a_still, square_pixels, square_pixels_note
46
46
  from _common.drawtext import shaping_refusal
47
47
  from _common.wrap import wrap_text
48
- from _common import text_width_em
48
+ from _common import text_width_em, refuse_output_is_input
49
49
  from _common.fonts import font_choice, record_font_choice, require_font_file
50
50
  from _common import drawtext_fontfile # noqa: E402
51
51
 
@@ -494,6 +494,9 @@ def main() -> int:
494
494
  "from --start to --end without a fade", hint="drop --fade for the video layer")
495
495
 
496
496
  output = args.output or default_output(args.input, "overlay", default_ext(alpha_codec) if alpha_codec else None)
497
+ # the brand file and a text layer's font file are read here and by drawtext's fontfile=, never
498
+ # as an -i, so run()'s guard did not see them (#522's sweep)
499
+ refuse_output_is_input(output, *[q for q in (args.brand, args.font_file if args.text else None) if isinstance(q, str) and q])
497
500
  enable = enable_expr(start, end, meta)
498
501
  cmd = ffmpeg_base() + ["-i", args.input]
499
502
 
package/scripts/redact.py CHANGED
@@ -30,6 +30,7 @@ import sys
30
30
  from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
31
31
  from _common.emit import note
32
32
  from _common.probe import display_size, square_pixels, square_pixels_note
33
+ from _common.decision import boxblur_chroma_radius
33
34
 
34
35
 
35
36
  def main() -> int:
@@ -118,7 +119,10 @@ def main() -> int:
118
119
  # --blur-strength 1, which left colour-only detail readable (review of #521). (FFmpeg
119
120
  # 7.1 refuses a chroma radius of 1 on a 2 px chroma plane, a 4 px wide region; 6.1
120
121
  # took it.) 0 is only a plane too small to blur: the luma still is
121
- chroma = max(0, min(max(1, radius // 2), (min(args.width, args.height) // 2 - 1) // 2))
122
+ # On the chroma planes' own size, which boxblur measures: a 4:1:1 or 4:1:0 source's
123
+ # quarter-width planes failed at the 4:2:0 bound (a 30x30 region: "radius 7, must be
124
+ # <= 3", #527's sweep). On 4:2:0 the radius is the one it was
125
+ chroma = boxblur_chroma_radius(max(1, radius // 2))
122
126
  region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
123
127
  else:
124
128
  region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
package/scripts/render.py CHANGED
@@ -1602,7 +1602,11 @@ def main() -> int:
1602
1602
  # the project file and every clip it names are inputs the JSON must not replace
1603
1603
  where = Path(args.project).resolve().parent if args.project else Path.cwd()
1604
1604
  srcs = [c["src"] for c in proj.get("clips") or [] if isinstance(c, dict) and isinstance(c.get("src"), str)]
1605
- guard_sidecar(args.write_project, args.project, *[str(where / s) for s in srcs])
1605
+ # every file the project reads, not only its clips: a template's brand, its font_file and
1606
+ # logo, the cue or SRT file, the chapters (review of #534's sweep: --write-project naming
1607
+ # the brand's font or logo replaced it with the JSON)
1608
+ guard_sidecar(args.write_project, args.project, *[str(where / s) for s in srcs],
1609
+ *project_input_files(proj, lambda p: p if os.path.isabs(str(p)) else str(where / str(p))))
1606
1610
  try:
1607
1611
  Path(args.write_project).write_text(json.dumps(proj, indent=2) + "\n", encoding="utf-8")
1608
1612
  except OSError as exc:
@@ -21,8 +21,8 @@ import tempfile
21
21
  from fractions import Fraction
22
22
  from pathlib import Path
23
23
 
24
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
25
- from _common.emit import note, warn
24
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
25
+ from _common.emit import note, refuse, warn
26
26
  from _common.probe import display_size, square_pixels_note
27
27
  from _common.timeline import TimelineError, exact_rate
28
28
 
@@ -60,6 +60,31 @@ def concat_list(frames: list, rate: Fraction) -> str:
60
60
  return "\n".join(lines)
61
61
 
62
62
 
63
+ def undecodable_frames(list_path: Path, frames: list, rate: Fraction) -> "list | None":
64
+ """The frames of the list that do not decode to a picture, in order; None when the check could
65
+ not be made (ffprobe failed). ffprobe decodes the same concat list the encode reads, so a frame
66
+ is what the encode would get: frame i is decoded at the i-th start the list gives it. Only the
67
+ first frame was probed, and a later one that was not an image (a text file named .png, a
68
+ truncated PNG) was left out by the decoder while the fps filter repeated its neighbour: the run
69
+ completed with that frame missing (#529)."""
70
+ proc = run([require_tool("ffprobe"), "-v", "error", "-f", "concat", "-safe", "0", "-i", str(list_path),
71
+ "-select_streams", "v:0", "-show_entries", "frame=best_effort_timestamp_time", "-of", "csv=p=0"],
72
+ quiet=True, check=False)
73
+ if proc.returncode != 0:
74
+ return None
75
+ decoded = set()
76
+ for line in proc.stdout.splitlines():
77
+ try:
78
+ decoded.add(round(float(line.strip().rstrip(",")) * rate))
79
+ except ValueError:
80
+ continue
81
+ if not decoded:
82
+ # the first frame was probed as an image, so it decodes: nothing read back is a check that
83
+ # did not happen, not every frame missing
84
+ return None
85
+ return [f for i, f in enumerate(frames) if i not in decoded]
86
+
87
+
63
88
  def natural_key(name: str) -> list:
64
89
  """img2 < img10: digit runs compare as numbers, the rest case-insensitively."""
65
90
  return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
@@ -164,6 +189,18 @@ def main() -> int:
164
189
  with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-sequence-") as tmp:
165
190
  list_path = Path(tmp) / "frames.txt"
166
191
  list_path.write_text(concat_list([f.resolve() for f in frames], rate), encoding="utf-8")
192
+ # every frame decodes, or the run is refused before the encode (a dry run too: ffprobe runs
193
+ # under --dry-run, as every probe does), naming the frames that do not
194
+ bad = undecodable_frames(list_path, frames, rate)
195
+ if bad is None:
196
+ note("the frames could not be checked before the encode (ffprobe failed on the frame list); "
197
+ "a frame that does not decode would be left out of the video")
198
+ elif bad:
199
+ names = ", ".join(f.name for f in bad[:5]) + (f" and {len(bad) - 5} more" if len(bad) > 5 else "")
200
+ refuse("input", "still_unreadable",
201
+ f"{len(bad)} of {len(frames)} frames do not decode as an image ({names}); they would be "
202
+ "missing from the video. Replace or remove them",
203
+ path=str(bad[0]), frames=[str(f) for f in bad], frames_unreadable=len(bad), frames_total=len(frames))
167
204
 
168
205
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", str(list_path)]
169
206
  vf = [f"scale={out_w}:{out_h}", "setsar=1", f"fps={rate.numerator}/{rate.denominator}"]
@@ -679,6 +679,10 @@ def main() -> int:
679
679
  refuse_existing_outputs([args.timemap])
680
680
  media_out = None if (args.list or args.filler_list) else (args.output or default_output(args.input, "tight"))
681
681
  guard_sidecar(args.edl, args.input, args.words, args.filler_words, siblings=(media_out,))
682
+ if media_out:
683
+ # the word timings and the filler list are read here, never as an -i: run()'s guard did not
684
+ # see a cut named like one of them (#522's sweep). Before any decode, so a dry run says it too
685
+ refuse_output_is_input(media_out, *[p for p in (args.words, args.filler_words) if p])
682
686
 
683
687
  if not math.isfinite(args.margin) or args.margin < 0:
684
688
  # nan made inside_margin() drop every silence and re-encode the whole file unchanged
package/scripts/sync.py CHANGED
@@ -32,7 +32,7 @@ import os
32
32
  import sys
33
33
  from typing import Dict, List
34
34
 
35
- from _common.runner import fail_if_classified
35
+ from _common.runner import fail_if_classified, refuse_output_is_input
36
36
  from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
@@ -365,6 +365,9 @@ def main() -> int:
365
365
 
366
366
  if args.replace_audio or args.trim_second:
367
367
  output = args.output or default_output(args.reference if args.replace_audio else args.second, "synced", "mp4")
368
+ # --trim-second's command takes only SECOND as -i, so run()'s guard never saw the reference:
369
+ # REF named SECOND_synced.mp4 (the default name) or `-o REF` was replaced (#522)
370
+ refuse_output_is_input(output, args.reference, args.second)
368
371
  # second started later (offset > 0) -> delay it by `offset` (pad the head)
369
372
  # second started earlier (offset < 0) -> drop its first `-offset` seconds
370
373
  delay_ms = int(round(offset * 1000))
package/scripts/verify.py CHANGED
@@ -18,6 +18,7 @@ import argparse
18
18
  import contextlib
19
19
  import io
20
20
  import json
21
+ import os
21
22
  import subprocess
22
23
  import sys
23
24
  import tempfile
@@ -318,6 +319,7 @@ def main() -> int:
318
319
  # the run will write -- step outputs, caption's sidecars, the cue file -- against every input,
319
320
  # through a link or another case of the name included, before the first is written.
320
321
  every_input = [str(f) for f in files]
322
+ every_written: List[str] = []
321
323
  for f, _entry, plan, cues in jobs:
322
324
  written = [("cue file", str(cues))] if cues else []
323
325
  for name, argv in plan:
@@ -328,15 +330,33 @@ def main() -> int:
328
330
  written += [(name, str(Path(out).with_suffix(ext))) for ext in (".srt", ".ass")]
329
331
  for name, out in written:
330
332
  refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
333
+ every_written += [out for _name, out in written]
334
+ if args.report:
335
+ # the report is written last, over whatever is at its path: one of the names the steps write
336
+ # (`--report OUT/clip_cut.mp4`) was replaced by the Markdown after the steps had read it (#522)
337
+ guard_sidecar(args.report, siblings=every_written)
331
338
 
332
339
  results = []
333
340
  started["step"] = True
334
341
  for f, entry, plan, cues in jobs:
335
342
  info(f"=== {f}")
336
- if cues:
343
+ # the cue file is verify's own write: an existing one is replaced only with --overwrite, as
344
+ # every step's output is. Without it the caption step fails on it, as a step fails on its
345
+ # existing output, and the file is left as it was (#522; it was replaced unasked)
346
+ # A link or a folder at its name is never written through, --overwrite or not.
347
+ odd = bool(cues and os.path.lexists(cues) and (os.path.islink(cues) or not os.path.isfile(cues)))
348
+ cues_taken = odd or bool(cues and os.path.lexists(cues) and not FORWARD["overwrite"])
349
+ if cues and not cues_taken:
337
350
  cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
338
351
  for name, argv in plan:
339
- r = step(name, argv, args.timeout)
352
+ if cues_taken and argv[0] == "caption.py":
353
+ msg = (f"refusing to write the cue file the caption step reads through {str(cues)!r}: it is a link "
354
+ "or not a regular file" if odd else
355
+ f"refusing to overwrite the existing cue file {str(cues)!r} the caption step reads: pass --overwrite to replace it")
356
+ r = {"step": name, "ok": False, "seconds": 0.0, "error": _cell(msg), "kind": "input",
357
+ "reason": "output_exists", "message": msg}
358
+ else:
359
+ r = step(name, argv, args.timeout)
340
360
  entry["steps"].append(r)
341
361
  info(f" {'PASS' if r['ok'] else 'FAIL'} {name:16s} {r['seconds']:6.1f}s {r['error']}")
342
362
  results.append(entry)
@@ -38,7 +38,7 @@ import sys
38
38
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
39
39
  from _common.probe import SILENT_PEAK_DBFS, refuse_not_a_still, square_pixels, square_pixels_note
40
40
  from _common import still_input_args # noqa: E402
41
- from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
41
+ from _common import (add_common, apply_common, aac_args, color_hex, default_output, die, draws_chroma, emit, ffmpeg_base,
42
42
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
43
43
  fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
44
44
  from _common.emit import absorb_stage_hw, note, warn
@@ -110,10 +110,12 @@ def main() -> int:
110
110
  brand = load_brand(args.brand) if args.brand else None
111
111
  if brand:
112
112
  bc = brand.get("colors") or {}
113
+ # read as caption.py, graphics.py and overlay.py read a brand colour (#535): a brand file
114
+ # writes six hex digits with or without # or 0x
113
115
  if args.color == "lime" and bc.get("primary"):
114
- args.color = bc["primary"]
116
+ args.color = color_hex(bc["primary"])
115
117
  if args.background == "black" and bc.get("background"):
116
- args.background = bc["background"]
118
+ args.background = color_hex(bc["background"])
117
119
  if args.image:
118
120
  if "://" in args.image:
119
121
  die("--image must be a readable local file; this skill has no network access, so save "