ffmpeg-skill 2.22.0 → 2.22.1
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/runner.py +46 -0
- package/scripts/_contract.py +1 -0
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 (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.22.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.22.1`) | 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`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"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"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.22.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.22.1", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.22.
|
|
3
|
+
"version": "2.22.1",
|
|
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",
|
|
@@ -553,6 +553,46 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
|
553
553
|
return enc
|
|
554
554
|
|
|
555
555
|
|
|
556
|
+
# A protocol prefix (`tcp:`, `http://`, `file:`, `concat:`, ...): ffmpeg opens such a value as a URL,
|
|
557
|
+
# while every guard here reads it as a path. Two or more letters, so a Windows
|
|
558
|
+
# drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
|
|
559
|
+
_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def looks_like_url(value: str) -> bool:
|
|
563
|
+
"""True for a value ffmpeg would open through a protocol rather than as a local file."""
|
|
564
|
+
return bool(_URL_SCHEME.match(value)) and not value.startswith("pipe:")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _refuse_url_media(cmd: Sequence[str]) -> None:
|
|
568
|
+
"""Refuse an ffmpeg/ffprobe command that names a URL as an input or as its output, before it
|
|
569
|
+
runs or is recorded. This skill reads and writes local files only, and the path guards below
|
|
570
|
+
(output-is-input, existing output, output directory) only mean something for a local path. A
|
|
571
|
+
`-f lavfi` input is a filtergraph, not a location, and is left alone; a local name that
|
|
572
|
+
contains a colon still works written as `./name`."""
|
|
573
|
+
base = os.path.basename(cmd[0])
|
|
574
|
+
if not (base.startswith("ffmpeg") or base.startswith("ffprobe")):
|
|
575
|
+
return
|
|
576
|
+
fmt = None
|
|
577
|
+
values = []
|
|
578
|
+
for i, a in enumerate(cmd[1:-1], start=1):
|
|
579
|
+
if a == "-f":
|
|
580
|
+
fmt = cmd[i + 1]
|
|
581
|
+
elif a == "-i":
|
|
582
|
+
if fmt != "lavfi":
|
|
583
|
+
values.append(cmd[i + 1])
|
|
584
|
+
fmt = None
|
|
585
|
+
last = cmd[-1]
|
|
586
|
+
if not last.startswith("-"):
|
|
587
|
+
values.append(last)
|
|
588
|
+
for value in values:
|
|
589
|
+
if looks_like_url(value):
|
|
590
|
+
refuse("input", "media_path_is_url",
|
|
591
|
+
f"{value!r} would be opened by ffmpeg as a URL ({value.split(':', 1)[0]}:), not as a local file; "
|
|
592
|
+
"this skill reads and writes local files only. Pass a plain path (a local name containing "
|
|
593
|
+
"a colon works as ./name)", path=value, scheme=value.split(":", 1)[0].lower())
|
|
594
|
+
|
|
595
|
+
|
|
556
596
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
557
597
|
"""Refuse an ffmpeg command whose output path resolves to the same file as one of its
|
|
558
598
|
inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
|
|
@@ -615,6 +655,10 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
615
655
|
output = cmd[-1]
|
|
616
656
|
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
617
657
|
return
|
|
658
|
+
if looks_like_url(output):
|
|
659
|
+
refuse("input", "media_path_is_url",
|
|
660
|
+
f"output {output!r} would be opened by ffmpeg as a URL, not as a local file; pass a plain path "
|
|
661
|
+
"(a local name containing a colon works as ./name)", path=output, scheme=output.split(":", 1)[0].lower())
|
|
618
662
|
if os.path.isdir(output):
|
|
619
663
|
refuse("input", "output_is_directory",
|
|
620
664
|
f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})", output=output)
|
|
@@ -1042,6 +1086,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
1042
1086
|
# or runs the command (color.py imports this module, hence the late import)
|
|
1043
1087
|
from _common.color import fold_bt709_matrix
|
|
1044
1088
|
cmd = fold_bt709_matrix(cmd)
|
|
1089
|
+
_refuse_url_media(cmd)
|
|
1045
1090
|
_check_no_overwrite_input(cmd)
|
|
1046
1091
|
_check_output_path(cmd)
|
|
1047
1092
|
_check_existing_output(cmd)
|
|
@@ -1144,6 +1189,7 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
|
|
|
1144
1189
|
wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
|
|
1145
1190
|
failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
|
|
1146
1191
|
lists the command in the --json `commands` like run() does."""
|
|
1192
|
+
_refuse_url_media(cmd)
|
|
1147
1193
|
if record:
|
|
1148
1194
|
STATE.commands.append(_cmdline(cmd))
|
|
1149
1195
|
limit = _limit_for(cmd)
|
package/scripts/_contract.py
CHANGED
|
@@ -415,6 +415,7 @@ ERROR_REASONS = {
|
|
|
415
415
|
"output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
|
|
416
416
|
"output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
|
|
417
417
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
418
|
+
"media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
|
|
418
419
|
"output_dir_missing": "the output's directory does not exist; details: {output, directory}",
|
|
419
420
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
420
421
|
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|