-
Notifications
You must be signed in to change notification settings - Fork 0
/
filmcompress.py
169 lines (154 loc) · 8.41 KB
/
filmcompress.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
#!/usr/bin/env python3
# Based on https://geoffruddock.com/bulk-filmcompress-x265-with-ffmpeg/
import fnmatch
import os
import pathlib
import shutil
from subprocess import run
import sys
from typing import Iterable
import click
from termcolor import colored
# pip install ffmpeg-python
import ffmpeg
__version__ = '0.6.0'
SUPPORTED_FORMATS = ['mp4', 'mov', 'm4a', 'mkv', 'webm', 'avi', '3gp']
SKIPPED_CODECS = ['hevc', 'av1']
# Ported from: https://github.com/victordomingos/optimize-images
def search_files(dirpath: str, recursive: bool) -> Iterable[str]:
if os.path.isfile(dirpath):
yield os.path.normpath(dirpath)
elif recursive:
for root, dirs, files in os.walk(dirpath):
for f in files:
if not os.path.isfile(os.path.join(root, f)):
continue
extension = os.path.splitext(f)[1][1:]
if extension.lower() in SUPPORTED_FORMATS:
yield os.path.join(root, f)
else:
with os.scandir(dirpath) as directory:
for f in directory:
if not os.path.isfile(os.path.normpath(f)):
continue
extension = os.path.splitext(f)[1][1:]
if extension.lower() in SUPPORTED_FORMATS:
yield os.path.normpath(f)
@click.command()
@click.argument('indir', type=click.Path(exists=True))
@click.argument('outdir', type=click.Path(exists=True, writable=True), required=False)
@click.option('--roku', is_flag=True, help="Prepare file for Roku player")
@click.option('-f', '--oformat', help="Output file format, mp4 is default", default='mp4')
@click.option('-r', '--recursive', is_flag=True, help='Recursive')
@click.option('-o', '--overwrite', is_flag=True, help='Overwrite old file with optimized file')
@click.option('--av1', help='AV1 codec (experimental)', type=click.Choice(['aom', 'svt', 'rav1e', 'amf'], case_sensitive=False))
@click.option('-g', '--gpu', type=click.Choice(['nvidia', 'intel', 'amd', 'none'], case_sensitive=False), help='Use GPU of type. Can be: nvidia, intel, amd. Defaults to none (recommended).')
@click.option('-i', '--include', default='*')
@click.option('-n', '--notranscode', is_flag=True, help='Skip any transcoding, good with Roku mode')
def main(indir, av1, outdir=None, oformat='mp4', include='*', recursive=False, overwrite=False, gpu='none', roku=False, notranscode=False):
""" Compress h264 video files in a directory using libx265 codec
indir: the directory to scan for video files
outdir: output directory
recursive: whether to search directory or all its contents
gpu: type of hardware encoder
av1: use experimetal av1 encoder
"""
outdir = pathlib.PurePath(outdir)
total = 0
command_line = ''
if recursive:
print('Processing recursively starting from', indir)
recursive = True
else:
print('Processing non-recursively starting from', indir)
recursive = False
if outdir is None:
print('No output directory given, processing in informational mode.', indir)
for fp in search_files(str(indir), recursive=recursive):
fp = pathlib.PurePath(fp)
if not fnmatch.fnmatch(fp, include):
continue
assert os.path.exists(fp)
try:
probe = ffmpeg.probe(fp)
except ffmpeg.Error as e:
print(e.stderr, file=sys.stderr)
sys.exit(1)
video_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None)
if video_stream is None:
print('No video stream found', file=sys.stderr)
sys.exit(1)
codec = video_stream['codec_name']
print(str(fp), "has codec", colored(codec, 'green'))
if outdir is None:
continue
if (codec in SKIPPED_CODECS) and not roku:
continue
if not fnmatch.fnmatch(fp, include):
continue
if roku:
# http://www.rokoding.com/index.html
print(colored('Roku mode', 'magenta'))
new_fp = outdir.joinpath(fp.with_suffix('.mkv').name)
if os.path.exists(new_fp):
print(colored(str(new_fp) + ' exists', 'yellow'))
continue
# Workaround for unsupported map in ffmpeg wrapper, we need '-map 0 -map -0:d'
if notranscode:
command_line = ['ffmpeg', '-nostdin', '-i', str(fp), '-ac', '2', '-c:a', 'copy', '-c:v', 'copy', '-c:s', 'svt', '-ignore_chapters', '1', '-map_metadata', '0', '-movflags', 'use_metadata_tags', '-map', '0', '-map', '-0:d', str(new_fp)]
else:
command_line = ['ffmpeg', '-nostdin', '-i', str(fp), '-ac', '2', '-c:a', 'libopus', '-b:a', '96k', '-af', 'loudnorm=I=-16:LRA=11:TP=-1.5', '-c:v', 'copy', '-c:s', 'svt', '-ignore_chapters', '1', '-map_metadata', '0', '-movflags', 'use_metadata_tags', '-map', '0', '-map', '-0:d', str(new_fp)]
print(command_line)
if run(command_line).returncode != 0:
exit(1)
else:
continue
else:
new_fp = outdir.joinpath(fp.with_suffix('.' + oformat).name)
if os.path.exists(new_fp):
print(colored(str(new_fp) + ' exists', 'yellow'))
continue
if gpu == 'nvidia':
print(colored('Encoding with nVidia hardware acceleration', 'yellow'))
# https://slhck.info/video/2017/03/01/rate-control.html
# https://docs.nvidia.com/video-technologies/video-codec-sdk/ffmpeg-with-nvidia-gpu/
# ffmpeg -h encoder=hevc_nvenc
#print(ffmpeg.input(fp).output(str(new_fp), acodec='copy', map=0, vcodec='hevc_nvenc', **{'rc-lookahead': 25}, map_metadata=0, movflags='use_metadata_tags', preset='p6', spatial_aq=1, temporal_aq=1).run())
print(ffmpeg.input(fp).output(str(new_fp), vcodec='hevc_nvenc', **{'rc-lookahead': 25}, map_metadata=0, movflags='use_metadata_tags', preset='p6', spatial_aq=1, temporal_aq=1).run())
if gpu == 'amd':
print(colored('Encoding with AMD hardware acceleration', 'red'))
print(ffmpeg.input(fp).output(str(new_fp), vcodec='hevc_amf', map_metadata=0, movflags='use_metadata_tags', quality='balanced', usage='lowlatency', rc="cqp").run())
elif av1:
# ffmpeg -h encoder=libaom-av1
print(colored('Encoding with experimental AV1 encoder', 'yellow'))
print('AV 1 codec:', colored(av1, 'yellow'))
if av1 == 'aom':
ffmpeg.input(fp).output(str(new_fp), pix_fmt='yuv420p', acodec='libopus', ab='96k', vcodec='libaom-av1', map_metadata=0, movflags='use_metadata_tags', crf=28).run()
elif av1 == 'svt':
# ffmpeg -h encoder=libsvtav1
ffmpeg.input(fp).output(str(new_fp), pix_fmt='yuv420p', acodec='libopus', ab='96k', vcodec='libsvtav1', qp=35, preset=5, map_metadata=0, movflags='use_metadata_tags').run()
elif av1 == 'rav1e':
print('Rav1e not yet supported')
exit(0)
elif av1 == 'amf':
# AMD hardware encoder
# ffmpeg -h encoder=av1_amf
ffmpeg.input(fp).output(str(new_fp), pix_fmt='yuv420p', acodec='libopus', ab='96k', vcodec='av1_amf', usage='lowlatency', map_metadata=0, movflags='use_metadata_tags').run()
else:
print(colored('Encoding with no hardware acceleration', 'yellow'))
# CRF 22 rationale: https://codecalamity.com/encoding-uhd-4k-hdr10-videos-with-ffmpeg/
# ffmpeg -h encoder=libx265
print(ffmpeg.input(fp).output(str(new_fp), acodec='libopus', ab='64k', vcodec='libx265', crf=22, preset='slow', map_metadata=0, movflags='use_metadata_tags').run())
saved = os.path.getsize(fp) - os.path.getsize(new_fp)
if saved <= 0:
print('Copying', fp, 'over', new_fp, 'because it is smaller')
shutil.copy2(fp, new_fp)
else:
if overwrite:
print('Moving', new_fp, 'over', fp, 'because overwrite mode is on')
shutil.move(new_fp, fp)
total += saved
print(colored(new_fp, 'green'), 'ready, saved', round(saved / 1024), 'KB')
print('Total saved', round(total / 1024 / 1024), 'MB')
if __name__ == '__main__':
main()