peg-this 4.0.0__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/audio.py +63 -16
- peg_this/features/batch.py +105 -79
- peg_this/features/convert.py +179 -95
- peg_this/features/crop.py +63 -34
- peg_this/features/inspect.py +71 -21
- peg_this/features/join.py +85 -31
- peg_this/features/subtitle.py +397 -0
- peg_this/features/trim.py +40 -10
- peg_this/peg_this.py +4 -1
- 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-4.0.0.dist-info → peg_this-4.1.0.dist-info}/WHEEL +1 -1
- peg_this-4.0.0.dist-info/METADATA +0 -164
- peg_this-4.0.0.dist-info/RECORD +0 -19
- {peg_this-4.0.0.dist-info → peg_this-4.1.0.dist-info}/entry_points.txt +0 -0
- {peg_this-4.0.0.dist-info → peg_this-4.1.0.dist-info}/licenses/LICENSE +0 -0
- {peg_this-4.0.0.dist-info → peg_this-4.1.0.dist-info}/top_level.txt +0 -0
peg_this/features/inspect.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import os
|
|
3
2
|
import logging
|
|
4
3
|
|
|
@@ -7,54 +6,105 @@ import questionary
|
|
|
7
6
|
from rich.console import Console
|
|
8
7
|
from rich.table import Table
|
|
9
8
|
|
|
9
|
+
from peg_this.utils.validation import validate_input_file, press_continue
|
|
10
|
+
|
|
10
11
|
console = Console()
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def inspect_file(file_path):
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
|
|
16
21
|
try:
|
|
17
22
|
info = ffmpeg.probe(file_path)
|
|
18
23
|
except ffmpeg.Error as e:
|
|
24
|
+
error_msg = e.stderr.decode('utf-8') if e.stderr else "Unknown error"
|
|
19
25
|
console.print("[bold red]An error occurred while inspecting the file:[/bold red]")
|
|
20
|
-
console.print(
|
|
21
|
-
logging.error(f"ffprobe error:{
|
|
22
|
-
|
|
26
|
+
console.print(f"[dim]{error_msg}[/dim]")
|
|
27
|
+
logging.error(f"ffprobe error: {error_msg}")
|
|
28
|
+
press_continue()
|
|
23
29
|
return
|
|
24
30
|
|
|
25
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
|
+
|
|
26
38
|
table = Table(title=f"File Information: {os.path.basename(file_path)}", show_header=True, header_style="bold magenta")
|
|
27
39
|
table.add_column("Property", style="dim")
|
|
28
40
|
table.add_column("Value")
|
|
29
41
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
33
50
|
|
|
34
51
|
table.add_row("Size", f"{size_mb:.2f} MB")
|
|
35
|
-
table.add_row("Duration", f"{duration_sec:.2f} seconds")
|
|
52
|
+
table.add_row("Duration", f"{duration_sec:.2f} seconds" if duration_sec > 0 else "N/A")
|
|
36
53
|
table.add_row("Format", format_info.get('format_long_name', 'N/A'))
|
|
37
|
-
table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s")
|
|
54
|
+
table.add_row("Bitrate", f"{bit_rate_kbps:.0f} kb/s" if bit_rate_kbps > 0 else "N/A")
|
|
38
55
|
console.print(table)
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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}")
|
|
44
70
|
stream_table.add_column("Stream")
|
|
45
71
|
stream_table.add_column("Codec")
|
|
72
|
+
|
|
46
73
|
if stream_type == 'video':
|
|
47
74
|
stream_table.add_column("Resolution")
|
|
48
75
|
stream_table.add_column("Frame Rate")
|
|
49
|
-
|
|
76
|
+
elif stream_type == 'audio':
|
|
50
77
|
stream_table.add_column("Sample Rate")
|
|
51
78
|
stream_table.add_column("Channels")
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
else:
|
|
80
|
+
stream_table.add_column("Language")
|
|
81
|
+
|
|
82
|
+
for s in type_streams:
|
|
54
83
|
if stream_type == 'video':
|
|
55
|
-
|
|
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
|
+
)
|
|
56
99
|
else:
|
|
57
|
-
|
|
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
|
+
|
|
58
108
|
console.print(stream_table)
|
|
59
109
|
|
|
60
|
-
|
|
110
|
+
press_continue()
|
peg_this/features/join.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import os
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
|
|
@@ -8,76 +7,131 @@ from rich.console import Console
|
|
|
8
7
|
|
|
9
8
|
from peg_this.utils.ffmpeg_utils import run_command
|
|
10
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
|
+
)
|
|
11
13
|
|
|
12
14
|
console = Console()
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
def join_videos():
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
console.print("[bold cyan]Select videos to join (in order).[/bold cyan]")
|
|
19
|
+
|
|
19
20
|
media_files = get_media_files()
|
|
20
21
|
video_files = [f for f in media_files if Path(f).suffix.lower() in [".mp4", ".mkv", ".mov", ".avi", ".webm"]]
|
|
21
22
|
|
|
22
23
|
if len(video_files) < 2:
|
|
23
|
-
console.print("[bold yellow]Not enough video files in the directory to join.[/bold yellow]")
|
|
24
|
-
|
|
24
|
+
console.print("[bold yellow]Not enough video files in the directory to join (need at least 2).[/bold yellow]")
|
|
25
|
+
press_continue()
|
|
25
26
|
return
|
|
26
27
|
|
|
27
|
-
selected_videos = questionary.checkbox(
|
|
28
|
+
selected_videos = questionary.checkbox(
|
|
29
|
+
"Select at least two videos to join in order:",
|
|
30
|
+
choices=video_files
|
|
31
|
+
).ask()
|
|
28
32
|
|
|
29
33
|
if not selected_videos or len(selected_videos) < 2:
|
|
30
34
|
console.print("[bold yellow]Joining cancelled. At least two videos must be selected.[/bold yellow]")
|
|
35
|
+
press_continue()
|
|
31
36
|
return
|
|
32
37
|
|
|
33
|
-
console.print("Videos will be joined in this order:")
|
|
38
|
+
console.print("\n[cyan]Videos will be joined in this order:[/cyan]")
|
|
34
39
|
for i, video in enumerate(selected_videos):
|
|
35
40
|
console.print(f" {i+1}. {video}")
|
|
36
41
|
|
|
37
42
|
output_file = questionary.text("Enter the output file name:", default="joined_video.mp4").ask()
|
|
38
|
-
if not output_file:
|
|
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
|
|
39
51
|
|
|
40
52
|
try:
|
|
41
53
|
first_video_path = os.path.abspath(selected_videos[0])
|
|
42
54
|
probe = ffmpeg.probe(first_video_path)
|
|
43
|
-
video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
|
|
46
62
|
target_width = video_info['width']
|
|
47
63
|
target_height = video_info['height']
|
|
48
64
|
target_sar = video_info.get('sample_aspect_ratio', '1:1')
|
|
49
|
-
|
|
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'
|
|
50
70
|
|
|
51
71
|
except Exception as e:
|
|
52
|
-
console.print(f"[bold red]Could not probe first video
|
|
72
|
+
console.print(f"[bold red]Could not probe first video: {e}[/bold red]")
|
|
73
|
+
press_continue()
|
|
53
74
|
return
|
|
54
75
|
|
|
55
|
-
|
|
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")
|
|
56
86
|
|
|
57
87
|
processed_streams = []
|
|
58
88
|
for video_file in selected_videos:
|
|
59
|
-
|
|
89
|
+
abs_path = os.path.abspath(video_file)
|
|
90
|
+
stream = ffmpeg.input(abs_path)
|
|
91
|
+
|
|
60
92
|
v = (
|
|
61
93
|
stream.video
|
|
62
94
|
.filter('scale', w=target_width, h=target_height, force_original_aspect_ratio='decrease')
|
|
63
95
|
.filter('pad', w=target_width, h=target_height, x='(ow-iw)/2', y='(oh-ih)/2')
|
|
64
|
-
.filter('setsar', sar=target_sar.replace(':','/'))
|
|
96
|
+
.filter('setsar', sar=target_sar.replace(':', '/'))
|
|
65
97
|
.filter('setpts', 'PTS-STARTPTS')
|
|
66
98
|
)
|
|
67
|
-
a = (
|
|
68
|
-
stream.audio
|
|
69
|
-
.filter('aresample', sample_rate=target_sample_rate)
|
|
70
|
-
.filter('asetpts', 'PTS-STARTPTS')
|
|
71
|
-
)
|
|
72
99
|
processed_streams.append(v)
|
|
73
|
-
processed_streams.append(a)
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
)
|
|
80
119
|
else:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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()
|