vidio-cli 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.
vidio_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("vidio-cli")
vidio_cli/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ """CLI entry point for vidio - A simple ffmpeg wrapper."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from vidio_cli import __version__
7
+ from vidio_cli.commands import get_commands
8
+ from vidio_cli.ffmpeg_utils import ensure_ffmpeg
9
+
10
+ # Create Typer app
11
+ app = typer.Typer(
12
+ help="A simple ffmpeg wrapper for common video operations",
13
+ add_completion=False, # No shell completion for now
14
+ no_args_is_help=True,
15
+ )
16
+
17
+ console = Console()
18
+
19
+
20
+ def version_callback(value: bool) -> None:
21
+ """Print the version of the package."""
22
+ if value:
23
+ console.print(f"[bold]vidio[/bold] version: {__version__}")
24
+ raise typer.Exit()
25
+
26
+
27
+ @app.callback()
28
+ def main(
29
+ ctx: typer.Context,
30
+ version: bool = typer.Option(
31
+ False,
32
+ "--version",
33
+ "-V",
34
+ help="Show the version and exit.",
35
+ callback=version_callback,
36
+ ),
37
+ verbose: bool = typer.Option(
38
+ False,
39
+ "--verbose",
40
+ "-v",
41
+ help="Show ffmpeg commands and other debug info.",
42
+ ),
43
+ ) -> None:
44
+ """
45
+ A simple CLI tool to perform common video operations using ffmpeg.
46
+ """
47
+ # Check if ffmpeg is installed
48
+ ensure_ffmpeg()
49
+
50
+ # Store verbose flag in context for global access
51
+ ctx.ensure_object(dict)
52
+ ctx.obj["VERBOSE"] = verbose
53
+
54
+
55
+ # Dynamic command registration
56
+ commands = get_commands()
57
+ for name, register_func in commands.items():
58
+ register_func(app)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ app()
@@ -0,0 +1,36 @@
1
+ """Command modules for vidio-cli."""
2
+
3
+ import pkgutil
4
+ from importlib import import_module
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+
9
+ def get_commands() -> dict[str, Callable]:
10
+ """
11
+ Dynamically discover and import all command modules in this package.
12
+
13
+ Returns:
14
+ A dictionary mapping command names to functions.
15
+ """
16
+ commands = {}
17
+
18
+ # Get the directory of this package
19
+ package_dir = Path(__file__).parent
20
+
21
+ # Find all Python modules in this package
22
+ for _, module_name, _ in pkgutil.iter_modules([str(package_dir)]):
23
+ # Skip the __init__ module
24
+ if module_name == "__init__":
25
+ continue
26
+
27
+ # Import the module
28
+ module = import_module(f"{__package__}.{module_name}")
29
+
30
+ # Look for a 'register' function
31
+ if hasattr(module, "register"):
32
+ # This is a valid command module, add it to the dict
33
+ command_name = module_name.replace("_", "-")
34
+ commands[command_name] = module.register
35
+
36
+ return commands
@@ -0,0 +1,103 @@
1
+ """Command module for concatenating videos horizontally or vertically."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from vidio_cli.ffmpeg_utils import check_output_file, run_ffmpeg
9
+
10
+ console = Console()
11
+
12
+
13
+ def register(app: typer.Typer) -> None:
14
+ """
15
+ Register the concat command with the Typer app.
16
+
17
+ Args:
18
+ app: The Typer app to register the command with.
19
+ """
20
+ app.command(no_args_is_help=True)(concat)
21
+
22
+
23
+ def concat(
24
+ ctx: typer.Context,
25
+ input_files: list[Path] = typer.Argument(
26
+ ...,
27
+ help="Input video files to concatenate",
28
+ exists=True,
29
+ dir_okay=False,
30
+ resolve_path=True,
31
+ min=2,
32
+ ),
33
+ output_file: Path = typer.Argument(
34
+ ...,
35
+ help="Output video file",
36
+ dir_okay=False,
37
+ resolve_path=True,
38
+ ),
39
+ vertical: bool = typer.Option(
40
+ False,
41
+ "--vertical",
42
+ help="Stack videos vertically instead of horizontally",
43
+ ),
44
+ overwrite: bool = typer.Option(
45
+ False,
46
+ "--overwrite",
47
+ help="Overwrite output file if it exists",
48
+ ),
49
+ ) -> None:
50
+ """
51
+ Concatenate multiple videos side by side (horizontally) or stacked (vertically).
52
+
53
+ Examples:
54
+ - Concatenate videos horizontally: vidio concat video1.mp4 video2.mp4 output.mp4
55
+ - Stack videos vertically: vidio concat video1.mp4 video2.mp4 output.mp4 --vertical
56
+ """
57
+ # Get verbose flag from global context
58
+ verbose = ctx.obj.get("VERBOSE", False) if ctx.obj else False
59
+
60
+ # Check if output file exists and if we should overwrite it
61
+ if not check_output_file(output_file, overwrite):
62
+ console.print("[yellow]Aborted.[/yellow]")
63
+ raise typer.Exit(code=0)
64
+
65
+ # Build the filter complex string for concatenation
66
+ filter_complex = ""
67
+
68
+ # Prepare inputs
69
+ inputs = []
70
+ for i, _ in enumerate(input_files):
71
+ inputs.extend(["-i", str(input_files[i])])
72
+
73
+ # Set the layout direction
74
+ layout = "vstack" if vertical else "hstack"
75
+
76
+ # Create filter_complex setting for proper scaling
77
+ inputs_str = "[0:v]"
78
+ for i in range(1, len(input_files)):
79
+ inputs_str += f"[{i}:v]"
80
+
81
+ filter_complex = f"{inputs_str}{layout}=inputs={len(input_files)}[v]"
82
+
83
+ # Build the ffmpeg command
84
+ command = [
85
+ "ffmpeg",
86
+ *inputs,
87
+ "-filter_complex",
88
+ filter_complex,
89
+ "-map",
90
+ "[v]",
91
+ # Map all audio streams (if present)
92
+ "-map",
93
+ "0:a?",
94
+ # Use the first audio stream for output
95
+ "-c:a",
96
+ "aac",
97
+ "-shortest", # End when shortest input ends
98
+ "-y" if overwrite else "-n", # Overwrite if specified
99
+ str(output_file),
100
+ ]
101
+
102
+ # Run the command
103
+ run_ffmpeg(command, verbose=verbose)
@@ -0,0 +1,391 @@
1
+ """Command module for cropping videos."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from vidio_cli.ffmpeg_utils import check_output_file, get_video_info, run_ffmpeg
10
+
11
+ console = Console()
12
+
13
+
14
+ def register(app: typer.Typer) -> None:
15
+ """
16
+ Register the crop command with the Typer app.
17
+
18
+ Args:
19
+ app: The Typer app to register the command with.
20
+ """
21
+ app.command(no_args_is_help=True)(crop)
22
+
23
+
24
+ def validate_crop_params(
25
+ width: int,
26
+ height: int,
27
+ x: int,
28
+ y: int,
29
+ original_width: int,
30
+ original_height: int,
31
+ ) -> None:
32
+ """
33
+ Validate crop parameters against original video dimensions.
34
+
35
+ Args:
36
+ width: Crop width
37
+ height: Crop height
38
+ x: X offset
39
+ y: Y offset
40
+ original_width: Original video width
41
+ original_height: Original video height
42
+
43
+ Raises:
44
+ typer.BadParameter: If parameters are invalid
45
+ """
46
+ if width <= 0 or height <= 0:
47
+ raise typer.BadParameter("Width and height must be positive")
48
+
49
+ if x < 0 or y < 0:
50
+ raise typer.BadParameter("X and Y offsets must be non-negative")
51
+
52
+ if x + width > original_width:
53
+ raise typer.BadParameter(
54
+ f"Crop region exceeds video width: {x} + {width} > {original_width}"
55
+ )
56
+
57
+ if y + height > original_height:
58
+ raise typer.BadParameter(
59
+ f"Crop region exceeds video height: {y} + {height} > {original_height}"
60
+ )
61
+
62
+
63
+ def parse_preset(
64
+ preset: str,
65
+ original_width: int,
66
+ original_height: int,
67
+ ) -> tuple[int, int, int, int]:
68
+ """
69
+ Parse preset crop values.
70
+
71
+ Args:
72
+ preset: Preset name (center-square, 16:9, 9:16, 4:3, 1:1)
73
+ original_width: Original video width
74
+ original_height: Original video height
75
+
76
+ Returns:
77
+ tuple: (width, height, x, y)
78
+
79
+ Raises:
80
+ typer.BadParameter: If preset is invalid or video dimensions are invalid
81
+ """
82
+ if original_width <= 0 or original_height <= 0:
83
+ raise typer.BadParameter(
84
+ f"Invalid video dimensions: {original_width}x{original_height}"
85
+ )
86
+
87
+ preset = preset.lower()
88
+
89
+ if preset == "center-square" or preset == "1:1":
90
+ # Crop to largest centered square
91
+ size = min(original_width, original_height)
92
+ x = (original_width - size) // 2
93
+ y = (original_height - size) // 2
94
+ return size, size, x, y
95
+
96
+ elif preset == "16:9":
97
+ # Crop to 16:9 aspect ratio
98
+ target_ratio = 16 / 9
99
+ current_ratio = original_width / original_height
100
+
101
+ if current_ratio > target_ratio:
102
+ # Too wide, crop width
103
+ height = original_height
104
+ width = int(height * target_ratio)
105
+ x = (original_width - width) // 2
106
+ y = 0
107
+ else:
108
+ # Too tall, crop height
109
+ width = original_width
110
+ height = int(width / target_ratio)
111
+ x = 0
112
+ y = (original_height - height) // 2
113
+
114
+ return width, height, x, y
115
+
116
+ elif preset == "9:16":
117
+ # Crop to 9:16 aspect ratio (vertical/portrait)
118
+ target_ratio = 9 / 16
119
+ current_ratio = original_width / original_height
120
+
121
+ if current_ratio > target_ratio:
122
+ # Too wide, crop width
123
+ height = original_height
124
+ width = int(height * target_ratio)
125
+ x = (original_width - width) // 2
126
+ y = 0
127
+ else:
128
+ # Too tall, crop height
129
+ width = original_width
130
+ height = int(width / target_ratio)
131
+ x = 0
132
+ y = (original_height - height) // 2
133
+
134
+ return width, height, x, y
135
+
136
+ elif preset == "4:3":
137
+ # Crop to 4:3 aspect ratio
138
+ target_ratio = 4 / 3
139
+ current_ratio = original_width / original_height
140
+
141
+ if current_ratio > target_ratio:
142
+ # Too wide, crop width
143
+ height = original_height
144
+ width = int(height * target_ratio)
145
+ x = (original_width - width) // 2
146
+ y = 0
147
+ else:
148
+ # Too tall, crop height
149
+ width = original_width
150
+ height = int(width / target_ratio)
151
+ x = 0
152
+ y = (original_height - height) // 2
153
+
154
+ return width, height, x, y
155
+
156
+ else:
157
+ raise typer.BadParameter(
158
+ f"Unknown preset: {preset}. Valid presets: center-square, 16:9, 9:16, 4:3, 1:1"
159
+ )
160
+
161
+
162
+ def crop(
163
+ ctx: typer.Context,
164
+ input_file: Path = typer.Argument(
165
+ ...,
166
+ help="Input video file to crop",
167
+ exists=True,
168
+ dir_okay=False,
169
+ resolve_path=True,
170
+ ),
171
+ output_file: Path = typer.Argument(
172
+ ...,
173
+ help="Output video file",
174
+ dir_okay=False,
175
+ resolve_path=True,
176
+ ),
177
+ width: Optional[int] = typer.Option(
178
+ None,
179
+ "--width",
180
+ "-w",
181
+ help="Width of the cropped region in pixels",
182
+ min=1,
183
+ ),
184
+ height: Optional[int] = typer.Option(
185
+ None,
186
+ "--height",
187
+ "-h",
188
+ help="Height of the cropped region in pixels",
189
+ min=1,
190
+ ),
191
+ x: Optional[int] = typer.Option(
192
+ None,
193
+ "--x",
194
+ help="X offset (left edge) of the crop region in pixels",
195
+ min=0,
196
+ ),
197
+ y: Optional[int] = typer.Option(
198
+ None,
199
+ "--y",
200
+ help="Y offset (top edge) of the crop region in pixels",
201
+ min=0,
202
+ ),
203
+ preset: Optional[str] = typer.Option(
204
+ None,
205
+ "--preset",
206
+ "-p",
207
+ help="Use a preset crop (center-square, 16:9, 9:16, 4:3, 1:1)",
208
+ ),
209
+ keep_aspect: bool = typer.Option(
210
+ True,
211
+ "--keep-aspect/--no-keep-aspect",
212
+ help="Keep aspect ratio when using width/height only",
213
+ ),
214
+ overwrite: bool = typer.Option(
215
+ False,
216
+ "--overwrite",
217
+ help="Overwrite output file if it exists",
218
+ ),
219
+ ) -> None:
220
+ """
221
+ Crop a video to a specific region.
222
+
223
+ You can specify the crop region manually with --width, --height, --x, --y,
224
+ or use a preset for common aspect ratios.
225
+
226
+ Examples:
227
+ - Crop to center square: vidio crop input.mp4 output.mp4 --preset center-square
228
+ - Crop to 16:9: vidio crop input.mp4 output.mp4 --preset 16:9
229
+ - Custom crop: vidio crop input.mp4 output.mp4 -w 1280 -h 720 --x 100 --y 50
230
+ - Crop from top-left: vidio crop input.mp4 output.mp4 -w 1920 -h 1080
231
+ """
232
+ # Get verbose flag from global context
233
+ verbose = ctx.obj.get("VERBOSE", False) if ctx.obj else False
234
+
235
+ # Check if output file exists and if we should overwrite it
236
+ if not check_output_file(output_file, overwrite):
237
+ console.print("[yellow]Aborted.[/yellow]")
238
+ raise typer.Exit(0)
239
+
240
+ # Get original video dimensions
241
+ try:
242
+ video_info = get_video_info(input_file, verbose)
243
+ video_streams = [
244
+ s for s in video_info.get("streams", []) if s.get("codec_type") == "video"
245
+ ]
246
+
247
+ if not video_streams:
248
+ console.print("[red]Error: No video stream found in input file[/red]")
249
+ console.print(
250
+ "[dim]The file may be corrupted or not a valid video file.[/dim]"
251
+ )
252
+ raise typer.Exit(1)
253
+
254
+ original_width = video_streams[0].get("width", 0)
255
+ original_height = video_streams[0].get("height", 0)
256
+
257
+ if not original_width or not original_height:
258
+ console.print(
259
+ "[red]Error: Could not determine original video dimensions[/red]"
260
+ )
261
+ console.print(
262
+ "[dim]The video file may be corrupted or in an unsupported format.[/dim]"
263
+ )
264
+ raise typer.Exit(1)
265
+
266
+ # Validate dimensions are reasonable
267
+ if original_width > 16384 or original_height > 16384:
268
+ console.print(
269
+ f"[yellow]Warning: Very large video dimensions ({original_width}x{original_height}). "
270
+ "Processing may be slow.[/yellow]"
271
+ )
272
+
273
+ if original_width < 2 or original_height < 2:
274
+ console.print(
275
+ f"[red]Error: Video dimensions too small ({original_width}x{original_height}). "
276
+ "Cannot crop.[/red]"
277
+ )
278
+ raise typer.Exit(1)
279
+
280
+ console.print(
281
+ f"[dim]Original video dimensions: {original_width}x{original_height}[/dim]"
282
+ )
283
+
284
+ except typer.Exit:
285
+ raise
286
+ except Exception as e:
287
+ console.print(f"[red]Error reading video info: {e}[/red]")
288
+ console.print("[dim]Make sure the input file is a valid video file.[/dim]")
289
+ raise typer.Exit(1)
290
+
291
+ # Determine crop parameters
292
+ if preset:
293
+ if any([width, height, x, y]):
294
+ console.print(
295
+ "[yellow]Warning: Preset specified, ignoring manual crop parameters[/yellow]"
296
+ )
297
+
298
+ try:
299
+ crop_width, crop_height, crop_x, crop_y = parse_preset(
300
+ preset, original_width, original_height
301
+ )
302
+ console.print(f"[blue]Using preset: {preset}[/blue]")
303
+ except typer.BadParameter as e:
304
+ console.print(f"[red]Error: {e}[/red]")
305
+ raise typer.Exit(1)
306
+
307
+ else:
308
+ # Manual crop parameters
309
+ if width is None or height is None:
310
+ console.print(
311
+ "[red]Error: Must specify --width and --height, or use --preset[/red]"
312
+ )
313
+ raise typer.Exit(1)
314
+
315
+ # Validate manual dimensions don't exceed video
316
+ if width > original_width:
317
+ console.print(
318
+ f"[red]Error: Crop width ({width}) exceeds video width ({original_width})[/red]"
319
+ )
320
+ raise typer.Exit(1)
321
+
322
+ if height > original_height:
323
+ console.print(
324
+ f"[red]Error: Crop height ({height}) exceeds video height ({original_height})[/red]"
325
+ )
326
+ raise typer.Exit(1)
327
+
328
+ crop_width = width
329
+ crop_height = height
330
+ crop_x = x if x is not None else 0
331
+ crop_y = y if y is not None else 0
332
+
333
+ # Default to center if no offsets specified and keep_aspect is True
334
+ if x is None and y is None and keep_aspect:
335
+ crop_x = (original_width - crop_width) // 2
336
+ crop_y = (original_height - crop_height) // 2
337
+ console.print("[dim]Centering crop region (no offsets specified)[/dim]")
338
+
339
+ # Ensure even dimensions for better codec compatibility
340
+ if crop_width % 2 != 0:
341
+ crop_width -= 1
342
+ console.print(
343
+ f"[dim]Adjusted width to {crop_width} (must be even for codec compatibility)[/dim]"
344
+ )
345
+
346
+ if crop_height % 2 != 0:
347
+ crop_height -= 1
348
+ console.print(
349
+ f"[dim]Adjusted height to {crop_height} (must be even for codec compatibility)[/dim]"
350
+ )
351
+
352
+ # Warn if crop dimensions are very small
353
+ if crop_width < 64 or crop_height < 64:
354
+ console.print(
355
+ f"[yellow]Warning: Very small crop dimensions ({crop_width}x{crop_height}). "
356
+ "Output quality may be poor.[/yellow]"
357
+ )
358
+
359
+ # Validate crop parameters
360
+ try:
361
+ validate_crop_params(
362
+ crop_width, crop_height, crop_x, crop_y, original_width, original_height
363
+ )
364
+ except typer.BadParameter as e:
365
+ console.print(f"[red]Error: {e}[/red]")
366
+ raise typer.Exit(1)
367
+
368
+ # Show what we're doing
369
+ console.print(
370
+ f"[blue]Cropping to {crop_width}x{crop_height} at position ({crop_x}, {crop_y})[/blue]"
371
+ )
372
+
373
+ # Build the ffmpeg command with crop filter
374
+ crop_filter = f"crop={crop_width}:{crop_height}:{crop_x}:{crop_y}"
375
+
376
+ command = [
377
+ "ffmpeg",
378
+ "-i",
379
+ str(input_file),
380
+ "-vf",
381
+ crop_filter,
382
+ "-c:a",
383
+ "copy", # Copy audio without re-encoding
384
+ "-y" if overwrite else "-n", # Overwrite if specified
385
+ str(output_file),
386
+ ]
387
+
388
+ # Run the command
389
+ run_ffmpeg(command, verbose=verbose)
390
+
391
+ console.print(f"[green]✓[/green] Cropped video saved to {output_file}")