diff --git a/source/video_trim/changelog.md b/source/video_trim/changelog.md index a62744f0f..cb85b2447 100644 --- a/source/video_trim/changelog.md +++ b/source/video_trim/changelog.md @@ -1,4 +1,10 @@ +**0.0.8** +- Update FFmpeg helper +- fix trim calculation, specifically trimming off the end +- move -ss and -to arguments to advanced options +- add some debug logger output + **0.0.7** - Update FFmpeg helper - Add platform declaration diff --git a/source/video_trim/info.json b/source/video_trim/info.json index 8f0737437..297693e49 100644 --- a/source/video_trim/info.json +++ b/source/video_trim/info.json @@ -17,5 +17,5 @@ "on_postprocessor_task_results": 99 }, "tags": "video,audio,ffmpeg", - "version": "0.0.7" + "version": "0.0.8" } diff --git a/source/video_trim/lib/ffmpeg/README.md b/source/video_trim/lib/ffmpeg/README.md index f1209743e..894d8afa8 100644 --- a/source/video_trim/lib/ffmpeg/README.md +++ b/source/video_trim/lib/ffmpeg/README.md @@ -3,14 +3,427 @@ This python module is a helper library for any Unmanic plugin that needs to build FFmpeg commands to be executed. -## Using the module +# Using the module -### Adding it to your project -It should be included in your plugin project as a submodule. +## Adding it to your project +```bash +└── my_plugin_id/ + ├── changelog.md + ├── description.md + ├── .gitignore + ├── icon.png + ├── info.json + ├── lib/ + | └── ffmpeg/ + | ├── __init__.py + | ├── LICENSE + | ├── mimetype_overrides.py + | ├── parser.py + | ├── probe.py + | ├── README.md + | └── stream_mapper.py + ├── LICENSE + ├── plugin.py + └── requirements.txt +``` + +### Git Submodule +It can be included in your plugin project as a submodule. ``` git submodule add https://github.com/Josh5/unmanic.plugin.helpers.ffmpeg.git ./lib/ffmpeg ``` +If you use it sure to include all files in the lib directory when publishing your project to the Unmanic plugin repository. + +### Project source download +Download the git repository as zip file and extract it to `lib` directory. +``` +mkdir -p ./lib +curl -L "https://github.com/Josh5/unmanic.plugin.helpers.ffmpeg/archive/refs/heads/master.zip" --output /tmp/unmanic.plugin.helpers.ffmpeg.zip +unzip /tmp/unmanic.plugin.helpers.ffmpeg.zip -d ./lib/ +mv -v ./lib/unmanic.plugin.helpers.ffmpeg-master ./lib/ffmpeg +``` + +--- + +## Importing it in your project + +This module comes with x3 classes to assist in generating FFmpeg commands for your Unmanic plugin. + +You can import all 3 classes into your plugin like this: + +```python +from my_plugin_id.lib.ffmpeg import Parser, Probe, StreamMapper +``` +> **Note** +> Be sure to rename 'my_plugin_id' in the example above. + +--- + +## Using the `Probe` class + +The Probe class is a wrapper around the `ffprobe` cli. This can be used to generate a file probe object containing file format and stream info. + +Add this to your plugin runner function: +```python + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['video', 'audio']) + if not probe: + # File not able to be probed by ffprobe. The file is probably not a audio/video file. + return +``` + +You can then use this newly created Probe object in your plugin. To read the FFprobe data, add this: +```python + ffprobe_data = probe.get_probe() +``` + +### FFprobe Example +
+ Show + + ```json +{ + "streams": [ + { + "index": 0, + "codec_name": "hevc", + "codec_long_name": "H.265 / HEVC (High Efficiency Video Coding)", + "profile": "Main", + "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": 2, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "16:9", + "pix_fmt": "yuv420p", + "level": 120, + "color_range": "tv", + "color_space": "bt709", + "color_transfer": "bt709", + "color_primaries": "bt709", + "chroma_location": "left", + "refs": 1, + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "time_base": "1/1000", + "start_pts": 21, + "start_time": "0.021000", + "extradata_size": 2471, + "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, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "DURATION": "00:00:10.239000000" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 6, + "channel_layout": "5.1", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "extradata_size": 5, + "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, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng", + "title": "Surround", + "DURATION": "00:00:10.005000000" + } + }, + { + "index": 2, + "codec_name": "ass", + "codec_long_name": "ASS (Advanced SSA) 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": 0, + "start_time": "0.000000", + "duration_ts": 10614, + "duration": "10.614000", + "extradata_size": 487, + "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, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "bul", + "DURATION": "00:00:10.614000000" + } + } + ], + "chapters": [ + { + "id": 1, + "time_base": "1/1000000000", + "start": 0, + "start_time": "0.000000", + "end": 10000000000, + "end_time": "10.000000", + "tags": { + "title": "Chapter 1" + } + } + ], + "format": { + "filename": "TEST_FILE.mkv", + "nb_streams": 3, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "10.614000", + "size": "1280059", + "bit_rate": "964807", + "probe_score": 100, + "tags": { + "ENCODER": "Lavf59.27.100" + } + } +} + ``` +
+ +--- + +## Using the `StreamMapper` class + +The StreamMapper class is used to simplify building a ffmpeg command. It uses a previously initialised probe object as an input and uses it to define stream mapping from the input file to the output. + +This class should be extended with a child class to configure it and implement the custom functions required to manage streams that will need to be processed. + +```python +class PluginStreamMapper(StreamMapper): + def __init__(self): + super(PluginStreamMapper, self).__init__(logger, ['video']) + self.settings = None + + def set_settings(self, settings): + self.settings = settings + + def test_stream_needs_processing(self, stream_info: dict): + """ + Run through a set of test against the given stream_info. + + Return 'True' if it needs to be process. + Return 'False' if it should just be copied over to the new file. + + :param stream_info: + :return: bool + """ + if stream_info.get('codec_name').lower() in ['h264']: + return False + return True + + def custom_stream_mapping(self, stream_info: dict, stream_id: int): + """ + Will be provided with stream_info and the stream_id of a stream that has been + determined to need processing by the `test_stream_needs_processing` function. + + Use this function to `-map` (select) an input stream to be included in the output file + and apply a `-c` (codec) selection and encoder arguments to the command. + + This function must return a dictionary containing 2 key values: + { + 'stream_mapping': [], + 'stream_encoding': [], + } + + Where: + - 'stream_mapping' is a list of arguments for input streams to map. Eg. ['-map', '0:v:1'] + - 'stream_encoding' is a list of encoder arguments. Eg. ['-c:v:1', 'libx264', '-preset', 'slow'] + + + :param stream_info: + :param stream_id: + :return: dict + """ + if self.settings.get_setting('advanced'): + stream_encoding = ['-c:v:{}'.format(stream_id), 'libx264'] + stream_encoding += self.settings.get_setting('custom_options').split() + else: + stream_encoding = [ + '-c:v:{}'.format(stream_id), 'libx264', + '-preset', str(self.settings.get_setting('preset')), + '-crf', str(self.settings.get_setting('crf')), + ] + + return { + 'stream_mapping': ['-map', '0:v:{}'.format(stream_id)], + 'stream_encoding': stream_encoding, + } +``` + +Once you have created your stream mapper class, you can use it to determine if a file needs a FFmpeg command executed against it using its `streams_need_processing` function. + +```python +def on_library_management_file_test(data): + + ... + + # Get plugin settings + settings = Settings(library_id=data.get('library_id')) + + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio']) + if not probe: + # File not able to be probed by FFprobe. The file is probably not a audio/video file. + return + + # Get stream mapper + mapper = PluginStreamMapper() + mapper.set_settings(settings) + mapper.set_probe(probe) + + # Check if file needs a FFmpeg command run against it + if mapper.streams_need_processing(): + # Mark this file to be added to the pending tasks + data['add_file_to_pending_tasks'] = True + + +def on_worker_process(data): + + ... + + # Get plugin settings + settings = Settings(library_id=data.get('library_id')) + + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio']) + if not probe: + # File not able to be probed by FFprobe. The file is probably not a audio/video file. + return + + # Get stream mapper + mapper = PluginStreamMapper() + mapper.set_settings(settings) + mapper.set_probe(probe) + + # Check if file needs a FFmpeg command run against it + if mapper.streams_need_processing(): + + """ + HERE: Configure FFmpeg command args as required for this plugin + """ + + # Set the input and output file + mapper.set_input_file(data.get('file_in')) + mapper.set_output_file(data.get('file_out')) + + # Get final generated FFmpeg args + ffmpeg_args = mapper.get_ffmpeg_args() + + # Apply FFmpeg args to command for Unmanic to execute + data['exec_command'] = ['ffmpeg'] + data['exec_command'] += ffmpeg_args + +``` + +--- + +## Using the `Parser` class + +Unmanic has the ability to execute a command provided by a plugin and display a output of that command's progress. As Unmanic is able to execute any command a plugin provides it, we need a way + +This progress is only possible if the provided command is accompanied with a progress parser function. If such a function is not provided to Unmanic, then the command will still be executed, but the Unmanic worker will only report an indeterminate progress status with the logs. + +This python module provides a function for parsing the output of a FFmpeg command to determine progress of that command's execution. + +This should be returned with the built command in the `on_worker_process` plugin function: + + +```python +def on_worker_process(data): + + ... + + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio']) + if not probe: + # File not able to be probed by FFprobe. The file is probably not a audio/video file. + return + + # Set the parser + parser = Parser(logger) + parser.set_probe(probe) + data['command_progress_parser'] = parser.parse_progress +``` + +--- -### Calling it in your project -For an example of how to use this module, see the [libx264 encoder plugin](https://github.com/Josh5/unmanic.plugin.encoder_video_h264_libx264). +## Examples +For examples of how to use this module, see these plugin sources: +- [Limit Library Search by FFprobe Data](https://github.com/Unmanic/plugin.limit_library_search_by_ffprobe_data/blob/master/plugin.py) +- [Re-order audio streams by language](https://github.com/Unmanic/plugin.reorder_audio_streams_by_language/blob/master/plugin.py) +- [Transcode Video Files](https://github.com/Unmanic/plugin.video_transcoder/blob/master/plugin.py) diff --git a/source/video_trim/lib/ffmpeg/probe.py b/source/video_trim/lib/ffmpeg/probe.py index 0fa2f1fe0..9f3ff51a0 100644 --- a/source/video_trim/lib/ffmpeg/probe.py +++ b/source/video_trim/lib/ffmpeg/probe.py @@ -24,6 +24,7 @@ import json import mimetypes import os +import shutil import subprocess from logging import Logger @@ -59,7 +60,13 @@ def ffprobe_cmd(params): raw_output = out.decode("utf-8") except Exception as e: raise FFProbeError(command, str(e)) - if pipe.returncode == 1 or 'error' in raw_output: + + if 'error' in raw_output: + try: + info = json.loads(raw_output) + except Exception as e: + raise FFProbeError(command, raw_output) + if pipe.returncode == 1: raise FFProbeError(command, raw_output) if not raw_output: raise FFProbeError(command, 'No info found') @@ -83,6 +90,7 @@ def ffprobe_file(vid_file_path): "-show_format", "-show_streams", "-show_error", + "-show_chapters", vid_file_path ] @@ -104,6 +112,10 @@ class Probe(object): probe_info = {} def __init__(self, logger: Logger, allowed_mimetypes=None): + # Ensure ffprobe is installed + if shutil.which('ffprobe') is None: + raise Exception("Unable to find executable 'ffprobe'. Please ensure that FFmpeg is installed correctly.") + self.logger = logger if allowed_mimetypes is None: allowed_mimetypes = ['audio', 'video', 'image'] @@ -139,11 +151,42 @@ class variable, it will fail this test. # Make sure the MIME type is either audio, video or image file_type_category = file_type.split('/')[0] if file_type_category not in self.allowed_mimetypes: - self.logger.debug("File MIME type not in 'audio', 'video' or 'image' - '{}'".format(file_path)) + self.logger.debug("File MIME type not in [{}] - '{}'".format(', '.join(self.allowed_mimetypes), file_path)) return False return True + @staticmethod + def init_probe(data, logger, allowed_mimetypes=None): + """ + Fetch the Probe object given a plugin's data object + + :param data: + :param logger: + :param allowed_mimetypes: + :return: + """ + probe = Probe(logger, allowed_mimetypes=allowed_mimetypes) + # Start by fetching probe data from 'shared_info'. + ffprobe_data = data.get('shared_info', {}).get('ffprobe') + if ffprobe_data: + if not probe.set_probe(ffprobe_data): + # Failed to set ffprobe from 'shared_info'. + # Probably due to it being for an incompatible mimetype declared above. + return + return probe + # No 'shared_info' ffprobe exists. Attempt to probe file. + if not probe.file(data.get('path')): + # File probe failed, skip the rest of this test. + # Again, probably due to it being for an incompatible mimetype. + return + # Successfully probed file. + # Set file probe to 'shared_info' for subsequent file test runners. + if 'shared_info' not in data: + data['shared_info'] = {} + data['shared_info']['ffprobe'] = probe.get_probe() + return probe + def file(self, file_path): """ Sets the 'probe' dict by probing the given file path. @@ -170,10 +213,18 @@ def file(self, file_path): # This will only happen if it was not a file that could be probed. self.logger.debug("File unable to be probed by FFProbe - '{}'".format(file_path)) return - except Exception as e: - # The process failed for some unknown reason. Log it. - self.logger.debug("Failed to set file probe - ".format(str(e))) + + def set_probe(self, probe_info): + """Sets the probe dictionary""" + file_path = probe_info.get('format', {}).get('filename') + if not file_path: + self.logger.error("Provided file probe information does not contain the expected 'filename' key.") return + if not self.__test_valid_mimetype(file_path): + return + + self.probe_info = probe_info + return self.probe_info def get_probe(self): """Return the probe dictionary""" diff --git a/source/video_trim/lib/ffmpeg/stream_mapper.py b/source/video_trim/lib/ffmpeg/stream_mapper.py index eb6312862..a4182caac 100644 --- a/source/video_trim/lib/ffmpeg/stream_mapper.py +++ b/source/video_trim/lib/ffmpeg/stream_mapper.py @@ -22,6 +22,7 @@ """ import os +import shutil from logging import Logger from .probe import Probe @@ -36,6 +37,14 @@ class StreamMapper(object): probe: Probe = None + stream_type_idents = { + 'video': 'v', + 'audio': 'a', + 'subtitle': 's', + 'data': 'd', + 'attachment': 't' + } + processing_stream_type = '' found_streams_to_encode = False stream_mapping = [] @@ -54,6 +63,10 @@ class StreamMapper(object): format_options = [] def __init__(self, logger: Logger, processing_stream_type: list): + # Ensure ffmpeg is installed + if shutil.which('ffmpeg') is None: + raise Exception("Unable to find executable 'ffmpeg'. Please ensure that FFmpeg is installed correctly.") + self.logger = logger if processing_stream_type is not None: if any(pst for pst in processing_stream_type if @@ -124,7 +137,7 @@ def test_stream_needs_processing(self, stream_info: dict): """ Overwrite this function to test a stream. Return 'True' if it needs to be process. - Return 'False' if it should just be copied over to the new file + Return 'False' if it should just be copied over to the new file. :param stream_info: :return: bool @@ -189,10 +202,12 @@ def __set_stream_mapping(self): self.video_stream_count += 1 continue else: - found_streams_to_process = True - self.__apply_custom_stream_mapping( - self.custom_stream_mapping(stream_info, self.video_stream_count) - ) + mapping = self.custom_stream_mapping(stream_info, self.video_stream_count) + if mapping: + found_streams_to_process = True + self.__apply_custom_stream_mapping(mapping) + else: + self.__copy_stream_mapping('v', self.video_stream_count) self.video_stream_count += 1 continue else: @@ -209,15 +224,28 @@ def __set_stream_mapping(self): self.audio_stream_count += 1 continue else: - found_streams_to_process = True - self.__apply_custom_stream_mapping( - self.custom_stream_mapping(stream_info, self.audio_stream_count) - ) + mapping = self.custom_stream_mapping(stream_info, self.audio_stream_count) + if mapping: + found_streams_to_process = True + self.__apply_custom_stream_mapping(mapping) + else: + self.__copy_stream_mapping('a', self.audio_stream_count) self.audio_stream_count += 1 continue else: - self.__copy_stream_mapping('a', self.audio_stream_count) - self.audio_stream_count += 1 + if self.settings.get_setting('mode') == 'advanced': + amaps = self.settings.get_setting('custom_options').split() + self.logger.debug("Advanced Mode Video Settings with custom audio encoding: '%s'", amaps) + if '-c:a' not in amaps: + self.logger.debug("-c:a not detected in custom mappings: '%s'", amaps) + self.__copy_stream_mapping('a', self.audio_stream_count) + else: + self.logger.debug("-c:a detected in custom mappings: '%s'", amaps) + self.stream_mapping += ['-map', '0:{}:{}'.format('a', self.audio_stream_count)] + self.audio_stream_count += 1 + else: + self.__copy_stream_mapping('a', self.audio_stream_count) + self.audio_stream_count += 1 continue # If this is a subtitle stream? @@ -229,15 +257,28 @@ def __set_stream_mapping(self): self.subtitle_stream_count += 1 continue else: - found_streams_to_process = True - self.__apply_custom_stream_mapping( - self.custom_stream_mapping(stream_info, self.subtitle_stream_count) - ) + mapping = self.custom_stream_mapping(stream_info, self.subtitle_stream_count) + if mapping: + found_streams_to_process = True + self.__apply_custom_stream_mapping(mapping) + else: + self.__copy_stream_mapping('s', self.subtitle_stream_count) self.subtitle_stream_count += 1 continue else: - self.__copy_stream_mapping('s', self.subtitle_stream_count) - self.subtitle_stream_count += 1 + if self.settings.get_setting('mode') == 'advanced': + submaps = self.settings.get_setting('custom_options').split() + self.logger.debug("Advanced Mode Video Settings with custom subtitle encoding: '%s'", submaps) + if '-c:s' not in submaps: + self.logger.debug("-c:s not detected in custom mappings: '%s'", submaps) + self.__copy_stream_mapping('s', self.subtitle_stream_count) + else: + self.logger.debug("-c:s detected in custom mappings: '%s'", submaps) + self.stream_mapping += ['-map', '0:{}:{}'.format('s', self.subtitle_stream_count)] + self.subtitle_stream_count += 1 + else: + self.__copy_stream_mapping('s', self.subtitle_stream_count) + self.subtitle_stream_count += 1 continue # If this is a data stream? @@ -249,10 +290,12 @@ def __set_stream_mapping(self): self.data_stream_count += 1 continue else: - found_streams_to_process = True - self.__apply_custom_stream_mapping( - self.custom_stream_mapping(stream_info, self.data_stream_count) - ) + mapping = self.custom_stream_mapping(stream_info, self.data_stream_count) + if mapping: + found_streams_to_process = True + self.__apply_custom_stream_mapping(mapping) + else: + self.__copy_stream_mapping('d', self.data_stream_count) self.data_stream_count += 1 continue else: @@ -269,10 +312,12 @@ def __set_stream_mapping(self): self.attachment_stream_count += 1 continue else: - found_streams_to_process = True - self.__apply_custom_stream_mapping( - self.custom_stream_mapping(stream_info, self.attachment_stream_count) - ) + mapping = self.custom_stream_mapping(stream_info, self.attachment_stream_count) + if mapping: + found_streams_to_process = True + self.__apply_custom_stream_mapping(mapping) + else: + self.__copy_stream_mapping('t', self.attachment_stream_count) self.attachment_stream_count += 1 continue else: @@ -344,6 +389,9 @@ def set_output_file(self, path): def set_output_null(self): """Set the output container to NULL for the FFmpeg args""" self.output_file = '-' + if os.name == "nt": + # Windows uses NUL instead + self.output_file = 'NUL' main_options = { "-f": 'null', } @@ -429,15 +477,15 @@ def get_ffmpeg_args(self): # Add generic options first args += self.generic_options + # Add other main options + args += self.main_options + # Add the input file # This class requires at least one input file specified with the input_file attribute if not self.input_file: raise Exception("Input file has not been set") args += ['-i', self.input_file] - # Add other main options - args += self.main_options - # Add advanced options. This includes the stream mapping and the encoding args args += self.advanced_options args += self.stream_mapping diff --git a/source/video_trim/lib/ffmpeg/tools.py b/source/video_trim/lib/ffmpeg/tools.py new file mode 100644 index 000000000..c4d9c5475 --- /dev/null +++ b/source/video_trim/lib/ffmpeg/tools.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.tools.py + + Written by: Josh.5 + Date: 17 Feb 2023, (12:07 PM) + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +image_video_codecs = [ + 'alias_pix', + 'apng', + 'brender_pix', + 'dds', + 'dpx', + 'exr', + 'fits', + 'gif', + 'mjpeg', + 'mjpegb', + 'pam', + 'pbm', + 'pcx', + 'pfm', + 'pgm', + 'pgmyuv', + 'pgx', + 'photocd', + 'pictor', + 'pixlet', + 'png', + 'ppm', + 'ptx', + 'sgi', + 'sunrast', + 'tiff', + 'vc1image', + 'wmv3image', + 'xbm', + 'xface', + 'xpm', + 'xwd', +] + +resolution_map = { + '480p_sdtv': { + 'width': 854, + 'height': 480, + 'label': "480p (SDTV)", + }, + '576p_sdtv': { + 'width': 1024, + 'height': 576, + 'label': "576p (SDTV)", + }, + '720p_hdtv': { + 'width': 1280, + 'height': 720, + 'label': "720p (HDTV)", + }, + '1080p_hdtv': { + 'width': 1920, + 'height': 1080, + 'label': "1080p (HDTV)", + }, + 'dci_2k_hdtv': { + 'width': 2048, + 'height': 1080, + 'label': "DCI 2K (HDTV)", + }, + '1440p': { + 'width': 2560, + 'height': 1440, + 'label': "1440p (WQHD)", + }, + '4k_uhd': { + 'width': 3840, + 'height': 2160, + 'label': "4K (UHD)", + }, + 'dci_4k': { + 'width': 4096, + 'height': 2160, + 'label': "DCI 4K", + }, + '8k_uhd': { + 'width': 8192, + 'height': 4608, + 'label': "8k (UHD)", + }, +} + + +def get_video_stream_resolution(streams: list) -> object: + """ + Given a list of streams from a video file, returns the first video + stream's resolution and index. + + :param streams: The list of streams for the video file. + :type streams: list + :return: A tuple of the (width, height, stream_index,) + :rtype: object + """ + width = 0 + height = 0 + video_stream_index = 0 + + for stream in streams: + if stream.get('codec_type', '') == 'video': + width = stream.get('width', stream.get('coded_width', 0)) + height = stream.get('height', stream.get('coded_height', 0)) + video_stream_index = stream.get('index') + break + + return width, height, video_stream_index diff --git a/source/video_trim/plugin.py b/source/video_trim/plugin.py index 2a721208b..9979a98be 100644 --- a/source/video_trim/plugin.py +++ b/source/video_trim/plugin.py @@ -72,38 +72,39 @@ def custom_stream_mapping(self, stream_info: dict, stream_id: int): def __gen_start_args(self, duration): start_seconds = self.settings.get_setting('start_seconds') - main_options = {} + advanced_options = {} if start_seconds and float(start_seconds) > 0: # Ensure the start trim is less than the duration of the file if float(start_seconds) > float(duration): # The configured value is larger than the duration of the file. # Skip this file for now... - return main_options + return advanced_options # Build the start trim args - main_options = { + advanced_options = { "-ss": str(self.settings.get_setting('start_seconds')), } - self.set_ffmpeg_main_options(**main_options) + self.set_ffmpeg_advanced_options(**advanced_options) - return main_options + return advanced_options def __gen_end_args(self, duration): # Reduce duration by X seconds less the start_seconds end_seconds = self.settings.get_setting('end_seconds') - main_options = {} + start_seconds = self.settings.get_setting('start_seconds') + advanced_options = {} if end_seconds and float(end_seconds) > 0: - # Ensure the end trim is less than the duration of the file + # Ensure the end trim is less than the duration of the file, less the start trim if float(end_seconds) > float(duration): # The configured value is larger than the duration of the file. # Skip this file for now... - return main_options + return advanced_options # Build the start trim args - main_options = { - "-to": str(duration), + advanced_options = { + "-to": str(float(duration) - float(end_seconds)), } - self.set_ffmpeg_main_options(**main_options) + self.set_ffmpeg_advanced_options(**advanced_options) - return main_options + return advanced_options def gen_trim_args(self): """ @@ -124,8 +125,10 @@ def gen_trim_args(self): args_string = '' for key in start_args: args_string += "{} {}".format(key, start_args.get(key)) + logger.debug("args_string: '{}'".format(args_string)) for key in end_args: args_string += "{} {}".format(key, end_args.get(key)) + logger.debug("args_string: '{}'".format(args_string)) return args_string @staticmethod @@ -268,6 +271,8 @@ def on_worker_process(data): data['exec_command'] = ['ffmpeg'] data['exec_command'] += ffmpeg_args + logger.debug("ffmpeg.args: '{}'".format(ffmpeg_args)) + # Set the parser parser = Parser(logger) parser.set_probe(probe)