ffmpeg-skill 3.1.4 → 3.1.5

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
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.1.4`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.1.5`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"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"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.1.4", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.1.5", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.1.4",
3
+ "version": "3.1.5",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -59,7 +59,7 @@ from _common.runner import (
59
59
  ffmpeg_bsfs, ffmpeg_filters, ffmpeg_list_error, require_filter, filter_missing_hint, ffmpeg_release, ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
60
60
  _odd_dimension_retry, _on_signal, _OutputLock, _pid_dead, place_output, PROBE_TIMEOUT, _progress_line,
61
61
  read_text_or_die, refuse_output_is_input, _remember_output, require_tool, run, run_analysis, _run_captured,
62
- run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, _SIGNALS_INSTALLED, _stage_existing_output,
62
+ run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, still_input_args, _SIGNALS_INSTALLED, _stage_existing_output,
63
63
  STATE, _timed_out, _unwatch, _watch, X264_PRESETS
64
64
  )
65
65
  from _common.emit import (
@@ -212,7 +212,7 @@ __all__ = [
212
212
  "decode_gray_frames", "frame_flow", "label_shot_flow", "MOTION_GRID", "MOTION_SEARCH", "MOTION_STATIC_PX", "MOTION_PAN_SPREAD",
213
213
  "_run_captured", "run_keeping_subtitles", "run_tool", "_run_with_progress", "_SCRIPT_FONT_CACHE",
214
214
  "_script_font_entry", "script_font_for_text", "script_font_status", "_script_font_uncached", "_SCRIPT_RANGES",
215
- "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
215
+ "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp", "still_input_args",
216
216
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
217
217
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
218
218
  "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
@@ -127,12 +127,18 @@ class MissingFpsError(ValueError):
127
127
  silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
128
128
 
129
129
 
130
+ def _on_windows() -> bool:
131
+ """Whether a backslash in a path is a separator (Windows) or a character of the name."""
132
+ return os.name == "nt"
133
+
134
+
130
135
  def concat_list_line(path: str) -> str:
131
136
  """One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
132
137
  inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
133
138
  with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
134
139
  name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
135
- escaped = str(path).replace("\\", "/").replace("'", "'\\''")
140
+ # only on Windows: on POSIX the quoted form takes a backslash literally, and it is part of the name (#408)
141
+ escaped = (str(path).replace("\\", "/") if _on_windows() else str(path)).replace("'", "'\\''")
136
142
  return f"file '{escaped}'"
137
143
 
138
144
 
@@ -252,8 +258,8 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
252
258
  must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
253
259
  written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
254
260
  ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
255
- Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
256
- never has to be escaped itself; `,`, `;`, `[` and `]` are graph-level characters and survive with
261
+ On Windows backslashes are turned into forward slashes first (ffmpeg accepts them there); on
262
+ POSIX a backslash is part of the name and is escaped through both passes (#408); `,`, `;`, `[` and `]` are graph-level characters and survive with
257
263
  one backslash. `'` is special: the graph parser also treats a quote as the start of a quoted
258
264
  token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
259
265
  filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
@@ -265,11 +271,23 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
265
271
  if bind and os.path.isfile(path) and path not in STATE.plan_inputs:
266
272
  STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
267
273
  p = str(Path(path))
268
- p = p.replace("\\", "/")
274
+ if _on_windows():
275
+ p = p.replace("\\", "/")
276
+ else:
277
+ # on POSIX a backslash is a character of the name (#408): turning it into `/` opened a
278
+ # different path, so it is escaped through both passes instead
279
+ p = p.replace("\\", "\\\\\\\\")
269
280
  p = p.replace(":", "\\\\:")
270
281
  p = p.replace("'", "\\\\\\'")
271
282
  for ch in (",", ";", "[", "]"):
272
283
  p = p.replace(ch, "\\" + ch)
284
+ # the graph parser trims unescaped whitespace at either end of a value (#408: a relative
285
+ # " lead.srt" opened "lead.srt"); both passes keep an escaped one
286
+ lead = len(p) - len(p.lstrip())
287
+ trail = len(p) - len(p.rstrip())
288
+ if lead or trail:
289
+ body = p[lead:len(p) - trail] if trail else p[lead:]
290
+ p = "".join("\\\\" + c for c in p[:lead]) + body + "".join("\\\\" + c for c in p[len(p) - trail:] if trail)
273
291
  return p
274
292
 
275
293
 
@@ -915,13 +915,22 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
915
915
  platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
916
916
  if platform and output and tool != "check":
917
917
  verify.append({"tool": "check", "platform": platform})
918
+ plan_inputs = _plan_inputs(ctx.commands, cleaned, ctx)
919
+ real = os.path.realpath(path)
920
+ # every file the plan names (#413 review): its inputs, side inputs included (an SRT, a still),
921
+ # and the output it promises, a default name too, which apply_common could not know
922
+ for named in plan_inputs + ([output] if output else []):
923
+ if os.path.realpath(named) == real:
924
+ refuse("input", "output_is_input" if named != output else "output_path_taken",
925
+ f"--plan {path!r} is also {'the output' if named == output else 'an input'} of this run "
926
+ f"({named!r}); give the plan its own path", output=path, other=named)
918
927
  doc = {
919
928
  "plan_version": PLAN_VERSION,
920
929
  "created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
921
930
  "tool": tool,
922
931
  "argv": cleaned,
923
932
  "cwd": os.getcwd(),
924
- "inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
933
+ "inputs": [fingerprint(p) for p in plan_inputs],
925
934
  # inputs that did not exist yet, so carry no fingerprint (render.py reports them on execution)
926
935
  "unbound_inputs": _plan_unbound_inputs(ctx.commands, ctx),
927
936
  "commands": list(ctx.commands),
@@ -13,7 +13,7 @@ import statistics
13
13
  from fractions import Fraction
14
14
  from typing import Any, Dict, List, Optional, Sequence, Tuple
15
15
  from _common.emit import die, warn
16
- from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
16
+ from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
17
17
 
18
18
 
19
19
  def fingerprint(path: str) -> Dict[str, Any]:
@@ -187,7 +187,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
187
187
  die(f"input not found: {path}")
188
188
  ffprobe = require_tool("ffprobe")
189
189
  proc = run(
190
- [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
190
+ [ffprobe, "-v", "error", *still_input_args(path),
191
+ "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
191
192
  quiet=True,
192
193
  check=False,
193
194
  )
@@ -441,6 +441,13 @@ def apply_common(args: "argparse.Namespace") -> None:
441
441
  STATE.progress = bool(getattr(args, "progress", False))
442
442
  STATE.fast = bool(getattr(args, "fast", False))
443
443
  STATE.overwrite = bool(getattr(args, "overwrite", False))
444
+ if STATE.plan:
445
+ # the plan is a file this run writes itself: the side-file guards apply (#408), so it
446
+ # never replaces an input, the output or an existing file without --overwrite
447
+ named = [getattr(args, k, None) for k in ("input", "inputs", "reference", "compare")]
448
+ inputs = [p for v in named for p in (v if isinstance(v, (list, tuple)) else [v]) if isinstance(p, str)]
449
+ out = getattr(args, "output", None)
450
+ guard_sidecar(STATE.plan, *inputs, siblings=(out,) if isinstance(out, str) else ())
444
451
  if getattr(args, "timeout", None) is not None:
445
452
  STATE.timeout = max(0.0, float(args.timeout))
446
453
  else:
@@ -683,11 +690,15 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
683
690
  # while every guard here reads it as a path. Two or more letters, so a Windows
684
691
  # drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
685
692
  _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
693
+ # Off Windows there are no drive letters, and ffmpeg reads a one-letter `x:y.mp4` as protocol `x`
694
+ # too (#408: it then failed as "Protocol not found", with no hint that ./x:y.mp4 works)
695
+ _URL_SCHEME_POSIX = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
686
696
 
687
697
 
688
698
  def looks_like_url(value: str) -> bool:
689
699
  """True for a value ffmpeg would open through a protocol rather than as a local file."""
690
- return bool(_URL_SCHEME.match(value)) and not value.startswith("pipe:")
700
+ scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
701
+ return bool(scheme.match(value)) and not value.startswith("pipe:")
691
702
 
692
703
 
693
704
  _PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat")
@@ -1705,13 +1716,34 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
1705
1716
  _remember_output(cmd)
1706
1717
 
1707
1718
 
1719
+ # extensions ffmpeg opens through the image2 demuxer, the only one that expands `%d` in a name
1720
+ # and the only one that has a `pattern_type` option (gif, avif, mp4... reject it: "Option
1721
+ # pattern_type not found")
1722
+ _IMAGE2_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".dpx", ".exr", ".tga",
1723
+ ".ppm", ".pgm", ".pbm", ".pam", ".sgi", ".jp2", ".j2k", ".xbm", ".xpm", ".pcx", ".qoi")
1724
+
1725
+
1726
+ def still_input_args(path: str) -> List[str]:
1727
+ """`-pattern_type none` for a still read through image2 whose path has a `%` in it (#408):
1728
+ image2 otherwise reads `logo%d.png` as a numbered sequence and opens `logo1.png`, a file the
1729
+ run's guards and fingerprints never looked at. Empty for every other path, a video or a GIF
1730
+ with a `%` in its name included: their demuxers never expand it and reject the option."""
1731
+ p = str(path)
1732
+ return ["-pattern_type", "none"] if "%" in p and os.path.splitext(p)[1].lower() in _IMAGE2_EXT else []
1733
+
1734
+
1708
1735
  def sibling_temp(dst: str, tag: str) -> str:
1709
1736
  """A hidden path next to `dst`, with its extension (the muxer is chosen by it), for a file
1710
1737
  place_output(..., move=True) renames into place: same directory, so the rename never
1711
1738
  crosses a filesystem."""
1712
1739
  d, base = os.path.split(dst)
1713
1740
  stem, ext = os.path.splitext(base)
1714
- return os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
1741
+ path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
1742
+ n = 1
1743
+ while os.path.lexists(path): # a leftover of an earlier run with this pid: not ours to replace
1744
+ n += 1
1745
+ path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}-{n}{ext}")
1746
+ return path
1715
1747
 
1716
1748
 
1717
1749
  _DRAWTEXT_TMPDIR: "Optional[str]" = None
@@ -44,7 +44,8 @@ from typing import Any, Dict, List, Optional, Tuple
44
44
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
45
  resolve as resolve_platform)
46
46
  from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
47
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
47
+ from _common.emit import refuse # noqa: E402
48
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
48
49
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
49
50
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
50
51
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -1276,28 +1277,52 @@ def main() -> int:
1276
1277
  # next to the output was replaced on a re-run, even one ffmpeg's check then refused.
1277
1278
  # Side files whose existence depends on the cues (an _adjusted.srt, an emoji-forced ASS) are
1278
1279
  # checked again where they are written, still before that write.
1279
- claimed: set = set()
1280
-
1281
- def claim(paths) -> None:
1282
- todo = [p for p in paths if p and os.path.realpath(p) not in claimed]
1280
+ claimed: Dict[str, str] = {} # real path -> which artifact of this run holds it
1281
+
1282
+ def claim(items) -> None:
1283
+ """Reserve (path, artifact) pairs: the same artifact claiming its path again is a no-op,
1284
+ another artifact of this run claiming it is refused (#413 review: --write-srt and
1285
+ --write-ass naming one file, the SRT then replaced by the ASS)."""
1286
+ todo = []
1287
+ for path, owner in items:
1288
+ if not path:
1289
+ continue
1290
+ real = os.path.realpath(path)
1291
+ held = claimed.get(real)
1292
+ if held is None and os.path.exists(real):
1293
+ # a second name for the same file (a hard link) is the same path (#413 review)
1294
+ held = next((o for r, o in claimed.items()
1295
+ if os.path.exists(r) and os.path.samefile(r, real)), None)
1296
+ if held is None:
1297
+ claimed[real] = owner
1298
+ todo.append(path)
1299
+ elif held != owner:
1300
+ refuse("input", "output_path_taken",
1301
+ f"{path!r} is named for both the {held} and the {owner} this run writes; give each its own path",
1302
+ output=path, other=held)
1283
1303
  refuse_existing_outputs(todo)
1284
- claimed.update(os.path.realpath(p) for p in todo)
1304
+ # and the rest of the side-file guards (#408): never an input, the video, a missing
1305
+ # directory or an option-shaped name, even under --overwrite
1306
+ side_inputs = [q for q in (args.input, getattr(args, "text", None), getattr(args, "ass", None),
1307
+ getattr(args, "brand", None), *(t[0] for t in srt_tracks)) if isinstance(q, str)]
1308
+ for path in todo:
1309
+ guard_sidecar(path, *side_inputs)
1285
1310
 
1286
1311
  planned_out = args.output or (default_output(args.input, "captioned") if args.input else None)
1287
- up_front: List[str] = []
1312
+ up_front: List[Tuple[str, str]] = []
1288
1313
  if args.transcribe:
1289
- up_front.append(args.write_srt or os.path.splitext(planned_out)[0] + ".srt")
1314
+ up_front.append((args.write_srt or os.path.splitext(planned_out)[0] + ".srt", "SRT"))
1290
1315
  elif args.text:
1291
- up_front.append(args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
1292
- else os.path.splitext(args.text)[0] + ".srt"))
1316
+ up_front.append((args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
1317
+ else os.path.splitext(args.text)[0] + ".srt"), "SRT"))
1293
1318
  if args.input:
1294
1319
  if (args.animate != "none" or args.karaoke) and not args.ass:
1295
- up_front.append(args.write_ass or os.path.splitext(planned_out)[0] + ".ass")
1320
+ up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
1296
1321
  if args.ass and args.offset and os.path.exists(args.ass):
1297
- up_front.append(os.path.splitext(planned_out)[0] + "_offset.ass")
1322
+ up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
1298
1323
  # -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
1299
1324
  if os.path.realpath(planned_out) != os.path.realpath(args.input):
1300
- up_front.append(planned_out)
1325
+ up_front.append((planned_out, "video"))
1301
1326
  claim(up_front)
1302
1327
  if args.transcribe:
1303
1328
  # the sidecar goes next to the output like the --text one, not into the source folder
@@ -1358,7 +1383,7 @@ def main() -> int:
1358
1383
  adjusted, changed = lay_out(parse_srt(srt_path))
1359
1384
  if changed:
1360
1385
  new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
1361
- claim([new_srt])
1386
+ claim([(new_srt, "adjusted SRT")])
1362
1387
  if STATE.dry_run:
1363
1388
  info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
1364
1389
  else:
@@ -1563,7 +1588,7 @@ def main() -> int:
1563
1588
  if args.karaoke and not getattr(args, "_word_timings", None):
1564
1589
  args._word_timings = whisper_word_timings(srt_path)
1565
1590
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
1566
- claim([ass_path])
1591
+ claim([(ass_path, "ASS")])
1567
1592
  w, h = meta["video"]["width"], meta["video"]["height"]
1568
1593
  if meta["video"].get("rotation") in (90, -90, 270, -270):
1569
1594
  w, h = h, w
@@ -32,7 +32,7 @@ import sys
32
32
  from typing import List, Optional, Tuple
33
33
 
34
34
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
35
- from _common import (require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
35
+ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
36
36
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
37
37
  ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
38
38
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
@@ -648,6 +648,10 @@ def main(force_route: Optional[str] = None) -> int:
648
648
  "overlays": len(emoji_overlays)}
649
649
  ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
650
650
  require_filter("ass", "drawing this text through libass") # before the sidecar is written
651
+ # a file this run writes itself: never the input or the video, a missing directory or an
652
+ # existing file without --overwrite is refused before anything is written (#408)
653
+ guard_sidecar(ass_path, *[q for q in (args.input, getattr(args, "brand", None), getattr(args, "font_file", None))
654
+ if isinstance(q, str)], siblings=(output,))
651
655
  if STATE.dry_run:
652
656
  info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
653
657
  else:
package/scripts/insert.py CHANGED
@@ -27,6 +27,7 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
+ from _common import still_input_args # noqa: E402
30
31
  from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
31
32
 
32
33
 
@@ -115,7 +116,7 @@ def main() -> int:
115
116
  ]
116
117
 
117
118
  output = args.output or default_output(args.input, "insert", "mp4")
118
- cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
119
+ cmd = ffmpeg_base() + ["-loop", "1", *still_input_args(args.input), "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
119
120
  # the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
120
121
  # the BT.709 that line is tagged with
121
122
  cmd += video_args(None, args.crf, args.preset, source=meta)
package/scripts/look.py CHANGED
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from typing import List, Optional
18
18
 
19
19
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
20
+ from _common import still_input_args # noqa: E402
20
21
  from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
21
22
  from _common import drawtext_fontfile # noqa: E402
22
23
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
@@ -239,8 +240,8 @@ def main() -> int:
239
240
  tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
240
241
  fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
241
242
  f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
242
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
243
- "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
243
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
244
+ "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
244
245
  run(cmd)
245
246
  outputs.append(out)
246
247
  elif args.at:
@@ -254,7 +255,7 @@ def main() -> int:
254
255
  # several frames, or -o given as a stem/prefix without an image extension
255
256
  out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
256
257
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
257
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
258
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
258
259
  run(cmd)
259
260
  outputs.append(out)
260
261
  else:
@@ -281,7 +282,7 @@ def main() -> int:
281
282
  tc_sheet = tc.replace(timecode_filter(font_prefix), timecode_filter(font_prefix, seek)) if seek else tc
282
283
  vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc_sheet},"
283
284
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
284
- cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
285
+ cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
285
286
  run(cmd)
286
287
  outputs.append(out)
287
288
  info(f"contact sheet: {n} frames every {step:.2f}s")
@@ -30,6 +30,7 @@ import sys
30
30
  from typing import List, Optional
31
31
 
32
32
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
33
+ from _common import still_input_args # noqa: E402
33
34
  from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
34
35
  from _common.emit import warn
35
36
  from _common.fonts import font_choice, record_font_choice
@@ -372,7 +373,7 @@ def main() -> int:
372
373
  if enable:
373
374
  ov += f":enable='{enable}'"
374
375
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
375
- cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
376
+ cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
376
377
  # the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
377
378
  # RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
378
379
  # 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
package/scripts/render.py CHANGED
@@ -342,6 +342,11 @@ def render_pack(names: List[str], args) -> int:
342
342
  and platform check), so the pack reports per-platform results rather than one verdict."""
343
343
  stem = Path(args.input).with_suffix("").name
344
344
  outdir = Path(args.output).parent if args.output else Path(args.input).resolve().parent
345
+ # the table is a file this run writes itself (#408): an existing one is refused without
346
+ # --overwrite, before the first destination renders
347
+ if outdir.is_dir(): # a missing folder is the destinations' own refusal (and a dry run's plan)
348
+ side = [v for k, v in vars(args).items() if k != "output" and isinstance(v, str) and os.path.isfile(v)]
349
+ guard_sidecar(str(outdir / f"{stem}_pack.md"), args.input, *side)
345
350
  dests = {name: str((load_template(name).get("check") or {}).get("platform") or name) for name in names}
346
351
  rows: List[Dict[str, Any]] = []
347
352
  outputs: List[str] = []
@@ -399,6 +404,9 @@ def render_pack(names: List[str], args) -> int:
399
404
  lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
400
405
  "Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
401
406
  if not STATE.dry_run:
407
+ if not outdir.is_dir():
408
+ die(f"pack: the output folder {outdir} does not exist, so the table was not written", kind="input",
409
+ pack=rows, outputs=outputs)
402
410
  Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
403
411
  info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
404
412
  if failed:
package/scripts/scenes.py CHANGED
@@ -302,7 +302,7 @@ def main() -> int:
302
302
  stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
303
303
  vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
304
304
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
305
- run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
305
+ run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
306
306
  info(f"wrote {args.sheet}")
307
307
  result["sheet"] = args.sheet
308
308
 
package/scripts/verify.py CHANGED
@@ -26,7 +26,7 @@ import time
26
26
  from pathlib import Path
27
27
  from typing import Any, Dict, List, Optional, Tuple
28
28
 
29
- from _common import STATE, add_common, apply_common, die, emit, info, probe
29
+ from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe
30
30
 
31
31
  HERE = Path(__file__).resolve().parent
32
32
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
@@ -160,9 +160,14 @@ def main() -> int:
160
160
  files = collect(args.paths)
161
161
  tmp = None
162
162
  if args.out:
163
+ # made first, so a --report inside a new --out folder passes the guard below (#413 review)
163
164
  outdir = Path(args.out)
164
165
  outdir.mkdir(parents=True, exist_ok=True)
165
- else:
166
+ if args.report:
167
+ # a file this run writes itself (#408): not one of the media it verifies (a folder's
168
+ # files included), nor an existing file without --overwrite
169
+ guard_sidecar(args.report, *args.paths, *map(str, files))
170
+ if not args.out:
166
171
  tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
167
172
  outdir = Path(tmp.name)
168
173
 
@@ -36,9 +36,10 @@ import sys
36
36
 
37
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
38
  from _common.probe import SILENT_PEAK_DBFS
39
+ from _common import still_input_args # noqa: E402
39
40
  from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
40
41
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
41
- fmt_secs, child_args, run_tool, measured_level_dbfs)
42
+ fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
42
43
  from _common.emit import absorb_stage_hw, warn
43
44
 
44
45
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
@@ -207,14 +208,15 @@ def main() -> int:
207
208
  if args.title or args.srt or args.text:
208
209
  # the visualisation is rendered first and the label/captions are drawn on it by the tools
209
210
  # that own those code paths, so neither is re-implemented here
210
- stem, ext = os.path.splitext(output)
211
- render = stem + "_vis" + ext
211
+ # hidden, pid-named siblings (#408): a fixed `<out>_vis` name was removed afterwards
212
+ # whoever had written it, the input or another file of the caller's included
213
+ render = sibling_temp(output, "vis")
212
214
  cmd = ffmpeg_base() + ["-i", args.input]
213
215
  if args.image:
214
216
  # -framerate before -loop: a looped still defaults to 25 fps, and overlay takes its rate
215
217
  # from the FIRST input -- so without this the file came out 25 fps however loud --fps or
216
218
  # --platform said otherwise (the colour-plate path never had the bug: `color=` carries r=).
217
- cmd += ["-framerate", f"{args.fps:g}", "-loop", "1", "-i", args.image]
219
+ cmd += ["-framerate", f"{args.fps:g}", "-loop", "1", *still_input_args(args.image), "-i", args.image]
218
220
  cmd += ["-filter_complex", vf]
219
221
  if args.image:
220
222
  cmd += ["-map", "[v]"]
@@ -227,15 +229,20 @@ def main() -> int:
227
229
  if meta.get("duration"):
228
230
  cmd += ["-t", f"{float(meta['duration']):.3f}"]
229
231
  cmd += ["-shortest", render]
230
- stem, ext = os.path.splitext(output)
231
- intermediates = [p for p in (render, stem + "_titled" + ext) if p != output]
232
+ titled_tmp = sibling_temp(output, "titled")
233
+ intermediates = [p for p in (render, titled_tmp) if p != output]
232
234
  try:
233
235
  run(cmd)
234
236
 
235
237
  stages = ["waveform"]
236
238
  current = render
237
239
  if args.title:
238
- titled = stem + "_titled" + ext if (args.srt or args.text) else output
240
+ titled = titled_tmp if (args.srt or args.text) else output
241
+ if titled == titled_tmp:
242
+ # graphics.py's libass route writes <its output stem>_gfx.ass beside it (#413 review)
243
+ titled_ass = os.path.splitext(titled)[0] + "_gfx.ass"
244
+ if not os.path.exists(titled_ass):
245
+ intermediates.append(titled_ass)
239
246
  # a dry run rendered no _vis file for graphics.py to probe, so its default --end (the
240
247
  # clip's end) came out 0 and the plan failed: give it the source's duration, which
241
248
  # is what -t caps the render at. A real run is unchanged.