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 +893 -0
- videoconv-2.1.dist-info/METADATA +13 -0
- videoconv-2.1.dist-info/RECORD +6 -0
- videoconv-2.1.dist-info/WHEEL +5 -0
- videoconv-2.1.dist-info/top_level.txt +1 -0
- videoconv.py +941 -0
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
|