ffmpeg-skill 3.1.2 → 3.1.3

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.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.1.3`) | 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.2", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.1.3", "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.2",
3
+ "version": "3.1.3",
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",
@@ -13,6 +13,59 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
13
13
  from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
14
14
 
15
15
 
16
+ # Characters that never belong in a message a calling agent reads (#400): C0 controls (a line
17
+ # break is one), DEL and C1, the Unicode line/paragraph separators and the bidi embedding,
18
+ # override and isolate marks. A path or other argument carrying one would otherwise reach
19
+ # error.message / a diagnostic verbatim, where a newline can start what reads as a new line of
20
+ # instructions and a bidi override can reorder what is shown.
21
+ _UNSAFE = frozenset([chr(c) for c in range(0x20) if c not in (0x09, 0x0A)] + [chr(0x7F)]
22
+ + [chr(c) for c in range(0x80, 0xA0)]
23
+ + ["\u2028", "\u2029", "\u200e", "\u200f"] + [chr(c) for c in range(0x202A, 0x202F)]
24
+ + [chr(c) for c in range(0x2066, 0x206A)])
25
+ _LINE_BREAKS = frozenset("\n")
26
+
27
+
28
+ def shown(value: Any) -> str:
29
+ """`value` as text with every unsafe character (and any line break) written as an escape
30
+ (\\n, \\x1b, \\u202e). A clean value comes back unchanged, so an ordinary path, a Windows one
31
+ with backslashes included, reads exactly as before."""
32
+ text = str(value)
33
+ if not any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in text):
34
+ return text
35
+ return "".join((ch.encode("unicode_escape").decode("ascii") if (ch in _UNSAFE or ch in _LINE_BREAKS) else ch)
36
+ for ch in text)
37
+
38
+
39
+ def _argument_forms(arg: str) -> List[str]:
40
+ """The spellings a message may print an argument in: as given, resolved, and its file name;
41
+ for `--opt=value`, the same for the value, which is what a tool prints back."""
42
+ given = [arg] + ([arg.split("=", 1)[1]] if arg.startswith("-") and "=" in arg else [])
43
+ forms = set(given)
44
+ for a in given:
45
+ try:
46
+ forms.update({os.path.abspath(a), os.path.realpath(a), os.path.basename(a)})
47
+ except (OSError, ValueError):
48
+ pass
49
+ return sorted((f for f in forms if f), key=len, reverse=True)
50
+
51
+
52
+ def neutralize(msg: Any) -> Any:
53
+ """A message with caller-supplied text made inert (#400): every command-line argument that
54
+ carries a line break or an unsafe character is replaced by its shown() form wherever it
55
+ appears (as given, resolved or as a file name), then any unsafe character left is escaped.
56
+ Line breaks the tool itself wrote (an ffmpeg stderr tail) stay; one inside an argument does not."""
57
+ if not isinstance(msg, str):
58
+ return msg
59
+ for arg in sys.argv[1:]:
60
+ if isinstance(arg, str) and any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in arg):
61
+ for form in _argument_forms(arg):
62
+ if form in msg and (form != os.path.basename(arg) or any(ch in _UNSAFE or ch in _LINE_BREAKS for ch in form)):
63
+ msg = msg.replace(form, shown(form))
64
+ if any(ch in _UNSAFE for ch in msg):
65
+ msg = "".join(ch.encode("unicode_escape").decode("ascii") if ch in _UNSAFE else ch for ch in msg)
66
+ return msg
67
+
68
+
16
69
  def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None,
17
70
  error_extra: "Optional[Dict[str, Any]]" = None, **extra: Any) -> "None":
18
71
  """Exit with a message. Under --json also print a machine-readable failure document
@@ -26,7 +79,8 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
26
79
 
27
80
  `error_extra` goes inside `error` (refuse() passes `reason` and `details` there); the
28
81
  collector's records (warn() and friends) are merged into the document as emit() merges them."""
29
- hint = extra.pop("hint", None)
82
+ hint = neutralize(extra.pop("hint", None))
83
+ msg = neutralize(msg)
30
84
  ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
31
85
  _set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
32
86
  ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
@@ -61,6 +115,28 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
61
115
  sys.exit(code)
62
116
 
63
117
 
118
+ def _report_dropped_audio_tracks(meta: Dict[str, Any], ctx: "Context") -> None:
119
+ """The tools write one audio track: a source with several must say how many did not come
120
+ along (#397, every tool, not only cut.py). Only when the output kept sound at all: a tool told
121
+ to drop it (--no-audio, a picture-only output) left nothing out by surprise."""
122
+ first = getattr(STATE, "first_input", None)
123
+ if not first or first[1] < 2 or not meta:
124
+ return
125
+ if len(getattr(STATE, "audio_inputs", None) or ()) > 1:
126
+ return # several sources with sound: which one's tracks the output carries is the tool's own choice
127
+ kept = len(meta.get("audio_streams") or [])
128
+ if not kept or kept >= first[1]:
129
+ return
130
+ if any(w.get("code") == "audio_tracks_dropped" for w in ctx.warnings):
131
+ return # the tool said it already (cut.py, with its own field)
132
+ warn(f"the source has {first[1]} audio tracks; this output kept {kept} and left {first[1] - kept} out "
133
+ "(the tools write one audio track; --audio-stream N picks which, where a tool takes it)",
134
+ "audio_tracks_dropped", ctx=ctx, source=first[1], kept=kept, dropped=first[1] - kept)
135
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
136
+ if note_line and note_line not in ctx.notes:
137
+ ctx.notes.append(note_line)
138
+
139
+
64
140
  def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
65
141
  ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
66
142
  """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
@@ -72,6 +148,7 @@ def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code
72
148
 
73
149
  def _record(bucket: str, msg: str, code: str, ctx: "Optional[Context]", data: Dict[str, Any], prefix: str) -> Dict[str, Any]:
74
150
  ctx = ctx or STATE
151
+ msg = neutralize(msg)
75
152
  entry: Dict[str, Any] = {"code": code, "message": msg}
76
153
  entry.update(data)
77
154
  # once per run: a decision taken for every encode of a job (an AV1 encoder fallback, a CRF
@@ -116,6 +193,9 @@ def _merge_collector(doc: Dict[str, Any], ctx: "Context") -> None:
116
193
  extra_notes = [n for n in ctx.notes if n not in (doc.get("notes") or [])]
117
194
  if extra_notes:
118
195
  doc["notes"] = list(doc.get("notes") or []) + extra_notes
196
+ if doc.get("notes"):
197
+ # a tool's own notes can quote an input path (join.py's duplicates): made inert as messages are (#400)
198
+ doc["notes"] = [neutralize(n) for n in doc["notes"]]
119
199
  diag = {k: list(getattr(ctx, k)) for k in ("warnings", "fallbacks", "skipped") if getattr(ctx, k)}
120
200
  if diag:
121
201
  merged = dict(doc.get("diagnostics") or {})
@@ -601,6 +681,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
601
681
  meta: Dict[str, Any] = {}
602
682
  if output and not ctx.dry_run:
603
683
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
684
+ _report_dropped_audio_tracks(meta, ctx)
604
685
  if ctx.json:
605
686
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
606
687
  if meta:
@@ -355,6 +355,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
355
355
  "language": (a.get("tags") or {}).get("language"),
356
356
  "title": (a.get("tags") or {}).get("title"),
357
357
  } for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
358
+ if role == "input" and out.get("audio_streams"):
359
+ STATE.audio_inputs.add(os.path.realpath(path))
360
+ if role == "input" and getattr(STATE, "first_input", None) is None:
361
+ STATE.first_input = (path, len(out.get("audio_streams") or []))
358
362
  return out
359
363
 
360
364
 
@@ -324,7 +324,7 @@ class Context:
324
324
 
325
325
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
326
326
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
327
- "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification")
327
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs")
328
328
 
329
329
  def __init__(self) -> None:
330
330
  self.reset()
@@ -332,6 +332,8 @@ class Context:
332
332
  def reset(self) -> None:
333
333
  self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
334
334
  self.json = False # emit() prints a JSON document instead of the output path
335
+ self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
336
+ self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
335
337
  self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
336
338
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
337
339
  self.fast = False # x264 preset forced to veryfast
@@ -109,7 +109,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
109
109
  required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
110
110
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
111
111
  "grid": dict(role="execution", inputs=["cols*rows video assets, filled left-to-right top-to-bottom"], outputs=["composited grid video artifact"],
112
- required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default)"}, {"capability": AAC, "when": "--audio-from"}],
112
+ required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default; without drawtext the cells are not labelled, fallback grid_no_drawtext)"}, {"capability": AAC, "when": "--audio-from"}],
113
113
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
114
114
  "straighten": dict(role="execution", inputs=["video asset"], outputs=["video artifact rotated by the given angle (horizon correction)"],
115
115
  required=FF + [X264, AAC, "filter:rotate"], optional=[HDR_X265],
@@ -464,7 +464,9 @@ DIAGNOSTIC_CODES = {
464
464
  "crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
465
465
  # fonts and text
466
466
  "emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
467
- "audio_tracks_dropped": ("warnings", "cut.py: the source has more audio tracks than the cut wrote (one); {source, kept, dropped}"),
467
+ "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams); {source, kept, dropped}"),
468
+ "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
469
+ "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
468
470
  "look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
469
471
  "font_substituted": ("fallbacks", "the asked font family is not installed: fontconfig or the Windows Fonts folder gave another (font_used names it); {asked, file, family, via}"),
470
472
  "font_lacks_script": ("warnings", "an explicit --font does not cover the text's script and is kept as asked; {font, script}"),
package/scripts/grid.py CHANGED
@@ -29,6 +29,8 @@ from _common import add_common, apply_common, aac_args, cfr_args, default_output
29
29
  draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
30
30
  from _common.fonts import font_choice, record_font_choice
31
31
  from _common import drawtext_fontfile # noqa: E402
32
+ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
33
+ from _common.emit import note, record_fallback # noqa: E402
32
34
 
33
35
  LABEL_MARGIN = 10
34
36
 
@@ -56,6 +58,13 @@ def main() -> int:
56
58
  add_common(ap)
57
59
  args = ap.parse_args()
58
60
  apply_common(args)
61
+ if args.label == "auto" and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
62
+ # the labels are the only drawtext in the grid: without the filter (Homebrew's plain
63
+ # ffmpeg) draw the grid unlabelled and say so, as look.py does for its stamps (#394)
64
+ args.label = "none"
65
+ record_fallback("this ffmpeg build has no drawtext filter, so the cells are not labelled "
66
+ "(the same as --label none)", "grid_no_drawtext")
67
+ note("no cell labels: this ffmpeg build has no drawtext filter (" + filter_missing_hint("drawtext") + ")")
59
68
 
60
69
  n = args.cols * args.rows
61
70
  if args.cols <= 0 or args.rows <= 0:
package/scripts/scenes.py CHANGED
@@ -27,6 +27,8 @@ from _common import (guard_sidecar, detect_scenes, STATE, add_common, apply_comm
27
27
  ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
28
28
  rms_envelope, BEAT_MIN_CONFIDENCE, decode_gray_frames, frame_flow, label_shot_flow)
29
29
  from _common import drawtext_fontfile # noqa: E402
30
+ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
31
+ from _common.emit import note, record_fallback # noqa: E402
30
32
 
31
33
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
32
34
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -287,6 +289,13 @@ def main() -> int:
287
289
  fps = meta["video"].get("fps") or 30.0
288
290
  expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
289
291
  stamp = ""
292
+ if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
293
+ # as look.py (#394): the sheet needs drawtext only for its stamps
294
+ args.no_timecode = True
295
+ record_fallback("this ffmpeg build has no drawtext filter, so the --sheet has no timecode stamps "
296
+ "(the same as --no-timecode)", "scenes_sheet_no_drawtext")
297
+ note("--sheet without timecode stamps: this ffmpeg build has no drawtext filter ("
298
+ + filter_missing_hint("drawtext") + ")")
290
299
  if not args.no_timecode:
291
300
  default_font = default_font_file("DejaVu Sans")
292
301
  font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
@@ -297,7 +306,7 @@ def main() -> int:
297
306
  info(f"wrote {args.sheet}")
298
307
  result["sheet"] = args.sheet
299
308
 
300
- if args.json:
309
+ if STATE.json: # --json-brief too: emit() is what adds the diagnostics (scenes_sheet_no_drawtext)
301
310
  emit(None, **result)
302
311
  else:
303
312
  print_json(result)