From 59503b566c7587b3b7dfd67bda84cc7d73c4e914 Mon Sep 17 00:00:00 2001 From: Maveno Date: Sat, 21 Sep 2024 23:01:45 +0200 Subject: [PATCH] inc stream handling --- bin/ffx.py | 206 ++++++++++++++++++++++++++--------- bin/ffx/ffx_controller.py | 2 + bin/ffx/file_pattern.py | 2 + bin/ffx/show.py | 2 + bin/ffx/show_controller.py | 2 + bin/ffx/stream_descriptor.py | 49 ++++++++- 6 files changed, 212 insertions(+), 51 deletions(-) create mode 100644 bin/ffx/ffx_controller.py create mode 100644 bin/ffx/file_pattern.py create mode 100644 bin/ffx/show.py create mode 100644 bin/ffx/show_controller.py diff --git a/bin/ffx.py b/bin/ffx.py index ba0bcc4..45e6d4f 100755 --- a/bin/ffx.py +++ b/bin/ffx.py @@ -299,7 +299,8 @@ def generateOutputTokens(filepath, format, ext): return ['-f', format, f"{filepath}.{ext}"] -def generateAudioTokens(context, index, layout): +def generateAudioEncodingTokens(context, index, layout): + """Generates ffmpeg options for one output audio stream including channel remapping, codec and bitrate""" if layout == STREAM_LAYOUT_6_1: return [f"-c:a:{index}", @@ -342,16 +343,25 @@ def generateClearTokens(streams): return clearTokens -def generateDispositionTokens(subDescriptor): +def getDispositionFlags(subStreamDescriptor): + return {k for (k,v) in subStreamDescriptor['disposition'].items() if v == 1} if 'disposition' in subStreamDescriptor.keys() else set() + + + +# def generateDispositionTokens(subDescriptor): +def generateDispositionTokens(subDescriptor, modifyOrder = []): """-disposition:s:X default+forced""" dispositionTokens = [] for subStreamIndex in range(len(subDescriptor)): + + sourceSubStreamIndex = modifyOrder[subStreamIndex] if modifyOrder else subStreamIndex + + subStream = subDescriptor[sourceSubStreamIndex] - subStream = subDescriptor[subStreamIndex] streamType = subStream['codec_type'][0] # v|a|s - dispositionFlags = {k for (k,v) in subStream['disposition'].items() if v == 1} if 'disposition' in subStream.keys() else set() + dispositionFlags = getDispositionFlags(subStream) if dispositionFlags: dispositionTokens += [f"-disposition:{streamType}:{subStreamIndex}", '+'.join(dispositionFlags)] @@ -360,6 +370,8 @@ def generateDispositionTokens(subDescriptor): return dispositionTokens +# def countStreamDispositions(subStreamDescriptor): +# return len([l for (k,v) in subStreamDescriptor['disposition'].items()]) def searchSubtitleFiles(dir, prefix): @@ -529,6 +541,7 @@ def convert(ctx, context['import_subtitles'] = (subtitle_directory and subtitle_prefix) availableFileSubtitleDescriptors = searchSubtitleFiles(subtitle_directory, subtitle_prefix) if context['import_subtitles'] else [] + # Overwrite audio tags if set audioLanguages = audio_language audioTitles = audio_title @@ -546,13 +559,18 @@ def convert(ctx, # Process crop parameters context['perform_crop'] = (crop != 'none') if context['perform_crop']: - cropTokens = crop.split(',') - if cropTokens and len(cropTokens) == 2: - context['crop_start'], context['crop_length'] = crop.split(',') + cTokens = crop.split(',') + if cTokens and len(cTokens) == 2: + cropStart, cropLength = crop.split(',') else: - context['crop_start'] = DEFAULT_CROP_START - context['crop_length'] = DEFAULT_CROP_LENGTH - click.echo(f"crop start={context['crop_start']} length={context['crop_length']}") + cropStart = DEFAULT_CROP_START + cropLength = DEFAULT_CROP_LENGTH + + click.echo(f"crop start={cropStart} length={cropLength}") + + cropTokens = generateCropTokens(int(cropStart), int(cropLength)) + else: + cropTokens = [] job_index = 0 @@ -597,6 +615,8 @@ def convert(ctx, else: file_index += 1 + matchingFileSubtitleDescriptors = sorted([d for d in availableFileSubtitleDescriptors if d['season'] == season and d['episode'] == episode], key=lambda d: d['stream']) if availableFileSubtitleDescriptors else [] + print(f"season={season} episode={episode} file={file_index}") @@ -628,9 +648,10 @@ def convert(ctx, click.echo(f"File with path {sourcePath} does not contain any audiovisual data, skipping ...") continue - ### + + ## ## ## targetStreamDescriptor = sourceStreamDescriptor.copy() - ### + ## ## ## click.echo('\nSource streams:') @@ -658,7 +679,7 @@ def convert(ctx, if forcedSubtitle == -1 and numForcedSubtitleStreams > 1: forcedSubtitle = click.prompt("More than one forced subtitle stream detected! Please select stream", type=int) - #Fix multiple default/forced tags + #Define default/forced tags if defaultAudio != -1: for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_AUDIO])): targetStreamDescriptor[STREAM_TYPE_AUDIO][substreamIndex]['disposition']['default'] = 1 if substreamIndex == defaultAudio else 0 @@ -670,7 +691,7 @@ def convert(ctx, targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition']['default'] = 1 if substreamIndex == defaultSubtitle else 0 if forcedSubtitle != -1: for substreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_SUBTITLE])): - targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition']['forded'] = 1 if substreamIndex == forcedSubtitle else 0 + targetStreamDescriptor[STREAM_TYPE_SUBTITLE][substreamIndex]['disposition']['forced'] = 1 if substreamIndex == forcedSubtitle else 0 # Set language and title in source stream descriptors if given per command line option @@ -687,7 +708,6 @@ def convert(ctx, targetStreamDescriptor[STREAM_TYPE_SUBTITLE][streamIndex]['tags']['title'] = subtitleTitles[streamIndex] - click.echo('\nTarget streams:') for aStream in targetStreamDescriptor[STREAM_TYPE_AUDIO]: click.echo(f"audio stream {aStream['sub_index']} lang={aStream['tags']['language']} title={aStream['tags']['title']} default={aStream['disposition']['default']} forced={aStream['disposition']['forced']}") @@ -695,10 +715,15 @@ def convert(ctx, click.echo(f"subtitle stream {sStream['sub_index']} lang={sStream['tags']['language']} title={sStream['tags']['title']} default={sStream['disposition']['default']} forced={sStream['disposition']['forced']}") - # Find source stream order - audioStreamSourceOrder = list(range(len(sourceStreamDescriptor[STREAM_TYPE_AUDIO]))) - subtitleStreamSourceOrder = list(range(len(sourceStreamDescriptor[STREAM_TYPE_SUBTITLE]))) + numSourceAudioSubStreams = len(sourceStreamDescriptor[STREAM_TYPE_AUDIO]) + numSourceSubtitleSubStreams = len(sourceStreamDescriptor[STREAM_TYPE_SUBTITLE]) + + # Stream order is just a list of integer + audioStreamSourceOrder = list(range(numSourceAudioSubStreams)) + subtitleStreamSourceOrder = list(range(numSourceSubtitleSubStreams)) + + # In order for the jellyfin media web UI to work properly the default/forced stream has to be the last in the sequence if jellyfin: defaultTargetAudioStreams = [a for a in targetStreamDescriptor[STREAM_TYPE_AUDIO] if a['disposition']['default'] == 1] @@ -710,33 +735,121 @@ def convert(ctx, subtitleStreamSourceOrder = getModifiedStreamOrder(len(sourceStreamDescriptor[STREAM_TYPE_SUBTITLE]), defaultTargetSubtitleStreams[0]['sub_index']) +# audioDispositionTokens = generateDispositionTokens(targetStreamDescriptor[STREAM_TYPE_AUDIO]) +# subtitleDispositionTokens = generateDispositionTokens(targetStreamDescriptor[STREAM_TYPE_SUBTITLE]) + + audioDispositionTokens = generateDispositionTokens(targetStreamDescriptor[STREAM_TYPE_AUDIO], modifyOrder = audioStreamSourceOrder) + subtitleDispositionTokens = generateDispositionTokens(targetStreamDescriptor[STREAM_TYPE_SUBTITLE], modifyOrder = subtitleStreamSourceOrder) - mappingVideoTokens = ['-map', '0:v:0'] + + mappingVideoTokens = ['-map', '0:v:0'] mappingTokens = mappingVideoTokens.copy() + dispositionTokens = [] - audioTokens = [] + audioEncodingTokens = [] audioMetadataTokens = [] - for audioStreamIndex in range(len(sourceStreamDescriptor[STREAM_TYPE_AUDIO])): + for audioStreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_AUDIO])): + # Modify selected source audio stream for jellyfin if required sourceAudioStreamIndex = audioStreamSourceOrder[audioStreamIndex] - sourceAudioStream = sourceStreamDescriptor[STREAM_TYPE_AUDIO][sourceAudioStreamIndex] - # Create mapping and ffmpeg options for audio streams - # mappingTokens += ['-map', f"0:a:{sourceAudioStreamIndex['src_sub_index']}"] + # Add audio mapping tokens to list of general mapping tokens mappingTokens += ['-map', f"0:a:{sourceAudioStreamIndex}"] - # audioTokens += generateAudioTokens(context, sourceAudioStream['src_sub_index'], sourceAudioStream['layout']) - audioTokens += generateAudioTokens(context, sourceAudioStreamIndex, sourceAudioStream['audio_layout']) - if sourceStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['language'] != targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['language']: - audioMetadataTokens += [f"-metadata:s:a:{audioStreamIndex}", f"language={targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['language']}"] + targetAudioStream = targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex] + + # audioEncodingTokens += generateAudioEncodingTokens(context, sourceAudioStream['src_sub_index'], sourceAudioStream['layout']) + audioEncodingTokens += generateAudioEncodingTokens(context, audioStreamIndex, targetAudioStream['audio_layout']) + + if sourceStreamDescriptor[STREAM_TYPE_AUDIO][sourceAudioStreamIndex]['tags']['language'] != targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['language']: + audioMetadataTokens += [f"-metadata:s:a:{audioStreamIndex}", f"language={targetStreamDescriptor[STREAM_TYPE_AUDIO][sourceAudioStreamIndex]['tags']['language']}"] + + if sourceStreamDescriptor[STREAM_TYPE_AUDIO][sourceAudioStreamIndex]['tags']['title'] != targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['title']: + audioMetadataTokens += [f"-metadata:s:a:{audioStreamIndex}", f"title={targetStreamDescriptor[STREAM_TYPE_AUDIO][sourceAudioStreamIndex]['tags']['title']}"] + + # targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['disposition']['default'] = 1 if streamIndex == defaultAudio else 0 + + + subtitleImportFileTokens = [] + subtitleMetadataTokens = [] + + if context['import_subtitles'] and numSourceSubtitleSubStreams != len(matchingFileSubtitleDescriptors): + click.echo(f"The number of subtitle streams found in file with path {sourcePath} is different from the number of subtitle streams provided by matching imported files, skipping ...") + continue + + # 0: Quelle f1 = forced + # 1: QUelle f2 = full + + for subtitleStreamIndex in range(len(targetStreamDescriptor[STREAM_TYPE_SUBTITLE])): + + # Modify selected source subtitle stream for jellyfin if required + sourceSubtitleStreamIndex = subtitleStreamSourceOrder[subtitleStreamIndex] + + + if context['import_subtitles']: + + fileSubtitleDescriptor = matchingFileSubtitleDescriptors[subtitleStreamIndex] # original order + + subtitleImportFileTokens += ['-i', fileSubtitleDescriptor['path']] # original order + + # Create mapping for subtitle streams when imported from files + mappingTokens += ['-map', f"{sourceSubtitleStreamIndex+1}:s:0"] # modified order + + + if fileSubtitleDescriptor['language'] != targetStreamDescriptor[STREAM_TYPE_SUBTITLE][subtitleStreamIndex]['tags']['language']: + subtitleMetadataTokens += [f"-metadata:s:s:{sourceSubtitleStreamIndex}", f"language={targetStreamDescriptor[STREAM_TYPE_SUBTITLE][subtitleStreamIndex]['tags']['language']}"] + + subtitleMetadataTokens += [f"-metadata:s:s:{sourceSubtitleStreamIndex}", f"title={targetStreamDescriptor[STREAM_TYPE_SUBTITLE][subtitleStreamIndex]['tags']['title']}"] + + else: + + # Add subtitle mapping tokens to list of general mapping tokens + mappingTokens += ['-map', f"0:s:{sourceSubtitleStreamIndex}"] + + if sourceStreamDescriptor[STREAM_TYPE_SUBTITLE][sourceSubtitleStreamIndex]['tags']['language'] != targetStreamDescriptor[STREAM_TYPE_SUBTITLE][subtitleStreamIndex]['tags']['language']: + subtitleMetadataTokens += [f"-metadata:s:s:{subtitleStreamIndex}", f"language={targetStreamDescriptor[STREAM_TYPE_SUBTITLE][subtitleStreamIndex]['tags']['language']}"] + + if sourceStreamDescriptor[STREAM_TYPE_SUBTITLE][sourceSubtitleStreamIndex]['tags']['title'] != targetStreamDescriptor[STREAM_TYPE_SUBTITLE][subtitleStreamIndex]['tags']['title']: + subtitleMetadataTokens += [f"-metadata:s:s:{subtitleStreamIndex}", f"title={targetStreamDescriptor[STREAM_TYPE_SUBTITLE][subtitleStreamIndex]['tags']['title']}"] + + + + + + +# # Reorder audio stream descriptors and create disposition options if default is given per command line option +# if defaultAudio == -1: +# sourceAudioStreams = audioStreams +# else: +# for streamIndex in range(len(audioStreams)): +# audioStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == defaultAudio else 0 +# +# sourceAudioStreams = getReorderedSubstreams(audioStreams, defaultAudio) if jellyfin else audioStreams +# +# dispositionTokens += generateDispositionTokens(sourceAudioStreams) +# +# # Set forced tag in subtitle descriptor if given per command line option +# if forcedSubtitle != -1: +# for streamIndex in range(len(subtitleStreams)): +# subtitleStreams[streamIndex]['disposition']['forced'] = 1 if streamIndex == forcedSubtitle else 0 +# +# # Reorder subtitle stream descriptors and create disposition options if default is given per command line option +# if defaultSubtitle == -1: +# sourceSubtitleStreams = subtitleStreams +# else: +# for streamIndex in range(len(subtitleStreams)): +# subtitleStreams[streamIndex]['disposition']['default'] = 1 if streamIndex == defaultSubtitle else 0 +# +# sourceSubtitleStreams = getReorderedSubstreams(subtitleStreams, defaultSubtitle) if jellyfin else subtitleStreams +# +# dispositionTokens += generateDispositionTokens(sourceSubtitleStreams) +# - if sourceStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['title'] != targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['title']: - audioMetadataTokens += [f"-metadata:s:a:{audioStreamIndex}", f"title={targetStreamDescriptor[STREAM_TYPE_AUDIO][audioStreamIndex]['tags']['title']}"] @@ -747,17 +860,12 @@ def convert(ctx, commandTokens = COMMAND_TOKENS + ['-i', sourcePath] - subtitleFileTokens = [] - subtitleMetadataTokens = [] # matchingSubtitles = [] # if context['import_subtitles']: # -# subtitles = [a for a in availableFileSubtitleDescriptors if a['season'] == season and a['episode'] == episode] -# mSubtitles = sorted(subtitles, key=lambda d: d['stream']) -# -# for sfd in mSubtitles: -# subtitleFileTokens += ['-i', sfd['path']] + + # # for streamIndex in range(len(mSubtitles)): # mSubtitles[streamIndex]['forced'] = 1 if forcedSubtitle != -1 and streamIndex == forcedSubtitle else 0 @@ -863,19 +971,19 @@ def convert(ctx, if video_encoder == 'av1': commandSequence = (commandTokens - + subtitleFileTokens + + subtitleImportFileTokens + mappingTokens - + dispositionTokens + audioMetadataTokens + subtitleMetadataTokens - + audioTokens - + generateAV1Tokens(q, preset) + audioTokens) + + audioDispositionTokens + + subtitleDispositionTokens + + audioEncodingTokens + + generateAV1Tokens(q, preset) + audioEncodingTokens) if clear_metadata: commandSequence += generateClearTokens(sourceStreamDescriptor) - if context['perform_crop']: - commandSequence += generateCropTokens(context['crop_start'], context['crop_length']) + commandSequence += cropTokens commandSequence += generateOutputTokens(targetFilename, DEFAULT_FILE_FORMAT, DEFAULT_FILE_EXTENSION) @@ -889,8 +997,7 @@ def convert(ctx, commandSequence1 = commandTokens + mappingVideoTokens + generateVP9Pass1Tokens(q) - if context['perform_crop']: - commandSequence1 += generateCropTokens(context['crop_start'], context['crop_length']) + commandSequence1 += cropTokens commandSequence1 += NULL_TOKENS @@ -904,22 +1011,23 @@ def convert(ctx, commandSequence2 = (commandTokens - + subtitleFileTokens + + subtitleImportFileTokens + mappingTokens + audioMetadataTokens + subtitleMetadataTokens + + audioDispositionTokens + + subtitleDispositionTokens + dispositionTokens) if denoise: commandSequence2 += generateDenoiseTokens() - commandSequence2 += generateVP9Pass2Tokens(q) + audioTokens + commandSequence2 += generateVP9Pass2Tokens(q) + audioEncodingTokens if clear_metadata: commandSequence2 += generateClearTokens(sourceStreamDescriptor) - if context['perform_crop']: - commandSequence2 += generateCropTokens(context['crop_start'], context['crop_length']) + commandSequence2 += cropTokens commandSequence2 += generateOutputTokens(targetFilename, DEFAULT_FILE_FORMAT, DEFAULT_FILE_EXTENSION) diff --git a/bin/ffx/ffx_controller.py b/bin/ffx/ffx_controller.py new file mode 100644 index 0000000..6892bae --- /dev/null +++ b/bin/ffx/ffx_controller.py @@ -0,0 +1,2 @@ +class FfxController(): + pass diff --git a/bin/ffx/file_pattern.py b/bin/ffx/file_pattern.py new file mode 100644 index 0000000..71991b7 --- /dev/null +++ b/bin/ffx/file_pattern.py @@ -0,0 +1,2 @@ +class FilePattern(): + pass diff --git a/bin/ffx/show.py b/bin/ffx/show.py new file mode 100644 index 0000000..175052a --- /dev/null +++ b/bin/ffx/show.py @@ -0,0 +1,2 @@ +class Show(): + pass diff --git a/bin/ffx/show_controller.py b/bin/ffx/show_controller.py new file mode 100644 index 0000000..931f771 --- /dev/null +++ b/bin/ffx/show_controller.py @@ -0,0 +1,2 @@ +class ShowController(): + pass diff --git a/bin/ffx/stream_descriptor.py b/bin/ffx/stream_descriptor.py index 83b9096..6933f5e 100644 --- a/bin/ffx/stream_descriptor.py +++ b/bin/ffx/stream_descriptor.py @@ -1,11 +1,56 @@ -from enum import Enum from language_data import LanguageData from stream_type import StreamType class StreamDescriptor(): - def __init__(self, streamType : StreamType, language : LanguageData, title : str): + def __init__(self, + streamType : StreamType, + language : LanguageData, + title : str, + codec : str, + subIndex : int = -1): self.__streamType = streamType + self.__subIndex = subIndex + self.__streamLanguage = language self.__streamTitle = title + + self.__codecName = codec + +# "index": 4, +# "codec_name": "hdmv_pgs_subtitle", +# "codec_long_name": "HDMV Presentation Graphic Stream subtitles", +# "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": 0, +# "start_time": "0.000000", +# "duration_ts": 1421035, +# "duration": "1421.035000", +# "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": "German Full"