agentcut 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.
agentcut/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """AgentCut - Video editing MCP server for AI agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import Client
6
+
7
+ __all__ = ["Client"]
agentcut/__main__.py ADDED
@@ -0,0 +1,300 @@
1
+ """AgentCut CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(
12
+ prog="agentcut",
13
+ description="AgentCut — Video editing for AI agents",
14
+ )
15
+ parser.add_argument(
16
+ "--mcp",
17
+ action="store_true",
18
+ help="Run as MCP server (default mode)",
19
+ )
20
+ subparsers = parser.add_subparsers(dest="command", help="CLI commands")
21
+
22
+ # info
23
+ info_p = subparsers.add_parser("info", help="Get video metadata")
24
+ info_p.add_argument("input", help="Input video file")
25
+
26
+ # trim
27
+ trim_p = subparsers.add_parser("trim", help="Trim a video")
28
+ trim_p.add_argument("input", help="Input video file")
29
+ trim_p.add_argument("-s", "--start", default="0", help="Start time")
30
+ trim_p.add_argument("-d", "--duration", help="Duration")
31
+ trim_p.add_argument("-e", "--end", help="End time")
32
+ trim_p.add_argument("-o", "--output", help="Output file path")
33
+
34
+ # merge
35
+ merge_p = subparsers.add_parser("merge", help="Merge multiple clips")
36
+ merge_p.add_argument("inputs", nargs="+", help="Input video files")
37
+ merge_p.add_argument("-t", "--transition", default=None, choices=["fade", "dissolve", "wipe-left", "wipe-right", "wipe-up", "wipe-down"])
38
+ merge_p.add_argument("--transitions", nargs="+", choices=["fade", "dissolve", "wipe-left", "wipe-right", "wipe-up", "wipe-down"], help="Per-pair transition types (overrides --transition)")
39
+ merge_p.add_argument("-td", "--transition-duration", type=float, default=1.0, help="Transition duration in seconds")
40
+ merge_p.add_argument("-o", "--output", help="Output file path")
41
+
42
+ # add_text
43
+ text_p = subparsers.add_parser("add-text", help="Overlay text on a video")
44
+ text_p.add_argument("input", help="Input video file")
45
+ text_p.add_argument("text", help="Text to overlay")
46
+ text_p.add_argument("-p", "--position", default="top-center", choices=["top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right"])
47
+ text_p.add_argument("--font", help="Path to font file")
48
+ text_p.add_argument("--size", type=int, default=48, help="Font size in pixels")
49
+ text_p.add_argument("--color", default="white", help="Text color")
50
+ text_p.add_argument("--no-shadow", action="store_true", help="Disable text shadow")
51
+ text_p.add_argument("--start-time", type=float, help="When text appears (seconds)")
52
+ text_p.add_argument("--duration", type=float, help="How long text is visible (seconds)")
53
+ text_p.add_argument("-o", "--output", help="Output file path")
54
+
55
+ # add_audio
56
+ audio_p = subparsers.add_parser("add-audio", help="Add or replace audio track")
57
+ audio_p.add_argument("video", help="Input video file")
58
+ audio_p.add_argument("audio", help="Audio file (MP3, WAV, etc.)")
59
+ audio_p.add_argument("-v", "--volume", type=float, default=1.0, help="Audio volume (0.0-2.0)")
60
+ audio_p.add_argument("--fade-in", type=float, default=0.0, help="Fade in duration")
61
+ audio_p.add_argument("--fade-out", type=float, default=0.0, help="Fade out duration")
62
+ audio_p.add_argument("--mix", action="store_true", help="Mix with existing audio instead of replacing")
63
+ audio_p.add_argument("--start-time", type=float, help="When audio starts (seconds)")
64
+ audio_p.add_argument("-o", "--output", help="Output file path")
65
+
66
+ # resize
67
+ resize_p = subparsers.add_parser("resize", help="Resize a video")
68
+ resize_p.add_argument("input", help="Input video file")
69
+ resize_p.add_argument("-w", "--width", type=int, help="Target width")
70
+ resize_p.add_argument("--height", type=int, help="Target height")
71
+ resize_p.add_argument("-a", "--aspect-ratio", choices=["16:9", "9:16", "1:1", "4:3", "4:5", "21:9"], help="Preset aspect ratio")
72
+ resize_p.add_argument("-q", "--quality", default="high", choices=["low", "medium", "high", "ultra"])
73
+ resize_p.add_argument("-o", "--output", help="Output file path")
74
+
75
+ # speed
76
+ speed_p = subparsers.add_parser("speed", help="Change playback speed")
77
+ speed_p.add_argument("input", help="Input video file")
78
+ speed_p.add_argument("-f", "--factor", type=float, default=1.0, help="Speed multiplier (0.5=slow, 2.0=fast)")
79
+ speed_p.add_argument("-o", "--output", help="Output file path")
80
+
81
+ # convert
82
+ convert_p = subparsers.add_parser("convert", help="Convert video format")
83
+ convert_p.add_argument("input", help="Input video file")
84
+ convert_p.add_argument("-f", "--format", default="mp4", choices=["mp4", "webm", "gif", "mov"])
85
+ convert_p.add_argument("-q", "--quality", default="high", choices=["low", "medium", "high", "ultra"])
86
+ convert_p.add_argument("-o", "--output", help="Output file path")
87
+
88
+ # thumbnail
89
+ thumb_p = subparsers.add_parser("thumbnail", help="Extract a single frame")
90
+ thumb_p.add_argument("input", help="Input video file")
91
+ thumb_p.add_argument("-t", "--timestamp", type=float, help="Time in seconds (default: 10%% of duration)")
92
+ thumb_p.add_argument("-o", "--output", help="Output image path")
93
+
94
+ # preview
95
+ preview_p = subparsers.add_parser("preview", help="Generate a fast low-res preview")
96
+ preview_p.add_argument("input", help="Input video file")
97
+ preview_p.add_argument("-o", "--output", help="Output file path")
98
+ preview_p.add_argument("-s", "--scale", type=int, default=4, help="Downscale factor (default: 4)")
99
+
100
+ # storyboard
101
+ storyboard_p = subparsers.add_parser("storyboard", help="Extract key frames as storyboard")
102
+ storyboard_p.add_argument("input", help="Input video file")
103
+ storyboard_p.add_argument("-o", "--output-dir", help="Output directory")
104
+ storyboard_p.add_argument("-n", "--frames", type=int, default=8, help="Number of frames (default: 8)")
105
+
106
+ # subtitles
107
+ subs_p = subparsers.add_parser("subtitles", help="Burn subtitles into video")
108
+ subs_p.add_argument("input", help="Input video file")
109
+ subs_p.add_argument("subtitle", help="Subtitle file (.srt or .vtt)")
110
+ subs_p.add_argument("-o", "--output", help="Output file path")
111
+
112
+ # watermark
113
+ wm_p = subparsers.add_parser("watermark", help="Add image watermark")
114
+ wm_p.add_argument("input", help="Input video file")
115
+ wm_p.add_argument("image", help="Watermark image (PNG recommended)")
116
+ wm_p.add_argument("-p", "--position", default="bottom-right", choices=["top-left", "top-center", "top-right", "center-left", "center", "bottom-left", "bottom-center", "bottom-right"])
117
+ wm_p.add_argument("--opacity", type=float, default=0.7, help="Watermark opacity (0.0-1.0)")
118
+ wm_p.add_argument("--margin", type=int, default=20, help="Margin from edge in pixels")
119
+ wm_p.add_argument("-o", "--output", help="Output file path")
120
+
121
+ # crop
122
+ crop_p = subparsers.add_parser("crop", help="Crop a video to a region")
123
+ crop_p.add_argument("input", help="Input video file")
124
+ crop_p.add_argument("-w", "--width", type=int, required=True, help="Crop width in pixels")
125
+ crop_p.add_argument("--height", type=int, required=True, help="Crop height in pixels")
126
+ crop_p.add_argument("-x", type=int, default=None, help="X offset (default: center)")
127
+ crop_p.add_argument("-y", type=int, default=None, help="Y offset (default: center)")
128
+ crop_p.add_argument("-o", "--output", help="Output file path")
129
+
130
+ # rotate
131
+ rotate_p = subparsers.add_parser("rotate", help="Rotate and/or flip a video")
132
+ rotate_p.add_argument("input", help="Input video file")
133
+ rotate_p.add_argument("-a", "--angle", type=int, default=0, choices=[0, 90, 180, 270], help="Rotation angle in degrees")
134
+ rotate_p.add_argument("--flip-h", action="store_true", help="Flip horizontally")
135
+ rotate_p.add_argument("--flip-v", action="store_true", help="Flip vertically")
136
+ rotate_p.add_argument("-o", "--output", help="Output file path")
137
+
138
+ # fade
139
+ fade_p = subparsers.add_parser("fade", help="Add fade in/out to video")
140
+ fade_p.add_argument("input", help="Input video file")
141
+ fade_p.add_argument("--fade-in", type=float, default=0.0, help="Fade in duration (seconds)")
142
+ fade_p.add_argument("--fade-out", type=float, default=0.0, help="Fade out duration (seconds)")
143
+ fade_p.add_argument("-o", "--output", help="Output file path")
144
+
145
+ # export
146
+ export_p = subparsers.add_parser("export", help="Export video with quality settings")
147
+ export_p.add_argument("input", help="Input video file")
148
+ export_p.add_argument("-q", "--quality", default="high", choices=["low", "medium", "high", "ultra"])
149
+ export_p.add_argument("-f", "--format", default="mp4", choices=["mp4", "webm", "gif", "mov"])
150
+ export_p.add_argument("-o", "--output", help="Output file path")
151
+
152
+ # extract_audio
153
+ extract_p = subparsers.add_parser("extract-audio", help="Extract audio from video")
154
+ extract_p.add_argument("input", help="Input video file")
155
+ extract_p.add_argument("-f", "--format", default="mp3", choices=["mp3", "aac", "wav", "ogg", "flac"])
156
+ extract_p.add_argument("-o", "--output", help="Output audio file path")
157
+
158
+ # edit (timeline)
159
+ edit_p = subparsers.add_parser("edit", help="Execute timeline-based edit from JSON")
160
+ edit_p.add_argument("timeline", help="Path to timeline JSON file")
161
+ edit_p.add_argument("-o", "--output", help="Output file path")
162
+
163
+ args = parser.parse_args()
164
+
165
+ # Default mode: run MCP server
166
+ if args.mcp or args.command is None:
167
+ from .server import mcp
168
+ mcp.run()
169
+ return
170
+
171
+ # CLI commands
172
+ try:
173
+ if args.command == "info":
174
+ from .engine import probe
175
+ info = probe(args.input)
176
+ print(json.dumps(info.model_dump(), indent=2))
177
+
178
+ elif args.command == "trim":
179
+ from .engine import trim
180
+ result = trim(args.input, start=args.start, duration=args.duration, end=args.end, output_path=args.output)
181
+ print(json.dumps(result.model_dump(), indent=2))
182
+
183
+ elif args.command == "merge":
184
+ from .engine import merge
185
+ result = merge(args.inputs, output_path=args.output, transition=args.transition, transitions=args.transitions, transition_duration=args.transition_duration)
186
+ print(json.dumps(result.model_dump(), indent=2))
187
+
188
+ elif args.command == "add-text":
189
+ from .engine import add_text
190
+ result = add_text(
191
+ args.input, text=args.text, position=args.position,
192
+ font=args.font, size=args.size, color=args.color,
193
+ shadow=not args.no_shadow,
194
+ start_time=args.start_time, duration=args.duration,
195
+ output_path=args.output,
196
+ )
197
+ print(json.dumps(result.model_dump(), indent=2))
198
+
199
+ elif args.command == "add-audio":
200
+ from .engine import add_audio
201
+ result = add_audio(
202
+ args.video, args.audio, volume=args.volume,
203
+ fade_in=args.fade_in, fade_out=args.fade_out,
204
+ mix=args.mix, start_time=args.start_time,
205
+ output_path=args.output,
206
+ )
207
+ print(json.dumps(result.model_dump(), indent=2))
208
+
209
+ elif args.command == "resize":
210
+ from .engine import resize
211
+ result = resize(
212
+ args.input, width=args.width, height=args.height,
213
+ aspect_ratio=args.aspect_ratio, quality=args.quality,
214
+ output_path=args.output,
215
+ )
216
+ print(json.dumps(result.model_dump(), indent=2))
217
+
218
+ elif args.command == "speed":
219
+ from .engine import speed
220
+ result = speed(args.input, factor=args.factor, output_path=args.output)
221
+ print(json.dumps(result.model_dump(), indent=2))
222
+
223
+ elif args.command == "convert":
224
+ from .engine import convert
225
+ result = convert(args.input, format=args.format, quality=args.quality, output_path=args.output)
226
+ print(json.dumps(result.model_dump(), indent=2))
227
+
228
+ elif args.command == "thumbnail":
229
+ from .engine import thumbnail
230
+ result = thumbnail(args.input, timestamp=args.timestamp, output_path=args.output)
231
+ print(json.dumps(result.model_dump(), indent=2))
232
+
233
+ elif args.command == "preview":
234
+ from .engine import preview
235
+ result = preview(args.input, output_path=args.output, scale_factor=args.scale)
236
+ print(json.dumps(result.model_dump(), indent=2))
237
+
238
+ elif args.command == "storyboard":
239
+ from .engine import storyboard
240
+ result = storyboard(args.input, output_dir=args.output_dir, frame_count=args.frames)
241
+ print(json.dumps(result.model_dump(), indent=2))
242
+
243
+ elif args.command == "subtitles":
244
+ from .engine import subtitles
245
+ result = subtitles(args.input, subtitle_path=args.subtitle, output_path=args.output)
246
+ print(json.dumps(result.model_dump(), indent=2))
247
+
248
+ elif args.command == "watermark":
249
+ from .engine import watermark
250
+ result = watermark(
251
+ args.input, image_path=args.image, position=args.position,
252
+ opacity=args.opacity, margin=args.margin,
253
+ output_path=args.output,
254
+ )
255
+ print(json.dumps(result.model_dump(), indent=2))
256
+
257
+ elif args.command == "crop":
258
+ from .engine import crop
259
+ result = crop(args.input, width=args.width, height=args.height, x=args.x, y=args.y, output_path=args.output)
260
+ print(json.dumps(result.model_dump(), indent=2))
261
+
262
+ elif args.command == "rotate":
263
+ from .engine import rotate
264
+ result = rotate(args.input, angle=args.angle, flip_horizontal=args.flip_h, flip_vertical=args.flip_v, output_path=args.output)
265
+ print(json.dumps(result.model_dump(), indent=2))
266
+
267
+ elif args.command == "fade":
268
+ from .engine import fade
269
+ result = fade(args.input, fade_in=args.fade_in, fade_out=args.fade_out, output_path=args.output)
270
+ print(json.dumps(result.model_dump(), indent=2))
271
+
272
+ elif args.command == "export":
273
+ from .engine import export_video
274
+ result = export_video(args.input, quality=args.quality, format=args.format, output_path=args.output)
275
+ print(json.dumps(result.model_dump(), indent=2))
276
+
277
+ elif args.command == "extract-audio":
278
+ from .engine import extract_audio
279
+ result = extract_audio(args.input, output_path=args.output, format=args.format)
280
+ print(result)
281
+
282
+ elif args.command == "edit":
283
+ from .models import Timeline
284
+ with open(args.timeline) as f:
285
+ tl = Timeline.model_validate(json.load(f))
286
+ from .engine import edit_timeline
287
+ result = edit_timeline(tl, output_path=args.output)
288
+ print(json.dumps(result.model_dump(), indent=2))
289
+
290
+ except Exception as e:
291
+ from .errors import AgentCutError
292
+ if isinstance(e, AgentCutError):
293
+ print(json.dumps({"success": False, "error": e.to_dict()}, indent=2), file=sys.stderr)
294
+ else:
295
+ print(json.dumps({"success": False, "error": {"type": "unknown", "message": str(e)}}, indent=2), file=sys.stderr)
296
+ sys.exit(1)
297
+
298
+
299
+ if __name__ == "__main__":
300
+ main()
agentcut/client.py ADDED
@@ -0,0 +1,272 @@
1
+ """AgentCut Python client — clean API for programmatic video editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from .engine import (
8
+ add_audio as _add_audio,
9
+ add_text as _add_text,
10
+ convert as _convert,
11
+ crop as _crop,
12
+ edit_timeline as _edit_timeline,
13
+ export_video as _export_video,
14
+ extract_audio as _extract_audio,
15
+ fade as _fade,
16
+ merge as _merge,
17
+ preview as _preview,
18
+ probe as _probe,
19
+ resize as _resize,
20
+ rotate as _rotate,
21
+ storyboard as _storyboard,
22
+ subtitles as _subtitles,
23
+ speed as _speed,
24
+ thumbnail as _thumbnail,
25
+ trim as _trim,
26
+ watermark as _watermark,
27
+ )
28
+ from .models import (
29
+ EditResult,
30
+ ExportFormat,
31
+ Position,
32
+ QualityLevel,
33
+ StoryboardResult,
34
+ ThumbnailResult,
35
+ VideoInfo,
36
+ )
37
+
38
+
39
+ class Client:
40
+ """AgentCut client for programmatic video editing.
41
+
42
+ Usage:
43
+ from agentcut import Client
44
+ editor = Client()
45
+
46
+ result = editor.trim("input.mp4", start="00:00:30", duration="00:00:15")
47
+ print(result.output_path)
48
+ """
49
+
50
+ def info(self, input_path: str) -> VideoInfo:
51
+ """Get metadata about a video file."""
52
+ return _probe(input_path)
53
+
54
+ def trim(
55
+ self,
56
+ input: str,
57
+ start: str | float = 0,
58
+ duration: str | float | None = None,
59
+ end: str | float | None = None,
60
+ output: str | None = None,
61
+ ) -> EditResult:
62
+ """Trim a clip by start time and duration."""
63
+ return _trim(input, start=start, duration=duration, end=end, output_path=output)
64
+
65
+ def merge(
66
+ self,
67
+ clips: list[str],
68
+ output: str | None = None,
69
+ transitions: list[str] | None = None,
70
+ transition_duration: float = 1.0,
71
+ ) -> EditResult:
72
+ """Merge multiple clips into one video.
73
+
74
+ Args:
75
+ clips: List of video file paths.
76
+ output: Output file path.
77
+ transitions: Transition types applied between each clip pair.
78
+ One per boundary (len = len(clips)-1). If fewer provided,
79
+ the last type is repeated. Example: ["fade", "dissolve", "fade"].
80
+ transition_duration: Duration of each transition in seconds.
81
+ """
82
+ return _merge(clips, output_path=output, transitions=transitions, transition_duration=transition_duration)
83
+
84
+ def add_text(
85
+ self,
86
+ video: str,
87
+ text: str,
88
+ position: str = "top-center",
89
+ font: str | None = None,
90
+ size: int = 48,
91
+ color: str = "white",
92
+ shadow: bool = True,
93
+ start_time: float | None = None,
94
+ duration: float | None = None,
95
+ output: str | None = None,
96
+ ) -> EditResult:
97
+ """Overlay text on a video."""
98
+ return _add_text(
99
+ video, text=text, position=position, font=font,
100
+ size=size, color=color, shadow=shadow,
101
+ start_time=start_time, duration=duration,
102
+ output_path=output,
103
+ )
104
+
105
+ def add_audio(
106
+ self,
107
+ video: str,
108
+ audio: str,
109
+ volume: float = 1.0,
110
+ fade_in: float = 0.0,
111
+ fade_out: float = 0.0,
112
+ mix: bool = False,
113
+ start_time: float | None = None,
114
+ output: str | None = None,
115
+ ) -> EditResult:
116
+ """Add or replace audio track."""
117
+ return _add_audio(
118
+ video, audio_path=audio, volume=volume,
119
+ fade_in=fade_in, fade_out=fade_out, mix=mix,
120
+ start_time=start_time, output_path=output,
121
+ )
122
+
123
+ def resize(
124
+ self,
125
+ video: str,
126
+ width: int | None = None,
127
+ height: int | None = None,
128
+ aspect_ratio: str | None = None,
129
+ quality: str = "high",
130
+ output: str | None = None,
131
+ ) -> EditResult:
132
+ """Resize a video or change aspect ratio."""
133
+ return _resize(
134
+ video, width=width, height=height,
135
+ aspect_ratio=aspect_ratio, quality=quality,
136
+ output_path=output,
137
+ )
138
+
139
+ def convert(
140
+ self,
141
+ video: str,
142
+ format: str = "mp4",
143
+ quality: str = "high",
144
+ output: str | None = None,
145
+ ) -> EditResult:
146
+ """Convert video to a different format."""
147
+ return _convert(video, format=format, quality=quality, output_path=output)
148
+
149
+ def speed(
150
+ self,
151
+ video: str,
152
+ factor: float = 1.0,
153
+ output: str | None = None,
154
+ ) -> EditResult:
155
+ """Change playback speed."""
156
+ return _speed(video, factor=factor, output_path=output)
157
+
158
+ def thumbnail(
159
+ self,
160
+ video: str,
161
+ timestamp: float | None = None,
162
+ output: str | None = None,
163
+ ) -> ThumbnailResult:
164
+ """Extract a frame from a video."""
165
+ return _thumbnail(video, timestamp=timestamp, output_path=output)
166
+
167
+ def preview(
168
+ self,
169
+ video: str,
170
+ output: str | None = None,
171
+ scale_factor: int = 4,
172
+ ) -> EditResult:
173
+ """Generate a fast low-res preview."""
174
+ return _preview(video, output_path=output, scale_factor=scale_factor)
175
+
176
+ def storyboard(
177
+ self,
178
+ video: str,
179
+ output_dir: str | None = None,
180
+ frame_count: int = 8,
181
+ ) -> StoryboardResult:
182
+ """Extract key frames as storyboard for human review."""
183
+ return _storyboard(video, output_dir=output_dir, frame_count=frame_count)
184
+
185
+ def subtitles(
186
+ self,
187
+ video: str,
188
+ subtitle_file: str,
189
+ output: str | None = None,
190
+ ) -> EditResult:
191
+ """Burn subtitles into a video."""
192
+ return _subtitles(video, subtitle_path=subtitle_file, output_path=output)
193
+
194
+ def watermark(
195
+ self,
196
+ video: str,
197
+ image: str,
198
+ position: str = "bottom-right",
199
+ opacity: float = 0.7,
200
+ margin: int = 20,
201
+ output: str | None = None,
202
+ ) -> EditResult:
203
+ """Add image watermark."""
204
+ return _watermark(
205
+ video, image_path=image, position=position,
206
+ opacity=opacity, margin=margin, output_path=output,
207
+ )
208
+
209
+ def crop(
210
+ self,
211
+ video: str,
212
+ width: int,
213
+ height: int,
214
+ x: int | None = None,
215
+ y: int | None = None,
216
+ output: str | None = None,
217
+ ) -> EditResult:
218
+ """Crop a video to a rectangular region."""
219
+ return _crop(video, width=width, height=height, x=x, y=y, output_path=output)
220
+
221
+ def rotate(
222
+ self,
223
+ video: str,
224
+ angle: int = 0,
225
+ flip_horizontal: bool = False,
226
+ flip_vertical: bool = False,
227
+ output: str | None = None,
228
+ ) -> EditResult:
229
+ """Rotate and/or flip a video."""
230
+ return _rotate(video, angle=angle, flip_horizontal=flip_horizontal, flip_vertical=flip_vertical, output_path=output)
231
+
232
+ def fade(
233
+ self,
234
+ video: str,
235
+ fade_in: float = 0.0,
236
+ fade_out: float = 0.0,
237
+ output: str | None = None,
238
+ ) -> EditResult:
239
+ """Add fade in/out effect to a video."""
240
+ return _fade(video, fade_in=fade_in, fade_out=fade_out, output_path=output)
241
+
242
+ def export(
243
+ self,
244
+ video: str,
245
+ output: str | None = None,
246
+ quality: str = "high",
247
+ format: str = "mp4",
248
+ ) -> EditResult:
249
+ """Render final video with quality settings."""
250
+ return _export_video(video, output_path=output, quality=quality, format=format)
251
+
252
+ def edit(self, timeline: dict[str, Any], output: str | None = None) -> EditResult:
253
+ """Execute a full timeline-based edit from JSON."""
254
+ return _edit_timeline(timeline, output_path=output)
255
+
256
+ def extract_audio(
257
+ self,
258
+ video: str,
259
+ output: str | None = None,
260
+ format: str = "mp3",
261
+ ) -> EditResult:
262
+ """Extract audio track from video."""
263
+ result_path = _extract_audio(video, output_path=output, format=format)
264
+ return EditResult(
265
+ output_path=result_path,
266
+ operation="extract_audio",
267
+ format=format,
268
+ )
269
+
270
+
271
+ # Fix the circular import for resize
272
+ from .engine import resize as _resize