diff --git a/.github/workflows/build_linux.yaml b/.github/workflows/build_linux.yaml index ecc1dbd7..10e67d10 100644 --- a/.github/workflows/build_linux.yaml +++ b/.github/workflows/build_linux.yaml @@ -27,7 +27,7 @@ jobs: - name: Insatll requirements run: | python -m pip install --upgrade pip setuptools --ignore-installed - python -m pip install --upgrade wheel + python -m pip install --upgrade wheel typing_extensions python -m pip install -r requirements-build.txt python -m pip install --upgrade pyqt5-tools diff --git a/.github/workflows/build_linux_legacy.yaml b/.github/workflows/build_linux_legacy.yaml index b53bca3b..50b6e6d0 100644 --- a/.github/workflows/build_linux_legacy.yaml +++ b/.github/workflows/build_linux_legacy.yaml @@ -27,7 +27,7 @@ jobs: - name: Insatll requirements run: | python -m pip install --upgrade pip setuptools --ignore-installed - python -m pip install --upgrade wheel + python -m pip install --upgrade wheel typing_extensions python -m pip install -r requirements-build.txt python -m pip install --upgrade pyqt5-tools diff --git a/.github/workflows/build_mac.yaml b/.github/workflows/build_mac.yaml index e2d02a8b..03f8d163 100644 --- a/.github/workflows/build_mac.yaml +++ b/.github/workflows/build_mac.yaml @@ -27,7 +27,7 @@ jobs: - name: Insatll requirements run: | python -m pip install --upgrade pip setuptools --ignore-installed - python -m pip install --upgrade wheel + python -m pip install --upgrade wheel typing_extensions python -m pip install -r requirements-build.txt - name: Grab iso-639 lists diff --git a/.github/workflows/build_windows.yaml b/.github/workflows/build_windows.yaml index 1059c0d5..1dfea57f 100644 --- a/.github/workflows/build_windows.yaml +++ b/.github/workflows/build_windows.yaml @@ -30,7 +30,7 @@ jobs: shell: cmd run: | python -m pip install --upgrade pip setuptools --ignore-installed - python -m pip install --upgrade pypiwin32 wheel + python -m pip install --upgrade pypiwin32 wheel typing_extensions python -m pip install -r requirements-build.txt - name: Grab iso-639 lists diff --git a/CHANGES b/CHANGES index 1d1d4941..e9914983 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,29 @@ # Changelog +## Version 4.2.0 + +* Adding #109 NVENC HEVC support based on FFmpeg (thanks to Zeid164) +* Adding NVEenC encoder for HEVC and AVC +* Adding #166 More robust queue that is recoverable +* Adding ability to extract HDR10+ metadata if hdr10plus_parser is detected on path +* Adding #178 selector for number of autocrop positions throughout video (thanks to bmcassagne) +* Adding Windows 10 notification for queue complete success +* Adding #194 fast two pass encoding (thanks to Ugurtan) +* Adding Confirm dialogue for cancel encode and replace currently working on video +* Changing AVC defaults and recommendations for CRF to higher values +* Changing VP9 to default to mkv instead of webm format to support more audio codecs +* Fixing German translations (thanks to SMESH) +* Fixing #171 Be able to select encoder before selecting video +* Fixing #176 Unable to change queue order or delete task from queue since 4.1.0 (thanks to Etz) +* Fixing #185 need to specify channel layout when downmixing (thanks to Ugurtan) +* Fixing #187 cleaning up partial download of FFmpeg (thanks to Todd Wilkinson) +* Fixing #190 add missing chromaloc parameter for x265 (thanks to Etz) +* Fixing #209 Double spaces were removed in incoming filenames, causing no file found (thanks to stilicrafter) +* Fixing that deinterlace detection could crash program due to CPython bug issue #43423 (thanks to macx) +* Fixing that returning item back from queue of a different encoder type would crash Fastflix +* Fixing HDR10 details to be track specific (thanks to Harybo) +* Fixing returning from queue works with duplicated audio tracks + ## Version 4.1.2 * Fixing #180 Minor UI glitch, custom bitrate retains "k" when edited from queue (thanks to Etz) diff --git a/FastFlix_Nix_OneFile.spec b/FastFlix_Nix_OneFile.spec index 3a748e70..0cd04122 100644 --- a/FastFlix_Nix_OneFile.spec +++ b/FastFlix_Nix_OneFile.spec @@ -11,7 +11,7 @@ for root, dirs, files in os.walk('fastflix'): for file in files: all_fastflix_files.append((os.path.join(root,file), root)) -all_imports = collect_submodules('pydantic') + ['dataclasses', 'colorsys'] +all_imports = collect_submodules('pydantic') + ['dataclasses', 'colorsys', 'typing_extensions'] with open("requirements-build.txt", "r") as reqs: for line in reqs: package = line.split("=")[0].split(">")[0].split("<")[0].replace('"', '').replace("'", '').strip() @@ -41,7 +41,7 @@ exe = EXE(pyz, debug=False, bootloader_ignore_signals=False, strip=False, - upx=True, + upx=False, upx_exclude=[], runtime_tmpdir=None, console=True , icon='fastflix/data/icon.ico') diff --git a/FastFlix_Windows_Installer.spec b/FastFlix_Windows_Installer.spec index dd4ae782..c1fc2d71 100644 --- a/FastFlix_Windows_Installer.spec +++ b/FastFlix_Windows_Installer.spec @@ -11,8 +11,7 @@ for root, dirs, files in os.walk('fastflix'): for file in files: all_fastflix_files.append((os.path.join(root,file), root)) - -all_imports = collect_submodules('pydantic') + ['dataclasses', 'colorsys'] +all_imports = collect_submodules('pydantic') + ['dataclasses', 'colorsys', 'typing_extensions'] with open("requirements-build.txt", "r") as reqs: for line in reqs: package = line.split("=")[0].split(">")[0].split("<")[0].replace('"', '').replace("'", '').strip() @@ -47,6 +46,6 @@ coll = COLLECT(exe, a.zipfiles, a.datas, strip=False, - upx=True, + upx=False, upx_exclude=[], name='FastFlix') diff --git a/FastFlix_Windows_OneFile.spec b/FastFlix_Windows_OneFile.spec index 88ce8eed..1634a192 100644 --- a/FastFlix_Windows_OneFile.spec +++ b/FastFlix_Windows_OneFile.spec @@ -11,7 +11,7 @@ for root, dirs, files in os.walk('fastflix'): for file in files: all_fastflix_files.append((os.path.join(root,file), root)) -all_imports = collect_submodules('pydantic') + ['dataclasses', 'colorsys'] +all_imports = collect_submodules('pydantic') + ['dataclasses', 'colorsys', 'typing_extensions'] with open("requirements-build.txt", "r") as reqs: for line in reqs: package = line.split("=")[0].split(">")[0].split("<")[0].replace('"', '').replace("'", '').strip() @@ -41,7 +41,7 @@ exe = EXE(pyz, debug=False, bootloader_ignore_signals=False, strip=False, - upx=True, + upx=False, upx_exclude=[], runtime_tmpdir=None, console=True , icon='fastflix\\data\\icon.ico') diff --git a/README.md b/README.md index 8533548d..7ab9aa66 100644 --- a/README.md +++ b/README.md @@ -16,24 +16,18 @@ Check out [the FastFlix github wiki](https://github.com/cdgriffith/FastFlix/wiki # Encoders - FastFlix supports the following encoders when their required libraries are found in FFmpeg: + FastFlix supports the following encoders if available: -* HEVC (libx265)     x265 -* AVC (libx264)        x264 -* AV1 (librav1e)        rav1e -* AV1 (libaom-av1)   av1_aom -* AV1 (libsvtav1)       svt_av1 -* VP9 (libvpx)           vpg -* WEBP (libwebp)    vpg -* GIF (gif)                 gif +| Encoder | x265 | NVENC HEVC | [NVEncC HEVC](https://github.com/rigaya/NVEnc/releases) | x264 | rav1e | AOM AV1 | SVT AV1 | VP9 | WEBP | GIF | +| --------- | ---- | ---------- | ----------- | ---- | ----- | ------- | ------- | --- | ---- | --- | +| HDR10 | ✓ | | ✓ | | | | | ✓* | | | +| HDR10+ | ✓ | | ✓ | | | | | | | | +| Audio | ✓ | ✓ | ✓* | ✓ | ✓ | ✓ | ✓ | ✓ | | | +| Subtitles | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | | +| Covers | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | | | | +| bt.2020 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | -All of these are currently supported by [BtbN's Windows FFmpeg builds](https://github.com/BtbN/FFmpeg-Builds) which is the default FFmpeg downloaded. - -Most other builds do not have all these encoders available by default and may require custom compiling FFmpeg for a specific encoder. - -* [Windows FFmpeg (and more) auto builder](https://github.com/m-ab-s/media-autobuild_suite) -* [Windows cross compile FFmpeg (build on linux)](https://github.com/rdp/ffmpeg-windows-build-helpers) -* [FFmpeg compilation guide](https://trac.ffmpeg.org/wiki/CompilationGuide) +`✓ - Full support | ✓* - Limited support` # Releases @@ -71,10 +65,12 @@ VP9 has limited support to copy some existing HDR10 metadata, usually from other ## HDR10+ -FastFlix supports using generated or [extracted JSON HDR10+ Metadata](https://github.com/cdgriffith/FastFlix/wiki/HDR10-Plus-Metadata-Extraction) with HEVC encodes via x265. However that is highly -dependant on a FFmpeg version that has been compiled with x265 that has HDR10+ support. [BtbN's Windows FFmpeg builds](https://github.com/BtbN/FFmpeg-Builds) +FastFlix supports using generated or [extracted JSON HDR10+ Metadata](https://github.com/cdgriffith/FastFlix/wiki/HDR10-Plus-Metadata-Extraction) with HEVC encodes via x265. However, that is highly +dependent on a FFmpeg version that has been compiled with x265 that has HDR10+ support. [BtbN's Windows FFmpeg builds](https://github.com/BtbN/FFmpeg-Builds) have this support as of 10/23/2020 and may require a [manual upgrade](https://github.com/cdgriffith/FastFlix/wiki/Updating-FFmpeg). +If you add HDR10+ metadata file, make sure the encoding log does NOT contain the line `x265 [warning]: –dhdr10-info disabled. Enable HDR10_PLUS in cmake` or else it is unsupported. + ## HLG FastFlix (v4.0.2+) passes through HLG color transfer information to everything except webp and GIF. @@ -83,6 +79,9 @@ FastFlix (v4.0.2+) passes through HLG color transfer information to everything e FastFlix does not plan to support Dolby Vision's proprietary format at this time. +# Support FastFlix + +Check out the different ways you can help [support FastFlix](https://github.com/cdgriffith/FastFlix/wiki/Support-FastFlix)! # License diff --git a/fastflix/application.py b/fastflix/application.py index 50051c1c..6163692a 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -4,7 +4,7 @@ import coloredlogs import reusables -from qtpy import QtGui +from qtpy import QtGui, QtWidgets, QtCore from fastflix.flix import ffmpeg_audio_encoders, ffmpeg_configuration, ffprobe_configuration from fastflix.language import t @@ -22,6 +22,10 @@ def create_app(): + if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): + QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) + if hasattr(QtCore.Qt, "AA_UseHighDpiPixmaps"): + QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True) main_app = FastFlixApp(sys.argv) main_app.setStyle("fusion") main_app.setApplicationDisplayName("FastFlix") @@ -49,24 +53,32 @@ def init_encoders(app: FastFlixApp, **_): from fastflix.encoders.avc_x264 import main as avc_plugin from fastflix.encoders.copy import main as copy_plugin from fastflix.encoders.gif import main as gif_plugin + from fastflix.encoders.ffmpeg_hevc_nvenc import main as nvenc_plugin from fastflix.encoders.hevc_x265 import main as hevc_plugin from fastflix.encoders.rav1e import main as rav1e_plugin from fastflix.encoders.svt_av1 import main as svt_av1_plugin from fastflix.encoders.vp9 import main as vp9_plugin from fastflix.encoders.webp import main as webp_plugin + from fastflix.encoders.nvencc_hevc import main as nvencc_plugin + from fastflix.encoders.nvencc_avc import main as nvencc_avc_plugin encoders = [ hevc_plugin, - avc_plugin, - gif_plugin, - vp9_plugin, - webp_plugin, + nvenc_plugin, av1_plugin, rav1e_plugin, svt_av1_plugin, + avc_plugin, + vp9_plugin, + gif_plugin, + webp_plugin, copy_plugin, ] + if app.fastflix.config.nvencc: + encoders.insert(1, nvencc_plugin) + encoders.insert(7, nvencc_avc_plugin) + app.fastflix.encoders = { encoder.name: encoder for encoder in encoders @@ -94,9 +106,9 @@ def register_app(): logger.exception("Could not set application ID for Windows, please raise issue in github with above error") -def start_app(worker_queue, status_queue, log_queue): +def start_app(worker_queue, status_queue, log_queue, queue_list, queue_lock): app = create_app() - app.fastflix = FastFlix() + app.fastflix = FastFlix(queue=queue_list, queue_lock=queue_lock) app.fastflix.log_queue = log_queue app.fastflix.status_queue = status_queue app.fastflix.worker_queue = worker_queue diff --git a/fastflix/command_runner.py b/fastflix/command_runner.py index b9d1f8e5..de8ec6e3 100644 --- a/fastflix/command_runner.py +++ b/fastflix/command_runner.py @@ -15,8 +15,6 @@ __all__ = ["BackgroundRunner"] -white_detect = re.compile(r"^\s+") - class BackgroundRunner: def __init__(self, log_queue): @@ -106,6 +104,8 @@ def read_output(self): err_excess = err_file.read() logger.info(err_excess) self.log_queue.put(err_excess) + if self.process.returncode is not None and self.process.returncode > 0: + self.error_detected = True break line = out_file.readline().rstrip() if line: @@ -156,7 +156,7 @@ def clean(self): self.started_at = None def kill(self, log=True): - if self.process_two and self.process.poll() is None: + if self.process_two and self.process_two.poll() is None: if log: logger.info(f"Killing worker process {self.process_two.pid}") try: @@ -165,6 +165,7 @@ def kill(self, log=True): except Exception as err: if log: logger.exception(f"Couldn't terminate process: {err}") + if self.process and self.process.poll() is None: if log: logger.info(f"Killing worker process {self.process.pid}") @@ -193,23 +194,3 @@ def resume(self): if not self.process: return False self.process.resume() - - -# if __name__ == "__main__": -# from queue import Queue -# -# logging.basicConfig(level=logging.DEBUG) -# br = BackgroundRunner(Queue()) -# import shutil -# -# ffmpeg = shutil.which("ffmpeg") -# br.start_piped_exec( -# command_one=shlex.split( -# rf'"{ffmpeg}" -loglevel panic -i C:\\Users\\Chris\\scoob_short.mkv -c:v copy -vbsf hevc_mp4toannexb -f hevc -' -# ), -# command_two=shlex.split(r'"C:\\Users\\Chris\\ffmpeg\\hdr10plus_parser.exe" --verify -'), -# work_dir=r"C:\Users\Chris", -# ) -# import time -# time.sleep(1) -# br.read_output() diff --git a/fastflix/conversion_worker.py b/fastflix/conversion_worker.py index ae6dc624..eeb77ba6 100644 --- a/fastflix/conversion_worker.py +++ b/fastflix/conversion_worker.py @@ -2,16 +2,28 @@ import logging from pathlib import Path from queue import Empty +from typing import Optional +from multiprocessing import Lock import reusables from appdirs import user_data_dir +from pathvalidate import sanitize_filename from fastflix.command_runner import BackgroundRunner from fastflix.language import t from fastflix.shared import file_date +from fastflix.models.video import Video + logger = logging.getLogger("fastflix-core") +log_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "logs" +after_done_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "after_done_logs" + +queue_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "queue.yaml" +queue_lock_file = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "queue.lock" + + CONTINUOUS = 0x80000000 SYSTEM_REQUIRED = 0x00000001 @@ -41,25 +53,91 @@ def allow_sleep_mode(): logger.debug("System has been allowed to enter sleep mode again") +def get_next_video(queue_list, queue_lock) -> Optional[Video]: + with queue_lock: + logger.debug(f"Retrieving next video from {queue_list}") + for video in queue_list: + if ( + not video.status.complete + and not video.status.success + and not video.status.cancelled + and not video.status.error + and not video.status.running + ): + logger.debug(f"Next video is {video.uuid} - {video.status}") + return video.copy() + + +def set_status( + current_video: Video, + queue_list, + queue_lock, + complete=None, + success=None, + cancelled=None, + errored=None, + running=None, + next_command=False, + reset_commands=False, +): + if not current_video: + return + + with queue_lock: + for i, video in enumerate(queue_list): + if video.uuid == current_video.uuid: + video_pos = i + break + else: + logger.error(f"Can't find video {current_video.uuid} in queue to update its status: {queue_list}") + return + + video_copy = queue_list.pop(video_pos) + + if complete is not None: + video_copy.status.complete = complete + if cancelled is not None: + video_copy.status.cancelled = cancelled + if errored is not None: + video_copy.status.error = errored + if success is not None: + video_copy.status.success = success + if running is not None: + video_copy.status.running = running + + if complete or cancelled or errored or success: + video_copy.status.running = False + + if next_command: + video_copy.status.current_command += 1 + if reset_commands: + video_copy.status.current_command = 0 + + queue_list.insert(video_pos, video_copy) + + @reusables.log_exception(log="fastflix-core") -def queue_worker(gui_proc, worker_queue, status_queue, log_queue): +def queue_worker(gui_proc, worker_queue, status_queue, log_queue, queue_list, queue_lock: Lock): runner = BackgroundRunner(log_queue=log_queue) # Command looks like (video_uuid, command_uuid, command, work_dir) after_done_command = "" - commands_to_run = [] gui_died = False currently_encoding = False paused = False - log_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "logs" - after_done_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "after_done_logs" + video: Optional[Video] = None def start_command(): nonlocal currently_encoding - log_queue.put(f"CLEAR_WINDOW:{commands_to_run[0][0]}:{commands_to_run[0][1]}") + log_queue.put( + f"CLEAR_WINDOW:{video.uuid}:{video.video_settings.conversion_commands[video.status.current_command].uuid}" + ) reusables.remove_file_handlers(logger) new_file_handler = reusables.get_file_handler( - log_path / f"flix_conversion_{commands_to_run[0][4]}_{file_date()}.log", + log_path + / sanitize_filename( + f"flix_conversion_{video.video_settings.video_title or video.video_settings.output_path.stem}_{file_date()}.log" + ), level=logging.DEBUG, log_format="%(asctime)s - %(message)s", encoding="utf-8", @@ -68,11 +146,13 @@ def start_command(): prevent_sleep_mode() currently_encoding = True runner.start_exec( - commands_to_run[0][2], - work_dir=commands_to_run[0][3], + video.video_settings.conversion_commands[video.status.current_command].command, + work_dir=str(video.work_path), ) + set_status(video, queue_list=queue_list, queue_lock=queue_lock, running=True) + status_queue.put(("queue",)) - status_queue.put(("running", commands_to_run[0][0], commands_to_run[0][1], runner.started_at.isoformat())) + # status_queue.put(("running", commands_to_run[0][0], commands_to_run[0][1], runner.started_at.isoformat())) while True: if currently_encoding and not runner.is_alive(): @@ -82,8 +162,8 @@ def start_command(): # Stop working! currently_encoding = False - status_queue.put(("error", commands_to_run[0][0], commands_to_run[0][1])) - commands_to_run = [] + set_status(video, queue_list=queue_list, queue_lock=queue_lock, errored=True) + status_queue.put(("error",)) allow_sleep_mode() if gui_died: return @@ -91,25 +171,35 @@ def start_command(): # Successfully encoded, do next one if it exists # First check if the current video has more commands - logger.info(t("Command has completed")) - status_queue.put(("converted", commands_to_run[0][0], commands_to_run[0][1])) - commands_to_run.pop(0) - if commands_to_run: - if not paused: - logger.info(t("starting next command")) - start_command() - else: - currently_encoding = False - allow_sleep_mode() - logger.debug(t("Queue has been paused")) + video.status.current_command += 1 + log_queue.put("STOP_TIMER") + + if len(video.video_settings.conversion_commands) > video.status.current_command: + logger.debug("About to run next command for this video") + set_status(video, queue_list=queue_list, queue_lock=queue_lock, next_command=True) + status_queue.put(("queue",)) + start_command() continue else: - logger.info(t("all conversions complete")) - # Finished the queue - # fastflix.current_encoding = None + logger.debug(f"{video.uuid} has been completed") + set_status(video, queue_list=queue_list, queue_lock=queue_lock, next_command=True, complete=True) + status_queue.put(("queue",)) + video = None + + if paused: currently_encoding = False - status_queue.put(("complete",)) allow_sleep_mode() + logger.debug(t("Queue has been paused")) + continue + + if video := get_next_video(queue_list=queue_list, queue_lock=queue_lock): + start_command() + continue + else: + currently_encoding = False + allow_sleep_mode() + logger.info(t("all conversions complete")) + status_queue.put(("complete",)) if after_done_command: logger.info(f"{t('Running after done command:')} {after_done_command}") try: @@ -142,36 +232,42 @@ def start_command(): # Request looks like (queue command, log_dir, (commands)) log_path = Path(request[1]) - for command in request[2]: - if command not in commands_to_run: - logger.debug(t(f"Adding command to the queue for {command[4]} - {command[2]}")) - commands_to_run.append(command) - # else: - # logger.debug(t(f"Command already in queue: {command[1]}")) - if not runner.is_alive() and not paused: - logger.debug(t("No encoding is currently in process, starting encode")) - start_command() + if not currently_encoding and not paused: + video = get_next_video(queue_list=queue_list, queue_lock=queue_lock) + if video: + start_command() + if request[0] == "cancel": logger.debug(t("Cancel has been requested, killing encoding")) runner.kill() + if video: + set_status(video, queue_list=queue_list, queue_lock=queue_lock, reset_commands=True, cancelled=True) currently_encoding = False allow_sleep_mode() - status_queue.put(("cancelled", commands_to_run[0][0], commands_to_run[0][1])) - commands_to_run = [] + status_queue.put(("cancelled", video.uuid if video else "")) + log_queue.put("STOP_TIMER") + video = None + if request[0] == "pause queue": logger.debug(t("Command worker received request to pause encoding after the current item completes")) paused = True + if request[0] == "resume queue": paused = False logger.debug(t("Command worker received request to resume encoding")) - if commands_to_run and not runner.is_alive(): - start_command() + if not currently_encoding: + if not video: + video = get_next_video(queue_list=queue_list, queue_lock=queue_lock) + if video: + start_command() + if request[0] == "set after done": after_done_command = request[1] if after_done_command: logger.debug(f'{t("Setting after done command to:")} {after_done_command}') else: logger.debug(t("Removing after done command")) + if request[0] == "pause encode": logger.debug(t("Command worker received request to pause current encode")) try: @@ -179,7 +275,7 @@ def start_command(): except Exception: logger.exception("Could not pause command") else: - status_queue.put(("paused encode", commands_to_run[0][0], commands_to_run[0][1])) + status_queue.put(("paused encode",)) if request[0] == "resume encode": logger.debug(t("Command worker received request to resume paused encode")) try: @@ -187,4 +283,4 @@ def start_command(): except Exception: logger.exception("Could not resume command") else: - status_queue.put(("resumed encode", commands_to_run[0][0], commands_to_run[0][1])) + status_queue.put(("resumed encode",)) diff --git a/fastflix/data/encoders/icon_nvenc.png b/fastflix/data/encoders/icon_nvenc.png new file mode 100644 index 00000000..7eb94ff9 Binary files /dev/null and b/fastflix/data/encoders/icon_nvenc.png differ diff --git a/fastflix/data/encoders/icon_nvencc.png b/fastflix/data/encoders/icon_nvencc.png new file mode 100644 index 00000000..2eccfd86 Binary files /dev/null and b/fastflix/data/encoders/icon_nvencc.png differ diff --git a/fastflix/data/icons/undo-arrow.png b/fastflix/data/icons/undo-arrow.png new file mode 100644 index 00000000..d4df03e7 Binary files /dev/null and b/fastflix/data/icons/undo-arrow.png differ diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 38acfc4d..7052ed74 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -5,6 +5,13 @@ ita: 4 o 5 disattiveranno l'ottimizzazione della distorsione del tasso, con un impatto ancora maggiore sulla qualità. spa: 4 o 5 desactivarán la optimización de la tasa de distorsión, lo que tendrá un impacto aún mayor en la calidad. zho: 4或5会关闭rate distortion optimization,对质量的影响会更大。 +AQ Strength: + deu: Strärke des AQ + eng: AQ Strength + fra: AQ Force + ita: Forza AQ + spa: Fuerza de AQ + zho: AQ强度 About: deu: Über eng: About @@ -54,6 +61,13 @@ Advanced: ita: Avanzato spa: Avanzado zho: 高级 +Advanced settings are currently not saved in Profiles: + deu: Erweiterte Einstellungen werden derzeit nicht in Profilen gespeichert + eng: Advanced settings are currently not saved in Profiles + fra: Les paramètres avancés ne sont actuellement pas enregistrés dans les Profils + ita: Le impostazioni avanzate non sono attualmente salvate in Profili + spa: Los ajustes avanzados no se guardan actualmente en Perfiles + zho: 高级设置目前不会保存到方案中 After Conversion: deu: Nach der Konvertierung eng: After Conversion @@ -90,7 +104,7 @@ Audio Tracks: spa: Pistas de audio zho: 音轨 Audio select language: - deu: Audio Sprache auswählen + deu: Audio-Sprache auswählen eng: Audio select language fra: Audio choisir la langue ita: Audio seleziona la lingua @@ -111,7 +125,7 @@ Auto: spa: Auto zho: 自动 Auto Burn-in first forced or default subtitle track: - deu: Auto Burn-in erste erzwungene oder Standard-Untertitelspur + deu: Auto Einbrennen der ersten erzwungenen oder Standard-Untertitelspur eng: Auto Burn-in first forced or default subtitle track fra: Auto Burn-in première piste de sous-titres forcée ou par défaut ita: Auto Burn-in prima traccia sottotitoli forzata o predefinita @@ -152,6 +166,20 @@ B Adapt: ita: B Adattare spa: B Adaptar zho: B Adapt +B Frames: + deu: B-Frames + eng: B Frames + fra: B Cadres + ita: B Frames + spa: Fotogramas B + zho: B型框架 +B Ref Mode: + deu: B-Ref-Modus + eng: B Ref Mode + fra: B Mode Ref + ita: Modalità B Ref + spa: Modo B Ref + zho: B参考模式 Bit Depth: deu: Bit-Tiefe eng: Bit Depth @@ -173,6 +201,13 @@ Block Size: ita: Dimensione del blocco spa: Tamaño del bloque zho: 块大小 +Both Passes: + deu: Beide Durchgänge + eng: Both Passes + fra: Les deux passages + ita: Entrambi i Pass + spa: Ambos pases + zho: 两遍均应用 Bottom: deu: Unten eng: Bottom @@ -194,6 +229,13 @@ Break the video into rows to encode faster (lesser quality): ita: Suddividere il video in righe per codificare più velocemente (qualità inferiore) spa: Dividir el video en filas para codificar más rápido (menor calidad) zho: 将视频按行分割,以更快地进行编码(质量较差)。 +Bufsize: + deu: Bufsize + eng: Bufsize + fra: Bufsize + ita: Bufsize + spa: Bufsize + zho: Bufsize Build: deu: Erstellen eng: Build @@ -215,6 +257,13 @@ CPU Used: ita: CPU usata spa: CPU utilizado zho: CPU用量 +Calculate PSNR and SSIM and show in the encoder output: + deu: PSNR und SSIM berechnen und in der Kodierungsausgabe anzeigen + eng: Calculate PSNR and SSIM and show in the encoder output + fra: Calculer PSNR et SSIM et afficher dans la sortie du codeur + ita: Calcolare PSNR e SSIM e mostrare nell'output del codificatore + spa: Calcular PSNR y SSIM y mostrar en la salida del codificador + zho: 计算PSNR和SSIM,并显示在编码器输出中。 Cancel: deu: Abbrechen eng: Cancel @@ -265,21 +314,21 @@ Cannot remove afterwards!: spa: No se puede quitar después! zho: 字幕内嵌之后无法去除! Check for Newer Version of FastFlix: - deu: Prüfen auf neuere Version von FastFlix + deu: Auf neuere Version von FastFlix überprüfen eng: Check for Newer Version of FastFlix fra: Consultez la nouvelle version de FastFlix ita: Verifica la presenza di una versione più recente di FastFlix spa: Busca la nueva versión de FastFlix zho: 检查FastFlix更新 Clean Old Logs: - deu: Alte Stämme reinigen + deu: Alte Protokolle löschen eng: Clean Old Logs fra: Nettoyer les vieilles bûches ita: Pulire i vecchi tronchi spa: Limpiar los viejos troncos zho: 清理旧日志 Clear Completed: - deu: Erledigt löschen + deu: Erledigte entfernen eng: Clear Completed fra: Clair Terminé ita: Chiaro Completato @@ -300,7 +349,7 @@ CodeCalamity UHD HDR Encoding Guide: spa: CodeCalamity UHD Guía de codificación HDR zho: CodeCalamity的UHD HDR编码指南(英文) Color Primaries: - deu: Farbe Primaries + deu: Grundfarbmodell eng: Color Primaries fra: Les couleurs primaires ita: Primarie di colore @@ -314,42 +363,42 @@ Color Space: spa: Espacio de color zho: 色彩空间 Color Transfer: - deu: Farbe übertragen + deu: Übertragungsfunktion eng: Color Transfer fra: Transfert de couleur ita: Trasferimento del colore spa: Transferencia de color zho: 色彩转换 Command has completed: - deu: Befehl wurde beendet + deu: Befehl wurde abgeschlossen eng: Command has completed fra: Le commandement a terminé ita: Il comando ha completato spa: El comando ha completado zho: 命令已完成 Command worker received request to pause current encode: - deu: Command Worker hat Anforderung erhalten, die aktuelle Codierung zu pausieren + deu: Befehlsablauf hat Anfrage erhalten, die aktuelle Kodierung zu pausieren eng: Command worker received request to pause current encode fra: Un employé du commandement a reçu une demande de pause de l'encodage en cours ita: Operatore di comando ha ricevuto la richiesta di mettere in pausa la codifica corrente spa: El trabajador del comando recibió una solicitud para pausar la codificación actual zho: 命令执行程序收到了暂停当前编码的请求 Command worker received request to pause encoding after the current item completes: - deu: Command-Worker hat Anforderung erhalten, das Encoding nach Abschluss des aktuellen Elements zu pausieren + deu: Befehlsablauf hat Anfrage erhalten, das Encoding nach Abschluss des aktuellen Elements zu pausieren eng: Command worker received request to pause encoding after the current item completes fra: Un membre du personnel de commandement a reçu une demande de pause d'encodage après la fin de l'élément en cours ita: L'operatore di comando ha ricevuto la richiesta di mettere in pausa la codifica dopo il completamento della voce corrente spa: El trabajador del comando recibió la solicitud de pausar la codificación después de que el elemento actual complete zho: 命令执行程序收到了在当前项目完成后暂停编码的请求 Command worker received request to resume encoding: - deu: Befehls-Worker hat Anforderung erhalten, die Kodierung fortzusetzen + deu: Befehlsablauf hat Anfrage erhalten, die Kodierung fortzusetzen eng: Command worker received request to resume encoding fra: Le commandant a reçu une demande de reprise de l'encodage ita: L'operatore di comando ha ricevuto la richiesta di riprendere la codifica spa: El comandante recibió la solicitud de reanudar la codificación zho: 命令执行程序收到了恢复编码的请求 Command worker received request to resume paused encode: - deu: Befehls-Worker hat Anforderung erhalten, die pausierte Codierung fortzusetzen + deu: Befehlsablauf hat Anfrage erhalten, die pausierte Kodierung fortzusetzen eng: Command worker received request to resume paused encode fra: Un officier de commandement a reçu une demande de reprise de l'encodage en pause ita: Operatore di comando ha ricevuto la richiesta di riprendere la pausa codificare @@ -369,6 +418,13 @@ Config File: ita: File di configurazione spa: Archivo de configuración zho: 配置文件 +Constant: + deu: Konstant + eng: Constant + fra: Constant + ita: Costante + spa: Constante + zho: 恒定 Conversion: deu: Konvertierung eng: Conversion @@ -384,7 +440,7 @@ Conversion cancelled, delete incomplete file: spa: Conversión cancelada, borrar archivo incompleto zho: 转换已取消,删除不完整的文件 Conversion worker shutting down: - deu: Konvertierungsarbeiter beim Herunterfahren + deu: Konvertierungsablauf fährt herunter eng: Conversion worker shutting down fra: Fermeture d'une entreprise de reconversion ita: Operaio di conversione in chiusura @@ -398,12 +454,12 @@ Convert: spa: Convierte zho: 转换 Convert BT2020 colorspace into bt709: - deu: BT2020-Farbraum in bt709 konvertieren - eng: Convert BT2020 colorspace into bt709 - fra: Convertir l'espace colorimétrique BT2020 en bt709 - ita: Convertire lo spazio di colore BT2020 in bt709 - spa: Convierte el espacio de color BT2020 en bt709 - zho: 将BT2020色彩空间转换为BT709 + deu: BT.2020-Farbraum nach BT.709 konvertieren + eng: Convert BT.2020 colorspace into BT.709 + fra: Convertir l'espace colorimétrique BT.2020 en BT.709 + ita: Convertire lo spazio di colore BT.2020 in BT.709 + spa: Convierte el espacio de color BT.2020 en BT.709 + zho: 将BT.2020色彩空间转换为BT.709 Copy Chapters: deu: Kapitel kopieren eng: Copy Chapters @@ -447,14 +503,14 @@ Copy Small Landscape Cover (no preview): spa: Copia de la cubierta de un pequeño paisaje (sin vista previa) zho: 复制横向小封面(无预览) Copy all commands to the clipboard: - deu: Kopiert alle Befehle in die Zwischenablage + deu: Kopiere alle Befehle in die Zwischenablage eng: Copy all commands to the clipboard fra: Copier toutes les commandes dans le presse-papiers ita: Copiare tutti i comandi negli appunti spa: Copia todos los comandos al portapapeles zho: 将所有命令复制到剪贴板 Copy the chapter markers as is from incoming source.: - deu: Kopieren Sie die Kapitelmarkierungen so wie sie sind aus der eingehenden Quelle. + deu: Unverändertes Kopieren der Kapitelmarkierungen aus der eingehenden Quelle. eng: Copy the chapter markers as is from incoming source. fra: Copiez les marqueurs de chapitre tels quels à partir de la source entrante. ita: Copiare i marcatori dei capitoli come da sorgente in entrata. @@ -482,7 +538,7 @@ Could not create / access work directory: spa: No pudo crear / acceder al directorio de trabajo zho: 无法创建/访问工作目录 Could not fix first subtitle track: - deu: Konnte die erste Untertitelspur nicht fixieren + deu: Konnte die erste Untertitelspur nicht reparieren eng: Could not fix first subtitle track fra: Impossible de réparer la première piste de sous-titres ita: Impossibile fissare la prima traccia dei sottotitoli @@ -503,7 +559,7 @@ Could not load config file!: spa: ¡No pude cargar el archivo de configuración! zho: 无法加载配置文件! Could not set language to: - deu: Konnte die Sprache nicht auf + deu: Konnte die Sprache nicht ändern auf eng: Could not set language to fra: Impossible de régler la langue sur ita: Non è stato possibile impostare la lingua su @@ -537,6 +593,13 @@ Crop: ita: Ritaglio spa: Crop zho: 裁剪 +Crop Detect Points: + deu: Feststellungspunkte fürs Cropping + eng: Crop Detect Points + fra: Points de détection des cultures + ita: Ritagliare i punti di rilevamento + spa: Puntos de detección de cultivos + zho: 作物检测点 Current Profile Settings: deu: Aktuelle Profileinstellungen eng: Current Profile Settings @@ -551,6 +614,13 @@ Currently only works for image based subtitles.: ita: Attualmente funziona solo per i sottotitoli basati su immagini. spa: Actualmente sólo funciona para subtítulos basados en imágenes. zho: 目前只适用于基于图像的字幕。 +Custom NVEncC options: + deu: angepasste NVEncC-Optionen + eng: Custom NVEncC options + fra: Options NVEncC personnalisées + ita: Opzioni NVEncC personalizzate + spa: Opciones personalizadas de NVEncC + zho: 自定义NVEncC选项 Custom ffmpeg options: deu: Benutzerdefinierte ffmpeg-Optionen eng: Custom ffmpeg options @@ -559,14 +629,14 @@ Custom ffmpeg options: spa: Opciones personalizadas de ffmpeg zho: 自定义ffmpeg选项 Deblock: - deu: Deblockieren + deu: Deblocking eng: Deblock fra: Deblock ita: Deblock spa: Deblock zho: 去块 Default 4. This parameter has a quadratic effect on the amount of memory allocated: - deu: Voreinstellung 4. Dieser Parameter hat einen quadratischen Effekt auf die Menge des zugewiesenen Speichers + deu: Voreinstellung 4. Die Menge des mit diesem Parameter zugewiesenen Speichers wächst quadratisch. eng: Default 4. This parameter has a quadratic effect on the amount of memory allocated fra: Défaut 4. Ce paramètre a un effet quadratique sur la quantité de mémoire allouée ita: Predefinito 4. Questo parametro ha un effetto quadratico sulla quantità di memoria allocata @@ -628,6 +698,13 @@ Denoise: ita: Denoise spa: Denoise zho: 降噪 +Detect HDR10+: + deu: HDR10+ erkennen + eng: Detect HDR10+ + fra: Détecter le HDR10+ + ita: Rileva HDR10 + spa: Detectar HDR10+ + zho: 检测HDR10+ Detecting Interlace: deu: Erkennen von Interlace eng: Detecting Interlace @@ -650,7 +727,7 @@ Disable update check on startup: spa: Desactivar la comprobación de actualización al inicio zho: 禁用启动时的更新检查 Disposition: - deu: Disposition + deu: Verwendung eng: Disposition fra: Disposition ita: Disposizione @@ -664,7 +741,7 @@ Dither: spa: Dither zho: 抖动 Dither is an intentionally applied form of noise used to randomize quantization error,: - deu: Dither ist eine absichtlich angewandte Form des Rauschens, die dazu dient, Quantisierungsfehler zu randomisieren, + deu: Dither dient dazu, sichtbare Quantisierungsfehler durch absichtliches Zufallsrauschen abzuschwächen. eng: Dither is an intentionally applied form of noise used to randomize quantization error, fra: Dither est une forme de bruit appliquée intentionnellement et utilisée pour randomiser l'erreur de quantification, ita: Il dither è una forma di rumore applicata intenzionalmente usata per randomizzare l'errore di quantizzazione, @@ -677,6 +754,13 @@ Download: ita: Scaricare spa: Descargar zho: 下载 +Download Cancelled: + deu: Download abgebrochen + eng: Download Cancelled + fra: Téléchargement annulé + ita: Scaricamento annullato + spa: Descarga cancelada + zho: 下载取消 Download Newest FFmpeg: deu: Neuestes FFmpeg herunterladen eng: Download Newest FFmpeg @@ -692,12 +776,19 @@ Downloading FFmpeg: spa: Descargar FFmpeg zho: 正在下载FFmpeg Dual License: - deu: Doppellizenz + deu: Doppellizenzierung eng: Dual License fra: Double licence ita: Doppia Licenza spa: Licencia doble zho: 双重许可 +Enable VBV: + deu: VBV aktivieren + eng: Enable VBV + fra: Activer la VBV + ita: Attivare VBV + spa: Activar VBV + zho: 启用VBV Enable row based multi-threading: deu: Zeilenbasiertes Multithreading aktivieren eng: Enable row based multi-threading @@ -706,14 +797,14 @@ Enable row based multi-threading: spa: Habilitar el multihilo basado en filas zho: 启用基于行的多线程 Enable strong intra smoothing for 32x32 intra blocks.: - deu: Aktivieren Sie die starke Intra-Glättung für 32x32-Intra-Blöcke. + deu: Aktivieren der starken Intra-Glättung für 32x32-Intra-Blöcke. eng: Enable strong intra smoothing for 32x32 intra blocks. fra: Permettre un lissage intra fort pour les blocs intra 32x32. ita: Abilita una forte lisciatura intra per gli intrablocchi 32x32. spa: Habilitar un fuerte alisamiento interno para los bloqueos internos de 32x32. zho: Enable strong intra smoothing for 32x32 intra blocks. Enable the yadif filter: - deu: Aktivieren Sie den Yadif-Filter + deu: Aktivieren des Yadif-Filters eng: Enable the yadif filter fra: Activer le filtre yadif ita: Attivare il filtro yadif @@ -755,28 +846,28 @@ Enabling cover thumbnails on your system: spa: Habilitando las miniaturas de la cubierta en su sistema zho: 在系统上启用封面缩略图(英文) Encoder: - deu: Codierer + deu: Kodierer eng: Encoder fra: Encodeur ita: Encoder spa: Codificador zho: 编码器 Encoder Output: - deu: Encoder-Ausgabe + deu: Kodierer-Ausgabe eng: Encoder Output fra: Sortie de l'encodeur ita: Uscita encoder spa: Salida del codificador zho: 编码器输出 Encoder Settings: - deu: Encoder-Einstellung + deu: Kodierer-Einstellung eng: Encoder Settings fra: Réglage du codeur ita: Impostazione dell'encoder spa: Configuración del codificador zho: 编码器设置 Encoding Queue: - deu: Kodierungswarteschlange + deu: Kodierer-Warteschlange eng: Encoding Queue fra: File d'attente d'encodage ita: Coda di codifica @@ -803,6 +894,13 @@ Encoding complete: ita: Codifica completa spa: Codificación completa zho: 编码完成 +Encoding errored: + deu: Error beim Kodieren + eng: Encoding errored + fra: Encodage erroné + ita: Codifica errata + spa: '' + zho: 编码错误 End: deu: Beenden eng: End @@ -811,7 +909,7 @@ End: spa: Fin zho: 结束 Enforce an encode profile: - deu: Erzwingen eines Kodierprofils + deu: Erzwingen eines Kodiererprofils eng: Enforce an encode profile fra: Faire appliquer un profil codé ita: Applicare un profilo di codifica @@ -846,7 +944,7 @@ Estimated file size based on bitrate: spa: Tamaño estimado del archivo basado en la tasa de bits zho: 基于比特率的估计文件大小 Estimated time left for current command: - deu: Geschätzte verbleibende Zeit für den aktuellen Befehl + deu: Geschätzte Restzeit für den aktuellen Befehl eng: Estimated time left for current command fra: Estimation du temps restant pour le commandement en cours ita: Tempo stimato rimasto per il comando corrente @@ -880,43 +978,78 @@ Extra x265 params in opt=1:opt2=0 format: ita: Parametri extra x265 nel formato opt=1:opt2=0 spa: Parámetros extra x265 en formato opt=1:opt2=0 zho: 额外的x265参数,格式为opt=1:opt2=0 +Extract: + deu: Auszug + eng: Extract + fra: Extrait + ita: Estratto + spa: Extracto + zho: 提取 +Extract HDR10+: + deu: HDR10+ extrahieren + eng: Extract HDR10+ + fra: Extrait HDR10+ + ita: Estrarre HDR10 + spa: Extraer HDR10+ + zho: 提取HDR10+ Extract covers: - deu: Extrahieren deckt ab + deu: Covers extrahieren eng: Extract covers fra: Extrait couvre ita: Coperture per l'estrazione spa: Extraer las cubiertas zho: 提取封面 +Extracted subtitles successfully: + deu: Untertitel erfolgreich extrahiert + eng: Extracted subtitles successfully + fra: Sous-titres extraits avec succès + ita: Sottotitoli estratti con successo + spa: Subtítulos extraídos con éxito + zho: 成功提取字幕 +Extracting HDR10+ metadata: + deu: HDR10+-Metadaten extrahieren + eng: Extracting HDR10+ metadata + fra: Extraction des métadonnées HDR10+. + ita: Estrarre i metadati HDR10 + spa: Extracción de metadatos HDR10+ + zho: 提取HDR10+元数据 +Extracting subtitles to: + deu: Extrahieren von Untertiteln auf + eng: Extracting subtitles to + fra: Extraction des sous-titres de + ita: Estrazione dei sottotitoli a + spa: Extracción de subtítulos a + zho: 提取字幕到 FFMPEG AV1 Encoding Guide: - deu: FFMPEG AV1-Kodierungsleitfaden + deu: FFMPEG AV1 Kodierungsanleitung eng: FFMPEG AV1 Encoding Guide fra: Guide d'encodage FFMPEG AV1 ita: Guida alla codifica FFMPEG AV1 spa: Guía de codificación del FFMPEG AV1 zho: FFMPEG AV1编码指南(英文) FFMPEG AVC / H.264 Encoding Guide: - deu: FFMPEG AVC / H.264 Kodierungsleitfaden + deu: FFMPEG AVC / H.264 Kodierungsanleitung eng: FFMPEG AVC / H.264 Encoding Guide fra: FFMPEG AVC / Guide d'encodage H.264 ita: Guida alla codifica FFMPEG AVC / H.264 spa: Guía de codificación FFMPEG AVC / H.264 zho: FFMPEG AVC / H.264 编码指南(英文) FFMPEG HEVC / H.265 Encoding Guide: - deu: FFMPEG HEVC / H.265 Kodierungsleitfaden + deu: FFMPEG HEVC / H.265 Kodierungsanleitung eng: FFMPEG HEVC / H.265 Encoding Guide fra: FFMPEG HEVC / Guide d'encodage H.265 ita: Guida alla codifica FFMPEG HEVC / H.265 spa: Guía de codificación FFMPEG HEVC / H.265 zho: FFMPEG HEVC / H.265编码指南(英文) FFMPEG VP9 Encoding Guide: - deu: FFMPEG VP9-Encoding-Anleitung + deu: FFMPEG VP9 Kodierungsanleitung eng: FFMPEG VP9 Encoding Guide fra: FFMPEG Guide d'encodage VP9 ita: Guida alla codifica FFMPEG VP9 spa: Guía de codificación del FFMPEG VP9 zho: FFMPEG VP9编码指南(英文) FFmpeg updated - Please restart FastFlix: - deu: FFmpeg aktualisiert - Bitte starten Sie FastFlix neu + deu: FFmpeg aktualisiert - Bitte FastFlix neustarten eng: FFmpeg updated - Please restart FastFlix fra: FFmpeg mis à jour - Veuillez redémarrer FastFlix ita: FFmpeg aggiornato - Si prega di riavviare FastFlix @@ -929,22 +1062,36 @@ FPS: ita: FPS spa: FPS zho: FPS +Fast first pass: + deu: schneller erster Durchlauf + eng: Fast first pass + fra: Première passe rapide + ita: Primo passaggio veloce + spa: Primera pasada rápida + zho: 快速的第一道 +File: + deu: Datei + eng: File + fra: Fichier + ita: Archivio + spa: Archivo + zho: 文件 Flat UI: - deu: Flaches UI - eng: Flat UI - fra: Assurance-chômage à plat - ita: IU piatta - spa: UI plana - zho: 扁平化UI + deu: Flaches Design der GUI + eng: Flat GUI design + fra: interface graphique à plat + ita: GUI piatta + spa: GUI plana + zho: 扁平化GUI For lossless, this is a size/speed tradeoff.: - deu: Bei verlustfreier Encodierung ist dies ein Kompromiss zwischen Größe und Geschwindigkeit. + deu: Bei verlustfreier Kodierung ist dies ein Kompromiss zwischen Größe und Geschwindigkeit. eng: For lossless, this is a size/speed tradeoff. fra: Pour les moins fortunés, il s'agit d'un compromis taille/vitesse. ita: Per i lossless, questo è un compromesso tra dimensioni e velocità. spa: Para los que no tienen pérdidas, esto es un intercambio de tamaño/velocidad. zho: 在无损编码时,此选项在大小与速度之间权衡。 For lossy, this is a quality/speed tradeoff.: - deu: Für verlustbehaftet ist dies ein Kompromiss zwischen Qualität und Geschwindigkeit. + deu: Für verlustbehafteter Kodierung ist dies ein Kompromiss zwischen Qualität und Geschwindigkeit. eng: For lossy, this is a quality/speed tradeoff. fra: Pour les perdants, il s'agit d'un compromis qualité/vitesse. ita: Per le perdite, si tratta di un compromesso qualità/velocità. @@ -971,22 +1118,36 @@ Frames Per Second: ita: Cornici al secondo spa: Cuadros por segundo zho: 每秒帧数 +GPU: + deu: GPU + eng: GPU + fra: GPU + ita: GPU + spa: GPU + zho: GPU +GUI Logging Level: + deu: GUI-Protokollierungsebene + eng: GUI Logging Level + fra: Niveau d'exploitation forestière + ita: Livello di registrazione GUI + spa: Nivel de registro GUI + zho: GUI日志级别 Gather FFmpeg audio encoders: - deu: Sammeln von FFmpeg-Audio-Encodern + deu: FFmpeg-Audio-Encoder abfragen eng: Gather FFmpeg audio encoders fra: Rassembler les encodeurs audio FFmpeg ita: Raccogliere gli encoder audio FFmpeg spa: Reúne los codificadores de audio FFmpeg zho: Gather FFmpeg audio encoders Gather FFmpeg version: - deu: Sammeln Sie FFmpeg-Version + deu: FFmpeg-Version abfragen eng: Gather FFmpeg version fra: Rassembler la version FFmpeg ita: Raccogliere la versione FFmpeg spa: Reunir la versión FFmpeg zho: 获取FFmpeg版本 Gather FFprobe version: - deu: Sammeln Sie FFprobe-Version + deu: FFprobe-Version abfragen eng: Gather FFprobe version fra: Rassembler la version FFprobe ita: Raccogliere la versione FFprobe @@ -1007,7 +1168,7 @@ Google's VP9 HDR Encoding Guide: spa: Guía de codificación HDR VP9 de Google zho: 谷歌VP9 HDR编码指南(英文) HDR -> SDR Tone Map: - deu: HDR -> SDR-Tonwertkarte + deu: HDR -> SDR Tone Mapping eng: HDR -> SDR Tone Map fra: HDR -> SDR Carte des tons ita: HDR -> Mappa dei toni SDR @@ -1042,7 +1203,7 @@ HDR10+ Optimizations: spa: Optimizaciones del HDR10+ zho: HDR10+优化 Have to select a video first: - deu: Sie müssen zuerst ein Video auswählen + deu: Es muss zuerst ein Video ausgewählt werden eng: Have to select a video first fra: Il faut d'abord sélectionner une vidéo ita: Devi prima selezionare un video @@ -1063,19 +1224,26 @@ Help: spa: Ayuda zho: 帮助 Hide NAL unit messages: - deu: NAL-Geräte-Meldungen ausblenden - eng: Hide NAL unit messages - fra: Cacher les messages de l'unité NAL - ita: Nascondere i messaggi delle unità NAL - spa: Ocultar los mensajes de la unidad NAL - zho: 隐藏NAL unit信息 + deu: NAL-Meldungen (AVC/HEVC Network Abstraction Layer) ausblenden + eng: Hide NAL unit (AVC/HEVC Network Abstraction Layer) messages + fra: Cacher les messages de l'unité NAL (AVC/HEVC Network Abstraction Layer) + ita: Nascondere i messaggi delle unità NAL (AVC/HEVC Network Abstraction Layer) + spa: Ocultar los mensajes de la unidad NAL (AVC/HEVC Network Abstraction Layer) + zho: 隐藏NAL unit信息 (AVC/HEVC 網路抽象層) Horizontal Flip: - deu: Horizontal Flip + deu: Horizontal spiegeln eng: Horizontal Flip fra: Renvoi horizontal ita: Capovolgimento orizzontale spa: Volteo horizontal zho: 水平翻转 +Init Q: + deu: Init Q + eng: Init Q + fra: Init Q + ita: Init Q + spa: Init Q + zho: 启动Q Initialize Encoders: deu: Kodierer initialisieren eng: Initialize Encoders @@ -1105,14 +1273,14 @@ Intra-refresh: spa: Intra-refresco zho: 帧内刷新 Invalid Crop: - deu: Ungültiger Zuschnitt + deu: Ungültiges Cropping eng: Invalid Crop fra: Récolte non valable ita: Raccolto non valido spa: Cultivo inválido zho: 无效裁剪 It is recommended that AQ-mode be enabled along with this feature: - deu: Es wird empfohlen, dass der AQ-Modus zusammen mit dieser Funktion aktiviert wird + deu: Es wird empfohlen, dass zusammen mit dieser Funktion auch der AQ-Mode aktiviert wird eng: It is recommended that AQ-mode be enabled along with this feature fra: Il est recommandé d'activer le mode AQ en même temps que cette fonction ita: Si raccomanda di abilitare la modalità AQ insieme a questa funzione @@ -1154,7 +1322,7 @@ LICENSES: spa: LICENCIAS zho: 许可 Landscape Cover: - deu: Abdeckung im Querformat + deu: Querformatiges Cover eng: Landscape Cover fra: Couverture du paysage ita: Copertura del paesaggio @@ -1174,20 +1342,34 @@ Left: ita: Sinistra spa: Izquierda zho: 左侧 +Level: + deu: Pegel + eng: Level + fra: Niveau + ita: Livello + spa: Nivel + zho: 级别 Log2 of number of tile columns to encode faster (lesser quality): - deu: Log2 der Anzahl der Kachelspalten, um schneller zu kodieren (geringere Qualität) + deu: Log2 der Anzahl der Gitterspalten, um schneller zu kodieren (geringere Qualität) eng: Log2 of number of tile columns to encode faster (lesser quality) fra: Log2 du nombre de colonnes de tuiles pour un encodage plus rapide (qualité moindre) ita: Log2 del numero di colonne di tegole da codificare più velocemente (qualità inferiore) spa: Log2 del número de columnas de azulejos para codificar más rápido (menor calidad) zho: Log2 of number of tile columns to encode faster (lesser quality) Log2 of number of tile rows to encode faster (lesser quality): - deu: Log2 der Anzahl der Kachelzeilen, um schneller zu kodieren (geringere Qualität) + deu: Log2 der Anzahl der Gitterzeilen, um schneller zu kodieren (geringere Qualität) eng: Log2 of number of tile rows to encode faster (lesser quality) fra: Log2 du nombre de rangées de tuiles pour un encodage plus rapide (qualité moindre) ita: Log2 del numero di file di tegole da codificare più velocemente (qualità inferiore) spa: Log2 del número de filas de azulejos para codificar más rápido (menor calidad) zho: Log2 of number of tile rows to encode faster (lesser quality) +Lookahead: + deu: Lookahead + eng: Lookahead + fra: Lookahead + ita: Lookahead + spa: Lookahead + zho: 瞻前顾后 Lossless: deu: Verlustfrei eng: Lossless @@ -1209,20 +1391,76 @@ Max Muxing Queue Size: ita: Dimensione massima della coda di Muxing spa: Tamaño máximo de la cola Muxing zho: 最大混流队列大小 +Max Q: + deu: Max Q + eng: Max Q + fra: Max Q + ita: Q massimo + spa: Max Q + zho: 最大Q值 Maximum B frames: - deu: Maximale B-Frames + deu: Max. Anzahl B-Frames eng: Maximum B frames fra: Cadres B maximum ita: Telaio massimo B spa: Máximo de cuadros B zho: 最大B帧数量 'Maximum number of consecutive b-frames. ': - deu: 'Maximale Anzahl von aufeinanderfolgenden B-Frames. ' + deu: 'Maximale Anzahl aufeinanderfolgender B-Frames. ' eng: 'Maximum number of consecutive b-frames. ' fra: "Nombre maximum d'images B consécutives. " ita: 'Numero massimo di b-frame consecutivi. ' spa: 'Número máximo de fotogramas B consecutivos. ' zho: 连续b帧的最大数量。 +Maxrate: + deu: Maxrate + eng: Maxrate + fra: Maxrate + ita: Maxrate + spa: Maxrate + zho: Maxrate +Metrics: + deu: Metriken + eng: Metrics + fra: Métriques + ita: Metriche + spa: Métricas + zho: 衡量标准 +Min Q: + deu: Min Q + eng: Min Q + fra: Min Q + ita: Min Q + spa: Q mínimo + zho: 最小Q +Motion vector accuracy: + deu: Genauigkeit des Bewegungsvektors + eng: Motion vector accuracy + fra: Précision du vecteur de mouvement + ita: Precisione del vettore di movimento + spa: Precisión del vector de movimiento + zho: 运动矢量精度 +Multipass: + deu: Multi-pass + eng: Multipass + fra: Multipass + ita: Multipass + spa: Multipass + zho: 多通道 +NVEncC Encoder support is still experimental!: + deu: NVEncC-Encoder-Unterstützung ist immer noch experimentell! + eng: NVEncC Encoder support is still experimental! + fra: Le support des encodeurs NVEncC est encore expérimental ! + ita: Il supporto NVEncC Encoder è ancora sperimentale! + spa: La compatibilidad con el codificador NVEncC es todavía experimental. + zho: NVEncC编码器支持仍然是实验性的! +NVEncC Options: + deu: NVEncC-Optionen + eng: NVEncC Options + fra: Options NVEncC + ita: Opzioni NVEncC + spa: Opciones de NVEncC + zho: NVEncC选项 New Profile: deu: Neues Profil eng: New Profile @@ -1252,7 +1490,7 @@ No Downmix: spa: No hay Downmix zho: 无缩混 No Flip: - deu: Kein Flip + deu: Keine Spiegelung eng: No Flip fra: Pas de retournement ita: Senza capovolgere @@ -1286,15 +1524,15 @@ No command found for: ita: Nessun comando trovato per spa: No se encontró ningún comando para zho: 没有找到命令 -No crop, scale, rotation,flip nor any other filters will be applied.: +No crop, scale, rotation, flip nor any other filters will be applied.: deu: Es werden weder Zuschneiden, Skalieren, Drehen, Spiegeln noch andere Filter angewendet. - eng: No crop, scale, rotation,flip nor any other filters will be applied. + eng: No crop, scale, rotation, flip nor any other filters will be applied. fra: Aucun filtre ne sera appliqué sur les cultures, les écailles, la rotation, le retournement ou autre. ita: Non verranno applicati filtri per il raccolto, la scala, la rotazione, il capovolgimento o altri filtri. spa: No se aplicará ningún filtro de cultivo, de escala, de rotación, de volteo ni ningún otro. zho: 不会应用裁剪、缩放、旋转、翻转或任何其他过滤器。 No encoding is currently in process, starting encode: - deu: Es wird gerade keine Kodierung durchgeführt, Kodierung wird gestartet + deu: Es wird momentan keine Kodierung durchgeführt, Kodierung wird gestartet eng: No encoding is currently in process, starting encode fra: Aucun codage n'est actuellement en cours, le codage de départ ita: Nessuna codifica è attualmente in corso, iniziando a codificare @@ -1336,7 +1574,7 @@ Not a video file: spa: No es un archivo de video zho: 不是视频文件 Only put the HDR10+ dynamic metadata in the IDR and frames where the values have changed.: - deu: Setzen Sie die dynamischen HDR10+-Metadaten nur in die IDR und Frames, bei denen sich die Werte geändert haben. + deu: Setzen der dynamischen HDR10+-Metadaten nur in die IDR und Frames, bei denen sich die Werte geändert haben. eng: Only put the HDR10+ dynamic metadata in the IDR and frames where the values have changed. fra: Ne placez les métadonnées dynamiques HDR10+ que dans l'IDR et les cadres dont les valeurs ont changé. ita: Mettete i metadati dinamici HDR10+ solo nell'IDR e nei frame in cui i valori sono cambiati. @@ -1364,14 +1602,14 @@ Open Directory: spa: Directorio Abierto zho: 打开目录 Open Log Directory: - deu: Log-Verzeichnis öffnen + deu: Protokoll-Verzeichnis öffnen eng: Open Log Directory fra: Répertoire des journaux ouverts ita: Aprire l'elenco dei log spa: Directorio abierto de registros zho: 打开日志目录 Output: - deu: Ausgang + deu: Ausgabe eng: Output fra: Sortie ita: Uscita @@ -1385,35 +1623,42 @@ Output FPS: spa: Salida FPS zho: 输出FPS Over-allocation of frame threads will not improve performance,: - deu: Eine Überbelegung von Frame-Threads wird die Leistung nicht verbessern, + deu: Die Auswahl von mehr als der verfügbaren Frame-Threads verbessert nicht die Leistung, eng: Over-allocation of frame threads will not improve performance, fra: Une allocation excessive des fils de trame n'améliorera pas les performances, ita: La sovra-assegnazione delle filettature del telaio non migliorerà le prestazioni, spa: La sobreasignación de hilos del marco no mejorará el rendimiento, zho: 过多分配帧线程无法提高性能, Overlay this subtitle track onto the video during conversion.: - deu: Überlagern Sie diese Untertitelspur während der Konvertierung mit dem Video. + deu: Diese Untertitelspur bei der Konvertierung auf das Video überlagern. eng: Overlay this subtitle track onto the video during conversion. fra: Superposez cette piste de sous-titres sur la vidéo pendant la conversion. ita: Sovrapponete questa traccia di sottotitoli al video durante la conversione. spa: Superponga esta pista de subtítulos en el vídeo durante la conversión. zho: 在转换时将此字幕轨叠加到视频上。 Override Source FPS: - deu: Überschreiben Quell-FPS + deu: Quell-FPS überschreiben eng: Override Source FPS fra: Annuler la source FPS ita: Annullare la sorgente FPS spa: Anular el FPS de la fuente zho: 覆盖来源 FPS +Override the preset rate-control: + deu: voreingestelle Rate Control überschreiben + eng: Override the preset rate-control + fra: Annuler le contrôle des taux préétablis + ita: Sovrascrivere il controllo del tasso preimpostato + spa: Anula el control de velocidad preestablecido + zho: 覆盖预设的速率控制 PIR can replace keyframes by inserting a column of intra blocks in non-keyframes,: - deu: PIR kann Keyframes durch Einfügen einer Spalte von Intrablöcken in Nicht-Keyframes ersetzen, + deu: PIR kann durch Einfügen einer Spalte von Intrablöcken in Nicht-Keyframes Keyframes ersetzen, eng: PIR can replace keyframes by inserting a column of intra blocks in non-keyframes, fra: Le PIR peut remplacer les images clés en insérant une colonne de blocs intra dans les images non clés, ita: Il PIR può sostituire i keyframe inserendo una colonna di blocchi interni nei non-keyframe, spa: PIR puede reemplazar los fotogramas clave insertando una columna de intrabloques en los fotogramas no clave, zho: PIR可以在非关键帧中插入一列intra blocks,从而替代关键帧。 Parse Video details: - deu: Video-Details parsen + deu: Video-Details analysieren eng: Parse Video details fra: Détails de la vidéo de Parse ita: Dettagli Parse Video @@ -1427,7 +1672,7 @@ Pause / Resume the current command: spa: Pausa / Reanudar el comando actual zho: 暂停/恢复当前命令 Pause Encode: - deu: Pause Encode + deu: Kodierung pausieren eng: Pause Encode fra: Pause Encode ita: Pausa Codificare @@ -1447,29 +1692,36 @@ Pixel Format (requires at least 10-bit for HDR): ita: Formato pixel (richiede almeno 10 bit per HDR) spa: Formato de píxeles (requiere al menos 10 bits para el HDR) zho: 像素格式(HDR要求至少10-bit) +Please make sure seek method is set to exact: + deu: Bitte sicherstellen, dass die Suchmethode auf exakt eingestellt ist + eng: Please make sure seek method is set to exact + fra: Veuillez vous assurer que la méthode de recherche est réglée sur l'exacte + ita: Si prega di assicurarsi che il metodo di ricerca sia impostato su + spa: Por favor, asegúrese de que el método de búsqueda se establece con exactitud + zho: 请确保检索方式已设置为exact Please provide a profile name: - deu: Bitte geben Sie einen Profilnamen an + deu: Bitte einen Profilnamen angeben eng: Please provide a profile name fra: Veuillez fournir un nom de profil ita: Si prega di fornire un nome di profilo spa: Por favor, proporcione un nombre de perfil zho: 请提供方案名称 Please report this issue: - deu: Bitte melden Sie dieses Problem + deu: Bitte dieses Problem melden eng: Please report this issue fra: Veuillez signaler ce problème ita: Si prega di segnalare questo problema spa: Por favor, informe de este asunto zho: 请报告这个问题 Please restart FastFlix to apply settings: - deu: Bitte starten Sie FastFlix neu, um die Einstellungen zu übernehmen + deu: Bitte FastFlix neustarten, um die Einstellungen zu übernehmen eng: Please restart FastFlix to apply settings fra: Por favor, reinicie FastFlix para aplicar la configuración ita: Riavviare FastFlix per applicare le impostazioni spa: Por favor, reinicie FastFlix para aplicar la configuración zho: 请重新启动FastFlix以应用设置 Poster Cover: - deu: Poster-Cover + deu: Plakat-Cover eng: Poster Cover fra: Couverture de l'affiche ita: Copertina per poster @@ -1489,6 +1741,13 @@ Preset: ita: Preset spa: Preset zho: 预设 +Profile Name: + deu: Profil-Name + eng: Profile Name + fra: Nom du profil + ita: Nome del profilo + spa: Nombre del perfil + zho: 方案名称 Profile_encoderopt: deu: Profil eng: Profile @@ -1510,13 +1769,6 @@ Profile_window: ita: Profilo spa: Perfil zho: 方案 -Profile Name: - deu: Profil-Name - eng: Profile Name - fra: Nom du profil - ita: Nome del profilo - spa: Nombre del perfil - zho: 方案名称 Profiles: deu: Profile eng: Profiles @@ -1531,6 +1783,13 @@ Python: ita: Python spa: Python zho: Python +Q-pel is highest precision: + deu: Q-pel ist die höchste Präzision + eng: Q-pel is highest precision + fra: Le Q-pel est de la plus haute précision + ita: Q-pel è la massima precisione + spa: Q-pel es la máxima precisión + zho: Q-pel是最高精度 Quality: deu: Qualität eng: Quality @@ -1539,28 +1798,28 @@ Quality: spa: Calidad zho: 质量 Quality and compression efficiency vs speed trade-off: - deu: Kompromiss zwischen Qualität und Kompressionseffizienz und Geschwindigkeit + deu: Kompromiss zwischen Qualität/Komprimierungs-Effizienz und Geschwindigkeit eng: Quality and compression efficiency vs speed trade-off fra: Le compromis entre la qualité et l'efficacité de la compression et la vitesse ita: Qualità ed efficienza di compressione rispetto al compromesso velocità spa: La calidad y la eficiencia de la compresión frente a la compensación de la velocidad zho: 在质量及压缩效率与速度之间进行权衡 Quality/Speed ratio modifier: - deu: Qualität/Geschwindigkeitsverhältnis-Modifikator + deu: Modifikator Qualitäts/Geschwindigkeits-Verhältnis eng: Quality/Speed ratio modifier fra: Modificateur du rapport qualité/vitesse ita: Modificatore rapporto qualità/velocità spa: Modificador de la relación calidad/velocidad zho: 调整质量与速度之比 Quality/Speed ratio modifier (defaults to -1): - deu: 'Qualitäts-/Geschwindigkeitsverhältnis-Modifikator (Standardwert: -1)' + deu: 'Modifikator Qualitäts/Geschwindigkeits-Verhältnis (Standardwert: -1)' eng: Quality/Speed ratio modifier (defaults to -1) fra: Modificateur du rapport qualité/vitesse (par défaut à -1) ita: Modificatore del rapporto qualità/velocità (valori predefiniti a -1) spa: Modificador de la relación calidad/velocidad (por defecto a -1) zho: 调整质量与速度之比(默认值为-1) Quality/Speed ratio modifier (defaults to 4): - deu: 'Modifikator für das Verhältnis Qualität/Geschwindigkeit (Standardwert: 4)' + deu: 'Modifikator Qualitäts/Geschwindigkeits-Verhältnis (Standardwert: 4)' eng: Quality/Speed ratio modifier (defaults to 4) fra: Modificateur du rapport qualité/vitesse (par défaut à 4) ita: Modificatore del rapporto qualità/velocità (valori predefiniti a 4) @@ -1580,15 +1839,29 @@ Queue has been paused: ita: La coda è stata messa in pausa spa: La cola se ha detenido zho: 队列已暂停 +RC Lookahead: + deu: RC Lookahead + eng: RC Lookahead + fra: RC Lookahead + ita: RC Lookahead + spa: RC Lookahead + zho: RC Lookahead Raise or lower per-block quantization based on complexity analysis of the source image.: - deu: Erhöhen oder verringern Sie die Quantisierung pro Block basierend auf der Komplexitätsanalyse des Quellbilds. + deu: Erhöhen oder verringern der Quantisierung pro Block basierend auf der Komplexitätsanalyse des Quellbildes. eng: Raise or lower per-block quantization based on complexity analysis of the source image. fra: Augmenter ou diminuer la quantification par bloc en fonction de l'analyse de la complexité de l'image source. ita: Aumentare o diminuire la quantizzazione per blocco in base all'analisi della complessità dell'immagine sorgente. spa: Aumentar o disminuir la cuantificación por bloque basada en el análisis de la complejidad de la imagen de origen. zho: 根据对源图像的复杂度分析,提升或降低各个块的量化。 +Rate Control: + deu: Ratensteuerung + eng: Rate Control + fra: Contrôle des tarifs + ita: Controllo della velocità + spa: Control de velocidad + zho: 速率控制 Raw Commands: - deu: Raw-Befehle + deu: Kommandozeilenbefehle eng: Raw Commands fra: Commandes brutes ita: Comandi grezzi @@ -1602,12 +1875,19 @@ Ready to encode: spa: Listo para codificar zho: 准备编码 Reconstructed output pictures are bit-exact to the input pictures.: - deu: Rekonstruierte Ausgangsbilder sind bit-genau zu den Eingangsbildern. + deu: Rekonstruierte Ausgabebilder entsprechen bit-genau den Eingangsbildern. eng: Reconstructed output pictures are bit-exact to the input pictures. fra: Les images de sortie reconstruites sont exactes au niveau des bits par rapport aux images d'entrée. ita: Le immagini di uscita ricostruite sono bit-esatte alle immagini di ingresso. spa: Las imágenes de salida reconstruidas son un poco exactas a las imágenes de entrada. zho: 重建后的输出图像与输入图像是逐位一致(bit-exact)的。 +Ref Frames: + deu: Ref-Frames + eng: Ref Frames + fra: Ref Frames + ita: Fotogrammi di rif. + spa: Fotogramas de referencia + zho: 参考框架 Remove HDR: deu: HDR entfernen eng: Remove HDR @@ -1630,21 +1910,21 @@ Remove completed tasks: spa: Eliminar las tareas completadas zho: 删除已完成的任务 Removing after done command: - deu: Entfernen nach erfolgtem Befehl + deu: Entfernen nach erledigtem Befehl eng: Removing after done command fra: Suppression après commande ita: Rimozione dopo aver eseguito il comando spa: Retirar después de la orden hecha zho: 在完成命令后删除 Repeat Headers: - deu: Kopfzeilen wiederholen + deu: Header wiederholen eng: Repeat Headers fra: Répéter les en-têtes ita: Ripetere le intestazioni spa: Repetición de los encabezados zho: 重复标头 Report Issue: - deu: Ausgabe melden + deu: Problem melden eng: Report Issue fra: Numéro du rapport ita: Segnala il problema @@ -1700,7 +1980,7 @@ Row multithreading: spa: 'Corriendo tras el mando hecho:' zho: 在完成命令后运行。 Running command: - deu: Laufendes Kommando + deu: Laufender Befehl eng: Running command fra: Commande en cours d'exécution ita: Comando in esecuzione @@ -1714,7 +1994,7 @@ SVT-AV1 Encoding Guide: spa: Guía de codificación del SVT-AV1 zho: SVT-AV1编码指南 Same as Source: - deu: Gleich wie die Quelle + deu: identisch zur Quelle eng: Same as Source fra: Même que la source ita: Uguale alla fonte @@ -1734,6 +2014,13 @@ Save Commands: ita: Comandi di salvataggio spa: Salvar los comandos zho: 保存命令 +Save File: + deu: Datei speichern + eng: Save File + fra: Enregistrer le fichier + ita: Salva file + spa: Guardar archivo + zho: 保存文件 Save commands to file: deu: Befehle in Datei speichern eng: Save commands to file @@ -1749,28 +2036,56 @@ Scale: spa: Escala zho: 缩放 Scrub away all incoming metadata, like video titles, unique markings and so on.: - deu: Entfernen Sie alle eingehenden Metadaten, wie Videotitel, eindeutige Markierungen und so weiter. + deu: Entfernen aller eingehenden Metadaten, wie Videotitel, eindeutige Markierungen usw. eng: Scrub away all incoming metadata, like video titles, unique markings and so on. fra: Supprimez toutes les métadonnées entrantes, comme les titres des vidéos, les marquages uniques, etc. ita: Cancella tutti i metadati in arrivo, come i titoli dei video, le marcature uniche e così via. spa: Borra todos los metadatos entrantes, como títulos de video, marcas únicas y demás. zho: 擦除输入文件中所有的元数据,如视频标题、唯一标记等。 +Selects which NVENC capable GPU to use. First GPU is 0, second is 1, and so on: + deu: Wählt aus, welche NVENC-fähige GPU genutzt werden soll. Die erste GPU ist 0, die zweite 1, usw + eng: Selects which NVENC capable GPU to use. First GPU is 0, second is 1, and so on + fra: Sélectionne le GPU compatible NVENC à utiliser. Le premier GPU est 0, le second est 1, et ainsi de suite + ita: Seleziona quale GPU con capacità NVENC utilizzare. La prima GPU è 0, la seconda è 1, e così via + spa: Selecciona qué GPU con capacidad NVENC se va a utilizar. La primera GPU es 0, la segunda es 1, y así sucesivamente + zho: 选择使用哪个NVENC功能的GPU。第一个GPU为0,第二个为1,以此类推。 +Set speed to 4 for first pass: + deu: Setze Geschwindigkeit des ersten Durchlaufs auf 4 + eng: Set speed to 4 for first pass + fra: Régler la vitesse à 4 pour le premier passage + ita: Imposta la velocità a 4 per il primo passaggio + spa: Establece la velocidad en 4 para la primera pasada + zho: 第一遍速度设置为4 Set the "title" tag, sometimes shown as "Movie Name": - deu: Setzen Sie den "Titel"-Tag, der manchmal als "Filmname" angezeigt wird + deu: Setzen des "Titel"-Tags, der manchmal als "Filmname" angezeigt wird eng: Set the "title" tag, sometimes shown as "Movie Name" fra: Poner la etiqueta "título", a veces se muestra como "Nombre de la película" ita: Impostare il tag "title", a volte mostrato come "Movie Name" (nome del film) spa: Poner la etiqueta "título", a veces se muestra como "Nombre de la película" zho: 设置“标题”(title,有时显示为“电影名称”(Movie Name))标签 +Set the encoding level restriction: + deu: Kodierungslevel-Einschränkung festlegen + eng: Set the encoding level restriction + fra: Définir la restriction du niveau d'encodage + ita: Imposta la restrizione del livello di codifica + spa: Establezca la restricción del nivel de codificación + zho: 设置编码级别限制 +Set the encoding tier: + deu: Kodierungsstufe festlegen + eng: Set the encoding tier + fra: Définir le niveau d'encodage + ita: Imposta il livello di codifica + spa: Establecer el nivel de codificación + zho: 设置编码层 Set the level of effort in determining B frame placement.: - deu: Legen Sie den Grad des Aufwands bei der Bestimmung der B-Frame-Platzierung fest. + deu: Grad des Aufwands bei der Bestimmung der B-Frame-Platzierung festlegen. eng: Set the level of effort in determining B frame placement. fra: Définir le niveau d'effort pour déterminer le placement des images B. ita: Impostare il livello di sforzo nel determinare il posizionamento del fotogramma B. spa: Establezca el nivel de esfuerzo para determinar la ubicación del cuadro B. zho: 对决定B帧位置时的工作量水平进行调整。 'Setting after done command to:': - deu: 'Einstellung nach dem Befehl done auf:' + deu: 'Einstellung nach erledigtem Befehl setzen auf:' eng: 'Setting after done command to:' fra: 'Régler après avoir fait la commande à :' ita: 'Impostare dopo il comando su:' @@ -1784,28 +2099,28 @@ Settings: spa: Ajustes zho: 设置 Single Pass (Bitrate): - deu: Einzelner Durchlauf (Bitrate) + deu: Ein einzelner Durchlauf (Bitrate) eng: Single Pass (Bitrate) fra: Passage unique (Bitrate) ita: Passaggio singolo (Bitrato) spa: Pase único (Bitrate) zho: 一遍编码(比特率) Single Pass (CRF): - deu: Einzelner Durchlauf (CRF) + deu: Ein einzelner Durchlauf (CRF) eng: Single Pass (CRF) fra: Laissez-passer unique (CRF) ita: Passaggio singolo (CRF) spa: Pase único (CRF) zho: 一遍编码(CRF) Size Estimate: - deu: Größe Schätzung + deu: geschätzte Größe eng: Size Estimate fra: Estimation de la taille ita: Stima delle dimensioni spa: Estimación del tamaño zho: 预计文件大小 Slow is highest personal recommenced, as past that is much smaller gains: - deu: Langsam ist die höchste persönliche Empfehlung, da danach viel kleinere Gewinne + deu: Slow ist die maximale persönliche Empfehlung, da die Zugewinne bei noch langsamerem deutlich kleiner sind. eng: Slow is highest personal recommenced, as past that is much smaller gains fra: La lenteur est la plus haute personnelle recommencée, comme passé c'est des gains beaucoup plus petits ita: Lento è più alto personale è ricominciato, come passato che è molto più piccolo guadagni @@ -1833,7 +2148,7 @@ Source Details: spa: Detalles de la fuente zho: 来源详情 Source Frame Rate: - deu: Quell-Bildrate + deu: Bildrate der Quelle eng: Source Frame Rate fra: Taux de trame source ita: Frame Rate della fonte @@ -1853,6 +2168,13 @@ Source width: ita: Larghezza della fonte spa: Ancho de la fuente zho: 源文件宽度 +Spatial AQ: + deu: Spatila AQ + eng: Spatial AQ + fra: QA spatiale + ita: AQ spaziale + spa: AQ espacial + zho: 空间空气质量 Speed: deu: Geschwindigkeit eng: Speed @@ -1861,7 +2183,7 @@ Speed: spa: Velocidad zho: 速度 Start: - deu: Beginn + deu: Start eng: Start fra: Démarrer ita: Iniziare @@ -1889,7 +2211,7 @@ Subtitle Tracks: spa: Pistas de subtítulos zho: 字幕轨 Subtitle select language: - deu: Untertitel Sprache wählen + deu: Untertitel-Sprache wählen eng: Subtitle select language fra: Sous-titre choisir la langue ita: Sottotitolo selezionare la lingua @@ -1909,6 +2231,13 @@ Success: ita: Successo spa: Éxito zho: 成功 +Support FastFlix: + deu: Unterstützt FastFlix + eng: Support FastFlix + fra: Soutenez FastFlix + ita: Supporto FastFlix + spa: Soporta FastFlix + zho: 支持FastFlix Supported Image Files: deu: Unterstützte Bilddateien eng: Supported Image Files @@ -1917,7 +2246,7 @@ Supported Image Files: spa: Archivos de imagen soportados zho: 支持的图像文件 The GUI might have died, but I'm going to keep converting!: - deu: Die GUI mag gestorben sein, aber ich werde weiter konvertieren! + deu: Die GUI ist eventuell abgestürzt, aber ich werde weiter konvertieren! eng: The GUI might have died, but I'm going to keep converting! fra: L'interface graphique est peut-être morte, mais je vais continuer à me convertir ! ita: L'interfaccia grafica sarà anche morta, ma continuerò a convertirmi! @@ -1931,14 +2260,14 @@ The more complex the block, the more quantization is used.: spa: Cuanto más complejo es el bloque, más cuantificación se utiliza. zho: 越复杂的块,使用的量化也越高。 The purpose is to prevent blocking or banding artifacts in regions with few/zero AC coefficients.: - deu: Der Zweck ist, Blocking- oder Banding-Artefakte in Regionen mit wenigen/null AC-Koeffizienten zu verhindern. + deu: Der Zweck ist, Blocking- oder Banding-Artefakte in Regionen mit wenigen/keinen AC-Koeffizienten zu verhindern. eng: The purpose is to prevent blocking or banding artifacts in regions with few/zero AC coefficients. fra: L'objectif est d'éviter de bloquer ou de banderoler des artefacts dans des régions où les coefficients AC sont faibles ou nuls. ita: Lo scopo è quello di prevenire il blocco o il banding di artefatti in regioni con pochi/zeri coefficienti AC. spa: El propósito es prevenir el bloqueo o los artefactos de bandas en regiones con coeficientes de CA bajos/cero. zho: 目的是为了防止在AC coefficients较少或为零的区域出现blocking或banding artifacts。 There is a conversion in process!: - deu: Es ist eine Konvertierung im Gange! + deu: Es ist eine Konvertierung am laufen! eng: There is a conversion in process! fra: Il y a une conversion en cours ! ita: C'è una conversione in corso! @@ -1966,14 +2295,14 @@ This flag performs bi-linear interpolation of the corner reference samples for a spa: Este banderín realiza una interpolación bi-línea de las muestras de referencia de las esquinas para un fuerte efecto de suavizado. zho: 这个选项对corner reference samples进行双线性插值,以获得强平滑效果。 This improves encoding speed significantly on systems that are otherwise underutilised when encoding VP9.: - deu: Dies verbessert die Kodiergeschwindigkeit auf Systemen, die sonst bei der Kodierung von VP9 nicht ausgelastet sind, erheblich. + deu: Dies verbessert erheblich die Kodiergeschwindigkeit auf Systemen, die anonsten bei der Kodierung von VP9 unausgelastet sind. eng: This improves encoding speed significantly on systems that are otherwise underutilised when encoding VP9. fra: Cela améliore considérablement la vitesse de codage sur des systèmes qui sont autrement sous-utilisés lors du codage VP9. ita: Questo migliora la velocità di codifica in modo significativo su sistemi che sono altrimenti sottoutilizzati durante la codifica VP9. spa: Esto mejora significativamente la velocidad de codificación en los sistemas que de otra manera son subutilizados al codificar el VP9. zho: 在编码VP9时资源利用不足的系统上,此选项能够显著提升编码速度。 This is intended for use when you do not have a container to keep the stream headers for you: - deu: Dies ist für die Verwendung vorgesehen, wenn Sie keinen Container haben, der die Stream-Header für Sie aufbewahrt + deu: Dies ist für die Verwendung vorgesehen, wenn kein Container vorhanden ist, der die Stream-Header aufbewahrt eng: This is intended for use when you do not have a container to keep the stream headers for you fra: Il est destiné à être utilisé lorsque vous ne disposez pas d'un conteneur pour conserver les en-têtes de flux ita: Questo è destinato all'uso quando non si dispone di un contenitore per mantenere le intestazioni dello stream per voi @@ -1994,7 +2323,7 @@ This is used for ultra-high bitrates with zero loss of quality.: spa: 'Esta opción no se reinicia a menos que se necesite conformar ' zho: 除非您需要为了刻录到物理光盘而遵守蓝光标准, This will just copy the video track as is.: - deu: Damit wird der Videotrack einfach so kopiert, wie er ist. + deu: Hiermit wird die Videospur einfach so kopiert, wie sie ist. eng: This will just copy the video track as is. fra: Il suffit de copier la piste vidéo telle quelle. ita: Questo si limiterà a copiare la traccia video così com'è. @@ -2029,14 +2358,14 @@ Tiles: spa: Baldosas zho: Tiles Time Left: - deu: Linke Zeit + deu: Verbleibende Dauer eng: Time Left fra: Temps restant ita: Tempo rimanente spa: Tiempo restante zho: 剩余时间 Time Elapsed: - deu: Verstrichene Zeit + deu: Verstrichene Dauer eng: Time Elapsed fra: Temps écoulé ita: Tempo trascorso @@ -2064,14 +2393,14 @@ Total video height must be greater than 0: spa: La altura total del video debe ser mayor que 0 zho: 视频总高度必须大于0 Tune: - deu: Abstimmen + deu: Feineinstellung eng: Tune fra: Tune ita: Tune spa: Sintoniza zho: 调校 Tune the settings for a particular type of source or situation: - deu: Abstimmen der Einstellungen für einen bestimmten Quellentyp oder eine bestimmte Situation + deu: Feineinstellungen für einen bestimmten Quelltyp oder eine bestimmte Situation eng: Tune the settings for a particular type of source or situation fra: Régler les paramètres pour un type de source ou une situation particulière ita: Sintonizzare le impostazioni per un particolare tipo di sorgente o situazione @@ -2092,14 +2421,21 @@ Usage: spa: Uso zho: 用法 Use --bframes 0 to force all P/I low-latency encodes.: - deu: Verwenden Sie --bframes 0, um alle P/I-Codierungen mit niedriger Latenz zu erzwingen. + deu: --bframes 0 verwenden, um alle P/I-Codierungen mit niedriger Latenz zu erzwingen. eng: Use --bframes 0 to force all P/I low-latency encodes. fra: Utilisez --bframes 0 pour forcer tous les codes P/I à faible latence. ita: Utilizzare --bframes 0 per forzare tutte le codifiche P/I a bassa latenza. spa: Use --bframes 0 para forzar todos los códigos de baja latencia P/I. zho: 使用--bframes 0强制进行全P/I帧的低延迟编码。 -Use Sane Audio Selection (updatable in config file): - deu: Sane Audio Selection verwenden (aktualisierbar in der Konfigurationsdatei) +Use B frames as references: + deu: B-Frames als Referenz nutzen + eng: Use B frames as references + fra: Utiliser les cadres B comme références + ita: Usa i fotogrammi B come riferimenti + spa: Utilizar los fotogramas B como referencia + zho: 用B帧作为参考 +Use Sane Audio Selection (customizable in config file): + deu: Nur sinnvolle Audioformate vorschlagen (anpassbar in der Konfigurationsdatei) eng: Use Sane Audio Selection (updatable in config file) fra: Utiliser la sélection audio Sane (actualisable dans le fichier de configuration) ita: Utilizzare Sane Audio Selection (aggiornabile nel file di configurazione) @@ -2113,7 +2449,7 @@ Useful when there is a desire to signal 0 values for max-cll and max-fall.: spa: Es útil cuando se desea señalar los valores 0 para max-cll y max-fall. zho: 当需要将max-cll及max-fall置0值时有用。 Useful when you have the "Too many packets buffered for output stream" error: - deu: Nützlich, wenn Sie den Fehler "Too many packets buffered for output stream" haben + deu: Nützlich, wenn der Fehler "Too many packets buffered for output stream" auftritt eng: Useful when you have the "Too many packets buffered for output stream" error fra: Utile lorsque vous avez l'erreur "Too many packets buffered for output stream ita: Utile quando si ha l'errore "Troppi pacchetti bufferizzati per il flusso di uscita". @@ -2127,12 +2463,19 @@ Using 1 or 2 will increase encoding speed at the expense of having some impact o spa: El uso de 1 o 2 aumentará la velocidad de codificación a expensas de tener algún impacto en la calidad y la precisión del control de la tasa. zho: 使用1或2会提高编码速度,但代价是对质量和码率控制精度有一定影响。 Using a single frame thread gives a slight improvement in compression,: - deu: Die Verwendung eines einzelnen Frame-Threads führt zu einer leichten Verbesserung der Komprimierung, + deu: Die Verwendung eines einzigen Frame-Threads führt zu einer leichten Verbesserung der Komprimierung, eng: Using a single frame thread gives a slight improvement in compression, fra: L'utilisation d'un seul fil de trame donne une légère amélioration de la compression, ita: L'utilizzo di una filettatura a telaio singolo offre un leggero miglioramento della compressione, spa: Usar un solo hilo de cuadro da una ligera mejora en la compresión, zho: 使用单帧线程会使压缩率略有提高, +VBR Target: + deu: VBR Zielrate + eng: VBR Target + fra: Cible VBR + ita: Obiettivo VBR + spa: Objetivo VBR + zho: VBR目标 'Values: 0:none; 1:fast; 2:full(trellis) default': deu: 'Werte: 0:keine; 1:schnell; 2:voll(trellis) Standard' eng: 'Values: 0:none; 1:fast; 2:full(trellis) default' @@ -2155,14 +2498,14 @@ Various: spa: Varios zho: 多种许可 Vert + Hoz Flip: - deu: Vert + Hoz Flip + deu: Vert + Hoz spiegeln eng: Vert + Hoz Flip fra: Vert + Hoz Flip ita: Vert + Hoz Flip spa: Vert + Hoz Flip zho: 垂直+水平翻转 Vertical Flip: - deu: Vertikaler Flip + deu: Vertikal spiegeln eng: Vertical Flip fra: Volteo vertical ita: Capovolgimento verticale @@ -2246,14 +2589,14 @@ Width must be divisible by 2: spa: El ancho debe ser divisible por 2 zho: 宽度必须能被2整除 Width must be divisible by 2 - Source width: - deu: Breite muss durch 2 teilbar sein - Quellbreite + deu: Breite muss durch 2 teilbar sein - Breite der Quelle eng: Width must be divisible by 2 - Source width fra: La largeur doit être divisible par 2 - Largeur de la source ita: La larghezza deve essere divisibile per 2 - Larghezza della sorgente spa: El ancho debe ser divisible por 2 - Ancho de la fuente zho: 宽度必须能被2整除--源文件宽度 Will fix first subtitle track to not be default: - deu: Repariert die erste Untertitelspur so, dass sie nicht standardmäßig ist + deu: Hiermit wird die erste Untertitelspur nicht mehr als Standard gesetzt eng: Will fix first subtitle track to not be default fra: Fixera la première piste de sous-titres pour qu'elle ne soit pas par défaut ita: Correggerà la prima traccia dei sottotitoli per non essere predefinita @@ -2267,7 +2610,7 @@ With b-adapt 0, the GOP structure is fixed based on the values of --keyint and - spa: Con b-adaptado 0, la estructura del GOP se fija en base a los valores de --keyint y --bframes. zho: 当b-adapt为0时,图像组(Group Of Pictures, GOP)结构是根据--keyint和--bframes的值确定并固定的。 With b-adapt 1 a light lookahead is used to choose B frame placement.: - deu: Mit b-adapt 1 wird ein leichter Lookahead verwendet, um die B-Frame-Platzierung zu wählen. + deu: Mit b-adapt 1 wird ein wenig vorausgeschaut, um die B-Frame-Platzierung zu wählen. eng: With b-adapt 1 a light lookahead is used to choose B frame placement. fra: Avec l'adaptateur b 1, un léger regard est utilisé pour choisir le placement des images B. ita: Con b-adapt 1 si usa un leggero lookahead per scegliere il posizionamento del telaio B. @@ -2295,7 +2638,7 @@ Yes: spa: Sì zho: 是 You are using the latest version of FastFlix: - deu: Sie verwenden die neueste Version von FastFlix + deu: Die aktuellste Version von FastFlix wird verwendet eng: You are using the latest version of FastFlix fra: Vous utilisez la dernière version de FastFlix ita: State utilizzando l'ultima versione di FastFlix @@ -2316,14 +2659,14 @@ already exists: spa: ya existe zho: 已有 and the amount of work performed by the full trellis version of --b-adapt lookahead.: - deu: und der Arbeitsaufwand, der von der Full Trellis-Version von --b-adapt lookahead durchgeführt wird. + deu: und der Arbeitsaufwand, der bei --b-adapt 2 (full trellis) durchgeführt wird. eng: and the amount of work performed by the full trellis version of --b-adapt lookahead. fra: et la quantité de travail effectuée par la version complète en treillis de --b-adapt lookahead. ita: e la quantità di lavoro svolto dalla versione completa della versione a traliccio di --b-adattate lookahead. spa: y la cantidad de trabajo realizado por la versión completa de la espaldera de --b-adaptado lookahead. zho: lookahead在full(trellis)模式下执行的工作量有二次方的影响。 and you want keyframes to be random access points.: - deu: und Sie wollen, dass Keyframes zufällige Zugriffspunkte sind. + deu: und es ist gewünscht, dass die Keyframes zufällige Zugriffspunkte sind. eng: and you want keyframes to be random access points. fra: et vous voulez que les images clés soient des points d'accès aléatoires. ita: e si desidera che i fotogrammi chiave siano punti di accesso casuali. @@ -2344,7 +2687,7 @@ are mere suggestions!: spa: son meras sugerencias! zho: 的对应关系仅供参考 attachment tracks found: - deu: Anhangspuren gefunden + deu: Dateianhang-Spuren gefunden eng: attachment tracks found fra: pistes d'attache trouvées ita: tracce di attacco trovate @@ -2365,7 +2708,7 @@ b-adapt: spa: b-adapt zho: b-adapt 'b-adapt: Set the level of effort in determining B frame placement.': - deu: 'b-adapt: Legen Sie den Grad des Aufwands bei der Bestimmung der B-Frame-Platzierung fest.' + deu: 'b-adapt: Festlegen des Grades des Aufwands bei der Bestimmung der B-Frame-Platzierung.' eng: 'b-adapt: Set the level of effort in determining B frame placement.' fra: "b-adapt : Fixe le niveau d'effort pour déterminer le placement de l'image B." ita: 'b-adatta: Impostare il livello di sforzo nel determinare il posizionamento del telaio B.' @@ -2379,7 +2722,7 @@ bad micro value: spa: mal valor micro zho: bad micro value best is recommended if you have lots of time and want the best compression efficiency.: - deu: best wird empfohlen, wenn Sie viel Zeit haben und die beste Komprimierungseffizienz wünschen. + deu: best wird empfohlen, wenn viel Zeit zur Verfügung steht und die beste Komprimierungseffizienz gewünscht ist. eng: best is recommended if you have lots of time and want the best compression efficiency. fra: Le meilleur est recommandé si vous avez beaucoup de temps et si vous voulez obtenir la meilleure efficacité de compression. ita: Il migliore è consigliato se si ha molto tempo a disposizione e si desidera la migliore efficienza di compressione. @@ -2393,7 +2736,7 @@ bframes: spa: bframes zho: b帧 'bframes: Maximum number of consecutive b-frames. ': - deu: 'bframes: Maximale Anzahl von aufeinanderfolgenden b-Frames. ' + deu: 'bframes: Maximale Anzahl aufeinanderfolgender b-Frames. ' eng: 'bframes: Maximum number of consecutive b-frames. ' fra: 'bframes : Nombre maximum de b-frames consécutives. ' ita: 'bframes: Numero massimo di b-frame consecutivi. ' @@ -2407,7 +2750,7 @@ but it has severe performance implications.: spa: pero tiene severas implicaciones de rendimiento. zho: 但对性能有严重影响。 but over a period of multiple frames instead of a single keyframe.: - deu: aber über einen Zeitraum von mehreren Frames anstelle eines einzelnen Keyframes. + deu: aber über eine Dauer von mehreren Frames anstatt eines einzelnen Keyframes. eng: but over a period of multiple frames instead of a single keyframe. fra: mais sur une période de plusieurs images au lieu d'une seule image clé. ita: ma su un periodo di frame multipli invece di un singolo keyframe. @@ -2470,7 +2813,7 @@ data tracks found: spa: 'Hilos de marcos: Número de cuadros codificados simultáneamente.' zho: frame-threads:同时编码的帧数。 good is the default and recommended for most applications: - deu: good ist der Standard und wird für die meisten Anwendungen empfohlen + deu: good ist die Standardeinstellung und wird für die meisten Anwendungszwecke empfohlen eng: good is the default and recommended for most applications fra: good est la valeur par défaut et est recommandé pour la plupart des applications ita: buono è il valore predefinito e raccomandato per la maggior parte delle applicazioni @@ -2490,6 +2833,13 @@ good is the default and recommended for most applications: ita: 'hdr10: Forza la segnalazione dei parametri HDR10 nei pacchetti SEI.' spa: 'hdr10: Forzar la señalización de los parámetros HDR10 en los paquetes SEI.' zho: hdr10:强制在SEI包中发送HDR10参数。 +hq - High Quality, ll - Low Latency, ull - Ultra Low Latency: + deu: hq - Hohe Qualität, ll - niedrige Latenz, ull - extrem niedrige Latenz + eng: hq - High Quality, ll - Low Latency, ull - Ultra Low Latency + fra: hq - Haute qualité, ll - Latence faible, ull - Latence ultra faible + ita: hq - Alta qualità, ll - Bassa latenza, ull - Ultra bassa latenza + spa: hq - Alta calidad, ll - Baja latencia, ull - Ultra baja latencia + zho: hq - 高质量,ll - 低延迟,ull - 超低延迟。 installer: deu: Installationsprogramm eng: installer @@ -2512,7 +2862,7 @@ is a default profile and will not be removed: spa: es un perfil predeterminado y no se eliminará zho: 是默认方案,不会被删除。 is extremely source dependant: - deu: ist extrem quellenabhängig + deu: ist extrem abhängig von der Quelle eng: is extremely source dependant fra: est extrêmement dépendante de la source ita: è estremamente dipendente dalla fonte @@ -2526,7 +2876,7 @@ it will generally just increase memory use.: spa: generalmente sólo aumentará el uso de la memoria. zho: 一般只会增加内存占用。 'keyint: Enable Intra-Encoding by forcing keyframes every 1 second (Blu-ray spec)': - deu: 'keyint: Aktiviert Intra-Encoding durch Erzwingen von Keyframes alle 1 Sekunde (Blu-ray-Spec)' + deu: 'keyint: Aktiviert Intra-Encoding durch Erzwingen von Keyframes alle 1 Sekunde (Blu-ray-Spezifikation)' eng: 'keyint: Enable Intra-Encoding by forcing keyframes every 1 second (Blu-ray spec)' fra: "keyint : Activer l'intra-encodage en forçant les images clés toutes les 1 seconde (spécification Blu-ray)" ita: "keyint: Attivare l'Intra-Encoding forzando i keyframe ogni 1 secondo (Blu-ray spec)" @@ -2540,7 +2890,7 @@ lossless: spa: Lossless zho: 无损 'max_muxing_queue_size: Raise to fix "Too many packets buffered for output stream" error': - deu: 'max_muxing_queue_size: Erhöhen, um den Fehler "Zu viele Pakete für den Ausgabestrom gepuffert" zu beheben' + deu: 'max_muxing_queue_size: Erhöhen, um den Fehler "Too many packets buffered for output stream" zu beheben' eng: 'max_muxing_queue_size: Raise to fix "Too many packets buffered for output stream" error' fra: "max_muxing_queue_size : Augmenter pour corriger l'erreur \"Too many packets buffered for output stream" ita: "max_muxing_queue_size: Alzare per correggere l'errore \"Troppi pacchetti bufferizzati per il flusso di uscita" @@ -2561,14 +2911,14 @@ of: spa: de zho: 的 out file is already in queue: - deu: out-Datei ist bereits in der Warteschlange + deu: Ausgabedatei ist bereits in der Warteschlange eng: out file is already in queue fra: notre dossier est déjà dans la file d'attente ita: il file in uscita è già in coda spa: nuestro archivo ya está en la cola zho: out file is already in queue portable: - deu: portable + deu: portabel eng: portable fra: portable ita: portatile @@ -2582,7 +2932,7 @@ preset: spa: preestablecido zho: 预设 'preset: The slower the preset, the better the compression and quality': - deu: 'voreingestellt: Je langsamer die Voreinstellung, desto besser die Komprimierung und Qualität' + deu: 'Voreinstellung: Je langsamer die Voreinstellung, desto besser die Komprimierung und Qualität' eng: 'preset: The slower the preset, the better the compression and quality' fra: 'préréglé : Plus le préréglage est lent, meilleure est la compression et la qualité' ita: 'preimpostata: Più lento è il preset, migliore è la compressione e la qualità' @@ -2603,7 +2953,7 @@ profile: spa: perfil zho: profile 'profile: Enforce an encode profile': - deu: 'Profil: Erzwingt ein Kodierprofil' + deu: 'Profil: Erzwingt ein Kodierungsprofil' eng: 'profile: Enforce an encode profile' fra: 'profil : Appliquer un profil de codage' ita: 'profilo: Applicare un profilo di codifica' @@ -2631,7 +2981,7 @@ rav1e github: spa: rav1e github zho: rav1e github 'repeat-headers: If enabled, x265 will emit VPS, SPS, and PPS headers with every keyframe.': - deu: 'Kopfzeilen wiederholeng: Wenn aktiviert, gibt x265 mit jedem Keyframe VPS-, SPS- und PPS-Header aus.' + deu: 'Kopfzeilen wiederholen: Wenn aktiviert, gibt x265 mit jedem Keyframe VPS-, SPS- und PPS-Header aus.' eng: 'repeat-headers: If enabled, x265 will emit VPS, SPS, and PPS headers with every keyframe.' fra: 'des en-têtes répétitifs : Si elle est activée, x265 émettra des en-têtes VPS, SPS et PPS avec chaque image clé.' ita: 'ripeti-intestazioni: Se abilitato, x265 emetterà testate VPS, SPS e PPS con ogni fotogramma chiave.' @@ -2659,14 +3009,14 @@ subtitle tracks found: spa: pistas de subtítulos encontradas zho: 找到字幕轨 that move across the video from one side to the other and thereby refresh the image: - deu: die sich über das Video von einer Seite zur anderen bewegen und dabei das Bild aktualisieren + deu: die sich von einer Seite zur anderen durch das Video bewegen und dabei das Bild aktualisieren eng: that move across the video from one side to the other and thereby refresh the image fra: qui passent d'un côté à l'autre de la vidéo et rafraîchissent ainsi l'image ita: che si muovono attraverso il video da un lato all'altro e quindi rinfrescano l'immagine spa: que se mueven a través del video de un lado a otro y así refrescan la imagen zho: 这些intra blocks的位置在若干帧的时间内从视频一侧移动到另一侧, the resolution-to-: - deu: die Reso + deu: die Auflösung zu eng: the resolution-to- fra: la reso ita: il risuonare @@ -2680,7 +3030,7 @@ to Blu-ray standards to burn to a physical disk: spa: a los estándares de Blu-ray para grabar en un disco físico zho: 否则不建议启用该选项。 'tune: Tune the settings for a particular type of source or situation': - deu: 'abstimmen: Die Einstellungen für eine bestimmte Art von Quelle oder Situation abstimmen' + deu: 'Feineinstellung: Die Einstellungen auf eine bestimmte Art von Quelle oder Situation abstimmen' eng: 'tune: Tune the settings for a particular type of source or situation' fra: 'afinar: Sintonizar los ajustes para un tipo de fuente o situación particular' ita: 'sintonizzarsi: Sintonizzare le impostazioni per un particolare tipo di sorgente o situazione' @@ -2700,80 +3050,87 @@ vsync: ita: vsync spa: vsync zho: vsync -File: - deu: Datei - eng: File - fra: Fichier - ita: Archivio - spa: Archivo - zho: 文件 -Both Passes: - deu: Beide Durchgänge - eng: Both Passes - fra: Les deux passages - ita: Entrambi i Pass - spa: Ambos pases - zho: 两遍均应用 -Advanced settings are currently not saved in Profiles: - deu: Erweiterte Einstellungen werden derzeit nicht in Profilen gespeichert - eng: Advanced settings are currently not saved in Profiles - fra: Les paramètres avancés ne sont actuellement pas enregistrés dans les Profils - ita: Le impostazioni avanzate non sono attualmente salvate in Profili - spa: Los ajustes avanzados no se guardan actualmente en Perfiles - zho: 高级设置目前不会保存到方案中 -Constant: - deu: Konstant - eng: Constant - fra: Constant - ita: Costante - spa: Constante - zho: 恒定 -Please make sure seek method is set to exact: - deu: Bitte stellen Sie sicher, dass die Suchmethode auf exakt eingestellt ist - eng: Please make sure seek method is set to exact - fra: Veuillez vous assurer que la méthode de recherche est réglée sur l'exacte - ita: Si prega di assicurarsi che il metodo di ricerca sia impostato su - spa: Por favor, asegúrese de que el método de búsqueda se establece con exactitud - zho: 请确保检索方式已设置为exact -Extract: - deu: Auszug - eng: Extract - fra: Extrait - ita: Estratto - spa: Extracto - zho: 提取 -Save File: - deu: Datei speichern - eng: Save File - fra: Enregistrer le fichier - ita: Salva file - spa: Guardar archivo - zho: 保存文件 -GUI Logging Level: - deu: GUI-Protokollierungsebene - eng: GUI Logging Level - fra: Niveau d'exploitation forestière - ita: Livello di registrazione GUI - spa: Nivel de registro GUI - zho: GUI日志级别 -Enable VBV: - deu: VBV freigeben - eng: Enable VBV - fra: Activer la VBV - ita: Attivare VBV - spa: Activar VBV - zho: 启用VBV -Maxrate: - deu: Maxrate - eng: Maxrate - fra: Maxrate - ita: Maxrate - spa: Maxrate - zho: Maxrate -Bufsize: - deu: Bufsize - eng: Bufsize - fra: Bufsize - ita: Bufsize - spa: Bufsize - zho: Bufsize +There are no videos to start converting: + deu: Es sind keine Videos vorhanden, die konvertiert werden können + eng: There are no videos to start converting + fra: Il n'y a pas de vidéos à convertir + ita: Non ci sono video da convertire + spa: No hay vídeos para empezar a convertir + zho: 没有视频可以开始转换 +No crop, scale, rotation,flip nor any other filters will be applied.: + deu: Es werden weder Beschneiden, Skalieren, Drehen, Spiegeln noch andere Filter angewendet. + eng: No crop, scale, rotation,flip nor any other filters will be applied. + fra: Aucun filtre de culture, d'échelle, de rotation, de retournement ou autre ne sera appliqué. + ita: Nessun ritaglio, scala, rotazione, flip o qualsiasi altro filtro sarà applicato. + spa: No se aplicará ningún filtro de recorte, escala, rotación, volteo ni ningún otro. + zho: 不会应用裁剪、缩放、旋转、翻转或任何其他滤镜。 +Are you sure you want to stop the current encode?: + deu: Sind Sie sicher, dass Sie die aktuelle Kodierung stoppen wollen? + eng: Are you sure you want to stop the current encode? + fra: Êtes-vous sûr de vouloir arrêter le codage actuel? + ita: Sei sicuro di voler fermare la codifica in corso? + spa: ¿Está seguro de que quiere detener la codificación actual? + zho: 你确定要停止当前的编码吗? +Confirm Stop Encode: + deu: Bestätigen Sie Stop Encode + eng: Confirm Stop Encode + fra: Confirmer le code d'arrêt + ita: Confermare Stop Encode + spa: Confirmar parada de codificación + zho: 确认 停止 编码 +Use Sane Audio Selection (updatable in config file): + deu: Audioauswahl verwenden (aktualisierbar in der Konfigurationsdatei) + eng: Use Sane Audio Selection (updatable in config file) + fra: Utiliser la sélection audio (mise à jour dans le fichier de configuration) + ita: Usa la selezione audio (aggiornabile nel file di configurazione) + spa: Utilizar la selección de audio (actualizable en el archivo de configuración) + zho: 使用音频选择(可在配置文件中更新) +HDR10+ Parser: + deu: HDR10+ Parser + eng: HDR10+ Parser + fra: HDR10+ Parser + ita: HDR10+ Parser + spa: Analizador HDR10+ + zho: HDR10+解析器 +Not all items in the queue were completed: + deu: Nicht alle Elemente in der Warteschlange wurden abgeschlossen + eng: Not all items in the queue were completed + fra: Tous les articles dans la file d'attente n'ont pas été complétés + ita: Non tutti gli elementi in coda sono stati completati + spa: No se han completado todos los elementos de la cola + zho: 并非队列中的所有项目都已完成 +Would you like to keep them in the queue?: + deu: Möchten Sie sie in der Warteschlange behalten? + eng: Would you like to keep them in the queue? + fra: Souhaitez-vous les garder dans la file d'attente? + ita: Volete tenerli in coda? + spa: ¿Quiere mantenerlos en la cola? + zho: 你想把他们留在队列中吗? +Recover Queue Items: + deu: Warteschlangenelemente wiederherstellen + eng: Recover Queue Items + fra: Récupérer les articles en file d'attente + ita: Recuperare elementi della coda + spa: Recuperar elementos de la cola + zho: 恢复队列中的项目 +There is already a video being processed: + deu: Es ist bereits ein Video in Bearbeitung + eng: There is already a video being processed + fra: Il y a déjà une vidéo en cours de traitement + ita: C'è già un video in elaborazione + spa: Ya hay un vídeo en proceso + zho: 已经有一个视频正在处理中 +Are you sure you want to discard it?: + deu: Sind Sie sicher, dass Sie es verwerfen wollen? + eng: Are you sure you want to discard it? + fra: Êtes-vous sûr de vouloir la supprimer? + ita: Sei sicuro di volerlo scartare? + spa: ¿Estás seguro de que quieres descartarlo? + zho: 您确定要丢弃它吗? +Discard current video: + deu: Aktuelles Video verwerfen + eng: Discard current video + fra: Jeter la vidéo actuelle + ita: Scartare il video corrente + spa: Descartar el vídeo actual + zho: 丢弃当前视频 diff --git a/fastflix/data/styles/default.qss b/fastflix/data/styles/default.qss index 2055fd5a..174b9cd7 100644 --- a/fastflix/data/styles/default.qss +++ b/fastflix/data/styles/default.qss @@ -8,7 +8,7 @@ QPushButton { min-height: 20px; } -QPushButton:pressed, QPushButton:clicked { +QPushButton:pressed, QPushButton:clicked, QPushButton:checked { background-color: #ccc; } @@ -56,3 +56,55 @@ QGroupBox { margin-top: 2px; border-bottom: 2px solid #ddd; } + +/* QSlider::groove:horizontal {*/ +/*border: 1px solid #bbb;*/ +/*background: none;*/ +/*height: 10px;*/ +/*border-radius: 4px;*/ +/*}*/ + +/*QSlider::sub-page:horizontal {*/ +/*background: none;*/ +/*border: 1px solid #bbb;*/ +/*height: 10px;*/ +/*border-radius: 4px;*/ +/*}*/ + +/*QSlider::add-page:horizontal {*/ +/*background: none;*/ +/*border: 1px solid #bbb;*/ +/*height: 10px;*/ +/*border-radius: 4px;*/ +/*}*/ + +/*QSlider::handle:horizontal {*/ +/*background: qlineargradient(x1:0, y1:0, x2:1, y2:1,stop:0 #eee, stop:1 #aaa);*/ +/*border: 1px solid #777;*/ +/*width: 13px;*/ +/*margin-top: -2px;*/ +/*margin-bottom: -2px;*/ +/*border-radius: 4px;*/ +/*}*/ + +/*QSlider::handle:horizontal:hover {*/ +/*background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #fff, stop:1 #ddd);*/ +/*border: 1px solid #444;*/ +/*border-radius: 4px;*/ +/*}*/ + +/*QSlider::sub-page:horizontal:disabled {*/ +/*background: #bbb;*/ +/*border-color: #999;*/ +/*}*/ + +/*QSlider::add-page:horizontal:disabled {*/ +/*background: #eee;*/ +/*border-color: #999;*/ +/*}*/ + +/*QSlider::handle:horizontal:disabled {*/ +/*background: #eee;*/ +/*border: 1px solid #aaa;*/ +/*border-radius: 4px;*/ +/*}*/ diff --git a/fastflix/encoders/av1_aom/command_builder.py b/fastflix/encoders/av1_aom/command_builder.py index b0c1c985..f4cffa0f 100644 --- a/fastflix/encoders/av1_aom/command_builder.py +++ b/fastflix/encoders/av1_aom/command_builder.py @@ -23,8 +23,6 @@ def build(fastflix: FastFlix): if settings.row_mt.lower() == "enabled": beginning += f"-row-mt 1 " - beginning = re.sub("[ ]+", " ", beginning) - if settings.bitrate: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" command_1 = f'{beginning} -passlogfile "{pass_log_file}" -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ""} -an -f matroska {null}' diff --git a/fastflix/encoders/avc_x264/command_builder.py b/fastflix/encoders/avc_x264/command_builder.py index d32a2be5..02d735a9 100644 --- a/fastflix/encoders/avc_x264/command_builder.py +++ b/fastflix/encoders/avc_x264/command_builder.py @@ -12,7 +12,7 @@ def build(fastflix: FastFlix): beginning, ending = generate_all(fastflix, "libx264") - beginning += f'{f"-tune {settings.tune}" if settings.tune else ""} ' f"{generate_color_details(fastflix)} " + beginning += f'{f"-tune:v {settings.tune}" if settings.tune else ""} {generate_color_details(fastflix)} ' if settings.profile and settings.profile != "default": beginning += f"-profile:v {settings.profile} " @@ -22,20 +22,20 @@ def build(fastflix: FastFlix): if settings.bitrate: command_1 = ( f"{beginning} -pass 1 " - f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} -preset {settings.preset} {settings.extra if settings.extra_both_passes else ""} -an -sn -dn -f mp4 {null}' + f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra if settings.extra_both_passes else ""} -an -sn -dn -f mp4 {null}' ) command_2 = ( f'{beginning} -pass 2 -passlogfile "{pass_log_file}" ' - f"-b:v {settings.bitrate} -preset {settings.preset} {settings.extra} " + f"-b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} " ) + ending return [ - Command(command=re.sub("[ ]+", " ", command_1), name="First pass bitrate", exe="ffmpeg"), - Command(command=re.sub("[ ]+", " ", command_2), name="Second pass bitrate", exe="ffmpeg"), + Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), + Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), ] elif settings.crf: - command = f"{beginning} -crf {settings.crf} " f"-preset {settings.preset} {settings.extra} {ending}" - return [Command(command=re.sub("[ ]+", " ", command), name="Single pass CRF", exe="ffmpeg")] + command = f"{beginning} -crf:v {settings.crf} " f"-preset:v {settings.preset} {settings.extra} {ending}" + return [Command(command=command, name="Single pass CRF", exe="ffmpeg")] else: return [] diff --git a/fastflix/encoders/avc_x264/settings_panel.py b/fastflix/encoders/avc_x264/settings_panel.py index b2bf9f39..90b5a3d8 100644 --- a/fastflix/encoders/avc_x264/settings_panel.py +++ b/fastflix/encoders/avc_x264/settings_panel.py @@ -36,14 +36,14 @@ "25", "24", "23 (x264 default)", - "22", - "21", - "20", - "19 (480p)", - "18 (720p)", - "17 (1080p)", - "16 (1440p)", - "15 (2160p)", + "22 (1080p)", + "21 (1440p)", + "20 (2160p)", + "19", + "18", + "17", + "16", + "15", "14 (higher quality)", "Custom", ] diff --git a/fastflix/encoders/common/attachments.py b/fastflix/encoders/common/attachments.py index 16cbfc98..b83d2b3f 100644 --- a/fastflix/encoders/common/attachments.py +++ b/fastflix/encoders/common/attachments.py @@ -3,6 +3,7 @@ from typing import List from fastflix.models.encode import AttachmentTrack +from fastflix.shared import clean_file_string def image_type(file: Path): @@ -20,9 +21,9 @@ def build_attachments(attachments: List[AttachmentTrack]) -> str: for attachment in attachments: if attachment.attachment_type == "cover": mime_type, ext_type = image_type(attachment.file_path) - unixy_path = str(attachment.file_path).replace("\\", "/") + clean_path = clean_file_string(attachment.file_path) commands.append( - f' -attach "{unixy_path}" -metadata:s:{attachment.outdex} mimetype="{mime_type}" ' + f' -attach "{clean_path}" -metadata:s:{attachment.outdex} mimetype="{mime_type}" ' f'-metadata:s:{attachment.outdex} filename="{attachment.filename}.{ext_type}" ' ) return " ".join(commands) diff --git a/fastflix/encoders/common/audio.py b/fastflix/encoders/common/audio.py index 11fa46d7..62c863e2 100644 --- a/fastflix/encoders/common/audio.py +++ b/fastflix/encoders/common/audio.py @@ -1,13 +1,35 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +channel_list = { + "mono": 1, + "stereo": 2, + "2.1": 3, + "3.0": 3, + "3.0(back)": 3, + "3.1": 4, + "4.0": 4, + "quad": 4, + "quad(side)": 4, + "5.0": 5, + "5.1": 6, + "6.0": 6, + "6.0(front)": 6, + "hexagonal": 6, + "6.1": 7, + "6.1(front)": 7, + "7.0": 7, + "7.0(front)": 7, + "7.1": 8, + "7.1(wide)": 8, +} + lossless = ["flac", "truehd", "alac", "tta", "wavpack", "mlp"] def build_audio(audio_tracks, audio_file_index=0): command_list = [] for track in audio_tracks: - downmix = f"-ac:{track.outdex} {track.downmix}" if track.downmix > 0 else "" command_list.append( f"-map {audio_file_index}:{track.index} " f'-metadata:s:{track.outdex} title="{track.title}" ' @@ -18,6 +40,11 @@ def build_audio(audio_tracks, audio_file_index=0): if not track.conversion_codec or track.conversion_codec == "none": command_list.append(f"-c:{track.outdex} copy") elif track.conversion_codec: + downmix = ( + f"-ac:{track.outdex} {channel_list[track.downmix]} -filter:{track.outdex} aformat=channel_layouts={track.downmix}" + if track.downmix + else "" + ) bitrate = "" if track.conversion_codec not in lossless: bitrate = f"-b:{track.outdex} {track.conversion_bitrate} " diff --git a/fastflix/encoders/common/helpers.py b/fastflix/encoders/common/helpers.py index bb95d461..3aa32aa7 100644 --- a/fastflix/encoders/common/helpers.py +++ b/fastflix/encoders/common/helpers.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import uuid from pathlib import Path -from typing import List, Tuple, Union +from typing import List, Tuple, Union, Optional, Dict import reusables from pydantic import BaseModel, Field @@ -9,31 +9,18 @@ from fastflix.encoders.common.attachments import build_attachments from fastflix.encoders.common.audio import build_audio from fastflix.encoders.common.subtitles import build_subtitle -from fastflix.models.base import BaseDataClass from fastflix.models.fastflix import FastFlix +from fastflix.shared import clean_file_string, sanitize null = "/dev/null" if reusables.win_based: null = "NUL" -class Loop: - item = "loop" - - def __init__(self, condition, commands, dirs=(), files=(), name="", ensure_paths=()): - self.name = name - self.condition = condition - self.commands = commands - self.ensure_paths = ensure_paths - self.dirs = dirs - self.files = files - - class Command(BaseModel): command: str item = "command" name: str = "" - ensure_paths: List = () exe: str = None shell: bool = False uuid: str = Field(default_factory=lambda: str(uuid.uuid4())) @@ -63,8 +50,8 @@ def generate_ffmpeg_start( incoming_fps = f"-r {source_fps}" if source_fps else "" vsync_text = f"-vsync {vsync}" if vsync else "" title = f'-metadata title="{video_title}"' if video_title else "" - source = str(source).replace("\\", "/") - ffmpeg = str(ffmpeg).replace("\\", "/") + source = clean_file_string(source) + ffmpeg = clean_file_string(ffmpeg) return " ".join( [ @@ -106,8 +93,7 @@ def generate_ending( f"{audio} {subtitles} {cover} " ) if output_video and not null_ending: - output_video = str(output_video).replace("\\", "/") - ending += f'"{output_video}"' + ending += f'"{clean_file_string(sanitize(output_video))}"' else: ending += null return ending @@ -116,11 +102,9 @@ def generate_ending( def generate_filters( selected_track, source=None, - crop=None, + crop: Optional[Dict] = None, scale=None, scale_filter="lanczos", - scale_width=None, - scale_height=None, remove_hdr=False, rotate=0, vertical_flip=None, @@ -128,6 +112,7 @@ def generate_filters( burn_in_subtitle_track=None, burn_in_subtitle_type=None, custom_filters=None, + start_filters=None, raw_filters=False, deinterlace=False, tone_map: str = "hable", @@ -139,21 +124,21 @@ def generate_filters( ): filter_list = [] + if start_filters: + filter_list.append(start_filters) if deinterlace: filter_list.append(f"yadif") if crop: - filter_list.append(f"crop={crop}") + filter_list.append(f"crop={crop['width']}:{crop['height']}:{crop['left']}:{crop['top']}") if scale: filter_list.append(f"scale={scale}:flags={scale_filter}") - elif scale_width: - filter_list.append(f"scale={scale_width}:-8:flags={scale_filter}") - elif scale_height: - filter_list.append(f"scale=-8:{scale_height}:flags={scale_filter}") if rotate: - if rotate < 3: - filter_list.append(f"transpose={rotate}") - if rotate == 4: + if rotate == 1: + filter_list.append(f"transpose=1") + if rotate == 2: filter_list.append(f"transpose=2,transpose=2") + if rotate == 3: + filter_list.append(f"transpose=2") if vertical_flip: filter_list.append("vflip") if horizontal_flip: @@ -183,8 +168,7 @@ def generate_filters( else: filter_complex = f"[0:{selected_track}][0:{burn_in_subtitle_track}]overlay[v]" else: - unixy = str(source).replace("\\", "/") - filter_complex = f"[0:{selected_track}]{f'{filters},' if filters else ''}subtitles='{unixy}':si={burn_in_subtitle_track}[v]" + filter_complex = f"[0:{selected_track}]{f'{filters},' if filters else ''}subtitles='{clean_file_string(source)}':si={burn_in_subtitle_track}[v]" elif filters: filter_complex = f"[0:{selected_track}]{filters}[v]" else: diff --git a/fastflix/encoders/common/nvencc_helpers.py b/fastflix/encoders/common/nvencc_helpers.py new file mode 100644 index 00000000..8a445d8c --- /dev/null +++ b/fastflix/encoders/common/nvencc_helpers.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +from typing import List, Dict +import logging + +from fastflix.models.video import SubtitleTrack, AudioTrack +from fastflix.encoders.common.audio import lossless + + +logger = logging.getLogger("fastflix") + + +def get_stream_pos(streams) -> Dict: + return {x.index: i for i, x in enumerate(streams, start=1)} + + +def build_audio(audio_tracks: List[AudioTrack], audio_streams): + command_list = [] + copies = [] + track_ids = set() + stream_ids = get_stream_pos(audio_streams) + + for track in sorted(audio_tracks, key=lambda x: x.outdex): + if track.index in track_ids: + logger.warning("NVEncC does not support copy and duplicate of audio tracks!") + track_ids.add(track.index) + audio_id = stream_ids[track.index] + if track.language: + command_list.append(f"--audio-metadata {audio_id}?language={track.language}") + if not track.conversion_codec or track.conversion_codec == "none": + copies.append(str(audio_id)) + elif track.conversion_codec: + downmix = f"--audio-stream {audio_id}?:{track.downmix}" if track.downmix else "" + bitrate = "" + if track.conversion_codec not in lossless: + bitrate = f"--audio-bitrate {audio_id}?{track.conversion_bitrate.rstrip('k')} " + command_list.append( + f"{downmix} --audio-codec {audio_id}?{track.conversion_codec} {bitrate} " + f"--audio-metadata {audio_id}?clear" + ) + + if track.title: + command_list.append( + f'--audio-metadata {audio_id}?title="{track.title}" ' + f'--audio-metadata {audio_id}?handler="{track.title}" ' + ) + + return f" --audio-copy {','.join(copies)} {' '.join(command_list)}" if copies else f" {' '.join(command_list)}" + + +def build_subtitle(subtitle_tracks: List[SubtitleTrack], subtitle_streams) -> str: + command_list = [] + copies = [] + stream_ids = get_stream_pos(subtitle_streams) + + for track in sorted(subtitle_tracks, key=lambda x: x.outdex): + sub_id = stream_ids[track.index] + if track.burn_in: + command_list.append(f"--vpp-subburn track={sub_id}") + else: + copies.append(str(sub_id)) + if track.disposition: + command_list.append(f"--sub-disposition {sub_id}?{track.disposition}") + command_list.append(f"--sub-metadata {sub_id}?language='{track.language}'") + + return f" --sub-copy {','.join(copies)} {' '.join(command_list)}" if copies else f" {' '.join(command_list)}" diff --git a/fastflix/encoders/common/setting_panel.py b/fastflix/encoders/common/setting_panel.py index a2aa35ad..f4e20df2 100644 --- a/fastflix/encoders/common/setting_panel.py +++ b/fastflix/encoders/common/setting_panel.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- import logging from typing import List, Tuple, Union +from pathlib import Path from box import Box -from qtpy import QtGui, QtWidgets +from qtpy import QtGui, QtWidgets, QtCore from fastflix.exceptions import FastFlixInternalException from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp +from fastflix.widgets.background_tasks import ExtractHDR10 logger = logging.getLogger("fastflix") @@ -37,12 +39,15 @@ def determine_default(self, widget_name, opt, items: List, raise_error: bool = F elif widget_name in ("crf", "qp"): if not opt: return 6 - items = [x.split("(")[0].split("-")[0].strip() for x in items] opt = str(opt) + items = [x.split("(")[0].split("-")[0].strip() for x in items] elif widget_name == "bitrate": if not opt: return 5 items = [x.split("(")[0].split("-")[0].strip() for x in items] + elif widget_name == "gpu": + if opt == -1: + return 0 if isinstance(opt, str): try: return items.index(opt) @@ -50,22 +55,34 @@ def determine_default(self, widget_name, opt, items: List, raise_error: bool = F if raise_error: raise FastFlixInternalException else: - logger.error(f"Could not set default for {widget_name} to {opt} as it's not in the list") + logger.error(f"Could not set default for {widget_name} to {opt} as it's not in the list: {items}") return 0 if isinstance(opt, bool): return int(opt) return opt def _add_combo_box( - self, label, options, widget_name, opt=None, connect="default", enabled=True, default=0, tooltip="" + self, + options, + widget_name, + label=None, + opt=None, + connect="default", + enabled=True, + default=0, + tooltip="", + min_width=None, ): layout = QtWidgets.QHBoxLayout() - self.labels[widget_name] = QtWidgets.QLabel(t(label)) - if tooltip: - self.labels[widget_name].setToolTip(self.translate_tip(tooltip)) + if label: + self.labels[widget_name] = QtWidgets.QLabel(t(label)) + if tooltip: + self.labels[widget_name].setToolTip(self.translate_tip(tooltip)) self.widgets[widget_name] = QtWidgets.QComboBox() self.widgets[widget_name].addItems(options) + if min_width: + self.widgets[widget_name].setMinimumWidth(min_width) if opt: default = self.determine_default( @@ -86,6 +103,57 @@ def _add_combo_box( else: self.widgets[widget_name].currentIndexChanged.connect(connect) + if not label: + return self.widgets[widget_name] + + layout.addWidget(self.labels[widget_name]) + layout.addWidget(self.widgets[widget_name]) + + return layout + + def _add_text_box( + self, + label, + widget_name, + opt=None, + connect="default", + enabled=True, + default="", + tooltip="", + validator=None, + width=None, + ): + layout = QtWidgets.QHBoxLayout() + self.labels[widget_name] = QtWidgets.QLabel(t(label)) + if tooltip: + self.labels[widget_name].setToolTip(self.translate_tip(tooltip)) + + self.widgets[widget_name] = QtWidgets.QLineEdit() + + if opt: + default = str(self.app.fastflix.config.encoder_opt(self.profile_name, opt)) or default + self.opts[widget_name] = opt + self.widgets[widget_name].setText(default) + self.widgets[widget_name].setDisabled(not enabled) + if tooltip: + self.widgets[widget_name].setToolTip(self.translate_tip(tooltip)) + if connect: + if connect == "default": + self.widgets[widget_name].textChanged.connect(lambda: self.main.page_update(build_thumbnail=False)) + elif connect == "self": + self.widgets[widget_name].textChanged.connect(lambda: self.page_update()) + else: + self.widgets[widget_name].textChanged.connect(connect) + + if validator: + if validator == "int": + self.widgets[widget_name].setValidator(self.only_int) + if validator == "float": + self.widgets[widget_name].setValidator(self.only_int) + + if width: + self.widgets[widget_name].setFixedWidth(width) + layout.addWidget(self.labels[widget_name]) layout.addWidget(self.widgets[widget_name]) @@ -93,8 +161,6 @@ def _add_combo_box( def _add_check_box(self, label, widget_name, opt, connect="default", enabled=True, checked=True, tooltip=""): layout = QtWidgets.QHBoxLayout() - # self.labels[widget_name] = QtWidgets.QLabel() - # self.labels[widget_name].setToolTip() self.widgets[widget_name] = QtWidgets.QCheckBox(t(label)) self.opts[widget_name] = opt @@ -115,9 +181,9 @@ def _add_check_box(self, label, widget_name, opt, connect="default", enabled=Tru return layout - def _add_custom(self, connect="default", disable_both_passes=False): + def _add_custom(self, title="Custom ffmpeg options", connect="default", disable_both_passes=False): layout = QtWidgets.QHBoxLayout() - self.labels.ffmpeg_options = QtWidgets.QLabel(t("Custom ffmpeg options")) + self.labels.ffmpeg_options = QtWidgets.QLabel(t(title)) self.labels.ffmpeg_options.setToolTip(t("Extra flags or options, cannot modify existing settings")) layout.addWidget(self.labels.ffmpeg_options) self.ffmpeg_extras_widget = QtWidgets.QLineEdit() @@ -166,11 +232,42 @@ def _add_file_select(self, label, widget_name, button_action, connect="default", layout.addWidget(button) return layout + def extract_hdr10plus(self): + self.extract_button.hide() + self.extract_label.show() + self.movie.start() + # self.extracting_hdr10 = True + self.extract_thrad = ExtractHDR10( + self.app, self.main, signal=self.hdr10plus_signal, ffmpeg_signal=self.hdr10plus_ffmpeg_signal + ) + self.extract_thrad.start() + + def done_hdr10plus_extract(self, metadata: str): + self.extract_button.show() + self.extract_label.hide() + self.movie.stop() + if Path(metadata).exists(): + self.widgets.hdr10plus_metadata.setText(metadata) + self.ffmpeg_level.setText("") + + def dhdr10_update(self): + dirname = Path(self.widgets.hdr10plus_metadata.text()).parent + if not dirname.exists(): + dirname = Path() + filename = QtWidgets.QFileDialog.getOpenFileName( + self, caption="hdr10_metadata", directory=str(dirname), filter="HDR10+ Metadata (*.json)" + ) + if not filename or not filename[0]: + return + self.widgets.hdr10plus_metadata.setText(filename[0]) + self.main.page_update() + def _add_modes( self, recommended_bitrates, recommended_qps, qp_name="crf", + add_qp=True, ): self.recommended_bitrates = recommended_bitrates self.recommended_qps = recommended_qps @@ -237,7 +334,8 @@ def _add_modes( custom_qp = True self.widgets[qp_name].setCurrentText("Custom") else: - self.widgets[qp_name].setCurrentIndex(default_qp_index) + if default_qp_index is not None: + self.widgets[qp_name].setCurrentIndex(default_qp_index) self.widgets[qp_name].currentIndexChanged.connect(lambda: self.mode_update()) self.widgets[f"custom_{qp_name}"] = QtWidgets.QLineEdit("30" if not custom_qp else str(qp_value)) @@ -264,6 +362,9 @@ def _add_modes( layout.addWidget(qp_group_box, 0, 0) layout.addWidget(bitrate_group_box, 1, 0) + if not add_qp: + qp_group_box.hide() + return layout @property @@ -273,7 +374,7 @@ def ffmpeg_extras(self): def ffmpeg_extra_update(self): global ffmpeg_extra_command ffmpeg_extra_command = self.ffmpeg_extras_widget.text().strip() - self.main.page_update() + self.main.page_update(build_thumbnail=False) def new_source(self): if not self.app.fastflix.current_video or not self.app.fastflix.current_video.streams: @@ -281,6 +382,7 @@ def new_source(self): def update_profile(self): global ffmpeg_extra_command + logger.debug("Update profile called") for widget_name, opt in self.opts.items(): if isinstance(self.widgets[widget_name], QtWidgets.QComboBox): default = self.determine_default( @@ -305,6 +407,7 @@ def update_profile(self): pass else: if bitrate: + self.mode = "Bitrate" self.qp_radio.setChecked(False) self.bitrate_radio.setChecked(True) for i, rec in enumerate(self.recommended_bitrates): @@ -315,6 +418,7 @@ def update_profile(self): self.widgets.bitrate.setCurrentText("Custom") self.widgets.custom_bitrate.setText(bitrate.rstrip("kKmMgGbB")) else: + self.mode = self.qp_name self.qp_radio.setChecked(True) self.bitrate_radio.setChecked(False) qp = str(self.app.fastflix.config.encoder_opt(self.profile_name, self.qp_name)) @@ -340,6 +444,7 @@ def init_max_mux(self): def reload(self): """This will reset the current settings to what is set in "current_video", useful for return from queue""" global ffmpeg_extra_command + logger.debug("Update reload called") self.updating_settings = True for widget_name, opt in self.opts.items(): data = getattr(self.app.fastflix.current_video.video_settings.video_encoder_settings, opt) @@ -352,15 +457,18 @@ def reload(self): self.widgets[widget_name].setCurrentIndex(data) else: self.widgets[widget_name].setCurrentText(data) + # Do smart check for cleaning up stuff + elif isinstance(self.widgets[widget_name], QtWidgets.QCheckBox): self.widgets[widget_name].setChecked(data) elif isinstance(self.widgets[widget_name], QtWidgets.QLineEdit): if widget_name == "x265_params": data = ":".join(data) - self.widgets[widget_name].setText(data or "") + self.widgets[widget_name].setText(str(data) or "") if getattr(self, "qp_radio", None): bitrate = getattr(self.app.fastflix.current_video.video_settings.video_encoder_settings, "bitrate", None) if bitrate: + self.mode = "Bitrate" self.qp_radio.setChecked(False) self.bitrate_radio.setChecked(True) for i, rec in enumerate(self.recommended_bitrates): @@ -371,6 +479,7 @@ def reload(self): self.widgets.bitrate.setCurrentText("Custom") self.widgets.custom_bitrate.setText(bitrate.rstrip("k")) else: + self.mode = self.qp_name self.qp_radio.setChecked(True) self.bitrate_radio.setChecked(False) qp = str(getattr(self.app.fastflix.current_video.video_settings.video_encoder_settings, self.qp_name)) diff --git a/fastflix/encoders/copy/command_builder.py b/fastflix/encoders/copy/command_builder.py index 7fa855a0..c2d4e1dc 100644 --- a/fastflix/encoders/copy/command_builder.py +++ b/fastflix/encoders/copy/command_builder.py @@ -11,11 +11,7 @@ def build(fastflix: FastFlix): return [ Command( - command=re.sub( - "[ ]+", - " ", - f"{beginning} {fastflix.current_video.video_settings.video_encoder_settings.extra} {ending}", - ), + command=f"{beginning} {fastflix.current_video.video_settings.video_encoder_settings.extra} {ending}", name="No Video Encoding", exe="ffmpeg", ) diff --git a/fastflix/encoders/ffmpeg_hevc_nvenc/__init__.py b/fastflix/encoders/ffmpeg_hevc_nvenc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/ffmpeg_hevc_nvenc/command_builder.py b/fastflix/encoders/ffmpeg_hevc_nvenc/command_builder.py new file mode 100644 index 00000000..5baad548 --- /dev/null +++ b/fastflix/encoders/ffmpeg_hevc_nvenc/command_builder.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +import re +import secrets + +from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null +from fastflix.models.encode import FFmpegNVENCSettings +from fastflix.models.fastflix import FastFlix + + +def build(fastflix: FastFlix): + settings: FFmpegNVENCSettings = fastflix.current_video.video_settings.video_encoder_settings + + beginning, ending = generate_all(fastflix, "hevc_nvenc") + + beginning += f'{f"-tune:v {settings.tune}" if settings.tune else ""} {generate_color_details(fastflix)} -spatial_aq:v {settings.spatial_aq} -tier:v {settings.tier} -rc-lookahead:v {settings.rc_lookahead} -gpu {settings.gpu} -b_ref_mode {settings.b_ref_mode} ' + + if settings.profile: + beginning += f"-profile:v {settings.profile} " + + if settings.rc: + beginning += f"-rc:v {settings.rc} " + + if settings.level: + beginning += f"-level:v {settings.level} " + + pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" + + command_1 = ( + f"{beginning} -pass 1 " + f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} -preset:v {settings.preset} -2pass 1 ' + f'{settings.extra if settings.extra_both_passes else ""} -an -sn -dn -f mp4 {null}' + ) + command_2 = ( + f'{beginning} -pass 2 -passlogfile "{pass_log_file}" -2pass 1 ' + f"-b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} " + ) + ending + return [ + Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), + Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), + ] diff --git a/fastflix/encoders/ffmpeg_hevc_nvenc/main.py b/fastflix/encoders/ffmpeg_hevc_nvenc/main.py new file mode 100644 index 00000000..130825ba --- /dev/null +++ b/fastflix/encoders/ffmpeg_hevc_nvenc/main.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +from pathlib import Path + +import pkg_resources + +name = "HEVC (NVENC)" +requires = "cuda-llvm" + +video_extension = "mkv" +video_dimension_divisor = 1 +icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_nvenc.png")).resolve()) + +enable_subtitles = True +enable_audio = True +enable_attachments = True + +audio_formats = [ + "aac", + "aac_mf", + "libfdk_aac", + "ac3", + "ac3_fixed", + "ac3_mf", + "adpcm_adx", + "g722", + "g726", + "g726le", + "adpcm_ima_qt", + "adpcm_ima_ssi", + "adpcm_ima_wav", + "adpcm_ms", + "adpcm_swf", + "adpcm_yamaha", + "alac", + "libopencore_amrnb", + "libvo_amrwbenc", + "aptx", + "aptx_hd", + "comfortnoise", + "dca", + "eac3", + "flac", + "g723_1", + "libgsm", + "libgsm_ms", + "libilbc", + "mlp", + "mp2", + "mp2fixed", + "libtwolame", + "mp3_mf", + "libmp3lame", + "nellymoser", + "opus", + "libopus", + "pcm_alaw", + "pcm_dvd", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", + "pcm_mulaw", + "pcm_s16be", + "pcm_s16be_planar", + "pcm_s16le", + "pcm_s16le_planar", + "pcm_s24be", + "pcm_s24daud", + "pcm_s24le", + "pcm_s24le_planar", + "pcm_s32be", + "pcm_s32le", + "pcm_s32le_planar", + "pcm_s64be", + "pcm_s64le", + "pcm_s8", + "pcm_s8_planar", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "pcm_u8", + "pcm_vidc", + "real_144", + "roq_dpcm", + "s302m", + "sbc", + "sonic", + "sonicls", + "libspeex", + "truehd", + "tta", + "vorbis", + "libvorbis", + "wavpack", + "wmav1", + "wmav2", +] + +from fastflix.encoders.ffmpeg_hevc_nvenc.command_builder import build +from fastflix.encoders.ffmpeg_hevc_nvenc.settings_panel import NVENC as settings_panel diff --git a/fastflix/encoders/ffmpeg_hevc_nvenc/settings_panel.py b/fastflix/encoders/ffmpeg_hevc_nvenc/settings_panel.py new file mode 100644 index 00000000..feff57b1 --- /dev/null +++ b/fastflix/encoders/ffmpeg_hevc_nvenc/settings_panel.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +import logging + +from box import Box +from qtpy import QtCore, QtWidgets + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.language import t +from fastflix.models.encode import FFmpegNVENCSettings +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.shared import link +from fastflix.exceptions import FastFlixInternalException + +logger = logging.getLogger("fastflix") + + +presets = [ + "slow", + "medium", + "fast", + "hp", + "hq", + "bd", + "ll", + "llhq", + "llhp", + "lossless", + "losslesshp", + "p1", + "p2", + "p3", + "p4", + "p5", + "p6", + "p7", +] + +recommended_bitrates = [ + "800k (320x240p @ 30fps)", + "1000k (640x360p @ 30fps)", + "1500k (640x480p @ 30fps)", + "2000k (1280x720p @ 30fps)", + "5000k (1280x720p @ 60fps)", + "6000k (1080p @ 30fps)", + "9000k (1080p @ 60fps)", + "15000k (1440p @ 30fps)", + "25000k (1440p @ 60fps)", + "35000k (2160p @ 30fps)", + "50000k (2160p @ 60fps)", + "Custom", +] + +recommended_crfs = [ + "28", + "27", + "26", + "25", + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", + "15", + "14", + "Custom", +] + +pix_fmts = ["8-bit: yuv420p", "10-bit: p010le"] + + +class NVENC(SettingPanel): + profile_name = "ffmpeg_hevc_nvenc" + + def __init__(self, parent, main, app: FastFlixApp): + super().__init__(parent, main, app) + self.main = main + self.app = app + + grid = QtWidgets.QGridLayout() + + self.widgets = Box(mode=None) + + self.mode = "CRF" + self.updating_settings = False + + grid.addLayout(self.init_modes(), 0, 2, 3, 4) + grid.addLayout(self._add_custom(), 10, 0, 1, 6) + + grid.addLayout(self.init_preset(), 0, 0, 1, 2) + grid.addLayout(self.init_max_mux(), 1, 0, 1, 2) + grid.addLayout(self.init_tune(), 2, 0, 1, 2) + grid.addLayout(self.init_profile(), 3, 0, 1, 2) + grid.addLayout(self.init_pix_fmt(), 4, 0, 1, 2) + grid.addLayout(self.init_tier(), 5, 0, 1, 2) + grid.addLayout(self.init_rc(), 6, 0, 1, 2) + grid.addLayout(self.init_spatial_aq(), 7, 0, 1, 2) + + a = QtWidgets.QHBoxLayout() + a.addLayout(self.init_rc_lookahead()) + a.addStretch(1) + a.addLayout(self.init_level()) + a.addStretch(1) + a.addLayout(self.init_gpu()) + a.addStretch(1) + a.addLayout(self.init_b_ref_mode()) + grid.addLayout(a, 3, 2, 1, 4) + + grid.setRowStretch(9, 1) + + # guide_label = QtWidgets.QLabel( + # link("https://trac.ffmpeg.org/wiki/Encode/H.264", t("FFMPEG AVC / H.264 Encoding Guide")) + # ) + # guide_label.setAlignment(QtCore.Qt.AlignBottom) + # guide_label.setOpenExternalLinks(True) + # grid.addWidget(guide_label, 11, 0, 1, 6) + + self.setLayout(grid) + self.hide() + + def init_preset(self): + return self._add_combo_box( + label="Preset", + widget_name="preset", + options=presets, + tooltip=("preset: The slower the preset, the better the compression and quality"), + connect="default", + opt="preset", + ) + + def init_tune(self): + return self._add_combo_box( + label="Tune", + widget_name="tune", + tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", + options=["hq", "ll", "ull", "lossless"], + opt="tune", + ) + + def init_profile(self): + return self._add_combo_box( + label="Profile_encoderopt", + widget_name="profile", + tooltip="Enforce an encode profile", + options=["main", "main10", "rext"], + opt="profile", + ) + + def init_pix_fmt(self): + return self._add_combo_box( + label="Bit Depth", + tooltip="Pixel Format (requires at least 10-bit for HDR)", + widget_name="pix_fmt", + options=pix_fmts, + opt="pix_fmt", + ) + + def init_tier(self): + return self._add_combo_box( + label="Tier", + tooltip="Set the encoding tier", + widget_name="tier", + options=["main", "high"], + opt="tier", + ) + + def init_rc(self): + return self._add_combo_box( + label="Rate Control", + tooltip="Override the preset rate-control", + widget_name="rc", + options=[ + "default", + "vbr", + "cbr", + "vbr_minqp", + "ll_2pass_quality", + "ll_2pass_size", + "vbr_2pass", + "cbr_ld_hq", + "cbr_hq", + "vbr_hq", + ], + opt="rc", + ) + + def init_spatial_aq(self): + return self._add_combo_box( + label="Spatial AQ", + tooltip="", + widget_name="spatial_aq", + options=["off", "on"], + opt="spatial_aq", + ) + + def init_rc_lookahead(self): + return self._add_text_box( + label="RC Lookahead", + tooltip="", + widget_name="rc_lookahead", + opt="rc_lookahead", + validator="int", + default="0", + width=30, + ) + + def init_level(self): + layout = self._add_combo_box( + label="Level", + tooltip="Set the encoding level restriction", + widget_name="level", + options=["auto", "1.0", "2.0", "2.1", "3.0", "3.1", "4.0", "4.1", "5.0", "5.1", "5.2", "6.0", "6.1", "6.2"], + opt="level", + ) + self.widgets.level.setMinimumWidth(60) + return layout + + def init_gpu(self): + layout = self._add_combo_box( + label="GPU", + tooltip="Selects which NVENC capable GPU to use. First GPU is 0, second is 1, and so on", + widget_name="gpu", + opt="gpu", + options=["any"] + [str(x) for x in range(8)], + ) + self.widgets.gpu.setMinimumWidth(50) + return layout + + def init_b_ref_mode(self): + layout = self._add_combo_box( + label="B Ref Mode", + tooltip="Use B frames as references", + widget_name="b_ref_mode", + opt="b_ref_mode", + options=["disabled", "each", "middle"], + ) + self.widgets.gpu.setMinimumWidth(50) + return layout + + def init_modes(self): + layout = self._add_modes(recommended_bitrates, recommended_crfs, qp_name="qp", add_qp=False) + self.qp_radio.setChecked(False) + self.bitrate_radio.setChecked(True) + self.qp_radio.setDisabled(True) + return layout + + def mode_update(self): + self.widgets.custom_qp.setDisabled(self.widgets.qp.currentText() != "Custom") + self.widgets.custom_bitrate.setDisabled(self.widgets.bitrate.currentText() != "Custom") + self.main.build_commands() + + def setting_change(self, update=True): + if self.updating_settings: + return + self.updating_settings = True + + if update: + self.main.page_update() + self.updating_settings = False + + def update_video_encoder_settings(self): + tune = self.widgets.tune.currentText() + + settings = FFmpegNVENCSettings( + preset=self.widgets.preset.currentText().split("-")[0].strip(), + max_muxing_queue_size=self.widgets.max_mux.currentText(), + profile=self.widgets.profile.currentText(), + pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(), + extra=self.ffmpeg_extras, + tune=tune.split("-")[0].strip(), + extra_both_passes=self.widgets.extra_both_passes.isChecked(), + rc=self.widgets.rc.currentText() if self.widgets.rc.currentIndex() != 0 else None, + spatial_aq=self.widgets.spatial_aq.currentIndex(), + rc_lookahead=int(self.widgets.rc_lookahead.text() or 0), + level=self.widgets.level.currentText() if self.widgets.level.currentIndex() != 0 else None, + gpu=int(self.widgets.gpu.currentText() or -1) if self.widgets.gpu.currentIndex() != 0 else -1, + b_ref_mode=self.widgets.b_ref_mode.currentText(), + tier=self.widgets.tier.currentText(), + ) + encode_type, q_value = self.get_mode_settings() + settings.qp = q_value if encode_type == "qp" else None + settings.bitrate = q_value if encode_type == "bitrate" else None + self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + + def set_mode(self, x): + self.mode = x.text() + self.main.build_commands() diff --git a/fastflix/encoders/gif/command_builder.py b/fastflix/encoders/gif/command_builder.py index a4bc711d..95dfd88f 100644 --- a/fastflix/encoders/gif/command_builder.py +++ b/fastflix/encoders/gif/command_builder.py @@ -4,6 +4,7 @@ from fastflix.encoders.common.helpers import Command, generate_filters from fastflix.models.encode import GIFSettings from fastflix.models.fastflix import FastFlix +from fastflix.shared import clean_file_string def build(fastflix: FastFlix): @@ -15,7 +16,7 @@ def build(fastflix: FastFlix): custom_filters=f"fps={settings.fps:.2f}", raw_filters=True, **fastflix.current_video.video_settings.dict() ) - output_video = str(fastflix.current_video.video_settings.output_path).replace("\\", "/") + output_video = clean_file_string(fastflix.current_video.video_settings.output_path) beginning = ( f'"{fastflix.config.ffmpeg}" -y ' f'{f"-ss {fastflix.current_video.video_settings.start_time}" if fastflix.current_video.video_settings.start_time else ""} ' diff --git a/fastflix/encoders/hevc_x265/command_builder.py b/fastflix/encoders/hevc_x265/command_builder.py index adf3c359..201c4523 100644 --- a/fastflix/encoders/hevc_x265/command_builder.py +++ b/fastflix/encoders/hevc_x265/command_builder.py @@ -74,6 +74,8 @@ color_matrix_mapping = {"bt2020_ncl": "bt2020nc", "bt2020_cl": "bt2020c"} +chromaloc_mapping = {"left": 0, "center": 1, "topleft": 2, "top": 3, "bottomleft": 4, "bottom": 5} + def build(fastflix: FastFlix): settings: x265Settings = fastflix.current_video.video_settings.video_encoder_settings @@ -81,7 +83,7 @@ def build(fastflix: FastFlix): beginning, ending = generate_all(fastflix, "libx265") if settings.tune and settings.tune != "default": - beginning += f"-tune {settings.tune} " + beginning += f"-tune:v {settings.tune} " if settings.profile and settings.profile != "default": beginning += f"-profile:v {settings.profile} " @@ -134,6 +136,10 @@ def build(fastflix: FastFlix): x265_params.append(f"hdr10={'1' if settings.hdr10 else '0'}") + current_chroma_loc = fastflix.current_video.current_video_stream.get("chroma_location") + if current_chroma_loc in chromaloc_mapping: + x265_params.append(f"chromaloc={chromaloc_mapping[current_chroma_loc]}") + if settings.hdr10plus_metadata: x265_params.append(f"dhdr10-info='{settings.hdr10plus_metadata}'") @@ -164,24 +170,24 @@ def get_x265_params(params=()): if settings.bitrate: command_1 = ( f'{beginning} {get_x265_params(["pass=1", "no-slow-firstpass=1"])} ' - f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} -preset {settings.preset} {settings.extra if settings.extra_both_passes else ""} ' + f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra if settings.extra_both_passes else ""} ' f" -an -sn -dn -f mp4 {null}" ) command_2 = ( f'{beginning} {get_x265_params(["pass=2"])} -passlogfile "{pass_log_file}" ' - f"-b:v {settings.bitrate} -preset {settings.preset} {settings.extra} {ending}" + f"-b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} {ending}" ) return [ - Command(command=re.sub("[ ]+", " ", command_1), name="First pass bitrate", exe="ffmpeg"), - Command(command=re.sub("[ ]+", " ", command_2), name="Second pass bitrate", exe="ffmpeg"), + Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), + Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), ] elif settings.crf: command = ( - f"{beginning} {get_x265_params()} -crf {settings.crf} " - f"-preset {settings.preset} {settings.extra} {ending}" + f"{beginning} {get_x265_params()} -crf:v {settings.crf} " + f"-preset:v {settings.preset} {settings.extra} {ending}" ) - return [Command(command=re.sub("[ ]+", " ", command), name="Single pass CRF", exe="ffmpeg")] + return [Command(command=command, name="Single pass CRF", exe="ffmpeg")] else: return [] diff --git a/fastflix/encoders/hevc_x265/settings_panel.py b/fastflix/encoders/hevc_x265/settings_panel.py index a88ec094..84dbcd13 100644 --- a/fastflix/encoders/hevc_x265/settings_panel.py +++ b/fastflix/encoders/hevc_x265/settings_panel.py @@ -10,8 +10,9 @@ from fastflix.language import t from fastflix.models.encode import x265Settings from fastflix.models.fastflix_app import FastFlixApp -from fastflix.resources import warning_icon +from fastflix.resources import loading_movie, warning_icon from fastflix.shared import link +from fastflix.widgets.background_tasks import ExtractHDR10 logger = logging.getLogger("fastflix") @@ -63,6 +64,8 @@ def get_breaker(): class HEVC(SettingPanel): profile_name = "x265" + hdr10plus_signal = QtCore.Signal(str) + hdr10plus_ffmpeg_signal = QtCore.Signal(str) def __init__(self, parent, main, app: FastFlixApp): super().__init__(parent, main, app) @@ -73,6 +76,7 @@ def __init__(self, parent, main, app: FastFlixApp): self.mode = "CRF" self.updating_settings = False + self.extract_thread = None grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_tune(), 1, 0, 1, 2) @@ -103,10 +107,12 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_dhdr10_info(), 9, 2, 1, 3) grid.addLayout(self.init_dhdr10_warning_and_opt(), 9, 5, 1, 1) + self.ffmpeg_level = QtWidgets.QLabel() + grid.addWidget(self.ffmpeg_level, 10, 2, 1, 4) - grid.setRowStretch(10, True) + grid.setRowStretch(11, True) - grid.addLayout(self._add_custom(), 11, 0, 1, 6) + grid.addLayout(self._add_custom(), 12, 0, 1, 6) link_1 = link( "https://trac.ffmpeg.org/wiki/Encode/H.265", @@ -125,8 +131,10 @@ def __init__(self, parent, main, app: FastFlixApp): guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 12, 0, 1, 6) + grid.addWidget(guide_label, 13, 0, 1, 6) + self.hdr10plus_signal.connect(self.done_hdr10plus_extract) + self.hdr10plus_ffmpeg_signal.connect(lambda x: self.ffmpeg_level.setText(x)) self.setLayout(grid) self.hide() @@ -151,6 +159,20 @@ def init_dhdr10_warning_and_opt(self): icon = QtGui.QIcon(warning_icon) label.setPixmap(icon.pixmap(22)) layout = QtWidgets.QHBoxLayout() + + self.extract_button = QtWidgets.QPushButton(t("Extract HDR10+")) + self.extract_button.hide() + self.extract_button.clicked.connect(self.extract_hdr10plus) + + self.extract_label = QtWidgets.QLabel(self) + self.extract_label.hide() + self.movie = QtGui.QMovie(loading_movie) + self.movie.setScaledSize(QtCore.QSize(25, 25)) + self.extract_label.setMovie(self.movie) + + layout.addWidget(self.extract_button) + layout.addWidget(self.extract_label) + layout.addWidget(label) layout.addLayout(self.init_dhdr10_opt()) return layout @@ -432,18 +454,6 @@ def init_x265_params(self): layout.addWidget(self.widgets.x265_params) return layout - def dhdr10_update(self): - dirname = Path(self.widgets.hdr10plus_metadata.text()).parent - if not dirname.exists(): - dirname = Path() - filename = QtWidgets.QFileDialog.getOpenFileName( - self, caption="hdr10_metadata", directory=str(dirname), filter="HDR10+ Metadata (*.json)" - ) - if not filename or not filename[0]: - return - self.widgets.hdr10plus_metadata.setText(filename[0]) - self.main.page_update() - def setting_change(self, update=True, pix_change=False): def hdr_opts(): if not self.widgets.pix_fmt.currentText().startswith( @@ -509,8 +519,19 @@ def hdr_opts(): self.updating_settings = False def new_source(self): + if not self.app.fastflix.current_video: + return super().new_source() self.setting_change() + if self.app.fastflix.current_video.hdr10_plus: + self.extract_button.show() + else: + self.extract_button.hide() + if self.extract_thread: + try: + self.extract_thread.terminate() + except Exception: + pass def update_video_encoder_settings(self): if not self.app.fastflix.current_video: @@ -536,7 +557,7 @@ def update_video_encoder_settings(self): frame_threads=self.widgets.frame_threads.currentIndex(), tune=self.widgets.tune.currentText(), x265_params=x265_params_text.split(":") if x265_params_text else [], - hdr10plus_metadata=self.widgets.hdr10plus_metadata.text().strip().replace("\\", "/"), + hdr10plus_metadata=self.widgets.hdr10plus_metadata.text().strip(), # .replace("\\", "/"), lossless=self.widgets.lossless.isChecked(), extra=self.ffmpeg_extras, extra_both_passes=self.widgets.extra_both_passes.isChecked(), diff --git a/fastflix/encoders/nvencc_avc/__init__.py b/fastflix/encoders/nvencc_avc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/nvencc_avc/command_builder.py b/fastflix/encoders/nvencc_avc/command_builder.py new file mode 100644 index 00000000..c3cf8f39 --- /dev/null +++ b/fastflix/encoders/nvencc_avc/command_builder.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +import logging + +from fastflix.encoders.common.helpers import Command +from fastflix.models.encode import NVEncCAVCSettings +from fastflix.models.video import Video +from fastflix.models.fastflix import FastFlix +from fastflix.shared import clean_file_string +from fastflix.encoders.common.nvencc_helpers import build_subtitle, build_audio + +logger = logging.getLogger("fastflix") + + +def build(fastflix: FastFlix): + video: Video = fastflix.current_video + settings: NVEncCAVCSettings = fastflix.current_video.video_settings.video_encoder_settings + + trim = "" + try: + if "/" in video.frame_rate: + over, under = [int(x) for x in video.frame_rate.split("/")] + rate = over / under + else: + rate = float(video.frame_rate) + except Exception: + logger.exception("Could not get framerate of this movie!") + else: + if video.video_settings.end_time: + end_frame = int(video.video_settings.end_time * rate) + start_frame = 0 + if video.video_settings.start_time: + start_frame = int(video.video_settings.start_time * rate) + trim = f"--trim {start_frame}:{end_frame}" + elif video.video_settings.start_time: + trim = f"--seek {video.video_settings.start_time}" + + if (video.frame_rate != video.average_frame_rate) and trim: + logger.warning("Cannot use 'trim' when working with variable frame rate videos") + trim = "" + + transform = "" + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" + + remove_hdr = "" + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + + crop = "" + if video.video_settings.crop: + crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + + vbv = "" + if video.video_settings.maxrate: + vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + + init_q = settings.init_q_i + if settings.init_q_i and settings.init_q_p and settings.init_q_b: + init_q = f"{settings.init_q_i}:{settings.init_q_p}:{settings.init_q_b}" + + min_q = settings.min_q_i + if settings.min_q_i and settings.min_q_p and settings.min_q_b: + min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + + max_q = settings.max_q_i + if settings.max_q_i and settings.max_q_p and settings.max_q_b: + max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None + + aq = "--no-aq" + if settings.aq.lower() == "spatial": + aq = f"--aq --aq-strength {settings.aq_strength}" + elif settings.aq.lower() == "temporal": + aq = f"--aq-temporal --aq-strength {settings.aq_strength}" + + command = [ + f'"{clean_file_string(fastflix.config.nvencc)}"', + "-i", + f'"{clean_file_string(video.source)}"', + (f"--video-streamid {stream_id}" if stream_id else ""), + trim, + (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), + transform, + (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), + crop, + (f"--video-metadata clear" if video.video_settings.remove_metadata else "--video-metadata copy"), + (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), + ("--chapter-copy" if video.video_settings.copy_chapters else ""), + "-c", + "avc", + (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), + vbv, + (f"--vbr-quality {settings.vbr_target}" if settings.vbr_target is not None and settings.bitrate else ""), + (f"--qp-init {init_q}" if init_q and settings.bitrate else ""), + (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), + (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), + (f"--bframes {settings.b_frames}" if settings.b_frames else ""), + (f"--ref {settings.ref}" if settings.ref else ""), + f"--bref-mode {settings.b_ref_mode}", + "--preset", + settings.preset, + (f"--lookahead {settings.lookahead}" if settings.lookahead else ""), + aq, + "--colormatrix", + (video.video_settings.color_space or "auto"), + "--transfer", + (video.video_settings.color_transfer or "auto"), + "--colorprim", + (video.video_settings.color_primaries or "auto"), + "--multipass", + settings.multipass, + "--mv-precision", + settings.mv_precision, + "--chromaloc", + "auto", + "--colorrange", + "auto", + f"--avsync {'cfr' if video.frame_rate == video.average_frame_rate else 'vfr'}", + (f"--interlace {video.interlaced}" if video.interlaced else ""), + ("--vpp-yadif" if video.video_settings.deinterlace else ""), + (f"--vpp-colorspace hdr2sdr=mobius" if video.video_settings.remove_hdr else ""), + remove_hdr, + "--psnr --ssim" if settings.metrics else "", + build_audio(video.video_settings.audio_tracks, video.streams.audio), + build_subtitle(video.video_settings.subtitle_tracks, video.streams.subtitle), + settings.extra, + "-o", + f'"{clean_file_string(video.video_settings.output_path)}"', + ] + + return [Command(command=" ".join(x for x in command if x), name="NVEncC Encode", exe="NVEncE")] diff --git a/fastflix/encoders/nvencc_avc/main.py b/fastflix/encoders/nvencc_avc/main.py new file mode 100644 index 00000000..a6c34e95 --- /dev/null +++ b/fastflix/encoders/nvencc_avc/main.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +from pathlib import Path + +import pkg_resources + +name = "AVC (NVEncC)" + +video_extension = "mkv" +video_dimension_divisor = 1 +icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_nvencc.png")).resolve()) + +enable_subtitles = True +enable_audio = True +enable_attachments = False + +audio_formats = [ + "aac", + "aac_mf", + "libfdk_aac", + "ac3", + "ac3_fixed", + "ac3_mf", + "adpcm_adx", + "g722", + "g726", + "g726le", + "adpcm_ima_qt", + "adpcm_ima_ssi", + "adpcm_ima_wav", + "adpcm_ms", + "adpcm_swf", + "adpcm_yamaha", + "alac", + "libopencore_amrnb", + "libvo_amrwbenc", + "aptx", + "aptx_hd", + "comfortnoise", + "dca", + "eac3", + "flac", + "g723_1", + "libgsm", + "libgsm_ms", + "libilbc", + "mlp", + "mp2", + "mp2fixed", + "libtwolame", + "mp3_mf", + "libmp3lame", + "nellymoser", + "opus", + "libopus", + "pcm_alaw", + "pcm_dvd", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", + "pcm_mulaw", + "pcm_s16be", + "pcm_s16be_planar", + "pcm_s16le", + "pcm_s16le_planar", + "pcm_s24be", + "pcm_s24daud", + "pcm_s24le", + "pcm_s24le_planar", + "pcm_s32be", + "pcm_s32le", + "pcm_s32le_planar", + "pcm_s64be", + "pcm_s64le", + "pcm_s8", + "pcm_s8_planar", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "pcm_u8", + "pcm_vidc", + "real_144", + "roq_dpcm", + "s302m", + "sbc", + "sonic", + "sonicls", + "libspeex", + "truehd", + "tta", + "vorbis", + "libvorbis", + "wavpack", + "wmav1", + "wmav2", +] + +from fastflix.encoders.nvencc_avc.command_builder import build +from fastflix.encoders.nvencc_avc.settings_panel import NVENCCAVC as settings_panel diff --git a/fastflix/encoders/nvencc_avc/settings_panel.py b/fastflix/encoders/nvencc_avc/settings_panel.py new file mode 100644 index 00000000..0954cd15 --- /dev/null +++ b/fastflix/encoders/nvencc_avc/settings_panel.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +import logging +from typing import List, Optional + +from box import Box +from qtpy import QtCore, QtWidgets, QtGui + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.language import t +from fastflix.models.encode import NVEncCAVCSettings +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.shared import link +from fastflix.exceptions import FastFlixInternalException +from fastflix.resources import loading_movie, warning_icon + +logger = logging.getLogger("fastflix") + +presets = ["default", "performance", "quality"] + +recommended_bitrates = [ + "200k (320x240p @ 30fps)", + "300k (640x360p @ 30fps)", + "1000k (640x480p @ 30fps)", + "1750k (1280x720p @ 30fps)", + "2500k (1280x720p @ 60fps)", + "4000k (1920x1080p @ 30fps)", + "5000k (1920x1080p @ 60fps)", + "7000k (2560x1440p @ 30fps)", + "10000k (2560x1440p @ 60fps)", + "15000k (3840x2160p @ 30fps)", + "20000k (3840x2160p @ 60fps)", + "Custom", +] + +recommended_crfs = [ + "28", + "27", + "26", + "25", + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", + "15", + "14", + "Custom", +] + + +def get_breaker(): + breaker_line = QtWidgets.QWidget() + breaker_line.setMaximumHeight(2) + breaker_line.setStyleSheet("background-color: #ccc; margin: auto 0; padding: auto 0;") + return breaker_line + + +class NVENCCAVC(SettingPanel): + profile_name = "nvencc_avc" + hdr10plus_signal = QtCore.Signal(str) + hdr10plus_ffmpeg_signal = QtCore.Signal(str) + + def __init__(self, parent, main, app: FastFlixApp): + super().__init__(parent, main, app) + self.main = main + self.app = app + + grid = QtWidgets.QGridLayout() + + self.widgets = Box(mode=None) + + self.mode = "Bitrate" + self.updating_settings = False + + grid.addLayout(self.init_modes(), 0, 2, 4, 4) + grid.addLayout(self._add_custom(title="Custom NVEncC options", disable_both_passes=True), 10, 0, 1, 6) + + grid.addLayout(self.init_preset(), 0, 0, 1, 2) + # grid.addLayout(self.init_profile(), 1, 0, 1, 2) + # grid.addLayout(self.init_tier(), 1, 0, 1, 2) + grid.addLayout(self.init_multipass(), 2, 0, 1, 2) + grid.addLayout(self.init_lookahead(), 3, 0, 1, 2) + + breaker = QtWidgets.QHBoxLayout() + breaker_label = QtWidgets.QLabel(t("Advanced")) + breaker_label.setFont(QtGui.QFont("helvetica", 8, weight=55)) + + breaker.addWidget(get_breaker(), stretch=1) + breaker.addWidget(breaker_label, alignment=QtCore.Qt.AlignHCenter) + breaker.addWidget(get_breaker(), stretch=1) + + grid.addLayout(breaker, 4, 0, 1, 6) + + grid.addLayout(self.init_aq(), 5, 0, 1, 2) + grid.addLayout(self.init_aq_strength(), 6, 0, 1, 2) + grid.addLayout(self.init_mv_precision(), 7, 0, 1, 2) + + qp_line = QtWidgets.QHBoxLayout() + qp_line.addLayout(self.init_vbr_target()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_init_q()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_min_q()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_max_q()) + + grid.addLayout(qp_line, 5, 2, 1, 4) + + advanced = QtWidgets.QHBoxLayout() + advanced.addLayout(self.init_ref()) + advanced.addStretch(1) + advanced.addLayout(self.init_b_frames()) + advanced.addStretch(1) + advanced.addLayout(self.init_level()) + advanced.addStretch(1) + advanced.addLayout(self.init_b_ref_mode()) + advanced.addStretch(1) + advanced.addLayout(self.init_metrics()) + grid.addLayout(advanced, 6, 2, 1, 4) + + grid.addLayout(self.init_dhdr10_info(), 7, 2, 1, 4) + + self.ffmpeg_level = QtWidgets.QLabel() + grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4) + + grid.setRowStretch(9, 1) + + guide_label = QtWidgets.QLabel( + link("https://github.com/rigaya/NVEnc/blob/master/NVEncC_Options.en.md", t("NVEncC Options")) + ) + + warning_label = QtWidgets.QLabel() + warning_label.setPixmap(QtGui.QIcon(warning_icon).pixmap(22)) + + guide_label.setAlignment(QtCore.Qt.AlignBottom) + guide_label.setOpenExternalLinks(True) + grid.addWidget(guide_label, 11, 0, 1, 4) + grid.addWidget(warning_label, 11, 4, 1, 1, alignment=QtCore.Qt.AlignRight) + grid.addWidget(QtWidgets.QLabel(t("NVEncC Encoder support is still experimental!")), 11, 5, 1, 1) + + self.setLayout(grid) + self.hide() + self.hdr10plus_signal.connect(self.done_hdr10plus_extract) + self.hdr10plus_ffmpeg_signal.connect(lambda x: self.ffmpeg_level.setText(x)) + + def init_preset(self): + return self._add_combo_box( + label="Preset", + widget_name="preset", + options=presets, + tooltip="preset: The slower the preset, the better the compression and quality", + connect="default", + opt="preset", + ) + + def init_tune(self): + return self._add_combo_box( + label="Tune", + widget_name="tune", + tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", + options=["hq", "ll", "ull", "lossless"], + opt="tune", + ) + + # def init_profile(self): + # # TODO auto + # return self._add_combo_box( + # label="Profile_encoderopt", + # widget_name="profile", + # tooltip="Enforce an encode profile", + # options=["main", "main10"], + # opt="profile", + # ) + + # def init_tier(self): + # return self._add_combo_box( + # label="Tier", + # tooltip="Set the encoding tier", + # widget_name="tier", + # options=["main", "high"], + # opt="tier", + # ) + + def init_aq(self): + return self._add_combo_box( + label="Adaptive Quantization", + tooltip="", + widget_name="aq", + options=["off", "spatial", "temporal"], + opt="aq", + ) + + def init_aq_strength(self): + return self._add_combo_box( + label="AQ Strength", + tooltip="", + widget_name="aq_strength", + options=["Auto"] + [str(x) for x in range(1, 16)], + opt="aq_strength", + ) + + def init_multipass(self): + return self._add_combo_box( + label="Multipass", + tooltip="", + widget_name="multipass", + options=["None", "2pass-quarter", "2pass-full"], + opt="multipass", + ) + + def init_mv_precision(self): + return self._add_combo_box( + label="Motion vector accuracy", + tooltip="Q-pel is highest precision", + widget_name="mv_precision", + options=["Auto", "Q-pel", "half-pel", "full-pel"], + opt="mv_precision", + ) + + def init_lookahead(self): + return self._add_combo_box( + label="Lookahead", + tooltip="", + widget_name="lookahead", + opt="lookahead", + options=["off"] + [str(x) for x in range(1, 33)], + ) + + def init_level(self): + layout = self._add_combo_box( + label="Level", + tooltip="Set the encoding level restriction", + widget_name="level", + options=[ + t("Auto"), + "1.0", + "2.0", + "2.1", + "3.0", + "3.1", + "4.0", + "4.1", + "5.0", + "5.1", + "5.2", + "6.0", + "6.1", + "6.2", + ], + opt="level", + ) + self.widgets.level.setMinimumWidth(60) + return layout + + def init_b_ref_mode(self): + layout = self._add_combo_box( + label="B Ref Mode", + tooltip="Use B frames as references", + widget_name="b_ref_mode", + opt="b_ref_mode", + options=["disabled", "each", "middle"], + min_width=60, + ) + return layout + + @staticmethod + def _qp_range(): + return [str(x) for x in range(0, 52)] + + def init_min_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Min Q"))) + layout.addWidget( + self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), min_width=45, opt="min_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), min_width=45, opt="min_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), min_width=45, opt="min_q_b") + ) + return layout + + def init_init_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Init Q"))) + layout.addWidget( + self._add_combo_box(widget_name="init_q_i", options=["I"] + self._qp_range(), min_width=45, opt="init_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="init_q_p", options=["P"] + self._qp_range(), min_width=45, opt="init_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="init_q_b", options=["B"] + self._qp_range(), min_width=45, opt="init_q_b") + ) + return layout + + def init_max_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Max Q"))) + layout.addWidget( + self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), min_width=45, opt="max_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), min_width=45, opt="max_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), min_width=45, opt="max_q_b") + ) + return layout + + def init_vbr_target(self): + return self._add_combo_box( + widget_name="vbr_target", + label="VBR Target", + options=[t("Auto")] + self._qp_range(), + opt="vbr_target", + min_width=60, + ) + + def init_b_frames(self): + return self._add_combo_box( + widget_name="b_frames", + label="B Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="b_frames", + min_width=60, + ) + + def init_ref(self): + return self._add_combo_box( + widget_name="ref", + label="Ref Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="ref", + min_width=60, + ) + + def init_metrics(self): + return self._add_check_box( + widget_name="metrics", + opt="metrics", + label="Metrics", + tooltip="Calculate PSNR and SSIM and show in the encoder output", + ) + + def init_dhdr10_info(self): + layout = self._add_file_select( + label="HDR10+ Metadata", + widget_name="hdr10plus_metadata", + button_action=lambda: self.dhdr10_update(), + tooltip="dhdr10_info: Path to HDR10+ JSON metadata file", + ) + self.labels["hdr10plus_metadata"].setFixedWidth(200) + self.extract_button = QtWidgets.QPushButton(t("Extract HDR10+")) + self.extract_button.hide() + self.extract_button.clicked.connect(self.extract_hdr10plus) + + self.extract_label = QtWidgets.QLabel(self) + self.extract_label.hide() + self.movie = QtGui.QMovie(loading_movie) + self.movie.setScaledSize(QtCore.QSize(25, 25)) + self.extract_label.setMovie(self.movie) + + layout.addWidget(self.extract_button) + layout.addWidget(self.extract_label) + + return layout + + def init_modes(self): + layout = self._add_modes(recommended_bitrates, recommended_crfs, qp_name="cqp") + return layout + + def mode_update(self): + self.widgets.custom_cqp.setDisabled(self.widgets.cqp.currentText() != "Custom") + self.widgets.custom_bitrate.setDisabled(self.widgets.bitrate.currentText() != "Custom") + self.main.build_commands() + + def setting_change(self, update=True): + if self.updating_settings: + return + self.updating_settings = True + + if update: + self.main.page_update() + self.updating_settings = False + + def update_video_encoder_settings(self): + settings = NVEncCAVCSettings( + preset=self.widgets.preset.currentText().split("-")[0].strip(), + # profile=self.widgets.profile.currentText(), + # tier=self.widgets.tier.currentText(), + lookahead=self.widgets.lookahead.currentIndex() if self.widgets.lookahead.currentIndex() > 0 else None, + aq=self.widgets.aq.currentText(), + aq_strength=self.widgets.aq_strength.currentIndex(), + hdr10plus_metadata=self.widgets.hdr10plus_metadata.text().strip(), # .replace("\\", "/"), + multipass=self.widgets.multipass.currentText(), + mv_precision=self.widgets.mv_precision.currentText(), + init_q_i=self.widgets.init_q_i.currentText() if self.widgets.init_q_i.currentIndex() != 0 else None, + init_q_p=self.widgets.init_q_p.currentText() if self.widgets.init_q_p.currentIndex() != 0 else None, + init_q_b=self.widgets.init_q_b.currentText() if self.widgets.init_q_b.currentIndex() != 0 else None, + max_q_i=self.widgets.max_q_i.currentText() if self.widgets.max_q_i.currentIndex() != 0 else None, + max_q_p=self.widgets.max_q_p.currentText() if self.widgets.max_q_p.currentIndex() != 0 else None, + max_q_b=self.widgets.max_q_b.currentText() if self.widgets.max_q_b.currentIndex() != 0 else None, + min_q_i=self.widgets.min_q_i.currentText() if self.widgets.min_q_i.currentIndex() != 0 else None, + min_q_p=self.widgets.min_q_p.currentText() if self.widgets.min_q_p.currentIndex() != 0 else None, + min_q_b=self.widgets.min_q_b.currentText() if self.widgets.min_q_b.currentIndex() != 0 else None, + extra=self.ffmpeg_extras, + metrics=self.widgets.metrics.isChecked(), + level=self.widgets.level.currentText() if self.widgets.level.currentIndex() != 0 else None, + b_frames=self.widgets.b_frames.currentText() if self.widgets.b_frames.currentIndex() != 0 else None, + ref=self.widgets.ref.currentText() if self.widgets.ref.currentIndex() != 0 else None, + vbr_target=self.widgets.vbr_target.currentText() if self.widgets.vbr_target.currentIndex() > 0 else None, + b_ref_mode=self.widgets.b_ref_mode.currentText(), + ) + + encode_type, q_value = self.get_mode_settings() + settings.cqp = q_value if encode_type == "qp" else None + settings.bitrate = q_value if encode_type == "bitrate" else None + self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + + def set_mode(self, x): + self.mode = x.text() + for group in ("init", "max", "min"): + for frame_type in ("i", "p", "b"): + self.widgets[f"{group}_q_{frame_type}"].setEnabled(self.mode.lower() == "bitrate") + self.widgets.vbr_target.setEnabled(self.mode.lower() == "bitrate") + self.main.build_commands() + + def new_source(self): + if not self.app.fastflix.current_video: + return + super().new_source() + if self.app.fastflix.current_video.hdr10_plus: + self.extract_button.show() + else: + self.extract_button.hide() diff --git a/fastflix/encoders/nvencc_hevc/__init__.py b/fastflix/encoders/nvencc_hevc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py new file mode 100644 index 00000000..7b088468 --- /dev/null +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +import logging + +from fastflix.encoders.common.helpers import Command +from fastflix.models.encode import NVEncCSettings +from fastflix.models.video import Video +from fastflix.models.fastflix import FastFlix +from fastflix.encoders.common.nvencc_helpers import build_subtitle, build_audio +from fastflix.flix import clean_file_string + +logger = logging.getLogger("fastflix") + + +def build(fastflix: FastFlix): + video: Video = fastflix.current_video + settings: NVEncCSettings = fastflix.current_video.video_settings.video_encoder_settings + + master_display = None + if fastflix.current_video.master_display: + master_display = ( + f'--master-display "G{fastflix.current_video.master_display.green}' + f"B{fastflix.current_video.master_display.blue}" + f"R{fastflix.current_video.master_display.red}" + f"WP{fastflix.current_video.master_display.white}" + f'L{fastflix.current_video.master_display.luminance}"' + ) + + max_cll = None + if fastflix.current_video.cll: + max_cll = f'--max-cll "{fastflix.current_video.cll}"' + + dhdr = None + if settings.hdr10plus_metadata: + dhdr = f'--dhdr10-info "{settings.hdr10plus_metadata}"' + + trim = "" + try: + if "/" in video.frame_rate: + over, under = [int(x) for x in video.frame_rate.split("/")] + rate = over / under + else: + rate = float(video.frame_rate) + except Exception: + logger.exception("Could not get framerate of this movie!") + else: + if video.video_settings.end_time: + end_frame = int(video.video_settings.end_time * rate) + start_frame = 0 + if video.video_settings.start_time: + start_frame = int(video.video_settings.start_time * rate) + trim = f"--trim {start_frame}:{end_frame}" + elif video.video_settings.start_time: + trim = f"--seek {video.video_settings.start_time}" + + if (video.frame_rate != video.average_frame_rate) and trim: + logger.warning("Cannot use 'trim' when working with variable frame rate videos") + trim = "" + + transform = "" + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" + + remove_hdr = "" + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + + crop = "" + if video.video_settings.crop: + crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + + vbv = "" + if video.video_settings.maxrate: + vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + + init_q = settings.init_q_i + if settings.init_q_i and settings.init_q_p and settings.init_q_b: + init_q = f"{settings.init_q_i}:{settings.init_q_p}:{settings.init_q_b}" + + min_q = settings.min_q_i + if settings.min_q_i and settings.min_q_p and settings.min_q_b: + min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + + max_q = settings.max_q_i + if settings.max_q_i and settings.max_q_p and settings.max_q_b: + max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None + + aq = "--no-aq" + if settings.aq.lower() == "spatial": + aq = f"--aq --aq-strength {settings.aq_strength}" + elif settings.aq.lower() == "temporal": + aq = f"--aq-temporal --aq-strength {settings.aq_strength}" + + command = [ + f'"{clean_file_string(fastflix.config.nvencc)}"', + "-i", + f'"{clean_file_string(video.source)}"', + (f"--video-streamid {stream_id}" if stream_id else ""), + trim, + (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), + transform, + (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), + crop, + (f"--video-metadata clear" if video.video_settings.remove_metadata else "--video-metadata copy"), + (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), + ("--chapter-copy" if video.video_settings.copy_chapters else ""), + "-c", + "hevc", + (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), + vbv, + (f"--vbr-quality {settings.vbr_target}" if settings.vbr_target is not None and settings.bitrate else ""), + (f"--qp-init {init_q}" if init_q and settings.bitrate else ""), + (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), + (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), + (f"--bframes {settings.b_frames}" if settings.b_frames else ""), + (f"--ref {settings.ref}" if settings.ref else ""), + f"--bref-mode {settings.b_ref_mode}", + "--preset", + settings.preset, + "--tier", + settings.tier, + (f"--lookahead {settings.lookahead}" if settings.lookahead else ""), + aq, + "--colormatrix", + (video.video_settings.color_space or "auto"), + "--transfer", + (video.video_settings.color_transfer or "auto"), + "--colorprim", + (video.video_settings.color_primaries or "auto"), + (master_display if master_display else ""), + (max_cll if max_cll else ""), + (dhdr if dhdr else ""), + "--output-depth", + ("10" if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr else "8"), + "--multipass", + settings.multipass, + "--mv-precision", + settings.mv_precision, + "--chromaloc", + "auto", + "--colorrange", + "auto", + f"--avsync {'cfr' if video.frame_rate == video.average_frame_rate else 'vfr'}", + (f"--interlace {video.interlaced}" if video.interlaced else ""), + ("--vpp-yadif" if video.video_settings.deinterlace else ""), + (f"--vpp-colorspace hdr2sdr=mobius" if video.video_settings.remove_hdr else ""), + remove_hdr, + "--psnr --ssim" if settings.metrics else "", + build_audio(video.video_settings.audio_tracks, video.streams.audio), + build_subtitle(video.video_settings.subtitle_tracks, video.streams.subtitle), + settings.extra, + "-o", + f'"{clean_file_string(video.video_settings.output_path)}"', + ] + + return [Command(command=" ".join(x for x in command if x), name="NVEncC Encode", exe="NVEncE")] diff --git a/fastflix/encoders/nvencc_hevc/main.py b/fastflix/encoders/nvencc_hevc/main.py new file mode 100644 index 00000000..3bfabe9d --- /dev/null +++ b/fastflix/encoders/nvencc_hevc/main.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +from pathlib import Path + +import pkg_resources + +name = "HEVC (NVEncC)" + +video_extension = "mkv" +video_dimension_divisor = 1 +icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_nvencc.png")).resolve()) + +enable_subtitles = True +enable_audio = True +enable_attachments = False + +audio_formats = [ + "aac", + "aac_mf", + "libfdk_aac", + "ac3", + "ac3_fixed", + "ac3_mf", + "adpcm_adx", + "g722", + "g726", + "g726le", + "adpcm_ima_qt", + "adpcm_ima_ssi", + "adpcm_ima_wav", + "adpcm_ms", + "adpcm_swf", + "adpcm_yamaha", + "alac", + "libopencore_amrnb", + "libvo_amrwbenc", + "aptx", + "aptx_hd", + "comfortnoise", + "dca", + "eac3", + "flac", + "g723_1", + "libgsm", + "libgsm_ms", + "libilbc", + "mlp", + "mp2", + "mp2fixed", + "libtwolame", + "mp3_mf", + "libmp3lame", + "nellymoser", + "opus", + "libopus", + "pcm_alaw", + "pcm_dvd", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", + "pcm_mulaw", + "pcm_s16be", + "pcm_s16be_planar", + "pcm_s16le", + "pcm_s16le_planar", + "pcm_s24be", + "pcm_s24daud", + "pcm_s24le", + "pcm_s24le_planar", + "pcm_s32be", + "pcm_s32le", + "pcm_s32le_planar", + "pcm_s64be", + "pcm_s64le", + "pcm_s8", + "pcm_s8_planar", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "pcm_u8", + "pcm_vidc", + "real_144", + "roq_dpcm", + "s302m", + "sbc", + "sonic", + "sonicls", + "libspeex", + "truehd", + "tta", + "vorbis", + "libvorbis", + "wavpack", + "wmav1", + "wmav2", +] + +from fastflix.encoders.nvencc_hevc.command_builder import build +from fastflix.encoders.nvencc_hevc.settings_panel import NVENCC as settings_panel diff --git a/fastflix/encoders/nvencc_hevc/settings_panel.py b/fastflix/encoders/nvencc_hevc/settings_panel.py new file mode 100644 index 00000000..19dc151c --- /dev/null +++ b/fastflix/encoders/nvencc_hevc/settings_panel.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +import logging +from typing import List, Optional + +from box import Box +from qtpy import QtCore, QtWidgets, QtGui + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.language import t +from fastflix.models.encode import NVEncCSettings +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.shared import link +from fastflix.exceptions import FastFlixInternalException +from fastflix.resources import loading_movie, warning_icon + +logger = logging.getLogger("fastflix") + +presets = ["default", "performance", "quality"] + +recommended_bitrates = [ + "200k (320x240p @ 30fps)", + "300k (640x360p @ 30fps)", + "1000k (640x480p @ 30fps)", + "1750k (1280x720p @ 30fps)", + "2500k (1280x720p @ 60fps)", + "4000k (1920x1080p @ 30fps)", + "5000k (1920x1080p @ 60fps)", + "7000k (2560x1440p @ 30fps)", + "10000k (2560x1440p @ 60fps)", + "15000k (3840x2160p @ 30fps)", + "20000k (3840x2160p @ 60fps)", + "Custom", +] + +recommended_crfs = [ + "28", + "27", + "26", + "25", + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", + "15", + "14", + "Custom", +] + + +def get_breaker(): + breaker_line = QtWidgets.QWidget() + breaker_line.setMaximumHeight(2) + breaker_line.setStyleSheet("background-color: #ccc; margin: auto 0; padding: auto 0;") + return breaker_line + + +class NVENCC(SettingPanel): + profile_name = "nvencc_hevc" + hdr10plus_signal = QtCore.Signal(str) + hdr10plus_ffmpeg_signal = QtCore.Signal(str) + + def __init__(self, parent, main, app: FastFlixApp): + super().__init__(parent, main, app) + self.main = main + self.app = app + + grid = QtWidgets.QGridLayout() + + self.widgets = Box(mode=None) + + self.mode = "Bitrate" + self.updating_settings = False + + grid.addLayout(self.init_modes(), 0, 2, 4, 4) + grid.addLayout(self._add_custom(title="Custom NVEncC options", disable_both_passes=True), 10, 0, 1, 6) + + grid.addLayout(self.init_preset(), 0, 0, 1, 2) + # grid.addLayout(self.init_profile(), 1, 0, 1, 2) + grid.addLayout(self.init_tier(), 1, 0, 1, 2) + grid.addLayout(self.init_multipass(), 2, 0, 1, 2) + grid.addLayout(self.init_lookahead(), 3, 0, 1, 2) + + breaker = QtWidgets.QHBoxLayout() + breaker_label = QtWidgets.QLabel(t("Advanced")) + breaker_label.setFont(QtGui.QFont("helvetica", 8, weight=55)) + + breaker.addWidget(get_breaker(), stretch=1) + breaker.addWidget(breaker_label, alignment=QtCore.Qt.AlignHCenter) + breaker.addWidget(get_breaker(), stretch=1) + + grid.addLayout(breaker, 4, 0, 1, 6) + + grid.addLayout(self.init_aq(), 5, 0, 1, 2) + grid.addLayout(self.init_aq_strength(), 6, 0, 1, 2) + grid.addLayout(self.init_mv_precision(), 7, 0, 1, 2) + + qp_line = QtWidgets.QHBoxLayout() + qp_line.addLayout(self.init_vbr_target()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_init_q()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_min_q()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_max_q()) + + grid.addLayout(qp_line, 5, 2, 1, 4) + + advanced = QtWidgets.QHBoxLayout() + advanced.addLayout(self.init_ref()) + advanced.addStretch(1) + advanced.addLayout(self.init_b_frames()) + advanced.addStretch(1) + advanced.addLayout(self.init_level()) + advanced.addStretch(1) + advanced.addLayout(self.init_b_ref_mode()) + advanced.addStretch(1) + advanced.addLayout(self.init_metrics()) + grid.addLayout(advanced, 6, 2, 1, 4) + + grid.addLayout(self.init_dhdr10_info(), 7, 2, 1, 4) + + self.ffmpeg_level = QtWidgets.QLabel() + grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4) + + grid.setRowStretch(9, 1) + + guide_label = QtWidgets.QLabel( + link("https://github.com/rigaya/NVEnc/blob/master/NVEncC_Options.en.md", t("NVEncC Options")) + ) + + warning_label = QtWidgets.QLabel() + warning_label.setPixmap(QtGui.QIcon(warning_icon).pixmap(22)) + + guide_label.setAlignment(QtCore.Qt.AlignBottom) + guide_label.setOpenExternalLinks(True) + grid.addWidget(guide_label, 11, 0, 1, 4) + grid.addWidget(warning_label, 11, 4, 1, 1, alignment=QtCore.Qt.AlignRight) + grid.addWidget(QtWidgets.QLabel(t("NVEncC Encoder support is still experimental!")), 11, 5, 1, 1) + + self.setLayout(grid) + self.hide() + self.hdr10plus_signal.connect(self.done_hdr10plus_extract) + self.hdr10plus_ffmpeg_signal.connect(lambda x: self.ffmpeg_level.setText(x)) + + def init_preset(self): + return self._add_combo_box( + label="Preset", + widget_name="preset", + options=presets, + tooltip="preset: The slower the preset, the better the compression and quality", + connect="default", + opt="preset", + ) + + def init_tune(self): + return self._add_combo_box( + label="Tune", + widget_name="tune", + tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", + options=["hq", "ll", "ull", "lossless"], + opt="tune", + ) + + # def init_profile(self): + # # TODO auto + # return self._add_combo_box( + # label="Profile_encoderopt", + # widget_name="profile", + # tooltip="Enforce an encode profile", + # options=["main", "main10"], + # opt="profile", + # ) + + def init_tier(self): + return self._add_combo_box( + label="Tier", + tooltip="Set the encoding tier", + widget_name="tier", + options=["main", "high"], + opt="tier", + ) + + def init_aq(self): + return self._add_combo_box( + label="Adaptive Quantization", + tooltip="", + widget_name="aq", + options=["off", "spatial", "temporal"], + opt="aq", + ) + + def init_aq_strength(self): + return self._add_combo_box( + label="AQ Strength", + tooltip="", + widget_name="aq_strength", + options=["Auto"] + [str(x) for x in range(1, 16)], + opt="aq_strength", + ) + + def init_multipass(self): + return self._add_combo_box( + label="Multipass", + tooltip="", + widget_name="multipass", + options=["None", "2pass-quarter", "2pass-full"], + opt="multipass", + ) + + def init_mv_precision(self): + return self._add_combo_box( + label="Motion vector accuracy", + tooltip="Q-pel is highest precision", + widget_name="mv_precision", + options=["Auto", "Q-pel", "half-pel", "full-pel"], + opt="mv_precision", + ) + + def init_lookahead(self): + return self._add_combo_box( + label="Lookahead", + tooltip="", + widget_name="lookahead", + opt="lookahead", + options=["off"] + [str(x) for x in range(1, 33)], + ) + + def init_level(self): + layout = self._add_combo_box( + label="Level", + tooltip="Set the encoding level restriction", + widget_name="level", + options=[ + t("Auto"), + "1.0", + "2.0", + "2.1", + "3.0", + "3.1", + "4.0", + "4.1", + "5.0", + "5.1", + "5.2", + "6.0", + "6.1", + "6.2", + ], + opt="level", + ) + self.widgets.level.setMinimumWidth(60) + return layout + + def init_b_ref_mode(self): + layout = self._add_combo_box( + label="B Ref Mode", + tooltip="Use B frames as references", + widget_name="b_ref_mode", + opt="b_ref_mode", + options=["disabled", "each", "middle"], + min_width=60, + ) + return layout + + @staticmethod + def _qp_range(): + return [str(x) for x in range(0, 52)] + + def init_min_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Min Q"))) + layout.addWidget( + self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), min_width=45, opt="min_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), min_width=45, opt="min_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), min_width=45, opt="min_q_b") + ) + return layout + + def init_init_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Init Q"))) + layout.addWidget( + self._add_combo_box(widget_name="init_q_i", options=["I"] + self._qp_range(), min_width=45, opt="init_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="init_q_p", options=["P"] + self._qp_range(), min_width=45, opt="init_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="init_q_b", options=["B"] + self._qp_range(), min_width=45, opt="init_q_b") + ) + return layout + + def init_max_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Max Q"))) + layout.addWidget( + self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), min_width=45, opt="max_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), min_width=45, opt="max_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), min_width=45, opt="max_q_b") + ) + return layout + + def init_vbr_target(self): + return self._add_combo_box( + widget_name="vbr_target", + label="VBR Target", + options=[t("Auto")] + self._qp_range(), + opt="vbr_target", + min_width=60, + ) + + def init_b_frames(self): + return self._add_combo_box( + widget_name="b_frames", + label="B Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="b_frames", + min_width=60, + ) + + def init_ref(self): + return self._add_combo_box( + widget_name="ref", + label="Ref Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="ref", + min_width=60, + ) + + def init_metrics(self): + return self._add_check_box( + widget_name="metrics", + opt="metrics", + label="Metrics", + tooltip="Calculate PSNR and SSIM and show in the encoder output", + ) + + def init_dhdr10_info(self): + layout = self._add_file_select( + label="HDR10+ Metadata", + widget_name="hdr10plus_metadata", + button_action=lambda: self.dhdr10_update(), + tooltip="dhdr10_info: Path to HDR10+ JSON metadata file", + ) + self.labels["hdr10plus_metadata"].setFixedWidth(200) + self.extract_button = QtWidgets.QPushButton(t("Extract HDR10+")) + self.extract_button.hide() + self.extract_button.clicked.connect(self.extract_hdr10plus) + + self.extract_label = QtWidgets.QLabel(self) + self.extract_label.hide() + self.movie = QtGui.QMovie(loading_movie) + self.movie.setScaledSize(QtCore.QSize(25, 25)) + self.extract_label.setMovie(self.movie) + + layout.addWidget(self.extract_button) + layout.addWidget(self.extract_label) + + return layout + + def init_modes(self): + layout = self._add_modes(recommended_bitrates, recommended_crfs, qp_name="cqp") + return layout + + def mode_update(self): + self.widgets.custom_cqp.setDisabled(self.widgets.cqp.currentText() != "Custom") + self.widgets.custom_bitrate.setDisabled(self.widgets.bitrate.currentText() != "Custom") + self.main.build_commands() + + def setting_change(self, update=True): + if self.updating_settings: + return + self.updating_settings = True + + if update: + self.main.page_update() + self.updating_settings = False + + def update_video_encoder_settings(self): + settings = NVEncCSettings( + preset=self.widgets.preset.currentText().split("-")[0].strip(), + # profile=self.widgets.profile.currentText(), + tier=self.widgets.tier.currentText(), + lookahead=self.widgets.lookahead.currentIndex() if self.widgets.lookahead.currentIndex() > 0 else None, + aq=self.widgets.aq.currentText(), + aq_strength=self.widgets.aq_strength.currentIndex(), + hdr10plus_metadata=self.widgets.hdr10plus_metadata.text().strip(), # .replace("\\", "/"), + multipass=self.widgets.multipass.currentText(), + mv_precision=self.widgets.mv_precision.currentText(), + init_q_i=self.widgets.init_q_i.currentText() if self.widgets.init_q_i.currentIndex() != 0 else None, + init_q_p=self.widgets.init_q_p.currentText() if self.widgets.init_q_p.currentIndex() != 0 else None, + init_q_b=self.widgets.init_q_b.currentText() if self.widgets.init_q_b.currentIndex() != 0 else None, + max_q_i=self.widgets.max_q_i.currentText() if self.widgets.max_q_i.currentIndex() != 0 else None, + max_q_p=self.widgets.max_q_p.currentText() if self.widgets.max_q_p.currentIndex() != 0 else None, + max_q_b=self.widgets.max_q_b.currentText() if self.widgets.max_q_b.currentIndex() != 0 else None, + min_q_i=self.widgets.min_q_i.currentText() if self.widgets.min_q_i.currentIndex() != 0 else None, + min_q_p=self.widgets.min_q_p.currentText() if self.widgets.min_q_p.currentIndex() != 0 else None, + min_q_b=self.widgets.min_q_b.currentText() if self.widgets.min_q_b.currentIndex() != 0 else None, + extra=self.ffmpeg_extras, + metrics=self.widgets.metrics.isChecked(), + level=self.widgets.level.currentText() if self.widgets.level.currentIndex() != 0 else None, + b_frames=self.widgets.b_frames.currentText() if self.widgets.b_frames.currentIndex() != 0 else None, + ref=self.widgets.ref.currentText() if self.widgets.ref.currentIndex() != 0 else None, + vbr_target=self.widgets.vbr_target.currentText() if self.widgets.vbr_target.currentIndex() > 0 else None, + b_ref_mode=self.widgets.b_ref_mode.currentText(), + ) + + encode_type, q_value = self.get_mode_settings() + settings.cqp = q_value if encode_type == "qp" else None + settings.bitrate = q_value if encode_type == "bitrate" else None + self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + + def set_mode(self, x): + self.mode = x.text() + for group in ("init", "max", "min"): + for frame_type in ("i", "p", "b"): + self.widgets[f"{group}_q_{frame_type}"].setEnabled(self.mode.lower() == "bitrate") + self.widgets.vbr_target.setEnabled(self.mode.lower() == "bitrate") + self.main.build_commands() + + def new_source(self): + if not self.app.fastflix.current_video: + return + super().new_source() + if self.app.fastflix.current_video.hdr10_plus: + self.extract_button.show() + else: + self.extract_button.hide() diff --git a/fastflix/encoders/rav1e/command_builder.py b/fastflix/encoders/rav1e/command_builder.py index 6d6d62aa..2d414163 100644 --- a/fastflix/encoders/rav1e/command_builder.py +++ b/fastflix/encoders/rav1e/command_builder.py @@ -45,8 +45,6 @@ def build(fastflix: FastFlix): # opts = ":".join(rav1e_options) # beginning += f'-rav1e-params "{opts}"' - beginning = re.sub("[ ]+", " ", beginning) - if not settings.single_pass: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" beginning += f'-passlogfile "{pass_log_file}" ' diff --git a/fastflix/encoders/rav1e/settings_panel.py b/fastflix/encoders/rav1e/settings_panel.py index 6b74e8e7..73867b60 100644 --- a/fastflix/encoders/rav1e/settings_panel.py +++ b/fastflix/encoders/rav1e/settings_panel.py @@ -106,10 +106,12 @@ def init_tile_columns(self): ) def init_tiles(self): - return self._add_combo_box("Tiles", [str(x) for x in range(-1, 17)], "tiles", opt="tiles") + return self._add_combo_box( + label="Tiles", options=[str(x) for x in range(-1, 17)], widget_name="tiles", opt="tiles" + ) def init_single_pass(self): - return self._add_check_box("Single Pass (Bitrate)", "single_pass", opt="single_pass") + return self._add_check_box(label="Single Pass (Bitrate)", widget_name="single_pass", opt="single_pass") def init_pix_fmt(self): return self._add_combo_box( diff --git a/fastflix/encoders/svt_av1/command_builder.py b/fastflix/encoders/svt_av1/command_builder.py index de7e14e4..f8d97272 100644 --- a/fastflix/encoders/svt_av1/command_builder.py +++ b/fastflix/encoders/svt_av1/command_builder.py @@ -28,8 +28,6 @@ def build(fastflix: FastFlix): f"{generate_color_details(fastflix)} " ) - beginning = re.sub("[ ]+", " ", beginning) - if not settings.single_pass: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" beginning += f'-passlogfile "{pass_log_file}" ' diff --git a/fastflix/encoders/vp9/command_builder.py b/fastflix/encoders/vp9/command_builder.py index 5f6783cd..1ebe86af 100644 --- a/fastflix/encoders/vp9/command_builder.py +++ b/fastflix/encoders/vp9/command_builder.py @@ -22,18 +22,18 @@ def build(fastflix: FastFlix): # if fastflix.current_video.color_space.startswith("bt2020"): # beginning += "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc -color_range 1" - beginning = re.sub("[ ]+", " ", beginning) - - details = f"-quality {settings.quality} -speed {settings.speed} -profile:v {settings.profile}" + details = f"-quality:v {settings.quality} -profile:v {settings.profile} -tile-columns:v {settings.tile_columns} -tile-rows:v {settings.tile_rows} " if settings.bitrate: - command_1 = f"{beginning} -b:v {settings.bitrate} {details} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f webm {null}" - command_2 = f"{beginning} -b:v {settings.bitrate} {details} -pass 2 {settings.extra} {ending}" + command_1 = f"{beginning} -speed:v {'4' if settings.fast_first_pass else settings.speed} -b:v {settings.bitrate} {details} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f webm {null}" + command_2 = ( + f"{beginning} -speed:v {settings.speed} -b:v {settings.bitrate} {details} -pass 2 {settings.extra} {ending}" + ) elif settings.crf: - command_1 = f"{beginning} -b:v 0 -crf {settings.crf} {details} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f webm {null}" + command_1 = f"{beginning} -b:v 0 -crf:v {settings.crf} {details} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f webm {null}" command_2 = ( - f"{beginning} -b:v 0 -crf {settings.crf} {details} " + f"{beginning} -b:v 0 -crf:v {settings.crf} {details} " f'{"-pass 2" if not settings.single_pass else ""} {settings.extra} {ending}' ) diff --git a/fastflix/encoders/vp9/main.py b/fastflix/encoders/vp9/main.py index 62459333..dc1ad009 100644 --- a/fastflix/encoders/vp9/main.py +++ b/fastflix/encoders/vp9/main.py @@ -10,8 +10,8 @@ icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_vp9.png")).resolve()) -video_extension = "webm" -video_dimension_divisor = 1 +video_extension = "mkv" +video_dimension_divisor = 2 enable_subtitles = False enable_audio = True diff --git a/fastflix/encoders/vp9/settings_panel.py b/fastflix/encoders/vp9/settings_panel.py index 6c426468..8f72e494 100644 --- a/fastflix/encoders/vp9/settings_panel.py +++ b/fastflix/encoders/vp9/settings_panel.py @@ -61,9 +61,19 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_max_mux(), 3, 0, 1, 2) grid.addLayout(self.init_profile(), 4, 0, 1, 2) + grid.addLayout(self.init_tile_columns(), 5, 0, 1, 2) + grid.addLayout(self.init_tile_rows(), 6, 0, 1, 2) + grid.addLayout(self.init_modes(), 0, 2, 5, 4) - grid.addLayout(self.init_single_pass(), 5, 2, 1, 2) - grid.addLayout(self.init_row_mt(), 5, 4, 1, 2) + + checkboxes = QtWidgets.QHBoxLayout() + checkboxes.addLayout(self.init_single_pass()) + checkboxes.addStretch(1) + checkboxes.addLayout(self.init_row_mt()) + checkboxes.addStretch(1) + checkboxes.addLayout(self.init_fast_first_pass()) + + grid.addLayout(checkboxes, 5, 2, 1, 4) # grid.addWidget(QtWidgets.QWidget(), 8, 0) grid.setRowStretch(8, 1) @@ -138,6 +148,32 @@ def init_row_mt(self): opt="row_mt", ) + def init_tile_columns(self): + return self._add_combo_box( + label="Tile Columns", + tooltip="Log2 of number of tile columns to encode faster (lesser quality)", + widget_name="tile_columns", + options=[str(x) for x in range(-1, 7)], + opt="tile_columns", + ) + + def init_tile_rows(self): + return self._add_combo_box( + label="Tile Rows", + tooltip="Log2 of number of tile rows to encode faster (lesser quality)", + widget_name="tile_rows", + options=[str(x) for x in range(-1, 3)], + opt="tile_rows", + ) + + def init_fast_first_pass(self): + return self._add_check_box( + label="Fast first pass", + tooltip="Set speed to 4 for first pass", + widget_name="fast_first_pass", + opt="fast_first_pass", + ) + def init_single_pass(self): return self._add_check_box(label="Single Pass (CRF)", tooltip="", widget_name="single_pass", opt="single_pass") @@ -160,6 +196,11 @@ def update_video_encoder_settings(self): profile=self.widgets.profile.currentIndex(), extra=self.ffmpeg_extras, extra_both_passes=self.widgets.extra_both_passes.isChecked(), + fast_first_pass=self.widgets.fast_first_pass.isChecked(), + tile_columns=self.widgets.tile_columns.currentText() + if self.widgets.tile_columns.currentIndex() > 0 + else "-1", + tile_rows=self.widgets.tile_rows.currentText() if self.widgets.tile_rows.currentIndex() > 0 else "-1", ) encode_type, q_value = self.get_mode_settings() settings.crf = q_value if encode_type == "qp" else None diff --git a/fastflix/encoders/webp/settings_panel.py b/fastflix/encoders/webp/settings_panel.py index 1945962e..e2296b6c 100644 --- a/fastflix/encoders/webp/settings_panel.py +++ b/fastflix/encoders/webp/settings_panel.py @@ -31,20 +31,23 @@ def __init__(self, parent, main, app: FastFlixApp): self.setLayout(grid) def init_lossless(self): - return self._add_combo_box("lossless", ["yes", "no"], "lossless", default=1) + return self._add_combo_box(label="lossless", options=["yes", "no"], widget_name="lossless", default=1) def init_compression(self): return self._add_combo_box( - "compression level", - ["0", "1", "2", "3", "4", "5", "6"], - "compression", + label="compression level", + options=["0", "1", "2", "3", "4", "5", "6"], + widget_name="compression", tooltip="For lossy, this is a quality/speed tradeoff.\nFor lossless, this is a size/speed tradeoff.", default=4, ) def init_preset(self): return self._add_combo_box( - "preset", ["none", "default", "picture", "photo", "drawing", "icon", "text"], "preset", default=1 + label="preset", + options=["none", "default", "picture", "photo", "drawing", "icon", "text"], + widget_name="preset", + default=1, ) def init_modes(self): diff --git a/fastflix/entry.py b/fastflix/entry.py index c1aeda9a..881284e7 100644 --- a/fastflix/entry.py +++ b/fastflix/entry.py @@ -2,7 +2,7 @@ import logging import sys import traceback -from multiprocessing import Process, Queue, freeze_support +from multiprocessing import Process, Queue, freeze_support, Manager, Lock try: import coloredlogs @@ -26,12 +26,12 @@ sys.exit(1) -def separate_app_process(worker_queue, status_queue, log_queue): +def separate_app_process(worker_queue, status_queue, log_queue, queue_list, queue_lock): """ This prevents any QT components being imported in the main process""" from fastflix.application import start_app freeze_support() - start_app(worker_queue, status_queue, log_queue) + start_app(worker_queue, status_queue, log_queue, queue_list, queue_lock) def startup_options(): @@ -66,7 +66,6 @@ def startup_options(): import fastflix.encoders.webp.main import fastflix.flix import fastflix.language - import fastflix.models.base import fastflix.models.config import fastflix.models.encode import fastflix.models.fastflix @@ -117,19 +116,18 @@ def main(): status_queue = Queue() log_queue = Queue() - gui_proc = Process( - target=separate_app_process, - args=( - worker_queue, - status_queue, - log_queue, - ), - ) - gui_proc.start() - exit_status = 1 - try: - queue_worker(gui_proc, worker_queue, status_queue, log_queue) - exit_status = 0 - finally: - gui_proc.kill() - return exit_status + queue_lock = Lock() + with Manager() as manager: + queue_list = manager.list() + gui_proc = Process( + target=separate_app_process, + args=(worker_queue, status_queue, log_queue, queue_list, queue_lock), + ) + gui_proc.start() + exit_status = 1 + try: + queue_worker(gui_proc, worker_queue, status_queue, log_queue, queue_list, queue_lock) + exit_status = 0 + finally: + gui_proc.kill() + return exit_status diff --git a/fastflix/flix.py b/fastflix/flix.py index a7a7de90..a1ce47ad 100644 --- a/fastflix/flix.py +++ b/fastflix/flix.py @@ -3,11 +3,12 @@ import os import re from pathlib import Path -from subprocess import PIPE, CompletedProcess, TimeoutExpired, run +from subprocess import PIPE, CompletedProcess, Popen, TimeoutExpired, run from typing import List, Tuple, Union import reusables from box import Box, BoxError +from pathvalidate import sanitize_filepath from fastflix.exceptions import FlixError from fastflix.language import t @@ -22,8 +23,8 @@ logger = logging.getLogger("fastflix") -def unixy(source): - return str(source).replace("\\", "/") +def clean_file_string(source): + return str(source).strip() def guess_bit_depth(pix_fmt: str, color_primaries: str = None) -> int: @@ -71,8 +72,7 @@ def guess_bit_depth(pix_fmt: str, color_primaries: str = None) -> int: if color_primaries and color_primaries.startswith("bt2020"): return 10 - else: - return 8 + return 8 def execute(command: List, work_dir: Union[Path, str] = None, timeout: int = None) -> CompletedProcess: @@ -131,7 +131,7 @@ def probe(app: FastFlixApp, file: Path) -> Box: "json", "-show_format", "-show_streams", - f"{unixy(file)}", + f"{clean_file_string(file)}", ] result = execute(command) if result.returncode != 0: @@ -147,30 +147,16 @@ def probe(app: FastFlixApp, file: Path) -> Box: raise FlixError(result.stderr) -def determine_rotation(streams) -> Tuple[int, int]: - rotation = 0 - if "rotate" in streams.video[0].get("tags", {}): - rotation = abs(int(streams.video[0].tags.rotate)) - # elif 'side_data_list' in self.streams.video[0]: - # rots = [abs(int(x.rotation)) for x in self.streams.video[0].side_data_list if 'rotation' in x] - # rotation = rots[0] if rots else 0 - - if rotation in (90, 270): - video_width = streams.video[0].height - video_height = streams.video[0].width - else: - video_width = streams.video[0].width - video_height = streams.video[0].height - return video_width, video_height - - def parse(app: FastFlixApp, **_): data = probe(app, app.fastflix.current_video.source) if "streams" not in data: raise FlixError(f"Not a video file, FFprobe output: {data}") streams = Box({"video": [], "audio": [], "subtitle": [], "attachment": [], "data": []}) for track in data.streams: - if track.codec_type == "video" and track.get("disposition", {}).get("attached_pic"): + if track.codec_type == "video" and ( + track.get("disposition", {}).get("attached_pic") + or track.get("tags", {}).get("MIMETYPE", "").startswith("image") + ): streams.attachment.append(track) elif track.codec_type in streams: streams[track.codec_type].append(track) @@ -188,7 +174,6 @@ def parse(app: FastFlixApp, **_): app.fastflix.current_video.streams = streams app.fastflix.current_video.video_settings.selected_track = streams.video[0].index - app.fastflix.current_video.width, app.fastflix.current_video.height = determine_rotation(streams) app.fastflix.current_video.format = data.format app.fastflix.current_video.duration = float(data.format.get("duration", 0)) @@ -213,14 +198,14 @@ def extract_attachment(ffmpeg: Path, source: Path, stream: int, work_dir: Path, f"{ffmpeg}", "-y", "-i", - f"{unixy(source)}", + f"{clean_file_string(source)}", "-map", f"0:{stream}", "-c", "copy", "-vframes", "1", - f"{unixy(file_name)}", + f"{clean_file_string(file_name)}", ], work_dir=work_dir, timeout=5, @@ -232,13 +217,10 @@ def extract_attachment(ffmpeg: Path, source: Path, stream: int, work_dir: Path, def generate_thumbnail_command( config: Config, source: Path, output: Path, filters: str, start_time: float = 0, input_track: int = 0 ) -> str: - start = "" - if start_time: - start = f"-ss {start_time}" return ( - f'"{config.ffmpeg}" {start} -loglevel error -i "{unixy(source)}" ' + f'"{config.ffmpeg}" -ss {start_time} -loglevel error -i "{clean_file_string(source)}" ' f" {filters} -an -y -map_metadata -1 -map 0:{input_track} " - f'-vframes 1 "{unixy(output)}" ' + f'-vframes 1 "{clean_file_string(output)}" ' ) @@ -260,7 +242,7 @@ def get_auto_crop( "-ss", f"{start_time}", "-i", - f"{unixy(source)}", + f"{clean_file_string(source)}", "-map", f"0:{input_track}", "-vf", @@ -303,25 +285,29 @@ def detect_interlaced(app: FastFlixApp, config: Config, source: Path, **_): # [Parsed_idet_0 @ 00000] Single frame detection: TFF: 0 BFF: 0 Progressive: 641 Undetermined: 359 # [Parsed_idet_0 @ 00000] Multi frame detection: TFF: 0 BFF: 0 Progressive: 953 Undetermined: 47 - output = execute( - [ - f"{config.ffmpeg}", - "-hide_banner", - "-i", - f"{unixy(source)}", - "-vf", - "idet", - "-frames:v", - "100", - "-an", - "-sn", - "-dn", - "-f", - "rawvideo", - f"{'NUL' if reusables.win_based else '/dev/null'}", - "-y", - ] - ) + try: + output = execute( + [ + f"{config.ffmpeg}", + "-hide_banner", + "-i", + f"{clean_file_string(source)}", + "-vf", + "idet", + "-frames:v", + "100", + "-an", + "-sn", + "-dn", + "-f", + "rawvideo", + f"{'NUL' if reusables.win_based else '/dev/null'}", + "-y", + ] + ) + except Exception: + logger.exception("Error while running the interlace detection command") + return for line in output.stderr.splitlines(): if "Single frame detection" in line: @@ -334,7 +320,7 @@ def detect_interlaced(app: FastFlixApp, config: Config, source: Path, **_): else: if int(tffs) + int(bffs) > int(progressive): app.fastflix.current_video.video_settings.deinterlace = True - app.fastflix.current_video.interlaced = True + app.fastflix.current_video.interlaced = "tff" if int(tffs) > int(bffs) else "bff" return app.fastflix.current_video.video_settings.deinterlace = False app.fastflix.current_video.interlaced = False @@ -397,49 +383,103 @@ def parse_hdr_details(app: FastFlixApp, **_): logger.exception(f"Unexpected error while processing master-display from {streams.video[0]}") else: if master_display: - app.fastflix.current_video.master_display = master_display - app.fastflix.current_video.cll = cll - return + app.fastflix.current_video.hdr10_streams.append( + Box(index=video_stream.index, master_display=master_display, cll=cll) + ) + continue + + result = execute( + [ + f"{app.fastflix.config.ffprobe}", + "-loglevel", + "panic", + "-select_streams", + f"v:{video_stream.index}", + "-print_format", + "json", + "-show_frames", + "-read_intervals", + "%+#1", + "-show_entries", + "frame=color_space,color_primaries,color_transfer,side_data_list,pix_fmt", + f"{clean_file_string(app.fastflix.current_video.source)}", + ] + ) - result = execute( - [ - f"{app.fastflix.config.ffprobe}", - "-loglevel", - "panic", - "-select_streams", - f"v:{video_track}", - "-print_format", - "json", - "-show_frames", - "-read_intervals", - "%+#1", - "-show_entries", - "frame=color_space,color_primaries,color_transfer,side_data_list,pix_fmt", - f"{unixy(app.fastflix.current_video.source)}", - ] - ) + try: + data = Box.from_json(result.stdout, default_box=True, default_box_attr="") + except BoxError: + # Could not parse details + logger.error( + "COULD NOT PARSE FFPROBE HDR METADATA, PLEASE OPEN ISSUE WITH THESE DETAILS:" + f"\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}" + ) + continue + if "frames" not in data or not len(data.frames): + continue + data = data.frames[0] + if not data.get("side_data_list"): + continue - try: - data = Box.from_json(result.stdout, default_box=True, default_box_attr="") - except BoxError: - # Could not parse details - logger.error( - "COULD NOT PARSE FFPROBE HDR METADATA, PLEASE OPEN ISSUE WITH THESE DETAILS:" - f"\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}" - ) - return - if "frames" not in data or not len(data.frames): - return - data = data.frames[0] - if not data.get("side_data_list"): + try: + master_display, cll = convert_mastering_display(data) + except FlixError as err: + logger.error(str(err)) + except Exception: + logger.exception(f"Unexpected error while processing master-display from {streams.video[0]}") + else: + if master_display: + app.fastflix.current_video.hdr10_streams.append( + Box(index=video_stream.index, master_display=master_display, cll=cll) + ) + + +def detect_hdr10_plus(app: FastFlixApp, config: Config, **_): + if not config.hdr10plus_parser or not config.hdr10plus_parser.exists(): return - try: - master_display, cll = convert_mastering_display(data) - except FlixError as err: - logger.error(str(err)) - except Exception: - logger.exception(f"Unexpected error while processing master-display from {streams.video[0]}") - else: - app.fastflix.current_video.master_display = master_display - app.fastflix.current_video.cll = cll + hdr10plus_streams = [] + + for stream in app.fastflix.current_video.streams.video: + logger.debug(f"Checking for hdr10+ in stream {stream.index}") + process = Popen( + [ + config.ffmpeg, + "-y", + "-i", + clean_file_string(app.fastflix.current_video.source), + "-map", + f"0:{stream.index}", + "-loglevel", + "panic", + "-c:v", + "copy", + "-vbsf", + "hevc_mp4toannexb", + "-f", + "hevc", + "-", + ], + stdout=PIPE, + stderr=PIPE, + stdin=PIPE, # FFmpeg can try to read stdin and wrecks havoc + ) + + process_two = Popen( + [config.hdr10plus_parser, "--verify", "-"], + stdout=PIPE, + stderr=PIPE, + stdin=process.stdout, + encoding="utf-8", + ) + + try: + stdout, stderr = process_two.communicate() + except Exception: + logger.exception(f"Unexpected error while trying to detect HDR10+ metadata in stream {stream.index}") + else: + if "Dynamic HDR10+ metadata detected." in stdout: + hdr10plus_streams.append(stream.index) + + if hdr10plus_streams: + app.fastflix.current_video.hdr10_plus = hdr10plus_streams diff --git a/fastflix/models/base.py b/fastflix/models/base.py deleted file mode 100644 index 710b56da..00000000 --- a/fastflix/models/base.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -from multiprocessing import Queue - -logger = logging.getLogger("fastflix") - -ignore_list = [Queue] - -NO_OPTION = object() - - -class BaseDataClass: - def __setattr__(self, key, value): - if value is not None and key in self.__class__.__annotations__: - annotation = self.__class__.__annotations__[key] - if hasattr(annotation, "__args__") and getattr(annotation, "_name", "") == "Union": - annotation = annotation.__args__ - elif hasattr(annotation, "_name"): - # Assuming this is a typing object we can't handle - return super().__setattr__(key, value) - if annotation in ignore_list: - return super().__setattr__(key, value) - try: - if not isinstance(value, annotation): - raise ValueError( - f'"{key}" attempted to be set to "{value}" of type "{type(value)}" but must be of type "{annotation}"' - ) - except TypeError as err: - logger.debug(f"Could not validate type for {key} with {annotation}: {err}") - return super().__setattr__(key, value) - - # def get(self, item, default=NO_OPTION): - # if default != NO_OPTION: - # return getattr(self, item, default) - # return getattr(self, item) diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 88e4ca3f..6c776837 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- import logging import shutil @@ -14,12 +15,16 @@ AOMAV1Settings, CopySettings, GIFSettings, + FFmpegNVENCSettings, SVTAV1Settings, VP9Settings, WebPSettings, rav1eSettings, x264Settings, x265Settings, + NVEncCSettings, + NVEncCAVCSettings, + setting_types, ) from fastflix.version import __version__ @@ -30,17 +35,6 @@ NO_OPT = object() -setting_types = { - "x265": x265Settings, - "x264": x264Settings, - "rav1e": rav1eSettings, - "svt_av1": SVTAV1Settings, - "vp9": VP9Settings, - "aom_av1": AOMAV1Settings, - "gif": GIFSettings, - "webp": WebPSettings, - "copy_settings": CopySettings, -} outdated_settings = ("copy",) @@ -77,6 +71,9 @@ class Profile(BaseModel): gif: Optional[GIFSettings] = None webp: Optional[WebPSettings] = None copy_settings: Optional[CopySettings] = None + ffmpeg_hevc_nvenc: Optional[FFmpegNVENCSettings] = None + nvencc_hevc: Optional[NVEncCSettings] = None + nvencc_avc: Optional[NVEncCAVCSettings] = None empty_profile = Profile(x265=x265Settings()) @@ -88,7 +85,7 @@ def get_preset_defaults(): "UHD HDR10 Film": Profile( auto_crop=True, x265=x265Settings(crf=18, hdr10=True, hdr10_opt=True, repeat_headers=True, preset="slow") ), - "1080p Film": Profile(auto_crop=True, encoder="AVC (x264)", x264=x264Settings(crf=17, preset="slow")), + "1080p Film": Profile(auto_crop=True, encoder="AVC (x264)", x264=x264Settings(crf=22, preset="slow")), } @@ -113,14 +110,25 @@ def find_ffmpeg_file(name, raise_on_missing=False): return None +def where(filename: str) -> Optional[Path]: + if location := shutil.which(filename): + return Path(location) + return None + + class Config(BaseModel): version: str = __version__ config_path: Path = fastflix_folder / "fastflix.yaml" ffmpeg: Path = Field(default_factory=lambda: find_ffmpeg_file("ffmpeg")) ffprobe: Path = Field(default_factory=lambda: find_ffmpeg_file("ffprobe")) + hdr10plus_parser: Optional[Path] = Field(default_factory=lambda: where("hdr10plus_parser")) + mkvpropedit: Optional[Path] = Field(default_factory=lambda: where("mkvpropedit")) + nvencc: Optional[Path] = Field(default_factory=lambda: where("NVEncC")) + output_directory: Optional[Path] = False flat_ui: bool = True language: str = "en" logging_level: int = 10 + crop_detect_points: int = 10 continue_on_failure: bool = True work_path: Path = fastflix_folder use_sane_audio: bool = True @@ -186,7 +194,7 @@ def load(self): "there may be non-recoverable errors while loading it." ) - paths = ("work_path", "ffmpeg", "ffprobe") + paths = ("work_path", "ffmpeg", "ffprobe", "hdr10plus_parser", "mkvpropedit", "nvencc", "output_directory") for key, value in data.items(): if key == "profiles": self.profiles = {} @@ -223,6 +231,12 @@ def load(self): self.ffprobe = find_ffmpeg_file("ffmpeg.ffprobe", raise_on_missing=True) except MissingFF: raise err from None + if not self.hdr10plus_parser: + self.hdr10plus_parser = where("hdr10plus_parser") + if not self.mkvpropedit: + self.mkvpropedit = where("mkvpropedit") + if not self.nvencc: + self.mkvpropedit = where("NVEncC") self.profiles.update(get_preset_defaults()) if self.selected_profile not in self.profiles: diff --git a/fastflix/models/encode.py b/fastflix/models/encode.py index 9652fcc2..17d8fc95 100644 --- a/fastflix/models/encode.py +++ b/fastflix/models/encode.py @@ -1,19 +1,27 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- from pathlib import Path -from typing import List, Optional, Union +from typing import List, Optional, Union, Dict from pydantic import BaseModel, Field +from box import Box class AudioTrack(BaseModel): index: int outdex: int codec: str = "" - downmix: int = 0 + downmix: Optional[str] = None title: str = "" language: str = "" conversion_bitrate: str = "" conversion_codec: str = "" + profile: Optional[str] = None + enabled: bool = True + original: bool = False + channels: int = 2 + friendly_info: str = "" + raw_info: Optional[Union[Dict, Box]] = None class SubtitleTrack(BaseModel): @@ -73,6 +81,84 @@ class x264Settings(EncoderSettings): bitrate: Optional[str] = None +class FFmpegNVENCSettings(EncoderSettings): + name = "HEVC (NVENC)" + preset: str = "slow" + profile: str = "main" + tune: str = "hq" + pix_fmt: str = "p010le" + bitrate: Optional[str] = "6000k" + qp: Optional[str] = None + cq: int = 0 + spatial_aq: int = 0 + rc_lookahead: int = 0 + rc: Optional[str] = None + tier: str = "main" + level: Optional[str] = None + gpu: int = -1 + b_ref_mode: str = "disabled" + + +class NVEncCSettings(EncoderSettings): + name = "HEVC (NVEncC)" + preset: str = "quality" + profile: str = "auto" + bitrate: Optional[str] = "5000k" + cqp: Optional[str] = None + aq: str = "off" + aq_strength: int = 0 + lookahead: Optional[int] = None + tier: str = "high" + level: Optional[str] = None + hdr10plus_metadata: str = "" + multipass: str = "2pass-full" + mv_precision: str = "Auto" + init_q_i: Optional[str] = None + init_q_p: Optional[str] = None + init_q_b: Optional[str] = None + min_q_i: Optional[str] = None + min_q_p: Optional[str] = None + min_q_b: Optional[str] = None + max_q_i: Optional[str] = None + max_q_p: Optional[str] = None + max_q_b: Optional[str] = None + vbr_target: Optional[str] = None + b_frames: Optional[str] = None + b_ref_mode: str = "disabled" + ref: Optional[str] = None + metrics: bool = True + + +class NVEncCAVCSettings(EncoderSettings): + name = "AVC (NVEncC)" + preset: str = "quality" + profile: str = "auto" + bitrate: Optional[str] = "5000k" + cqp: Optional[str] = None + aq: str = "off" + aq_strength: int = 0 + lookahead: Optional[int] = None + tier: str = "high" + level: Optional[str] = None + hdr10plus_metadata: str = "" + multipass: str = "2pass-full" + mv_precision: str = "Auto" + init_q_i: Optional[str] = None + init_q_p: Optional[str] = None + init_q_b: Optional[str] = None + min_q_i: Optional[str] = None + min_q_p: Optional[str] = None + min_q_b: Optional[str] = None + max_q_i: Optional[str] = None + max_q_p: Optional[str] = None + max_q_b: Optional[str] = None + vbr_target: Optional[str] = None + b_frames: Optional[str] = None + b_ref_mode: str = "disabled" + ref: Optional[str] = None + metrics: bool = True + + class rav1eSettings(EncoderSettings): name = "AV1 (rav1e)" speed: str = "-1" @@ -105,6 +191,9 @@ class VP9Settings(EncoderSettings): single_pass: bool = False crf: Optional[Union[int, float]] = 31 bitrate: Optional[str] = None + fast_first_pass: Optional[bool] = True + tile_columns: str = "-1" + tile_rows: str = "-1" class AOMAV1Settings(EncoderSettings): @@ -134,3 +223,19 @@ class GIFSettings(EncoderSettings): class CopySettings(EncoderSettings): name = "Copy" + + +setting_types = { + "x265": x265Settings, + "x264": x264Settings, + "rav1e": rav1eSettings, + "svt_av1": SVTAV1Settings, + "vp9": VP9Settings, + "aom_av1": AOMAV1Settings, + "gif": GIFSettings, + "webp": WebPSettings, + "copy_settings": CopySettings, + "ffmpeg_hevc_nvenc": FFmpegNVENCSettings, + "nvencc_hevc": NVEncCSettings, + "nvencc_avc": NVEncCAVCSettings, +} diff --git a/fastflix/models/fastflix.py b/fastflix/models/fastflix.py index 6bb47c65..13d25196 100644 --- a/fastflix/models/fastflix.py +++ b/fastflix/models/fastflix.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from dataclasses import dataclass, field -# from multiprocessing import Queue +from multiprocessing import Lock from pathlib import Path from typing import Any, Dict, List, Optional @@ -18,6 +18,7 @@ class FastFlix(BaseModel): config: Config = None data_path: Path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) log_path: Path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "logs" + queue_path: Path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "queue.yaml" ffmpeg_version: str = "" ffmpeg_config: List[str] = "" ffprobe_version: str = "" @@ -28,4 +29,5 @@ class FastFlix(BaseModel): log_queue: Any = None current_video: Optional[Video] = None - queue: List[Video] = Field(default_factory=list) + queue: Any = None + queue_lock: Any = None diff --git a/fastflix/models/video.py b/fastflix/models/video.py index ef5fd043..f23c21da 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import uuid from pathlib import Path -from typing import List, Optional, Union +from typing import List, Optional, Union, Tuple from box import Box from pydantic import BaseModel, Field @@ -12,6 +12,7 @@ AudioTrack, CopySettings, GIFSettings, + FFmpegNVENCSettings, SubtitleTrack, SVTAV1Settings, VP9Settings, @@ -19,13 +20,45 @@ rav1eSettings, x264Settings, x265Settings, + NVEncCSettings, + NVEncCAVCSettings, ) -__all__ = ["VideoSettings", "Status", "Video"] +__all__ = ["VideoSettings", "Status", "Video", "Crop", "Status"] + + +def determine_rotation(streams, track: int = 0) -> Tuple[int, int]: + for stream in streams.video: + if int(track) == stream["index"]: + video_stream = stream + break + else: + return 0, 0 + + rotation = 0 + if "rotate" in streams.video[0].get("tags", {}): + rotation = abs(int(video_stream.tags.rotate)) + + if rotation in (90, 270): + video_width = video_stream.height + video_height = video_stream.width + else: + video_width = video_stream.width + video_height = video_stream.height + return video_width, video_height + + +class Crop(BaseModel): + top: int = 0 + right: int = 0 + bottom: int = 0 + left: int = 0 + width: int = 0 + height: int = 0 class VideoSettings(BaseModel): - crop: Optional[str] = None + crop: Optional[Crop] = None start_time: Union[float, int] = 0 end_time: Union[float, int] = 0 fast_seek: bool = True @@ -63,6 +96,9 @@ class VideoSettings(BaseModel): GIFSettings, WebPSettings, CopySettings, + FFmpegNVENCSettings, + NVEncCSettings, + NVEncCAVCSettings, ] = None audio_tracks: List[AudioTrack] = Field(default_factory=list) subtitle_tracks: List[SubtitleTrack] = Field(default_factory=list) @@ -76,28 +112,63 @@ class Status(BaseModel): complete: bool = False running: bool = False cancelled: bool = False + subtitle_fixed: bool = False current_command: int = 0 + @property + def ready(self) -> bool: + return not self.success and not self.error and not self.complete and not self.running and not self.cancelled + + def clear(self): + self.success = False + self.error = False + self.complete = False + self.running = False + self.cancelled = False + self.subtitle_fixed = False + self.current_command = 0 + class Video(BaseModel): source: Path - width: int = 0 - height: int = 0 duration: Union[float, int] = 0 streams: Box = None work_path: Path = None format: Box = None - interlaced: bool = True + interlaced: Union[str, bool] = False - # HDR10 Details - master_display: Box = None - cll: str = "" + hdr10_streams: List[Box] = Field(default_factory=list) + hdr10_plus: List[int] = Field(default_factory=list) video_settings: VideoSettings = Field(default_factory=VideoSettings) status: Status = Field(default_factory=Status) uuid: str = Field(default_factory=lambda: str(uuid.uuid4())) + @property + def width(self): + w, _ = determine_rotation(self.streams, self.video_settings.selected_track) + return w + + @property + def height(self): + _, h = determine_rotation(self.streams, self.video_settings.selected_track) + return h + + @property + def master_display(self) -> Optional[Box]: + for track in self.hdr10_streams: + if track.index == self.video_settings.selected_track: + return track["master_display"] + return None + + @property + def cll(self) -> Optional[str]: + for track in self.hdr10_streams: + if track.index == self.video_settings.selected_track: + return track["cll"] + return None + @property def current_video_stream(self): try: diff --git a/fastflix/program_downloads.py b/fastflix/program_downloads.py index e640273d..427b6d14 100644 --- a/fastflix/program_downloads.py +++ b/fastflix/program_downloads.py @@ -61,6 +61,7 @@ def stop_me(): raise if stop: + message(t("Download Cancelled")) return gpl_ffmpeg = [asset for asset in data["assets"] if asset["name"].endswith("win64-gpl.zip")] @@ -82,6 +83,9 @@ def stop_me(): signal.emit(int(((i * 1024) / gpl_ffmpeg[0]["size"]) * 90)) f.write(block) if stop: + f.close() + Path(filename).unlink() + message(t("Download Cancelled")) return if filename.stat().st_size < 1000: @@ -99,6 +103,8 @@ def stop_me(): raise if stop: + Path(filename).unlink() + message(t("Download Cancelled")) return signal.emit(95) diff --git a/fastflix/queue.py b/fastflix/queue.py new file mode 100644 index 00000000..440c9209 --- /dev/null +++ b/fastflix/queue.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +from typing import List +import os +from pathlib import Path +import logging +import shutil +import uuid + +from box import Box, BoxError +from ruamel.yaml import YAMLError + +from fastflix.models.video import Video, VideoSettings, Status, Crop +from fastflix.models.encode import AudioTrack, SubtitleTrack, AttachmentTrack +from fastflix.models.encode import setting_types +from fastflix.models.config import Config + +logger = logging.getLogger("fastflix") + + +def get_queue(queue_file: Path, config: Config) -> List[Video]: + if not queue_file.exists(): + return [] + + try: + loaded = Box.from_yaml(filename=queue_file) + except (BoxError, YAMLError): + logger.exception("Could not open queue") + return [] + + queue = [] + for video in loaded["queue"]: + video["source"] = Path(video["source"]) + video["work_path"] = Path(video["work_path"]) + video["video_settings"]["output_path"] = Path(video["video_settings"]["output_path"]) + encoder_settings = video["video_settings"]["video_encoder_settings"] + ves = [x(**encoder_settings) for x in setting_types.values() if x().name == encoder_settings["name"]][0] + audio = [AudioTrack(**x) for x in video["video_settings"]["audio_tracks"]] + subtitles = [SubtitleTrack(**x) for x in video["video_settings"]["subtitle_tracks"]] + attachments = [] + for x in video["video_settings"]["attachment_tracks"]: + try: + attachment_path = x.pop("file_path") + except KeyError: + attachment_path = None + attachment = AttachmentTrack(**x) + attachment.file_path = Path(attachment_path) + attachments.append(attachment) + status = Status(**video["status"]) + crop = None + if video["video_settings"]["crop"]: + crop = Crop(**video["video_settings"]["crop"]) + del video["video_settings"]["audio_tracks"] + del video["video_settings"]["subtitle_tracks"] + del video["video_settings"]["attachment_tracks"] + del video["video_settings"]["video_encoder_settings"] + del video["status"] + del video["video_settings"]["crop"] + vs = VideoSettings( + **video["video_settings"], + audio_tracks=audio, + subtitle_tracks=subtitles, + attachment_tracks=attachments, + crop=crop, + ) + vs.video_encoder_settings = ves # No idea why this has to be called after, otherwise reset to x265 + del video["video_settings"] + queue.append(Video(**video, video_settings=vs, status=status)) + return queue + + +def save_queue(queue: List[Video], queue_file: Path, config: Config): + items = [] + queue_covers = config.work_path / "covers" + queue_covers.mkdir(parents=True, exist_ok=True) + queue_data = config.work_path / "queue_extras" + queue_data.mkdir(parents=True, exist_ok=True) + + def update_conversion_command(vid, old_path: str, new_path: str): + for command in vid["video_settings"]["conversion_commands"]: + new_command = command["command"].replace(old_path, new_path) + if new_command == command["command"]: + logger.error(f'Could not replace "{old_path}" with "{new_path}" in {command["command"]}') + command["command"] = new_command + + for video in queue: + video = video.dict() + video["source"] = os.fspath(video["source"]) + video["work_path"] = os.fspath(video["work_path"]) + video["video_settings"]["output_path"] = os.fspath(video["video_settings"]["output_path"]) + if metadata := video["video_settings"]["video_encoder_settings"].get("hdr10plus_metadata"): + new_metadata_file = queue_data / f"{uuid.uuid4().hex}_metadata.json" + try: + shutil.copy(metadata, new_metadata_file) + except OSError: + logger.exception("Could not save HDR10+ metadata file to queue recovery location, removing HDR10+") + + update_conversion_command( + video, + str(metadata), + str(new_metadata_file), + ) + video["video_settings"]["video_encoder_settings"]["hdr10plus_metadata"] = str(new_metadata_file) + for track in video["video_settings"]["attachment_tracks"]: + if track.get("file_path"): + new_file = queue_covers / f'{uuid.uuid4().hex}_{track["file_path"].name}' + try: + shutil.copy(track["file_path"], new_file) + except OSError: + logger.exception("Could not save cover to queue recovery location, removing cover") + update_conversion_command(video, str(track["file_path"]), str(new_file)) + track["file_path"] = str(new_file) + + items.append(video) + Box(queue=items).to_yaml(filename=queue_file) + logger.debug(f"queue saved to recovery file {queue_file}") diff --git a/fastflix/resources.py b/fastflix/resources.py index d306eca0..b3b95c2b 100644 --- a/fastflix/resources.py +++ b/fastflix/resources.py @@ -36,4 +36,5 @@ working_icon = str(Path(pkg_resources.resource_filename(__name__, "data/icons/pending-work.png")).resolve()) advanced_icon = str(Path(pkg_resources.resource_filename(__name__, "data/icons/advanced.png")).resolve()) info_icon = str(Path(pkg_resources.resource_filename(__name__, "data/icons/info.png")).resolve()) +undo_icon = str(Path(pkg_resources.resource_filename(__name__, "data/icons/undo-arrow.png")).resolve()) loading_movie = str(Path(pkg_resources.resource_filename(__name__, "data/icons/loading.gif")).resolve()) diff --git a/fastflix/shared.py b/fastflix/shared.py index f540af6d..0b4f9e5d 100644 --- a/fastflix/shared.py +++ b/fastflix/shared.py @@ -11,6 +11,8 @@ import pkg_resources import requests import reusables +from pathvalidate import sanitize_filepath + try: # PyInstaller creates a temp folder and stores path in _MEIPASS @@ -25,8 +27,6 @@ from fastflix.language import t -QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) -QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True) DEVMODE = os.getenv("DEVMODE", "").lower() in ("1", "true") my_data = str(Path(pkg_resources.resource_filename(__name__, f"../data/icon.ico")).resolve()) @@ -226,7 +226,7 @@ def clean_logs(signal, app, **_): except UnicodeDecodeError: pass else: - if len(condensed) < len(original): + if (len(condensed) + 100) < len(original): logger.debug(f"Compressed {file.name} from {len(original)} characters to {len(condensed)}") file.write_text(condensed, encoding="utf-8") if is_old: @@ -256,3 +256,12 @@ def timedelta_to_str(delta): output_string = output_string.split(".")[0] # Remove .XXX microseconds return output_string + + +def clean_file_string(source): + return str(source).strip().strip("'\"") + + +def sanitize(source): + return str(sanitize_filepath(source, platform="Windows" if reusables.win_based else "Linux")) + # return str().replace("\\", "/") diff --git a/fastflix/version.py b/fastflix/version.py index aab4fbdb..a323c5f2 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "4.1.2" +__version__ = "4.2.0" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/about.py b/fastflix/widgets/about.py index 7a35a138..48c2f6ad 100644 --- a/fastflix/widgets/about.py +++ b/fastflix/widgets/about.py @@ -18,7 +18,7 @@ def __init__(self, parent=None): super(About, self).__init__(parent) layout = QtWidgets.QGridLayout() - self.setMinimumSize(400, 400) + self.setMinimumSize(QtCore.QSize(400, 400)) build_file = Path(base_path, "build_version") @@ -33,28 +33,38 @@ def __init__(self, parent=None): label.setAlignment(QtCore.Qt.AlignCenter) label.setOpenExternalLinks(True) label.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + layout.addWidget(label) + + support_label = QtWidgets.QLabel( + f'{link("https://github.com/cdgriffith/FastFlix/wiki/Support-FastFlix", t("Support FastFlix"))}

' + ) + support_label.setOpenExternalLinks(True) + support_label.setFont(QtGui.QFont("Arial", 12)) + support_label.setAlignment((QtCore.Qt.AlignCenter | QtCore.Qt.AlignTop)) + layout.addWidget(support_label) + + bundle_label = QtWidgets.QLabel( + f"Conversion suites: {link('https://www.ffmpeg.org/download.html', 'FFmpeg')} ({t('Various')}), " + f"{link('https://github.com/rigaya/NVEnc', 'NVEncC')} (MIT)

" + f"Encoders:
{link('https://github.com/rigaya/NVEnc', 'NVEncC')} (MIT), " + f"SVT AV1 (MIT), rav1e (MIT), aom (MIT), x265 (GPL), x264 (GPL), libvpx (BSD)" + ) + bundle_label.setAlignment(QtCore.Qt.AlignCenter) + bundle_label.setOpenExternalLinks(True) + layout.addWidget(bundle_label) supporting_libraries_label = QtWidgets.QLabel( "Supporting libraries
" f"{link('https://www.python.org/', t('Python'))}{reusables.version_string} (PSF LICENSE), " f"{link('https://github.com/cdgriffith/Box', t('python-box'))} {box_version} (MIT), " f"{link('https://github.com/cdgriffith/Reusables', t('Reusables'))} {reusables.__version__} (MIT)
" - "mistune (BSD), colorama (BSD), coloredlogs (MIT), Requests (Apache 2.0)" + "mistune (BSD), colorama (BSD), coloredlogs (MIT), Requests (Apache 2.0)
" + "appdirs (MIT), iso639-lang (MIT), psutil (BSD), qtpy (MIT), pathvalidate (MIT)
" ) supporting_libraries_label.setAlignment(QtCore.Qt.AlignCenter) supporting_libraries_label.setOpenExternalLinks(True) - - layout.addWidget(label) layout.addWidget(supporting_libraries_label) - bundle_label = QtWidgets.QLabel( - f"Conversion suite: {link('https://www.ffmpeg.org/download.html', 'FFmpeg')} ({t('Various')})

" - "Encoders:
SVT AV1 (MIT), rav1e (MIT), aom (MIT), x265 (GPL), x264 (GPL), libvpx (BSD)" - ) - bundle_label.setAlignment(QtCore.Qt.AlignCenter) - bundle_label.setOpenExternalLinks(True) - layout.addWidget(bundle_label) - if pyinstaller: pyinstaller_label = QtWidgets.QLabel( f"Packaged with: {link('https://www.pyinstaller.org/index.html', 'PyInstaller')}" diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py index 5c31fedb..85b71a4e 100644 --- a/fastflix/widgets/background_tasks.py +++ b/fastflix/widgets/background_tasks.py @@ -8,10 +8,11 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp +from fastflix.shared import clean_file_string logger = logging.getLogger("fastflix") -__all__ = ["ThumbnailCreator", "ExtractSubtitleSRT", "SubtitleFix"] +__all__ = ["ThumbnailCreator", "ExtractSubtitleSRT", "SubtitleFix", "ExtractHDR10"] class ThumbnailCreator(QtCore.QThread): @@ -47,7 +48,7 @@ def __init__(self, main, mkv_prop_edit, video_path): self.video_path = video_path def run(self): - output_file = str(self.video_path).replace("\\", "/") + output_file = clean_file_string(self.video_path) self.main.thread_logging_signal.emit(f'INFO:{t("Will fix first subtitle track to not be default")}') try: result = run( @@ -108,49 +109,70 @@ def run(self): self.signal.emit() -# class ExtractHDR10(QtCore.QThread): -# def __init__(self, app: FastFlixApp, main, index, signal): -# super().__init__(main) -# self.main = main -# self.app = app -# self.index = index -# self.signal = signal -# -# def run(self): -# # VERIFY ffmpeg -loglevel panic -i input.mkv -c:v copy -vbsf hevc_mp4toannexb -f hevc - | hdr10plus_parser --verify - -# -# self.main.thread_logging_signal.emit(f'INFO:{t("Extracting HDR10+ metadata")}') -# -# process = Popen( -# [ -# self.app.fastflix.config.ffmpeg, -# "-y", -# "-i", -# self.main.input_video, -# "-map", -# f"0:{self.index}", -# "-loglevel", -# "panic", -# "-c:v", -# "copy", -# "-vbsf", -# "hevc_mp4toannexb", -# "-f", -# "hevc", -# "-" -# ], -# stdout=PIPE, -# stderr=PIPE, -# stdin=PIPE, # FFmpeg can try to read stdin and wrecks havoc -# ) -# -# process_two = Popen( -# ["hdr10plus_parser", "--verify", "-"], -# stdout=PIPE, -# stderr=PIPE, -# stdin=self.process.stdout, -# encoding="utf-8", -# ) -# -# stdout, stderr = process_two.communicate() -# +class ExtractHDR10(QtCore.QThread): + def __init__(self, app: FastFlixApp, main, signal, ffmpeg_signal): + super().__init__(main) + self.main = main + self.app = app + self.signal = signal + self.ffmpeg_signal = ffmpeg_signal + + def run(self): + if not self.app.fastflix.current_video.hdr10_plus: + self.main.thread_logging_signal.emit("ERROR:No tracks have HDR10+ data to extract") + return + + output = self.app.fastflix.current_video.work_path / "metadata.json" + + track = self.app.fastflix.current_video.video_settings.selected_track + if track not in self.app.fastflix.current_video.hdr10_plus: + self.main.thread_logging_signal.emit( + "WARNING:Selected video track not detected to have HDR10+ data, selecting first track that does" + ) + track = self.app.fastflix.current_video.hdr10_plus[0] + + self.main.thread_logging_signal.emit(f'INFO:{t("Extracting HDR10+ metadata")} to {output}') + + self.ffmpeg_signal.emit("Extracting HDR10+ metadata") + + process = Popen( + [ + self.app.fastflix.config.ffmpeg, + "-y", + "-i", + clean_file_string(self.app.fastflix.current_video.source), + "-map", + f"0:{track}", + "-c:v", + "copy", + "-vbsf", + "hevc_mp4toannexb", + "-f", + "hevc", + "-", + ], + stdout=PIPE, + stderr=open(self.app.fastflix.current_video.work_path / "hdr10extract_out.txt", "wb"), + # stdin=PIPE, # FFmpeg can try to read stdin and wrecks havoc + ) + + process_two = Popen( + [self.app.fastflix.config.hdr10plus_parser, "-o", clean_file_string(output), "-"], + stdout=PIPE, + stderr=PIPE, + stdin=process.stdout, + encoding="utf-8", + cwd=str(self.app.fastflix.current_video.work_path), + ) + + with open(self.app.fastflix.current_video.work_path / "hdr10extract_out.txt", "r", encoding="utf-8") as f: + while True: + if process.poll() is not None or process_two.poll() is not None: + break + if line := f.readline().rstrip(): + if line.startswith("frame"): + self.ffmpeg_signal.emit(line) + + stdout, stderr = process_two.communicate() + self.main.thread_logging_signal.emit(f"DEBUG: HDR10+ Extract: {stdout}") + self.signal.emit(str(output)) diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 727df493..2a1defed 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -18,6 +18,7 @@ from fastflix.program_downloads import latest_ffmpeg from fastflix.resources import main_icon from fastflix.shared import clean_logs, error_message, latest_fastflix, message +from fastflix.windows_tools import cleanup_windows_notification from fastflix.widgets.about import About from fastflix.widgets.changes import Changes from fastflix.widgets.logs import Logs @@ -33,6 +34,7 @@ class Container(QtWidgets.QMainWindow): def __init__(self, app: FastFlixApp, **kwargs): super().__init__(None) self.app = app + self.pb = None self.logs = Logs() self.changes = Changes() @@ -45,11 +47,17 @@ def __init__(self, app: FastFlixApp, **kwargs): self.profile = ProfileWindow(self.app, self.main) self.setCentralWidget(self.main) - self.setMinimumSize(QtCore.QSize(1200, 650)) + self.setMinimumSize(QtCore.QSize(1280, 700)) self.icon = QtGui.QIcon(main_icon) self.setWindowIcon(self.icon) + self.main.set_profile() def closeEvent(self, a0: QtGui.QCloseEvent) -> None: + if self.pb: + try: + self.pb.stop_signal.emit() + except Exception: + pass if self.main.converting: sm = QtWidgets.QMessageBox() sm.setText(f"

{t('There is a conversion in process!')}

") @@ -73,6 +81,8 @@ def closeEvent(self, a0: QtGui.QCloseEvent) -> None: shutil.rmtree(item, ignore_errors=True) if item.name.lower().endswith((".jpg", ".jpeg", ".png", ".gif")): item.unlink() + if reusables.win_based: + cleanup_windows_notification() self.main.close(from_container=True) super(Container, self).closeEvent(a0) @@ -190,7 +200,9 @@ def download_ffmpeg(self): ffmpeg = ffmpeg_folder / "ffmpeg.exe" ffprobe = ffmpeg_folder / "ffprobe.exe" try: - ProgressBar(self.app, [Task(t("Downloading FFmpeg"), latest_ffmpeg)], signal_task=True, can_cancel=True) + self.pb = ProgressBar( + self.app, [Task(t("Downloading FFmpeg"), latest_ffmpeg)], signal_task=True, can_cancel=True + ) except FastFlixInternalException: pass except Exception as err: @@ -201,12 +213,14 @@ def download_ffmpeg(self): else: self.app.fastflix.config.ffmpeg = ffmpeg self.app.fastflix.config.ffprobe = ffprobe + self.pb = None def clean_old_logs(self): try: - ProgressBar(self.app, [Task(t("Clean Old Logs"), clean_logs)], signal_task=True, can_cancel=False) + self.pb = ProgressBar(self.app, [Task(t("Clean Old Logs"), clean_logs)], signal_task=True, can_cancel=False) except Exception: error_message(t("Could not compress old logs"), traceback=True) + self.pb = None class OpenFolder(QtCore.QThread): @@ -216,7 +230,10 @@ def __init__(self, parent, path): self.path = str(path) def __del__(self): - self.wait() + try: + self.wait() + except BaseException: + pass def run(self): if reusables.win_based: diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index ae51454a..af7618e2 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -21,6 +21,7 @@ from fastflix.encoders.common import helpers from fastflix.exceptions import FastFlixInternalException, FlixError from fastflix.flix import ( + detect_hdr10_plus, detect_interlaced, extract_attachments, generate_thumbnail_command, @@ -30,17 +31,21 @@ ) from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp -from fastflix.models.video import Status, Video, VideoSettings +from fastflix.models.video import Status, Video, VideoSettings, Crop +from fastflix.queue import save_queue from fastflix.resources import ( black_x_icon, folder_icon, + main_icon, play_round_icon, profile_add_icon, settings_icon, video_add_icon, video_playlist_icon, + undo_icon, ) -from fastflix.shared import error_message, time_to_number, yes_no_message +from fastflix.shared import error_message, message, time_to_number, yes_no_message, clean_file_string +from fastflix.windows_tools import show_windows_notification from fastflix.widgets.background_tasks import SubtitleFix, ThumbnailCreator from fastflix.widgets.progress_bar import ProgressBar, Task from fastflix.widgets.video_options import VideoOptions @@ -89,6 +94,8 @@ class MainWidgets(BaseModel): remove_hdr: QtWidgets.QCheckBox = None video_title: QtWidgets.QLineEdit = None profile_box: QtWidgets.QComboBox = None + thumb_time: QtWidgets.QSlider = None + thumb_key: QtWidgets.QCheckBox = None class Config: arbitrary_types_allowed = True @@ -111,7 +118,7 @@ class Main(QtWidgets.QWidget): thumbnail_complete = QtCore.Signal(int) cancelled = QtCore.Signal(str) close_event = QtCore.Signal() - status_update_signal = QtCore.Signal(str) + status_update_signal = QtCore.Signal() thread_logging_signal = QtCore.Signal(str) def __init__(self, parent, app: FastFlixApp): @@ -123,6 +130,7 @@ def __init__(self, parent, app: FastFlixApp): self.initialized = False self.loading_video = True self.scale_updating = False + self.last_thumb_hash = "" self.notifier = Notifier(self, self.app, self.app.fastflix.status_queue) self.notifier.start() @@ -145,7 +153,7 @@ def __init__(self, parent, app: FastFlixApp): self.buttons = [] - self.thumb_file = Path(self.app.fastflix.config.work_path, "thumbnail_preview.png") + self.thumb_file = Path(self.app.fastflix.config.work_path, "thumbnail_preview.jpg") self.video_options = VideoOptions( self, @@ -168,23 +176,24 @@ def __init__(self, parent, app: FastFlixApp): self.grid = QtWidgets.QGridLayout() self.grid.addLayout(self.init_top_bar(), 0, 0, 1, 14) - self.grid.addLayout(self.init_video_area(), 1, 0, 6, 6) - self.grid.addLayout(self.init_scale_and_crop(), 1, 6, 5, 4) - self.grid.addWidget(self.init_preview_image(), 1, 10, 5, 4, (QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)) + self.grid.addLayout(self.init_video_area(), 2, 0, 6, 6) + self.grid.addLayout(self.init_scale_and_crop(), 2, 6, 6, 4) + self.grid.addWidget(self.init_preview_image(), 2, 10, 5, 4, (QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)) + self.grid.addLayout(self.init_thumb_time_selector(), 7, 10, 1, 4, (QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)) spacer = QtWidgets.QLabel() spacer.setFixedHeight(5) - self.grid.addWidget(spacer, 6, 0, 1, 14) - self.grid.addWidget(self.video_options, 7, 0, 10, 14) + self.grid.addWidget(spacer, 8, 0, 1, 14) + self.grid.addWidget(self.video_options, 9, 0, 10, 14) self.grid.setSpacing(5) self.paused = False self.disable_all() self.setLayout(self.grid) - self.set_profile() self.show() self.initialized = True + self.loading_video = False self.last_page_update = time.time() def init_top_bar(self): @@ -247,6 +256,28 @@ def init_top_bar(self): return top_bar + def init_thumb_time_selector(self): + layout = QtWidgets.QHBoxLayout() + + self.widgets.thumb_key = QtWidgets.QCheckBox("Keyframe") + self.widgets.thumb_key.setChecked(False) + self.widgets.thumb_key.clicked.connect(self.thumb_time_change) + + self.widgets.thumb_time = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.widgets.thumb_time.setMinimum(1) + self.widgets.thumb_time.setMaximum(10) + self.widgets.thumb_time.setValue(2) + self.widgets.thumb_time.setTickPosition(QtWidgets.QSlider.TicksBelow) + self.widgets.thumb_time.setTickInterval(1) + self.widgets.thumb_time.setAutoFillBackground(False) + self.widgets.thumb_time.sliderReleased.connect(self.thumb_time_change) + layout.addWidget(self.widgets.thumb_key) + layout.addWidget(self.widgets.thumb_time) + return layout + + def thumb_time_change(self): + self.generate_thumbnail() + def get_temp_work_path(self): new_temp = self.app.fastflix.config.work_path / f"temp_{secrets.token_hex(12)}" if new_temp.exists(): @@ -269,7 +300,7 @@ def pause_resume(self): logger.info("Resuming FFmpeg conversion") def config_update(self): - self.thumb_file = Path(self.app.fastflix.config.work_path, "thumbnail_preview.png") + self.thumb_file = Path(self.app.fastflix.config.work_path, "thumbnail_preview.jpg") self.change_output_types() self.page_update(build_thumbnail=True) @@ -278,9 +309,6 @@ def init_video_area(self): spacer = QtWidgets.QLabel() spacer.setFixedHeight(2) layout.addWidget(spacer) - # layout.addLayout(self.init_button_menu()) - # layout.addWidget(self.video_path_widget) - # layout.addLayout(self.init_encoder_drop_down()) output_layout = QtWidgets.QHBoxLayout() @@ -370,7 +398,7 @@ def init_video_track_select(self): layout = QtWidgets.QHBoxLayout() self.widgets.video_track = QtWidgets.QComboBox() self.widgets.video_track.addItems([]) - self.widgets.video_track.currentIndexChanged.connect(lambda: self.page_update()) + self.widgets.video_track.currentIndexChanged.connect(self.video_track_update) track_label = QtWidgets.QLabel(t("Video Track")) track_label.setFixedWidth(65) @@ -382,27 +410,27 @@ def init_video_track_select(self): def set_profile(self): if self.loading_video: return - # self.video_options.new_source() - # previous_auto_crop = self.app.fastflix.config.opt("auto_crop") self.app.fastflix.config.selected_profile = self.widgets.profile_box.currentText() self.app.fastflix.config.save() - self.widgets.convert_to.setCurrentText(f" {self.app.fastflix.config.opt('encoder')}") + self.widgets.convert_to.setCurrentText(self.app.fastflix.config.opt("encoder")) if self.app.fastflix.config.opt("auto_crop") and not self.build_crop(): self.get_auto_crop() self.loading_video = True - self.widgets.scale.keep_aspect.setChecked(self.app.fastflix.config.opt("keep_aspect_ratio")) - self.widgets.rotate.setCurrentIndex(self.app.fastflix.config.opt("rotate") or 0 // 90) - - v_flip = self.app.fastflix.config.opt("vertical_flip") - h_flip = self.app.fastflix.config.opt("horizontal_flip") - - self.widgets.flip.setCurrentIndex(self.flip_to_int(v_flip, h_flip)) - self.video_options.change_conversion(self.app.fastflix.config.opt("encoder")) - self.video_options.update_profile() - if self.app.fastflix.current_video: - self.video_options.new_source() - # Hack to prevent a lot of thumbnail generation - self.loading_video = False + try: + self.widgets.scale.keep_aspect.setChecked(self.app.fastflix.config.opt("keep_aspect_ratio")) + self.widgets.rotate.setCurrentIndex(self.app.fastflix.config.opt("rotate") or 0 // 90) + + v_flip = self.app.fastflix.config.opt("vertical_flip") + h_flip = self.app.fastflix.config.opt("horizontal_flip") + + self.widgets.flip.setCurrentIndex(self.flip_to_int(v_flip, h_flip)) + self.video_options.change_conversion(self.app.fastflix.config.opt("encoder")) + self.video_options.update_profile() + if self.app.fastflix.current_video: + self.video_options.new_source() + finally: + # Hack to prevent a lot of thumbnail generation + self.loading_video = False self.page_update() def save_profile(self): @@ -455,17 +483,9 @@ def init_rotate(self): return self.widgets.rotate - def rotation_to_transpose(self): - mapping = {0: 0, 1: 1, 2: 4, 3: 2} - return mapping[self.widgets.rotate.currentIndex()] - - def transpose_to_rotation(self, transpose): - mapping = {0: 0, 1: 1, 4: 2, 2: 3} - return mapping[int(transpose)] - def change_output_types(self): self.widgets.convert_to.clear() - self.widgets.convert_to.addItems([f" {x}" for x in self.app.fastflix.encoders.keys()]) + self.widgets.convert_to.addItems(self.app.fastflix.encoders.keys()) for i, plugin in enumerate(self.app.fastflix.encoders.values()): if getattr(plugin, "icon", False): self.widgets.convert_to.setItemIcon(i, QtGui.QIcon(plugin.icon)) @@ -477,6 +497,8 @@ def change_output_types(self): def init_encoder_drop_down(self): layout = QtWidgets.QHBoxLayout() self.widgets.convert_to = QtWidgets.QComboBox() + self.widgets.convert_to.setMinimumWidth(180) + self.widgets.convert_to.setFixedHeight(40) self.change_output_types() self.widgets.convert_to.currentTextChanged.connect(self.change_encoder) @@ -488,9 +510,11 @@ def init_encoder_drop_down(self): return layout def change_encoder(self): - if not self.initialized or not self.app.fastflix.current_video or not self.convert_to: + if not self.initialized or not self.convert_to: return self.video_options.change_conversion(self.convert_to) + if not self.app.fastflix.current_video: + return if not self.output_video_path_widget.text().endswith(self.current_encoder.video_extension): # Make sure it's using the right file extension self.output_video_path_widget.setText(self.generate_output_filename) @@ -507,12 +531,27 @@ def current_encoder(self): def init_start_time(self): group_box = QtWidgets.QGroupBox() group_box.setStyleSheet("QGroupBox{padding-top:18px; margin-top:-18px}") - self.widgets.start_time, layout = self.build_hoz_int_field( - f"{t('Start')} ", right_stretch=False, left_stretch=True, time_field=True + + layout = QtWidgets.QHBoxLayout() + + reset = QtWidgets.QPushButton(QtGui.QIcon(undo_icon), "") + reset.setIconSize(QtCore.QSize(10, 10)) + reset.clicked.connect(self.reset_time) + self.buttons.append(reset) + layout.addWidget(reset) + + self.widgets.start_time, start_layout = self.build_hoz_int_field( + f"{t('Start')} ", + right_stretch=False, + left_stretch=True, + time_field=True, ) - self.widgets.end_time, layout = self.build_hoz_int_field( - f" {t('End')} ", left_stretch=True, right_stretch=True, layout=layout, time_field=True + self.widgets.end_time, end_layout = self.build_hoz_int_field( + f" {t('End')} ", left_stretch=True, right_stretch=True, time_field=True ) + layout.addLayout(start_layout) + layout.addLayout(end_layout) + self.widgets.start_time.textChanged.connect(lambda: self.page_update()) self.widgets.end_time.textChanged.connect(lambda: self.page_update()) self.widgets.fast_time = QtWidgets.QComboBox() @@ -523,27 +562,44 @@ def init_start_time(self): "vs a specific [exact] frame lookup. (GIF encodings use [fast])" ) self.widgets.fast_time.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) - self.widgets.fast_time.setFixedWidth(75) + self.widgets.fast_time.setFixedWidth(65) layout.addWidget(QtWidgets.QLabel(" ")) layout.addWidget(self.widgets.fast_time, QtCore.Qt.AlignRight) group_box.setLayout(layout) return group_box + def reset_time(self): + self.widgets.start_time.setText(self.number_to_time(0)) + self.widgets.end_time.setText(self.number_to_time(self.app.fastflix.current_video.duration)) + def init_scale(self): scale_area = QtWidgets.QGroupBox(self) scale_area.setFont(self.app.font()) scale_area.setStyleSheet("QGroupBox{padding-top:15px; margin-top:-18px}") scale_layout = QtWidgets.QVBoxLayout() - self.widgets.scale.width, new_scale_layout = self.build_hoz_int_field(f"{t('Width')} ", right_stretch=False) - self.widgets.scale.height, new_scale_layout, lb, rb = self.build_hoz_int_field( - f" {t('Height')} ", left_stretch=False, layout=new_scale_layout, return_buttons=True + main_row = QtWidgets.QHBoxLayout() + + reset = QtWidgets.QPushButton(QtGui.QIcon(undo_icon), "") + reset.setIconSize(QtCore.QSize(10, 10)) + reset.clicked.connect(self.reset_scales) + self.buttons.append(reset) + main_row.addWidget(reset, alignment=(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)) + main_row.addStretch(1) + + self.widgets.scale.width, width_layout = self.build_hoz_int_field(f"{t('Width')} ") + self.widgets.scale.height, height_layout, lb, rb = self.build_hoz_int_field( + f" {t('Height')} ", return_buttons=True ) self.widgets.scale.height.setDisabled(True) self.widgets.scale.height.setText("Auto") lb.setDisabled(True) rb.setDisabled(True) - QtWidgets.QPushButton() + + main_row.addLayout(width_layout) + main_row.addLayout(height_layout) + main_row.addWidget(QtWidgets.QLabel(" ")) + main_row.addStretch(1) # TODO scale 0 error @@ -552,24 +608,29 @@ def init_scale(self): bottom_row = QtWidgets.QHBoxLayout() self.widgets.scale.keep_aspect = QtWidgets.QCheckBox(t("Keep aspect ratio")) - self.widgets.scale.keep_aspect.setMaximumHeight(40) + # self.widgets.scale.keep_aspect.setMaximumHeight(40) self.widgets.scale.keep_aspect.setChecked(True) self.widgets.scale.keep_aspect.toggled.connect(lambda: self.toggle_disable((self.widgets.scale.height, lb, rb))) self.widgets.scale.keep_aspect.toggled.connect(lambda: self.keep_aspect_update()) - label = QtWidgets.QLabel(t("Scale"), alignment=(QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight)) + label = QtWidgets.QLabel(t("Scale")) label.setStyleSheet("QLabel{color:#777}") label.setMaximumHeight(40) - bottom_row.addWidget(self.widgets.scale.keep_aspect, alignment=QtCore.Qt.AlignCenter) + bottom_row.addWidget(self.widgets.scale.keep_aspect, alignment=(QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft)) + bottom_row.addWidget(label, alignment=(QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight)) - scale_layout.addLayout(new_scale_layout) - bottom_row.addWidget(label) + scale_layout.addLayout(main_row) scale_layout.addLayout(bottom_row) scale_area.setLayout(scale_layout) - return scale_area + def reset_scales(self): + self.loading_video = True + self.widgets.scale.width.setText(str(self.app.fastflix.current_video.width)) + self.loading_video = False + self.widgets.scale.height.setText(str(self.app.fastflix.current_video.height)) + def init_crop(self): crop_box = QtWidgets.QGroupBox() crop_box.setStyleSheet("QGroupBox{padding-top:17px; margin-top:-18px}") @@ -597,7 +658,15 @@ def init_crop(self): auto_crop.clicked.connect(self.get_auto_crop) self.buttons.append(auto_crop) + reset = QtWidgets.QPushButton(QtGui.QIcon(undo_icon), "") + reset.setIconSize(QtCore.QSize(10, 10)) + reset.clicked.connect(self.reset_crop) + self.buttons.append(reset) + # crop_bottom_layout.addWidget(label) + l1 = QtWidgets.QVBoxLayout() + l1.addWidget(reset, alignment=(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)) + l2 = QtWidgets.QVBoxLayout() l2.addWidget(auto_crop, alignment=(QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)) l2.addWidget(label, alignment=(QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight)) @@ -606,12 +675,21 @@ def init_crop(self): crop_layout.addLayout(crop_hz_layout) crop_layout.addLayout(crop_bottom_layout) outer = QtWidgets.QHBoxLayout() + outer.addLayout(l1) outer.addLayout(crop_layout) outer.addLayout(l2) crop_box.setLayout(outer) return crop_box + def reset_crop(self): + self.loading_video = True + self.widgets.crop.top.setText("0") + self.widgets.crop.left.setText("0") + self.widgets.crop.right.setText("0") + self.loading_video = False + self.widgets.crop.bottom.setText("0") + @staticmethod def toggle_disable(widget_list): for widget in widget_list: @@ -641,7 +719,7 @@ def build_hoz_int_field( layout = QtWidgets.QHBoxLayout() layout.setSpacing(0) if left_stretch: - layout.addStretch() + layout.addStretch(1) layout.addWidget(QtWidgets.QLabel(name)) minus_button = QtWidgets.QPushButton("-") minus_button.setAutoRepeat(True) @@ -671,7 +749,7 @@ def build_hoz_int_field( layout.addWidget(widget) layout.addWidget(plus_button) if right_stretch: - layout.addStretch() + layout.addStretch(1) if return_buttons: return widget, layout, minus_button, plus_button return widget, layout @@ -742,7 +820,16 @@ def open_file(self): ) if not filename or not filename[0]: return - self.input_video = Path(filename[0]) + + if self.app.fastflix.current_video: + discard = yes_no_message( + f'{t("There is already a video being processed.")}
' f'{t("Are you sure you want to discard it?")}', + title="Discard current video", + ) + if not discard: + return + + self.input_video = Path(clean_file_string(filename[0])) self.video_path_widget.setText(str(self.input_video)) self.output_video_path_widget.setText(self.generate_output_filename) self.output_video_path_widget.setDisabled(False) @@ -759,13 +846,15 @@ def open_file(self): @property def generate_output_filename(self): + if self.app.fastflix.config.output_directory: + return f"{self.app.fastflix.config.output_directory / self.input_video.stem}-fastflix-{secrets.token_hex(2)}.{self.current_encoder.video_extension}" if self.input_video: return f"{self.input_video.parent / self.input_video.stem}-fastflix-{secrets.token_hex(2)}.{self.current_encoder.video_extension}" return f"{Path('~').expanduser()}{os.sep}fastflix-{secrets.token_hex(2)}.{self.current_encoder.video_extension}" @property def output_video(self): - return self.output_video_path_widget.text() + return clean_file_string(self.output_video_path_widget.text().strip("'\"")) @reusables.log_exception("fastflix", show_traceback=False) def save_file(self, extension="mkv"): @@ -781,7 +870,9 @@ def get_auto_crop(self): start_pos = self.start_time or self.app.fastflix.current_video.duration // 10 - blocks = math.ceil((self.app.fastflix.current_video.duration - start_pos) / 5) + blocks = math.ceil( + (self.app.fastflix.current_video.duration - start_pos) / (self.app.fastflix.config.crop_detect_points + 1) + ) if blocks < 1: blocks = 1 @@ -789,7 +880,7 @@ def get_auto_crop(self): x for x in range(int(start_pos), int(self.app.fastflix.current_video.duration), blocks) if x < self.app.fastflix.current_video.duration - ][:4] + ][: self.app.fastflix.config.crop_detect_points] if not times: return @@ -838,32 +929,37 @@ def get_auto_crop(self): self.loading_video = False self.widgets.crop.bottom.setText(str(b)) - def build_crop(self) -> Union[str, None]: + def build_crop(self) -> Union[Crop, None]: if not self.initialized or not self.app.fastflix.current_video: return None try: - top = int(self.widgets.crop.top.text()) - left = int(self.widgets.crop.left.text()) - right = int(self.widgets.crop.right.text()) - bottom = int(self.widgets.crop.bottom.text()) + crop = Crop( + top=int(self.widgets.crop.top.text()), + left=int(self.widgets.crop.left.text()), + right=int(self.widgets.crop.right.text()), + bottom=int(self.widgets.crop.bottom.text()), + ) except (ValueError, AttributeError): logger.error("Invalid crop") return None - width = self.app.fastflix.current_video.width - right - left - height = self.app.fastflix.current_video.height - bottom - top - if (top + left + right + bottom) == 0: - return None - try: - assert top >= 0, t("Top must be positive number") - assert left >= 0, t("Left must be positive number") - assert width > 0, t("Total video width must be greater than 0") - assert height > 0, t("Total video height must be greater than 0") - assert width <= self.app.fastflix.current_video.width, t("Width must be smaller than video width") - assert height <= self.app.fastflix.current_video.height, t("Height must be smaller than video height") - except AssertionError as err: - error_message(f"{t('Invalid Crop')}: {err}") - return - return f"{width}:{height}:{left}:{top}" + else: + crop.width = self.app.fastflix.current_video.width - crop.right - crop.left + crop.height = self.app.fastflix.current_video.height - crop.bottom - crop.top + if (crop.top + crop.left + crop.right + crop.bottom) == 0: + return None + try: + assert crop.top >= 0, t("Top must be positive number") + assert crop.left >= 0, t("Left must be positive number") + assert crop.width > 0, t("Total video width must be greater than 0") + assert crop.height > 0, t("Total video height must be greater than 0") + assert crop.width <= self.app.fastflix.current_video.width, t("Width must be smaller than video width") + assert crop.height <= self.app.fastflix.current_video.height, t( + "Height must be smaller than video height" + ) + except AssertionError as err: + error_message(f"{t('Invalid Crop')}: {err}") + return + return crop def keep_aspect_update(self) -> None: keep_aspect = self.widgets.scale.keep_aspect.isChecked() @@ -895,7 +991,7 @@ def keep_aspect_update(self) -> None: def disable_all(self): for name, widget in self.widgets.items(): - if name in ("preview", "convert_button", "pause_resume"): + if name in ("preview", "convert_button", "pause_resume", "convert_to", "profile_box"): continue if isinstance(widget, dict): for sub_widget in widget.values(): @@ -910,7 +1006,7 @@ def disable_all(self): def enable_all(self): for name, widget in self.widgets.items(): - if name in ("preview", "convert_button", "pause_resume"): + if name in ("preview", "convert_button", "pause_resume", "convert_to", "profile_box"): continue if isinstance(widget, dict): for sub_widget in widget.values(): @@ -937,8 +1033,9 @@ def scale_update(self): self.widgets.scale.height.setDisabled(keep_aspect) height = self.app.fastflix.current_video.height width = self.app.fastflix.current_video.width - if self.build_crop(): - width, height, *_ = (int(x) for x in self.build_crop().split(":")) + if crop := self.build_crop(): + width = crop.width + height = crop.height if keep_aspect and (not height or not width): self.scale_updating = False @@ -1053,28 +1150,38 @@ def reload_video_from_queue(self, video: Video): self.loading_video = True self.app.fastflix.current_video = video + self.app.fastflix.current_video.work_path.mkdir(parents=True, exist_ok=True) + extract_attachments(app=self.app) self.input_video = video.source - + hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams] text_video_tracks = [ - f'{x.index}: {t("codec")} {x.codec_name} - {x.get("pix_fmt")} - {t("profile")} {x.get("profile")}' + ( + f'{x.index}: {x.codec_name} {x.get("bit_depth", "8")}-bit ' + f'{x["color_primaries"] if x.get("color_primaries") else ""}' + f'{" - HDR10" if x.index in hdr10_indexes else ""}' + f'{" | HDR10+" if x.index in self.app.fastflix.current_video.hdr10_plus else ""}' + ) for x in self.app.fastflix.current_video.streams.video ] self.widgets.video_track.clear() self.widgets.video_track.addItems(text_video_tracks) + selected_track = 0 + for track in self.app.fastflix.current_video.streams.video: + if track.index == self.app.fastflix.current_video.video_settings.selected_track: + selected_track = track.index + self.widgets.video_track.setCurrentIndex(selected_track) + end_time = self.app.fastflix.current_video.video_settings.end_time or video.duration if self.app.fastflix.current_video.video_settings.crop: - width, height, left, top = self.app.fastflix.current_video.video_settings.crop.split(":") - right = str(self.app.fastflix.current_video.width - (int(width) + int(left))) - bottom = str(self.app.fastflix.current_video.height - (int(height) + int(top))) + self.widgets.crop.top.setText(str(self.app.fastflix.current_video.video_settings.crop.top)) + self.widgets.crop.left.setText(str(self.app.fastflix.current_video.video_settings.crop.left)) + self.widgets.crop.right.setText(str(self.app.fastflix.current_video.video_settings.crop.right)) + self.widgets.crop.bottom.setText(str(self.app.fastflix.current_video.video_settings.crop.bottom)) else: - top, left, right, bottom = "0", "0", "0", "0" - - end_time = self.app.fastflix.current_video.video_settings.end_time or video.duration - - self.widgets.crop.top.setText(top) - self.widgets.crop.left.setText(left) - self.widgets.crop.right.setText(right) - self.widgets.crop.bottom.setText(bottom) + self.widgets.crop.top.setText("0") + self.widgets.crop.left.setText("0") + self.widgets.crop.right.setText("0") + self.widgets.crop.bottom.setText("0") self.widgets.start_time.setText(self.number_to_time(video.video_settings.start_time)) self.widgets.end_time.setText(self.number_to_time(end_time)) self.widgets.video_title.setText(self.app.fastflix.current_video.video_settings.video_title) @@ -1083,9 +1190,8 @@ def reload_video_from_queue(self, video: Video): self.widgets.remove_metadata.setChecked(self.app.fastflix.current_video.video_settings.remove_metadata) self.widgets.chapters.setChecked(self.app.fastflix.current_video.video_settings.copy_chapters) self.widgets.remove_hdr.setChecked(self.app.fastflix.current_video.video_settings.remove_hdr) - self.widgets.rotate.setCurrentIndex(self.transpose_to_rotation(video.video_settings.rotate)) + self.widgets.rotate.setCurrentIndex(video.video_settings.rotate) self.widgets.fast_time.setCurrentIndex(0 if video.video_settings.fast_seek else 1) - if video.video_settings.vertical_flip: self.widgets.flip.setCurrentIndex(1) if video.video_settings.horizontal_flip: @@ -1106,12 +1212,10 @@ def reload_video_from_queue(self, video: Video): self.widgets.scale.width.setText(str(self.app.fastflix.current_video.width)) self.widgets.scale.height.setText("Auto") self.widgets.scale.keep_aspect.setChecked(True) - self.video_options.reload() self.enable_all() self.app.fastflix.current_video.status = Status() - self.loading_video = False self.page_update() @@ -1122,8 +1226,9 @@ def update_video_info(self): tasks = [ Task(t("Parse Video details"), parse), Task(t("Extract covers"), extract_attachments), - Task(t("Determine HDR details"), parse_hdr_details), Task(t("Detecting Interlace"), detect_interlaced, dict(source=self.input_video)), + Task(t("Determine HDR details"), parse_hdr_details), + Task(t("Detect HDR10+"), detect_hdr10_plus), ] try: @@ -1132,9 +1237,20 @@ def update_video_info(self): error_message(f"{t('Not a video file')}
{self.input_video}") self.clear_current_video() return + except Exception: + logger.exception(f"Could not properly read the files {self.input_video}") + self.clear_current_video() + error_message(f"Could not properly read the file {self.input_video}") + return + hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams] text_video_tracks = [ - f'{x.index}: {t("codec")} {x.codec_name} - {x.get("pix_fmt")} - {t("profile")} {x.get("profile")}' + ( + f'{x.index}: {x.codec_name} {x.get("bit_depth", "8")}-bit ' + f'{x["color_primaries"] if x.get("color_primaries") else ""}' + f'{" - HDR10" if x.index in hdr10_indexes else ""}' + f'{" | HDR10+" if x.index in self.app.fastflix.current_video.hdr10_plus else ""}' + ) for x in self.app.fastflix.current_video.streams.video ] self.widgets.video_track.clear() @@ -1193,11 +1309,17 @@ def update_video_info(self): @property def video_track(self) -> int: - return int(self.widgets.video_track.currentIndex()) + return self.widgets.video_track.currentIndex() @property def original_video_track(self) -> int: - return int(self.widgets.video_track.currentText().split(":", 1)[0]) + if not self.app.fastflix.current_video or not self.widgets.video_track.currentText(): + return 0 + try: + return int(self.widgets.video_track.currentText().split(":", 1)[0]) + except Exception: + logger.exception("Could not get original_video_track") + return 0 @property def pix_fmt(self) -> str: @@ -1231,6 +1353,11 @@ def copy_chapters(self) -> bool: def remove_hdr(self) -> bool: return self.widgets.remove_hdr.isChecked() + @property + def preview_place(self) -> Union[float, int]: + ticks = self.app.fastflix.current_video.duration / 10 + return (self.widgets.thumb_time.value() - 1) * ticks + @reusables.log_exception("fastflix", show_traceback=False) def generate_thumbnail(self): if not self.input_video or self.loading_video: @@ -1244,16 +1371,14 @@ def generate_thumbnail(self): ): settings["remove_hdr"] = True - custom_filters = "scale='min(320\\,iw):-8'" - if self.app.fastflix.current_video.color_transfer == "arib-std-b67": - custom_filters += ",select=eq(pict_type\\,I)" - - filters = helpers.generate_filters(custom_filters=custom_filters, **settings) + custom_filters = "scale='min(720\\,iw):-8'" + # if self.app.fastflix.current_video.color_transfer == "arib-std-b67": + # custom_filters += ",select=eq(pict_type\\,I)" - preview_place = ( - self.app.fastflix.current_video.duration // 10 - if self.app.fastflix.current_video.video_settings.start_time == 0 - else self.app.fastflix.current_video.video_settings.start_time + filters = helpers.generate_filters( + start_filters="select=eq(pict_type\\,I)" if self.widgets.thumb_key.isChecked() else None, + custom_filters=custom_filters, + **settings, ) thumb_command = generate_thumbnail_command( @@ -1261,7 +1386,7 @@ def generate_thumbnail(self): source=self.input_video, output=self.thumb_file, filters=filters, - start_time=preview_place, + start_time=self.preview_place, input_track=self.app.fastflix.current_video.video_settings.selected_track, ) try: @@ -1285,7 +1410,7 @@ def thumbnail_generated(self, success=False): self.widgets.preview.setText(t("Error Updating Thumbnail")) return pixmap = QtGui.QPixmap(str(self.thumb_file)) - pixmap = pixmap.scaled(320, 213, QtCore.Qt.KeepAspectRatio) + pixmap = pixmap.scaled(320, 190, QtCore.Qt.KeepAspectRatio) self.widgets.preview.setPixmap(pixmap) def build_scale(self): @@ -1303,7 +1428,7 @@ def get_all_settings(self): end_time = self.end_time if self.end_time == float(self.app.fastflix.current_video.format.get("duration", 0)): end_time = 0 - if self.end_time and self.end_time - 0.1 <= self.app.fastflix.current_video.duration <= self.end_time + 0.1: + if self.end_time and (self.end_time - 0.1 <= self.app.fastflix.current_video.duration <= self.end_time + 0.1): end_time = 0 scale = self.build_scale() @@ -1321,12 +1446,11 @@ def get_all_settings(self): start_time=self.start_time, end_time=end_time, selected_track=self.original_video_track, - # stream_track=self.video_track, fast_seek=self.fast_time, - rotate=self.rotation_to_transpose(), + rotate=self.widgets.rotate.currentIndex(), vertical_flip=v_flip, horizontal_flip=h_flip, - output_path=Path(self.output_video), + output_path=Path(clean_file_string(self.output_video)), deinterlace=self.widgets.deinterlace.isChecked(), remove_metadata=self.remove_metadata, copy_chapters=self.copy_chapters, @@ -1376,6 +1500,20 @@ def hdr_update(self): self.video_options.advanced.hdr_settings() self.encoder_settings_update() + def video_track_update(self): + if not self.app.fastflix.current_video or self.loading_video: + return + self.loading_video = True + self.app.fastflix.current_video.video_settings.selected_track = self.original_video_track + self.widgets.crop.top.setText("0") + self.widgets.crop.left.setText("0") + self.widgets.crop.right.setText("0") + self.widgets.crop.bottom.setText("0") + self.widgets.scale.width.setText(str(self.app.fastflix.current_video.width)) + self.widgets.scale.height.setText(str(self.app.fastflix.current_video.height)) + self.loading_video = False + self.page_update(build_thumbnail=True) + def page_update(self, build_thumbnail=True): if not self.initialized or self.loading_video or not self.app.fastflix.current_video: return @@ -1383,6 +1521,15 @@ def page_update(self, build_thumbnail=True): self.video_options.refresh() self.build_commands() if build_thumbnail: + new_hash = ( + f"{self.build_crop()}:{self.build_scale()}:{self.start_time}:{self.end_time}:" + f"{self.app.fastflix.current_video.video_settings.selected_track}:" + f"{int(self.remove_hdr)}:{self.preview_place}:{self.widgets.rotate.currentIndex()}:" + f"{self.widgets.flip.currentIndex()}" + ) + if new_hash == self.last_thumb_hash: + return + self.last_thumb_hash = new_hash self.generate_thumbnail() def close(self, no_cleanup=False, from_container=False): @@ -1403,10 +1550,6 @@ def convert_to(self): return self.widgets.convert_to.currentText().strip() return list(self.app.fastflix.encoders.keys())[0] - # @property - # def current_encoder(self): - # return self.app.fastflix.encoders[self.convert_to] - def encoding_checks(self): if not self.input_video: error_message(t("Have to select a video first")) @@ -1463,16 +1606,16 @@ def set_convert_button(self, convert=True): self.widgets.convert_button.setIcon(QtGui.QIcon(black_x_icon)) self.widgets.convert_button.setIconSize(QtCore.QSize(22, 20)) - @reusables.log_exception("fastflix", show_traceback=False) + @reusables.log_exception("fastflix", show_traceback=True) def encode_video(self): - if self.converting: + sure = yes_no_message(t("Are you sure you want to stop the current encode?"), title="Confirm Stop Encode") + if not sure: + return logger.debug(t("Canceling current encode")) self.app.fastflix.worker_queue.put(["cancel"]) self.video_options.queue.reset_pause_encode() return - else: - logger.debug(t("Starting conversion process")) if not self.app.fastflix.queue or self.app.fastflix.current_video: add_current = True @@ -1481,16 +1624,16 @@ def encode_video(self): if add_current: if not self.add_to_queue(): return - - # Command looks like (video_uuid, command_uuid, command, work_dir, filename) - # Request looks like (queue command, log_dir, (commands)) requests = ["add_items", str(self.app.fastflix.log_path)] - commands = self.get_commands() - if not commands: - return error_message(t("No new items in queue to convert")) + for video in self.app.fastflix.queue: + if video.status.ready: + break + else: + error_message(t("There are no videos to start converting")) + return - requests.append(tuple(commands)) + logger.debug(t("Starting conversion process")) self.converting = True self.set_convert_button(False) @@ -1536,10 +1679,8 @@ def add_to_queue(self): # TODO ask if ok # return - video = self.app.fastflix.current_video - - self.app.fastflix.queue.append(copy.deepcopy(video)) - self.video_options.update_queue(currently_encoding=self.converting) + self.app.fastflix.queue.append(copy.deepcopy(self.app.fastflix.current_video)) + self.video_options.update_queue() self.video_options.show_queue() if self.converting: @@ -1548,6 +1689,7 @@ def add_to_queue(self): self.app.fastflix.worker_queue.put(tuple(requests)) self.clear_current_video() + save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) return True @reusables.log_exception("fastflix", show_traceback=False) @@ -1560,24 +1702,30 @@ def conversion_complete(self, return_code): error_message(t("There was an error during conversion and the queue has stopped"), title=t("Error")) else: self.video_options.show_queue() - error_message(t("All queue items have completed"), title=t("Success")) + if reusables.win_based: + try: + show_windows_notification("FastFlix", t("All queue items have completed"), icon_path=main_icon) + except Exception: + message(t("All queue items have completed"), title=t("Success")) + else: + message(t("All queue items have completed"), title=t("Success")) @reusables.log_exception("fastflix", show_traceback=False) def conversion_cancelled(self, data): self.converting = False - self.paused = False self.set_convert_button() + if not data: + return + try: - video_uuid, command_uuid, *_ = data.split("|") + video_uuid, *_ = data.split("|") cancelled_video = self.find_video(video_uuid) + exists = cancelled_video.video_settings.output_path.exists() except Exception: return - if self.video_options.queue.paused: - self.video_options.queue.pause_resume_queue() - - if cancelled_video.video_settings.output_path.exists(): + if exists: sm = QtWidgets.QMessageBox() sm.setWindowTitle(t("Cancelled")) sm.setText( @@ -1594,15 +1742,24 @@ def conversion_cancelled(self, data): except OSError: pass - @reusables.log_exception("fastflix", show_traceback=False) + @reusables.log_exception("fastflix", show_traceback=True) def dropEvent(self, event): if not event.mimeData().hasUrls: return event.ignore() event.setDropAction(QtCore.Qt.CopyAction) event.accept() + + if self.app.fastflix.current_video: + discard = yes_no_message( + f'{t("There is already a video being processed")}
' f'{t("Are you sure you want to discard it?")}', + title="Discard current video", + ) + if not discard: + return + try: - self.input_video = Path(event.mimeData().urls()[0].toLocalFile()) + self.input_video = Path(clean_file_string(event.mimeData().urls()[0].toLocalFile())) except (ValueError, IndexError): return event.ignore() @@ -1626,53 +1783,24 @@ def dragEnterEvent(self, event): def dragMoveEvent(self, event): event.accept() if event.mimeData().hasUrls else event.ignoreAF() - def status_update(self, status): - logger.debug(f"Updating status from command worker: {status}") - try: - command, video_uuid, command_uuid, *_ = status.split("|") - except ValueError: - logger.exception(f"Could not process status update from the command worker: {status}") - return - - try: - video = self.find_video(video_uuid) - command_index = self.find_command(video, command_uuid) - except FlixError as err: - logger.error(f"Could not update queue status due to not found video/command - {err}") - return - - if command == "converted": - if command_index == len(video.video_settings.conversion_commands): - video.status.complete = True - video.status.success = True - video.status.running = False - if video.video_settings.subtitle_tracks and not video.video_settings.subtitle_tracks[0].disposition: - if mkv_prop_edit := shutil.which("mkvpropedit"): - worker = SubtitleFix(self, mkv_prop_edit, video.video_settings.output_path) - worker.start() - self.video_options.update_queue(currently_encoding=self.converting) - else: - logger.error(f"This should not happen? {status} - {video}") - - elif command == "running": - video.status.current_command = command_index - video.status.running = True - self.video_options.update_queue(currently_encoding=self.converting) - - elif command == "error": - video.status.error = True - video.status.running = False - self.video_options.update_queue(currently_encoding=self.converting) - - elif command == "cancelled": - video.status.cancelled = True - video.status.running = False - self.video_options.update_queue(currently_encoding=self.converting) - - elif command in ("paused encode", "resumed encode"): - pass - else: - logger.warning(f"status worker received unknown command: {command}") + def status_update(self): + logger.debug(f"Updating queue from command worker") + + with self.app.fastflix.queue_lock: + fixed_vids = [] + for i, video in enumerate(self.app.fastflix.queue): + if video.status.complete and not video.status.subtitle_fixed: + if video.video_settings.subtitle_tracks and not video.video_settings.subtitle_tracks[0].disposition: + if mkv_prop_edit := shutil.which("mkvpropedit"): + worker = SubtitleFix(self, mkv_prop_edit, video.video_settings.output_path) + worker.start() + fixed_vids.append(i) + for index in fixed_vids: + video = self.app.fastflix.queue.pop(index) + video.status.subtitle_fixed = True + self.app.fastflix.queue.insert(index, video) + save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) + self.video_options.update_queue() def find_video(self, uuid) -> Video: for video in self.app.fastflix.queue: @@ -1701,19 +1829,18 @@ def run(self): while True: # Message looks like (command, video_uuid, command_uuid) status = self.status_queue.get() + self.app.processEvents() + self.main.status_update_signal.emit() + self.app.processEvents() if status[0] == "complete": self.main.completed.emit(0) elif status[0] == "error": - self.main.status_update_signal.emit("|".join(status)) self.main.completed.emit(1) elif status[0] == "cancelled": self.main.cancelled.emit("|".join(status[1:])) - self.main.status_update_signal.emit("|".join(status)) elif status[0] == "exit": try: self.terminate() finally: self.main.close_event.emit() return - else: - self.main.status_update_signal.emit("|".join(status)) diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index c62a9f1f..8ea62b97 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -1,18 +1,18 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from typing import List +from typing import List, Optional from box import Box from iso639 import Lang from iso639.exceptions import InvalidLanguageValue from qtpy import QtCore, QtGui, QtWidgets -from fastflix.encoders.common.audio import lossless +from fastflix.encoders.common.audio import lossless, channel_list from fastflix.language import t from fastflix.models.encode import AudioTrack from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import black_x_icon, copy_icon, down_arrow_icon, up_arrow_icon -from fastflix.shared import no_border +from fastflix.shared import no_border, error_message from fastflix.widgets.panels.abstract_list import FlixList language_list = sorted((k for k, v in Lang._data["name"].items() if v["pt2B"] and v["pt1"]), key=lambda x: x.lower()) @@ -37,6 +37,7 @@ def __init__( codecs=(), channels=2, all_info=None, + disable_dup=False, ): self.loading = True super(Audio, self).__init__(parent) @@ -54,6 +55,7 @@ def __init__( self.codecs = codecs self.channels = channels self.available_audio_encoders = available_audio_encoders + self.all_info = all_info self.widgets = Box( track_number=QtWidgets.QLabel(f"{index}:{self.outdex}" if enabled else "❌"), @@ -78,17 +80,6 @@ def __init__( if all_info: self.widgets.audio_info.setToolTip(all_info.to_yaml()) - downmix_options = [ - "mono", - "stereo", - "2.1 / 3.0", - "3.1 / 4.0", - "4.1 / 5.0", - "5.1 / 6.0", - "6.1 / 7.0", - "7.1 / 8.0", - ] - self.widgets.language.addItems(["No Language Set"] + language_list) self.widgets.language.setMaximumWidth(110) if language: @@ -105,7 +96,7 @@ def __init__( self.widgets.title.textChanged.connect(self.page_update) self.widgets.audio_info.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - self.widgets.downmix.addItems([t("No Downmix")] + downmix_options[: channels - 2]) + self.widgets.downmix.addItems([t("No Downmix")] + [k for k, v in channel_list.items() if v <= channels]) self.widgets.downmix.currentIndexChanged.connect(self.update_downmix) self.widgets.downmix.setCurrentIndex(0) self.widgets.downmix.setDisabled(True) @@ -115,6 +106,10 @@ def __init__( self.widgets.dup_button.clicked.connect(lambda: self.dup_me()) self.widgets.dup_button.setFixedWidth(20) + if disable_dup: + self.widgets.dup_button.hide() + self.widgets.dup_button.setDisabled(True) + self.widgets.delete_button.clicked.connect(lambda: self.del_me()) self.widgets.delete_button.setFixedWidth(20) @@ -175,7 +170,7 @@ def init_conversion(self): self.widgets.convert_bitrate.setFixedWidth(70) self.widgets.convert_bitrate.addItems( - [f"{x}k" for x in range(32 * self.channels, (256 * self.channels) + 1, 32 * self.channels)] + [f"{x}k" for x in range(16 * self.channels, (256 * self.channels) + 1, 16 * self.channels)] if self.channels else [ "32k", @@ -219,15 +214,19 @@ def update_enable(self): self.parent.reorder(update=True) def update_downmix(self): - channels = self.widgets.downmix.currentIndex() + channels = ( + channel_list[self.widgets.downmix.currentText()] + if self.widgets.downmix.currentIndex() > 0 + else self.channels + ) self.widgets.convert_bitrate.clear() if channels > 0: self.widgets.convert_bitrate.addItems( - [f"{x}k" for x in range(32 * channels, (256 * channels) + 1, 32 * channels)] + [f"{x}k" for x in range(16 * channels, (256 * channels) + 1, 16 * channels)] ) else: self.widgets.convert_bitrate.addItems( - [f"{x}k" for x in range(32 * self.channels, (256 * self.channels) + 1, 32 * self.channels)] + [f"{x}k" for x in range(16 * self.channels, (256 * self.channels) + 1, 16 * self.channels)] ) self.widgets.convert_bitrate.setCurrentIndex(3) self.page_update() @@ -278,8 +277,8 @@ def conversion(self): return {"codec": self.widgets.convert_to.currentText(), "bitrate": self.widgets.convert_bitrate.currentText()} @property - def downmix(self) -> int: - return self.widgets.downmix.currentIndex() + def downmix(self) -> Optional[str]: + return self.widgets.downmix.currentText() if self.widgets.downmix.currentIndex() > 0 else None @property def language(self) -> str: @@ -358,6 +357,7 @@ def lang_match(self, track): def new_source(self, codecs): self.tracks: List[Audio] = [] self._first_selected = False + disable_dup = "nvencc" in self.main.convert_to.lower() for i, x in enumerate(self.app.fastflix.current_video.streams.audio, start=1): track_info = "" tags = x.get("tags", {}) @@ -386,6 +386,7 @@ def new_source(self, codecs): available_audio_encoders=self.available_audio_encoders, enabled=self.lang_match(x), all_info=x, + disable_dup=disable_dup, ) self.tracks.append(new_item) @@ -397,6 +398,20 @@ def new_source(self, codecs): self.update_audio_settings() def allowed_formats(self, allowed_formats=None): + disable_dups = "nvencc" in self.main.convert_to.lower() + tracks_need_removed = False + for track in self.tracks: + track.widgets.dup_button.setDisabled(disable_dups) + if not track.original: + if disable_dups: + tracks_need_removed = True + else: + if disable_dups: + track.widgets.dup_button.hide() + else: + track.widgets.dup_button.show() + if tracks_need_removed: + error_message(t("This encoder does not support duplicating audio tracks, please remove copied tracks!")) if not allowed_formats: return for track in self.tracks: @@ -416,25 +431,86 @@ def update_audio_settings(self): downmix=track.downmix, title=track.title, language=track.language, + profile=track.profile, + channels=track.channels, + enabled=track.enabled, + original=track.original, + raw_info=track.all_info, + friendly_info=track.audio, ) ) self.app.fastflix.current_video.video_settings.audio_tracks = tracks - def reload(self, original_tracks, audio_formats): - enabled_tracks = [x.index for x in original_tracks] - self.new_source(audio_formats) - for track in self.tracks: - enabled = track.index in enabled_tracks - track.widgets.enable_check.setChecked(enabled) - if enabled: - existing_track = [x for x in original_tracks if x.index == track.index][0] - track.widgets.downmix.setCurrentIndex(existing_track.downmix) - track.widgets.convert_to.setCurrentText(existing_track.conversion_codec) - track.widgets.convert_bitrate.setCurrentText(existing_track.conversion_bitrate) - track.widgets.title.setText(existing_track.title) - if existing_track.language: - track.widgets.language.setCurrentText(Lang(existing_track.language).name) - else: - track.widgets.language.setCurrentIndex(0) + def reload(self, original_tracks: List[AudioTrack], audio_formats): + disable_dups = "nvencc" in self.main.convert_to.lower() + + repopulated_tracks = set() + for track in original_tracks: + if track.original: + repopulated_tracks.add(track.index) + + new_track = Audio( + parent=self, + audio=track.friendly_info, + all_info=Box(track.raw_info) if track.raw_info else None, + title=track.title, + language=track.language, + profile=track.profile, + original=track.original, + index=track.index, + outdex=track.outdex, + codec=track.codec, + codecs=audio_formats, + channels=track.channels, + available_audio_encoders=self.available_audio_encoders, + enabled=True, + disable_dup=disable_dups, + ) + + new_track.widgets.downmix.setCurrentText(track.downmix) + new_track.widgets.convert_to.setCurrentText(track.conversion_codec) + new_track.widgets.convert_bitrate.setCurrentText(track.conversion_bitrate) + new_track.widgets.title.setText(track.title) + if track.language: + new_track.widgets.language.setCurrentText(Lang(track.language).name) + else: + new_track.widgets.language.setCurrentIndex(0) + + self.tracks.append(new_track) + + for i, x in enumerate(self.app.fastflix.current_video.streams.audio, start=1): + if x.index in repopulated_tracks: + continue + track_info = "" + tags = x.get("tags", {}) + if tags: + track_info += tags.get("title", "") + # if "language" in tags: + # track_info += f" {tags.language}" + track_info += f" - {x.codec_name}" + if "profile" in x: + track_info += f" ({x.profile})" + track_info += f" - {x.channels} {t('channels')}" + + new_item = Audio( + self, + track_info, + title=tags.get("title"), + language=tags.get("language"), + profile=x.get("profile"), + original=True, + index=x.index, + outdex=i, + codec=x.codec_name, + codecs=audio_formats, + channels=x.channels, + available_audio_encoders=self.available_audio_encoders, + enabled=False, + all_info=x, + disable_dup=disable_dups, + ) + self.tracks.append(new_item) + + self.tracks.sort(key=lambda z: (int(not z.original), z.index)) super()._new_source(self.tracks) diff --git a/fastflix/widgets/panels/cover_panel.py b/fastflix/widgets/panels/cover_panel.py index be29b44c..4a0c7ece 100644 --- a/fastflix/widgets/panels/cover_panel.py +++ b/fastflix/widgets/panels/cover_panel.py @@ -98,7 +98,7 @@ def select_cover(self): self, caption=t("Cover"), directory=str(dirname), - filter=f"{t('Supported Image Files')} (*.png;*.jpeg;*.jpg)", + filter=f"{t('Supported Image Files')} (*.png *.jpeg *.jpg)", ) if not filename or not filename[0]: return @@ -153,7 +153,7 @@ def select_landscape_cover(self): self, caption=t("Landscape Cover"), directory=str(dirname), - filter=f"{t('Supported Image Files')} (*.png;*.jpeg;*.jpg)", + filter=f"{t('Supported Image Files')} (*.png *.jpeg *.jpg)", ) if not filename or not filename[0]: return @@ -230,7 +230,7 @@ def update_cover_settings(self): def cover_passthrough_check(self): checked = self.cover_passthrough_checkbox.isChecked() - if checked: + if checked and "cover" in self.attachments: self.cover_path.setDisabled(True) self.cover_button.setDisabled(True) pixmap = QtGui.QPixmap(str(self.app.fastflix.current_video.work_path / self.attachments.cover.name)) @@ -253,7 +253,7 @@ def small_cover_passthrough_check(self): def cover_land_passthrough_check(self): checked = self.cover_land_passthrough_checkbox.isChecked() - if checked: + if checked and "cover_land" in self.attachments: self.cover_land_path.setDisabled(True) self.landscape_button.setDisabled(True) pixmap = QtGui.QPixmap(str(self.app.fastflix.current_video.work_path / self.attachments.cover_land.name)) diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 7f72377c..609f62dc 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -3,6 +3,7 @@ import copy import sys +import logging import reusables from box import Box @@ -11,10 +12,21 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.video import Video -from fastflix.resources import black_x_icon, down_arrow_icon, edit_box_icon, folder_icon, play_icon, up_arrow_icon -from fastflix.shared import no_border, open_folder +from fastflix.queue import get_queue, save_queue +from fastflix.resources import ( + black_x_icon, + down_arrow_icon, + edit_box_icon, + folder_icon, + play_icon, + up_arrow_icon, + undo_icon, +) +from fastflix.shared import no_border, open_folder, yes_no_message from fastflix.widgets.panels.abstract_list import FlixList +logger = logging.getLogger("fastflix") + done_actions = { "linux": { "shutdown": 'shutdown -h 1 "FastFlix conversion complete, shutting down"', @@ -33,28 +45,26 @@ class EncodeItem(QtWidgets.QTabWidget): - def __init__(self, parent, video: Video, index, first=False, currently_encoding=False): + def __init__(self, parent, video: Video, index, first=False): self.loading = True super().__init__(parent) self.parent = parent self.index = index self.first = first self.last = False - self.video = video - self.currently_encoding = currently_encoding + self.video = video.copy() self.setFixedHeight(60) self.widgets = Box( up_button=QtWidgets.QPushButton(QtGui.QIcon(up_arrow_icon), ""), down_button=QtWidgets.QPushButton(QtGui.QIcon(down_arrow_icon), ""), cancel_button=QtWidgets.QPushButton(QtGui.QIcon(black_x_icon), ""), - reload_buttom=QtWidgets.QPushButton(QtGui.QIcon(edit_box_icon), ""), + reload_button=QtWidgets.QPushButton(QtGui.QIcon(edit_box_icon), ""), + retry_button=QtWidgets.QPushButton(QtGui.QIcon(undo_icon), ""), ) for widget in self.widgets.values(): widget.setStyleSheet(no_border) - if self.currently_encoding: - widget.setDisabled(True) title = QtWidgets.QLabel( video.video_settings.video_title @@ -87,6 +97,7 @@ def __init__(self, parent, video: Video, index, first=False, currently_encoding= open_button.setStyleSheet(no_border) view_button.setStyleSheet(no_border) + add_retry = False status = t("Ready to encode") if video.status.error: status = t("Encoding errored") @@ -94,17 +105,21 @@ def __init__(self, parent, video: Video, index, first=False, currently_encoding= status = f"{t('Encoding complete')}" elif video.status.running: status = ( - f"{t('Encoding command')} {video.status.current_command} {t('of')} " + f"{t('Encoding command')} {video.status.current_command + 1} {t('of')} " f"{len(video.video_settings.conversion_commands)}" ) elif video.status.cancelled: - status = t("Cancelled - Ready to try again") + status = t("Cancelled") + add_retry = True - if not self.currently_encoding: + if not self.video.status.running: self.widgets.cancel_button.clicked.connect(lambda: self.parent.remove_item(self.video)) - self.widgets.reload_buttom.clicked.connect(lambda: self.parent.reload_from_queue(self.video)) - self.widgets.cancel_button.setFixedWidth(25) - self.widgets.reload_buttom.setFixedWidth(25) + self.widgets.reload_button.clicked.connect(lambda: self.parent.reload_from_queue(self.video)) + self.widgets.cancel_button.setFixedWidth(25) + self.widgets.reload_button.setFixedWidth(25) + else: + self.widgets.cancel_button.hide() + self.widgets.reload_button.hide() grid = QtWidgets.QGridLayout() grid.addLayout(self.init_move_buttons(), 0, 0) @@ -117,17 +132,16 @@ def __init__(self, parent, video: Video, index, first=False, currently_encoding= if video.status.complete: grid.addWidget(view_button, 0, 8) grid.addWidget(open_button, 0, 9) + elif add_retry: + grid.addWidget(self.widgets.retry_button, 0, 8) + self.widgets.retry_button.setFixedWidth(25) + self.widgets.retry_button.clicked.connect(lambda: self.parent.retry_video(self.video)) right_buttons = QtWidgets.QHBoxLayout() - right_buttons.addWidget(self.widgets.reload_buttom) + right_buttons.addWidget(self.widgets.reload_button) right_buttons.addWidget(self.widgets.cancel_button) grid.addLayout(right_buttons, 0, 10, alignment=QtCore.Qt.AlignRight) - # grid.addLayout(disposition_layout, 0, 4) - # grid.addWidget(self.widgets.burn_in, 0, 5) - # grid.addLayout(self.init_language(), 0, 6) - # # grid.addWidget(self.init_extract_button(), 0, 6) - # grid.addWidget(self.widgets.enable_check, 0, 8) self.setLayout(grid) self.loading = False @@ -136,10 +150,8 @@ def __init__(self, parent, video: Video, index, first=False, currently_encoding= def init_move_buttons(self): layout = QtWidgets.QVBoxLayout() layout.setSpacing(0) - self.widgets.up_button.setDisabled(True if self.currently_encoding else self.first) self.widgets.up_button.setFixedWidth(20) self.widgets.up_button.clicked.connect(lambda: self.parent.move_up(self)) - self.widgets.down_button.setDisabled(True if self.currently_encoding else self.last) self.widgets.down_button.setFixedWidth(20) self.widgets.down_button.clicked.connect(lambda: self.parent.move_down(self)) layout.addWidget(self.widgets.up_button) @@ -148,11 +160,9 @@ def init_move_buttons(self): def set_first(self, first=True): self.first = first - self.widgets.up_button.setDisabled(True if self.currently_encoding else self.first) def set_last(self, last=True): self.last = last - self.widgets.down_button.setDisabled(True if self.currently_encoding else self.last) def set_outdex(self, outdex): pass @@ -176,7 +186,6 @@ def __init__(self, parent, app: FastFlixApp): self.paused = False self.encode_paused = False self.encoding = False - self.main.status_update_signal.connect(self.update_status) top_layout = QtWidgets.QHBoxLayout() top_layout.addWidget(QtWidgets.QLabel(t("Queue"))) @@ -231,32 +240,98 @@ def __init__(self, parent, app: FastFlixApp): top_layout.addWidget(self.clear_queue, QtCore.Qt.AlignRight) super().__init__(app, parent, t("Queue"), "queue", top_row_layout=top_layout) + try: + self.queue_startup_check() + except Exception: + logger.exception("Could not load queue as it is outdated or malformed. Deleting for safety.") + save_queue([], queue_file=self.app.fastflix.queue_path, config=self.app.fastflix.config) + + def queue_startup_check(self): + new_queue = get_queue(self.app.fastflix.queue_path, self.app.fastflix.config) + # self.app.fastflix.queue.append(item) + reset_vids = [] + remove_vids = [] + for i, video in enumerate(new_queue): + if video.status.running: + reset_vids.append(i) + if video.status.complete: + remove_vids.append(video) + + for index in reset_vids: + vid: Video = new_queue.pop(index) + vid.status.clear() + new_queue.insert(index, vid) + + for video in remove_vids: + new_queue.remove(video) + + if new_queue: + if yes_no_message( + f"{t('Not all items in the queue were completed')}\n" + f"{t('Would you like to keep them in the queue?')}", + title="Recover Queue Items", + ): + with self.app.fastflix.queue_lock: + for item in new_queue: + self.app.fastflix.queue.append(item) + # self.app.fastflix.queue = [] + with self.app.fastflix.queue_lock: + save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) + self.new_source() def reorder(self, update=True): super().reorder(update=update) - self.app.fastflix.queue = [track.video for track in self.tracks] + + with self.app.fastflix.queue_lock: + for i in range(len(self.app.fastflix.queue)): + self.app.fastflix.queue.pop() + for track in self.tracks: + self.app.fastflix.queue.append(track.video) + + for track in self.tracks: + track.widgets.up_button.setDisabled(False) + track.widgets.down_button.setDisabled(False) + if self.tracks: + self.tracks[0].widgets.up_button.setDisabled(True) + self.tracks[-1].widgets.down_button.setDisabled(True) def new_source(self): for track in self.tracks: track.close() self.tracks = [] for i, video in enumerate(self.app.fastflix.queue, start=1): - self.tracks.append(EncodeItem(self, video, index=i, currently_encoding=self.encoding)) + self.tracks.append(EncodeItem(self, video, index=i)) + if self.tracks: + self.tracks[0].widgets.up_button.setDisabled(True) + self.tracks[-1].widgets.down_button.setDisabled(True) super()._new_source(self.tracks) def clear_complete(self): for queued_item in self.tracks: if queued_item.video.status.complete: - self.remove_item(queued_item.video) - - def remove_item(self, video): - self.app.fastflix.queue.remove(video) + self.remove_item(queued_item.video, part_of_clear=True) + with self.app.fastflix.queue_lock: + save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) self.new_source() + def remove_item(self, video, part_of_clear=False): + with self.app.fastflix.queue_lock: + for i, vid in enumerate(self.app.fastflix.queue): + if vid.uuid == video.uuid: + pos = i + break + else: + logger.error("No matching video found to remove from queue") + return + self.app.fastflix.queue.pop(pos) + if not part_of_clear: + save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) + if not part_of_clear: + self.new_source() + def reload_from_queue(self, video): self.main.reload_video_from_queue(video) - self.app.fastflix.queue.remove(video) - self.new_source() + self.remove_item(video) def reset_pause_encode(self): self.pause_encode.setText(t("Pause Encode")) @@ -267,6 +342,11 @@ def pause_resume_queue(self): if self.paused: self.pause_queue.setText(t("Pause Queue")) self.pause_queue.setIcon(self.app.style().standardIcon(QtWidgets.QStyle.SP_MediaPause)) + for i, video in enumerate(self.app.fastflix.queue): + if video.status.ready: + self.main.converting = True + self.main.set_convert_button(False) + break self.app.fastflix.worker_queue.put(["resume queue"]) else: self.pause_queue.setText(t("Resume Queue")) @@ -300,14 +380,21 @@ def set_after_done(self): self.app.fastflix.worker_queue.put(["set after done", command]) - def update_status(self, status: str): - command, *_ = status.split("|") - self.encoding = command not in ("complete", "error", "cancelled", "converted") - for track in self.tracks: - for widget in track.widgets.values(): - widget.setDisabled(self.encoding) - if self.tracks: - self.tracks[0].set_first(True) - self.tracks[-1].set_last(True) - if self.encoding and self.paused: - self.pause_resume_queue() + def retry_video(self, current_video): + with self.app.fastflix.queue_lock: + for i, video in enumerate(self.app.fastflix.queue): + if video.uuid == current_video.uuid: + video_pos = i + break + else: + logger.error(f"Can't find video {current_video.uuid} in queue to update its status") + return + + video = self.app.fastflix.queue.pop(video_pos) + video.status.cancelled = False + video.status.current_command = 0 + + self.app.fastflix.queue.insert(video_pos, video) + save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) + + self.new_source() diff --git a/fastflix/widgets/panels/status_panel.py b/fastflix/widgets/panels/status_panel.py index 06284795..638e5a44 100644 --- a/fastflix/widgets/panels/status_panel.py +++ b/fastflix/widgets/panels/status_panel.py @@ -20,6 +20,7 @@ class StatusPanel(QtWidgets.QWidget): speed = QtCore.Signal(str) bitrate = QtCore.Signal(str) + nvencc_signal = QtCore.Signal(str) tick_signal = QtCore.Signal() def __init__(self, parent, app: FastFlixApp): @@ -29,7 +30,7 @@ def __init__(self, parent, app: FastFlixApp): self.current_video: Optional[Video] = None self.started_at = None - self.ticker_thread = ElapsedTimeTicker(self, self.main.status_update_signal, self.tick_signal) + self.ticker_thread = ElapsedTimeTicker(self, self.tick_signal) self.ticker_thread.start() layout = QtWidgets.QGridLayout() @@ -62,6 +63,7 @@ def __init__(self, parent, app: FastFlixApp): self.speed.connect(self.update_speed) self.bitrate.connect(self.update_bitrate) + self.nvencc_signal.connect(self.update_nvencc) self.main.status_update_signal.connect(self.on_status_update) self.tick_signal.connect(self.update_time_elapsed) @@ -72,7 +74,7 @@ def cleanup(self): def get_movie_length(self): if not self.current_video: - return + return 0 return ( self.current_video.video_settings.end_time or self.current_video.duration ) - self.current_video.video_settings.start_time @@ -114,7 +116,7 @@ def update_bitrate(self, bitrate): except AttributeError: self.size_label.setText(f"{t('Size Estimate')}: N/A") except Exception: - logger.exception(f"can't update bitrate: {bitrate}") + logger.exception(f"can't update bitrate: {bitrate} - length {self.get_movie_length()}") self.size_label.setText(f"{t('Size Estimate')}: N/A") else: if not size_eta: @@ -122,18 +124,17 @@ def update_bitrate(self, bitrate): self.size_label.setText(f"{t('Size Estimate')}: {size_eta:.2f}MB") - def update_title_bar(self): - pass - - def set_started_at(self, msg): - try: - started_at = datetime.datetime.fromisoformat(msg.split("|")[-1]) - except Exception: - logger.exception("Unable to parse start time, assuming it was now") - self.started_at = datetime.datetime.now(datetime.timezone.utc) - return - - self.started_at = started_at + def update_nvencc(self, raw_line): + """ + Example line: + [53.1%] 19/35 frames: 150.57 fps, 5010 kb/s, remain 0:01:55, GPU 10%, VE 96%, VD 42%, est out size 920.6MB + """ + for section in raw_line.split(","): + section = section.strip() + if section.startswith("remain"): + self.eta_label.setText(f"{t('Time Left')}: {section.rsplit(maxsplit=1)[1]}") + elif section.startswith("est out size"): + self.size_label.setText(f"{t('Size Estimate')}: {section.rsplit(maxsplit=1)[1]}") def update_time_elapsed(self): now = datetime.datetime.now(datetime.timezone.utc) @@ -150,11 +151,9 @@ def update_time_elapsed(self): self.time_elapsed_label.setText(f"{t('Time Elapsed')}: {timedelta_to_str(time_elapsed)}") - def on_status_update(self, msg): - update_type = msg.split("|")[0] - - if update_type == "running": - self.set_started_at(msg) + def on_status_update(self): + # If there was a status change, we need to restart ticker no matter what + self.started_at = datetime.datetime.now(datetime.timezone.utc) def close(self): self.ticker_thread.terminate() @@ -164,6 +163,7 @@ def close(self): class Logs(QtWidgets.QTextBrowser): log_signal = QtCore.Signal(str) clear_window = QtCore.Signal(str) + timer_signal = QtCore.Signal(str) def __init__(self, parent, app: FastFlixApp, main, log_queue): super(Logs, self).__init__(parent) @@ -172,9 +172,9 @@ def __init__(self, parent, app: FastFlixApp, main, log_queue): self.main = main self.status_panel = parent self.current_video = None - self.current_command = None self.log_signal.connect(self.update_text) self.clear_window.connect(self.blank) + self.timer_signal.connect(self.timer_update) self.log_updater = LogUpdater(self, log_queue) self.log_updater.start() @@ -196,26 +196,34 @@ def update_text(self, msg): self.status_panel.bitrate.emit(frame.get("bitrate", "")) except Exception: pass + elif "remain" in msg: + self.status_panel.nvencc_signal.emit(msg) self.append(msg) def blank(self, data): _, video_uuid, command_uuid = data.split(":") - self.parent.current_video = self.main.find_video(video_uuid) try: + self.parent.current_video = self.main.find_video(video_uuid) self.current_command = self.main.find_command(self.parent.current_video, command_uuid) except FlixError: + logger.error(f"Couldn't find video or command for UUID {video_uuid}:{command_uuid}") + self.parent.current_video = None self.current_command = None self.setText("") - self.parent.update_title_bar() + self.parent.started_at = datetime.datetime.now(datetime.timezone.utc) + + def timer_update(self, cmd): + self.parent.ticker_thread.state_signal.emit(cmd == "START") def closeEvent(self, event): self.hide() class ElapsedTimeTicker(QtCore.QThread): - stop_signal = QtCore.Signal() + state_signal = QtCore.Signal(bool) + stop_signal = QtCore.Signal() # Clean exit of program - def __init__(self, parent, status_update_signal, tick_signal): + def __init__(self, parent, tick_signal): super().__init__(parent) self.parent = parent self.tick_signal = tick_signal @@ -223,7 +231,7 @@ def __init__(self, parent, status_update_signal, tick_signal): self.send_tick_signal = False self.stop_received = False - status_update_signal.connect(self.on_status_update) + self.state_signal.connect(self.set_state) self.stop_signal.connect(self.on_stop) def __del__(self): @@ -240,13 +248,8 @@ def run(self): logger.debug("Ticker thread stopped") - def on_status_update(self, msg): - update_type = msg.split("|")[0] - - if update_type in ("complete", "error", "cancelled", "converted"): - self.send_tick_signal = False - else: - self.send_tick_signal = True + def set_state(self, state): + self.send_tick_signal = state def on_stop(self): self.stop_received = True @@ -266,6 +269,9 @@ def run(self): msg = self.log_queue.get() if msg.startswith("CLEAR_WINDOW"): self.parent.clear_window.emit(msg) + self.parent.timer_signal.emit("START") + elif msg == "STOP_TIMER": + self.parent.timer_signal.emit("STOP") elif msg == "UPDATE_QUEUE": self.parent.status_panel.main.video_options.update_queue(currently_encoding=self.parent.converting) else: diff --git a/fastflix/widgets/profile_window.py b/fastflix/widgets/profile_window.py index 8dc063bb..92031524 100644 --- a/fastflix/widgets/profile_window.py +++ b/fastflix/widgets/profile_window.py @@ -2,6 +2,7 @@ import shutil from pathlib import Path +import logging from box import Box from iso639 import Lang @@ -21,11 +22,16 @@ rav1eSettings, x264Settings, x265Settings, + NVEncCSettings, + NVEncCAVCSettings, + FFmpegNVENCSettings, ) from fastflix.shared import error_message language_list = sorted((k for k, v in Lang._data["name"].items() if v["pt2B"] and v["pt1"]), key=lambda x: x.lower()) +logger = logging.getLogger("fastflix") + class ProfileWindow(QtWidgets.QWidget): def __init__(self, app: FastFlixApp, main, *args, **kwargs): @@ -130,7 +136,7 @@ def save(self): auto_crop=self.auto_crop.isChecked(), keep_aspect_ratio=self.main.widgets.scale.keep_aspect.isChecked(), fast_seek=self.main.fast_time, - rotate=self.main.rotation_to_transpose(), + rotate=self.main.widgets.rotate.currentIndex(), vertical_flip=v_flip, horizontal_flip=h_flip, copy_chapters=self.main.copy_chapters, @@ -150,22 +156,31 @@ def save(self): if isinstance(self.encoder, x265Settings): new_profile.x265 = self.encoder - if isinstance(self.encoder, x264Settings): + elif isinstance(self.encoder, x264Settings): new_profile.x264 = self.encoder - if isinstance(self.encoder, rav1eSettings): + elif isinstance(self.encoder, rav1eSettings): new_profile.rav1e = self.encoder - if isinstance(self.encoder, SVTAV1Settings): + elif isinstance(self.encoder, SVTAV1Settings): new_profile.svt_av1 = self.encoder - if isinstance(self.encoder, VP9Settings): + elif isinstance(self.encoder, VP9Settings): new_profile.vp9 = self.encoder - if isinstance(self.encoder, AOMAV1Settings): + elif isinstance(self.encoder, AOMAV1Settings): new_profile.aom_av1 = self.encoder - if isinstance(self.encoder, GIFSettings): + elif isinstance(self.encoder, GIFSettings): new_profile.gif = self.encoder - if isinstance(self.encoder, WebPSettings): + elif isinstance(self.encoder, WebPSettings): new_profile.webp = self.encoder - if isinstance(self.encoder, CopySettings): + elif isinstance(self.encoder, CopySettings): new_profile.copy_settings = self.encoder + elif isinstance(self.encoder, NVEncCSettings): + new_profile.nvencc_hevc = self.encoder + elif isinstance(self.encoder, NVEncCAVCSettings): + new_profile.nvencc_avc = self.encoder + elif isinstance(self.encoder, FFmpegNVENCSettings): + new_profile.ffmpeg_hevc_nvenc = self.encoder + else: + logger.error("Profile cannot be saved! Unknown encoder type.") + return self.app.fastflix.config.profiles[profile_name] = new_profile self.app.fastflix.config.selected_profile = profile_name @@ -187,3 +202,4 @@ def delete_current_profile(self): self.main.widgets.profile_box.addItems(self.app.fastflix.config.profiles.keys()) self.main.loading_video = False self.main.widgets.profile_box.setCurrentText("Standard Profile") + self.main.widgets.convert_to.setCurrentIndex(0) diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 1137406b..3294250c 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -17,6 +17,7 @@ language_list = sorted((k for k, v in Lang._data["name"].items() if v["pt2B"] and v["pt1"]), key=lambda x: x.lower()) known_language_list = ["English", "Chinese", "Italian", "French", "Spanish", "German"] +possible_detect_points = ["1", "2", "4", "6", "8", "10", "15", "20", "25", "50", "100"] # "Japanese", "Korean", "Hindi", "Russian", "Portuguese" @@ -105,18 +106,50 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): self.flat_ui = QtWidgets.QCheckBox(t("Flat UI")) self.flat_ui.setChecked(self.app.fastflix.config.flat_ui) + self.crop_detect_points_widget = QtWidgets.QComboBox() + self.crop_detect_points_widget.addItems(possible_detect_points) + + try: + self.crop_detect_points_widget.setCurrentIndex( + possible_detect_points.index(str(self.app.fastflix.config.crop_detect_points)) + ) + except ValueError: + self.crop_detect_points_widget.setCurrentIndex(5) + + nvencc_label = QtWidgets.QLabel("NVEncC") + self.nvencc_path = QtWidgets.QLineEdit() + if self.app.fastflix.config.nvencc: + self.nvencc_path.setText(str(self.app.fastflix.config.nvencc)) + nvenc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + nvenc_path_button.clicked.connect(lambda: self.select_nvencc()) + layout.addWidget(nvencc_label, 12, 0) + layout.addWidget(self.nvencc_path, 12, 1) + layout.addWidget(nvenc_path_button, 12, 2) + + hdr10_parser_label = QtWidgets.QLabel(t("HDR10+ Parser")) + self.hdr10_parser_path = QtWidgets.QLineEdit() + if self.app.fastflix.config.hdr10plus_parser: + self.hdr10_parser_path.setText(str(self.app.fastflix.config.hdr10plus_parser)) + nvenc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + nvenc_path_button.clicked.connect(lambda: self.select_hdr10_parser()) + layout.addWidget(hdr10_parser_label, 13, 0) + layout.addWidget(self.hdr10_parser_path, 13, 1) + layout.addWidget(nvenc_path_button, 13, 2) + layout.addWidget(self.use_sane_audio, 7, 0, 1, 2) layout.addWidget(self.disable_version_check, 8, 0, 1, 2) layout.addWidget(QtWidgets.QLabel(t("GUI Logging Level")), 9, 0) layout.addWidget(self.logger_level_widget, 9, 1) layout.addWidget(self.flat_ui, 10, 0, 1, 2) + layout.addWidget(QtWidgets.QLabel(t("Crop Detect Points")), 11, 0, 1, 1) + layout.addWidget(self.crop_detect_points_widget, 11, 1, 1, 1) button_layout = QtWidgets.QHBoxLayout() button_layout.addStretch() button_layout.addWidget(cancel) button_layout.addWidget(save) - layout.addLayout(button_layout, 11, 0, 1, 3) + layout.addLayout(button_layout, 15, 0, 1, 3) self.setLayout(layout) @@ -153,6 +186,17 @@ def save(self): log_level = (self.logger_level_widget.currentIndex() + 1) * 10 self.app.fastflix.config.logging_level = log_level logger.setLevel(log_level) + self.app.fastflix.config.crop_detect_points = int(self.crop_detect_points_widget.currentText()) + + new_nvencc = Path(self.nvencc_path.text()) if self.nvencc_path.text() else None + if self.app.fastflix.config.nvencc != new_nvencc: + restart_needed = True + self.app.fastflix.config.nvencc = new_nvencc + + new_hdr10_parser = Path(self.hdr10_parser_path.text()) if self.hdr10_parser_path.text() else None + if self.app.fastflix.config.hdr10plus_parser != new_hdr10_parser: + restart_needed = True + self.app.fastflix.config.hdr10plus_parser = new_hdr10_parser self.main.config_update() self.app.fastflix.config.save() @@ -169,6 +213,24 @@ def select_ffmpeg(self): return self.ffmpeg_path.setText(filename[0]) + def select_nvencc(self): + dirname = Path(self.nvencc_path.text()).parent + if not dirname.exists(): + dirname = Path() + filename = QtWidgets.QFileDialog.getOpenFileName(self, caption="NVEncC location", directory=str(dirname)) + if not filename or not filename[0]: + return + self.nvencc_path.setText(filename[0]) + + def select_hdr10_parser(self): + dirname = Path(self.hdr10_parser_path.text()).parent + if not dirname.exists(): + dirname = Path() + filename = QtWidgets.QFileDialog.getOpenFileName(self, caption="hdr10+ parser", directory=str(dirname)) + if not filename or not filename[0]: + return + self.hdr10_parser_path.setText(filename[0]) + @staticmethod def path_check(name, new_path): if not new_path.exists(): diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index 30eb8787..0d59e4fd 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -104,6 +104,8 @@ def get_settings(self): self.main.container.profile.update_settings() def new_source(self): + if not self.app.fastflix.current_video: + return if getattr(self.main.current_encoder, "enable_audio", False): self.audio.new_source(self.audio_formats) if getattr(self.main.current_encoder, "enable_subtitles", False): @@ -124,7 +126,6 @@ def refresh(self): self.subtitles.refresh() self.advanced.update_settings() self.main.container.profile.update_settings() - self.debug.reset() def update_profile(self): self.current_settings.update_profile() @@ -139,20 +140,34 @@ def update_profile(self): self.main.container.profile.update_settings() def reload(self): - self.current_settings.reload() + self.change_conversion(self.app.fastflix.current_video.video_settings.video_encoder_settings.name) + self.main.widgets.convert_to.setCurrentIndex( + list(self.app.fastflix.encoders.keys()).index( + self.app.fastflix.current_video.video_settings.video_encoder_settings.name + ) + ) + try: + self.current_settings.reload() + except Exception: + logger.exception("Should not have happened, could not reload from queue") + return if self.app.fastflix.current_video: streams = copy.deepcopy(self.app.fastflix.current_video.streams) settings = copy.deepcopy(self.app.fastflix.current_video.video_settings) audio_tracks = settings.audio_tracks subtitle_tracks = settings.subtitle_tracks - if getattr(self.main.current_encoder, "enable_audio", False): - self.audio.reload(audio_tracks, self.audio_formats) - if getattr(self.main.current_encoder, "enable_subtitles", False): - self.subtitles.reload(subtitle_tracks) - if getattr(self.main.current_encoder, "enable_attachments", False): - self.attachments.reload_from_queue(streams, settings) - self.advanced.reset(settings=settings) - self.info.reset() + try: + if getattr(self.main.current_encoder, "enable_audio", False): + self.audio.reload(audio_tracks, self.audio_formats) + if getattr(self.main.current_encoder, "enable_subtitles", False): + self.subtitles.reload(subtitle_tracks) + if getattr(self.main.current_encoder, "enable_attachments", False): + self.attachments.reload_from_queue(streams, settings) + self.advanced.reset(settings=settings) + self.info.reset() + except Exception: + logger.exception("Should not have happened, could not reload from queue") + return self.debug.reset() def clear_tracks(self): @@ -165,7 +180,7 @@ def clear_tracks(self): self.info.reset() self.debug.reset() - def update_queue(self, currently_encoding=False): + def update_queue(self): self.queue.new_source() def show_queue(self): diff --git a/fastflix/windows_tools.py b/fastflix/windows_tools.py new file mode 100644 index 00000000..a3d40af5 --- /dev/null +++ b/fastflix/windows_tools.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + + +tool_window = None +tool_icon = None + + +def show_windows_notification(title, msg, icon_path): + global tool_window, tool_icon + from win32api import GetModuleHandle + from win32con import ( + CW_USEDEFAULT, + IMAGE_ICON, + LR_DEFAULTSIZE, + LR_LOADFROMFILE, + WM_USER, + WS_OVERLAPPED, + WS_SYSMENU, + ) + from win32gui import ( + NIF_ICON, + NIF_INFO, + NIF_MESSAGE, + NIF_TIP, + NIM_ADD, + NIM_MODIFY, + WNDCLASS, + CreateWindow, + LoadImage, + RegisterClass, + Shell_NotifyIcon, + UpdateWindow, + ) + + wc = WNDCLASS() + hinst = wc.hInstance = GetModuleHandle(None) + wc.lpszClassName = "FastFlix" + if not tool_window: + tool_window = CreateWindow( + RegisterClass(wc), + "Taskbar", + WS_OVERLAPPED | WS_SYSMENU, + 0, + 0, + CW_USEDEFAULT, + CW_USEDEFAULT, + 0, + 0, + hinst, + None, + ) + UpdateWindow(tool_window) + + icon_flags = LR_LOADFROMFILE | LR_DEFAULTSIZE + tool_icon = LoadImage(hinst, icon_path, IMAGE_ICON, 0, 0, icon_flags) + + flags = NIF_ICON | NIF_MESSAGE | NIF_TIP + nid = (tool_window, 0, flags, WM_USER + 20, tool_icon, "FastFlix Notifications") + Shell_NotifyIcon(NIM_ADD, nid) + + Shell_NotifyIcon( + NIM_MODIFY, (tool_window, 0, NIF_INFO, WM_USER + 20, tool_icon, "Balloon Tooltip", msg, 200, title, 4) + ) + + +def cleanup_windows_notification(): + from win32gui import DestroyWindow, UnregisterClass + + if tool_window: + DestroyWindow(tool_window) + UnregisterClass("FastFlix", None) diff --git a/pyproject.toml b/pyproject.toml index 00d21ab6..9414fb76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,3 @@ -[tool.poetry] -name = "fastflix" -version = "4.0.0" -description = "Easy to use video encoder GUI" -authors = ["Chris Griffith "] -license = "MIT" -include = ["fastflix/data/rotations/*.png", "fastflix/data/encoders/*.png", "fastflix/data/icon.ico", "fastflix/CHANGES"] -readme = "README.md" - -[tool.poetry.dependencies] -python = ">=3.6,<3.10" -appdirs = "^1.4.4" -qtpy = "^1.9.0" -python-box = {version = "^5.1.1", extras = ["all"]} -requests = "^2.24.0" -reusables = "^0.9.5" -"ruamel.yaml" = "^0.16.10" -mistune = "^0.8.4" -coloredlogs = "^14.0" -psutil = "^5.7.2" -PySide2 = "^5.15.1" -colorama = "^0.4.4" -iso639-lang = "^0.0.8" - -[tool.poetry.dev-dependencies] - -[build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" - -[tool.poetry.scripts] -fastflix = 'fastflix.__main__:start_fastflix' - [tool.black] line-length = 120 target-version = ['py36', 'py37', 'py38'] diff --git a/requirements-build.txt b/requirements-build.txt index bef8b02c..3ca1cc45 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1,14 +1,15 @@ -appdirs -colorama -coloredlogs -iso639-lang -mistune -psutil -pydantic -pyinstaller==4.1 -pyqt5 -python-box -qtpy -requests -reusables +appdirs==1.4.4 +colorama==0.4.4 +coloredlogs==15.0 +iso639-lang==0.0.9 +mistune==0.8.4 +pathvalidate==2.3.2 +psutil==5.8.0 +pydantic==1.8.1 +pyinstaller==4.2 +pyqt5==5.15.3 +python-box==5.3.0 +qtpy==1.9.0 +requests==2.25.1 +reusables==0.9.6 ruamel.yaml<0.16 diff --git a/requirements.txt b/requirements.txt index 339c8a90..7b86ccba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,10 @@ colorama coloredlogs iso639-lang mistune +pathvalidate psutil pydantic -pyside2 +pyqt5 python-box qtpy requests diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 825b863f..0ad54442 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +from subprocess import run, PIPE import re from distutils.version import StrictVersion @@ -11,10 +11,6 @@ def test_version(): with open("fastflix/version.py") as version_file: code_version = StrictVersion(re.search(r"__version__ *= *['\"](.+)['\"]", version_file.read()).group(1)) - pyproject_version = StrictVersion(Box.from_toml(filename="pyproject.toml").tool.poetry.version) - - assert code_version == pyproject_version, f"Code Version {code_version} vs PyProject Version {pyproject_version}" - url = "https://api.github.com/repos/cdgriffith/FastFlix/releases/latest" data = requests.get(url).json() assert (