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 +3 -0
- vidio_cli/cli.py +62 -0
- vidio_cli/commands/__init__.py +36 -0
- vidio_cli/commands/concat.py +103 -0
- vidio_cli/commands/crop.py +391 -0
- vidio_cli/commands/grid.py +258 -0
- vidio_cli/commands/info.py +244 -0
- vidio_cli/commands/list.py +316 -0
- vidio_cli/commands/resize.py +220 -0
- vidio_cli/commands/to_gif.py +351 -0
- vidio_cli/commands/trim.py +133 -0
- vidio_cli/config.py +25 -0
- vidio_cli/ffmpeg_utils.py +197 -0
- vidio_cli-0.1.0.dist-info/METADATA +238 -0
- vidio_cli-0.1.0.dist-info/RECORD +18 -0
- vidio_cli-0.1.0.dist-info/WHEEL +4 -0
- vidio_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vidio_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
vidio_cli/__init__.py
ADDED
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}")
|