diff --git a/bin/ffx.py b/bin/ffx.py index c531707..417a2b5 100755 --- a/bin/ffx.py +++ b/bin/ffx.py @@ -1,6 +1,6 @@ #! /usr/bin/python3 -import os, sys, subprocess, json, click +import os, sys, subprocess, json, click, time VERSION='0.1.0' @@ -10,6 +10,8 @@ DEFAULT_QUALITY = 23 DEFAULT_AV1_PRESET = 5 +DEFAULT_FILE_SUFFIX = 'webm' + DEFAULT_STEREO_BANDWIDTH = "128" DEFAULT_AC3_BANDWIDTH = "256" DEFAULT_DTS_BANDWIDTH = "320" @@ -31,6 +33,15 @@ MKVMERGE_METADATA_KEYS = ['BPS', COMMAND_TOKENS = ['ffmpeg', '-y', '-i'] NULL_TOKENS = ['-f', 'null', '/dev/null'] +STREAM_TYPE_VIDEO = 'video' +STREAM_TYPE_AUDIO = 'audio' +STREAM_TYPE_SUBTITLE = 'subtitle' + +STREAM_LAYOUT_6_1 = '6.1' +STREAM_LAYOUT_5_1 = '5.1(side)' +STREAM_LAYOUT_STEREO = 'stereo' +STREAM_LAYOUT_6CH = '6ch' + def executeProcess(commandSequence): @@ -60,27 +71,46 @@ def getStreamDescriptor(filename): descriptor = [] - for d in [s for s in streamData if s['codec_type'] == 'video']: + i = 0 + for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_VIDEO]: descriptor.append({ 'index': d['index'], - 'type': 'video', + 'sub_index': i, + 'type': STREAM_TYPE_VIDEO, 'codec': d['codec_name'] }) + i += 1 - for d in [s for s in streamData if s['codec_type'] == 'audio']: - descriptor.append({ + i = 0 + for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_AUDIO]: + + streamDescriptor = { 'index': d['index'], - 'type': 'audio', + 'sub_index': i, + 'type': STREAM_TYPE_AUDIO, 'codec': d['codec_name'], 'channels': d['channels'] - }) + } + + if 'channel_layout' in d.keys(): + streamDescriptor['layout'] = d['channel_layout'] + elif d['channels'] == 6: + streamDescriptor['layout'] = STREAM_LAYOUT_6CH + else: + streamDescriptor['layout'] = 'undefined' - for d in [s for s in streamData if s['codec_type'] == 'subtitle']: + descriptor.append(streamDescriptor) + i += 1 + + i = 0 + for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_SUBTITLE]: descriptor.append({ 'index': d['index'], - 'type': 'subtitle', + 'sub_index': i, + 'type': STREAM_TYPE_SUBTITLE, 'codec': d['codec_name'] }) + i += 1 return descriptor @@ -126,56 +156,19 @@ def generateDenoiseTokens(spatial=5, patch=7, research=7, hw=False): return ['-vf', f"{filterName}=s={spatial}:p={patch}:r={research}"] -def generateOutputTokens(f, q=''): - if q: - fTokens = f.split('.') - paddedFilename = '.'.join(fTokens[:-1]) + f" q{q}" + '.' + fTokens[-1] - return ['-f', 'webm', paddedFilename] - else: - return ['-f', 'webm', f] +def generateOutputTokens(f, suffix, q=None): + + if q is None: + return ['-f', 'webm', f"{f}.{suffix}"] + else: + return ['-f', 'webm', f"{f}_q{q}.{suffix}"] + -# inputFilename = sys.argv[1] -# outputFilename = sys.argv[2] -# -# targetFormat = 'vp9' -# if 'av1' in sys.argv: -# targetFormat = 'av1' -# if 'vp9' in sys.argv: -# targetFormat = 'vp9' -# -# -# qualities = [str(DEFAULT_QUALITY)] -# qualitiesTokens = [q for q in sys.argv if q.startswith('q=')] -# if qualitiesTokens: -# qualitiesString = qualitiesTokens[0].split('=')[1] -# qualities = qualitiesString.split(',') -# # preset = DEFAULT_AV1_PRESET # presetTokens = [p for p in sys.argv if p.startswith('p=')] # if presetTokens: # preset = int(presetTokens[0].split('=')[1]) -# -# stereoBandwidth = DEFAULT_STEREO_BANDWIDTH -# stereoTokens = [s for s in sys.argv if s.startswith('a=')] -# if stereoTokens: -# stereoBandwidth = str(stereoTokens[0].split('=')[1]) -# if not stereoBandwidth.endswith('k'): -# stereoBandwidth += "k" -# -# ac3Bandwidth = DEFAULT_AC3_BANDWIDTH -# ac3Tokens = [a for a in sys.argv if a.startswith('ac3=')] -# if ac3Tokens: -# ac3Bandwidth = str(ac3Tokens[0].split('=')[1]) -# if not ac3Bandwidth.endswith('k'): -# ac3Bandwidth += "k" -# -# dtsBandwidth = DEFAULT_DTS_BANDWIDTH -# dtsTokens = [d for d in sys.argv if d.startswith('dts=')] -# if dtsTokens: -# dtsBandwidth = str(dtsTokens[0].split('=')[1]) -# if not dtsBandwidth.endswith('k'): -# dtsBandwidth += "k" -# + # cropStart = '' # cropLength = '' # cropTokens = [c for c in sys.argv if c.startswith('crop')] @@ -196,58 +189,54 @@ def generateOutputTokens(f, q=''): # else: # print(f"unknown audio stream with {aStream['channels']} channels") #channel_layout -# -# audioTokens = [] -# audioStreamIndex = 0 -# for aStream in audioStreams: -# -# channels = aStream['channels'] -# -# if 'channel_layout' in aStream.keys(): -# -# channelLayout = aStream['channel_layout'] -# -# if channelLayout == '6.1': -# audioTokens += [f"-c:a:{audioStreamIndex}", -# 'libopus', -# f"-filter:a:{audioStreamIndex}", -# 'channelmap=channel_layout=6.1', -# f"-b:a:{audioStreamIndex}", -# dtsBandwidth] -# -# if channelLayout == '5.1(side)': -# audioTokens += [f"-c:a:{audioStreamIndex}", -# 'libopus', -# f"-filter:a:{audioStreamIndex}", -# "channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1", -# f"-b:a:{audioStreamIndex}", -# ac3Bandwidth] -# -# if channelLayout == 'stereo': -# audioTokens += [f"-c:a:{audioStreamIndex}", -# 'libopus', -# f"-b:a:{audioStreamIndex}", -# stereoBandwidth] -# else: -# -# if channels == 6: -# audioTokens += [f"-c:a:{audioStreamIndex}", -# 'libopus', -# f"-filter:a:{audioStreamIndex}", -# "channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1", -# f"-b:a:{audioStreamIndex}", -# ac3Bandwidth] -# -# -# audioStreamIndex += 1 -# -# -# nullTokens = ['-f', 'null', '/dev/null'] +def generateAudioTokens(context, index, layout): + + if layout == STREAM_LAYOUT_6_1: + return [f"-c:a:{index}", + 'libopus', + f"-filter:a:{index}", + 'channelmap=channel_layout=6.1', + f"-b:a:{index}", + context['bitrates']['dts']] + + elif layout == STREAM_LAYOUT_5_1: + return [f"-c:a:{index}", + 'libopus', + f"-filter:a:{index}", + "channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1", + f"-b:a:{index}", + context['bitrates']['ac3']] + + elif layout == STREAM_LAYOUT_STEREO: + return [f"-c:a:{index}", + 'libopus', + f"-b:a:{index}", + context['bitrates']['stereo']] + + elif layout == STREAM_LAYOUT_6CH: + return [f"-c:a:{index}", + 'libopus', + f"-filter:a:{index}", + "channelmap=FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1", + f"-b:a:{index}", + context['bitrates']['ac3']] + else: + return [] + + +def generateClearTokens(streams): + clearTokens = [] + for s in streams: + for k in MKVMERGE_METADATA_KEYS: + clearTokens += [f"-metadata:s:{s['type'][0]}:{s['sub_index']}", f"{k}="] + return clearTokens + @click.group() @click.pass_context def ffx(ctx): """FFX""" + ctx.obj = {} pass @@ -271,7 +260,9 @@ def streams(filename): click.echo(f"{d['codec']}{' (' + str(d['channels']) + ')' if d['type'] == 'audio' else ''}") + @ffx.command() +@click.pass_context @click.argument('args', nargs=-1) @click.option('-ve', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER, help='Target video encoder (vp9 or av1) default: vp9') @@ -288,10 +279,13 @@ def streams(filename): @click.option("--crop", is_flag=False, flag_value="default", default="none") -@click.option("--clear-metadata", is_flag=True, default=False) +@click.option("-c", "--clear-metadata", is_flag=True, default=False) +@click.option("-d", "--denoise", is_flag=True, default=False) + +def convert(ctx, args, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, dts_bitrate, crop, clear_metadata, default_subtitle, default_audio, denoise): -def convert(args, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, dts_bitrate, crop, clear_metadata, default_subtitle, default_audio): + startTime = time.perf_counter() if len(args) != 2: raise click.BadArgumentUsage( @@ -302,20 +296,30 @@ def convert(args, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, d sourcePath = args[0] targetFilename = args[1] + if not os.path.isfile(sourcePath): + raise click.ClickException(f"There is no file with path {sourcePath}") + click.echo(f"src: {sourcePath} tgt: {targetFilename}") click.echo(f"ve={video_encoder}") - q_list = quality.split(',') - click.echo(f"q={q_list}") + qualityTokens = quality.split(',') + q_list = [q for q in qualityTokens if q.isnumeric()] + click.echo(q_list) - click.echo(f"a={stereo_bitrate}") - click.echo(f"ac3={ac3_bitrate}") - click.echo(f"dts={dts_bitrate}") + ctx.obj['bitrates'] = {} + ctx.obj['bitrates']['stereo'] = str(stereo_bitrate) if str(stereo_bitrate).endswith('k') else f"{stereo_bitrate}k" + ctx.obj['bitrates']['ac3'] = str(ac3_bitrate) if str(ac3_bitrate).endswith('k') else f"{ac3_bitrate}k" + ctx.obj['bitrates']['dts'] = str(dts_bitrate) if str(dts_bitrate).endswith('k') else f"{dts_bitrate}k" + + + click.echo(f"a={ctx.obj['bitrates']['stereo']}") + click.echo(f"ac3={ctx.obj['bitrates']['ac3']}") + click.echo(f"dts={ctx.obj['bitrates']['dts']}") performCrop = (crop != 'none') @@ -337,82 +341,95 @@ def convert(args, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, d click.echo(f"\nRunning {len(q_list)} jobs") - - - for q in q_list: - click.echo(f"\nRunning job q={q}") - streamDescriptor = getStreamDescriptor(sourcePath) + streamDescriptor = getStreamDescriptor(sourcePath) + + commandTokens = COMMAND_TOKENS + [sourcePath] + + + for q in q_list: - commandTokens = ['ffmpeg', '-y', '-i', sourcePath] + click.echo(f"\nRunning job q={q}") mappingVideoTokens = ['-map', 'v:0'] mappingTokens = mappingVideoTokens.copy() + audioTokens = [] + + audioIndex = 0 + for audioStreamDescriptor in streamDescriptor: + + if audioStreamDescriptor['type'] == STREAM_TYPE_AUDIO: + + mappingTokens += ['-map', f"a:{audioIndex}"] + audioTokens += generateAudioTokens(ctx.obj, audioIndex, audioStreamDescriptor['layout']) + audioIndex += 1 - for a in range(len(audioStreams)): - mappingTokens += ['-map', f"a:{a}"] - for s in range(len(subtitleStreams)): + for s in range(len([d for d in streamDescriptor if d['type'] == STREAM_TYPE_SUBTITLE])): mappingTokens += ['-map', f"s:{s}"] if video_encoder == 'av1': - commandSequence = COMMAND_TOKENS #+ mappingTokens + generateAV1Tokens(quality, preset) + audioTokens + commandSequence = commandTokens + mappingTokens + audioTokens + generateAV1Tokens(q, preset) + audioTokens - #if cropStart: - # commandSequence += generateCropTokens(cropStart, cropLength) + if clear_metadata: + commandSequence += generateClearTokens(streamDescriptor) - #if len(qualities) > 1: - # commandSequence += generateOutputTokens(outputFilename, quality) - #else: - # commandSequence += generateOutputTokens(outputFilename) + if performCrop: + commandSequence += generateCropTokens(cropStart, cropLength) + + commandSequence += generateOutputTokens(targetFilename, DEFAULT_FILE_SUFFIX, q) - print(f"Command: {' '.join(commandSequence)}") + click.echo(f"Command: {' '.join(commandSequence)}") executeProcess(commandSequence) if video_encoder == 'vp9': - commandSequence1 = COMMAND_TOKENS #+ mappingVideoTokens + generateVP9Pass1Tokens(quality) + commandSequence1 = commandTokens + mappingVideoTokens + generateVP9Pass1Tokens(q) - #if cropStart: - # commandSequence1 += generateCropTokens(cropStart, cropLength) + if performCrop: + commandSequence1 += generateCropTokens(cropStart, cropLength) - #commandSequence1 += nullTokens + commandSequence1 += NULL_TOKENS - print(f"Command 1: {' '.join(commandSequence1)}") + click.echo(f"Command 1: {' '.join(commandSequence1)}") - #if os.path.exists(TEMP_FILE_NAME): - # os.remove(TEMP_FILE_NAME) + if os.path.exists(TEMP_FILE_NAME): + os.remove(TEMP_FILE_NAME) - #executeProcess(commandSequence1) + executeProcess(commandSequence1) - commandSequence2 = COMMAND_TOKENS #+ mappingTokens + commandSequence2 = commandTokens + mappingTokens - #if denoiseTokens: - # commandSequence2 += generateDenoiseTokens() + if denoise: + commandSequence2 += generateDenoiseTokens() - #commandSequence2 += generateVP9Pass2Tokens(quality) + audioTokens + commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens - #if cropStart: - # commandSequence2 += generateCropTokens(cropStart, cropLength) + if clear_metadata: + commandSequence2 += generateClearTokens(streamDescriptor) - #if len(qualities) > 1: - # commandSequence2 += generateOutputTokens(outputFilename, quality) - #else: - # commandSequence2 += generateOutputTokens(outputFilename) + if performCrop: + commandSequence2 += generateCropTokens(cropStart, cropLength) - print(f"Command 2: {' '.join(commandSequence2)}") + commandSequence2 += generateOutputTokens(targetFilename, DEFAULT_FILE_SUFFIX, q) + + click.echo(f"Command 2: {' '.join(commandSequence2)}") - #executeProcess(commandSequence2) + executeProcess(commandSequence2) + + + click.echo('\nDONE\n') + endTime = time.perf_counter() + click.echo(f"Time elapsed {endTime - startTime}") - click.echo('\nDONE\n') if __name__ == '__main__':