ff-toolkit 0.1.0__py3-none-any.whl
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.
- ff_kit/__init__.py +30 -0
- ff_kit/cli.py +159 -0
- ff_kit/core/__init__.py +7 -0
- ff_kit/core/add_subtitles.py +64 -0
- ff_kit/core/clip.py +56 -0
- ff_kit/core/extract_audio.py +54 -0
- ff_kit/core/merge.py +89 -0
- ff_kit/core/transcode.py +76 -0
- ff_kit/dispatch.py +93 -0
- ff_kit/executor.py +120 -0
- ff_kit/mcp/__init__.py +0 -0
- ff_kit/mcp/__main__.py +6 -0
- ff_kit/mcp/server.py +116 -0
- ff_kit/schemas/__init__.py +4 -0
- ff_kit/schemas/anthropic.py +218 -0
- ff_kit/schemas/openai.py +238 -0
- ff_toolkit-0.1.0.dist-info/METADATA +223 -0
- ff_toolkit-0.1.0.dist-info/RECORD +21 -0
- ff_toolkit-0.1.0.dist-info/WHEEL +4 -0
- ff_toolkit-0.1.0.dist-info/entry_points.txt +3 -0
- ff_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
ff_kit/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ff-kit — FFmpeg operations as LLM-callable tools.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from ff_kit import clip, merge, extract_audio, add_subtitles, transcode
|
|
7
|
+
|
|
8
|
+
result = clip("in.mp4", "out.mp4", start="00:01:00", duration="30")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ff_kit.core.clip import clip
|
|
14
|
+
from ff_kit.core.merge import merge
|
|
15
|
+
from ff_kit.core.extract_audio import extract_audio
|
|
16
|
+
from ff_kit.core.add_subtitles import add_subtitles
|
|
17
|
+
from ff_kit.core.transcode import transcode
|
|
18
|
+
from ff_kit.executor import Executor, FFmpegResult
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"clip",
|
|
24
|
+
"merge",
|
|
25
|
+
"extract_audio",
|
|
26
|
+
"add_subtitles",
|
|
27
|
+
"transcode",
|
|
28
|
+
"Executor",
|
|
29
|
+
"FFmpegResult",
|
|
30
|
+
]
|
ff_kit/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ff-kit CLI — use FFmpeg tools directly from the command line.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
ffkit clip input.mp4 output.mp4 --start 00:01:00 --duration 30
|
|
7
|
+
ffkit merge a.mp4 b.mp4 -o merged.mp4
|
|
8
|
+
ffkit extract-audio video.mp4 audio.wav --sample-rate 16000 --channels 1
|
|
9
|
+
ffkit add-subtitles video.mp4 output.mp4 --subtitle subs.srt --mode burn
|
|
10
|
+
ffkit transcode input.mp4 output.webm --video-codec libvpx-vp9 --crf 30
|
|
11
|
+
ffkit probe video.mp4
|
|
12
|
+
ffkit list-tools --format openai
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from ff_kit.executor import Executor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None) -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="ffkit",
|
|
27
|
+
description="ff-kit: FFmpeg operations as LLM-callable tools.",
|
|
28
|
+
)
|
|
29
|
+
sub = parser.add_subparsers(dest="command", help="Available commands")
|
|
30
|
+
|
|
31
|
+
# ── clip ──────────────────────────────────────────────────────
|
|
32
|
+
p_clip = sub.add_parser("clip", help="Trim a segment from a media file")
|
|
33
|
+
p_clip.add_argument("input", help="Source media file")
|
|
34
|
+
p_clip.add_argument("output", help="Output file")
|
|
35
|
+
p_clip.add_argument("--start", "-s", required=True, help="Start time (HH:MM:SS or seconds)")
|
|
36
|
+
p_clip.add_argument("--end", "-e", help="End time")
|
|
37
|
+
p_clip.add_argument("--duration", "-d", help="Duration")
|
|
38
|
+
|
|
39
|
+
# ── merge ─────────────────────────────────────────────────────
|
|
40
|
+
p_merge = sub.add_parser("merge", help="Concatenate multiple files")
|
|
41
|
+
p_merge.add_argument("inputs", nargs="+", help="Files to merge (at least 2)")
|
|
42
|
+
p_merge.add_argument("--output", "-o", required=True, help="Output file")
|
|
43
|
+
p_merge.add_argument("--method", "-m", default="concat_demuxer",
|
|
44
|
+
choices=["concat_demuxer", "concat_filter"])
|
|
45
|
+
|
|
46
|
+
# ── extract-audio ─────────────────────────────────────────────
|
|
47
|
+
p_audio = sub.add_parser("extract-audio", help="Extract audio from a media file")
|
|
48
|
+
p_audio.add_argument("input", help="Source media file")
|
|
49
|
+
p_audio.add_argument("output", help="Output audio file (.mp3, .wav, .aac, etc.)")
|
|
50
|
+
p_audio.add_argument("--codec", "-c", default="copy", help="Audio codec (default: copy)")
|
|
51
|
+
p_audio.add_argument("--sample-rate", "-r", type=int, help="Sample rate in Hz (e.g. 16000)")
|
|
52
|
+
p_audio.add_argument("--channels", type=int, help="Channels (1=mono, 2=stereo)")
|
|
53
|
+
|
|
54
|
+
# ── add-subtitles ─────────────────────────────────────────────
|
|
55
|
+
p_subs = sub.add_parser("add-subtitles", help="Add subtitles to a video")
|
|
56
|
+
p_subs.add_argument("input", help="Source video file")
|
|
57
|
+
p_subs.add_argument("output", help="Output video file")
|
|
58
|
+
p_subs.add_argument("--subtitle", required=True, help="Subtitle file (.srt, .ass, .vtt)")
|
|
59
|
+
p_subs.add_argument("--mode", default="burn", choices=["burn", "embed"])
|
|
60
|
+
|
|
61
|
+
# ── transcode ─────────────────────────────────────────────────
|
|
62
|
+
p_trans = sub.add_parser("transcode", help="Convert format/codec/resolution")
|
|
63
|
+
p_trans.add_argument("input", help="Source file")
|
|
64
|
+
p_trans.add_argument("output", help="Destination file")
|
|
65
|
+
p_trans.add_argument("--video-codec", help="Video codec (e.g. libx264)")
|
|
66
|
+
p_trans.add_argument("--audio-codec", help="Audio codec (e.g. aac)")
|
|
67
|
+
p_trans.add_argument("--resolution", help="Resolution as WxH (e.g. 1280x720)")
|
|
68
|
+
p_trans.add_argument("--bitrate", help="Target bitrate (e.g. 2M)")
|
|
69
|
+
p_trans.add_argument("--fps", type=int, help="Frame rate")
|
|
70
|
+
p_trans.add_argument("--preset", help="Encoder preset (e.g. fast, medium, slow)")
|
|
71
|
+
p_trans.add_argument("--crf", type=int, help="Constant Rate Factor")
|
|
72
|
+
|
|
73
|
+
# ── probe ─────────────────────────────────────────────────────
|
|
74
|
+
p_probe = sub.add_parser("probe", help="Show media file info (ffprobe)")
|
|
75
|
+
p_probe.add_argument("input", help="Media file to inspect")
|
|
76
|
+
|
|
77
|
+
# ── list-tools ────────────────────────────────────────────────
|
|
78
|
+
p_list = sub.add_parser("list-tools", help="Print tool schemas for LLM integration")
|
|
79
|
+
p_list.add_argument("--format", "-f", default="openai",
|
|
80
|
+
choices=["openai", "anthropic"],
|
|
81
|
+
help="Schema format (default: openai)")
|
|
82
|
+
|
|
83
|
+
# ── parse ─────────────────────────────────────────────────────
|
|
84
|
+
args = parser.parse_args(argv)
|
|
85
|
+
|
|
86
|
+
if not args.command:
|
|
87
|
+
parser.print_help()
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return _run(args)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run(args: argparse.Namespace) -> int:
|
|
98
|
+
if args.command == "list-tools":
|
|
99
|
+
if args.format == "openai":
|
|
100
|
+
from ff_kit.schemas.openai import openai_tools
|
|
101
|
+
print(json.dumps(openai_tools(), indent=2))
|
|
102
|
+
else:
|
|
103
|
+
from ff_kit.schemas.anthropic import anthropic_tools
|
|
104
|
+
print(json.dumps(anthropic_tools(), indent=2))
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
if args.command == "probe":
|
|
108
|
+
exe = Executor()
|
|
109
|
+
info = exe.probe(args.input)
|
|
110
|
+
print(json.dumps(info, indent=2))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
# All other commands need the core functions
|
|
114
|
+
from ff_kit.core import clip, merge, extract_audio, add_subtitles, transcode
|
|
115
|
+
|
|
116
|
+
if args.command == "clip":
|
|
117
|
+
result = clip(
|
|
118
|
+
args.input, args.output,
|
|
119
|
+
start=args.start, end=args.end, duration=args.duration,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
elif args.command == "merge":
|
|
123
|
+
result = merge(args.inputs, args.output, method=args.method)
|
|
124
|
+
|
|
125
|
+
elif args.command == "extract-audio":
|
|
126
|
+
result = extract_audio(
|
|
127
|
+
args.input, args.output,
|
|
128
|
+
codec=args.codec,
|
|
129
|
+
sample_rate=args.sample_rate,
|
|
130
|
+
channels=args.channels,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
elif args.command == "add-subtitles":
|
|
134
|
+
result = add_subtitles(
|
|
135
|
+
args.input, args.output,
|
|
136
|
+
subtitle_path=args.subtitle,
|
|
137
|
+
mode=args.mode,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
elif args.command == "transcode":
|
|
141
|
+
result = transcode(
|
|
142
|
+
args.input, args.output,
|
|
143
|
+
video_codec=args.video_codec,
|
|
144
|
+
audio_codec=args.audio_codec,
|
|
145
|
+
resolution=args.resolution,
|
|
146
|
+
bitrate=args.bitrate,
|
|
147
|
+
fps=args.fps,
|
|
148
|
+
preset=args.preset,
|
|
149
|
+
crf=args.crf,
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
print(f"Done: {result.output_path}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main())
|
ff_kit/core/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from ff_kit.core.clip import clip
|
|
2
|
+
from ff_kit.core.merge import merge
|
|
3
|
+
from ff_kit.core.extract_audio import extract_audio
|
|
4
|
+
from ff_kit.core.add_subtitles import add_subtitles
|
|
5
|
+
from ff_kit.core.transcode import transcode
|
|
6
|
+
|
|
7
|
+
__all__ = ["clip", "merge", "extract_audio", "add_subtitles", "transcode"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Burn or embed subtitles into a video file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ff_kit.executor import Executor, FFmpegResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_subtitles(
|
|
11
|
+
input_path: str,
|
|
12
|
+
output_path: str,
|
|
13
|
+
subtitle_path: str,
|
|
14
|
+
*,
|
|
15
|
+
mode: str = "burn",
|
|
16
|
+
executor: Executor | None = None,
|
|
17
|
+
) -> FFmpegResult:
|
|
18
|
+
"""
|
|
19
|
+
Add subtitles to a video.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
input_path : str
|
|
24
|
+
Source video file.
|
|
25
|
+
output_path : str
|
|
26
|
+
Output video file.
|
|
27
|
+
subtitle_path : str
|
|
28
|
+
Path to subtitle file (``.srt``, ``.ass``, ``.vtt``).
|
|
29
|
+
mode : str
|
|
30
|
+
``"burn"`` (default) — hard-code subtitles into the video pixels
|
|
31
|
+
(uses the ``subtitles`` filter; universal playback).
|
|
32
|
+
``"embed"`` — add as a soft subtitle stream (requires
|
|
33
|
+
a container that supports subtitle tracks, e.g. MKV/MP4).
|
|
34
|
+
executor : Executor, optional
|
|
35
|
+
Custom executor.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
FFmpegResult
|
|
40
|
+
"""
|
|
41
|
+
if mode not in ("burn", "embed"):
|
|
42
|
+
raise ValueError(f"mode must be 'burn' or 'embed', got {mode!r}")
|
|
43
|
+
|
|
44
|
+
exe = executor or Executor()
|
|
45
|
+
|
|
46
|
+
if mode == "burn":
|
|
47
|
+
# Escape path for the subtitles filter (colons, backslashes)
|
|
48
|
+
safe_sub = str(Path(subtitle_path).resolve()).replace("\\", "/").replace(":", "\\:")
|
|
49
|
+
args = [
|
|
50
|
+
"-i", input_path,
|
|
51
|
+
"-vf", f"subtitles={safe_sub}",
|
|
52
|
+
"-c:a", "copy",
|
|
53
|
+
output_path,
|
|
54
|
+
]
|
|
55
|
+
else: # embed
|
|
56
|
+
args = [
|
|
57
|
+
"-i", input_path,
|
|
58
|
+
"-i", subtitle_path,
|
|
59
|
+
"-c", "copy",
|
|
60
|
+
"-c:s", "mov_text",
|
|
61
|
+
output_path,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
return exe.run(args, output_path=output_path)
|
ff_kit/core/clip.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Clip (trim) a segment from a media file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ff_kit.executor import Executor, FFmpegResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def clip(
|
|
9
|
+
input_path: str,
|
|
10
|
+
output_path: str,
|
|
11
|
+
start: str,
|
|
12
|
+
end: str | None = None,
|
|
13
|
+
duration: str | None = None,
|
|
14
|
+
*,
|
|
15
|
+
executor: Executor | None = None,
|
|
16
|
+
) -> FFmpegResult:
|
|
17
|
+
"""
|
|
18
|
+
Extract a segment from *input_path* and write it to *output_path*.
|
|
19
|
+
|
|
20
|
+
Specify the segment with *start* + either *end* or *duration*.
|
|
21
|
+
Time format: ``HH:MM:SS.ms`` or seconds (e.g. ``"90"``).
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
input_path : str
|
|
26
|
+
Path to the source media file.
|
|
27
|
+
output_path : str
|
|
28
|
+
Path for the trimmed output file.
|
|
29
|
+
start : str
|
|
30
|
+
Start timestamp (e.g. ``"00:01:30"`` or ``"90"``).
|
|
31
|
+
end : str, optional
|
|
32
|
+
End timestamp. Mutually exclusive with *duration*.
|
|
33
|
+
duration : str, optional
|
|
34
|
+
Duration of the clip. Mutually exclusive with *end*.
|
|
35
|
+
executor : Executor, optional
|
|
36
|
+
Custom executor; a default one is created if omitted.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
FFmpegResult
|
|
41
|
+
"""
|
|
42
|
+
if not end and not duration:
|
|
43
|
+
raise ValueError("Either 'end' or 'duration' must be provided.")
|
|
44
|
+
if end and duration:
|
|
45
|
+
raise ValueError("Provide either 'end' or 'duration', not both.")
|
|
46
|
+
|
|
47
|
+
exe = executor or Executor()
|
|
48
|
+
args = ["-i", input_path, "-ss", start]
|
|
49
|
+
|
|
50
|
+
if end:
|
|
51
|
+
args.extend(["-to", end])
|
|
52
|
+
else:
|
|
53
|
+
args.extend(["-t", duration]) # type: ignore[arg-type]
|
|
54
|
+
|
|
55
|
+
args.extend(["-c", "copy", output_path])
|
|
56
|
+
return exe.run(args, output_path=output_path)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Extract audio track from a media file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ff_kit.executor import Executor, FFmpegResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_audio(
|
|
9
|
+
input_path: str,
|
|
10
|
+
output_path: str,
|
|
11
|
+
*,
|
|
12
|
+
codec: str = "copy",
|
|
13
|
+
sample_rate: int | None = None,
|
|
14
|
+
channels: int | None = None,
|
|
15
|
+
executor: Executor | None = None,
|
|
16
|
+
) -> FFmpegResult:
|
|
17
|
+
"""
|
|
18
|
+
Extract the audio stream from a video/audio file.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
input_path : str
|
|
23
|
+
Source media file.
|
|
24
|
+
output_path : str
|
|
25
|
+
Destination audio file (e.g. ``"out.mp3"``, ``"out.wav"``).
|
|
26
|
+
codec : str
|
|
27
|
+
Audio codec. ``"copy"`` (default) keeps the original codec;
|
|
28
|
+
use ``"libmp3lame"``, ``"aac"``, ``"pcm_s16le"``, etc. to re-encode.
|
|
29
|
+
sample_rate : int, optional
|
|
30
|
+
Output sample rate in Hz (e.g. ``16000`` for ASR pipelines).
|
|
31
|
+
channels : int, optional
|
|
32
|
+
Number of audio channels (``1`` = mono, ``2`` = stereo).
|
|
33
|
+
executor : Executor, optional
|
|
34
|
+
Custom executor.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
FFmpegResult
|
|
39
|
+
"""
|
|
40
|
+
exe = executor or Executor()
|
|
41
|
+
args = ["-i", input_path, "-vn"]
|
|
42
|
+
|
|
43
|
+
if codec != "copy":
|
|
44
|
+
args.extend(["-acodec", codec])
|
|
45
|
+
else:
|
|
46
|
+
args.extend(["-acodec", "copy"])
|
|
47
|
+
|
|
48
|
+
if sample_rate is not None:
|
|
49
|
+
args.extend(["-ar", str(sample_rate)])
|
|
50
|
+
if channels is not None:
|
|
51
|
+
args.extend(["-ac", str(channels)])
|
|
52
|
+
|
|
53
|
+
args.append(output_path)
|
|
54
|
+
return exe.run(args, output_path=output_path)
|
ff_kit/core/merge.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Merge (concatenate) multiple media files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ff_kit.executor import Executor, FFmpegResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def merge(
|
|
12
|
+
input_paths: list[str],
|
|
13
|
+
output_path: str,
|
|
14
|
+
*,
|
|
15
|
+
method: str = "concat_demuxer",
|
|
16
|
+
executor: Executor | None = None,
|
|
17
|
+
) -> FFmpegResult:
|
|
18
|
+
"""
|
|
19
|
+
Concatenate multiple media files into one.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
input_paths : list[str]
|
|
24
|
+
Ordered list of files to concatenate.
|
|
25
|
+
output_path : str
|
|
26
|
+
Destination path for the merged file.
|
|
27
|
+
method : str
|
|
28
|
+
``"concat_demuxer"`` (default, fast, same-codec) or
|
|
29
|
+
``"concat_filter"`` (re-encodes, works across formats).
|
|
30
|
+
executor : Executor, optional
|
|
31
|
+
Custom executor.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
FFmpegResult
|
|
36
|
+
"""
|
|
37
|
+
if len(input_paths) < 2:
|
|
38
|
+
raise ValueError("Need at least 2 input files to merge.")
|
|
39
|
+
|
|
40
|
+
exe = executor or Executor()
|
|
41
|
+
|
|
42
|
+
if method == "concat_demuxer":
|
|
43
|
+
return _merge_demuxer(exe, input_paths, output_path)
|
|
44
|
+
elif method == "concat_filter":
|
|
45
|
+
return _merge_filter(exe, input_paths, output_path)
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError(f"Unknown merge method: {method!r}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _merge_demuxer(
|
|
51
|
+
exe: Executor, input_paths: list[str], output_path: str
|
|
52
|
+
) -> FFmpegResult:
|
|
53
|
+
"""Fast concat via the concat demuxer (same codec required)."""
|
|
54
|
+
with tempfile.NamedTemporaryFile(
|
|
55
|
+
mode="w", suffix=".txt", delete=False
|
|
56
|
+
) as f:
|
|
57
|
+
for p in input_paths:
|
|
58
|
+
f.write(f"file '{Path(p).resolve()}'\n")
|
|
59
|
+
list_file = f.name
|
|
60
|
+
|
|
61
|
+
args = [
|
|
62
|
+
"-f", "concat",
|
|
63
|
+
"-safe", "0",
|
|
64
|
+
"-i", list_file,
|
|
65
|
+
"-c", "copy",
|
|
66
|
+
output_path,
|
|
67
|
+
]
|
|
68
|
+
return exe.run(args, output_path=output_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _merge_filter(
|
|
72
|
+
exe: Executor, input_paths: list[str], output_path: str
|
|
73
|
+
) -> FFmpegResult:
|
|
74
|
+
"""Re-encoding concat via the concat filter (cross-format)."""
|
|
75
|
+
args: list[str] = []
|
|
76
|
+
for p in input_paths:
|
|
77
|
+
args.extend(["-i", p])
|
|
78
|
+
|
|
79
|
+
n = len(input_paths)
|
|
80
|
+
filter_str = "".join(f"[{i}:v][{i}:a]" for i in range(n))
|
|
81
|
+
filter_str += f"concat=n={n}:v=1:a=1[outv][outa]"
|
|
82
|
+
|
|
83
|
+
args.extend([
|
|
84
|
+
"-filter_complex", filter_str,
|
|
85
|
+
"-map", "[outv]",
|
|
86
|
+
"-map", "[outa]",
|
|
87
|
+
output_path,
|
|
88
|
+
])
|
|
89
|
+
return exe.run(args, output_path=output_path)
|
ff_kit/core/transcode.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Transcode a media file to a different format / codec / resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ff_kit.executor import Executor, FFmpegResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def transcode(
|
|
9
|
+
input_path: str,
|
|
10
|
+
output_path: str,
|
|
11
|
+
*,
|
|
12
|
+
video_codec: str | None = None,
|
|
13
|
+
audio_codec: str | None = None,
|
|
14
|
+
resolution: str | None = None,
|
|
15
|
+
bitrate: str | None = None,
|
|
16
|
+
fps: int | None = None,
|
|
17
|
+
preset: str | None = None,
|
|
18
|
+
crf: int | None = None,
|
|
19
|
+
extra_args: list[str] | None = None,
|
|
20
|
+
executor: Executor | None = None,
|
|
21
|
+
) -> FFmpegResult:
|
|
22
|
+
"""
|
|
23
|
+
Transcode a media file with full control over codecs and quality.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
input_path : str
|
|
28
|
+
Source file.
|
|
29
|
+
output_path : str
|
|
30
|
+
Destination file — the container format is inferred from the
|
|
31
|
+
extension (e.g. ``.mp4``, ``.webm``, ``.mkv``).
|
|
32
|
+
video_codec : str, optional
|
|
33
|
+
Video codec (e.g. ``"libx264"``, ``"libx265"``, ``"libvpx-vp9"``).
|
|
34
|
+
audio_codec : str, optional
|
|
35
|
+
Audio codec (e.g. ``"aac"``, ``"libopus"``).
|
|
36
|
+
resolution : str, optional
|
|
37
|
+
Output resolution as ``"WxH"`` (e.g. ``"1280x720"``).
|
|
38
|
+
bitrate : str, optional
|
|
39
|
+
Target bitrate (e.g. ``"2M"``, ``"500k"``).
|
|
40
|
+
fps : int, optional
|
|
41
|
+
Output frame rate.
|
|
42
|
+
preset : str, optional
|
|
43
|
+
Encoder preset (e.g. ``"fast"``, ``"medium"``, ``"slow"``).
|
|
44
|
+
crf : int, optional
|
|
45
|
+
Constant Rate Factor for quality-based encoding (lower = better).
|
|
46
|
+
extra_args : list[str], optional
|
|
47
|
+
Any additional ffmpeg arguments.
|
|
48
|
+
executor : Executor, optional
|
|
49
|
+
Custom executor.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
FFmpegResult
|
|
54
|
+
"""
|
|
55
|
+
exe = executor or Executor()
|
|
56
|
+
args = ["-i", input_path]
|
|
57
|
+
|
|
58
|
+
if video_codec:
|
|
59
|
+
args.extend(["-c:v", video_codec])
|
|
60
|
+
if audio_codec:
|
|
61
|
+
args.extend(["-c:a", audio_codec])
|
|
62
|
+
if resolution:
|
|
63
|
+
args.extend(["-s", resolution])
|
|
64
|
+
if bitrate:
|
|
65
|
+
args.extend(["-b:v", bitrate])
|
|
66
|
+
if fps:
|
|
67
|
+
args.extend(["-r", str(fps)])
|
|
68
|
+
if preset:
|
|
69
|
+
args.extend(["-preset", preset])
|
|
70
|
+
if crf is not None:
|
|
71
|
+
args.extend(["-crf", str(crf)])
|
|
72
|
+
if extra_args:
|
|
73
|
+
args.extend(extra_args)
|
|
74
|
+
|
|
75
|
+
args.append(output_path)
|
|
76
|
+
return exe.run(args, output_path=output_path)
|
ff_kit/dispatch.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool-call dispatcher — routes LLM tool calls to ff-kit functions.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from ff_kit.dispatch import dispatch
|
|
7
|
+
|
|
8
|
+
# Given a tool call from any LLM provider:
|
|
9
|
+
result = dispatch("ffkit_clip", {
|
|
10
|
+
"input_path": "in.mp4",
|
|
11
|
+
"output_path": "out.mp4",
|
|
12
|
+
"start": "00:01:00",
|
|
13
|
+
"duration": "30",
|
|
14
|
+
})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ff_kit.core import clip, merge, extract_audio, add_subtitles, transcode
|
|
22
|
+
from ff_kit.executor import Executor, FFmpegResult
|
|
23
|
+
|
|
24
|
+
# Map of tool name → (function, set of valid kwargs)
|
|
25
|
+
_REGISTRY: dict[str, tuple[Any, set[str]]] = {
|
|
26
|
+
"ffkit_clip": (clip, {"input_path", "output_path", "start", "end", "duration"}),
|
|
27
|
+
"ffkit_merge": (merge, {"input_paths", "output_path", "method"}),
|
|
28
|
+
"ffkit_extract_audio": (
|
|
29
|
+
extract_audio,
|
|
30
|
+
{"input_path", "output_path", "codec", "sample_rate", "channels"},
|
|
31
|
+
),
|
|
32
|
+
"ffkit_add_subtitles": (
|
|
33
|
+
add_subtitles,
|
|
34
|
+
{"input_path", "output_path", "subtitle_path", "mode"},
|
|
35
|
+
),
|
|
36
|
+
"ffkit_transcode": (
|
|
37
|
+
transcode,
|
|
38
|
+
{
|
|
39
|
+
"input_path", "output_path", "video_codec", "audio_codec",
|
|
40
|
+
"resolution", "bitrate", "fps", "preset", "crf", "extra_args",
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dispatch(
|
|
47
|
+
tool_name: str,
|
|
48
|
+
arguments: dict[str, Any],
|
|
49
|
+
*,
|
|
50
|
+
executor: Executor | None = None,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Execute an ff-kit tool by name and return a JSON-serialisable result.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
tool_name : str
|
|
58
|
+
One of the registered tool names (e.g. ``"ffkit_clip"``).
|
|
59
|
+
arguments : dict
|
|
60
|
+
The arguments dict as parsed from the LLM's tool call.
|
|
61
|
+
executor : Executor, optional
|
|
62
|
+
Shared executor instance (reuses ffmpeg path / timeout settings).
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
dict
|
|
67
|
+
``{"status": "ok", ...result_fields}`` on success, or
|
|
68
|
+
``{"status": "error", "error": "..."}`` on failure.
|
|
69
|
+
"""
|
|
70
|
+
if tool_name not in _REGISTRY:
|
|
71
|
+
return {
|
|
72
|
+
"status": "error",
|
|
73
|
+
"error": f"Unknown tool: {tool_name!r}. Available: {sorted(_REGISTRY)}",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn, valid_keys = _REGISTRY[tool_name]
|
|
77
|
+
|
|
78
|
+
# Filter out any unexpected keys the LLM might hallucinate
|
|
79
|
+
kwargs = {k: v for k, v in arguments.items() if k in valid_keys}
|
|
80
|
+
|
|
81
|
+
if executor is not None:
|
|
82
|
+
kwargs["executor"] = executor
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
result: FFmpegResult = fn(**kwargs)
|
|
86
|
+
return {"status": "ok", **result.to_dict()}
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
return {"status": "error", "error": f"{type(exc).__name__}: {exc}"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_tools() -> list[str]:
|
|
92
|
+
"""Return the names of all registered tools."""
|
|
93
|
+
return sorted(_REGISTRY)
|