videoconv 2.1__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.
MyVideoConverter.py ADDED
@@ -0,0 +1,893 @@
1
+ """
2
+ MyVideoConverter
3
+
4
+ Batch video converter and compressor built on top of ffmpeg. Supports
5
+ sequential and parallel (multiprocessing) execution, optional rich progress
6
+ bars, file-size and custom-predicate filtering, anime-tuned encoding, and
7
+ automatic timestamp preservation.
8
+
9
+ Public entry points:
10
+ main() — convert every matching file in a folder.
11
+ main_files() — convert an explicit list of files.
12
+ """
13
+
14
+ import multiprocessing
15
+ import re as regex
16
+ import shlex
17
+ import subprocess
18
+ import time
19
+ from collections.abc import Callable
20
+ from concurrent.futures import ProcessPoolExecutor
21
+ from datetime import datetime, timedelta
22
+ from multiprocessing import Pool
23
+ from typing import Optional
24
+
25
+ import filedate
26
+ from ext_pathlib import Path
27
+ from ffmpeg_progress_yield import FfmpegProgress
28
+ from ffprobe import ffprobe_info, get_info
29
+ from plyer import notification
30
+ from rich import progress
31
+ from rich.console import Console
32
+ from rich.rule import Rule
33
+ from rich.theme import Theme
34
+
35
+ console = Console()
36
+
37
+ VIDEO_EXTENSIONS: tuple[str, ...] = (".mp4", ".webm", ".gif", ".mov", ".m4v", ".mkv")
38
+
39
+
40
+ def is_even(num: int) -> bool:
41
+ """Return True if *num* is evenly divisible by 2."""
42
+ return num % 2 == 0
43
+
44
+
45
+ def pretty_print_timedelta(timedelta_obj: timedelta) -> str:
46
+ """
47
+ Convert a timedelta into a human-readable string such as "1 hour, 3 minutes, 5 seconds".
48
+
49
+ Only non-zero components are included. Seconds are always shown when all
50
+ other components are zero (e.g. a 0-second delta renders as "0 seconds").
51
+
52
+ Args:
53
+ timedelta_obj: The duration to format.
54
+
55
+ Returns:
56
+ A comma-separated string of hours, minutes, and/or seconds.
57
+ """
58
+
59
+ # Get the total number of hours, minutes, and seconds
60
+ hours = int(timedelta_obj.total_seconds() // 3600)
61
+ minutes = int((timedelta_obj.total_seconds() % 3600) // 60)
62
+ seconds = int(timedelta_obj.total_seconds() % 60)
63
+
64
+ values = []
65
+
66
+ if hours:
67
+ trailing = "s" if hours > 1 else ""
68
+ values.append(f"{hours} hour{trailing}")
69
+
70
+ if minutes:
71
+ trailing = "s" if minutes > 1 else ""
72
+ values.append(f"{minutes} minute{trailing}")
73
+
74
+ if seconds or len(values) == 0:
75
+ trailing = "s" if seconds > 1 or len(values) == 0 else ""
76
+ values.append(f"{seconds} second{trailing}")
77
+
78
+ return ", ".join(values)
79
+
80
+
81
+ def mkv_to_mp4(mkv_file: Path) -> Path | bool:
82
+ """
83
+ Remux an MKV file to MP4 using ffmpeg stream-copy (no re-encoding).
84
+
85
+ On success the source MKV is deleted and the new MP4 path is returned.
86
+ On failure an error message is printed and False is returned.
87
+
88
+ Args:
89
+ mkv_file: Path to the source MKV file.
90
+
91
+ Returns:
92
+ The Path of the newly created MP4 file, or False if ffmpeg failed.
93
+ """
94
+ mp4 = Path(mkv_file.parent.resolve(), f"{mkv_file.stem}.mp4")
95
+ cmd = f'ffmpeg -y -i "{mkv_file.resolve()}" -c copy "{mp4.resolve()}"'
96
+ sp = subprocess.run(
97
+ cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
98
+ )
99
+ if sp.returncode == 0:
100
+ mkv_file.unlink()
101
+ return mp4
102
+
103
+ console.print("[red]ffmpeg error occured converting MKV to MP4.[/]")
104
+ print(sp.stderr.decode("utf-8"))
105
+ return False
106
+
107
+
108
+ def video_conversion(
109
+ file: Path,
110
+ crf: int = 23,
111
+ resize: bool = False,
112
+ date_fix: bool = False,
113
+ anime: bool = False,
114
+ progress_dict: Optional[dict] = None,
115
+ task_id: Optional[int] = 0,
116
+ ) -> None:
117
+ """
118
+ Encode a single video file to H.264/MP4 via ffmpeg, then call mkv_to_mp4
119
+ to remux the intermediate MKV to the final MP4 container.
120
+
121
+ The output file is written next to the source as ``<stem>_conv.mp4``.
122
+ Audio and subtitle streams are copied without re-encoding. A scale filter
123
+ is inserted when the source has odd dimensions or is a GIF/WebM, and
124
+ optionally when the longest edge exceeds 1280 px.
125
+
126
+ When *progress_dict* and *task_id* are both provided the function streams
127
+ per-frame progress into the shared dict (used by the rich parallel pool).
128
+ Otherwise ffmpeg runs silently via subprocess and results are printed on
129
+ completion.
130
+
131
+ Args:
132
+ file: Path to the input video file.
133
+ crf: H.264 Constant Rate Factor — lower = higher quality, larger file.
134
+ Defaults to 23.
135
+ resize: If True and the longest edge exceeds 1280 px, downscale to 1280
136
+ on that axis while preserving aspect ratio. Defaults to False.
137
+ date_fix: If True, stamp the output file with the original source
138
+ file's created/modified/accessed times. Defaults to False.
139
+ anime: If True, apply ``-tune animation`` and ``-level:v 4`` for
140
+ better compression on animated content. Defaults to False.
141
+ progress_dict: Shared manager dict used to report progress back to the
142
+ rich progress bar in the parallel pool. Defaults to None.
143
+ task_id: Key under which this task writes into *progress_dict*.
144
+ Defaults to 0.
145
+ """
146
+ file_out: Path = Path(file.parent.resolve(), f"{file.stem}_conv.mkv")
147
+ if file_out.exists():
148
+ console.print("[yellow]Converted file already exists. Stopping.[/]")
149
+ return
150
+ info: ffprobe_info = get_info(file)
151
+
152
+ video_setting_line: str = (
153
+ f"-map 0:v -c:v libx264 -pix_fmt yuv420p -crf {crf} -preset slow"
154
+ )
155
+
156
+ if anime:
157
+ video_setting_line += " -level:v 4 -tune animation"
158
+ else:
159
+ video_setting_line += " -level:v 3.1"
160
+
161
+ cmd_list: list[str] = [
162
+ "ffmpeg -hide_banner -i",
163
+ f'"{file.resolve()}"',
164
+ video_setting_line,
165
+ "-map 0:a? -c:a copy",
166
+ f'"{file_out}"',
167
+ ]
168
+
169
+ # Resize logic
170
+ w: int = info.width
171
+ h: int = info.height
172
+ if max(w, h) > 1280:
173
+ if resize:
174
+ cmd_list.insert(
175
+ 4, "-vf \"scale='if(gt(iw,ih),1280,-2)':'if(gt(iw,ih),-2,1280)'\""
176
+ )
177
+ elif not all([is_even(w), is_even(h)]) or file.suffix in [".gif", ".webm"]:
178
+ cmd_list.insert(4, '-vf "scale=ceil(iw/2)*2:ceil(ih/2)*2"')
179
+
180
+ # Subtitle logic
181
+ if info.has_subtitle:
182
+ subtitle_maps: list[str] = []
183
+ for index, subtitle in enumerate(info.subtitle_streams):
184
+ subtitle_maps.append(f"-map 0:s:{index}")
185
+ subtitle_map_str: str = " ".join(subtitle_maps) + " -c:s copy"
186
+ cmd_list.insert(4, subtitle_map_str)
187
+
188
+ cmd: str = " ".join(cmd_list)
189
+
190
+ start: float = time.perf_counter()
191
+ is_error: bool = False
192
+ if progress_dict is not None and task_id is not None:
193
+ ff: FfmpegProgress = FfmpegProgress(shlex.split(cmd))
194
+ done_percentage: float = 0.0
195
+ video_time: int = int(info.dur.total_seconds())
196
+ video_time_done: int = 0
197
+ for doneperc in ff.run_command_with_progress():
198
+ done_percentage = doneperc
199
+ video_time_done = int(doneperc / 100 * video_time)
200
+ progress_dict[task_id] = {
201
+ "progress": doneperc,
202
+ "total": 100,
203
+ "current": video_time_done,
204
+ "total_time": video_time,
205
+ }
206
+ is_error = done_percentage != 100
207
+ rich_used: bool = True
208
+ else:
209
+ sp: subprocess.CompletedProcess = subprocess.run(
210
+ cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
211
+ )
212
+ is_error = sp.returncode != 0
213
+ rich_used = False
214
+ elapsed: timedelta = timedelta(seconds=time.perf_counter() - start)
215
+
216
+ if is_error:
217
+ console.print("[red]ffmpeg error occured.[/]")
218
+ if not rich_used:
219
+ print(sp.stderr.decode("utf-8"))
220
+ else:
221
+ mp4_file: Path = mkv_to_mp4(file_out)
222
+ if not mp4_file:
223
+ console.log(
224
+ "[yellow]Video converted to MKV, but MP4 conversion was unsuccessful.[/]"
225
+ )
226
+ return
227
+
228
+ in_size: int | float = file.mb()
229
+ out_size: int | float = mp4_file.mb()
230
+ in_bytes: int = file.b()
231
+ out_bytes: int = mp4_file.b()
232
+
233
+ if not rich_used:
234
+ console.print("[green]Success![/]")
235
+ stat_str = (
236
+ f"Converted '{file.stem}' length {info.dur} in {elapsed}. New file is"
237
+ f" {out_bytes/in_bytes:.2%} of the original size. ({in_size:.1f}MB -> {out_size:.1f}MB)"
238
+ )
239
+ if not rich_used:
240
+ console.print(stat_str)
241
+ if date_fix:
242
+ ctime, mtime, atime = file.getctime(), file.getmtime(), file.getatime()
243
+ filedate.File(mp4_file).set(created=ctime, modified=mtime, accessed=atime)
244
+
245
+
246
+ def _run_conversions(
247
+ files_dict: list[dict], threads: int = 4, use_rich: bool = True
248
+ ) -> None:
249
+ """
250
+ Run all pending conversions, choosing the execution strategy based on
251
+ *threads* and *use_rich*.
252
+
253
+ - threads is None / 0 / 1 → simple sequential loop, no multiprocessing.
254
+ - threads >= 2, use_rich=False → multiprocessing.Pool with a plain spinner.
255
+ - threads >= 2, use_rich=True → ProcessPoolExecutor with a rich multi-bar
256
+ progress display driven by a shared manager dict.
257
+
258
+ Args:
259
+ files_dict: List of kwarg dicts, each ready to be unpacked into
260
+ ``video_conversion(**file_dict)``.
261
+ threads: Number of worker processes for parallel execution. Values of
262
+ None, 0, or 1 disable multiprocessing entirely. Defaults to 4.
263
+ use_rich: Whether to show the rich per-file progress bars when running
264
+ in parallel. Defaults to True.
265
+ """
266
+ if not threads or threads == 1:
267
+ for file_dict in files_dict:
268
+ video_conversion(**file_dict)
269
+ return
270
+
271
+ if not use_rich:
272
+ with Pool(threads) as p:
273
+ with console.status(
274
+ "[green]Process Pool running[/]", spinner="simpleDotsScrolling"
275
+ ):
276
+ p.map(lambda kw: video_conversion(**kw), files_dict)
277
+ return
278
+
279
+ # Rich progress pool
280
+ theme = Theme(
281
+ {
282
+ "progress.elapsed": "white",
283
+ "progress.download": "white",
284
+ "progress.remaining": "white",
285
+ "progress.percentage": "white",
286
+ }
287
+ )
288
+
289
+ with progress.Progress(
290
+ progress.SpinnerColumn(),
291
+ "[progress.description]{task.description}",
292
+ progress.BarColumn(
293
+ style="gray50",
294
+ complete_style="dodger_blue2",
295
+ finished_style="green3",
296
+ ),
297
+ "[progress.percentage]{task.percentage:>3.0f}%",
298
+ progress.TextColumn("•"),
299
+ progress.TimeRemainingColumn(),
300
+ progress.TextColumn("•"),
301
+ progress.TimeElapsedColumn(),
302
+ refresh_per_second=10,
303
+ console=Console(theme=theme),
304
+ ) as progress_bar:
305
+ futures = []
306
+ with multiprocessing.Manager() as manager:
307
+ _progress = manager.dict()
308
+ overall_progress_task = progress_bar.add_task(
309
+ f"[green]{'Conversion Progress':<40}"
310
+ )
311
+ total_queue_time: int = int(
312
+ sum(
313
+ get_info(file_dict["file"]).dur.total_seconds()
314
+ for file_dict in files_dict
315
+ )
316
+ )
317
+
318
+ with ProcessPoolExecutor(max_workers=threads) as executor:
319
+ for file_dict in files_dict:
320
+ task_id = progress_bar.add_task(
321
+ f"{file_dict['file'].stem[:40]:<40}", visible=False
322
+ )
323
+ file_dict["progress_dict"] = _progress
324
+ file_dict["task_id"] = task_id
325
+ futures.append(executor.submit(video_conversion, **file_dict))
326
+
327
+ while sum(future.done() for future in futures) < len(futures):
328
+ overall_video_time_done: int = sum(
329
+ tup[1].get("current", 0) for tup in _progress.items()
330
+ )
331
+ progress_bar.update(
332
+ overall_progress_task,
333
+ completed=overall_video_time_done,
334
+ total=total_queue_time,
335
+ )
336
+ for task_id, update_data in _progress.items():
337
+ latest = update_data["progress"]
338
+ total = update_data["total"]
339
+ progress_bar.update(
340
+ task_id,
341
+ completed=latest,
342
+ total=total,
343
+ visible=latest < total,
344
+ )
345
+
346
+ progress_bar.update(
347
+ overall_progress_task, completed=len(futures), total=len(futures)
348
+ )
349
+
350
+ for future in futures:
351
+ future.result()
352
+
353
+
354
+ def _build_files_dict(
355
+ files: list[Path],
356
+ crf: int,
357
+ resize: bool,
358
+ date_fix: bool,
359
+ anime: bool,
360
+ size_filt: str | None = None,
361
+ func_filt: Callable | None = None,
362
+ ) -> list[dict]:
363
+ """
364
+ Filter *files* and build the list of kwarg dicts consumed by
365
+ ``video_conversion`` / ``_run_conversions``.
366
+
367
+ A file is skipped if any of the following are true:
368
+ - Its converted output (``<stem>_conv.mp4``) already exists.
369
+ - Its byte size is below the *size_filt* threshold.
370
+ - *func_filt* returns False for it.
371
+
372
+ Args:
373
+ files: Candidate input files to evaluate.
374
+ crf: H.264 Constant Rate Factor passed through to ``video_conversion``.
375
+ resize: Resize flag passed through to ``video_conversion``.
376
+ date_fix: Timestamp-preservation flag passed through to
377
+ ``video_conversion``.
378
+ anime: Anime-encoding flag passed through to ``video_conversion``.
379
+ size_filt: Human-readable minimum file size (e.g. ``"500k"``, ``"2M"``).
380
+ Files smaller than this are skipped. Defaults to None (no filter).
381
+ func_filt: Optional predicate — a file is included only when this
382
+ callable returns True for it. Defaults to None (no filter).
383
+
384
+ Returns:
385
+ A list of dicts, each ready to be unpacked as
386
+ ``video_conversion(**file_dict)``.
387
+ """
388
+ min_size: int | float = parse_bytes(size_filt) if size_filt else 0
389
+ files_dict = []
390
+ for f in files:
391
+ if min_size and f.stat().st_size < min_size:
392
+ continue
393
+ if func_filt and not func_filt(f):
394
+ continue
395
+ _out = Path(f.parent, f"{f.stem}_conv.mp4")
396
+ if not _out.exists():
397
+ files_dict.append(
398
+ {
399
+ "file": f,
400
+ "crf": crf,
401
+ "resize": resize,
402
+ "date_fix": date_fix,
403
+ "anime": anime,
404
+ }
405
+ )
406
+ return files_dict
407
+
408
+
409
+ def _notify() -> None:
410
+ """
411
+ Send a desktop notification announcing that the conversion batch is done.
412
+
413
+ Errors from the notification system (including KeyboardInterrupt) are
414
+ silently swallowed so that a missing notification daemon never crashes the
415
+ script.
416
+ """
417
+ try:
418
+ notification.notify(
419
+ title="VideoConverter", message="Script is done!", timeout=5
420
+ )
421
+ except KeyboardInterrupt:
422
+ pass
423
+ except Exception:
424
+ console.print("[red]Error showing notification[/]")
425
+
426
+
427
+ def parse_bytes(
428
+ value: str, default: int | float = 0, suffixes: str = "bkmgtp"
429
+ ) -> int | float:
430
+ """
431
+ Parse a human-readable byte size string (e.g. ``"500k"``, ``"2.5M"``,
432
+ ``"1G"``) and return the equivalent number of bytes as an int.
433
+
434
+ The suffix character (case-insensitive) maps to a power of 1024:
435
+ b=1, k=1024, m=1024², g=1024³, t=1024⁴, p=1024⁵. If no recognised
436
+ suffix is present the value is treated as plain bytes.
437
+
438
+ Args:
439
+ value: The size string to parse (e.g. ``"500k"``, ``"2.5M"``).
440
+ default: Value returned when *value* cannot be parsed. Defaults to 0.
441
+ suffixes: Ordered string of recognised suffix characters.
442
+ Defaults to ``"bkmgtp"``.
443
+
444
+ Returns:
445
+ The parsed byte count rounded to the nearest integer, or *default* on
446
+ any parse error.
447
+ """
448
+
449
+ try:
450
+ last = value[-1].lower()
451
+ except (TypeError, LookupError):
452
+ return default
453
+
454
+ if last in suffixes:
455
+ mul = 1024 ** suffixes.index(last)
456
+ value = value[:-1]
457
+ else:
458
+ mul = 1
459
+
460
+ try:
461
+ return round(float(value) * mul)
462
+ except ValueError:
463
+ return default
464
+
465
+
466
+ def folder_iter(
467
+ folder: str = ".",
468
+ exts: tuple[str] | list[str] = ("*.webm", "*.gif"),
469
+ crf: int = 23,
470
+ resize: bool = False,
471
+ date_fix: bool = False,
472
+ anime: bool = False,
473
+ threads: int = 4,
474
+ use_rich: bool = True,
475
+ notify: bool = True,
476
+ size_filt: str | None = None,
477
+ func_filt: Callable | None = None,
478
+ ) -> None:
479
+ """
480
+ Scan a folder for video files and convert all that have not already been
481
+ processed.
482
+
483
+ Matching files are collected, filtered through ``_build_files_dict``, then
484
+ dispatched via ``_run_conversions``. Already-converted files
485
+ (``<stem>_conv.mp4``) are automatically skipped.
486
+
487
+ Args:
488
+ folder: Directory to scan. Defaults to the current working directory.
489
+ exts: Glob patterns for the file extensions to include
490
+ (e.g. ``["*.mp4", "*.webm"]``). Defaults to ``("*.webm", "*.gif")``.
491
+ crf: H.264 Constant Rate Factor — lower = higher quality, larger file.
492
+ Defaults to 23.
493
+ resize: Downscale videos whose longest edge exceeds 1280 px.
494
+ Defaults to False.
495
+ date_fix: Copy the source file's timestamps to the output file.
496
+ Defaults to False.
497
+ anime: Apply anime-optimised encoding settings (``-tune animation``).
498
+ Defaults to False.
499
+ threads: Worker processes for parallel conversion. None / 0 / 1 runs
500
+ sequentially. Defaults to 4.
501
+ use_rich: Show per-file rich progress bars when running in parallel.
502
+ Defaults to True.
503
+ notify: Send a desktop notification when the batch finishes.
504
+ Defaults to True.
505
+ size_filt: Skip files smaller than this size (e.g. ``"500k"``).
506
+ Defaults to None.
507
+ func_filt: Skip files for which this predicate returns False.
508
+ Defaults to None.
509
+ """
510
+
511
+ files: list[Path] = sorted(
512
+ (
513
+ f
514
+ for f in Path(folder).scan(exts)
515
+ if not regex.search("_conv$", f.stem, regex.I)
516
+ ),
517
+ key=lambda x: x.name.casefold(),
518
+ )
519
+ files_dict = _build_files_dict(
520
+ files, crf, resize, date_fix, anime, size_filt, func_filt
521
+ )
522
+ _run_conversions(files_dict, threads=threads, use_rich=use_rich)
523
+ if notify:
524
+ _notify()
525
+
526
+
527
+ def file_converter(
528
+ files: str | Path | list[str] | list[Path],
529
+ crf: int = 23,
530
+ resize: bool = False,
531
+ date_fix: bool = False,
532
+ anime: bool = False,
533
+ threads: int = 4,
534
+ use_rich: bool = True,
535
+ notify: bool = True,
536
+ size_filt: str | None = None,
537
+ func_filt: Callable | None = None,
538
+ ) -> None:
539
+ """
540
+ Convert an explicit file or list of files, skipping any whose output
541
+ already exists.
542
+
543
+ Accepts a single path (str or Path) or a collection of paths, normalises
544
+ them to a list of Path objects, then delegates filtering to
545
+ ``_build_files_dict`` and execution to ``_run_conversions``.
546
+
547
+ Args:
548
+ files: A single file path or a list of file paths to convert.
549
+ crf: H.264 Constant Rate Factor — lower = higher quality, larger file.
550
+ Defaults to 23.
551
+ resize: Downscale videos whose longest edge exceeds 1280 px.
552
+ Defaults to False.
553
+ date_fix: Copy the source file's timestamps to the output file.
554
+ Defaults to False.
555
+ anime: Apply anime-optimised encoding settings (``-tune animation``).
556
+ Defaults to False.
557
+ threads: Worker processes for parallel conversion. None / 0 / 1 runs
558
+ sequentially. Defaults to 4.
559
+ use_rich: Show per-file rich progress bars when running in parallel.
560
+ Defaults to True.
561
+ notify: Send a desktop notification when the batch finishes.
562
+ Defaults to True.
563
+ size_filt: Skip files smaller than this size (e.g. ``"500k"``).
564
+ Defaults to None.
565
+ func_filt: Skip files for which this predicate returns False.
566
+ Defaults to None.
567
+ """
568
+
569
+ if isinstance(files, (str, Path)):
570
+ _files: list[Path] = [Path(files)]
571
+ else:
572
+ _files = [Path(f) for f in files]
573
+
574
+ files_dict = _build_files_dict(
575
+ _files, crf, resize, date_fix, anime, size_filt, func_filt
576
+ )
577
+ _run_conversions(files_dict, threads=threads, use_rich=use_rich)
578
+ if notify:
579
+ _notify()
580
+
581
+
582
+ def _find_conv_files(folder: str | Path) -> list[tuple["Path", "Path"]]:
583
+ """
584
+ Scan *folder* and return ``(converted, original)`` Path pairs for every
585
+ file whose stem ends with ``_conv`` and whose pre-conversion source still
586
+ exists in the same directory.
587
+
588
+ Used by ``conversion_size_check`` and ``conversion_delete`` to locate
589
+ original files without duplicating the scan logic.
590
+
591
+ Args:
592
+ folder: Directory to scan.
593
+
594
+ Returns:
595
+ A list of ``(conv_file, original_file)`` tuples. Files whose original
596
+ cannot be found are omitted.
597
+ """
598
+ pairs = []
599
+ for file in Path(folder).scan("*"):
600
+ if not (
601
+ regex.search("_conv$", file.stem, regex.I)
602
+ and file.is_file()
603
+ and file.suffix != ".py"
604
+ ):
605
+ continue
606
+ non_conv_name = regex.sub("_conv$", "", file.stem, regex.I)
607
+ try:
608
+ original = next(
609
+ Path(file.parent, f"{non_conv_name}{ext}")
610
+ for ext in VIDEO_EXTENSIONS
611
+ if Path(file.parent, f"{non_conv_name}{ext}").exists()
612
+ )
613
+ pairs.append((file, original))
614
+ except StopIteration:
615
+ continue
616
+ return pairs
617
+
618
+
619
+ def conversion_size_check(folder: str | Path = ".") -> None:
620
+ """
621
+ Print a summary of the total size reduction achieved by the conversion batch.
622
+
623
+ Scans *folder* for ``_conv`` files via ``_find_conv_files``, sums the byte
624
+ sizes of both the originals and the converted outputs, and prints a single
625
+ rich-formatted line showing file count, before/after sizes in MB, and the
626
+ percentage reduction. Does nothing if no converted files are found.
627
+
628
+ Args:
629
+ folder: Directory to inspect. Defaults to the current working directory.
630
+ """
631
+ pairs = _find_conv_files(folder)
632
+ total_before_size = sum(orig.b() for _, orig in pairs)
633
+ total_after_size = sum(conv.b() for conv, _ in pairs)
634
+ try:
635
+ console.print(
636
+ Rule(style="bright_white"),
637
+ (
638
+ f"[bold cyan]{len(pairs)}[/] files converted, [bold cyan]{total_before_size/1024**2:.1f}"
639
+ f"[/]MB -> [bold cyan]{total_after_size/1024**2:.1f}[/]MB, new files are [bold cyan]"
640
+ f"{(total_after_size/total_before_size)*100:.1f}[/]% the size of the old files."
641
+ ),
642
+ highlight=False,
643
+ )
644
+ except ZeroDivisionError:
645
+ pass
646
+
647
+
648
+ def conversion_delete(folder: str | Path = ".") -> None:
649
+ """
650
+ Delete the original (pre-conversion) source files in *folder*.
651
+
652
+ Uses ``_find_conv_files`` to locate every ``_conv`` file that still has a
653
+ matching original alongside it, then deletes only the originals. The
654
+ converted outputs are left untouched.
655
+
656
+ Args:
657
+ folder: Directory to clean up. Defaults to the current working directory.
658
+ """
659
+ for _, original in _find_conv_files(folder):
660
+ original.unlink()
661
+
662
+
663
+ def _main_finish(
664
+ start: "datetime",
665
+ folders: list["Path"],
666
+ prompt_delete: bool,
667
+ force_delete: bool,
668
+ wait_on_finish: bool,
669
+ ) -> None:
670
+ """
671
+ Print timing stats, handle optional cleanup, and optionally wait for input.
672
+
673
+ Shared by ``main`` and ``main_files`` to avoid duplicating their identical
674
+ closing logic. Prints the start/finish timestamps and human-readable
675
+ elapsed time, then conditionally deletes original files (with or without a
676
+ confirmation prompt), then optionally blocks until the user presses Enter.
677
+
678
+ Args:
679
+ start: Timestamp recorded immediately before the conversion began.
680
+ folders: Directories whose originals may be deleted.
681
+ prompt_delete: If True, ask the user whether to delete originals before
682
+ doing so. Takes precedence over *force_delete*.
683
+ force_delete: If True (and *prompt_delete* is False), delete originals
684
+ without asking.
685
+ wait_on_finish: If True, block until the user presses Enter — useful
686
+ when running from a double-clicked script that would otherwise close
687
+ its window immediately.
688
+ """
689
+ end = datetime.now()
690
+ fmt = "%m/%d %I:%M:%S %p"
691
+ console.print(
692
+ (
693
+ f"Started : [bold green]{start.strftime(fmt)}[/]"
694
+ f"\nFinished : [bold green]{end.strftime(fmt)}[/]"
695
+ ),
696
+ highlight=False,
697
+ )
698
+ console.print(
699
+ f"Elapsed : [bold cyan]{pretty_print_timedelta(end - start)}[/]",
700
+ highlight=False,
701
+ )
702
+
703
+ if prompt_delete:
704
+ clean = console.input(
705
+ "Type [green3]'y'[/] to delete pre-conversion files,"
706
+ "type [red3]'n'[/] or nothing to preserve it: "
707
+ )
708
+ if regex.search(r"^\s*y\s*$", clean, regex.I):
709
+ for folder in folders:
710
+ conversion_delete(folder=folder)
711
+ elif force_delete:
712
+ for folder in folders:
713
+ conversion_delete(folder=folder)
714
+
715
+ if wait_on_finish:
716
+ wait_for_input()
717
+
718
+
719
+ def wait_for_input() -> None:
720
+ """
721
+ Print a prompt and block until the user presses Enter.
722
+
723
+ Useful when the script is launched by double-clicking so that the terminal
724
+ window stays open long enough to read the output.
725
+ """
726
+
727
+ console.print(r"Press [bright_white]\[enter][/] when you're done.", highlight=False)
728
+ input()
729
+
730
+
731
+ def main(
732
+ folder: str = ".",
733
+ exts: tuple[str] | list[str] = (
734
+ "*.webm",
735
+ "*.gif",
736
+ "*.mp4",
737
+ "*.mov",
738
+ "*.m4v",
739
+ "*.mkv",
740
+ ),
741
+ crf: int = 28,
742
+ resize: bool = False,
743
+ date_fix: bool = True,
744
+ anime: bool = False,
745
+ threads: int = 4,
746
+ use_rich: bool = True,
747
+ size_filt: str | None = None,
748
+ func_filt: Callable | None = None,
749
+ prompt_delete: bool = False,
750
+ force_delete: bool = False,
751
+ notify_on_finish: bool = True,
752
+ wait_on_finish: bool = True,
753
+ ) -> None:
754
+ """
755
+ Convert all matching video files in *folder* and print a final summary.
756
+
757
+ This is the primary entry point for folder-based batch conversion. It
758
+ runs ``folder_iter`` to perform the conversions, then calls
759
+ ``conversion_size_check`` to report size savings, and finally
760
+ ``_main_finish`` to print timing, handle optional cleanup, and optionally
761
+ wait for user input before returning.
762
+
763
+ Args:
764
+ folder: Directory containing the videos to convert. Defaults to the
765
+ current working directory.
766
+ exts: Glob patterns for file extensions to include. Defaults to the
767
+ most common video formats.
768
+ crf: H.264 Constant Rate Factor. Lower = higher quality / larger file.
769
+ Defaults to 28.
770
+ resize: Downscale videos whose longest edge exceeds 1280 px.
771
+ Defaults to False.
772
+ date_fix: Preserve the source file's timestamps on the output.
773
+ Defaults to True.
774
+ anime: Apply anime-optimised encoding settings. Defaults to False.
775
+ threads: Worker processes for parallel conversion. None / 0 / 1 runs
776
+ sequentially. Defaults to 4.
777
+ use_rich: Show per-file rich progress bars when running in parallel.
778
+ Defaults to True.
779
+ size_filt: Skip files smaller than this size (e.g. ``"500k"``).
780
+ Defaults to None.
781
+ func_filt: Skip files for which this predicate returns False.
782
+ Defaults to None.
783
+ prompt_delete: Ask the user whether to delete originals after conversion.
784
+ Defaults to False.
785
+ force_delete: Delete originals after conversion without prompting.
786
+ Defaults to False.
787
+ notify_on_finish: Send a desktop notification when the batch finishes.
788
+ Defaults to True.
789
+ wait_on_finish: Block until the user presses Enter before returning.
790
+ Defaults to True.
791
+ """
792
+ start = datetime.now()
793
+ folder_iter(
794
+ folder=folder,
795
+ exts=exts,
796
+ crf=crf,
797
+ resize=resize,
798
+ date_fix=date_fix,
799
+ anime=anime,
800
+ threads=threads,
801
+ use_rich=use_rich,
802
+ notify=notify_on_finish,
803
+ size_filt=size_filt,
804
+ func_filt=func_filt,
805
+ )
806
+ conversion_size_check(folder=folder)
807
+ _main_finish(
808
+ start=start,
809
+ folders=[Path(folder)],
810
+ prompt_delete=prompt_delete,
811
+ force_delete=force_delete,
812
+ wait_on_finish=wait_on_finish,
813
+ )
814
+
815
+
816
+ def main_files(
817
+ files: str | Path | list[str] | list[Path],
818
+ crf: int = 28,
819
+ resize: bool = False,
820
+ date_fix: bool = True,
821
+ anime: bool = False,
822
+ threads: int = 4,
823
+ use_rich: bool = True,
824
+ size_filt: str | None = None,
825
+ func_filt: Callable | None = None,
826
+ prompt_delete: bool = False,
827
+ force_delete: bool = False,
828
+ notify_on_finish: bool = True,
829
+ wait_on_finish: bool = True,
830
+ ) -> None:
831
+ """
832
+ Convert an explicit list of video files and print a final summary.
833
+
834
+ This is the primary entry point for file-based batch conversion. It runs
835
+ ``file_converter`` to perform the conversions, then calls
836
+ ``conversion_size_check`` for each unique parent directory, and finally
837
+ ``_main_finish`` to print timing, handle optional cleanup, and optionally
838
+ wait for user input before returning.
839
+
840
+ Args:
841
+ files: A single file path or a list of file paths to convert.
842
+ crf: H.264 Constant Rate Factor. Lower = higher quality / larger file.
843
+ Defaults to 28.
844
+ resize: Downscale videos whose longest edge exceeds 1280 px.
845
+ Defaults to False.
846
+ date_fix: Preserve the source file's timestamps on the output.
847
+ Defaults to True.
848
+ anime: Apply anime-optimised encoding settings. Defaults to False.
849
+ threads: Worker processes for parallel conversion. None / 0 / 1 runs
850
+ sequentially. Defaults to 4.
851
+ use_rich: Show per-file rich progress bars when running in parallel.
852
+ Defaults to True.
853
+ size_filt: Skip files smaller than this size (e.g. ``"500k"``).
854
+ Defaults to None.
855
+ func_filt: Skip files for which this predicate returns False.
856
+ Defaults to None.
857
+ prompt_delete: Ask the user whether to delete originals after conversion.
858
+ Defaults to False.
859
+ force_delete: Delete originals after conversion without prompting.
860
+ Defaults to False.
861
+ notify_on_finish: Send a desktop notification when the batch finishes.
862
+ Defaults to True.
863
+ wait_on_finish: Block until the user presses Enter before returning.
864
+ Defaults to True.
865
+ """
866
+
867
+ start = datetime.now()
868
+ file_converter(
869
+ files=files,
870
+ crf=crf,
871
+ resize=resize,
872
+ date_fix=date_fix,
873
+ anime=anime,
874
+ threads=threads,
875
+ use_rich=use_rich,
876
+ notify=notify_on_finish,
877
+ size_filt=size_filt,
878
+ func_filt=func_filt,
879
+ )
880
+ folders: list[Path] = list({Path(f).parent for f in files})
881
+ for folder in folders:
882
+ conversion_size_check(folder=folder)
883
+ _main_finish(
884
+ start=start,
885
+ folders=folders,
886
+ prompt_delete=prompt_delete,
887
+ force_delete=force_delete,
888
+ wait_on_finish=wait_on_finish,
889
+ )
890
+
891
+
892
+ if __name__ == "__main__":
893
+ pass