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 +2 -2
- package/package.json +1 -1
- package/scripts/_common/emit.py +82 -1
- package/scripts/_common/probe.py +4 -0
- package/scripts/_common/runner.py +3 -1
- package/scripts/_contract.py +4 -2
- package/scripts/grid.py +9 -0
- package/scripts/scenes.py +10 -1
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
package/scripts/_contract.py
CHANGED
|
@@ -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", "
|
|
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
|
|
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)
|