peg-this 3.0.0__py3-none-any.whl → 3.0.1__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/peg_this.py CHANGED
@@ -1,44 +1,118 @@
1
+
1
2
  import os
2
- import ffmpeg
3
+ import subprocess
3
4
  import json
4
5
  from pathlib import Path
5
6
  import sys
6
- import random
7
7
  import logging
8
- import tkinter as tk
9
- from tkinter import filedialog, messagebox
10
- from PIL import Image, ImageTk
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
8
 
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 ---
34
+
35
+
36
+ # --- Global Configuration ---
16
37
  # Configure logging
17
38
  log_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ffmpeg_log.txt")
18
39
  logging.basicConfig(
19
40
  level=logging.INFO,
20
41
  format='%(asctime)s - %(levelname)s - %(message)s',
21
42
  handlers=[
22
- logging.FileHandler(log_file),
43
+ logging.FileHandler(log_file, mode='w') # Overwrite log on each run
23
44
  ]
24
45
  )
25
46
 
47
+ # Initialize Rich Console
26
48
  console = Console()
49
+ # --- End Global Configuration ---
27
50
 
28
51
 
29
- def run_command(stream, description="Processing...", show_progress=False):
30
- """Runs a command using ffmpeg-python, with an optional progress bar."""
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
+ """
31
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)}")
32
81
 
33
82
  if not show_progress:
34
83
  try:
35
- out, err = ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
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).")
36
87
  return out.decode('utf-8')
37
88
  except ffmpeg.Error as e:
89
+ error_message = e.stderr.decode('utf-8')
38
90
  console.print("[bold red]An error occurred:[/bold red]")
39
- console.print(e.stderr.decode('utf-8'))
91
+ console.print(error_message)
92
+ logging.error(f"ffmpeg error:{error_message}")
40
93
  return None
41
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
+
42
116
  with Progress(
43
117
  SpinnerColumn(),
44
118
  TextColumn("[progress.description]{task.description}"),
@@ -47,23 +121,38 @@ def run_command(stream, description="Processing...", show_progress=False):
47
121
  console=console,
48
122
  ) as progress:
49
123
  task = progress.add_task(description, total=100)
50
- process = ffmpeg.run_async(stream, pipe_stdout=True, pipe_stderr=True)
51
-
52
- # The following progress bar is a simulation, as ffmpeg-python does not directly expose progress.
53
- # A more accurate progress bar would require parsing ffmpeg's stderr.
54
- while process.poll() is None:
55
- progress.update(task, advance=0.5)
56
- # A more sophisticated implementation could read from process.stderr
57
- # and parse the progress information.
58
- import time
59
- time.sleep(0.1)
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
+ )
60
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()
61
148
  progress.update(task, completed=100)
62
- out, err = process.communicate()
149
+
63
150
  if process.returncode != 0:
64
- console.print(f"[bold red]An error occurred during processing.[/bold red]")
65
- console.print(err.decode('utf-8'))
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]")
66
153
  return None
154
+
155
+ logging.info("Command successful (with progress bar).")
67
156
  return "Success"
68
157
 
69
158
 
@@ -75,235 +164,236 @@ def get_media_files():
75
164
 
76
165
 
77
166
  def select_media_file():
78
- """Display a menu to select a media file, or open a file picker if none are found."""
167
+ """Display a menu to select a media file, or open a file picker."""
79
168
  media_files = get_media_files()
80
169
  if not media_files:
81
170
  console.print("[bold yellow]No media files found in this directory.[/bold yellow]")
82
171
  if tk and questionary.confirm("Would you like to select a file from another location?").ask():
83
172
  root = tk.Tk()
84
- root.withdraw() # Hide the main window
173
+ root.withdraw()
85
174
  file_path = filedialog.askopenfilename(
86
175
  title="Select a media file",
87
- filetypes=[
88
- ("Media Files", "*.mkv *.mp4 *.avi *.mov *.webm *.flv *.wmv *.mp3 *.flac *.wav *.ogg *.gif"),
89
- ("All Files", "*.*")
90
- ]
176
+ filetypes=[("Media Files", "*.mkv *.mp4 *.avi *.mov *.webm *.flv *.wmv *.mp3 *.flac *.wav *.ogg *.gif"), ("All Files", "*.*")]
91
177
  )
92
- return file_path
93
- else:
94
- return None
178
+ return file_path if file_path else None
179
+ return None
95
180
 
96
- file = questionary.select(
97
- "Select a media file to process:",
98
- choices=media_files + [questionary.Separator(), "Back"],
99
- use_indicator=True
100
- ).ask()
101
-
102
- return file if file != "Back" else None
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
103
186
 
104
187
 
105
188
  def inspect_file(file_path):
106
- """Show detailed information about the selected media file."""
189
+ """Show detailed information about the selected media file using ffprobe."""
190
+ console.print(f"Inspecting {os.path.basename(file_path)}...")
107
191
  try:
108
192
  info = ffmpeg.probe(file_path)
109
193
  except ffmpeg.Error as e:
110
194
  console.print("[bold red]An error occurred while inspecting the file:[/bold red]")
111
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()
112
198
  return
113
199
 
114
200
  format_info = info.get('format', {})
115
-
116
201
  table = Table(title=f"File Information: {os.path.basename(file_path)}", show_header=True, header_style="bold magenta")
117
202
  table.add_column("Property", style="dim")
118
203
  table.add_column("Value")
119
204
 
120
- size_bytes = int(format_info.get('size', 0))
121
- size_mb = size_bytes / (1024 * 1024)
205
+ size_mb = float(format_info.get('size', 0)) / (1024 * 1024)
122
206
  duration_sec = float(format_info.get('duration', 0))
207
+ bit_rate_kbps = float(format_info.get('bit_rate', 0)) / 1000
123
208
 
124
209
  table.add_row("Size", f"{size_mb:.2f} MB")
125
210
  table.add_row("Duration", f"{duration_sec:.2f} seconds")
126
211
  table.add_row("Format", format_info.get('format_long_name', 'N/A'))
127
- table.add_row("Bitrate", f"{float(format_info.get('bit_rate', 0)) / 1000:.0f} kb/s")
128
-
212
+ table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s")
129
213
  console.print(table)
130
214
 
131
- video_streams = [s for s in info.get('streams', []) if s.get('codec_type') == 'video']
132
- if video_streams:
133
- video_table = Table(title="Video Streams", show_header=True, header_style="bold cyan")
134
- video_table.add_column("Stream")
135
- video_table.add_column("Codec")
136
- video_table.add_column("Resolution")
137
- video_table.add_column("Frame Rate")
138
- for s in video_streams:
139
- video_table.add_row(
140
- f"#{s.get('index')}",
141
- s.get('codec_name'),
142
- f"{s.get('width')}x{s.get('height')}",
143
- s.get('r_frame_rate')
144
- )
145
- console.print(video_table)
146
-
147
- audio_streams = [s for s in info.get('streams', []) if s.get('codec_type') == 'audio']
148
- if audio_streams:
149
- audio_table = Table(title="Audio Streams", show_header=True, header_style="bold green")
150
- audio_table.add_column("Stream")
151
- audio_table.add_column("Codec")
152
- audio_table.add_column("Sample Rate")
153
- audio_table.add_column("Channels")
154
- for s in audio_streams:
155
- audio_table.add_row(
156
- f"#{s.get('index')}",
157
- s.get('codec_name'),
158
- f"{s.get('sample_rate')} Hz",
159
- str(s.get('channels'))
160
- )
161
- console.print(audio_table)
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)
162
234
 
163
235
  questionary.press_any_key_to_continue().ask()
164
236
 
165
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
+
166
329
  def trim_video(file_path):
167
330
  """Cut a video by specifying start and end times."""
168
- start_time = questionary.text("Enter start time (HH:MM:SS):").ask()
331
+ start_time = questionary.text("Enter start time (HH:MM:SS or seconds):").ask()
169
332
  if not start_time: return
170
- end_time = questionary.text("Enter end time (HH:MM:SS):").ask()
333
+ end_time = questionary.text("Enter end time (HH:MM:SS or seconds):").ask()
171
334
  if not end_time: return
172
335
 
173
336
  output_file = f"{Path(file_path).stem}_trimmed{Path(file_path).suffix}"
174
- stream = ffmpeg.input(file_path, ss=start_time, to=end_time).output(output_file, c='copy')
337
+
338
+ stream = ffmpeg.input(file_path, ss=start_time, to=end_time).output(output_file, c='copy', y=None)
339
+
175
340
  run_command(stream, "Trimming video...", show_progress=True)
176
341
  console.print(f"[bold green]Successfully trimmed to {output_file}[/bold green]")
177
342
  questionary.press_any_key_to_continue().ask()
178
343
 
344
+
179
345
  def extract_audio(file_path):
180
346
  """Extract the audio track from a video file."""
181
- audio_format = questionary.select(
182
- "Select audio format:",
183
- choices=[
184
- questionary.Choice("MP3 (lossy)", {"codec": "libmp3lame", "ext": "mp3", "q": 2}),
185
- questionary.Choice("FLAC (lossless)", {"codec": "flac", "ext": "flac"}),
186
- questionary.Choice("WAV (uncompressed)", {"codec": "pcm_s16le", "ext": "wav"})
187
- ],
188
- use_indicator=True
189
- ).ask()
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
190
351
 
352
+ audio_format = questionary.select("Select audio format:", choices=["mp3", "flac", "wav"], use_indicator=True).ask()
191
353
  if not audio_format: return
192
354
 
193
- output_file = f"{Path(file_path).stem}_audio.{audio_format['ext']}"
194
- stream = ffmpeg.input(file_path).output(output_file, vn=None, acodec=audio_format['codec'], **({'q:a': audio_format['q']} if 'q' in audio_format else {}))
195
- run_command(stream, f"Extracting audio to {audio_format['ext'].upper()}...", show_progress=True)
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)
196
359
  console.print(f"[bold green]Successfully extracted audio to {output_file}[/bold green]")
197
360
  questionary.press_any_key_to_continue().ask()
198
361
 
362
+
199
363
  def remove_audio(file_path):
200
364
  """Create a silent version of a video."""
201
365
  output_file = f"{Path(file_path).stem}_no_audio{Path(file_path).suffix}"
202
- stream = ffmpeg.input(file_path).output(output_file, vcodec='copy', an=None)
366
+ stream = ffmpeg.input(file_path).output(output_file, vcodec='copy', an=None, y=None)
367
+
203
368
  run_command(stream, "Removing audio track...", show_progress=True)
204
369
  console.print(f"[bold green]Successfully removed audio, saved to {output_file}[/bold green]")
205
370
  questionary.press_any_key_to_continue().ask()
206
371
 
207
372
 
208
- def batch_convert():
209
- """Convert all media files in the directory to a specific format."""
210
- output_format = questionary.select(
211
- "Select output format for the batch conversion:",
212
- choices=["mp4", "mkv", "mov", "avi", "webm", "flv", "wmv", "mp3", "flac", "wav", "ogg", "m4a", "aac", "gif"],
213
- use_indicator=True
214
- ).ask()
215
-
216
- if not output_format: return
217
-
218
- quality = "copy"
219
- if output_format in ["mp4", "webm", "avi", "wmv"]:
220
- quality = questionary.select(
221
- "Select quality preset:",
222
- choices=["Same as source (lossless if possible)", "High Quality (CRF 18)", "Medium Quality (CRF 23)", "Low Quality (CRF 28)"],
223
- use_indicator=True
224
- ).ask()
225
- if not quality: return
226
-
227
- confirm = questionary.confirm(
228
- f"This will attempt to convert ALL media files in the current directory to .{output_format}. Are you sure?",
229
- default=False
230
- ).ask()
231
-
232
- if not confirm:
233
- console.print("[bold yellow]Batch conversion cancelled.[/bold yellow]")
234
- return
235
-
236
- media_files = get_media_files()
237
-
238
- for file in media_files:
239
- is_gif = Path(file).suffix.lower() == '.gif'
240
- has_audio = has_audio_stream(file)
241
-
242
- if is_gif and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
243
- console.print(f"[bold yellow]Skipping {file}: Cannot convert a GIF to an audio format.[/bold yellow]")
244
- continue
245
-
246
- if not has_audio and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
247
- console.print(f"[bold yellow]Skipping {file}: No audio stream found.[/bold yellow]")
248
- continue
249
-
250
- output_file = f"{Path(file).stem}_batch.{output_format}"
251
- stream = ffmpeg.input(file)
252
-
253
- if output_format in ["mp4", "webm", "avi", "wmv"]:
254
- if quality == "Same as source (lossless if possible)":
255
- stream = stream.output(output_file, c='copy')
256
- else:
257
- crf = quality.split(" ")[-1][1:-1]
258
- audio_kwargs = {'c:a': 'aac', 'b:a': '192k'} if has_audio else {'an': None}
259
- stream = stream.output(output_file, **{'c:v': 'libx264', 'crf': crf, 'pix_fmt': 'yuv420p'}, **audio_kwargs)
260
- elif output_format in ["mp3", "m4a", "aac"]:
261
- stream = stream.output(output_file, vn=None, acodec='libmp3lame', **{'b:a': '192k'})
262
- elif output_format in ["flac", "wav", "ogg"]:
263
- stream = stream.output(output_file, vn=None, acodec=output_format)
264
- elif output_format == "gif":
265
- fps = "15"
266
- scale = "480"
267
- palette_file = f"palette_{Path(file).stem}.png"
268
- palette_stream = ffmpeg.input(file).filter('fps', fps=fps).filter('scale', size=f"{scale}:-1", flags='lanczos').output(palette_file, y=None)
269
- run_command(palette_stream, f"Generating color palette for {file}...")
270
- stream = ffmpeg.input(file).overlay(ffmpeg.input(palette_file).filter('paletteuse')).output(output_file, y=None)
271
-
272
-
273
- if run_command(stream, f"Converting {file}...", show_progress=True):
274
- console.print(f" -> Saved as {output_file}")
275
- else:
276
- console.print(f"[bold red]Failed to convert {file}.[/bold red]")
277
-
278
- if output_format == "gif" and os.path.exists(f"palette_{Path(file).stem}.png"):
279
- os.remove(f"palette_{Path(file).stem}.png")
280
-
281
- console.print("\n[bold green]Batch conversion finished.[/bold green]")
282
- questionary.press_any_key_to_continue().ask()
283
-
284
373
  def crop_video(file_path):
285
374
  """Visually crop a video by selecting an area."""
286
- logging.info(f"Starting crop_video for {file_path}")
287
375
  if not tk:
288
- logging.error("Cannot perform cropping: tkinter/Pillow is not installed.")
289
- console.print("[bold red]Cannot perform cropping: tkinter/Pillow is not installed.[/bold red]")
376
+ console.print("[bold red]Cannot perform visual cropping: tkinter & Pillow are not installed.[/bold red]")
290
377
  return
291
378
 
379
+ preview_frame = f"preview_{Path(file_path).stem}.jpg"
292
380
  try:
293
- info = ffmpeg.probe(file_path)
294
- duration = float(info['streams'][0].get('duration', '0'))
381
+ # Extract a frame from the middle of the video for preview
382
+ probe = ffmpeg.probe(file_path)
383
+ duration = float(probe['format']['duration'])
295
384
  mid_point = duration / 2
296
385
 
297
- preview_frame = "preview.jpg"
298
- stream = ffmpeg.input(file_path, ss=mid_point).output(preview_frame, vframes=1, qv=2, y=None)
299
- run_command(stream, "Extracting a frame for preview...")
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
+ )
300
391
 
301
392
  if not os.path.exists(preview_frame):
302
- logging.error(f"Could not extract preview frame. File not found: {preview_frame}")
303
393
  console.print("[bold red]Could not extract a frame from the video.[/bold red]")
304
394
  return
305
- logging.info(f"Successfully extracted preview frame to {preview_frame}")
306
395
 
396
+ # --- Tkinter GUI for Cropping ---
307
397
  root = tk.Tk()
308
398
  root.title("Crop Video - Drag to select area, close window to confirm")
309
399
  root.attributes("-topmost", True)
@@ -320,145 +410,180 @@ def crop_video(file_path):
320
410
 
321
411
  def on_press(event):
322
412
  nonlocal rect_id
323
- rect_coords['x1'] = event.x
324
- rect_coords['y1'] = event.y
413
+ rect_coords['x1'], rect_coords['y1'] = event.x, event.y
325
414
  rect_id = canvas.create_rectangle(0, 0, 1, 1, outline='red', width=2)
326
415
 
327
416
  def on_drag(event):
328
- nonlocal rect_id
329
- rect_coords['x2'] = event.x
330
- rect_coords['y2'] = event.y
417
+ rect_coords['x2'], rect_coords['y2'] = event.x, event.y
331
418
  canvas.coords(rect_id, rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2'])
332
419
 
333
- def on_release(event):
334
- pass
335
-
336
420
  canvas.bind("<ButtonPress-1>", on_press)
337
421
  canvas.bind("<B1-Motion>", on_drag)
338
- canvas.bind("<ButtonRelease-1>", on_release)
339
-
340
- messagebox.showinfo("Instructions", "Click and drag on the image to draw a cropping rectangle.\nClose this window when you are satisfied with the selection.", parent=root)
341
422
 
423
+ messagebox.showinfo("Instructions", "Click and drag to draw a cropping rectangle.\nClose this window when you are done.", parent=root)
342
424
  root.mainloop()
343
425
 
344
- os.remove(preview_frame)
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'])
345
431
 
346
- x1, y1, x2, y2 = rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2']
347
-
348
- crop_x = min(x1, x2)
349
- crop_y = min(y1, y2)
350
- crop_w = abs(x2 - x1)
351
- crop_h = abs(y2 - y1)
352
-
353
- if crop_w == 0 or crop_h == 0:
354
- console.print("[bold yellow]Cropping cancelled as no area was selected.[/bold yellow]")
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]")
355
434
  return
356
435
 
357
436
  console.print(f"Selected crop area: [bold]width={crop_w} height={crop_h} at (x={crop_x}, y={crop_y})[/bold]")
358
437
 
359
438
  output_file = f"{Path(file_path).stem}_cropped{Path(file_path).suffix}"
360
- stream = ffmpeg.input(file_path).filter('crop', crop_w, crop_h, crop_x, crop_y).output(output_file, **{'c:a': 'copy'})
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
+
361
452
  run_command(stream, "Applying crop to video...", show_progress=True)
362
453
  console.print(f"[bold green]Successfully cropped video and saved to {output_file}[/bold green]")
363
- questionary.press_any_key_to_continue().ask()
364
- except Exception as e:
365
- logging.exception(f"An error occurred in crop_video for file: {file_path}")
366
- console.print(f"[bold red]An error occurred during the crop operation. Check {log_file} for details.[/bold red]")
454
+
455
+ finally:
456
+ if os.path.exists(preview_frame):
457
+ os.remove(preview_frame)
367
458
  questionary.press_any_key_to_continue().ask()
368
459
 
369
- def has_audio_stream(file_path):
370
- """Check if the media file has an audio stream."""
371
- try:
372
- info = ffmpeg.probe(file_path)
373
- return any(s for s in info.get('streams', []) if s.get('codec_type') == 'audio')
374
- except ffmpeg.Error as e:
375
- logging.error(f"Error checking for audio stream in {file_path}: {e.stderr.decode('utf-8')}")
376
- return False
377
460
 
378
- def convert_file(file_path):
379
- """Convert the file to a different format."""
380
- is_gif = Path(file_path).suffix.lower() == '.gif'
381
- has_audio = has_audio_stream(file_path)
382
461
 
383
- output_format = questionary.select(
384
- "Select the output format:",
385
- choices=["mp4", "mkv", "mov", "avi", "webm", "flv", "wmv", "mp3", "flac", "wav", "ogg", "m4a", "aac", "gif"],
386
- use_indicator=True
387
- ).ask()
388
462
 
389
- if not output_format:
390
- return
391
463
 
392
- if is_gif and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
393
- console.print("[bold red]Error: Cannot convert a GIF (no audio) to an audio format.[/bold red]")
394
- questionary.press_any_key_to_continue().ask()
395
- return
396
-
397
- if not has_audio and output_format in ["mp3", "flac", "wav", "ogg", "m4a", "aac"]:
398
- console.print("[bold red]Error: The source file has no audio stream to convert.[/bold red]")
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]")
399
469
  questionary.press_any_key_to_continue().ask()
400
470
  return
401
471
 
402
- output_file = f"{Path(file_path).stem}_converted.{output_format}"
403
- stream = ffmpeg.input(file_path)
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
404
478
 
405
- if output_format in ["mp4", "webm", "avi", "wmv"]:
406
- quality = questionary.select(
479
+ quality_preset = None
480
+ if output_format in ["mp4", "mkv", "mov", "avi", "webm"]:
481
+ quality_preset = questionary.select(
407
482
  "Select quality preset:",
408
- choices=["Same as source (lossless if possible)", "High Quality (CRF 18)", "Medium Quality (CRF 23)", "Low Quality (CRF 28)"],
483
+ choices=["Same as source", "High (CRF 18)", "Medium (CRF 23)", "Low (CRF 28)"],
409
484
  use_indicator=True
410
485
  ).ask()
486
+ if not quality_preset: return
411
487
 
412
- if not quality: return
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()
413
492
 
414
- if quality == "Same as source (lossless if possible)":
415
- stream = stream.output(output_file, c='copy')
416
- else:
417
- crf = quality.split(" ")[-1][1:-1]
418
- audio_kwargs = {'c:a': 'aac', 'b:a': '192k'} if has_audio else {'an': None}
419
- stream = stream.output(output_file, **{'c:v': 'libx264', 'crf': crf, 'pix_fmt': 'yuv420p'}, **audio_kwargs)
493
+ if not confirm:
494
+ console.print("[bold yellow]Batch conversion cancelled.[/bold yellow]")
495
+ return
420
496
 
421
- elif output_format in ["mp3", "m4a", "aac"]:
422
- bitrate = questionary.select("Select audio bitrate:", choices=["128k", "192k", "256k", "320k"]).ask()
423
- if not bitrate: return
424
- stream = stream.output(output_file, vn=None, acodec='libmp3lame', **{'b:a': bitrate})
497
+ success_count = 0
498
+ fail_count = 0
425
499
 
426
- elif output_format in ["flac", "wav", "ogg"]:
427
- stream = stream.output(output_file, vn=None, acodec=output_format)
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)
428
505
 
429
- elif output_format == "gif":
430
- fps = questionary.text("Enter frame rate (e.g., 15):", default="15").ask()
431
- if not fps: return
432
- scale = questionary.text("Enter width in pixels (e.g., 480):", default="480").ask()
433
- if not scale: return
434
-
435
- palette_file = "palette.png"
436
- palette_stream = ffmpeg.input(file_path).filter('fps', fps=fps).filter('scale', size=f"{scale}:-1", flags='lanczos').output(palette_file, y=None)
437
- run_command(palette_stream, "Generating color palette...")
438
- stream = ffmpeg.input(file_path).overlay(ffmpeg.input(palette_file).filter('paletteuse')).output(output_file, y=None)
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
439
509
 
440
- if run_command(stream, f"Converting to {output_format}...", show_progress=True):
441
- console.print(f"[bold green]Successfully converted to {output_file}[/bold green]")
442
- else:
443
- console.print("[bold red]Conversion failed. Please check the logs for more details.[/bold red]")
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}
444
514
 
445
- if output_format == "gif" and os.path.exists("palette.png"):
446
- os.remove("palette.png")
447
-
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}")
448
573
  questionary.press_any_key_to_continue().ask()
449
574
 
450
575
 
451
576
  def action_menu(file_path):
452
577
  """Display the menu of actions for a selected file."""
453
578
  while True:
454
- console.rule(f"[bold]Actions for: {file_path}[/bold]")
579
+ console.rule(f"[bold]Actions for: {os.path.basename(file_path)}[/bold]")
455
580
  action = questionary.select(
456
581
  "Choose an action:",
457
582
  choices=[
458
583
  "Inspect File Details",
459
584
  "Convert",
460
585
  "Trim Video",
461
- "Crop Video",
586
+ "Crop Video (Visual)",
462
587
  "Extract Audio",
463
588
  "Remove Audio",
464
589
  questionary.Separator(),
@@ -474,21 +599,97 @@ def action_menu(file_path):
474
599
  "Inspect File Details": inspect_file,
475
600
  "Convert": convert_file,
476
601
  "Trim Video": trim_video,
477
- "Crop Video": crop_video,
602
+ "Crop Video (Visual)": crop_video,
478
603
  "Extract Audio": extract_audio,
479
604
  "Remove Audio": remove_audio,
480
605
  }
481
- actions[action](file_path)
606
+ # Ensure we have a valid action before calling
607
+ if action in actions:
608
+ actions[action](file_path)
609
+
610
+
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
+
482
681
 
483
682
  def main_menu():
484
683
  """Display the main menu."""
684
+ check_ffmpeg_ffprobe()
485
685
  while True:
486
686
  console.rule("[bold magenta]ffmPEG-this[/bold magenta]")
487
687
  choice = questionary.select(
488
688
  "What would you like to do?",
489
689
  choices=[
490
- "Select a Media File to Process",
491
- "Batch Convert All Videos to a Format",
690
+ "Process a Single Media File",
691
+ "Join Multiple Videos",
692
+ "Batch Convert All Media in Directory",
492
693
  "Exit"
493
694
  ],
494
695
  use_indicator=True
@@ -497,24 +698,23 @@ def main_menu():
497
698
  if choice is None or choice == "Exit":
498
699
  console.print("[bold]Goodbye![/bold]")
499
700
  break
500
- elif choice == "Select a Media File to Process":
701
+ elif choice == "Process a Single Media File":
501
702
  selected_file = select_media_file()
502
703
  if selected_file:
503
704
  action_menu(selected_file)
504
- elif choice == "Batch Convert All Videos to a Format":
705
+ elif choice == "Join Multiple Videos":
706
+ join_videos()
707
+ elif choice == "Batch Convert All Media in Directory":
505
708
  batch_convert()
506
709
 
507
710
 
508
- def main():
711
+ if __name__ == "__main__":
509
712
  try:
510
713
  main_menu()
511
- except KeyboardInterrupt:
714
+ except (KeyboardInterrupt, EOFError):
512
715
  logging.info("Operation cancelled by user.")
513
- console.print("\n[bold]Operation cancelled by user. Goodbye![/bold]")
716
+ console.print("[bold]Operation cancelled. Goodbye![/bold]")
514
717
  except Exception as e:
515
718
  logging.exception("An unexpected error occurred.")
516
719
  console.print(f"[bold red]An unexpected error occurred: {e}[/bold red]")
517
720
  console.print(f"Details have been logged to {log_file}")
518
-
519
- if __name__ == "__main__":
520
- main()
@@ -1,34 +1,42 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: peg_this
3
- Version: 3.0.0
4
- Summary: A powerful tool for converting, manipulating, and inspecting media files using FFmpeg.
3
+ Version: 3.0.1
4
+ Summary: A powerful, intuitive command-line video editor suite, built on FFmpeg.
5
5
  Author-email: Hariharen S S <thisishariharen@gmail.com>
6
+ Project-URL: Homepage, https://github.com/hariharen9/ffmpeg-this
7
+ Project-URL: Bug Tracker, https://github.com/hariharen9/ffmpeg-this/issues
8
+ Project-URL: Releases, https://github.com/hariharen9/ffmpeg-this/releases
6
9
  Classifier: Programming Language :: Python :: 3
7
10
  Classifier: License :: OSI Approved :: MIT License
8
11
  Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.7
12
+ Classifier: Topic :: Multimedia :: Video :: Conversion
13
+ Classifier: Topic :: Multimedia :: Video :: Non-Linear Editor
14
+ Requires-Python: >=3.8
10
15
  Description-Content-Type: text/markdown
11
16
  License-File: LICENSE
12
- Requires-Dist: ffmpeg-python
13
- Requires-Dist: questionary
14
- Requires-Dist: rich
15
- Requires-Dist: Pillow
17
+ Requires-Dist: ffmpeg-python==0.2.0
18
+ Requires-Dist: questionary>=2.0.0
19
+ Requires-Dist: rich>=13.0.0
20
+ Requires-Dist: Pillow>=9.0.0
16
21
  Dynamic: license-file
17
22
 
18
23
  # 🎬 ffmPEG-this
19
24
 
25
+ > Your Video editor within CLI 🚀
26
+
20
27
  A powerful and user-friendly batch script for converting, manipulating, and inspecting media files using the power of FFmpeg. This script provides a simple command-line menu to perform common audio and video tasks without needing to memorize complex FFmpeg commands.
21
28
 
22
29
  ## ✨ Features
23
30
 
24
- - **Action-Oriented Menu:** Select a file, then choose from a list of available actions.
25
- - **File Inspection:** View detailed information about a media file, including resolution, duration, size, and stream details.
26
- - **Lossless Conversion:** Change a file's container format (e.g., **MKV to MP4, , MP4 to GIF** etc.) without re-encoding, preserving the original quality.
27
- - **Lossy Conversion:** Re-encode video to reduce file size, with simple quality presets.
28
- - **Video Trimming:** Cut a video by specifying a start and end time.
29
- - **Audio Extraction:** Extract the audio from a video file into formats like MP3, FLAC, or WAV.
30
- - **Audio Removal:** Create a silent version of a video by removing its audio track.
31
- - **Batch Conversion:** Convert all video files in the directory to a specific format in one go.
31
+ - **Inspect Media Properties**: View detailed information about video and audio streams, including codecs, resolution, frame rate, bitrates, and more.
32
+ - **Convert & Transcode**: Convert videos and audio to a wide range of popular formats (MP4, MKV, WebM, MP3, FLAC, WAV, GIF) with simple quality presets.
33
+ - **Join Videos (Concatenate)**: Combine two or more videos into a single file. The tool automatically handles differences in resolution and audio sample rates for a seamless join.
34
+ - **Trim (Cut) Videos**: Easily cut a video to a specific start and end time without re-encoding for fast, lossless clips.
35
+ - **Visually Crop Videos**: An interactive tool that shows you a frame of the video, allowing you to click and drag to select the exact area you want to crop.
36
+ - **Extract Audio**: Rip the audio track from any video file into MP3, FLAC, or WAV.
37
+ - **Remove Audio**: Create a silent version of your video by stripping out all audio streams.
38
+ - **Batch Conversion**: Convert all media files in the current directory to a specified format in one go.
39
+
32
40
 
33
41
  ## 🚀 Usage
34
42
 
@@ -0,0 +1,8 @@
1
+ peg_this/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ peg_this/peg_this.py,sha256=khAYs2cz5zzxrq2onEt7YNcHjS9xcNJv_dvpXQD-RVg,30150
3
+ peg_this-3.0.1.dist-info/licenses/LICENSE,sha256=WL1MklYSco7KZvDjbf191tIKOxWQdekqda7dDJc6Wn8,1067
4
+ peg_this-3.0.1.dist-info/METADATA,sha256=HhoqodFFu5cKBkuezJ7gDAEPlXwkOn_Z-TbC8L7oyZc,3527
5
+ peg_this-3.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ peg_this-3.0.1.dist-info/entry_points.txt,sha256=9GVTFuE1w_wgY-Tz3--wI5j52BAKrt4atphVD8ioHhQ,52
7
+ peg_this-3.0.1.dist-info/top_level.txt,sha256=kSS5jZg3KN2kJqYZwMvQnI4gvlFxsUNzIm3QJsbKFdc,9
8
+ peg_this-3.0.1.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- peg_this/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- peg_this/peg_this.py,sha256=aDGeEMBWxfr_jt0Fn0xdwVa6KzS9bcBvXoDfNQ2B7Ao,21508
3
- peg_this-3.0.0.dist-info/licenses/LICENSE,sha256=WL1MklYSco7KZvDjbf191tIKOxWQdekqda7dDJc6Wn8,1067
4
- peg_this-3.0.0.dist-info/METADATA,sha256=OMOLRyxRv5zs7xtbMZferx0RjWVs448zQrPemgepgZY,2902
5
- peg_this-3.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- peg_this-3.0.0.dist-info/entry_points.txt,sha256=9GVTFuE1w_wgY-Tz3--wI5j52BAKrt4atphVD8ioHhQ,52
7
- peg_this-3.0.0.dist-info/top_level.txt,sha256=kSS5jZg3KN2kJqYZwMvQnI4gvlFxsUNzIm3QJsbKFdc,9
8
- peg_this-3.0.0.dist-info/RECORD,,