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 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())
@@ -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)
@@ -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)