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 +483 -283
- {peg_this-3.0.0.dist-info → peg_this-3.0.1.dist-info}/METADATA +23 -15
- peg_this-3.0.1.dist-info/RECORD +8 -0
- peg_this-3.0.0.dist-info/RECORD +0 -8
- {peg_this-3.0.0.dist-info → peg_this-3.0.1.dist-info}/WHEEL +0 -0
- {peg_this-3.0.0.dist-info → peg_this-3.0.1.dist-info}/entry_points.txt +0 -0
- {peg_this-3.0.0.dist-info → peg_this-3.0.1.dist-info}/licenses/LICENSE +0 -0
- {peg_this-3.0.0.dist-info → peg_this-3.0.1.dist-info}/top_level.txt +0 -0
peg_this/peg_this.py
CHANGED
|
@@ -1,44 +1,118 @@
|
|
|
1
|
+
|
|
1
2
|
import os
|
|
2
|
-
import
|
|
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
|
|
30
|
-
"""
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
149
|
+
|
|
63
150
|
if process.returncode != 0:
|
|
64
|
-
|
|
65
|
-
console.print(
|
|
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
|
|
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()
|
|
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
|
-
|
|
94
|
-
return None
|
|
178
|
+
return file_path if file_path else None
|
|
179
|
+
return None
|
|
95
180
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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"{
|
|
128
|
-
|
|
212
|
+
table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s")
|
|
129
213
|
console.print(table)
|
|
130
214
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
"
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
194
|
-
stream = ffmpeg.input(file_path).output(output_file, vn=None, acodec=
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
491
|
-
"
|
|
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 == "
|
|
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 == "
|
|
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
|
-
|
|
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("
|
|
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.
|
|
4
|
-
Summary: A powerful
|
|
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
|
-
|
|
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
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **Audio
|
|
30
|
-
- **Audio
|
|
31
|
-
- **Batch Conversion
|
|
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,,
|
peg_this-3.0.0.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|