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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import ffmpeg
|
|
5
|
+
import questionary
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from peg_this.utils.ffmpeg_utils import run_command, has_audio_stream
|
|
9
|
+
from peg_this.utils.validation import (
|
|
10
|
+
validate_input_file, check_output_file, warn_reencode, press_continue
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import tkinter as tk
|
|
15
|
+
from tkinter import messagebox
|
|
16
|
+
from PIL import Image, ImageTk
|
|
17
|
+
except ImportError:
|
|
18
|
+
tk = None
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def crop_video(file_path):
|
|
24
|
+
if not tk:
|
|
25
|
+
console.print("[bold red]Cannot perform visual cropping: tkinter & Pillow are not installed.[/bold red]")
|
|
26
|
+
console.print("[dim]Install them with: pip install tk Pillow[/dim]")
|
|
27
|
+
press_continue()
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if not validate_input_file(file_path):
|
|
31
|
+
press_continue()
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
preview_frame = f"preview_{Path(file_path).stem}.jpg"
|
|
35
|
+
try:
|
|
36
|
+
probe = ffmpeg.probe(file_path)
|
|
37
|
+
duration = float(probe['format'].get('duration', 0))
|
|
38
|
+
|
|
39
|
+
if duration <= 0:
|
|
40
|
+
console.print("[bold red]Error: Could not determine video duration.[/bold red]")
|
|
41
|
+
press_continue()
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
mid_point = duration / 2
|
|
45
|
+
|
|
46
|
+
run_command(
|
|
47
|
+
ffmpeg.input(file_path, ss=mid_point).output(preview_frame, vframes=1, **{'q:v': 2}).overwrite_output(),
|
|
48
|
+
"Extracting a frame for preview..."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if not os.path.exists(preview_frame):
|
|
52
|
+
console.print("[bold red]Could not extract a frame from the video.[/bold red]")
|
|
53
|
+
press_continue()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
root = tk.Tk()
|
|
57
|
+
root.title("Crop Video - Drag to select area, close window to confirm")
|
|
58
|
+
root.attributes("-topmost", True)
|
|
59
|
+
|
|
60
|
+
img = Image.open(preview_frame)
|
|
61
|
+
img_tk = ImageTk.PhotoImage(img)
|
|
62
|
+
|
|
63
|
+
canvas = tk.Canvas(root, width=img.width, height=img.height, cursor="cross")
|
|
64
|
+
canvas.pack()
|
|
65
|
+
canvas.create_image(0, 0, anchor=tk.NW, image=img_tk)
|
|
66
|
+
|
|
67
|
+
rect_coords = {"x1": 0, "y1": 0, "x2": 0, "y2": 0}
|
|
68
|
+
rect_id = None
|
|
69
|
+
|
|
70
|
+
def on_press(event):
|
|
71
|
+
nonlocal rect_id
|
|
72
|
+
rect_coords['x1'], rect_coords['y1'] = event.x, event.y
|
|
73
|
+
rect_id = canvas.create_rectangle(0, 0, 1, 1, outline='red', width=2)
|
|
74
|
+
|
|
75
|
+
def on_drag(event):
|
|
76
|
+
rect_coords['x2'], rect_coords['y2'] = event.x, event.y
|
|
77
|
+
canvas.coords(rect_id, rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2'])
|
|
78
|
+
|
|
79
|
+
canvas.bind("<ButtonPress-1>", on_press)
|
|
80
|
+
canvas.bind("<B1-Motion>", on_drag)
|
|
81
|
+
|
|
82
|
+
messagebox.showinfo("Instructions", "Click and drag to draw a cropping rectangle.\nClose this window when you are done.", parent=root)
|
|
83
|
+
root.mainloop()
|
|
84
|
+
|
|
85
|
+
crop_w = abs(rect_coords['x2'] - rect_coords['x1'])
|
|
86
|
+
crop_h = abs(rect_coords['y2'] - rect_coords['y1'])
|
|
87
|
+
crop_x = min(rect_coords['x1'], rect_coords['x2'])
|
|
88
|
+
crop_y = min(rect_coords['y1'], rect_coords['y2'])
|
|
89
|
+
|
|
90
|
+
if crop_w < 2 or crop_h < 2:
|
|
91
|
+
console.print("[bold yellow]Cropping cancelled as no valid area was selected.[/bold yellow]")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
console.print(f"Selected crop area: [bold]width={crop_w} height={crop_h} at (x={crop_x}, y={crop_y})[/bold]")
|
|
95
|
+
|
|
96
|
+
output_file = f"{Path(file_path).stem}_cropped{Path(file_path).suffix}"
|
|
97
|
+
action_result, final_output = check_output_file(output_file, "Video file")
|
|
98
|
+
|
|
99
|
+
if action_result == 'cancel':
|
|
100
|
+
console.print("[yellow]Operation cancelled.[/yellow]")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
warn_reencode("Video cropping")
|
|
104
|
+
|
|
105
|
+
input_stream = ffmpeg.input(file_path)
|
|
106
|
+
video_stream = input_stream.video.filter('crop', w=crop_w, h=crop_h, x=crop_x, y=crop_y)
|
|
107
|
+
|
|
108
|
+
if has_audio_stream(file_path):
|
|
109
|
+
audio_stream = input_stream.audio
|
|
110
|
+
stream = ffmpeg.output(video_stream, audio_stream, final_output, **{'c:a': 'copy'})
|
|
111
|
+
else:
|
|
112
|
+
stream = ffmpeg.output(video_stream, final_output)
|
|
113
|
+
|
|
114
|
+
if action_result == 'overwrite':
|
|
115
|
+
stream = stream.overwrite_output()
|
|
116
|
+
|
|
117
|
+
if run_command(stream, "Applying crop to video...", show_progress=True):
|
|
118
|
+
console.print(f"[bold green]Successfully cropped video and saved to {final_output}[/bold green]")
|
|
119
|
+
else:
|
|
120
|
+
console.print("[bold red]Video cropping failed.[/bold red]")
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
console.print(f"[bold red]An error occurred: {e}[/bold red]")
|
|
124
|
+
finally:
|
|
125
|
+
if os.path.exists(preview_frame):
|
|
126
|
+
os.remove(preview_frame)
|
|
127
|
+
press_continue()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def crop_image(file_path):
|
|
131
|
+
if not tk:
|
|
132
|
+
console.print("[bold red]Cannot perform visual cropping: tkinter & Pillow are not installed.[/bold red]")
|
|
133
|
+
console.print("[dim]Install them with: pip install tk Pillow[/dim]")
|
|
134
|
+
press_continue()
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if not validate_input_file(file_path):
|
|
138
|
+
press_continue()
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
root = tk.Tk()
|
|
143
|
+
root.title("Crop Image - Drag to select area, close window to confirm")
|
|
144
|
+
root.attributes("-topmost", True)
|
|
145
|
+
|
|
146
|
+
img = Image.open(file_path)
|
|
147
|
+
|
|
148
|
+
max_width = root.winfo_screenwidth() - 100
|
|
149
|
+
max_height = root.winfo_screenheight() - 100
|
|
150
|
+
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
|
151
|
+
|
|
152
|
+
img_tk = ImageTk.PhotoImage(img)
|
|
153
|
+
|
|
154
|
+
canvas = tk.Canvas(root, width=img.width, height=img.height, cursor="cross")
|
|
155
|
+
canvas.pack()
|
|
156
|
+
canvas.create_image(0, 0, anchor=tk.NW, image=img_tk)
|
|
157
|
+
|
|
158
|
+
rect_coords = {"x1": 0, "y1": 0, "x2": 0, "y2": 0}
|
|
159
|
+
rect_id = None
|
|
160
|
+
|
|
161
|
+
def on_press(event):
|
|
162
|
+
nonlocal rect_id
|
|
163
|
+
rect_coords['x1'], rect_coords['y1'] = event.x, event.y
|
|
164
|
+
rect_id = canvas.create_rectangle(0, 0, 1, 1, outline='red', width=2)
|
|
165
|
+
|
|
166
|
+
def on_drag(event):
|
|
167
|
+
rect_coords['x2'], rect_coords['y2'] = event.x, event.y
|
|
168
|
+
canvas.coords(rect_id, rect_coords['x1'], rect_coords['y1'], rect_coords['x2'], rect_coords['y2'])
|
|
169
|
+
|
|
170
|
+
canvas.bind("<ButtonPress-1>", on_press)
|
|
171
|
+
canvas.bind("<B1-Motion>", on_drag)
|
|
172
|
+
|
|
173
|
+
messagebox.showinfo("Instructions", "Click and drag to draw a cropping rectangle.\nClose this window when you are done.", parent=root)
|
|
174
|
+
root.mainloop()
|
|
175
|
+
|
|
176
|
+
crop_w = abs(rect_coords['x2'] - rect_coords['x1'])
|
|
177
|
+
crop_h = abs(rect_coords['y2'] - rect_coords['y1'])
|
|
178
|
+
crop_x = min(rect_coords['x1'], rect_coords['x2'])
|
|
179
|
+
crop_y = min(rect_coords['y1'], rect_coords['y2'])
|
|
180
|
+
|
|
181
|
+
if crop_w < 2 or crop_h < 2:
|
|
182
|
+
console.print("[bold yellow]Cropping cancelled as no valid area was selected.[/bold yellow]")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
console.print(f"Selected crop area: [bold]width={crop_w} height={crop_h} at (x={crop_x}, y={crop_y})[/bold]")
|
|
186
|
+
|
|
187
|
+
output_file = f"{Path(file_path).stem}_cropped{Path(file_path).suffix}"
|
|
188
|
+
action_result, final_output = check_output_file(output_file, "Image file")
|
|
189
|
+
|
|
190
|
+
if action_result == 'cancel':
|
|
191
|
+
console.print("[yellow]Operation cancelled.[/yellow]")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
stream = ffmpeg.input(file_path).filter('crop', w=crop_w, h=crop_h, x=crop_x, y=crop_y).output(final_output)
|
|
195
|
+
|
|
196
|
+
if action_result == 'overwrite':
|
|
197
|
+
stream = stream.overwrite_output()
|
|
198
|
+
|
|
199
|
+
if run_command(stream, "Applying crop to image..."):
|
|
200
|
+
console.print(f"[bold green]Successfully cropped image and saved to {final_output}[/bold green]")
|
|
201
|
+
else:
|
|
202
|
+
console.print("[bold red]Image cropping failed.[/bold red]")
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
console.print(f"[bold red]An error occurred during cropping: {e}[/bold red]")
|
|
206
|
+
finally:
|
|
207
|
+
press_continue()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import ffmpeg
|
|
5
|
+
import questionary
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from peg_this.utils.validation import validate_input_file, press_continue
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def inspect_file(file_path):
|
|
15
|
+
if not validate_input_file(file_path):
|
|
16
|
+
press_continue()
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
console.print(f"Inspecting [bold]{os.path.basename(file_path)}[/bold]...")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
info = ffmpeg.probe(file_path)
|
|
23
|
+
except ffmpeg.Error as e:
|
|
24
|
+
error_msg = e.stderr.decode('utf-8') if e.stderr else "Unknown error"
|
|
25
|
+
console.print("[bold red]An error occurred while inspecting the file:[/bold red]")
|
|
26
|
+
console.print(f"[dim]{error_msg}[/dim]")
|
|
27
|
+
logging.error(f"ffprobe error: {error_msg}")
|
|
28
|
+
press_continue()
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
format_info = info.get('format', {})
|
|
32
|
+
|
|
33
|
+
if not format_info:
|
|
34
|
+
console.print("[bold red]Error: Could not read file format information.[/bold red]")
|
|
35
|
+
press_continue()
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
table = Table(title=f"File Information: {os.path.basename(file_path)}", show_header=True, header_style="bold magenta")
|
|
39
|
+
table.add_column("Property", style="dim")
|
|
40
|
+
table.add_column("Value")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
size_mb = float(format_info.get('size', 0)) / (1024 * 1024)
|
|
44
|
+
duration_sec = float(format_info.get('duration', 0))
|
|
45
|
+
bit_rate_kbps = float(format_info.get('bit_rate', 0)) / 1000
|
|
46
|
+
except (ValueError, TypeError):
|
|
47
|
+
size_mb = 0
|
|
48
|
+
duration_sec = 0
|
|
49
|
+
bit_rate_kbps = 0
|
|
50
|
+
|
|
51
|
+
table.add_row("Size", f"{size_mb:.2f} MB")
|
|
52
|
+
table.add_row("Duration", f"{duration_sec:.2f} seconds" if duration_sec > 0 else "N/A")
|
|
53
|
+
table.add_row("Format", format_info.get('format_long_name', 'N/A'))
|
|
54
|
+
table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s" if bit_rate_kbps > 0 else "N/A")
|
|
55
|
+
console.print(table)
|
|
56
|
+
|
|
57
|
+
streams = info.get('streams', [])
|
|
58
|
+
|
|
59
|
+
for stream_type in ['video', 'audio', 'subtitle']:
|
|
60
|
+
type_streams = [s for s in streams if s.get('codec_type') == stream_type]
|
|
61
|
+
if type_streams:
|
|
62
|
+
if stream_type == 'video':
|
|
63
|
+
color = 'cyan'
|
|
64
|
+
elif stream_type == 'audio':
|
|
65
|
+
color = 'green'
|
|
66
|
+
else:
|
|
67
|
+
color = 'yellow'
|
|
68
|
+
|
|
69
|
+
stream_table = Table(title=f"{stream_type.capitalize()} Streams", show_header=True, header_style=f"bold {color}")
|
|
70
|
+
stream_table.add_column("Stream")
|
|
71
|
+
stream_table.add_column("Codec")
|
|
72
|
+
|
|
73
|
+
if stream_type == 'video':
|
|
74
|
+
stream_table.add_column("Resolution")
|
|
75
|
+
stream_table.add_column("Frame Rate")
|
|
76
|
+
elif stream_type == 'audio':
|
|
77
|
+
stream_table.add_column("Sample Rate")
|
|
78
|
+
stream_table.add_column("Channels")
|
|
79
|
+
else:
|
|
80
|
+
stream_table.add_column("Language")
|
|
81
|
+
|
|
82
|
+
for s in type_streams:
|
|
83
|
+
if stream_type == 'video':
|
|
84
|
+
width = s.get('width', '?')
|
|
85
|
+
height = s.get('height', '?')
|
|
86
|
+
stream_table.add_row(
|
|
87
|
+
f"#{s.get('index')}",
|
|
88
|
+
s.get('codec_name', 'N/A'),
|
|
89
|
+
f"{width}x{height}",
|
|
90
|
+
s.get('r_frame_rate', 'N/A')
|
|
91
|
+
)
|
|
92
|
+
elif stream_type == 'audio':
|
|
93
|
+
stream_table.add_row(
|
|
94
|
+
f"#{s.get('index')}",
|
|
95
|
+
s.get('codec_name', 'N/A'),
|
|
96
|
+
f"{s.get('sample_rate', 'N/A')} Hz",
|
|
97
|
+
str(s.get('channels', 'N/A'))
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
tags = s.get('tags', {})
|
|
101
|
+
lang = tags.get('language', 'N/A')
|
|
102
|
+
stream_table.add_row(
|
|
103
|
+
f"#{s.get('index')}",
|
|
104
|
+
s.get('codec_name', 'N/A'),
|
|
105
|
+
lang
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
console.print(stream_table)
|
|
109
|
+
|
|
110
|
+
press_continue()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import ffmpeg
|
|
5
|
+
import questionary
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from peg_this.utils.ffmpeg_utils import run_command
|
|
9
|
+
from peg_this.utils.ui_utils import get_media_files
|
|
10
|
+
from peg_this.utils.validation import (
|
|
11
|
+
check_output_file, check_has_audio_stream, warn_reencode, press_continue
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def join_videos():
|
|
18
|
+
console.print("[bold cyan]Select videos to join (in order).[/bold cyan]")
|
|
19
|
+
|
|
20
|
+
media_files = get_media_files()
|
|
21
|
+
video_files = [f for f in media_files if Path(f).suffix.lower() in [".mp4", ".mkv", ".mov", ".avi", ".webm"]]
|
|
22
|
+
|
|
23
|
+
if len(video_files) < 2:
|
|
24
|
+
console.print("[bold yellow]Not enough video files in the directory to join (need at least 2).[/bold yellow]")
|
|
25
|
+
press_continue()
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
selected_videos = questionary.checkbox(
|
|
29
|
+
"Select at least two videos to join in order:",
|
|
30
|
+
choices=video_files
|
|
31
|
+
).ask()
|
|
32
|
+
|
|
33
|
+
if not selected_videos or len(selected_videos) < 2:
|
|
34
|
+
console.print("[bold yellow]Joining cancelled. At least two videos must be selected.[/bold yellow]")
|
|
35
|
+
press_continue()
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
console.print("\n[cyan]Videos will be joined in this order:[/cyan]")
|
|
39
|
+
for i, video in enumerate(selected_videos):
|
|
40
|
+
console.print(f" {i+1}. {video}")
|
|
41
|
+
|
|
42
|
+
output_file = questionary.text("Enter the output file name:", default="joined_video.mp4").ask()
|
|
43
|
+
if not output_file:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
action_result, final_output = check_output_file(output_file, "Video file")
|
|
47
|
+
if action_result == 'cancel':
|
|
48
|
+
console.print("[yellow]Operation cancelled.[/yellow]")
|
|
49
|
+
press_continue()
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
first_video_path = os.path.abspath(selected_videos[0])
|
|
54
|
+
probe = ffmpeg.probe(first_video_path)
|
|
55
|
+
video_info = next((s for s in probe['streams'] if s['codec_type'] == 'video'), None)
|
|
56
|
+
|
|
57
|
+
if not video_info:
|
|
58
|
+
console.print("[bold red]Error: First video has no video stream.[/bold red]")
|
|
59
|
+
press_continue()
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
target_width = video_info['width']
|
|
63
|
+
target_height = video_info['height']
|
|
64
|
+
target_sar = video_info.get('sample_aspect_ratio', '1:1')
|
|
65
|
+
|
|
66
|
+
# Check for audio stream (optional)
|
|
67
|
+
audio_info = next((s for s in probe['streams'] if s['codec_type'] == 'audio'), None)
|
|
68
|
+
has_audio = audio_info is not None
|
|
69
|
+
target_sample_rate = audio_info['sample_rate'] if audio_info else '44100'
|
|
70
|
+
|
|
71
|
+
except Exception as e:
|
|
72
|
+
console.print(f"[bold red]Could not probe first video: {e}[/bold red]")
|
|
73
|
+
press_continue()
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Check if all videos have audio (if first one does)
|
|
77
|
+
if has_audio:
|
|
78
|
+
for video in selected_videos[1:]:
|
|
79
|
+
if not check_has_audio_stream(os.path.abspath(video)):
|
|
80
|
+
console.print(f"[yellow]Warning: '{video}' has no audio stream.[/yellow]")
|
|
81
|
+
if not questionary.confirm("Continue anyway? (silent sections will be added)", default=True).ask():
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
console.print(f"\n[dim]Standardizing to: {target_width}x{target_height} @ {target_sample_rate} Hz[/dim]")
|
|
85
|
+
warn_reencode("Joining videos")
|
|
86
|
+
|
|
87
|
+
processed_streams = []
|
|
88
|
+
for video_file in selected_videos:
|
|
89
|
+
abs_path = os.path.abspath(video_file)
|
|
90
|
+
stream = ffmpeg.input(abs_path)
|
|
91
|
+
|
|
92
|
+
v = (
|
|
93
|
+
stream.video
|
|
94
|
+
.filter('scale', w=target_width, h=target_height, force_original_aspect_ratio='decrease')
|
|
95
|
+
.filter('pad', w=target_width, h=target_height, x='(ow-iw)/2', y='(oh-ih)/2')
|
|
96
|
+
.filter('setsar', sar=target_sar.replace(':', '/'))
|
|
97
|
+
.filter('setpts', 'PTS-STARTPTS')
|
|
98
|
+
)
|
|
99
|
+
processed_streams.append(v)
|
|
100
|
+
|
|
101
|
+
if has_audio:
|
|
102
|
+
if check_has_audio_stream(abs_path):
|
|
103
|
+
a = (
|
|
104
|
+
stream.audio
|
|
105
|
+
.filter('aresample', sample_rate=target_sample_rate)
|
|
106
|
+
.filter('asetpts', 'PTS-STARTPTS')
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
# Generate silent audio for videos without audio
|
|
110
|
+
a = ffmpeg.input('anullsrc', f='lavfi', t=1).filter('aresample', sample_rate=target_sample_rate)
|
|
111
|
+
processed_streams.append(a)
|
|
112
|
+
|
|
113
|
+
if has_audio:
|
|
114
|
+
joined = ffmpeg.concat(*processed_streams, v=1, a=1).node
|
|
115
|
+
output_stream = ffmpeg.output(
|
|
116
|
+
joined[0], joined[1], final_output,
|
|
117
|
+
**{'c:v': 'libx264', 'crf': 23, 'c:a': 'aac', 'b:a': '192k'}
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
joined = ffmpeg.concat(*processed_streams, v=1, a=0).node
|
|
121
|
+
output_stream = ffmpeg.output(
|
|
122
|
+
joined[0], final_output,
|
|
123
|
+
**{'c:v': 'libx264', 'crf': 23}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if action_result == 'overwrite':
|
|
127
|
+
output_stream = output_stream.overwrite_output()
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
if run_command(output_stream, "Joining and re-encoding videos...", show_progress=True):
|
|
131
|
+
console.print(f"[bold green]Successfully joined videos into {final_output}[/bold green]")
|
|
132
|
+
else:
|
|
133
|
+
console.print("[bold red]Failed to join videos.[/bold red]")
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
|
|
136
|
+
|
|
137
|
+
press_continue()
|