diff --git a/.gitignore b/.gitignore index 472e594..d158bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ __pycache__ junk/ -.vscode/launch.json +.vscode .ipynb_checkpoints/ diff --git a/bin/ffx.py b/bin/ffx.py index df23611..00520a8 100755 --- a/bin/ffx.py +++ b/bin/ffx.py @@ -17,6 +17,7 @@ from ffx.track_descriptor import TrackDescriptor from ffx.track_type import TrackType from ffx.video_encoder import VideoEncoder from ffx.track_disposition import TrackDisposition +from ffx.nlmeans_controller import NlmeansController from ffx.process import executeProcess from ffx.helper import filterFilename @@ -262,38 +263,42 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor): @click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix') -@click.option('-v', '--video-encoder', type=str, default=FfxController.DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1) default: {FfxController.DEFAULT_VIDEO_ENCODER}") +@click.option('-v', '--video-encoder', type=str, default=FfxController.DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9 or av1)", show_default=True) -@click.option('-q', '--quality', type=str, default=DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder (default: {DEFAULT_QUALITY})") -@click.option('-p', '--preset', type=str, default=DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder (default: {DEFAULT_AV1_PRESET})") +@click.option('-q', '--quality', type=str, default=DEFAULT_QUALITY, help=f"Quality settings to be used with VP9 encoder", show_default=True) +@click.option('-p', '--preset', type=str, default=DEFAULT_AV1_PRESET, help=f"Quality preset to be used with AV1 encoder", show_default=True) -@click.option('-a', '--stereo-bitrate', type=int, default=DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams (default: {DEFAULT_STEREO_BANDWIDTH})") -@click.option('--ac3', type=int, default=DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams (default: {DEFAULT_AC3_BANDWIDTH})") -@click.option('--dts', type=int, default=DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams (default: {DEFAULT_DTS_BANDWIDTH})") +@click.option('-a', '--stereo-bitrate', type=int, default=DEFAULT_STEREO_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode stereo audio streams", show_default=True) +@click.option('--ac3', type=int, default=DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams", show_default=True) +@click.option('--dts', type=int, default=DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams", show_default=True) @click.option('--subtitle-directory', type=str, default='', help='Load subtitles from here') @click.option('--subtitle-prefix', type=str, default='', help='Subtitle filename prefix') +@click.option('--language', type=str, multiple=True, help='Set stream language. Use format :<3 letter iso code>') +@click.option('--title', type=str, multiple=True, help='Set stream title. Use format :') -@click.option('--audio-language', type=str, multiple=True, help='Audio stream language(s)') -@click.option('--audio-title', type=str, multiple=True, help='Audio stream title(s)') - +@click.option('--default-video', type=int, default=-1, help='Index of default video stream') +@click.option('--forced-video', type=int, default=-1, help='Index of forced video stream') @click.option('--default-audio', type=int, default=-1, help='Index of default audio stream') @click.option('--forced-audio', type=int, default=-1, help='Index of forced audio stream') - - -@click.option('--subtitle-language', type=str, multiple=True, help='Subtitle stream language(s)') -@click.option('--subtitle-title', type=str, multiple=True, help='Subtitle stream title(s)') - @click.option('--default-subtitle', type=int, default=-1, help='Index of default subtitle stream') -@click.option('--forced-subtitle', type=int, default=-1, help='Index of forced subtitle stream') # (including default audio stream tag) +@click.option('--forced-subtitle', type=int, default=-1, help='Index of forced subtitle stream') +@click.option('--rearrange-streams', type=str, default="", help='Rearrange output streams order. Use format comma separated integers') @click.option("--crop", is_flag=False, flag_value="default", default="none") @click.option("--output-directory", type=str, default='') -@click.option("--denoise", is_flag=True, default=False) +@click.option("--denoise", is_flag=False, flag_value="default", default="none") +@click.option("--denoise-use-hw", is_flag=True, default=False) +@click.option('--denoise-strength', type=str, default='', help='Denoising strength, more blurring vs more details.') +@click.option('--denoise-patch-size', type=str, default='', help='Subimage size to apply filtering on luminosity plane. Reduces broader noise patterns but costly.') +@click.option('--denoise-chroma-patch-size', type=str, default='', help='Subimage size to apply filtering on chroma planes.') +@click.option('--denoise-research-window', type=str, default='', help='Range to search for comparable patches on luminosity plane. Better filtering but costly.') +@click.option('--denoise-chroma-research-window', type=str, default='', help='Range to search for comparable patches on chroma planes.') + @click.option("--no-tmdb", is_flag=True, default=False) # @click.option("--no-jellyfin", is_flag=True, default=False) @@ -318,19 +323,29 @@ def convert(ctx, subtitle_directory, subtitle_prefix, - audio_language, - audio_title, + language, + title, + + default_video, + forced_video, default_audio, forced_audio, - - subtitle_language, - subtitle_title, default_subtitle, forced_subtitle, + rearrange_streams, + crop, output_directory, + denoise, + denoise_use_hw, + denoise_strength, + denoise_patch_size, + denoise_chroma_patch_size, + denoise_research_window, + denoise_chroma_research_window, + no_tmdb, # no_jellyfin, no_pattern, @@ -360,12 +375,70 @@ def convert(ctx, context['no_signature'] = no_signature context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata + context['denoiser'] = NlmeansController(parameters = denoise, + strength = denoise_strength, + patchSize = denoise_patch_size, + chromaPatchSize = denoise_chroma_patch_size, + researchWindow = denoise_research_window, + chromaResearchWindow = denoise_chroma_research_window, + useHardware = denoise_use_hw) context['import_subtitles'] = (subtitle_directory and subtitle_prefix) if context['import_subtitles']: context['subtitle_directory'] = subtitle_directory context['subtitle_prefix'] = subtitle_prefix + + cliOverrides = {} + + if language: + cliOverrides['languages'] = {} + for overLang in language: + olTokens = overLang.split(':') + if len(olTokens) == 2: + try: + cliOverrides['languages'][int(olTokens[0])] = olTokens[1] + except ValueError: + ctx.obj['logger'].warning(f"Ignoring non-integer language index {olTokens[0]}") + continue + + if title: + cliOverrides['titles'] = {} + for overTitle in title: + otTokens = overTitle.split(':') + if len(otTokens) == 2: + try: + cliOverrides['titles'][int(otTokens[0])] = otTokens[1] + except ValueError: + ctx.obj['logger'].warning(f"Ignoring non-integer title index {otTokens[0]}") + continue + + if default_video != -1: + cliOverrides['default_video'] = default_video + if forced_video != -1: + cliOverrides['forced_video'] = forced_video + if default_audio != -1: + cliOverrides['default_audio'] = default_audio + if forced_audio != -1: + cliOverrides['forced_audio'] = forced_audio + if default_subtitle != -1: + cliOverrides['default_subtitle'] = default_subtitle + if forced_subtitle != -1: + cliOverrides['forced_subtitle'] = forced_subtitle + + if cliOverrides: + context['overrides'] = cliOverrides + + + if rearrange_streams: + try: + cliOverrides['stream_order'] = [int(si) for si in rearrange_streams.split(",")] + except ValueError as ve: + errorMessage = "Non-integer in rearrange stream parameter" + ctx.obj['logger'].error(errorMessage) + raise click.Abort() + + ctx.obj['logger'].debug(f"\nVideo encoder: {video_encoder}") qualityTokens = quality.split(',') @@ -437,10 +510,10 @@ def convert(ctx, mediaFileProperties.getSeason(), mediaFileProperties.getEpisode()) - # if context['use_jellyfin']: - # # Reorder subtracks in types with default the last, then make subindices flat again - # sourceMediaDescriptor.applyJellyfinOrder() + if cliOverrides: + sourceMediaDescriptor.applyOverrides(cliOverrides) + #YOLO fc = FfxController(context, sourceMediaDescriptor) @@ -483,9 +556,10 @@ def convert(ctx, ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}") - # if context['use_jellyfin']: - # # Reorder subtracks in types with default the last, then make subindices flat again - # targetMediaDescriptor.applyJellyfinOrder() + + if cliOverrides: + targetMediaDescriptor.applyOverrides(cliOverrides) + ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}") @@ -525,8 +599,7 @@ def convert(ctx, targetPath, context['video_encoder'], q, - preset, - denoise) + preset) #TODO: click.confirm('Warning! This file is not compliant to the defined source schema! Do you want to continue?', abort=True) diff --git a/bin/ffx/ffx_controller.py b/bin/ffx/ffx_controller.py index 4ca5689..9c69f3c 100644 --- a/bin/ffx/ffx_controller.py +++ b/bin/ffx/ffx_controller.py @@ -112,44 +112,6 @@ class FfxController(): return ['-ss', str(cropStart), '-t', str(cropLength)] - def generateDenoiseTokens(self, - strength: float = 2.8, - patchSize: int = 12, - chromaPatchSize: int = 8, - researchWindow: int = 22, - chromaResearchWindow: int= 16, - useHardware: bool = False): - """ - s: double - - Denoising strength (from 1 to 30) (default 1) - Trade-off between noise removal and detail retention. Comparable to gaussian sigma. - - p: int patch size (from 0 to 99) (default 7) - - Catches larger areas reducing broader noise patterns, but costly - - pc: int patch size for chroma planes (from 0 to 99) (default 0) - - r: int research window (from 0 to 99) (default 15) - - Range to search for comparable patches. - Better filtering but costly - - rc: int research window for chroma planes (from 0 to 99) (default 0) - - Good values to denoise film grain that was subobtimally encoded: - strength: float = 2.8 - patchSize: int = 12 - chromaPatchSize: int = 8 - researchWindow: int = 22 - chromaResearchWindow: int= 16 - """ - - filterName = 'nlmeans_opencl' if useHardware else 'nlmeans' - return ['-vf', f"{filterName}=s={strength}:p={patchSize}:pc={chromaPatchSize}:r={researchWindow}:rc={chromaResearchWindow}"] - - def generateOutputTokens(self, filepath, format, ext): outputFilePath = f"{filepath}.{ext}" return ['-f', format, outputFilePath] @@ -300,10 +262,8 @@ class FfxController(): targetPath, videoEncoder: VideoEncoder = VideoEncoder.VP9, quality: int = DEFAULT_QUALITY, - preset: int = DEFAULT_AV1_PRESET, - denoise: bool = False): + preset: int = DEFAULT_AV1_PRESET): - # self.__targetMediaDescriptor order OK commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath] @@ -314,11 +274,12 @@ class FfxController(): + self.__targetMediaDescriptor.getInputMappingTokens() + self.generateDispositionTokens()) - if not self.__sourceMediaDescriptor is None: + if not self.__sourceMediaDescriptor is None or 'overrides' in self.__context.keys(): commandSequence += self.generateMetadataTokens() - if denoise: - commandSequence += self.generateDenoiseTokens() + # if denoise: + # commandSequence += self.generateDenoiseTokens() + commandSequence1 += self.__context['denoiser'].generateDenoiseTokens() commandSequence += (self.generateAudioEncodingTokens() + self.generateAV1Tokens(int(quality), int(preset)) @@ -361,11 +322,12 @@ class FfxController(): + self.__targetMediaDescriptor.getInputMappingTokens() + self.generateDispositionTokens()) - if not self.__sourceMediaDescriptor is None: + if not self.__sourceMediaDescriptor is None or 'overrides' in self.__context.keys(): commandSequence2 += self.generateMetadataTokens() - if denoise: - commandSequence2 += self.generateDenoiseTokens() + # if denoise: + # commandSequence2 += self.generateDenoiseTokens() + commandSequence2 += self.__context['denoiser'].generateDenoiseTokens() commandSequence2 += self.generateVP9Pass2Tokens(int(quality)) + self.generateAudioEncodingTokens() diff --git a/bin/ffx/media_descriptor.py b/bin/ffx/media_descriptor.py index 1324adc..11937b4 100644 --- a/bin/ffx/media_descriptor.py +++ b/bin/ffx/media_descriptor.py @@ -3,6 +3,8 @@ import os, re, click, logging from typing import List, Self from ffx.track_type import TrackType +from ffx.iso_language import IsoLanguage + from ffx.track_disposition import TrackDisposition from ffx.track_descriptor import TrackDescriptor @@ -70,17 +72,42 @@ class MediaDescriptor: else: self.__trackDescriptors = [] - # if MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY in kwargs.keys(): - # if type(kwargs[MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY]) is not bool: - # raise TypeError( - # f"MediaDescriptor.__init__(): Argument {MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY} is required to be of type bool" - # ) - # self.__jellyfinOrder = kwargs[MediaDescriptor.JELLYFIN_ORDER_FLAG_KEY] - # else: - # self.__jellyfinOrder = False - # self.__jellyfinOrder = self.__context['use_jellyfin'] if 'use_jellyfin' in self.__context.keys() else False + #TODO: to be removed self.__jellyfinOrder = False + def setTrackLanguage(self, language: str, index: int, trackType: TrackType = None): + + trackLanguage = IsoLanguage.findThreeLetter(language) + if trackLanguage == IsoLanguage.UNDEFINED: + self.__logger.warning('MediaDescriptor.setTrackLanguage(): Parameter language does not contain a registered ' + + f"ISO 639 3-letter language code, skipping to set language for" + + str('' if trackType is None else trackType.label()) + f"track {index}") + + trackList = self.getTrackDescriptors(trackType=trackType) + + if index < 0 or index > len(trackList) - 1: + self.__logger.warning(f"MediaDescriptor.setTrackLanguage(): Parameter index ({index}) is " + + f"out of range of {'' if trackType is None else trackType.label()}track list") + + td: TrackDescriptor = trackList[index] + td.setLanguage(trackLanguage) + + return + + + def setTrackTitle(self, title: str, index: int, trackType: TrackType = None): + + trackList = self.getTrackDescriptors(trackType=trackType) + + if index < 0 or index > len(trackList) - 1: + self.__logger.error(f"MediaDescriptor.setTrackTitle(): Parameter index ({index}) is " + + f"out of range of {'' if trackType is None else trackType.label()}track list") + raise click.Abort() + + td: TrackDescriptor = trackList[index] + td.setTitle(title) + + def setDefaultSubTrack(self, trackType: TrackType, subIndex: int): for t in self.getAllTrackDescriptors(): if t.getType() == trackType: @@ -123,6 +150,47 @@ class MediaDescriptor: raise ValueError('Multiple streams originating from the same source stream') + def applyOverrides(self, overrides: dict): + + if 'languages' in overrides.keys(): + for trackIndex in overrides['languages'].keys(): + self.setTrackLanguage(overrides['languages'][trackIndex], trackIndex) + + if 'titles' in overrides.keys(): + for trackIndex in overrides['titles'].keys(): + self.setTrackTitle(overrides['titles'][trackIndex], trackIndex) + + if 'forced_video' in overrides.keys(): + sti = int(overrides['forced_video']) + self.setForcedSubTrack(TrackType.VIDEO, sti) + self.setDefaultSubTrack(TrackType.VIDEO, sti) + + elif 'default_video' in overrides.keys(): + sti = int(overrides['default_video']) + self.setDefaultSubTrack(TrackType.VIDEO, sti) + + if 'forced_audio' in overrides.keys(): + sti = int(overrides['forced_audio']) + self.setForcedSubTrack(TrackType.AUDIO, sti) + self.setDefaultSubTrack(TrackType.AUDIO, sti) + + elif 'default_audio' in overrides.keys(): + sti = int(overrides['default_audio']) + self.setDefaultSubTrack(TrackType.AUDIO, sti) + + if 'forced_subtitle' in overrides.keys(): + sti = int(overrides['forced_subtitle']) + self.setForcedSubTrack(TrackType.SUBTITLE, sti) + self.setDefaultSubTrack(TrackType.SUBTITLE, sti) + + elif 'default_subtitle' in overrides.keys(): + sti = int(overrides['default_subtitle']) + self.setDefaultSubTrack(TrackType.SUBTITLE, sti) + + if 'stream_order' in overrides.keys(): + self.rearrangeTrackDescriptors(overrides['stream_order']) + + def applySourceIndices(self, sourceMediaDescriptor: Self): sourceTrackDescriptors = sourceMediaDescriptor.getAllTrackDescriptors() @@ -131,51 +199,16 @@ class MediaDescriptor: raise ValueError('MediaDescriptor.applySourceIndices (): Number of track descriptors does not match') for trackIndex in range(numTrackDescriptors): - # click.echo(f"{trackIndex} -> {sourceTrackDescriptors[trackIndex].getSourceIndex()}") self.__trackDescriptors[trackIndex].setSourceIndex(sourceTrackDescriptors[trackIndex].getSourceIndex()) - def applyJellyfinOrder(self): - """Reorder subtracks in types with default the last, then make subindices flat again""" - - # videoTracks = self.sortSubIndices(self.getVideoTracks()) - # audioTracks = self.sortSubIndices(self.getAudioTracks()) - # subtitleTracks = self.sortSubIndices(self.getSubtitleTracks()) - - self.checkConfiguration() - - # from self.__trackDescriptors - videoTracks = self.getVideoTracks() - audioTracks = self.getAudioTracks() - subtitleTracks = self.getSubtitleTracks() - - defaultVideoTracks = [v for v in videoTracks if v.getDispositionFlag(TrackDisposition.DEFAULT)] - defaultAudioTracks = [a for a in audioTracks if a.getDispositionFlag(TrackDisposition.DEFAULT)] - defaultSubtitleTracks = [s for s in subtitleTracks if s.getDispositionFlag(TrackDisposition.DEFAULT)] - - if defaultVideoTracks: - videoTracks.append(videoTracks.pop(videoTracks.index(defaultVideoTracks[0]))) - #self.sortSubIndices(videoTracks) - numVideoTracks = len(videoTracks) - for vIndex in range(numVideoTracks): - videoTracks[vIndex].setDispositionFlag(TrackDisposition.DEFAULT, - vIndex == numVideoTracks - 1) - if defaultAudioTracks: - audioTracks.append(audioTracks.pop(audioTracks.index(defaultAudioTracks[0]))) - #self.sortSubIndices(audioTracks) - numAudioTracks = len(audioTracks) - for aIndex in range(numAudioTracks): - audioTracks[aIndex].setDispositionFlag(TrackDisposition.DEFAULT, - aIndex == numAudioTracks - 1) - if defaultSubtitleTracks: - subtitleTracks.append(subtitleTracks.pop(subtitleTracks.index(defaultSubtitleTracks[0]))) - #self.sortSubIndices(subtitleTracks) - numSubtitleTracks = len(subtitleTracks) - for sIndex in range(numSubtitleTracks): - subtitleTracks[sIndex].setDispositionFlag(TrackDisposition.DEFAULT, - sIndex == numSubtitleTracks - 1) - self.__trackDescriptors = videoTracks + audioTracks + subtitleTracks - #self.sortIndices(self.__trackDescriptors) + def rearrangeTrackDescriptors(self, newOrder: List[int]): + if len(newOrder) != len(self.__trackDescriptors): + raise ValueError('Length of list with reordered indices does not match number of track descriptors') + reorderedTrackDescriptors = {} + for oldIndex in newOrder: + reorderedTrackDescriptors.append(self.__trackDescriptors[oldIndex]) + self.__trackDescriptors = reorderedTrackDescriptors self.reindexSubIndices() self.reindexIndices() @@ -254,18 +287,30 @@ class MediaDescriptor: tdList[trackIndex].setIndex(trackIndex) - def getAllTrackDescriptors(self) -> List[TrackDescriptor]: + def getAllTrackDescriptors(self): + """Returns all track descriptors sorted by type: video, audio then subtitles""" return self.getVideoTracks() + self.getAudioTracks() + self.getSubtitleTracks() + + def getTrackDescriptors(self, + trackType: TrackType = None) -> List[TrackDescriptor]: + + if trackType is None: + return self.__trackDescriptors + + descriptorList = [] + for td in self.__trackDescriptors: + if td.getType() == trackType: + descriptorList.append(td) + + return descriptorList + + def getVideoTracks(self) -> List[TrackDescriptor]: - return [ - v for v in self.__trackDescriptors if v.getType() == TrackType.VIDEO - ] + return [v for v in self.__trackDescriptors if v.getType() == TrackType.VIDEO] def getAudioTracks(self) -> List[TrackDescriptor]: - return [ - a for a in self.__trackDescriptors if a.getType() == TrackType.AUDIO - ] + return [a for a in self.__trackDescriptors if a.getType() == TrackType.AUDIO] def getSubtitleTracks(self) -> List[TrackDescriptor]: return [ @@ -278,10 +323,8 @@ class MediaDescriptor: def compare(self, vsMediaDescriptor: Self): if not isinstance(vsMediaDescriptor, self.__class__): - errorMessage = f"MediaDescriptor.compare(): Argument is required to be of type {self.__class__}" - self.__logger.error(errorMessage) - # raise click.ClickException(errorMessage) - click.Abort() + self.__logger.error(f"MediaDescriptor.compare(): Argument is required to be of type {self.__class__}") + raise click.Abort() vsTags = vsMediaDescriptor.getTags() tags = self.getTags() @@ -357,10 +400,8 @@ class MediaDescriptor: def getImportFileTokens(self, use_sub_index: bool = True): - # reorderedTrackDescriptors = self.getReorderedTrackDescriptors() importFileTokens = [] - #for rtd in reorderedTrackDescriptors: for td in self.__trackDescriptors: importedFilePath = td.getExternalSourceFilePath() @@ -377,14 +418,6 @@ class MediaDescriptor: def getInputMappingTokens(self, use_sub_index: bool = True, only_video: bool = False): """Tracks must be reordered for source index order""" - # sourceTrackDescriptorSubIndices = [self.__trackDescriptors[std.getSourceIndex()].getSubIndex() - # for std in self.__trackDescriptors] - - # self.reindexSubIndices(trackDescriptors = sourceOrderTrackDescriptors) - # self.reindexIndices(trackDescriptors = sourceOrderTrackDescriptors) - - # click.echo(sourceTrackDescriptorIndices) - inputMappingTokens = [] filePointer = 1 @@ -467,17 +500,12 @@ class MediaDescriptor: availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix) - # click.echo(f"availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}") self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}") subtitleTracks = self.getSubtitleTracks() - # click.echo(f"subtitleTracks: {[s.getIndex() for s in subtitleTracks]}") self.__logger.debug(f"importSubtitles(): subtitleTracks: {[s.getIndex() for s in subtitleTracks]}") - # if len(availableFileSubtitleDescriptors) != len(subtitleTracks): - # raise click.ClickException(f"MediaDescriptor.importSubtitles(): Number if subtitle files not matching number of subtitle tracks") - matchingSubtitleFileDescriptors = ( sorted( [ @@ -491,9 +519,7 @@ class MediaDescriptor: else [] ) - # click.echo(f"matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}") self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}") - # click.echo(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}") for msfd in matchingSubtitleFileDescriptors: matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]] diff --git a/bin/ffx/nlmeans_controller.py b/bin/ffx/nlmeans_controller.py new file mode 100644 index 0000000..1de91ca --- /dev/null +++ b/bin/ffx/nlmeans_controller.py @@ -0,0 +1,142 @@ +class NlmeansController(): + """ + s: double + + Denoising strength (from 1 to 30) (default 1) + Trade-off between noise removal and detail retention. Comparable to gaussian sigma. + + p: int patch size (from 0 to 99) (default 7) + + Catches larger areas reducing broader noise patterns, but costly + + pc: int patch size for chroma planes (from 0 to 99) (default 0) + + r: int research window (from 0 to 99) (default 15) + + Range to search for comparable patches. + Better filtering but costly + + rc: int research window for chroma planes (from 0 to 99) (default 0) + + Good values to denoise film grain that was subobtimally encoded: + strength: float = 2.8 + patchSize: int = 12 + chromaPatchSize: int = 8 + researchWindow: int = 22 + chromaResearchWindow: int= 16 + """ + + DEFAULT_STRENGTH: float = 2.8 + DEFAULT_PATCH_SIZE: int = 13 + DEFAULT_CHROMA_PATCH_SIZE: int = 9 + DEFAULT_RESEARCH_WINDOW: int = 23 + DEFAULT_CHROMA_RESEARCH_WINDOW: int= 17 + + def __init__(self, + parameters: str = "none", + strength: str = "", + patchSize: str = "", + chromaPatchSize: str = "", + researchWindow: str = "", + chromaResearchWindow: str = "", + useHardware: bool = False): + + self.__isActive = (parameters != "none" + or strength + or patchSize + or chromaPatchSize + or researchWindow + or chromaResearchWindow) + self.__useHardware = useHardware + + parameterTokens = parameters.split(',') + + self.__strengthList = [] + if strength: + strengthTokens = strength.split(',') + for st in strengthTokens: + try: + strengthValue = float(st) + except: + raise ValueError('NlmeansController: Strength value has to be of type float') + if strengthValue < 1.0 or strengthValue > 30.0: + raise ValueError('NlmeansController: Strength value has to be between 1.0 and 30.0') + self.__strengthList.append(strengthValue) + else: + self.__strengthList = [NlmeansController.DEFAULT_STRENGTH] + + self.__patchSizeList = [] + if patchSize: + patchSizeTokens = patchSize.split(',') + for pst in patchSizeTokens: + try: + patchSizeValue = int(pst) + except: + raise ValueError('NlmeansController: Patch size value has to be of type int') + if patchSizeValue < 0 or patchSizeValue > 99: + raise ValueError('NlmeansController: Patch size value has to be between 0 and 99') + if patchSizeValue % 2 == 0: + raise ValueError('NlmeansController: Patch size value has to an odd number') + self.__patchSizeList.append(patchSizeValue) + else: + self.__patchSizeList = [NlmeansController.DEFAULT_PATCH_SIZE] + + self.__chromaPatchSizeList = [] + if chromaPatchSize: + chromaPatchSizeTokens = chromaPatchSize.split(',') + for cpst in chromaPatchSizeTokens: + try: + chromaPatchSizeValue = int(pst) + except: + raise ValueError('NlmeansController: Chroma patch size value has to be of type int') + if chromaPatchSizeValue < 0 or chromaPatchSizeValue > 99: + raise ValueError('NlmeansController: Chroma patch value has to be between 0 and 99') + if chromaPatchSizeValue % 2 == 0: + raise ValueError('NlmeansController: Chroma patch value has to an odd number') + self.__chromaPatchSizeList.append(chromaPatchSizeValue) + else: + self.__chromaPatchSizeList = [NlmeansController.DEFAULT_CHROMA_PATCH_SIZE] + + self.__researchWindowList = [] + if researchWindow: + researchWindowTokens = researchWindow.split(',') + for rwt in researchWindowTokens: + try: + researchWindowValue = int(rwt) + except: + raise ValueError('NlmeansController: Research window value has to be of type int') + if researchWindowValue < 0 or researchWindowValue > 99: + raise ValueError('NlmeansController: Research window value has to be between 0 and 99') + if researchWindowValue % 2 == 0: + raise ValueError('NlmeansController: Research window value has to an odd number') + self.__researchWindowList.append(researchWindowValue) + else: + self.__researchWindowList = [NlmeansController.DEFAULT_RESEARCH_WINDOW] + + self.__chromaResearchWindowList = [] + if chromaResearchWindow: + chromaResearchWindowTokens = chromaResearchWindow.split(',') + for crwt in chromaResearchWindowTokens: + try: + chromaResearchWindowValue = int(crwt) + except: + raise ValueError('NlmeansController: Chroma research window value has to be of type int') + if chromaResearchWindowValue < 0 or chromaResearchWindowValue > 99: + raise ValueError('NlmeansController: Chroma research window value has to be between 0 and 99') + if chromaResearchWindowValue % 2 == 0: + raise ValueError('NlmeansController: Chroma research window value has to an odd number') + self.__chromaResearchWindowList.append(chromaResearchWindowValue) + else: + self.__chromaResearchWindowList = [NlmeansController.DEFAULT_CHROMA_RESEARCH_WINDOW] + + def isActive(self): + return self.__isActive + + def generateDenoiseTokens(self): + filterName = 'nlmeans_opencl' if self.__useHardware else 'nlmeans' + return ['-vf', f"{filterName}=s={self.__strengthList[0]}" + + f":p={self.__patchSizeList[0]}" + + f":pc={self.__chromaPatchSizeList[0]}" + + f":r={self.__researchWindowList[0]}" + + f":rc={self.__chromaResearchWindowList[0]}"] if self.__isActive else [] + diff --git a/bin/ffx/test/scenario_2.py b/bin/ffx/test/scenario_2.py index 67c0f3e..fd5964d 100644 --- a/bin/ffx/test/scenario_2.py +++ b/bin/ffx/test/scenario_2.py @@ -136,9 +136,9 @@ class Scenario2(Scenario): resultFileProperties = FileProperties(testContext, resultFile) resultMediaDescriptor = resultFileProperties.getMediaDescriptor() - if testContext['use_jellyfin']: - sourceMediaDescriptor.applyJellyfinOrder() - resultMediaDescriptor.applySourceIndices(sourceMediaDescriptor) + # if testContext['use_jellyfin']: + # sourceMediaDescriptor.applyJellyfinOrder() + # resultMediaDescriptor.applySourceIndices(sourceMediaDescriptor) resultMediaTracks = resultMediaDescriptor.getAllTrackDescriptors() diff --git a/bin/ffx/test/scenario_4.py b/bin/ffx/test/scenario_4.py index c7df816..48dc7f1 100644 --- a/bin/ffx/test/scenario_4.py +++ b/bin/ffx/test/scenario_4.py @@ -237,8 +237,8 @@ class Scenario4(Scenario): for l in rmd.getConfiguration(label = 'resultMediaDescriptor'): self._logger.debug(l) - if testContext['use_jellyfin']: - sourceMediaDescriptor.applyJellyfinOrder() + # if testContext['use_jellyfin']: + # sourceMediaDescriptor.applyJellyfinOrder() # num tracks differ rmd.applySourceIndices(sourceMediaDescriptor) diff --git a/bin/ffx/track_descriptor.py b/bin/ffx/track_descriptor.py index 91d01ef..a8b96cb 100644 --- a/bin/ffx/track_descriptor.py +++ b/bin/ffx/track_descriptor.py @@ -282,12 +282,21 @@ class TrackDescriptor: else: return IsoLanguage.UNDEFINED + def setLanguage(self, language: IsoLanguage): + if not type(language) is IsoLanguage: + raise TypeError('language has to be of type IsoLanguage') + self.__trackTags["language"] = language + def getTitle(self): if "title" in self.__trackTags.keys(): return str(self.__trackTags["title"]) else: return "" + def setTitle(self, title: str): + self.__trackTags["title"] = str(title) + + def getAudioLayout(self): return self.__audioLayout