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.
@@ -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()