peg-this 3.0.2__py3-none-any.whl → 4.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.
- peg_this/features/__init__.py +0 -0
- peg_this/features/audio.py +85 -0
- peg_this/features/batch.py +151 -0
- peg_this/features/convert.py +309 -0
- peg_this/features/crop.py +207 -0
- peg_this/features/inspect.py +110 -0
- peg_this/features/join.py +137 -0
- peg_this/features/subtitle.py +397 -0
- peg_this/features/trim.py +56 -0
- peg_this/peg_this.py +53 -622
- peg_this/utils/__init__.py +0 -0
- peg_this/utils/ffmpeg_utils.py +129 -0
- peg_this/utils/ui_utils.py +52 -0
- peg_this/utils/validation.py +228 -0
- peg_this-4.1.0.dist-info/METADATA +283 -0
- peg_this-4.1.0.dist-info/RECORD +21 -0
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/WHEEL +1 -1
- peg_this-3.0.2.dist-info/METADATA +0 -87
- peg_this-3.0.2.dist-info/RECORD +0 -8
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/entry_points.txt +0 -0
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/licenses/LICENSE +0 -0
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/top_level.txt +0 -0
peg_this/peg_this.py
CHANGED
|
@@ -1,37 +1,22 @@
|
|
|
1
|
-
|
|
2
1
|
import os
|
|
3
|
-
import subprocess
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
2
|
import sys
|
|
7
3
|
import logging
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
print("Please install it by running: pip install ffmpeg-python")
|
|
25
|
-
sys.exit(1)
|
|
26
|
-
|
|
27
|
-
try:
|
|
28
|
-
import tkinter as tk
|
|
29
|
-
from tkinter import filedialog, messagebox
|
|
30
|
-
from PIL import Image, ImageTk
|
|
31
|
-
except ImportError:
|
|
32
|
-
tk = None
|
|
33
|
-
# --- End Dependency Check ---
|
|
34
|
-
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from peg_this.features.audio import extract_audio, remove_audio
|
|
11
|
+
from peg_this.features.batch import batch_convert
|
|
12
|
+
from peg_this.features.convert import convert_file, convert_image, resize_image, rotate_image, flip_image
|
|
13
|
+
from peg_this.features.crop import crop_video, crop_image
|
|
14
|
+
from peg_this.features.inspect import inspect_file
|
|
15
|
+
from peg_this.features.join import join_videos
|
|
16
|
+
from peg_this.features.subtitle import generate_subtitles
|
|
17
|
+
from peg_this.features.trim import trim_video
|
|
18
|
+
from peg_this.utils.ffmpeg_utils import check_ffmpeg_ffprobe
|
|
19
|
+
from peg_this.utils.ui_utils import select_media_file
|
|
35
20
|
|
|
36
21
|
# --- Global Configuration ---
|
|
37
22
|
# Configure logging
|
|
@@ -46,531 +31,42 @@ logging.basicConfig(
|
|
|
46
31
|
|
|
47
32
|
# Initialize Rich Console
|
|
48
33
|
console = Console()
|
|
34
|
+
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"]
|
|
49
35
|
# --- End Global Configuration ---
|
|
50
36
|
|
|
51
37
|
|
|
52
|
-
def
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"""
|
|
69
|
-
Runs an ffmpeg command using ffmpeg-python.
|
|
70
|
-
- For simple commands, it runs directly.
|
|
71
|
-
- For commands with a progress bar, it generates the ffmpeg arguments,
|
|
72
|
-
runs them as a subprocess, and parses stderr to show progress,
|
|
73
|
-
mimicking the logic from the original script for accuracy.
|
|
74
|
-
"""
|
|
75
|
-
console.print(f"[bold cyan]{description}[/bold cyan]")
|
|
76
|
-
|
|
77
|
-
# Get the full command arguments from the ffmpeg-python stream object
|
|
78
|
-
args = stream_spec.get_args()
|
|
79
|
-
full_command = ['ffmpeg'] + args
|
|
80
|
-
logging.info(f"Executing command: {' '.join(full_command)}")
|
|
81
|
-
|
|
82
|
-
if not show_progress:
|
|
83
|
-
try:
|
|
84
|
-
# Use ffmpeg.run() for simple, non-progress tasks. It's cleaner.
|
|
85
|
-
out, err = ffmpeg.run(stream_spec, capture_stdout=True, capture_stderr=True, quiet=True)
|
|
86
|
-
logging.info("Command successful (no progress bar).")
|
|
87
|
-
return out.decode('utf-8')
|
|
88
|
-
except ffmpeg.Error as e:
|
|
89
|
-
error_message = e.stderr.decode('utf-8')
|
|
90
|
-
console.print("[bold red]An error occurred:[/bold red]")
|
|
91
|
-
console.print(error_message)
|
|
92
|
-
logging.error(f"ffmpeg error:{error_message}")
|
|
93
|
-
return None
|
|
94
|
-
else:
|
|
95
|
-
# For the progress bar, we must run ffmpeg as a subprocess and parse stderr.
|
|
96
|
-
duration = 0
|
|
97
|
-
try:
|
|
98
|
-
# Find the primary input file from the command arguments to probe it.
|
|
99
|
-
input_file_path = None
|
|
100
|
-
for i, arg in enumerate(full_command):
|
|
101
|
-
if arg == '-i' and i + 1 < len(full_command):
|
|
102
|
-
# This is a robust way to find the first input file.
|
|
103
|
-
input_file_path = full_command[i+1]
|
|
104
|
-
break
|
|
105
|
-
|
|
106
|
-
if input_file_path:
|
|
107
|
-
probe_info = ffmpeg.probe(input_file_path)
|
|
108
|
-
duration = float(probe_info['format']['duration'])
|
|
109
|
-
else:
|
|
110
|
-
logging.warning("Could not find input file in command to determine duration for progress bar.")
|
|
111
|
-
|
|
112
|
-
except (ffmpeg.Error, KeyError) as e:
|
|
113
|
-
console.print(f"[bold yellow]Warning: Could not determine video duration for progress bar.[/bold yellow]")
|
|
114
|
-
logging.warning(f"Could not probe for duration: {e}")
|
|
115
|
-
|
|
116
|
-
with Progress(
|
|
117
|
-
SpinnerColumn(),
|
|
118
|
-
TextColumn("[progress.description]{task.description}"),
|
|
119
|
-
BarColumn(),
|
|
120
|
-
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
121
|
-
console=console,
|
|
122
|
-
) as progress:
|
|
123
|
-
task = progress.add_task(description, total=100)
|
|
124
|
-
|
|
125
|
-
# Run the command as a subprocess to capture stderr in real-time
|
|
126
|
-
process = subprocess.Popen(
|
|
127
|
-
full_command,
|
|
128
|
-
stdout=subprocess.PIPE,
|
|
129
|
-
stderr=subprocess.PIPE,
|
|
130
|
-
universal_newlines=True,
|
|
131
|
-
encoding='utf-8'
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
for line in process.stderr:
|
|
135
|
-
logging.debug(f"ffmpeg stderr: {line.strip()}")
|
|
136
|
-
if "time=" in line and duration > 0:
|
|
137
|
-
try:
|
|
138
|
-
time_str = line.split("time=")[1].split(" ")[0].strip()
|
|
139
|
-
h, m, s_parts = time_str.split(':')
|
|
140
|
-
s = float(s_parts)
|
|
141
|
-
elapsed_time = int(h) * 3600 + int(m) * 60 + s
|
|
142
|
-
percent_complete = (elapsed_time / duration) * 100
|
|
143
|
-
progress.update(task, completed=min(percent_complete, 100))
|
|
144
|
-
except Exception:
|
|
145
|
-
pass # Ignore any parsing errors
|
|
146
|
-
|
|
147
|
-
process.wait()
|
|
148
|
-
progress.update(task, completed=100)
|
|
149
|
-
|
|
150
|
-
if process.returncode != 0:
|
|
151
|
-
# The error was already logged line-by-line, but we can add a final message.
|
|
152
|
-
console.print(f"[bold red]An error occurred during processing. Check {log_file} for details.[/bold red]")
|
|
153
|
-
return None
|
|
154
|
-
|
|
155
|
-
logging.info("Command successful (with progress bar).")
|
|
156
|
-
return "Success"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def get_media_files():
|
|
160
|
-
"""Scan the current directory for media files."""
|
|
161
|
-
media_extensions = [".mkv", ".mp4", ".avi", ".mov", ".webm", ".flv", ".wmv", ".mp3", ".flac", ".wav", ".ogg", ".gif"]
|
|
162
|
-
files = [f for f in os.listdir('.') if os.path.isfile(f) and Path(f).suffix.lower() in media_extensions]
|
|
163
|
-
return files
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def select_media_file():
|
|
167
|
-
"""Display a menu to select a media file, or open a file picker."""
|
|
168
|
-
media_files = get_media_files()
|
|
169
|
-
if not media_files:
|
|
170
|
-
console.print("[bold yellow]No media files found in this directory.[/bold yellow]")
|
|
171
|
-
if tk and questionary.confirm("Would you like to select a file from another location?").ask():
|
|
172
|
-
root = tk.Tk()
|
|
173
|
-
root.withdraw()
|
|
174
|
-
file_path = filedialog.askopenfilename(
|
|
175
|
-
title="Select a media file",
|
|
176
|
-
filetypes=[("Media Files", "*.mkv *.mp4 *.avi *.mov *.webm *.flv *.wmv *.mp3 *.flac *.wav *.ogg *.gif"), ("All Files", "*.*")]
|
|
177
|
-
)
|
|
178
|
-
return file_path if file_path else None
|
|
179
|
-
return None
|
|
180
|
-
|
|
181
|
-
choices = media_files + [questionary.Separator(), "Back"]
|
|
182
|
-
file = questionary.select("Select a media file to process:", choices=choices, use_indicator=True).ask()
|
|
183
|
-
|
|
184
|
-
# Return the absolute path to prevent "file not found" errors
|
|
185
|
-
return os.path.abspath(file) if file and file != "Back" else None
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def inspect_file(file_path):
|
|
189
|
-
"""Show detailed information about the selected media file using ffprobe."""
|
|
190
|
-
console.print(f"Inspecting {os.path.basename(file_path)}...")
|
|
191
|
-
try:
|
|
192
|
-
info = ffmpeg.probe(file_path)
|
|
193
|
-
except ffmpeg.Error as e:
|
|
194
|
-
console.print("[bold red]An error occurred while inspecting the file:[/bold red]")
|
|
195
|
-
console.print(e.stderr.decode('utf-8'))
|
|
196
|
-
logging.error(f"ffprobe error:{e.stderr.decode('utf-8')}")
|
|
197
|
-
questionary.press_any_key_to_continue().ask()
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
format_info = info.get('format', {})
|
|
201
|
-
table = Table(title=f"File Information: {os.path.basename(file_path)}", show_header=True, header_style="bold magenta")
|
|
202
|
-
table.add_column("Property", style="dim")
|
|
203
|
-
table.add_column("Value")
|
|
204
|
-
|
|
205
|
-
size_mb = float(format_info.get('size', 0)) / (1024 * 1024)
|
|
206
|
-
duration_sec = float(format_info.get('duration', 0))
|
|
207
|
-
bit_rate_kbps = float(format_info.get('bit_rate', 0)) / 1000
|
|
208
|
-
|
|
209
|
-
table.add_row("Size", f"{size_mb:.2f} MB")
|
|
210
|
-
table.add_row("Duration", f"{duration_sec:.2f} seconds")
|
|
211
|
-
table.add_row("Format", format_info.get('format_long_name', 'N/A'))
|
|
212
|
-
table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s")
|
|
213
|
-
console.print(table)
|
|
214
|
-
|
|
215
|
-
for stream_type in ['video', 'audio']:
|
|
216
|
-
streams = [s for s in info.get('streams', []) if s.get('codec_type') == stream_type]
|
|
217
|
-
if streams:
|
|
218
|
-
stream_table = Table(title=f"{stream_type.capitalize()} Streams", show_header=True, header_style=f"bold {'cyan' if stream_type == 'video' else 'green'}")
|
|
219
|
-
stream_table.add_column("Stream")
|
|
220
|
-
stream_table.add_column("Codec")
|
|
221
|
-
if stream_type == 'video':
|
|
222
|
-
stream_table.add_column("Resolution")
|
|
223
|
-
stream_table.add_column("Frame Rate")
|
|
224
|
-
else:
|
|
225
|
-
stream_table.add_column("Sample Rate")
|
|
226
|
-
stream_table.add_column("Channels")
|
|
227
|
-
|
|
228
|
-
for s in streams:
|
|
229
|
-
if stream_type == 'video':
|
|
230
|
-
stream_table.add_row(f"#{s.get('index')}", s.get('codec_name'), f"{s.get('width')}x{s.get('height')}", s.get('r_frame_rate'))
|
|
231
|
-
else:
|
|
232
|
-
stream_table.add_row(f"#{s.get('index')}", s.get('codec_name'), f"{s.get('sample_rate')} Hz", str(s.get('channels')))
|
|
233
|
-
console.print(stream_table)
|
|
234
|
-
|
|
235
|
-
questionary.press_any_key_to_continue().ask()
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def has_audio_stream(file_path):
|
|
239
|
-
"""Check if the media file has an audio stream."""
|
|
240
|
-
try:
|
|
241
|
-
probe = ffmpeg.probe(file_path, select_streams='a')
|
|
242
|
-
return 'streams' in probe and len(probe['streams']) > 0
|
|
243
|
-
except ffmpeg.Error:
|
|
244
|
-
return False
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def convert_file(file_path):
|
|
248
|
-
"""Convert the file to a different format."""
|
|
249
|
-
is_gif = Path(file_path).suffix.lower() == '.gif'
|
|
250
|
-
has_audio = has_audio_stream(file_path)
|
|
251
|
-
|
|
252
|
-
output_format = questionary.select("Select the output format:", choices=["mp4", "mkv", "mov", "avi", "webm", "mp3", "flac", "wav", "gif"], use_indicator=True).ask()
|
|
253
|
-
if not output_format: return
|
|
254
|
-
|
|
255
|
-
if (is_gif or not has_audio) and output_format in ["mp3", "flac", "wav"]:
|
|
256
|
-
console.print("[bold red]Error: Source has no audio to convert.[/bold red]")
|
|
257
|
-
questionary.press_any_key_to_continue().ask()
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
output_file = f"{Path(file_path).stem}_converted.{output_format}"
|
|
261
|
-
|
|
262
|
-
input_stream = ffmpeg.input(file_path)
|
|
263
|
-
output_stream = None
|
|
264
|
-
kwargs = {'y': None}
|
|
265
|
-
|
|
266
|
-
if output_format in ["mp4", "mkv", "mov", "avi", "webm"]:
|
|
267
|
-
quality = questionary.select("Select quality preset:", choices=["Same as source", "High (CRF 18)", "Medium (CRF 23)", "Low (CRF 28)"], use_indicator=True).ask()
|
|
268
|
-
if not quality: return
|
|
269
|
-
|
|
270
|
-
if quality == "Same as source":
|
|
271
|
-
kwargs['c'] = 'copy'
|
|
272
|
-
else:
|
|
273
|
-
crf = quality.split(" ")[-1][1:-1]
|
|
274
|
-
kwargs['c:v'] = 'libx264'
|
|
275
|
-
kwargs['crf'] = crf
|
|
276
|
-
kwargs['pix_fmt'] = 'yuv420p'
|
|
277
|
-
if has_audio:
|
|
278
|
-
kwargs['c:a'] = 'aac'
|
|
279
|
-
kwargs['b:a'] = '192k'
|
|
280
|
-
else:
|
|
281
|
-
kwargs['an'] = None
|
|
282
|
-
output_stream = input_stream.output(output_file, **kwargs)
|
|
283
|
-
|
|
284
|
-
elif output_format in ["mp3", "flac", "wav"]:
|
|
285
|
-
kwargs['vn'] = None
|
|
286
|
-
if output_format == 'mp3':
|
|
287
|
-
bitrate = questionary.select("Select audio bitrate:", choices=["128k", "192k", "256k", "320k"]).ask()
|
|
288
|
-
if not bitrate: return
|
|
289
|
-
kwargs['c:a'] = 'libmp3lame'
|
|
290
|
-
kwargs['b:a'] = bitrate
|
|
291
|
-
else:
|
|
292
|
-
kwargs['c:a'] = output_format
|
|
293
|
-
output_stream = input_stream.output(output_file, **kwargs)
|
|
294
|
-
|
|
295
|
-
elif output_format == "gif":
|
|
296
|
-
fps = questionary.text("Enter frame rate (e.g., 15):", default="15").ask()
|
|
297
|
-
if not fps: return
|
|
298
|
-
scale = questionary.text("Enter width in pixels (e.g., 480):", default="480").ask()
|
|
299
|
-
if not scale: return
|
|
300
|
-
|
|
301
|
-
palette_file = f"palette_{Path(file_path).stem}.png"
|
|
302
|
-
|
|
303
|
-
# Correctly chain filters for palette generation using explicit w/h arguments
|
|
304
|
-
palette_gen_stream = input_stream.video.filter('fps', fps=fps).filter('scale', w=scale, h=-1, flags='lanczos').filter('palettegen')
|
|
305
|
-
run_command(palette_gen_stream.output(palette_file, y=None), "Generating color palette...")
|
|
306
|
-
|
|
307
|
-
if not os.path.exists(palette_file):
|
|
308
|
-
console.print("[bold red]Failed to generate color palette for GIF.[/bold red]")
|
|
309
|
-
questionary.press_any_key_to_continue().ask()
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
palette_input = ffmpeg.input(palette_file)
|
|
313
|
-
video_stream = input_stream.video.filter('fps', fps=fps).filter('scale', w=scale, h=-1, flags='lanczos')
|
|
314
|
-
|
|
315
|
-
final_stream = ffmpeg.filter([video_stream, palette_input], 'paletteuse')
|
|
316
|
-
output_stream = final_stream.output(output_file, y=None)
|
|
317
|
-
|
|
318
|
-
if output_stream and run_command(output_stream, f"Converting to {output_format}...", show_progress=True):
|
|
319
|
-
console.print(f"[bold green]Successfully converted to {output_file}[/bold green]")
|
|
320
|
-
else:
|
|
321
|
-
console.print("[bold red]Conversion failed.[/bold red]")
|
|
322
|
-
|
|
323
|
-
if output_format == "gif" and os.path.exists(f"palette_{Path(file_path).stem}.png"):
|
|
324
|
-
os.remove(f"palette_{Path(file_path).stem}.png")
|
|
325
|
-
|
|
326
|
-
questionary.press_any_key_to_continue().ask()
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def trim_video(file_path):
|
|
330
|
-
"""Cut a video by specifying start and end times."""
|
|
331
|
-
start_time = questionary.text("Enter start time (HH:MM:SS or seconds):").ask()
|
|
332
|
-
if not start_time: return
|
|
333
|
-
end_time = questionary.text("Enter end time (HH:MM:SS or seconds):").ask()
|
|
334
|
-
if not end_time: return
|
|
335
|
-
|
|
336
|
-
output_file = f"{Path(file_path).stem}_trimmed{Path(file_path).suffix}"
|
|
337
|
-
|
|
338
|
-
stream = ffmpeg.input(file_path, ss=start_time, to=end_time).output(output_file, c='copy', y=None)
|
|
339
|
-
|
|
340
|
-
run_command(stream, "Trimming video...", show_progress=True)
|
|
341
|
-
console.print(f"[bold green]Successfully trimmed to {output_file}[/bold green]")
|
|
342
|
-
questionary.press_any_key_to_continue().ask()
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def extract_audio(file_path):
|
|
346
|
-
"""Extract the audio track from a video file."""
|
|
347
|
-
if not has_audio_stream(file_path):
|
|
348
|
-
console.print("[bold red]Error: No audio stream found in the file.[/bold red]")
|
|
349
|
-
questionary.press_any_key_to_continue().ask()
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
audio_format = questionary.select("Select audio format:", choices=["mp3", "flac", "wav"], use_indicator=True).ask()
|
|
353
|
-
if not audio_format: return
|
|
354
|
-
|
|
355
|
-
output_file = f"{Path(file_path).stem}_audio.{audio_format}"
|
|
356
|
-
stream = ffmpeg.input(file_path).output(output_file, vn=None, acodec='libmp3lame' if audio_format == 'mp3' else audio_format, y=None)
|
|
357
|
-
|
|
358
|
-
run_command(stream, f"Extracting audio to {audio_format.upper()}...", show_progress=True)
|
|
359
|
-
console.print(f"[bold green]Successfully extracted audio to {output_file}[/bold green]")
|
|
360
|
-
questionary.press_any_key_to_continue().ask()
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
def remove_audio(file_path):
|
|
364
|
-
"""Create a silent version of a video."""
|
|
365
|
-
output_file = f"{Path(file_path).stem}_no_audio{Path(file_path).suffix}"
|
|
366
|
-
stream = ffmpeg.input(file_path).output(output_file, vcodec='copy', an=None, y=None)
|
|
367
|
-
|
|
368
|
-
run_command(stream, "Removing audio track...", show_progress=True)
|
|
369
|
-
console.print(f"[bold green]Successfully removed audio, saved to {output_file}[/bold green]")
|
|
370
|
-
questionary.press_any_key_to_continue().ask()
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def crop_video(file_path):
|
|
374
|
-
"""Visually crop a video by selecting an area."""
|
|
375
|
-
if not tk:
|
|
376
|
-
console.print("[bold red]Cannot perform visual cropping: tkinter & Pillow are not installed.[/bold red]")
|
|
377
|
-
return
|
|
378
|
-
|
|
379
|
-
preview_frame = f"preview_{Path(file_path).stem}.jpg"
|
|
380
|
-
try:
|
|
381
|
-
# Extract a frame from the middle of the video for preview
|
|
382
|
-
probe = ffmpeg.probe(file_path)
|
|
383
|
-
duration = float(probe['format']['duration'])
|
|
384
|
-
mid_point = duration / 2
|
|
385
|
-
|
|
386
|
-
# Corrected frame extraction command with `-q:v`
|
|
387
|
-
run_command(
|
|
388
|
-
ffmpeg.input(file_path, ss=mid_point).output(preview_frame, vframes=1, **{'q:v': 2}, y=None),
|
|
389
|
-
"Extracting a frame for preview..."
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
if not os.path.exists(preview_frame):
|
|
393
|
-
console.print("[bold red]Could not extract a frame from the video.[/bold red]")
|
|
394
|
-
return
|
|
395
|
-
|
|
396
|
-
# --- Tkinter GUI for Cropping ---
|
|
397
|
-
root = tk.Tk()
|
|
398
|
-
root.title("Crop Video - Drag to select area, close window to confirm")
|
|
399
|
-
root.attributes("-topmost", True)
|
|
400
|
-
|
|
401
|
-
img = Image.open(preview_frame)
|
|
402
|
-
img_tk = ImageTk.PhotoImage(img)
|
|
403
|
-
|
|
404
|
-
canvas = tk.Canvas(root, width=img.width, height=img.height, cursor="cross")
|
|
405
|
-
canvas.pack()
|
|
406
|
-
canvas.create_image(0, 0, anchor=tk.NW, image=img_tk)
|
|
407
|
-
|
|
408
|
-
rect_coords = {"x1": 0, "y1": 0, "x2": 0, "y2": 0}
|
|
409
|
-
rect_id = None
|
|
410
|
-
|
|
411
|
-
def on_press(event):
|
|
412
|
-
nonlocal rect_id
|
|
413
|
-
rect_coords['x1'], rect_coords['y1'] = event.x, event.y
|
|
414
|
-
rect_id = canvas.create_rectangle(0, 0, 1, 1, outline='red', width=2)
|
|
415
|
-
|
|
416
|
-
def on_drag(event):
|
|
417
|
-
rect_coords['x2'], rect_coords['y2'] = event.x, event.y
|
|
418
|
-
canvas.coords(rect_id, rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2'])
|
|
419
|
-
|
|
420
|
-
canvas.bind("<ButtonPress-1>", on_press)
|
|
421
|
-
canvas.bind("<B1-Motion>", on_drag)
|
|
422
|
-
|
|
423
|
-
messagebox.showinfo("Instructions", "Click and drag to draw a cropping rectangle.\nClose this window when you are done.", parent=root)
|
|
424
|
-
root.mainloop()
|
|
425
|
-
|
|
426
|
-
# --- Cropping Logic ---
|
|
427
|
-
crop_w = abs(rect_coords['x2'] - rect_coords['x1'])
|
|
428
|
-
crop_h = abs(rect_coords['y2'] - rect_coords['y1'])
|
|
429
|
-
crop_x = min(rect_coords['x1'], rect_coords['x2'])
|
|
430
|
-
crop_y = min(rect_coords['y1'], rect_coords['y2'])
|
|
431
|
-
|
|
432
|
-
if crop_w < 2 or crop_h < 2: # Avoid tiny, invalid crops
|
|
433
|
-
console.print("[bold yellow]Cropping cancelled as no valid area was selected.[/bold yellow]")
|
|
434
|
-
return
|
|
435
|
-
|
|
436
|
-
console.print(f"Selected crop area: [bold]width={crop_w} height={crop_h} at (x={crop_x}, y={crop_y})[/bold]")
|
|
437
|
-
|
|
438
|
-
output_file = f"{Path(file_path).stem}_cropped{Path(file_path).suffix}"
|
|
439
|
-
|
|
440
|
-
input_stream = ffmpeg.input(file_path)
|
|
441
|
-
video_stream = input_stream.video.filter('crop', w=crop_w, h=crop_h, x=crop_x, y=crop_y)
|
|
442
|
-
|
|
443
|
-
kwargs = {'y': None} # Overwrite output
|
|
444
|
-
# Check for audio and copy it if it exists
|
|
445
|
-
if has_audio_stream(file_path):
|
|
446
|
-
audio_stream = input_stream.audio
|
|
447
|
-
kwargs['c:a'] = 'copy'
|
|
448
|
-
stream = ffmpeg.output(video_stream, audio_stream, output_file, **kwargs)
|
|
449
|
-
else:
|
|
450
|
-
stream = ffmpeg.output(video_stream, output_file, **kwargs)
|
|
451
|
-
|
|
452
|
-
run_command(stream, "Applying crop to video...", show_progress=True)
|
|
453
|
-
console.print(f"[bold green]Successfully cropped video and saved to {output_file}[/bold green]")
|
|
454
|
-
|
|
455
|
-
finally:
|
|
456
|
-
if os.path.exists(preview_frame):
|
|
457
|
-
os.remove(preview_frame)
|
|
458
|
-
questionary.press_any_key_to_continue().ask()
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
def batch_convert():
|
|
465
|
-
"""Convert all media files in the directory to a specific format."""
|
|
466
|
-
media_files = get_media_files()
|
|
467
|
-
if not media_files:
|
|
468
|
-
console.print("[bold yellow]No media files found in the current directory.[/bold yellow]")
|
|
469
|
-
questionary.press_any_key_to_continue().ask()
|
|
470
|
-
return
|
|
471
|
-
|
|
472
|
-
output_format = questionary.select(
|
|
473
|
-
"Select output format for the batch conversion:",
|
|
474
|
-
choices=["mp4", "mkv", "mov", "avi", "webm", "mp3", "flac", "wav", "gif"],
|
|
475
|
-
use_indicator=True
|
|
476
|
-
).ask()
|
|
477
|
-
if not output_format: return
|
|
478
|
-
|
|
479
|
-
quality_preset = None
|
|
480
|
-
if output_format in ["mp4", "mkv", "mov", "avi", "webm"]:
|
|
481
|
-
quality_preset = questionary.select(
|
|
482
|
-
"Select quality preset:",
|
|
483
|
-
choices=["Same as source", "High (CRF 18)", "Medium (CRF 23)", "Low (CRF 28)"],
|
|
38
|
+
def image_action_menu(file_path):
|
|
39
|
+
"""Display the menu of actions for a selected image file."""
|
|
40
|
+
while True:
|
|
41
|
+
console.rule(f"[bold]Actions for Image: {os.path.basename(file_path)}[/bold]")
|
|
42
|
+
action = questionary.select(
|
|
43
|
+
"Choose an action:",
|
|
44
|
+
choices=[
|
|
45
|
+
"Inspect File Details",
|
|
46
|
+
"Convert Format",
|
|
47
|
+
"Resize",
|
|
48
|
+
"Rotate",
|
|
49
|
+
"Flip",
|
|
50
|
+
"Crop (Visual)",
|
|
51
|
+
questionary.Separator(),
|
|
52
|
+
"Back to File List"
|
|
53
|
+
],
|
|
484
54
|
use_indicator=True
|
|
485
55
|
).ask()
|
|
486
|
-
if not quality_preset: return
|
|
487
|
-
|
|
488
|
-
confirm = questionary.confirm(
|
|
489
|
-
f"This will convert {len(media_files)} file(s) in the current directory to .{output_format}. Continue?",
|
|
490
|
-
default=False
|
|
491
|
-
).ask()
|
|
492
|
-
|
|
493
|
-
if not confirm:
|
|
494
|
-
console.print("[bold yellow]Batch conversion cancelled.[/bold yellow]")
|
|
495
|
-
return
|
|
496
|
-
|
|
497
|
-
success_count = 0
|
|
498
|
-
fail_count = 0
|
|
499
|
-
|
|
500
|
-
for file in media_files:
|
|
501
|
-
console.rule(f"Processing: {file}")
|
|
502
|
-
file_path = os.path.abspath(file)
|
|
503
|
-
is_gif = Path(file_path).suffix.lower() == '.gif'
|
|
504
|
-
has_audio = has_audio_stream(file_path)
|
|
505
|
-
|
|
506
|
-
if (is_gif or not has_audio) and output_format in ["mp3", "flac", "wav"]:
|
|
507
|
-
console.print(f"[bold yellow]Skipping {file}: Source has no audio to convert.[/bold yellow]")
|
|
508
|
-
continue
|
|
509
|
-
|
|
510
|
-
output_file = f"{Path(file_path).stem}_batch.{output_format}"
|
|
511
|
-
input_stream = ffmpeg.input(file_path)
|
|
512
|
-
output_stream = None
|
|
513
|
-
kwargs = {'y': None}
|
|
514
56
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if quality_preset == "Same as source":
|
|
518
|
-
kwargs['c'] = 'copy'
|
|
519
|
-
else:
|
|
520
|
-
crf = quality_preset.split(" ")[-1][1:-1]
|
|
521
|
-
kwargs['c:v'] = 'libx264'
|
|
522
|
-
kwargs['crf'] = crf
|
|
523
|
-
kwargs['pix_fmt'] = 'yuv420p'
|
|
524
|
-
if has_audio:
|
|
525
|
-
kwargs['c:a'] = 'aac'
|
|
526
|
-
kwargs['b:a'] = '192k'
|
|
527
|
-
else:
|
|
528
|
-
kwargs['an'] = None
|
|
529
|
-
output_stream = input_stream.output(output_file, **kwargs)
|
|
530
|
-
|
|
531
|
-
elif output_format in ["mp3", "flac", "wav"]:
|
|
532
|
-
kwargs['vn'] = None
|
|
533
|
-
kwargs['c:a'] = 'libmp3lame' if output_format == 'mp3' else output_format
|
|
534
|
-
if output_format == 'mp3':
|
|
535
|
-
kwargs['b:a'] = '192k' # Default bitrate for batch
|
|
536
|
-
output_stream = input_stream.output(output_file, **kwargs)
|
|
537
|
-
|
|
538
|
-
elif output_format == "gif":
|
|
539
|
-
fps = "15"
|
|
540
|
-
scale = "480"
|
|
541
|
-
palette_file = f"palette_{Path(file_path).stem}.png"
|
|
542
|
-
|
|
543
|
-
palette_gen_stream = input_stream.video.filter('fps', fps=fps).filter('scale', w=scale, h=-1, flags='lanczos').filter('palettegen')
|
|
544
|
-
run_command(palette_gen_stream.output(palette_file, y=None), f"Generating palette for {file}...")
|
|
545
|
-
|
|
546
|
-
if not os.path.exists(palette_file):
|
|
547
|
-
console.print(f"[bold red]Failed to generate color palette for {file}.[/bold red]")
|
|
548
|
-
fail_count += 1
|
|
549
|
-
continue
|
|
550
|
-
|
|
551
|
-
palette_input = ffmpeg.input(palette_file)
|
|
552
|
-
video_stream = input_stream.video.filter('fps', fps=fps).filter('scale', w=scale, h=-1, flags='lanczos')
|
|
553
|
-
final_stream = ffmpeg.filter([video_stream, palette_input], 'paletteuse')
|
|
554
|
-
output_stream = final_stream.output(output_file, y=None)
|
|
555
|
-
|
|
556
|
-
if output_stream and run_command(output_stream, f"Converting {file}...", show_progress=True):
|
|
557
|
-
console.print(f" -> [bold green]Successfully converted to {output_file}[/bold green]")
|
|
558
|
-
success_count += 1
|
|
559
|
-
else:
|
|
560
|
-
console.print(f" -> [bold red]Failed to convert {file}.[/bold red]")
|
|
561
|
-
fail_count += 1
|
|
562
|
-
|
|
563
|
-
if output_format == "gif" and os.path.exists(f"palette_{Path(file_path).stem}.png"):
|
|
564
|
-
os.remove(f"palette_{Path(file_path).stem}.png")
|
|
565
|
-
|
|
566
|
-
except Exception as e:
|
|
567
|
-
console.print(f"[bold red]An unexpected error occurred while processing {file}: {e}[/bold red]")
|
|
568
|
-
logging.error(f"Batch convert error for file {file}: {e}")
|
|
569
|
-
fail_count += 1
|
|
57
|
+
if action is None or action == "Back to File List":
|
|
58
|
+
break
|
|
570
59
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
60
|
+
actions = {
|
|
61
|
+
"Inspect File Details": inspect_file,
|
|
62
|
+
"Convert Format": convert_image,
|
|
63
|
+
"Resize": resize_image,
|
|
64
|
+
"Rotate": rotate_image,
|
|
65
|
+
"Flip": flip_image,
|
|
66
|
+
"Crop (Visual)": crop_image,
|
|
67
|
+
}
|
|
68
|
+
if action in actions:
|
|
69
|
+
actions[action](file_path)
|
|
574
70
|
|
|
575
71
|
|
|
576
72
|
def action_menu(file_path):
|
|
@@ -582,6 +78,7 @@ def action_menu(file_path):
|
|
|
582
78
|
choices=[
|
|
583
79
|
"Inspect File Details",
|
|
584
80
|
"Convert",
|
|
81
|
+
"Generate Subtitles (Whisper)",
|
|
585
82
|
"Trim Video",
|
|
586
83
|
"Crop Video (Visual)",
|
|
587
84
|
"Extract Audio",
|
|
@@ -598,6 +95,7 @@ def action_menu(file_path):
|
|
|
598
95
|
actions = {
|
|
599
96
|
"Inspect File Details": inspect_file,
|
|
600
97
|
"Convert": convert_file,
|
|
98
|
+
"Generate Subtitles (Whisper)": generate_subtitles,
|
|
601
99
|
"Trim Video": trim_video,
|
|
602
100
|
"Crop Video (Visual)": crop_video,
|
|
603
101
|
"Extract Audio": extract_audio,
|
|
@@ -608,77 +106,6 @@ def action_menu(file_path):
|
|
|
608
106
|
actions[action](file_path)
|
|
609
107
|
|
|
610
108
|
|
|
611
|
-
def join_videos():
|
|
612
|
-
"""Join multiple videos into a single file after standardizing their resolutions and sample rates."""
|
|
613
|
-
console.print("[bold cyan]Select videos to join (in order). Press Enter when done.[/bold cyan]")
|
|
614
|
-
|
|
615
|
-
media_files = get_media_files()
|
|
616
|
-
video_files = [f for f in media_files if Path(f).suffix.lower() in [".mp4", ".mkv", ".mov", ".avi", ".webm"]]
|
|
617
|
-
|
|
618
|
-
if len(video_files) < 2:
|
|
619
|
-
console.print("[bold yellow]Not enough video files in the directory to join.[/bold yellow]")
|
|
620
|
-
questionary.press_any_key_to_continue().ask()
|
|
621
|
-
return
|
|
622
|
-
|
|
623
|
-
selected_videos = questionary.checkbox("Select at least two videos to join in order:", choices=video_files).ask()
|
|
624
|
-
|
|
625
|
-
if not selected_videos or len(selected_videos) < 2:
|
|
626
|
-
console.print("[bold yellow]Joining cancelled. At least two videos must be selected.[/bold yellow]")
|
|
627
|
-
return
|
|
628
|
-
|
|
629
|
-
console.print("Videos will be joined in this order:")
|
|
630
|
-
for i, video in enumerate(selected_videos):
|
|
631
|
-
console.print(f" {i+1}. {video}")
|
|
632
|
-
|
|
633
|
-
output_file = questionary.text("Enter the output file name:", default="joined_video.mp4").ask()
|
|
634
|
-
if not output_file: return
|
|
635
|
-
|
|
636
|
-
try:
|
|
637
|
-
first_video_path = os.path.abspath(selected_videos[0])
|
|
638
|
-
probe = ffmpeg.probe(first_video_path)
|
|
639
|
-
video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
|
|
640
|
-
audio_info = next(s for s in probe['streams'] if s['codec_type'] == 'audio')
|
|
641
|
-
|
|
642
|
-
target_width = video_info['width']
|
|
643
|
-
target_height = video_info['height']
|
|
644
|
-
target_sar = video_info.get('sample_aspect_ratio', '1:1')
|
|
645
|
-
target_sample_rate = audio_info['sample_rate']
|
|
646
|
-
|
|
647
|
-
except Exception as e:
|
|
648
|
-
console.print(f"[bold red]Could not probe first video for target parameters: {e}[/bold red]")
|
|
649
|
-
return
|
|
650
|
-
|
|
651
|
-
console.print(f"Standardizing all videos to: {target_width}x{target_height} resolution and {target_sample_rate} Hz audio.")
|
|
652
|
-
|
|
653
|
-
processed_streams = []
|
|
654
|
-
for video_file in selected_videos:
|
|
655
|
-
stream = ffmpeg.input(os.path.abspath(video_file))
|
|
656
|
-
v = (
|
|
657
|
-
stream.video
|
|
658
|
-
.filter('scale', w=target_width, h=target_height, force_original_aspect_ratio='decrease')
|
|
659
|
-
.filter('pad', w=target_width, h=target_height, x='(ow-iw)/2', y='(oh-ih)/2')
|
|
660
|
-
.filter('setsar', sar=target_sar.replace(':','/'))
|
|
661
|
-
.filter('setpts', 'PTS-STARTPTS')
|
|
662
|
-
)
|
|
663
|
-
a = (
|
|
664
|
-
stream.audio
|
|
665
|
-
.filter('aresample', sample_rate=target_sample_rate)
|
|
666
|
-
.filter('asetpts', 'PTS-STARTPTS')
|
|
667
|
-
)
|
|
668
|
-
processed_streams.append(v)
|
|
669
|
-
processed_streams.append(a)
|
|
670
|
-
|
|
671
|
-
joined = ffmpeg.concat(*processed_streams, v=1, a=1).node
|
|
672
|
-
output_stream = ffmpeg.output(joined[0], joined[1], output_file, **{'c:v': 'libx264', 'crf': 23, 'c:a': 'aac', 'b:a': '192k', 'y': None})
|
|
673
|
-
|
|
674
|
-
if run_command(output_stream, "Joining and re-encoding videos...", show_progress=True):
|
|
675
|
-
console.print(f"[bold green]Successfully joined videos into {output_file}[/bold green]")
|
|
676
|
-
else:
|
|
677
|
-
console.print("[bold red]Failed to join videos.[/bold red]")
|
|
678
|
-
|
|
679
|
-
questionary.press_any_key_to_continue().ask()
|
|
680
|
-
|
|
681
|
-
|
|
682
109
|
def main_menu():
|
|
683
110
|
"""Display the main menu."""
|
|
684
111
|
check_ffmpeg_ffprobe()
|
|
@@ -697,11 +124,15 @@ def main_menu():
|
|
|
697
124
|
|
|
698
125
|
if choice is None or choice == "Exit":
|
|
699
126
|
console.print("[bold]Goodbye![/bold]")
|
|
127
|
+
console.print("\n[italic cyan]Built with ❤️ by Hariharen[/italic cyan]")
|
|
700
128
|
break
|
|
701
129
|
elif choice == "Process a Single Media File":
|
|
702
130
|
selected_file = select_media_file()
|
|
703
131
|
if selected_file:
|
|
704
|
-
|
|
132
|
+
if Path(selected_file).suffix.lower() in IMAGE_EXTENSIONS:
|
|
133
|
+
image_action_menu(selected_file)
|
|
134
|
+
else:
|
|
135
|
+
action_menu(selected_file)
|
|
705
136
|
elif choice == "Join Multiple Videos":
|
|
706
137
|
join_videos()
|
|
707
138
|
elif choice == "Batch Convert All Media in Directory":
|
|
@@ -709,7 +140,7 @@ def main_menu():
|
|
|
709
140
|
|
|
710
141
|
|
|
711
142
|
def main():
|
|
712
|
-
"""Main entry point for the application
|
|
143
|
+
"""Main entry point for the application."""
|
|
713
144
|
try:
|
|
714
145
|
main_menu()
|
|
715
146
|
except (KeyboardInterrupt, EOFError):
|