peg-this 3.0.4__py3-none-any.whl → 3.0.6__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.

Potentially problematic release.


This version of peg-this might be problematic. Click here for more details.

peg_this/peg_this.py CHANGED
@@ -1,37 +1,20 @@
1
-
2
1
  import os
3
- import subprocess
4
- import json
5
- from pathlib import Path
6
2
  import sys
7
3
  import logging
4
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
8
5
 
9
- # --- Dependency Check ---
10
- try:
11
- import questionary
12
- from rich.console import Console
13
- from rich.table import Table
14
- from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
15
- except ImportError:
16
- print("Error: Required libraries 'questionary' and 'rich' are not installed.")
17
- print("Please install them by running: pip install questionary rich")
18
- sys.exit(1)
19
-
20
- try:
21
- import ffmpeg
22
- except ImportError:
23
- print("Error: The 'ffmpeg-python' library is not installed.")
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 ---
6
+ import questionary
7
+ from rich.console import Console
34
8
 
9
+ from peg_this.features.audio import extract_audio, remove_audio
10
+ from peg_this.features.batch import batch_convert
11
+ from peg_this.features.convert import convert_file
12
+ from peg_this.features.crop import crop_video
13
+ from peg_this.features.inspect import inspect_file
14
+ from peg_this.features.join import join_videos
15
+ from peg_this.features.trim import trim_video
16
+ from peg_this.utils.ffmpeg_utils import check_ffmpeg_ffprobe
17
+ from peg_this.utils.ui_utils import select_media_file
35
18
 
36
19
  # --- Global Configuration ---
37
20
  # Configure logging
@@ -49,530 +32,6 @@ console = Console()
49
32
  # --- End Global Configuration ---
50
33
 
51
34
 
52
- def check_ffmpeg_ffprobe():
53
- """
54
- Checks if ffmpeg and ffprobe executables are available in the system's PATH.
55
- ffmpeg-python requires this.
56
- """
57
- try:
58
- # The library does this internally, but we can provide a clearer error message.
59
- subprocess.check_call(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
60
- subprocess.check_call(['ffprobe', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
61
- except FileNotFoundError:
62
- console.print("[bold red]Error: ffmpeg and ffprobe not found.[/bold red]")
63
- console.print("Please install FFmpeg and ensure its location is in your system's PATH.")
64
- sys.exit(1)
65
-
66
-
67
- def run_command(stream_spec, description="Processing...", show_progress=False):
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)"],
484
- use_indicator=True
485
- ).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
-
515
- try:
516
- if output_format in ["mp4", "mkv", "mov", "avi", "webm"]:
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
570
-
571
- console.rule("[bold green]Batch Conversion Complete[/bold green]")
572
- console.print(f"Successful: {success_count} | Failed: {fail_count}")
573
- questionary.press_any_key_to_continue().ask()
574
-
575
-
576
35
  def action_menu(file_path):
577
36
  """Display the menu of actions for a selected file."""
578
37
  while True:
@@ -608,77 +67,6 @@ def action_menu(file_path):
608
67
  actions[action](file_path)
609
68
 
610
69
 
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
70
  def main_menu():
683
71
  """Display the main menu."""
684
72
  check_ffmpeg_ffprobe()
File without changes