472 lines
19 KiB
Python
472 lines
19 KiB
Python
import os, click
|
|
from logging import Logger
|
|
|
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
|
|
|
from ffx.media_descriptor import MediaDescriptor
|
|
from ffx.audio_layout import AudioLayout
|
|
from ffx.track_type import TrackType
|
|
from ffx.track_codec import TrackCodec
|
|
from ffx.video_encoder import VideoEncoder
|
|
from ffx.process import executeProcess
|
|
|
|
from ffx.constants import (
|
|
DEFAULT_CONTAINER_EXTENSION,
|
|
DEFAULT_CONTAINER_FORMAT,
|
|
DEFAULT_VIDEO_ENCODER_LABEL,
|
|
DEFAULT_cut_start,
|
|
DEFAULT_cut_length,
|
|
FFMPEG_COMMAND_TOKENS,
|
|
FFMPEG_NULL_OUTPUT_TOKENS,
|
|
SUPPORTED_INPUT_FILE_EXTENSIONS,
|
|
)
|
|
|
|
from ffx.filter.quality_filter import QualityFilter
|
|
from ffx.filter.preset_filter import PresetFilter
|
|
from ffx.filter.crop_filter import CropFilter
|
|
|
|
from ffx.model.pattern import Pattern
|
|
|
|
|
|
class FfxController():
|
|
|
|
COMMAND_TOKENS = list(FFMPEG_COMMAND_TOKENS)
|
|
NULL_TOKENS = list(FFMPEG_NULL_OUTPUT_TOKENS) # -f null /dev/null
|
|
|
|
TEMP_FILE_NAME = "ffmpeg2pass-0.log"
|
|
|
|
DEFAULT_VIDEO_ENCODER = DEFAULT_VIDEO_ENCODER_LABEL
|
|
|
|
DEFAULT_FILE_FORMAT = DEFAULT_CONTAINER_FORMAT
|
|
DEFAULT_FILE_EXTENSION = DEFAULT_CONTAINER_EXTENSION
|
|
|
|
INPUT_FILE_EXTENSIONS = list(SUPPORTED_INPUT_FILE_EXTENSIONS)
|
|
|
|
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.__targetMediaDescriptor = targetMediaDescriptor
|
|
self.__sourceMediaDescriptor = sourceMediaDescriptor
|
|
|
|
self.__mdcs = MediaDescriptorChangeSet(context,
|
|
targetMediaDescriptor,
|
|
sourceMediaDescriptor)
|
|
|
|
self.__logger: Logger = context['logger']
|
|
|
|
|
|
def executeCommandSequence(self, commandSequence):
|
|
out, err, rc = executeProcess(commandSequence, context=self.__context)
|
|
if rc:
|
|
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
|
|
return out, err, rc
|
|
|
|
|
|
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 libx264 -preset slow -crf 17
|
|
def generateH264Tokens(self, quality, subIndex : int = 0):
|
|
|
|
return [f"-c:v:{int(subIndex)}", 'libx264',
|
|
"-preset", "slow",
|
|
'-crf', str(quality)]
|
|
|
|
|
|
# -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 generateVideoCopyTokens(self, subIndex):
|
|
return [f"-c:v:{int(subIndex)}",
|
|
'copy']
|
|
|
|
def generateAudioCopyTokens(self, subIndex):
|
|
return [f"-c:a:{int(subIndex)}", 'copy']
|
|
|
|
def generateSubtitleCopyTokens(self, subIndex):
|
|
return [f"-c:s:{int(subIndex)}", 'copy']
|
|
|
|
def generateAttachmentCopyTokens(self, subIndex):
|
|
return [f"-c:t:{int(subIndex)}", 'copy']
|
|
|
|
def generateCopyTokens(self):
|
|
copyTokens = []
|
|
|
|
for trackDescriptor in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
|
copyTokens += self.generateVideoCopyTokens(trackDescriptor.getSubIndex())
|
|
|
|
for trackDescriptor in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.AUDIO):
|
|
copyTokens += self.generateAudioCopyTokens(trackDescriptor.getSubIndex())
|
|
|
|
for trackDescriptor in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.SUBTITLE):
|
|
copyTokens += self.generateSubtitleCopyTokens(trackDescriptor.getSubIndex())
|
|
|
|
attachmentDescriptors = (
|
|
self.__sourceMediaDescriptor.getTrackDescriptors(trackType=TrackType.ATTACHMENT)
|
|
if self.__sourceMediaDescriptor is not None
|
|
else self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.ATTACHMENT)
|
|
)
|
|
for trackDescriptor in attachmentDescriptors:
|
|
copyTokens += self.generateAttachmentCopyTokens(trackDescriptor.getSubIndex())
|
|
|
|
return copyTokens
|
|
|
|
|
|
def generateCropTokens(self):
|
|
|
|
if 'cut_start' in self.__context.keys() and 'cut_length' in self.__context.keys():
|
|
cropStart = int(self.__context['cut_start'])
|
|
cropLength = int(self.__context['cut_length'])
|
|
else:
|
|
cropStart = DEFAULT_cut_start
|
|
cropLength = DEFAULT_cut_length
|
|
|
|
return ['-ss', str(cropStart), '-t', str(cropLength)]
|
|
|
|
|
|
def generateOutputTokens(self, filePathBase, format = '', ext = ''):
|
|
|
|
self.__logger.debug(f"FfxController.generateOutputTokens(): base='{filePathBase}' format='{format}' ext='{ext}'")
|
|
|
|
outputFilePath = f"{filePathBase}{('.'+str(ext)) if ext else ''}"
|
|
if format:
|
|
return ['-f', format, outputFilePath]
|
|
else:
|
|
return [outputFilePath]
|
|
|
|
|
|
def generateEncodingMetadataTags(self, videoEncoder: VideoEncoder, quality, preset) -> dict:
|
|
metadataTags = {}
|
|
|
|
if videoEncoder in (VideoEncoder.AV1, VideoEncoder.H264, VideoEncoder.VP9):
|
|
metadataTags["ENCODING_QUALITY"] = str(quality)
|
|
|
|
if videoEncoder == VideoEncoder.AV1:
|
|
metadataTags["ENCODING_PRESET"] = str(preset)
|
|
|
|
return metadataTags
|
|
|
|
|
|
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]
|
|
targetAudioTrackDescriptors = self.__targetMediaDescriptor.getTrackDescriptors(trackType=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']]
|
|
|
|
# -ac 5 ?
|
|
if trackAudioLayout == AudioLayout.LAYOUT_5_0:
|
|
audioTokens += [f"-c:a:{trackSubIndex}",
|
|
'libopus',
|
|
f"-filter:a:{trackSubIndex}",
|
|
'channelmap=channel_layout=5.0',
|
|
f"-b:a:{trackSubIndex}",
|
|
self.__context['bitrates']['ac3']]
|
|
|
|
trackSubIndex += 1
|
|
return audioTokens
|
|
|
|
|
|
def runJob(self,
|
|
sourcePath,
|
|
targetPath,
|
|
targetFormat: str = '',
|
|
chainIteration: list = [],
|
|
cropArguments: dict = {},
|
|
currentPattern: Pattern = None,
|
|
currentShowDescriptor = None):
|
|
# quality: int = DEFAULT_QUALITY,
|
|
# preset: int = DEFAULT_AV1_PRESET):
|
|
|
|
|
|
videoEncoder: VideoEncoder = self.__context.get('video_encoder', VideoEncoder.VP9)
|
|
|
|
|
|
qualityFilters = [fy for fy in chainIteration if fy['identifier'] == 'quality']
|
|
presetFilters = [fy for fy in chainIteration if fy['identifier'] == 'preset']
|
|
|
|
cropFilters = [fy for fy in chainIteration if fy['identifier'] == 'crop']
|
|
denoiseFilters = [fy for fy in chainIteration if fy['identifier'] == 'nlmeans']
|
|
deinterlaceFilters = [fy for fy in chainIteration if fy['identifier'] == 'bwdif']
|
|
|
|
|
|
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
|
|
self.__logger.info(f"Setting quality {quality} from command line")
|
|
elif currentPattern is not None and (quality := currentPattern.quality):
|
|
self.__logger.info(f"Setting quality {quality} from pattern")
|
|
elif currentShowDescriptor is not None and (quality := currentShowDescriptor.getQuality()):
|
|
self.__logger.info(f"Setting quality {quality} from show")
|
|
else:
|
|
quality = (QualityFilter.DEFAULT_H264_QUALITY
|
|
if (videoEncoder == VideoEncoder.H264)
|
|
else QualityFilter.DEFAULT_VP9_QUALITY)
|
|
self.__logger.info(f"Setting quality {quality} from default")
|
|
|
|
|
|
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
|
|
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
|
|
videoEncoder,
|
|
quality,
|
|
preset,
|
|
)
|
|
|
|
|
|
filterParamTokens = []
|
|
|
|
if cropArguments:
|
|
|
|
cropParams = (f"crop="
|
|
+ f"{cropArguments[CropFilter.OUTPUT_WIDTH_KEY]}"
|
|
+ f":{cropArguments[CropFilter.OUTPUT_HEIGHT_KEY]}"
|
|
+ f":{cropArguments[CropFilter.OFFSET_X_KEY]}"
|
|
+ f":{cropArguments[CropFilter.OFFSET_Y_KEY]}")
|
|
|
|
filterParamTokens.append(cropParams)
|
|
|
|
filterParamTokens.extend(denoiseFilters[0]['tokens'] if denoiseFilters else [])
|
|
filterParamTokens.extend(deinterlaceFilters[0]['tokens'] if deinterlaceFilters else [])
|
|
|
|
deinterlaceFilters
|
|
|
|
filterTokens = ['-vf', ', '.join(filterParamTokens)] if filterParamTokens else []
|
|
|
|
|
|
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath]
|
|
|
|
if videoEncoder == VideoEncoder.COPY:
|
|
|
|
commandSequence = (commandTokens
|
|
+ self.__targetMediaDescriptor.getImportFileTokens()
|
|
+ self.__targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor = self.__sourceMediaDescriptor)
|
|
+ self.__mdcs.generateDispositionTokens())
|
|
|
|
commandSequence += self.__mdcs.generateMetadataTokens()
|
|
commandSequence += self.generateCopyTokens()
|
|
|
|
if self.__context['perform_cut']:
|
|
commandSequence += self.generateCropTokens()
|
|
|
|
commandSequence += self.generateOutputTokens(targetPath,
|
|
targetFormat)
|
|
|
|
self.__logger.debug("FfxController.runJob(): Running command sequence")
|
|
|
|
if not self.__context['dry_run']:
|
|
self.executeCommandSequence(commandSequence)
|
|
return
|
|
|
|
if videoEncoder == VideoEncoder.AV1:
|
|
|
|
commandSequence = (commandTokens
|
|
+ self.__targetMediaDescriptor.getImportFileTokens()
|
|
+ self.__targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor = self.__sourceMediaDescriptor)
|
|
+ self.__mdcs.generateDispositionTokens())
|
|
|
|
# Optional tokens
|
|
commandSequence += self.__mdcs.generateMetadataTokens()
|
|
commandSequence += filterTokens
|
|
|
|
for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
|
#HINT: Attached thumbnails are not supported by .webm container format
|
|
if td.getCodec != TrackCodec.PNG:
|
|
commandSequence += self.generateAV1Tokens(int(quality), int(preset))
|
|
|
|
commandSequence += self.generateAudioEncodingTokens()
|
|
|
|
if self.__context['perform_cut']:
|
|
commandSequence += self.generateCropTokens()
|
|
|
|
commandSequence += self.generateOutputTokens(targetPath,
|
|
targetFormat)
|
|
|
|
self.__logger.debug(f"FfxController.runJob(): Running command sequence")
|
|
|
|
if not self.__context['dry_run']:
|
|
self.executeCommandSequence(commandSequence)
|
|
|
|
|
|
if videoEncoder == VideoEncoder.H264:
|
|
|
|
commandSequence = (commandTokens
|
|
+ self.__targetMediaDescriptor.getImportFileTokens()
|
|
+ self.__targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor = self.__sourceMediaDescriptor)
|
|
+ self.__mdcs.generateDispositionTokens())
|
|
|
|
# Optional tokens
|
|
commandSequence += self.__mdcs.generateMetadataTokens()
|
|
commandSequence += filterTokens
|
|
|
|
for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
|
#HINT: Attached thumbnails are not supported by .webm container format
|
|
if td.getCodec != TrackCodec.PNG:
|
|
commandSequence += self.generateH264Tokens(int(quality))
|
|
|
|
commandSequence += self.generateAudioEncodingTokens()
|
|
|
|
if self.__context['perform_cut']:
|
|
commandSequence += self.generateCropTokens()
|
|
|
|
commandSequence += self.generateOutputTokens(targetPath,
|
|
targetFormat)
|
|
|
|
self.__logger.debug(f"FfxController.runJob(): Running command sequence")
|
|
|
|
if not self.__context['dry_run']:
|
|
self.executeCommandSequence(commandSequence)
|
|
|
|
|
|
|
|
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'].generatefilterTokens()
|
|
|
|
for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
|
#HINT: Attached thumbnails are not supported by .webm container format
|
|
if td.getCodec != TrackCodec.PNG:
|
|
commandSequence1 += self.generateVP9Pass1Tokens(int(quality))
|
|
|
|
if self.__context['perform_cut']:
|
|
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']:
|
|
self.executeCommandSequence(commandSequence1)
|
|
|
|
commandSequence2 = (commandTokens
|
|
+ self.__targetMediaDescriptor.getImportFileTokens()
|
|
+ self.__targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor = self.__sourceMediaDescriptor)
|
|
+ self.__mdcs.generateDispositionTokens())
|
|
|
|
# Optional tokens
|
|
commandSequence2 += self.__mdcs.generateMetadataTokens()
|
|
commandSequence2 += filterTokens
|
|
|
|
for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
|
#HINT: Attached thumbnails are not supported by .webm container format
|
|
if td.getCodec != TrackCodec.PNG:
|
|
commandSequence2 += self.generateVP9Pass2Tokens(int(quality))
|
|
|
|
commandSequence2 += self.generateAudioEncodingTokens()
|
|
|
|
if self.__context['perform_cut']:
|
|
commandSequence2 += self.generateCropTokens()
|
|
|
|
commandSequence2 += self.generateOutputTokens(targetPath,
|
|
targetFormat)
|
|
|
|
self.__logger.debug(f"FfxController.runJob(): Running command sequence 2")
|
|
|
|
if not self.__context['dry_run']:
|
|
self.executeCommandSequence(commandSequence2)
|
|
|
|
|
|
|
|
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]
|
|
|
|
self.executeCommandSequence(commandTokens)
|