diff --git a/cpmyvideos.py b/cpmyvideos.py index de18ba9..523f8ea 100755 --- a/cpmyvideos.py +++ b/cpmyvideos.py @@ -6,6 +6,7 @@ import argparse from timeit import default_timer as timer import os +import sys import shutil #from pprint import pprint import magic @@ -13,10 +14,14 @@ from mymediainfo import MyMediaInfo from lib import format_time, run_cmd, ENCODERS -RESOLUTIONS = (720, 1080, 1440, 2160) # 1620 1800 -DEF_RES = 1440 -FF_PRESETS = ('ultrafast', 'superfast', 'veryfast', 'faster', 'fast', - 'medium', 'slow', 'slower', 'veryslow') +RESOLUTIONS = ('720', '1080', '1440', '1620', '2160') +DEF_RES = '1440' +HEVC_DEF_PRESET = 'medium' +SVTAV1_PRESETS = (0,13) # range +SVTAV1_DEF_PRESET = '6' +DNXHR = ('lb', 'sq', 'hq', 'hqx', '444') +NVENC_DEF_PRESET='p5' # p7=2pass +NVENC_DEF_TUNE='hq' FORMATS = ('dnxhr', 'hevc', 'av1') FORMAT_EXTENSIONS = { 'av1': 'MP4', @@ -25,8 +30,9 @@ } BIT_DEPTHS = {'8', '10'} CRF = { - 'hevc': '18', - 'av1': '28', + 'hevc': '18', # 20 + 'av1': '28', # 30, default 35 + 'nv': '19', 'dnxhr': None, } @@ -43,24 +49,25 @@ #parser.add_argument('-y', action='store_true', help='Overwrite') parser.add_argument('--dns', action='store_true', help=f'Do Not Scale to {DEF_RES}') -parser.add_argument('--res', default=DEF_RES, type=int, +parser.add_argument('--res', default=DEF_RES, choices=RESOLUTIONS, help='Resolution (%(default)s)') -parser.add_argument('--fmt', default='hevc', +parser.add_argument('--fmt', default='hevc', choices=FORMATS, help='Target format (%(default)s)') -parser.add_argument('--preset', default='slow', - help='ffmpeg preset (%(default)s)') -parser.add_argument('--svtav1preset', default='4', - help='svtav1 preset (%(default)s)') -parser.add_argument('--crf', - help=f'ffmpeg crf ({CRF})') -parser.add_argument('--enc', default='sw', - help='Encoder type (%(default)s)') -parser.add_argument('--bits', help='Target bit depth 8|10') +parser.add_argument('--preset', help='Preset HEVC/SVT-AV1/NVENC') +parser.add_argument('--tune', help='Tune HEVC/SVT-AV1/NVENC') +parser.add_argument('--crf', help=f'CRF/quality ({CRF})') +parser.add_argument('--params', help='Param HEVC/SVT-AV1') +parser.add_argument('--dnx', choices=DNXHR, help='DNxHR profile') +parser.add_argument('--enc', default='sw', choices=ENCODERS, + help='Encoder (%(default)s)') +parser.add_argument('--bits', choices=BIT_DEPTHS, help='Force bit depth') parser.add_argument('--nometa', action='store_true', help='Do not map metadata') parser.add_argument('--fnparams', action='store_true', help='Add params to dest file names') parser.add_argument('-t', dest='duration') +parser.add_argument('-1', dest='first', action='store_true', + help='only 1st found file') parser.add_argument('--dry', action='store_true', help='Dry run') args = parser.parse_args() @@ -73,22 +80,25 @@ raise ValueError(f"Source dir '{args.srcdir}' doesn't exist") if not os.path.exists(args.dstdir): raise ValueError(f"Destination dir '{args.dstdir}' doesn't exist") -if not args.res in RESOLUTIONS: - raise ValueError(f"Bad resolution '{args.res}', use one of: " - f"{', '.join(map(str,RESOLUTIONS))}") -if not args.fmt in FORMATS: - raise ValueError(f"Bad format '{args.fmt}', use one of: {', '.join(FORMATS)}") -if not args.preset in FF_PRESETS: - raise ValueError(f"Bad preset '{args.preset}', use one of:" - f"{', '.join(FF_PRESETS)}") -if not args.enc in ENCODERS: - raise ValueError(f"Bad encoder '{args.enc}', use one of: {', '.join(ENCODERS)}") -if args.bits and args.bits not in BIT_DEPTHS: - raise ValueError(f"Bad bit depth '{args.bits}', use one of: " - f"{', '.join(BIT_DEPTHS)}") #print(args) ; exit() -crf = args.crf if args.crf else CRF[args.fmt] +crf = args.crf if args.crf else CRF.get(args.enc) or CRF[args.fmt] +if crf: + print(f'CRF: {crf}') + +preset = None +if args.preset: + preset = args.preset +else: + if args.enc == 'nv': + preset = NVENC_DEF_PRESET + elif args.enc == 'sw': + if args.fmt == 'hevc': + preset = HEVC_DEF_PRESET + elif args.fmt == 'av1': + preset = SVTAV1_DEF_PRESET +if preset: + print(f'Preset: {preset}') if args.newer: args.newer = dateparser.parse(args.newer).timestamp() @@ -97,37 +107,32 @@ def v_transcode(src, dst, info): cmd = ['ffmpeg', '-hide_banner', '-nostdin', '-ignore_editlist', '1'] params_in = {} filter_v = {} - bit_depth = args.bits or str(info.bit_depth()) if info.is_hq(): params = { 'c:v': 'copy' } elif args.fmt == 'dnxhr': - # https://dovidenko.com/2019/999/ffmpeg-dnxhd-dnxhr-mxf-proxies-and-optimized-media.html - # dnxhr_lb - Low Bandwidth. 8-bit 4:2:2 (yuv422p). Offline Quality. - # dnxhr_sq - Standard Quality. 8-bit 4:2:2 (yuv422p). Suitable for delivery format. - # dnxhr_hq - High Quality. 8-bit 4:2:2 (yuv422p). - # dnxhr_hqx - High Quality. 10-bit 4:2:2 (yuv422p10le). UHD/4K Broadcast-quality delivery. - # dnxhr_444 - Finishing Quality. 10-bit 4:4:4 (yuv444p10le). Cinema-quality delivery. + # dnxhr_lb Low Bandwidth. 8-bit 4:2:2 (yuv422p). Offline Quality. 22:1 + # dnxhr_sq Standard Quality. 8-bit 4:2:2 (yuv422p). Suitable for delivery. 7:1 + # dnxhr_hq High Quality. 8-bit 4:2:2 (yuv422p). 4.5:1 + # dnxhr_hqx High Quality. 10-bit 4:2:2 (yuv422p10le). UHD/4K Broadcast-quality. 5.5:1 + # dnxhr_444 Finishing Quality. 10-bit 4:4:4 (yuv444p10le). Cinema-quality. 4.5:1 + bit_depth = args.bits or str(info.bit_depth()) params = {'c:v': 'dnxhd'} - if bit_depth == '8': - filter_v['format'] = 'yuv422p' - params['profile:v'] = 'dnxhr_hq' - elif bit_depth == '10': - filter_v['format'] = 'yuv420p10le' - params['profile:v'] = 'dnxhr_hqx' + filter_v['format'] = 'yuv422p' if bit_depth == '8' else 'yuv422p10le' + default_profile = 'dnxhr_hq' if bit_depth == '8' else 'dnxhr_hqx' + params['profile:v'] = f'dnxhr_{args.dnx}' if args.dnx else default_profile + elif args.fmt == 'hevc': # http://trac.ffmpeg.org/wiki/Encode/H.265 - # https://trac.ffmpeg.org/wiki/Hardware/AMF - # https://github.com/GPUOpen-LibrariesAndSDKs/AMF/wiki/FFmpeg-and-AMF-HW-Acceleration - # Panasonic 420/8 420/10 422/10 + bit_depth = args.bits or '10' match args.enc: + # https://trac.ffmpeg.org/wiki/Hardware/AMF + # https://github.com/GPUOpen-LibrariesAndSDKs/AMF/wiki/FFmpeg-and-AMF-HW-Acceleration case 'amf': params = { 'c:v': 'hevc_amf', 'usage': 'lowlatency_high_quality', - # 'profile:v': 'main' # only main - 'profile_tier': 'high', 'quality': 'quality', - 'rc': 'cqp', + 'rc:v': 'cqp', 'qp_p': crf, 'qp_i': crf, } @@ -147,42 +152,46 @@ def v_transcode(src, dst, info): 'rc_mode': 'CQP', 'compression_level': '29', 'qp': crf, - # 'async_depth': '4' 'tier': 'high', } case 'nv': # https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html + # https://developer.nvidia.com/blog/nvidia-ffmpeg-transcoding-guide/ params_in = { 'hwaccel': 'cuda', + # keeps the decoded frames in GPU memory 'hwaccel_output_format': 'cuda' } params = { 'c:v': 'hevc_nvenc', - 'fps_mode': 'passthrough', - 'preset': 'p5', # p6,p7 + 'preset:v': preset, + 'tune:v': args.tune or NVENC_DEF_TUNE, + 'rc:v': 'vbr', + 'cq:v': crf, + 'b:v': '0', 'tier': 'high', - 'tune': 'hq', - # new - 'rc': 'constqp', - 'qp': crf + #'profile:v': 'high', } if bit_depth == '10': params['profile:v'] = 'main10' case 'sw': + # https://x265.readthedocs.io/en/stable/ params = { 'c:v': 'libx265', - 'preset': args.preset, + 'preset': preset, 'crf': crf, # default 28 'x265-params': 'level-idc=5.1' } if bit_depth == '10': params['profile:v'] = 'main10' - #params['x265-params'] += ':profile=main10' filter_v['format'] = 'yuv420p10le' - - # params['tag:v'] = 'hvc1' # apple/qt + if args.tune: + params['x265-params'] += f':tune={args.tune}' + if args.params: + params['x265-params'] += f':{args.params}' elif args.fmt == 'av1': # https://trac.ffmpeg.org/wiki/Encode/AV1 + bit_depth = args.bits or '10' match args.enc: case 'amf': params = { @@ -200,7 +209,6 @@ def v_transcode(src, dst, info): params = { 'c:v': 'av1_vaapi', 'compression_level': '29', - 'tier': 'high', } case 'nv': params_in = { @@ -210,34 +218,36 @@ def v_transcode(src, dst, info): params = { 'c:v': 'av1_nvenc', 'fps_mode': 'passthrough', - #'preset': 'slow', - #'preset': '6', + 'preset': preset, 'tune': 'hq', } case 'sw': params = { 'c:v': 'libsvtav1', - 'preset': args.svtav1preset, - 'crf': crf, # default 35 - # 'qp': '35' - 'svtav1-params': 'rc=0:level=5.2', + 'preset': preset, + 'crf': crf, + 'svtav1-params': 'rc=0:film-grain-denoise=0:enable-overlays=1', } if bit_depth == '8': filter_v['format'] = 'yuv422p' elif bit_depth == '10': filter_v['format'] = 'yuv420p10le' - # input-depth 8|10 - params['svtav1-params'] += 'input-depth=10' + params['svtav1-params'] += ':input-depth=10' + if args.tune: + params['svtav1-params'] += f':tune={args.tune}' + if args.params: + params['svtav1-params'] += f':{args.params}' - if info.height() > args.res and not args.dns: + if info.height() > int(args.res) and not args.dns: match args.enc: case 'nv': + # scale_cuda | scale_npp (`--enable-cuda-nvcc`) filter_v['scale_cuda'] = f'w=-1:h={args.res}:interp_algo=lanczos' if bit_depth == '10': filter_v['scale_cuda'] += ':format=p010le' case 'vaapi': # mode=hq|nl_anamorphic - filter_v['scale_vaapi'] = f'w=-1:h={args.res}:mode=hq' + filter_v['scale_vaapi'] = f'w=-1:h={args.res}:mode=hq:force_original_aspect_ratio=1' if bit_depth == '10': filter_v['scale_vaapi'] += ':format=p010' case _: @@ -285,7 +295,7 @@ def v_transcode(src, dst, info): cmd.extend([f'-{key}', value]) cmd.append(dst) - run_cmd(cmd, args.dry) + return run_cmd(cmd, args.dry) def v_copy(src, dst): print(f"COPY {src} {dst}") @@ -306,6 +316,14 @@ def v_copy(src, dst): base_name += f'_crf{crf}' if args.bits: base_name += f'_bits{args.bits}' + if args.res and not args.dns: + base_name += f'_res{args.res}' + if args.preset: + base_name += f'_pr{args.preset}' + if args.tune: + base_name += f'_tun{args.tune}' + if args.dnx: + base_name += f'_dnx{args.dnx}' dst_file = os.path.join(args.dstdir, f'{base_name}.{ext}') if os.path.exists(dst_file): print(f'EXISTS {dst_file}') @@ -327,10 +345,17 @@ def v_copy(src, dst): if args.copy: v_copy(src_file, dst_file) else: - v_transcode(src_file, dst_file, mi) + rc = v_transcode(src_file, dst_file, mi) + if rc != 0: + print(f'v_transcode failed with code: {rc}') + if os.path.exists(dst_file) and os.path.getsize(dst_file) == 0: + os.remove(dst_file) + sys.exit(1) end_time = timer() - start_time print(f"TIME {format_time(end_time)}\n") TOTAL_TIME += end_time + if args.first: + break print(f"TOTAL TIME: {format_time(TOTAL_TIME)}") diff --git a/lib.py b/lib.py index 610ff3e..c4741a5 100644 --- a/lib.py +++ b/lib.py @@ -19,7 +19,7 @@ def format_time(seconds): def run_cmd(cmd, dry=False): print(f"COMMAND: {' '.join(cmd)}") if dry: - return + return 0 def reader_thread(pipe, process): for line in iter(pipe.readline, b''): @@ -42,5 +42,6 @@ def reader_thread(pipe, process): stdout_thread.join() stderr_thread.join() - if return_code != 0: - print(f"Failed with return code: {return_code}") + #if return_code != 0: + # print(f"Failed with return code: {return_code}") + return return_code diff --git a/mymediainfo.py b/mymediainfo.py index d2b8a08..ebdb60f 100644 --- a/mymediainfo.py +++ b/mymediainfo.py @@ -44,6 +44,12 @@ def height(self): def frame_rate(self): return self.video_track.frame_rate if self.video_track else None + def is_interlaced(self): + return self.video_track.scan_type != 'Progressive' + + def is_hq(self): + return self.format() in ['VC-3', 'FFV1', 'ProRes', 'HFYU'] + def video_data(self): return self.video_track.to_data() @@ -62,11 +68,6 @@ def audio_sampling_rate(self): def audio_data(self): return self.audio_track.to_data() - def is_hq(self): - if self.format() in ['VC-3', 'FFV1', 'ProRes', 'HFYU']: - return True - return False - def print(self): print(f'Video: {self.width()}x{self.height()} @ {self.frame_rate()}') print(f"Bit rate: {self.bit_rate()}") @@ -74,8 +75,8 @@ def print(self): print(f'Format: {self.format()}') print(f'Format profile: {self.format_profile()}') print(f'Format settings: {self.format_settings()}') - print(f'Color space: {self.color_space()}') - print(f'Chroma subsampling: {self.video_track.chroma_subsampling}') + print(f'Scan: {self.video_track.scan_type}') + print(f'Color: {self.color_space()} {self.video_track.chroma_subsampling}') print(f"Audio format: {self.audio_format()}") print(f"Audio sampling rate: {self.audio_sampling_rate()}") print(f"Audio channels: {self.audio_channels()}") diff --git a/youtube.py b/youtube.py index 3f082aa..fa6916e 100755 --- a/youtube.py +++ b/youtube.py @@ -104,6 +104,7 @@ def run(self): params['qp_p'] = self.args.crf params['qp_b'] = self.args.crf case 'vaapi': + # ffmpeg -hide_banner -h encoder=h264_vaapi|less params_in = { 'threads': '1', 'hwaccel': 'vaapi',