peg-this 3.0.2__py3-none-any.whl → 4.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- peg_this/features/__init__.py +0 -0
- peg_this/features/audio.py +85 -0
- peg_this/features/batch.py +151 -0
- peg_this/features/convert.py +309 -0
- peg_this/features/crop.py +207 -0
- peg_this/features/inspect.py +110 -0
- peg_this/features/join.py +137 -0
- peg_this/features/subtitle.py +397 -0
- peg_this/features/trim.py +56 -0
- peg_this/peg_this.py +53 -622
- peg_this/utils/__init__.py +0 -0
- peg_this/utils/ffmpeg_utils.py +129 -0
- peg_this/utils/ui_utils.py +52 -0
- peg_this/utils/validation.py +228 -0
- peg_this-4.1.0.dist-info/METADATA +283 -0
- peg_this-4.1.0.dist-info/RECORD +21 -0
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/WHEEL +1 -1
- peg_this-3.0.2.dist-info/METADATA +0 -87
- peg_this-3.0.2.dist-info/RECORD +0 -8
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/entry_points.txt +0 -0
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/licenses/LICENSE +0 -0
- {peg_this-3.0.2.dist-info → peg_this-4.1.0.dist-info}/top_level.txt +0 -0
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
|
|
2
|
+
import subprocess
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import ffmpeg
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_ffmpeg_ffprobe():
|
|
14
|
+
"""
|
|
15
|
+
Checks if ffmpeg and ffprobe executables are available in the system's PATH.
|
|
16
|
+
ffmpeg-python requires this.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
# The library does this internally, but we can provide a clearer error message.
|
|
20
|
+
subprocess.check_call(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
21
|
+
subprocess.check_call(['ffprobe', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
console.print("[bold red]Error: ffmpeg and ffprobe not found.[/bold red]")
|
|
24
|
+
if sys.platform == "win32":
|
|
25
|
+
console.print("You can install it using Chocolatey: [bold]choco install ffmpeg[/bold]")
|
|
26
|
+
console.print("Or Scoop: [bold]scoop install ffmpeg[/bold]")
|
|
27
|
+
elif sys.platform == "darwin":
|
|
28
|
+
console.print("You can install it using Homebrew: [bold]brew install ffmpeg[/bold]")
|
|
29
|
+
else:
|
|
30
|
+
console.print("You can install it using your system's package manager, e.g., [bold]sudo apt update && sudo apt install ffmpeg[/bold] on Debian/Ubuntu.")
|
|
31
|
+
console.print("Please ensure its location is in your system's PATH.")
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_command(stream_spec, description="Processing...", show_progress=False):
|
|
36
|
+
"""
|
|
37
|
+
Runs an ffmpeg command using ffmpeg-python.
|
|
38
|
+
- For simple commands, it runs directly.
|
|
39
|
+
- For commands with a progress bar, it generates the ffmpeg arguments,
|
|
40
|
+
runs them as a subprocess, and parses stderr to show progress.
|
|
41
|
+
Returns True on success, False on failure.
|
|
42
|
+
"""
|
|
43
|
+
console.print(f"[bold cyan]{description}[/bold cyan]")
|
|
44
|
+
|
|
45
|
+
args = stream_spec.get_args()
|
|
46
|
+
full_command = ['ffmpeg'] + args
|
|
47
|
+
logging.info(f"Executing command: {' '.join(full_command)}")
|
|
48
|
+
|
|
49
|
+
if not show_progress:
|
|
50
|
+
try:
|
|
51
|
+
# Use ffmpeg.run() for simple, non-progress tasks. It's cleaner.
|
|
52
|
+
ffmpeg.run(stream_spec, capture_stdout=True, capture_stderr=True, quiet=True)
|
|
53
|
+
logging.info("Command successful (no progress bar).")
|
|
54
|
+
return True
|
|
55
|
+
except ffmpeg.Error as e:
|
|
56
|
+
error_message = e.stderr.decode('utf-8')
|
|
57
|
+
console.print("[bold red]An error occurred:[/bold red]")
|
|
58
|
+
console.print(error_message)
|
|
59
|
+
logging.error(f"ffmpeg error:{error_message}")
|
|
60
|
+
return False
|
|
61
|
+
else:
|
|
62
|
+
# For the progress bar, we must run ffmpeg as a subprocess and parse stderr.
|
|
63
|
+
duration = 0
|
|
64
|
+
try:
|
|
65
|
+
input_file_path = None
|
|
66
|
+
for i, arg in enumerate(full_command):
|
|
67
|
+
if arg == '-i' and i + 1 < len(full_command):
|
|
68
|
+
input_file_path = full_command[i+1]
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
if input_file_path:
|
|
72
|
+
probe_info = ffmpeg.probe(input_file_path)
|
|
73
|
+
duration = float(probe_info['format']['duration'])
|
|
74
|
+
else:
|
|
75
|
+
logging.warning("Could not find input file in command to determine duration for progress bar.")
|
|
76
|
+
|
|
77
|
+
except (ffmpeg.Error, KeyError) as e:
|
|
78
|
+
console.print(f"[bold yellow]Warning: Could not determine video duration for progress bar.[/bold yellow]")
|
|
79
|
+
logging.warning(f"Could not probe for duration: {e}")
|
|
80
|
+
|
|
81
|
+
with Progress(
|
|
82
|
+
SpinnerColumn(),
|
|
83
|
+
TextColumn("[progress.description]{task.description}"),
|
|
84
|
+
BarColumn(),
|
|
85
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
86
|
+
console=console,
|
|
87
|
+
) as progress:
|
|
88
|
+
task = progress.add_task(description, total=100)
|
|
89
|
+
|
|
90
|
+
process = subprocess.Popen(
|
|
91
|
+
full_command,
|
|
92
|
+
stdout=subprocess.PIPE,
|
|
93
|
+
stderr=subprocess.PIPE,
|
|
94
|
+
universal_newlines=True,
|
|
95
|
+
encoding='utf-8'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for line in process.stderr:
|
|
99
|
+
logging.debug(f"ffmpeg stderr: {line.strip()}")
|
|
100
|
+
if "time=" in line and duration > 0:
|
|
101
|
+
try:
|
|
102
|
+
time_str = line.split("time=")[1].split(" ")[0].strip()
|
|
103
|
+
h, m, s_parts = time_str.split(':')
|
|
104
|
+
s = float(s_parts)
|
|
105
|
+
elapsed_time = int(h) * 3600 + int(m) * 60 + s
|
|
106
|
+
percent_complete = (elapsed_time / duration) * 100
|
|
107
|
+
progress.update(task, completed=min(percent_complete, 100))
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
process.wait()
|
|
112
|
+
progress.update(task, completed=100)
|
|
113
|
+
|
|
114
|
+
if process.returncode != 0:
|
|
115
|
+
log_file = logging.getLogger().handlers[0].baseFilename
|
|
116
|
+
console.print(f"[bold red]An error occurred during processing. Check {log_file} for details.[/bold red]")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
logging.info("Command successful (with progress bar).")
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def has_audio_stream(file_path):
|
|
124
|
+
"""Check if the media file has an audio stream."""
|
|
125
|
+
try:
|
|
126
|
+
probe = ffmpeg.probe(file_path, select_streams='a')
|
|
127
|
+
return 'streams' in probe and len(probe['streams']) > 0
|
|
128
|
+
except ffmpeg.Error:
|
|
129
|
+
return False
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tkinter as tk
|
|
10
|
+
from tkinter import filedialog
|
|
11
|
+
except ImportError:
|
|
12
|
+
tk = None
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_media_files():
|
|
18
|
+
"""Scan the current directory for media files."""
|
|
19
|
+
media_extensions = [
|
|
20
|
+
# Video
|
|
21
|
+
".mkv", ".mp4", ".avi", ".mov", ".webm", ".flv", ".wmv",
|
|
22
|
+
# Audio
|
|
23
|
+
".mp3", ".flac", ".wav", ".ogg",
|
|
24
|
+
# GIF
|
|
25
|
+
".gif",
|
|
26
|
+
# Image
|
|
27
|
+
".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"
|
|
28
|
+
]
|
|
29
|
+
files = [f for f in os.listdir('.') if os.path.isfile(f) and Path(f).suffix.lower() in media_extensions]
|
|
30
|
+
return files
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def select_media_file():
|
|
34
|
+
"""Display a menu to select a media file, or open a file picker."""
|
|
35
|
+
media_files = get_media_files()
|
|
36
|
+
if not media_files:
|
|
37
|
+
console.print("[bold yellow]No media files found in this directory.[/bold yellow]")
|
|
38
|
+
if tk and questionary.confirm("Would you like to select a file from another location?").ask():
|
|
39
|
+
root = tk.Tk()
|
|
40
|
+
root.withdraw()
|
|
41
|
+
file_path = filedialog.askopenfilename(
|
|
42
|
+
title="Select a media file",
|
|
43
|
+
filetypes=[("Media Files", "*.mkv *.mp4 *.avi *.mov *.webm *.flv *.wmv *.mp3 *.flac *.wav *.ogg *.gif *.jpg *.jpeg *.png *.webp *.bmp *.tiff"), ("All Files", "*.*")]
|
|
44
|
+
)
|
|
45
|
+
return file_path if file_path else None
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
choices = media_files + [questionary.Separator(), "Back"]
|
|
49
|
+
file = questionary.select("Select a media file to process:", choices=choices, use_indicator=True).ask()
|
|
50
|
+
|
|
51
|
+
# Return the absolute path to prevent "file not found" errors
|
|
52
|
+
return os.path.abspath(file) if file and file != "Back" else None
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import ffmpeg
|
|
7
|
+
import questionary
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_file_exists(file_path):
|
|
14
|
+
if not os.path.exists(file_path):
|
|
15
|
+
console.print("[bold red]Error: File not found.[/bold red]")
|
|
16
|
+
return False
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check_file_readable(file_path):
|
|
21
|
+
if not os.access(file_path, os.R_OK):
|
|
22
|
+
console.print("[bold red]Error: Cannot read file. Check permissions.[/bold red]")
|
|
23
|
+
return False
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_write_permission(directory):
|
|
28
|
+
if not os.access(directory, os.W_OK):
|
|
29
|
+
console.print("[bold red]Error: Cannot write to this location. Check permissions.[/bold red]")
|
|
30
|
+
return False
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_input_file(file_path):
|
|
35
|
+
if not check_file_exists(file_path):
|
|
36
|
+
return False
|
|
37
|
+
if not check_file_readable(file_path):
|
|
38
|
+
return False
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_output_file(output_path, file_type="file"):
|
|
43
|
+
if not os.path.exists(output_path):
|
|
44
|
+
return 'proceed', output_path
|
|
45
|
+
|
|
46
|
+
console.print(f"[yellow]Warning: {file_type} already exists:[/yellow]")
|
|
47
|
+
console.print(f"[dim]{output_path}[/dim]")
|
|
48
|
+
|
|
49
|
+
choice = questionary.select(
|
|
50
|
+
"What would you like to do?",
|
|
51
|
+
choices=["Overwrite existing file", "Save with a new name", "Cancel operation"]
|
|
52
|
+
).ask()
|
|
53
|
+
|
|
54
|
+
if not choice or "Cancel" in choice:
|
|
55
|
+
return 'cancel', None
|
|
56
|
+
elif "Overwrite" in choice:
|
|
57
|
+
return 'overwrite', output_path
|
|
58
|
+
else:
|
|
59
|
+
path = Path(output_path)
|
|
60
|
+
counter = 1
|
|
61
|
+
while True:
|
|
62
|
+
new_name = f"{path.stem}_{counter}{path.suffix}"
|
|
63
|
+
new_path = path.with_name(new_name)
|
|
64
|
+
if not os.path.exists(new_path):
|
|
65
|
+
console.print(f"[cyan]Will save as: {new_path.name}[/cyan]")
|
|
66
|
+
return 'rename', str(new_path)
|
|
67
|
+
counter += 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_disk_space(file_path, multiplier=2):
|
|
71
|
+
try:
|
|
72
|
+
input_size = os.path.getsize(file_path)
|
|
73
|
+
required_space = input_size * multiplier
|
|
74
|
+
total, used, free = shutil.disk_usage(Path(file_path).parent)
|
|
75
|
+
if free < required_space:
|
|
76
|
+
free_gb = free / (1024**3)
|
|
77
|
+
required_gb = required_space / (1024**3)
|
|
78
|
+
console.print(f"[yellow]Warning: Low disk space![/yellow]")
|
|
79
|
+
console.print(f"[dim]Available: {free_gb:.1f} GB, Estimated needed: {required_gb:.1f} GB[/dim]")
|
|
80
|
+
if not questionary.confirm("Continue anyway?", default=False).ask():
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
except Exception:
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_video_duration(file_path):
|
|
88
|
+
try:
|
|
89
|
+
probe = ffmpeg.probe(file_path)
|
|
90
|
+
return float(probe['format'].get('duration', 0))
|
|
91
|
+
except Exception:
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_duration(seconds):
|
|
96
|
+
if seconds < 60:
|
|
97
|
+
return f"{int(seconds)} seconds"
|
|
98
|
+
elif seconds < 3600:
|
|
99
|
+
mins = int(seconds // 60)
|
|
100
|
+
secs = int(seconds % 60)
|
|
101
|
+
return f"{mins}m {secs}s"
|
|
102
|
+
else:
|
|
103
|
+
hours = int(seconds // 3600)
|
|
104
|
+
mins = int((seconds % 3600) // 60)
|
|
105
|
+
return f"{hours}h {mins}m"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_time_to_seconds(time_str):
|
|
109
|
+
time_str = time_str.strip()
|
|
110
|
+
|
|
111
|
+
# Already in seconds (numeric)
|
|
112
|
+
if re.match(r'^\d+(\.\d+)?$', time_str):
|
|
113
|
+
return float(time_str)
|
|
114
|
+
|
|
115
|
+
# HH:MM:SS or MM:SS format
|
|
116
|
+
if re.match(r'^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$', time_str):
|
|
117
|
+
parts = time_str.split(':')
|
|
118
|
+
if len(parts) == 2:
|
|
119
|
+
mins, secs = parts
|
|
120
|
+
return int(mins) * 60 + float(secs)
|
|
121
|
+
elif len(parts) == 3:
|
|
122
|
+
hours, mins, secs = parts
|
|
123
|
+
return int(hours) * 3600 + int(mins) * 60 + float(secs)
|
|
124
|
+
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate_time_input(time_str, max_duration=None, field_name="Time"):
|
|
129
|
+
seconds = parse_time_to_seconds(time_str)
|
|
130
|
+
|
|
131
|
+
if seconds is None:
|
|
132
|
+
console.print(f"[bold red]Invalid {field_name.lower()} format. Use HH:MM:SS, MM:SS, or seconds.[/bold red]")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if seconds < 0:
|
|
136
|
+
console.print(f"[bold red]{field_name} cannot be negative.[/bold red]")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
if max_duration and seconds > max_duration:
|
|
140
|
+
console.print(f"[bold red]{field_name} ({format_duration(seconds)}) exceeds video duration ({format_duration(max_duration)}).[/bold red]")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
return seconds
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_time_range(start_str, end_str, duration):
|
|
147
|
+
start = validate_time_input(start_str, duration, "Start time")
|
|
148
|
+
if start is None:
|
|
149
|
+
return None, None
|
|
150
|
+
|
|
151
|
+
end = validate_time_input(end_str, duration, "End time")
|
|
152
|
+
if end is None:
|
|
153
|
+
return None, None
|
|
154
|
+
|
|
155
|
+
if end <= start:
|
|
156
|
+
console.print("[bold red]End time must be greater than start time.[/bold red]")
|
|
157
|
+
return None, None
|
|
158
|
+
|
|
159
|
+
clip_duration = end - start
|
|
160
|
+
console.print(f"[dim]Clip duration: {format_duration(clip_duration)}[/dim]")
|
|
161
|
+
|
|
162
|
+
return start, end
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_has_video_stream(file_path):
|
|
166
|
+
try:
|
|
167
|
+
probe = ffmpeg.probe(file_path, select_streams='v')
|
|
168
|
+
return len(probe.get('streams', [])) > 0
|
|
169
|
+
except Exception:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_has_audio_stream(file_path):
|
|
174
|
+
try:
|
|
175
|
+
probe = ffmpeg.probe(file_path, select_streams='a')
|
|
176
|
+
return len(probe.get('streams', [])) > 0
|
|
177
|
+
except Exception:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def warn_long_operation(duration, threshold=300, operation="This operation"):
|
|
182
|
+
if duration > threshold:
|
|
183
|
+
console.print(f"[yellow]Note: {operation} may take a while for this {format_duration(duration)} video.[/yellow]")
|
|
184
|
+
if not questionary.confirm("Continue?", default=True).ask():
|
|
185
|
+
return False
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def warn_reencode(operation="This operation"):
|
|
190
|
+
console.print(f"[dim]{operation} requires re-encoding and may take a while...[/dim]")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def handle_keyboard_interrupt():
|
|
194
|
+
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def generate_output_path(input_path, suffix, new_extension=None):
|
|
198
|
+
p = Path(input_path)
|
|
199
|
+
ext = new_extension if new_extension else p.suffix
|
|
200
|
+
return str(p.with_name(f"{p.stem}_{suffix}{ext}"))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_file_size_mb(file_path):
|
|
204
|
+
try:
|
|
205
|
+
size = os.path.getsize(file_path)
|
|
206
|
+
return size / (1024 * 1024)
|
|
207
|
+
except Exception:
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_positive_integer(value, field_name="Value"):
|
|
212
|
+
try:
|
|
213
|
+
num = int(value)
|
|
214
|
+
if num <= 0 and num != -1: # -1 is valid for "auto" in some contexts
|
|
215
|
+
console.print(f"[bold red]{field_name} must be a positive number.[/bold red]")
|
|
216
|
+
return None
|
|
217
|
+
return num
|
|
218
|
+
except ValueError:
|
|
219
|
+
console.print(f"[bold red]{field_name} must be a valid number.[/bold red]")
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def confirm_operation(message, default=True):
|
|
224
|
+
return questionary.confirm(message, default=default).ask()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def press_continue():
|
|
228
|
+
questionary.press_any_key_to_continue().ask()
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: peg_this
|
|
3
|
+
Version: 4.1.0
|
|
4
|
+
Summary: A powerful and intuitive command-line video editor, built on FFmpeg.
|
|
5
|
+
Author-email: Hariharen S S <thisishariharen@gmail.com>
|
|
6
|
+
Maintainer-email: Hariharen S S <thisishariharen@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/hariharen9/ffmpeg-this
|
|
9
|
+
Project-URL: Documentation, https://github.com/hariharen9/ffmpeg-this/blob/main/README.md
|
|
10
|
+
Project-URL: Repository, https://github.com/hariharen9/ffmpeg-this
|
|
11
|
+
Project-URL: Changelog, https://github.com/hariharen9/ffmpeg-this/releases
|
|
12
|
+
Project-URL: Bug Tracker, https://github.com/hariharen9/ffmpeg-this/issues
|
|
13
|
+
Project-URL: Funding, https://www.buymeacoffee.com/hariharen
|
|
14
|
+
Project-URL: Sponsor, https://github.com/sponsors/hariharen9
|
|
15
|
+
Keywords: ffmpeg,video,audio,media,converter,editor,cli,subtitles,whisper,transcription,trim,crop,compress,gif,batch
|
|
16
|
+
Classifier: Development Status :: 4 - Beta
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
27
|
+
Classifier: Operating System :: OS Independent
|
|
28
|
+
Classifier: Operating System :: MacOS
|
|
29
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
30
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
31
|
+
Classifier: Topic :: Multimedia
|
|
32
|
+
Classifier: Topic :: Multimedia :: Video
|
|
33
|
+
Classifier: Topic :: Multimedia :: Video :: Conversion
|
|
34
|
+
Classifier: Topic :: Multimedia :: Video :: Non-Linear Editor
|
|
35
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
36
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
|
|
37
|
+
Classifier: Topic :: Utilities
|
|
38
|
+
Requires-Python: >=3.8
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: ffmpeg-python==0.2.0
|
|
42
|
+
Requires-Dist: questionary>=2.0.0
|
|
43
|
+
Requires-Dist: rich>=13.0.0
|
|
44
|
+
Requires-Dist: Pillow>=9.0.0
|
|
45
|
+
Requires-Dist: faster-whisper>=1.0.0
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
48
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
49
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
50
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
<h1 align="center">FFm<u><i>PEG</i></u>-this</h1>
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<a href="https://pypi.org/project/peg-this/">
|
|
57
|
+
<img src="https://img.shields.io/pypi/v/peg_this?color=blue&label=version" alt="PyPI Version">
|
|
58
|
+
</a>
|
|
59
|
+
<a href="https://pypi.org/project/peg-this/">
|
|
60
|
+
<img src="https://img.shields.io/pypi/pyversions/peg_this.svg" alt="PyPI Python Versions">
|
|
61
|
+
</a>
|
|
62
|
+
<a href="https://github.com/hariharen9/ffmpeg-this/blob/main/LICENSE">
|
|
63
|
+
<img src="https://img.shields.io/github/license/hariharen9/ffmpeg-this" alt="License">
|
|
64
|
+
</a>
|
|
65
|
+
<a href="https://pepy.tech/project/peg-this">
|
|
66
|
+
<img src="https://static.pepy.tech/badge/peg-this" alt="Downloads">
|
|
67
|
+
</a>
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
<p align="center"><b>Your Editor within CLI</b></p>
|
|
71
|
+
|
|
72
|
+
A powerful and user-friendly Python CLI tool for converting, manipulating, and inspecting media files using the power of FFmpeg. This tool provides a simple command-line menu to perform common audio and video tasks without needing to memorize complex FFmpeg commands.
|
|
73
|
+
|
|
74
|
+
<p align="center">
|
|
75
|
+
<img src="/assets/peg.gif" width="720">
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
## Features at a Glance
|
|
79
|
+
|
|
80
|
+
| Category | Feature | Description |
|
|
81
|
+
|----------|---------|-------------|
|
|
82
|
+
| **Inspect** | Media Properties | View detailed codec, resolution, frame rate, bitrate, and stream information |
|
|
83
|
+
| **Convert** | Video Formats | Convert to MP4, MKV, MOV, AVI, WebM with quality presets (CRF 18/23/28) |
|
|
84
|
+
| | Audio Formats | Convert to MP3 (128k-320k bitrate), FLAC, WAV |
|
|
85
|
+
| | GIF Creation | Convert video clips to animated GIFs with optimized palette |
|
|
86
|
+
| | Image Formats | Convert between JPG, PNG, WebP, BMP, TIFF with quality control |
|
|
87
|
+
| **Subtitles** | AI Transcription | Generate subtitles using Whisper AI (7 model sizes available) |
|
|
88
|
+
| | Sidecar Export | Save as `.srt`, `.vtt`, `.txt`, or `.lrc` files |
|
|
89
|
+
| | Soft Subtitles | Embed toggleable subtitle track into video |
|
|
90
|
+
| | Hard Subtitles | Burn permanent subtitles directly into video |
|
|
91
|
+
| | Multi-language | Support for 99+ languages with auto-detection |
|
|
92
|
+
| **Edit** | Trim/Cut | Extract video segments by start/end time (lossless, no re-encoding) |
|
|
93
|
+
| | Visual Crop | Interactive GUI to select crop area on video/image |
|
|
94
|
+
| | Join/Concatenate | Merge multiple videos with automatic resolution matching |
|
|
95
|
+
| **Audio** | Extract Audio | Rip audio track to MP3, FLAC, or WAV |
|
|
96
|
+
| | Remove Audio | Create silent version of video (keeps video intact) |
|
|
97
|
+
| **Image** | Resize | Scale images with aspect ratio preservation |
|
|
98
|
+
| | Rotate | Rotate 90°, 180°, or 270° |
|
|
99
|
+
| | Flip | Flip horizontally or vertically |
|
|
100
|
+
| | Crop | Visual cropping with click-and-drag selection |
|
|
101
|
+
| **Batch** | Batch Convert | Convert all media files in directory at once |
|
|
102
|
+
|
|
103
|
+
## Detailed Feature Breakdown
|
|
104
|
+
|
|
105
|
+
### Video Operations
|
|
106
|
+
|
|
107
|
+
| Operation | Input | Output | Method | Re-encoding |
|
|
108
|
+
|-----------|-------|--------|--------|-------------|
|
|
109
|
+
| **Convert** | Any video | MP4, MKV, MOV, AVI, WebM | FFmpeg transcode | Yes (CRF quality) |
|
|
110
|
+
| **Trim** | Any video | Same format | Stream copy | No (lossless) |
|
|
111
|
+
| **Crop** | Any video | Same format | Visual selection + crop filter | Yes |
|
|
112
|
+
| **Join** | Multiple videos | Single MP4 | Concat filter + normalize | Yes |
|
|
113
|
+
| **To GIF** | Any video | Animated GIF | 2-pass palette optimization | Yes |
|
|
114
|
+
|
|
115
|
+
### Audio Operations
|
|
116
|
+
|
|
117
|
+
| Operation | Input | Output | Notes |
|
|
118
|
+
|-----------|-------|--------|-------|
|
|
119
|
+
| **Extract** | Video with audio | MP3, FLAC, WAV | Preserves original quality for FLAC/WAV |
|
|
120
|
+
| **Remove** | Video with audio | Silent video | Stream copy (fast, no re-encoding) |
|
|
121
|
+
| **Convert** | Audio file | MP3, FLAC, WAV | Bitrate selection for MP3 |
|
|
122
|
+
|
|
123
|
+
### Subtitle Generation
|
|
124
|
+
|
|
125
|
+
| Model | Size | Speed | Accuracy | Languages |
|
|
126
|
+
|-------|------|-------|----------|-----------|
|
|
127
|
+
| `tiny.en` | ~75 MB | Fastest | Good | English only |
|
|
128
|
+
| `base.en` | ~150 MB | Fast | Better | English only |
|
|
129
|
+
| `small.en` | ~500 MB | Balanced | Great | English only |
|
|
130
|
+
| `medium.en` | ~1.5 GB | Slower | Excellent | English only |
|
|
131
|
+
| `small` | ~500 MB | Balanced | Great | 99+ languages |
|
|
132
|
+
| `medium` | ~1.5 GB | Slower | Excellent | 99+ languages |
|
|
133
|
+
| `large-v3` | ~3 GB | Slowest | Best | 99+ languages |
|
|
134
|
+
|
|
135
|
+
**Output Options:**
|
|
136
|
+
| Type | File Extension | Description |
|
|
137
|
+
|------|----------------|-------------|
|
|
138
|
+
| Sidecar | `.srt` | SubRip - most compatible format |
|
|
139
|
+
| Sidecar | `.vtt` | WebVTT - for web/HTML5 players |
|
|
140
|
+
| Sidecar | `.txt` | Plain text transcript |
|
|
141
|
+
| Sidecar | `.lrc` | Lyrics format with timestamps |
|
|
142
|
+
| Soft Subs | `.mp4/.mkv` | Embedded, toggleable in players |
|
|
143
|
+
| Hard Subs | `.mp4/.mkv` | Burned in, always visible |
|
|
144
|
+
|
|
145
|
+
### Image Operations
|
|
146
|
+
|
|
147
|
+
| Operation | Options | Notes |
|
|
148
|
+
|-----------|---------|-------|
|
|
149
|
+
| **Convert** | JPG, PNG, WebP, BMP, TIFF | Quality presets (95%, 80%, 60%) |
|
|
150
|
+
| **Resize** | Custom width/height | Use `-1` to preserve aspect ratio |
|
|
151
|
+
| **Rotate** | 90° CW, 90° CCW, 180° | Lossless rotation |
|
|
152
|
+
| **Flip** | Horizontal, Vertical | Mirror image |
|
|
153
|
+
| **Crop** | Visual selection | Interactive GUI with preview |
|
|
154
|
+
|
|
155
|
+
### Supported Formats
|
|
156
|
+
|
|
157
|
+
| Type | Supported Formats |
|
|
158
|
+
|------|-------------------|
|
|
159
|
+
| **Video Input** | `.mp4`, `.mkv`, `.avi`, `.mov`, `.webm`, `.flv`, `.wmv`, `.gif` |
|
|
160
|
+
| **Video Output** | `.mp4`, `.mkv`, `.mov`, `.avi`, `.webm`, `.gif` |
|
|
161
|
+
| **Audio Input** | `.mp3`, `.flac`, `.wav`, `.ogg`, `.aac`, `.m4a` |
|
|
162
|
+
| **Audio Output** | `.mp3`, `.flac`, `.wav` |
|
|
163
|
+
| **Image Input** | `.jpg`, `.jpeg`, `.png`, `.webp`, `.bmp`, `.tiff` |
|
|
164
|
+
| **Image Output** | `.jpg`, `.png`, `.webp`, `.bmp`, `.tiff` |
|
|
165
|
+
| **Subtitle Output** | `.srt`, `.vtt`, `.txt`, `.lrc` |
|
|
166
|
+
|
|
167
|
+
## Usage
|
|
168
|
+
|
|
169
|
+
### Prerequisite: Install FFmpeg
|
|
170
|
+
|
|
171
|
+
> [!NOTE]
|
|
172
|
+
> `peg_this` uses a library called `ffmpeg-python` which acts as a controller for the main FFmpeg program. It does not include FFmpeg itself. Therefore, you must have FFmpeg installed on your system and available in your terminal's PATH.
|
|
173
|
+
|
|
174
|
+
For **macOS** users, the easiest way to install it is with [Homebrew](https://brew.sh/):
|
|
175
|
+
```bash
|
|
176
|
+
brew install ffmpeg
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
For **Windows** users, you can use a package manager like [Chocolatey](https://chocolatey.org/) or [Scoop](https://scoop.sh/):
|
|
180
|
+
```bash
|
|
181
|
+
# Using Chocolatey
|
|
182
|
+
choco install ffmpeg
|
|
183
|
+
|
|
184
|
+
# Using Scoop
|
|
185
|
+
scoop install ffmpeg
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For other systems, please see the official download page: **[ffmpeg.org/download.html](https://ffmpeg.org/download.html)**
|
|
189
|
+
|
|
190
|
+
There are three ways to use `peg_this`:
|
|
191
|
+
|
|
192
|
+
### 1. Pip Install (Recommended)
|
|
193
|
+
This is the easiest way to get started. This will install the tool and all its dependencies.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
pip install peg_this
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Once installed, you can run the tool from your terminal:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
peg_this
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 2. Download from Release
|
|
206
|
+
If you prefer not to install the package, you can download a pre-built executable from the [Releases](https://github.com/hariharen9/ffmpeg-this/releases/latest) page.
|
|
207
|
+
|
|
208
|
+
1. Download the executable for your operating system (Windows, macOS, or Linux).
|
|
209
|
+
2. Place it in a directory with your media files.
|
|
210
|
+
3. Run the executable directly from your terminal.
|
|
211
|
+
|
|
212
|
+
### 3. Run from Source
|
|
213
|
+
If you want to run the tool directly from the source code:
|
|
214
|
+
|
|
215
|
+
1. **Clone the repository:**
|
|
216
|
+
```bash
|
|
217
|
+
git clone https://github.com/hariharen9/ffmpeg-this.git
|
|
218
|
+
cd ffmpeg-this
|
|
219
|
+
```
|
|
220
|
+
2. **Install dependencies:**
|
|
221
|
+
```bash
|
|
222
|
+
pip install -r requirements.txt
|
|
223
|
+
```
|
|
224
|
+
3. **Run the tool:**
|
|
225
|
+
```bash
|
|
226
|
+
python -m src.peg_this.peg_this
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Subtitle Generation
|
|
230
|
+
|
|
231
|
+
The subtitle feature uses [faster-whisper](https://github.com/SYSTRAN/faster-whisper), a fast and accurate speech-to-text engine powered by OpenAI's Whisper model.
|
|
232
|
+
|
|
233
|
+
### How it works
|
|
234
|
+
|
|
235
|
+
1. Select a video file
|
|
236
|
+
2. Choose "Generate Subtitles (Whisper)"
|
|
237
|
+
3. Pick a model size (tiny to large-v3)
|
|
238
|
+
4. Select processing mode (Fast or Accurate)
|
|
239
|
+
5. Choose output type:
|
|
240
|
+
- **Sidecar file**: Export as `.srt`, `.vtt`, `.txt`, or `.lrc`
|
|
241
|
+
- **Soft subtitles**: Embed into video (can be toggled on/off in players)
|
|
242
|
+
- **Hard subtitles**: Burn into video (permanent, always visible)
|
|
243
|
+
|
|
244
|
+
### Supported Languages
|
|
245
|
+
|
|
246
|
+
Using multilingual models (`small`, `medium`, `large-v3`), you can transcribe audio in 99+ languages including English, Spanish, French, German, Chinese, Japanese, Korean, Hindi, Arabic, and many more.
|
|
247
|
+
|
|
248
|
+
## Star History
|
|
249
|
+
|
|
250
|
+
<p align="center">
|
|
251
|
+
<a href="https://star-history.com/#hariharen9/ffmpeg-this&Date">
|
|
252
|
+
<img src="https://api.star-history.com/svg?repos=hariharen9/ffmpeg-this&type=Date" alt="Star History Chart">
|
|
253
|
+
</a>
|
|
254
|
+
</p>
|
|
255
|
+
|
|
256
|
+
## Sponsor
|
|
257
|
+
|
|
258
|
+
<p align="center">
|
|
259
|
+
<a href="https://github.com/sponsors/hariharen9">
|
|
260
|
+
<img src="https://img.shields.io/github/sponsors/hariharen9?style=for-the-badge&logo=github&color=white" alt="GitHub Sponsors">
|
|
261
|
+
</a>
|
|
262
|
+
<a href="https://www.buymeacoffee.com/hariharen">
|
|
263
|
+
<img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me a Coffee">
|
|
264
|
+
</a>
|
|
265
|
+
</p>
|
|
266
|
+
|
|
267
|
+
## Contributors
|
|
268
|
+
|
|
269
|
+
<a href="https://github.com/hariharen9/ffmpeg-this/graphs/contributors">
|
|
270
|
+
<img src="https://contrib.rocks/image?repo=hariharen9/ffmpeg-this" />
|
|
271
|
+
</a>
|
|
272
|
+
|
|
273
|
+
## Contributing
|
|
274
|
+
|
|
275
|
+
Contributions are welcome! Please see the [Contributing Guidelines](CONTRIBUTING.md) for more information.
|
|
276
|
+
|
|
277
|
+
## License
|
|
278
|
+
|
|
279
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
280
|
+
|
|
281
|
+
<p align="center">
|
|
282
|
+
Made with ❤️ by <a href="https://hariharen.site">Hariharen</a>
|
|
283
|
+
</p>
|