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.
@@ -0,0 +1,397 @@
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import ffmpeg
6
+ import questionary
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
9
+
10
+ from peg_this.utils.ffmpeg_utils import run_command, has_audio_stream
11
+ from peg_this.utils.validation import (
12
+ validate_input_file, check_output_file, check_disk_space,
13
+ get_video_duration, format_duration, press_continue
14
+ )
15
+
16
+ console = Console()
17
+
18
+
19
+ def check_existing_subtitles(file_path):
20
+ try:
21
+ probe = ffmpeg.probe(file_path)
22
+ subtitle_streams = [s for s in probe.get('streams', []) if s.get('codec_type') == 'subtitle']
23
+ return len(subtitle_streams) > 0, len(subtitle_streams)
24
+ except Exception:
25
+ return False, 0
26
+
27
+
28
+ def sanitize_path_for_filter(path):
29
+ path_str = str(path)
30
+ path_str = path_str.replace("\\", "/")
31
+ path_str = path_str.replace(":", "\\:")
32
+ path_str = path_str.replace("'", "\\'")
33
+ return path_str
34
+
35
+
36
+ def extract_audio_for_whisper(input_file, temp_dir):
37
+ temp_wav = os.path.join(temp_dir, "temp_audio.wav")
38
+ try:
39
+ console.print("[cyan]Extracting audio for analysis...[/cyan]")
40
+ (
41
+ ffmpeg
42
+ .input(input_file)
43
+ .output(temp_wav, ac=1, ar=16000, vn=None, loglevel="error")
44
+ .overwrite_output()
45
+ .run(capture_stdout=True, capture_stderr=True)
46
+ )
47
+ if not os.path.exists(temp_wav):
48
+ console.print("[bold red]Error: Failed to extract audio file.[/bold red]")
49
+ return None
50
+ if os.path.getsize(temp_wav) == 0:
51
+ console.print("[bold red]Error: Extracted audio is empty.[/bold red]")
52
+ return None
53
+ return temp_wav
54
+ except ffmpeg.Error as e:
55
+ error_msg = e.stderr.decode() if e.stderr else "Unknown error"
56
+ console.print(f"[bold red]Failed to extract audio: {error_msg}[/bold red]")
57
+ return None
58
+
59
+
60
+ def format_timestamp(seconds):
61
+ hours = int(seconds // 3600)
62
+ minutes = int((seconds % 3600) // 60)
63
+ secs = int(seconds % 60)
64
+ millis = int((seconds - int(seconds)) * 1000)
65
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
66
+
67
+
68
+ def format_timestamp_vtt(seconds):
69
+ hours = int(seconds // 3600)
70
+ minutes = int((seconds % 3600) // 60)
71
+ secs = int(seconds % 60)
72
+ millis = int((seconds - int(seconds)) * 1000)
73
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"
74
+
75
+
76
+ def segments_to_srt(segments):
77
+ srt_content = []
78
+ for i, segment in enumerate(segments, 1):
79
+ start = format_timestamp(segment.start)
80
+ end = format_timestamp(segment.end)
81
+ text = segment.text.strip()
82
+ srt_content.append(f"{i}\n{start} --> {end}\n{text}\n")
83
+ return "\n".join(srt_content)
84
+
85
+
86
+ def segments_to_vtt(segments):
87
+ vtt_content = ["WEBVTT\n"]
88
+ for segment in segments:
89
+ start = format_timestamp_vtt(segment.start)
90
+ end = format_timestamp_vtt(segment.end)
91
+ text = segment.text.strip()
92
+ vtt_content.append(f"{start} --> {end}\n{text}\n")
93
+ return "\n".join(vtt_content)
94
+
95
+
96
+ def segments_to_txt(segments):
97
+ return "\n".join(segment.text.strip() for segment in segments)
98
+
99
+
100
+ def segments_to_lrc(segments):
101
+ lrc_content = []
102
+ for segment in segments:
103
+ minutes = int(segment.start // 60)
104
+ seconds = segment.start % 60
105
+ text = segment.text.strip()
106
+ lrc_content.append(f"[{minutes:02d}:{seconds:05.2f}]{text}")
107
+ return "\n".join(lrc_content)
108
+
109
+
110
+ def generate_subtitles(file_path):
111
+ if not validate_input_file(file_path):
112
+ press_continue()
113
+ return
114
+
115
+ if not has_audio_stream(file_path):
116
+ console.print("[bold red]Error: File has no audio stream.[/bold red]")
117
+ console.print("[dim]Subtitles require audio to transcribe.[/dim]")
118
+ press_continue()
119
+ return
120
+
121
+ has_subs, sub_count = check_existing_subtitles(file_path)
122
+ if has_subs:
123
+ console.print(f"[yellow]Note: This video already has {sub_count} subtitle track(s) embedded.[/yellow]")
124
+ if not questionary.confirm("Continue generating new subtitles?", default=True).ask():
125
+ return
126
+
127
+ duration = get_video_duration(file_path)
128
+ if duration > 3600:
129
+ console.print(f"[yellow]Note: This is a long video ({format_duration(duration)}).[/yellow]")
130
+ console.print("[dim]Transcription may take a while. Consider using a smaller model for faster results.[/dim]")
131
+ if not questionary.confirm("Continue?", default=True).ask():
132
+ return
133
+
134
+ try:
135
+ from faster_whisper import WhisperModel
136
+ except ImportError:
137
+ console.print("[bold red]Error: faster-whisper is not installed.[/bold red]")
138
+ console.print("[yellow]Install it with: pip install faster-whisper[/yellow]")
139
+ press_continue()
140
+ return
141
+
142
+ console.print("\n[bold cyan]Subtitle Generation (Whisper AI)[/bold cyan]")
143
+ if duration > 0:
144
+ console.print(f"[dim]Video duration: {format_duration(duration)}[/dim]")
145
+
146
+ if duration > 1800:
147
+ default_model = "tiny.en (fastest, English only, ~75MB)"
148
+ console.print("[dim]Tip: For long videos, smaller models are recommended.[/dim]")
149
+ else:
150
+ default_model = "small.en (balanced, English only, ~500MB)"
151
+
152
+ model_choice = questionary.select(
153
+ "Select Whisper model:",
154
+ choices=[
155
+ "tiny.en (fastest, English only, ~75MB)",
156
+ "base.en (fast, English only, ~150MB)",
157
+ "small.en (balanced, English only, ~500MB)",
158
+ "medium.en (accurate, English only, ~1.5GB)",
159
+ "small (balanced, multilingual, ~500MB)",
160
+ "medium (accurate, multilingual, ~1.5GB)",
161
+ "large-v3 (best quality, multilingual, ~3GB)",
162
+ ],
163
+ default=default_model
164
+ ).ask()
165
+ if not model_choice:
166
+ return
167
+
168
+ model_name = model_choice.split(" ")[0]
169
+
170
+ language = "en"
171
+ if not model_name.endswith(".en"):
172
+ if questionary.confirm("Change language? (default: English)", default=False).ask():
173
+ console.print("\n[dim]Common codes: en (English), ta (Tamil), hi (Hindi), te (Telugu),")
174
+ console.print("ml (Malayalam), kn (Kannada), fr (French), de (German), es (Spanish), zh (Chinese)[/dim]")
175
+ console.print("[dim]Full list: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes[/dim]")
176
+ language = questionary.text(
177
+ "Enter language code (or 'auto' to detect automatically):",
178
+ default="en"
179
+ ).ask()
180
+ if not language:
181
+ return
182
+ if language == "auto":
183
+ language = None
184
+
185
+ processing_mode = questionary.select(
186
+ "Select processing mode:",
187
+ choices=[
188
+ "Fast (Recommended) - Optimized for speed, great accuracy",
189
+ "Accurate - Best quality, slower processing",
190
+ ],
191
+ default="Fast (Recommended) - Optimized for speed, great accuracy"
192
+ ).ask()
193
+ if not processing_mode:
194
+ return
195
+
196
+ compute_type = "int8" if "Fast" in processing_mode else "float32"
197
+
198
+ action = questionary.select(
199
+ "What do you want to do with the subtitles?",
200
+ choices=[
201
+ "Export as sidecar file (.srt/.vtt)",
202
+ "Embed into video (Soft Subtitles)",
203
+ "Burn into video (Hard Subtitles)"
204
+ ]
205
+ ).ask()
206
+ if not action:
207
+ return
208
+
209
+ output_format = "srt"
210
+ if "sidecar" in action:
211
+ output_format = questionary.select(
212
+ "Select format:",
213
+ choices=["srt", "vtt", "txt", "lrc"]
214
+ ).ask()
215
+ if not output_format:
216
+ return
217
+
218
+ input_p = Path(file_path)
219
+
220
+ if "sidecar" in action:
221
+ output_path = input_p.with_name(f"{input_p.stem}.{output_format}")
222
+ action_result, final_output_path = check_output_file(str(output_path), "Subtitle file")
223
+ elif "Embed" in action:
224
+ output_path = input_p.with_name(f"{input_p.stem}_softsub{input_p.suffix}")
225
+ action_result, final_output_path = check_output_file(str(output_path), "Video file")
226
+ elif "Burn" in action:
227
+ output_path = input_p.with_name(f"{input_p.stem}_hardsub{input_p.suffix}")
228
+ action_result, final_output_path = check_output_file(str(output_path), "Video file")
229
+ if action_result != 'cancel' and not check_disk_space(file_path, multiplier=2):
230
+ return
231
+ else:
232
+ action_result = 'proceed'
233
+ final_output_path = None
234
+
235
+ if action_result == 'cancel':
236
+ console.print("[yellow]Operation cancelled.[/yellow]")
237
+ press_continue()
238
+ return
239
+
240
+ crf = "23"
241
+ if "Burn" in action:
242
+ quality = questionary.select(
243
+ "Select Video Quality (CRF):",
244
+ choices=["High (18)", "Medium (23)", "Low (28)"],
245
+ default="Medium (23)"
246
+ ).ask()
247
+ if not quality:
248
+ return
249
+ crf = quality.split("(")[1].strip(")")
250
+
251
+ with tempfile.TemporaryDirectory() as temp_dir:
252
+ wav_path = extract_audio_for_whisper(file_path, temp_dir)
253
+ if not wav_path:
254
+ press_continue()
255
+ return
256
+
257
+ console.print(f"[cyan]Loading Whisper model '{model_name}'...[/cyan]")
258
+ console.print("[dim]First run will download the model (may take a few minutes)[/dim]")
259
+
260
+ try:
261
+ model = WhisperModel(model_name, device="cpu", compute_type=compute_type)
262
+ except Exception as e:
263
+ error_msg = str(e)
264
+ if "out of memory" in error_msg.lower():
265
+ console.print("[bold red]Error: Not enough memory to load model.[/bold red]")
266
+ console.print("[yellow]Try using a smaller model (tiny or base).[/yellow]")
267
+ elif "network" in error_msg.lower() or "connection" in error_msg.lower():
268
+ console.print("[bold red]Error: Failed to download model. Check your internet connection.[/bold red]")
269
+ else:
270
+ console.print(f"[bold red]Failed to load model: {e}[/bold red]")
271
+ press_continue()
272
+ return
273
+
274
+ console.print("[cyan]Transcribing audio...[/cyan]")
275
+
276
+ try:
277
+ with Progress(
278
+ SpinnerColumn(),
279
+ TextColumn("[progress.description]{task.description}"),
280
+ BarColumn(),
281
+ TimeElapsedColumn(),
282
+ console=console
283
+ ) as progress:
284
+ task = progress.add_task("Transcribing...", total=None)
285
+ segments_generator, info = model.transcribe(
286
+ wav_path,
287
+ language=language,
288
+ beam_size=5,
289
+ vad_filter=True,
290
+ vad_parameters=dict(min_silence_duration_ms=500)
291
+ )
292
+ segments = list(segments_generator)
293
+ progress.update(task, completed=100)
294
+
295
+ except KeyboardInterrupt:
296
+ console.print("\n[yellow]Transcription cancelled by user.[/yellow]")
297
+ press_continue()
298
+ return
299
+ except Exception as e:
300
+ error_msg = str(e)
301
+ if "out of memory" in error_msg.lower():
302
+ console.print("[bold red]Error: Ran out of memory during transcription.[/bold red]")
303
+ console.print("[yellow]Try using a smaller model or processing a shorter video.[/yellow]")
304
+ else:
305
+ console.print(f"[bold red]Transcription failed: {e}[/bold red]")
306
+ press_continue()
307
+ return
308
+
309
+ if not segments:
310
+ console.print("[bold yellow]No speech detected in audio.[/bold yellow]")
311
+ console.print("[dim]The video might be silent, have only music, or the audio quality is too low.[/dim]")
312
+ press_continue()
313
+ return
314
+
315
+ detected_lang = info.language if language is None else language
316
+ console.print(f"[green]Detected language: {detected_lang}[/green]")
317
+ console.print(f"[green]Transcribed {len(segments)} segments[/green]")
318
+
319
+ if output_format == "srt" or "Embed" in action or "Burn" in action:
320
+ subtitle_content = segments_to_srt(segments)
321
+ sub_ext = "srt"
322
+ elif output_format == "vtt":
323
+ subtitle_content = segments_to_vtt(segments)
324
+ sub_ext = "vtt"
325
+ elif output_format == "txt":
326
+ subtitle_content = segments_to_txt(segments)
327
+ sub_ext = "txt"
328
+ elif output_format == "lrc":
329
+ subtitle_content = segments_to_lrc(segments)
330
+ sub_ext = "lrc"
331
+ else:
332
+ subtitle_content = segments_to_srt(segments)
333
+ sub_ext = "srt"
334
+
335
+ if not subtitle_content.strip():
336
+ console.print("[bold yellow]Warning: Generated subtitles are empty.[/bold yellow]")
337
+ press_continue()
338
+ return
339
+
340
+ sub_temp_path = os.path.join(temp_dir, f"output.{sub_ext}")
341
+ try:
342
+ with open(sub_temp_path, "w", encoding="utf-8") as f:
343
+ f.write(subtitle_content)
344
+ except IOError as e:
345
+ console.print(f"[bold red]Error writing subtitle file: {e}[/bold red]")
346
+ press_continue()
347
+ return
348
+
349
+ try:
350
+ if "sidecar" in action:
351
+ with open(final_output_path, "w", encoding="utf-8") as f:
352
+ f.write(subtitle_content)
353
+ console.print(f"[bold green]Saved subtitles to: {final_output_path}[/bold green]")
354
+
355
+ elif "Embed" in action:
356
+ console.print("[cyan]Embedding subtitles (Soft Subs)...[/cyan]")
357
+ ext = input_p.suffix.lower()
358
+ scodec = "mov_text" if ext in ['.mp4', '.m4v', '.mov'] else "srt"
359
+ stream = ffmpeg.input(file_path)
360
+ sub_stream = ffmpeg.input(sub_temp_path)
361
+ out = ffmpeg.output(
362
+ stream, sub_stream, str(final_output_path),
363
+ c='copy', **{'c:s': scodec}, **{'metadata:s:s:0': f'language={detected_lang}'}
364
+ )
365
+ if action_result == 'overwrite':
366
+ out = out.overwrite_output()
367
+ if run_command(out, "Embedding subtitles...", show_progress=True):
368
+ console.print(f"[bold green]Created: {final_output_path}[/bold green]")
369
+ else:
370
+ console.print("[bold red]Failed to embed subtitles.[/bold red]")
371
+
372
+ elif "Burn" in action:
373
+ console.print("[cyan]Burning subtitles (Hard Subs)...[/cyan]")
374
+ console.print("[dim]This requires re-encoding and may take a while...[/dim]")
375
+ stream = ffmpeg.input(file_path)
376
+ video = stream.video.filter('subtitles', sub_temp_path)
377
+ audio = stream.audio
378
+ out = ffmpeg.output(
379
+ video, audio, str(final_output_path),
380
+ vcodec='libx264', acodec='copy', crf=crf, preset='fast'
381
+ )
382
+ if action_result == 'overwrite':
383
+ out = out.overwrite_output()
384
+ if run_command(out, "Burning subtitles (Re-encoding)...", show_progress=True):
385
+ console.print(f"[bold green]Created: {final_output_path}[/bold green]")
386
+ else:
387
+ console.print("[bold red]Failed to burn subtitles.[/bold red]")
388
+
389
+ except PermissionError:
390
+ console.print("[bold red]Error: Permission denied. Cannot write to output location.[/bold red]")
391
+ console.print("[dim]Try saving to a different location or check folder permissions.[/dim]")
392
+ except IOError as e:
393
+ console.print(f"[bold red]Error writing output: {e}[/bold red]")
394
+ except Exception as e:
395
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")
396
+
397
+ press_continue()
peg_this/features/trim.py CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  from pathlib import Path
3
2
 
4
3
  import ffmpeg
@@ -6,21 +5,52 @@ import questionary
6
5
  from rich.console import Console
7
6
 
8
7
  from peg_this.utils.ffmpeg_utils import run_command
8
+ from peg_this.utils.validation import (
9
+ validate_input_file, check_output_file, get_video_duration,
10
+ validate_time_range, format_duration, press_continue
11
+ )
9
12
 
10
13
  console = Console()
11
14
 
12
15
 
13
16
  def trim_video(file_path):
14
- """Cut a video by specifying start and end times."""
17
+ if not validate_input_file(file_path):
18
+ press_continue()
19
+ return
20
+
21
+ duration = get_video_duration(file_path)
22
+ if duration > 0:
23
+ console.print(f"[dim]Video duration: {format_duration(duration)}[/dim]")
24
+
15
25
  start_time = questionary.text("Enter start time (HH:MM:SS or seconds):").ask()
16
- if not start_time: return
26
+ if not start_time:
27
+ return
28
+
17
29
  end_time = questionary.text("Enter end time (HH:MM:SS or seconds):").ask()
18
- if not end_time: return
30
+ if not end_time:
31
+ return
32
+
33
+ start_secs, end_secs = validate_time_range(start_time, end_time, duration if duration > 0 else None)
34
+ if start_secs is None:
35
+ press_continue()
36
+ return
19
37
 
20
38
  output_file = f"{Path(file_path).stem}_trimmed{Path(file_path).suffix}"
21
-
22
- stream = ffmpeg.input(file_path, ss=start_time, to=end_time).output(output_file, c='copy', y=None)
23
-
24
- run_command(stream, "Trimming video...", show_progress=True)
25
- console.print(f"[bold green]Successfully trimmed to {output_file}[/bold green]")
26
- questionary.press_any_key_to_continue().ask()
39
+ action_result, final_output = check_output_file(output_file, "Video file")
40
+
41
+ if action_result == 'cancel':
42
+ console.print("[yellow]Operation cancelled.[/yellow]")
43
+ press_continue()
44
+ return
45
+
46
+ stream = ffmpeg.input(file_path, ss=start_secs, to=end_secs).output(final_output, c='copy')
47
+
48
+ if action_result == 'overwrite':
49
+ stream = stream.overwrite_output()
50
+
51
+ if run_command(stream, "Trimming video...", show_progress=True):
52
+ console.print(f"[bold green]Successfully trimmed to {final_output}[/bold green]")
53
+ else:
54
+ console.print("[bold red]Failed to trim video.[/bold red]")
55
+
56
+ press_continue()
peg_this/peg_this.py CHANGED
@@ -13,6 +13,7 @@ from peg_this.features.convert import convert_file, convert_image, resize_image,
13
13
  from peg_this.features.crop import crop_video, crop_image
14
14
  from peg_this.features.inspect import inspect_file
15
15
  from peg_this.features.join import join_videos
16
+ from peg_this.features.subtitle import generate_subtitles
16
17
  from peg_this.features.trim import trim_video
17
18
  from peg_this.utils.ffmpeg_utils import check_ffmpeg_ffprobe
18
19
  from peg_this.utils.ui_utils import select_media_file
@@ -77,6 +78,7 @@ def action_menu(file_path):
77
78
  choices=[
78
79
  "Inspect File Details",
79
80
  "Convert",
81
+ "Generate Subtitles (Whisper)",
80
82
  "Trim Video",
81
83
  "Crop Video (Visual)",
82
84
  "Extract Audio",
@@ -93,6 +95,7 @@ def action_menu(file_path):
93
95
  actions = {
94
96
  "Inspect File Details": inspect_file,
95
97
  "Convert": convert_file,
98
+ "Generate Subtitles (Whisper)": generate_subtitles,
96
99
  "Trim Video": trim_video,
97
100
  "Crop Video (Visual)": crop_video,
98
101
  "Extract Audio": extract_audio,
@@ -137,7 +140,7 @@ def main_menu():
137
140
 
138
141
 
139
142
  def main():
140
- """Main entry point for the application script."""
143
+ """Main entry point for the application."""
141
144
  try:
142
145
  main_menu()
143
146
  except (KeyboardInterrupt, EOFError):