#! /usr/bin/python3 import os, sys, subprocess, json, click, time VERSION='0.1.0' DEFAULT_VIDEO_ENCODER = 'vp9' DEFAULT_QUALITY = 23 DEFAULT_AV1_PRESET = 5 DEFAULT_LABEL='output' DEFAULT_FILE_SUFFIX = 'webm' DEFAULT_STEREO_BANDWIDTH = "128" DEFAULT_AC3_BANDWIDTH = "256" DEFAULT_DTS_BANDWIDTH = "320" DEFAULT_CROP_START = 60 DEFAULT_CROP_LENGTH = 180 TEMP_FILE_NAME = "ffmpeg2pass-0.log" MKVMERGE_METADATA_KEYS = ['BPS', 'NUMBER_OF_FRAMES', 'NUMBER_OF_BYTES', '_STATISTICS_WRITING_APP', '_STATISTICS_WRITING_DATE_UTC', '_STATISTICS_TAGS'] 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): process = subprocess.Popen(commandSequence, stdout=subprocess.PIPE, stderr=subprocess.PIPE) output, error = process.communicate() return output #[{'index': 0, 'codec_name': 'vp9', 'codec_long_name': 'Google VP9', 'profile': 'Profile 0', 'codec_type': 'video', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'width': 1920, 'height': 1080, 'coded_width': 1920, 'coded_height': 1080, 'closed_captions': 0, 'film_grain': 0, 'has_b_frames': 0, 'sample_aspect_ratio': '1:1', 'display_aspect_ratio': '16:9', 'pix_fmt': 'yuv420p', 'level': -99, 'color_range': 'tv', 'chroma_location': 'left', 'field_order': 'progressive', 'refs': 1, 'r_frame_rate': '24000/1001', 'avg_frame_rate': '24000/1001', 'time_base': '1/1000', 'start_pts': 0, 'start_time': '0.000000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'BPS': '7974017', 'NUMBER_OF_FRAMES': '34382', 'NUMBER_OF_BYTES': '1429358655', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libvpx-vp9', 'DURATION': '00:23:54.016000000'}}] #[{'index': 1, 'codec_name': 'opus', 'codec_long_name': 'Opus (Opus Interactive Audio Codec)', 'codec_type': 'audio', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'sample_fmt': 'fltp', 'sample_rate': '48000', 'channels': 2, 'channel_layout': 'stereo', 'bits_per_sample': 0, 'initial_padding': 312, 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'extradata_size': 19, 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'jpn', 'title': 'Japanisch', 'BPS': '128000', 'NUMBER_OF_FRAMES': '61763', 'NUMBER_OF_BYTES': '22946145', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 libopus', 'DURATION': '00:23:54.141000000'}}] #[{'index': 2, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 1, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'ger', 'title': 'Deutsch [Full]', 'BPS': '118', 'NUMBER_OF_FRAMES': '300', 'NUMBER_OF_BYTES': '21128', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:54.010000000'}}, {'index': 3, 'codec_name': 'webvtt', 'codec_long_name': 'WebVTT subtitle', 'codec_type': 'subtitle', 'codec_tag_string': '[0][0][0][0]', 'codec_tag': '0x0000', 'r_frame_rate': '0/0', 'avg_frame_rate': '0/0', 'time_base': '1/1000', 'start_pts': -7, 'start_time': '-0.007000', 'duration_ts': 1434141, 'duration': '1434.141000', 'disposition': {'default': 0, 'dub': 0, 'original': 0, 'comment': 0, 'lyrics': 0, 'karaoke': 0, 'forced': 0, 'hearing_impaired': 0, 'visual_impaired': 0, 'clean_effects': 0, 'attached_pic': 0, 'timed_thumbnails': 0, 'non_diegetic': 0, 'captions': 0, 'descriptions': 0, 'metadata': 0, 'dependent': 0, 'still_image': 0}, 'tags': {'language': 'eng', 'title': 'Englisch [Full]', 'BPS': '101', 'NUMBER_OF_FRAMES': '276', 'NUMBER_OF_BYTES': '16980', '_STATISTICS_WRITING_APP': "mkvmerge v63.0.0 ('Everything') 64-bit", '_STATISTICS_WRITING_DATE_UTC': '2023-10-07 13:59:46', '_STATISTICS_TAGS': 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES', 'ENCODER': 'Lavc61.3.100 webvtt', 'DURATION': '00:23:53.230000000'}}] def getStreamDescriptor(filename): ffprobeOutput = executeProcess(["ffprobe", "-show_streams", "-of", "json", filename]) streamData = json.loads(ffprobeOutput)['streams'] descriptor = [] i = 0 for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_VIDEO]: descriptor.append({ 'index': d['index'], 'sub_index': i, 'type': STREAM_TYPE_VIDEO, 'codec': d['codec_name'] }) i += 1 i = 0 for d in [s for s in streamData if s['codec_type'] == STREAM_TYPE_AUDIO]: streamDescriptor = { 'index': d['index'], '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' 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'], 'sub_index': i, 'type': STREAM_TYPE_SUBTITLE, 'codec': d['codec_name'] }) i += 1 return descriptor def generateAV1Tokens(q, p): return ['-c:v:0', 'libsvtav1', '-svtav1-params', f"crf={q}:preset={p}:tune=0:enable-overlays=1:scd=1:scm=0", '-pix_fmt', 'yuv420p10le'] def generateVP9Pass1Tokens(q): return ['-c:v:0', 'libvpx-vp9', '-row-mt', '1', '-crf', str(q), '-pass', '1', '-speed', '4', '-frame-parallel', '0', '-g', '9999', '-aq-mode', '0'] def generateVP9Pass2Tokens(q): return ['-c:v:0', 'libvpx-vp9', '-row-mt', '1', '-crf', str(q), '-pass', '2', '-frame-parallel', '0', '-g', '9999', '-aq-mode', '0', '-auto-alt-ref', '1', '-lag-in-frames', '25'] def generateCropTokens(start, length): return ['-ss', str(start), '-t', str(length)] def generateDenoiseTokens(spatial=5, patch=7, research=7, hw=False): filterName = 'nlmeans_opencl' if hw else 'nlmeans' return ['-vf', f"{filterName}=s={spatial}:p={patch}:r={research}"] 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}"] # preset = DEFAULT_AV1_PRESET # presetTokens = [p for p in sys.argv if p.startswith('p=')] # if presetTokens: # preset = int(presetTokens[0].split('=')[1]) # cropStart = '' # cropLength = '' # cropTokens = [c for c in sys.argv if c.startswith('crop')] # if cropTokens: # if '=' in cropTokens[0]: # cropString = cropTokens[0].split('=')[1] # cropStart, cropLength = cropString.split(',') # else: # cropStart = 60 # cropLength = 180 # # denoiseTokens = [d for d in sys.argv if d.startswith('denoise')] # # for aStream in audioStreams: # if 'channel_layout' in aStream: # print(f"audio stream: {aStream['channel_layout']}") #channel_layout # else: # print(f"unknown audio stream with {aStream['channels']} channels") #channel_layout 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 # Define a subcommand @ffx.command() def version(): click.echo(VERSION) # Another subcommand @ffx.command() def help(): click.echo(f"ffx {VERSION}\n") click.echo(f"Usage: ffx [input file] [output file] [vp9|av1] [q=[nn[,nn,...]]] [p=nn] [a=nnn[k]] [ac3=nnn[k]] [dts=nnn[k]] [crop]") @click.argument('filename', nargs=1) @ffx.command() def streams(filename): for d in getStreamDescriptor(filename): click.echo(f"{d['codec']}{' (' + str(d['channels']) + ')' if d['type'] == 'audio' else ''}") @ffx.command() @click.pass_context @click.argument('paths', nargs=-1) @click.option('-l', '--label', type=str, default=DEFAULT_LABEL, help='Label to be used as filename prefix') @click.option('-ve', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER, help='Target video encoder (vp9 or av1) default: vp9') @click.option('-q', '--quality', type=str, default=DEFAULT_QUALITY, help='Quality settings to be used with VP9 encoder (default: 23)') @click.option('-p', '--preset', type=str, default=DEFAULT_QUALITY, help='Quality preset to be used with AV1 encoder (default: 5)') @click.option('-a', '--stereo-bitrate', type=int, default=DEFAULT_STEREO_BANDWIDTH, help='Bitrate in kbit/s to be used to encode stereo audio streams') @click.option('-ac3', '--ac3-bitrate', type=int, default=DEFAULT_AC3_BANDWIDTH, help='Bitrate in kbit/s to be used to encode 5.1 audio streams') @click.option('-dts', '--dts-bitrate', type=int, default=DEFAULT_DTS_BANDWIDTH, help='Bitrate in kbit/s to be used to encode 6.1 audio streams') @click.option('-ds', '--default-subtitle', type=int, help='Index of default subtitle stream') @click.option('-da', '--default-audio', type=int, help='Index of default audio stream') @click.option("--crop", is_flag=False, flag_value="default", default="none") @click.option("-c", "--clear-metadata", is_flag=True, default=False) @click.option("-d", "--denoise", is_flag=True, default=False) def convert(ctx, paths, label, video_encoder, quality, preset, stereo_bitrate, ac3_bitrate, dts_bitrate, crop, clear_metadata, default_subtitle, default_audio, denoise): startTime = time.perf_counter() if len(paths) != 2: raise click.BadArgumentUsage( 'Exactly 2 arguments containing the source file path and target file name must be provided\n' 'Usage: ffx convert source_path target_filename' ) sourcePath = paths[0] targetFilename = paths[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}") qualityTokens = quality.split(',') q_list = [q for q in qualityTokens if q.isnumeric()] click.echo(q_list) 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') if performCrop: cropTokens = crop.split(',') if cropTokens and len(cropTokens) == 2: cropStart, cropLength = crop.split(',') else: cropStart = DEFAULT_CROP_START cropLength = DEFAULT_CROP_LENGTH click.echo(f"crop start={cropStart} length={cropLength}") click.echo(f"\nRunning {len(q_list)} jobs") streamDescriptor = getStreamDescriptor(sourcePath) commandTokens = COMMAND_TOKENS + [sourcePath] for q in q_list: 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 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 = commandTokens + mappingTokens + audioTokens + generateAV1Tokens(q, preset) + audioTokens if clear_metadata: commandSequence += generateClearTokens(streamDescriptor) if performCrop: commandSequence += generateCropTokens(cropStart, cropLength) commandSequence += generateOutputTokens(targetFilename, DEFAULT_FILE_SUFFIX, q) click.echo(f"Command: {' '.join(commandSequence)}") executeProcess(commandSequence) if video_encoder == 'vp9': commandSequence1 = commandTokens + mappingVideoTokens + generateVP9Pass1Tokens(q) if performCrop: commandSequence1 += generateCropTokens(cropStart, cropLength) commandSequence1 += NULL_TOKENS click.echo(f"Command 1: {' '.join(commandSequence1)}") if os.path.exists(TEMP_FILE_NAME): os.remove(TEMP_FILE_NAME) executeProcess(commandSequence1) commandSequence2 = commandTokens + mappingTokens if denoise: commandSequence2 += generateDenoiseTokens() commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens if clear_metadata: commandSequence2 += generateClearTokens(streamDescriptor) if performCrop: commandSequence2 += generateCropTokens(cropStart, cropLength) commandSequence2 += generateOutputTokens(targetFilename, DEFAULT_FILE_SUFFIX, q) click.echo(f"Command 2: {' '.join(commandSequence2)}") executeProcess(commandSequence2) click.echo('\nDONE\n') endTime = time.perf_counter() click.echo(f"Time elapsed {endTime - startTime}") if __name__ == '__main__': ffx()