You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
368 lines
15 KiB
Python
368 lines
15 KiB
Python
import os, click, re
|
|
|
|
from ffx.media_descriptor import MediaDescriptor
|
|
from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY, DIFF_CHANGED_KEY
|
|
from ffx.track_descriptor import TrackDescriptor
|
|
from ffx.model.track import Track
|
|
from ffx.audio_layout import AudioLayout
|
|
from ffx.track_type import TrackType
|
|
from ffx.video_encoder import VideoEncoder
|
|
from ffx.process import executeProcess
|
|
from ffx.track_disposition import TrackDisposition
|
|
|
|
from ffx.constants import DEFAULT_QUALITY, DEFAULT_AV1_PRESET
|
|
from ffx.constants import DEFAULT_CROP_START, DEFAULT_CROP_LENGTH
|
|
|
|
from ffx.filter.quality_filter import QualityFilter
|
|
from ffx.filter.preset_filter import PresetFilter
|
|
from ffx.filter.nlmeans_filter import NlmeansFilter
|
|
|
|
|
|
class FfxController():
|
|
|
|
COMMAND_TOKENS = ['ffmpeg', '-y']
|
|
NULL_TOKENS = ['-f', 'null', '/dev/null'] # -f null /dev/null
|
|
|
|
TEMP_FILE_NAME = "ffmpeg2pass-0.log"
|
|
|
|
DEFAULT_VIDEO_ENCODER = VideoEncoder.VP9.label()
|
|
|
|
DEFAULT_FILE_FORMAT = 'webm'
|
|
DEFAULT_FILE_EXTENSION = 'webm'
|
|
|
|
INPUT_FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
|
|
|
|
CHANNEL_MAP_5_1 = 'FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1'
|
|
|
|
SIGNATURE_TAGS = {'RECODED_WITH': 'FFX'}
|
|
|
|
def __init__(self,
|
|
context : dict,
|
|
targetMediaDescriptor : MediaDescriptor,
|
|
sourceMediaDescriptor : MediaDescriptor = None):
|
|
|
|
self.__context = context
|
|
self.__sourceMediaDescriptor = sourceMediaDescriptor
|
|
self.__targetMediaDescriptor = targetMediaDescriptor
|
|
|
|
self.__configurationData = self.__context['config'].getData()
|
|
|
|
# Convenience
|
|
# self.__niceness = self.__context['resource_limits']['niceness'] if 'resource_limits' in self.__context.keys() and 'niceness' in self.__context['resource_limits'].keys() else 99
|
|
# self.__cpuPercent = self.__context['resource_limits']['cpu_percent'] if 'resource_limits' in self.__context.keys() and 'cpu_percent' in self.__context['resource_limits'].keys() else 0
|
|
|
|
self.__logger = context['logger']
|
|
|
|
|
|
def generateAV1Tokens(self, quality, preset, subIndex : int = 0):
|
|
|
|
return [f"-c:v:{int(subIndex)}", 'libsvtav1',
|
|
'-svtav1-params', f"crf={quality}:preset={preset}:tune=0:enable-overlays=1:scd=1:scm=0",
|
|
'-pix_fmt', 'yuv420p10le']
|
|
|
|
|
|
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0
|
|
def generateVP9Pass1Tokens(self, quality, subIndex : int = 0):
|
|
|
|
return [f"-c:v:{int(subIndex)}",
|
|
'libvpx-vp9',
|
|
'-row-mt', '1',
|
|
'-crf', str(quality),
|
|
'-pass', '1',
|
|
'-speed', '4',
|
|
'-frame-parallel', '0',
|
|
'-g', '9999',
|
|
'-aq-mode', '0']
|
|
|
|
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 2 -frame-parallel 0 -g 9999 -aq-mode 0 -auto-alt-ref 1 -lag-in-frames 25
|
|
def generateVP9Pass2Tokens(self, quality, subIndex : int = 0):
|
|
|
|
return [f"-c:v:{int(subIndex)}",
|
|
'libvpx-vp9',
|
|
'-row-mt', '1',
|
|
'-crf', str(quality),
|
|
'-pass', '2',
|
|
'-frame-parallel', '0',
|
|
'-g', '9999',
|
|
'-aq-mode', '0',
|
|
'-auto-alt-ref', '1',
|
|
'-lag-in-frames', '25']
|
|
|
|
|
|
def generateCropTokens(self):
|
|
|
|
if 'crop_start' in self.__context.keys() and 'crop_length' in self.__context.keys():
|
|
cropStart = int(self.__context['crop_start'])
|
|
cropLength = int(self.__context['crop_length'])
|
|
else:
|
|
cropStart = DEFAULT_CROP_START
|
|
cropLength = DEFAULT_CROP_LENGTH
|
|
|
|
return ['-ss', str(cropStart), '-t', str(cropLength)]
|
|
|
|
|
|
def generateOutputTokens(self, filePathBase, format = '', ext = ''):
|
|
outputFilePath = f"{filePathBase}{'.'+str(ext) if ext else ''}"
|
|
if format:
|
|
return ['-f', format, outputFilePath]
|
|
else:
|
|
return [outputFilePath]
|
|
|
|
|
|
def generateAudioEncodingTokens(self):
|
|
"""Generates ffmpeg options audio streams including channel remapping, codec and bitrate"""
|
|
|
|
audioTokens = []
|
|
|
|
targetAudioTrackDescriptors = [td for td in self.__targetMediaDescriptor.getAllTrackDescriptors() if td.getType() == TrackType.AUDIO]
|
|
|
|
trackSubIndex = 0
|
|
for trackDescriptor in targetAudioTrackDescriptors:
|
|
|
|
trackAudioLayout = trackDescriptor.getAudioLayout()
|
|
|
|
if trackAudioLayout == AudioLayout.LAYOUT_6_1:
|
|
audioTokens += [f"-c:a:{trackSubIndex}",
|
|
'libopus',
|
|
f"-filter:a:{trackSubIndex}",
|
|
'channelmap=channel_layout=6.1',
|
|
f"-b:a:{trackSubIndex}",
|
|
self.__context['bitrates']['dts']]
|
|
|
|
if trackAudioLayout == AudioLayout.LAYOUT_5_1:
|
|
audioTokens += [f"-c:a:{trackSubIndex}",
|
|
'libopus',
|
|
f"-filter:a:{trackSubIndex}",
|
|
f"channelmap={FfxController.CHANNEL_MAP_5_1}",
|
|
f"-b:a:{trackSubIndex}",
|
|
self.__context['bitrates']['ac3']]
|
|
|
|
if trackAudioLayout == AudioLayout.LAYOUT_STEREO:
|
|
audioTokens += [f"-c:a:{trackSubIndex}",
|
|
'libopus',
|
|
f"-b:a:{trackSubIndex}",
|
|
self.__context['bitrates']['stereo']]
|
|
|
|
if trackAudioLayout == AudioLayout.LAYOUT_6CH:
|
|
audioTokens += [f"-c:a:{trackSubIndex}",
|
|
'libopus',
|
|
f"-filter:a:{trackSubIndex}",
|
|
f"channelmap={FfxController.CHANNEL_MAP_5_1}",
|
|
f"-b:a:{trackSubIndex}",
|
|
self.__context['bitrates']['ac3']]
|
|
trackSubIndex += 1
|
|
return audioTokens
|
|
|
|
|
|
# -disposition:s:0 default -disposition:s:1 0
|
|
def generateDispositionTokens(self):
|
|
|
|
targetTrackDescriptors = self.__targetMediaDescriptor.getAllTrackDescriptors()
|
|
|
|
sourceTrackDescriptors = ([] if self.__sourceMediaDescriptor is None
|
|
else self.__sourceMediaDescriptor.getAllTrackDescriptors())
|
|
|
|
dispositionTokens = []
|
|
|
|
for trackIndex in range(len(targetTrackDescriptors)):
|
|
|
|
td = targetTrackDescriptors[trackIndex]
|
|
|
|
#HINT: No dispositions for pgs subtitle tracks that have no external file source
|
|
if (td.getExternalSourceFilePath()
|
|
or td.getCodec() != TrackDescriptor.CODEC_PGS):
|
|
|
|
subIndex = td.getSubIndex()
|
|
streamIndicator = td.getType().indicator()
|
|
|
|
|
|
sourceDispositionSet = sourceTrackDescriptors[td.getSourceIndex()].getDispositionSet() if sourceTrackDescriptors else set()
|
|
|
|
#TODO: Alles discarden was im targetDescriptor vorhanden ist (?)
|
|
sourceDispositionSet.discard(TrackDisposition.DEFAULT)
|
|
|
|
dispositionSet = td.getDispositionSet() | sourceDispositionSet
|
|
|
|
if dispositionSet:
|
|
dispositionTokens += [f"-disposition:{streamIndicator}:{subIndex}", '+'.join([d.label() for d in dispositionSet])]
|
|
else:
|
|
dispositionTokens += [f"-disposition:{streamIndicator}:{subIndex}", '0']
|
|
|
|
return dispositionTokens
|
|
|
|
|
|
def generateMetadataTokens(self):
|
|
|
|
metadataTokens = []
|
|
|
|
metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {}
|
|
|
|
signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {}
|
|
removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else []
|
|
removeTrackKeys = metadataConfiguration['streams']['remove'] if 'streams' in metadataConfiguration.keys() and 'remove' in metadataConfiguration['streams'].keys() else []
|
|
|
|
mediaTags = {k:v for k,v in self.__targetMediaDescriptor.getTags().items() if not k in removeGlobalKeys}
|
|
|
|
if (not 'no_signature' in self.__context.keys()
|
|
or not self.__context['no_signature']):
|
|
outputMediaTags = mediaTags | signatureTags
|
|
else:
|
|
outputMediaTags = mediaTags
|
|
|
|
for tagKey, tagValue in outputMediaTags.items():
|
|
metadataTokens += [f"-metadata:g",
|
|
f"{tagKey}={tagValue}"]
|
|
|
|
for removeKey in removeGlobalKeys:
|
|
metadataTokens += [f"-metadata:g",
|
|
f"{removeKey}="]
|
|
|
|
|
|
removeMkvmergeMetadata = (not 'keep_mkvmerge_metadata' in self.__context.keys()
|
|
or not self.__context['keep_mkvmerge_metadata'])
|
|
|
|
#HINT: With current ffmpeg version track metadata tags are not passed to the outfile
|
|
for td in self.__targetMediaDescriptor.getAllTrackDescriptors():
|
|
|
|
typeIndicator = td.getType().indicator()
|
|
subIndex = td.getSubIndex()
|
|
|
|
for tagKey, tagValue in td.getTags().items():
|
|
|
|
if not tagKey in removeTrackKeys:
|
|
metadataTokens += [f"-metadata:s:{typeIndicator}:{subIndex}",
|
|
f"{tagKey}={tagValue}"]
|
|
|
|
for removeKey in removeTrackKeys:
|
|
metadataTokens += [f"-metadata:s:{typeIndicator}:{subIndex}",
|
|
f"{removeKey}="]
|
|
|
|
|
|
return metadataTokens
|
|
|
|
|
|
def runJob(self,
|
|
sourcePath,
|
|
targetPath,
|
|
targetFormat: str = '',
|
|
videoEncoder: VideoEncoder = VideoEncoder.VP9,
|
|
chainIteration: list = []):
|
|
# quality: int = DEFAULT_QUALITY,
|
|
# preset: int = DEFAULT_AV1_PRESET):
|
|
|
|
qualityFilters = [fy for fy in chainIteration if fy['identifier'] == 'quality']
|
|
presetFilters = [fy for fy in chainIteration if fy['identifier'] == 'preset']
|
|
denoiseFilters = [fy for fy in chainIteration if fy['identifier'] == 'nlmeans']
|
|
|
|
quality = qualityFilters[0]['parameters']['quality'] if qualityFilters else QualityFilter.DEFAULT_QUALITY
|
|
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
|
|
|
|
|
|
denoiseTokens = denoiseFilters[0]['tokens'] if denoiseFilters else []
|
|
|
|
|
|
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath]
|
|
|
|
if videoEncoder == VideoEncoder.AV1:
|
|
|
|
commandSequence = (commandTokens
|
|
+ self.__targetMediaDescriptor.getImportFileTokens()
|
|
+ self.__targetMediaDescriptor.getInputMappingTokens()
|
|
+ self.generateDispositionTokens())
|
|
|
|
# Optional tokens
|
|
commandSequence += self.generateMetadataTokens()
|
|
commandSequence += denoiseTokens
|
|
|
|
commandSequence += (self.generateAudioEncodingTokens()
|
|
+ self.generateAV1Tokens(int(quality), int(preset))
|
|
+ self.generateAudioEncodingTokens())
|
|
|
|
if self.__context['perform_crop']:
|
|
commandSequence += FfxController.generateCropTokens()
|
|
|
|
commandSequence += self.generateOutputTokens(targetPath,
|
|
targetFormat)
|
|
|
|
self.__logger.debug(f"FfxController.runJob(): Running command sequence")
|
|
|
|
if not self.__context['dry_run']:
|
|
executeProcess(commandSequence, context = self.__context)
|
|
|
|
|
|
if videoEncoder == VideoEncoder.VP9:
|
|
|
|
commandSequence1 = (commandTokens
|
|
+ self.__targetMediaDescriptor.getInputMappingTokens(only_video=True))
|
|
|
|
# Optional tokens
|
|
#NOTE: Filters and so needs to run on the first pass as well, as here
|
|
# the required bitrate for the second run is determined and recorded
|
|
# TODO: Results seems to be slightly better with first pass omitted,
|
|
# Confirm or find better filter settings for 2-pass
|
|
# commandSequence1 += self.__context['denoiser'].generateDenoiseTokens()
|
|
|
|
commandSequence1 += self.generateVP9Pass1Tokens(int(quality))
|
|
|
|
if self.__context['perform_crop']:
|
|
commandSequence1 += self.generateCropTokens()
|
|
|
|
commandSequence1 += FfxController.NULL_TOKENS
|
|
|
|
if os.path.exists(FfxController.TEMP_FILE_NAME):
|
|
os.remove(FfxController.TEMP_FILE_NAME)
|
|
|
|
self.__logger.debug(f"FfxController.runJob(): Running command sequence 1")
|
|
|
|
if not self.__context['dry_run']:
|
|
executeProcess(commandSequence1, context = self.__context)
|
|
|
|
commandSequence2 = (commandTokens
|
|
+ self.__targetMediaDescriptor.getImportFileTokens()
|
|
+ self.__targetMediaDescriptor.getInputMappingTokens()
|
|
+ self.generateDispositionTokens())
|
|
|
|
# Optional tokens
|
|
commandSequence2 += self.generateMetadataTokens()
|
|
commandSequence2 += denoiseTokens
|
|
|
|
commandSequence2 += self.generateVP9Pass2Tokens(int(quality)) + self.generateAudioEncodingTokens()
|
|
|
|
if self.__context['perform_crop']:
|
|
commandSequence2 += self.generateCropTokens()
|
|
|
|
commandSequence2 += self.generateOutputTokens(targetPath,
|
|
targetFormat)
|
|
|
|
self.__logger.debug(f"FfxController.runJob(): Running command sequence 2")
|
|
|
|
if not self.__context['dry_run']:
|
|
out, err, rc = executeProcess(commandSequence2, context = self.__context)
|
|
if rc:
|
|
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
|
|
|
|
|
|
|
|
def createEmptyFile(self,
|
|
path: str = 'empty.mkv',
|
|
sizeX: int = 1280,
|
|
sizeY: int = 720,
|
|
rate: int = 25,
|
|
length: int = 10):
|
|
|
|
commandTokens = FfxController.COMMAND_TOKENS
|
|
|
|
commandTokens += ['-f',
|
|
'lavfi',
|
|
'-i',
|
|
f"color=size={sizeX}x{sizeY}:rate={rate}:color=black",
|
|
'-f',
|
|
'lavfi',
|
|
'-i',
|
|
'anullsrc=channel_layout=stereo:sample_rate=44100',
|
|
'-t',
|
|
str(length),
|
|
path]
|
|
|
|
out, err, rc = executeProcess(commandTokens, context = self.__context)
|