From e8bcc38a53e91aeba3bb7c86b5ed584c24407eb4 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sun, 22 Feb 2026 11:00:08 -0600 Subject: [PATCH 01/12] * Fixing cover extraction to not be during video load and blocking, but a background task --- CHANGES | 4 ++ fastflix/data/languages.yaml | 15 +++++ fastflix/version.py | 2 +- fastflix/widgets/background_tasks.py | 19 +++++- fastflix/widgets/main.py | 85 +++++++++++++++++++++----- fastflix/widgets/panels/cover_panel.py | 82 ++++++++++++++++++------- 6 files changed, 170 insertions(+), 37 deletions(-) diff --git a/CHANGES b/CHANGES index 54038609..3884f257 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,9 @@ # Changelog +## Version 6.1.2 + +* Fixing cover extraction to not be during video load and blocking, but a background task + ## Version 6.1.1 * Adding "Show completion popup message" (default off) and "Show error popup message" (default on) settings, replacing the old "Disable completion and error messages" toggle (thanks to Balthazar) diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index cab4c78f..ea865a12 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -14157,3 +14157,18 @@ Show error popup message: ukr: Показати спливаюче повідомлення про помилку kor: 오류 팝업 메시지 표시 ron: Afișați mesajul pop-up de eroare +Cover extraction complete: + eng: Cover extraction complete + deu: Deckelentfernung abgeschlossen + fra: Extraction de la couverture terminée + ita: Estrazione del coperchio completata + spa: Extracción completa de la cubierta + jpn: カバー取り出し完了 + rus: Извлечение крышки завершено + por: Extração da tampa concluída + swe: Täckningsextraktion slutförd + pol: Wyciąganie pokrywy zakończone + chs: 盖板提取完成 + ukr: Зняття кришки завершено + kor: 표지 추출 완료 + ron: Extragerea capacului completă diff --git a/fastflix/version.py b/fastflix/version.py index 18dc9462..6129e179 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "6.1.1" +__version__ = "6.1.2" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py index 22b8f1ca..3dec9b59 100644 --- a/fastflix/widgets/background_tasks.py +++ b/fastflix/widgets/background_tasks.py @@ -10,6 +10,7 @@ from PySide6 import QtCore from ffmpeg_normalize import FFmpegNormalize +from fastflix.flix import extract_attachments from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.shared import clean_file_string @@ -30,7 +31,7 @@ def _format_command(command): return " ".join(parts) -__all__ = ["ThumbnailCreator", "ExtractSubtitleSRT", "ExtractHDR10"] +__all__ = ["ThumbnailCreator", "ExtractSubtitleSRT", "ExtractHDR10", "ExtractCovers"] class ThumbnailCreator(QtCore.QThread): @@ -496,3 +497,19 @@ def run(self): stdout, stderr = process_two.communicate() self.main.thread_logging_signal.emit(f"DEBUG: HDR10+ Extract: {stdout}") self.signal.emit(str(output)) + + +class ExtractCovers(QtCore.QThread): + def __init__(self, app: FastFlixApp, main, signal): + super().__init__(main) + self.main = main + self.app = app + self.signal = signal + + def run(self): + try: + extract_attachments(app=self.app) + self.main.thread_logging_signal.emit(f"INFO:{t('Cover extraction complete')}") + except Exception as err: + self.main.thread_logging_signal.emit(f"WARNING:{t('Cover extraction failed')}: {err}") + self.signal.emit() diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 1a99f019..a893ff3f 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -28,7 +28,6 @@ from fastflix.flix import ( detect_hdr10_plus, detect_interlaced, - extract_attachments, generate_thumbnail_command, get_auto_crop, parse, @@ -55,7 +54,7 @@ shrink_text_to_fit, ) from fastflix.windows_tools import prevent_sleep_mode, allow_sleep_mode -from fastflix.widgets.background_tasks import ThumbnailCreator +from fastflix.widgets.background_tasks import ThumbnailCreator, ExtractCovers from fastflix.widgets.status_bar import Task, STATE_ENCODING, STATE_ERROR, STATE_COMPLETE, STATE_IDLE from fastflix.widgets.video_options import VideoOptions from fastflix.widgets.windows.crop_window import CropPreviewWindow @@ -134,6 +133,7 @@ class MainWidgets(BaseModel): preview: QtWidgets.QLabel = None convert_to: QtWidgets.QComboBox = None convert_button: QtWidgets.QPushButton = None + queue_button: QtWidgets.QPushButton = None deinterlace: QtWidgets.QCheckBox = None remove_hdr: QtWidgets.QCheckBox = None profile_box: QtWidgets.QComboBox = None @@ -170,6 +170,7 @@ class Main(QtWidgets.QWidget): thread_logging_signal = QtCore.Signal(str) encoding_progress_signal = QtCore.Signal(int) encoding_status_signal = QtCore.Signal(str, str) # (message, state) + cover_extraction_complete = QtCore.Signal() def __init__(self, parent, app: FastFlixApp): super().__init__(parent) @@ -186,6 +187,8 @@ def __init__(self, parent, app: FastFlixApp): self.last_thumb_hash = "" self.page_updating = False self.previous_encoder_no_audio = False + self._cover_extract_thread = None + self._cover_extract_video_source = None self.crop_preview = CropPreviewWindow(self) @@ -275,6 +278,7 @@ def __init__(self, parent, app: FastFlixApp): self.thumbnail_complete.connect(self.thumbnail_generated) self.status_update_signal.connect(self.status_update) self.thread_logging_signal.connect(self.thread_logger) + self.cover_extraction_complete.connect(self.on_cover_extraction_complete) self.encoding_worker = None self.command_runner = None self.side_data = Box() @@ -432,13 +436,13 @@ def init_top_bar_right(self): top_bar_h = scaler.scale(HEIGHTS.TOP_BAR_BUTTON) - queue = QtWidgets.QPushButton(QtGui.QIcon(onyx_queue_add_icon), f"{t('Add to Queue')} ") - queue.setIconSize(scaler.scale_size(ICONS.LARGE, ICONS.LARGE)) - queue.setFixedHeight(top_bar_h) - queue.setStyleSheet(theme) - queue.setLayoutDirection(QtCore.Qt.RightToLeft) - queue.clicked.connect(lambda: self.add_to_queue()) - self._top_bar_widgets.append(queue) + self.widgets.queue_button = QtWidgets.QPushButton(QtGui.QIcon(onyx_queue_add_icon), f"{t('Add to Queue')} ") + self.widgets.queue_button.setIconSize(scaler.scale_size(ICONS.LARGE, ICONS.LARGE)) + self.widgets.queue_button.setFixedHeight(top_bar_h) + self.widgets.queue_button.setStyleSheet(theme) + self.widgets.queue_button.setLayoutDirection(QtCore.Qt.RightToLeft) + self.widgets.queue_button.clicked.connect(lambda: self.add_to_queue()) + self._top_bar_widgets.append(self.widgets.queue_button) self.widgets.convert_button = QtWidgets.QPushButton(QtGui.QIcon(onyx_convert_icon), f"{t('Convert')} ") self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.LARGE, ICONS.LARGE)) @@ -448,7 +452,7 @@ def init_top_bar_right(self): self.widgets.convert_button.setLayoutDirection(QtCore.Qt.RightToLeft) self.widgets.convert_button.clicked.connect(lambda: self.encode_video()) top_bar_right.addStretch(1) - top_bar_right.addWidget(queue) + top_bar_right.addWidget(self.widgets.queue_button) top_bar_right.addWidget(self.widgets.convert_button) return top_bar_right @@ -1877,7 +1881,7 @@ def build_crop(self) -> Union[Crop, None]: def disable_all(self): for name, widget in self.widgets.items(): - if name in ("preview", "convert_button", "pause_resume", "convert_to", "profile_box"): + if name in ("preview", "convert_button", "queue_button", "pause_resume", "convert_to", "profile_box"): continue if isinstance(widget, dict): for sub_widget in widget.values(): @@ -1893,7 +1897,7 @@ def disable_all(self): def enable_all(self): for name, widget in self.widgets.items(): - if name in {"preview", "convert_button", "pause_resume", "convert_to", "profile_box"}: + if name in {"preview", "convert_button", "queue_button", "pause_resume", "convert_to", "profile_box"}: continue if isinstance(widget, dict): for sub_widget in widget.values(): @@ -1909,6 +1913,14 @@ def enable_all(self): self.update_resolution() def clear_current_video(self): + if self._cover_extract_thread and self._cover_extract_thread.isRunning(): + self._cover_extract_thread.wait(2000) + self._cover_extract_thread = None + self.widgets.queue_button.setEnabled(True) + self.widgets.queue_button.setToolTip("") + self.widgets.convert_button.setEnabled(True) + self.widgets.convert_button.setToolTip("") + self.loading_video = True self.app.fastflix.current_video = None self.input_video = None @@ -1963,7 +1975,6 @@ def reload_video_from_queue(self, video: Video): 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 self.source_video_path_widget.setText(str(self.input_video)) hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams] @@ -2032,6 +2043,8 @@ def reload_video_from_queue(self, video: Video): self.video_options.reload() self.enable_all() + self._start_cover_extraction() + self.app.fastflix.current_video.status = Status() self.update_video_info_labels() self.loading_video = False @@ -2050,7 +2063,6 @@ def update_video_info(self, hide_progress=False): self.app.fastflix.current_video.video_settings.template_generated_name = name tasks = [ Task(t("Parse Video details"), parse), - Task(t("Extract covers"), extract_attachments), Task(t("Determine HDR details"), parse_hdr_details), Task(t("Detect HDR10+"), detect_hdr10_plus), ] @@ -2132,6 +2144,8 @@ def update_video_info(self, hide_progress=False): # self.widgets.convert_button.setDisabled(False) # self.widgets.convert_button.setStyleSheet("background-color:green;") + self._start_cover_extraction() + self.loading_video = False self.update_resolution_labels() self.update_video_info_labels() @@ -2495,6 +2509,8 @@ def close(self, no_cleanup=False, from_container=False): shutil.rmtree(self.temp_dir, ignore_errors=True) except Exception: pass + if self._cover_extract_thread and self._cover_extract_thread.isRunning(): + self._cover_extract_thread.wait(3000) self.video_options.cleanup() self.notifier.request_shutdown() self.notifier.wait(1000) # Wait up to 1 second for graceful shutdown @@ -2540,6 +2556,47 @@ def encoding_checks(self): return False return True + def _start_cover_extraction(self): + """Start background cover extraction thread.""" + if self.app.fastflix.config.disable_cover_extraction: + return + if not self.app.fastflix.current_video: + return + has_covers = any( + track.get("tags", {}).get("filename", "").rsplit(".", 1)[0] + in ("cover", "small_cover", "cover_land", "small_cover_land") + for track in self.app.fastflix.current_video.streams.attachment + ) + if not has_covers: + return + + self.widgets.queue_button.setDisabled(True) + self.widgets.queue_button.setToolTip(t("Extracting cover images...")) + self.widgets.convert_button.setDisabled(True) + self.widgets.convert_button.setToolTip(t("Extracting cover images...")) + + self.video_options.attachments.set_extracting(True) + + self._cover_extract_video_source = self.app.fastflix.current_video.source + self._cover_extract_thread = ExtractCovers(app=self.app, main=self, signal=self.cover_extraction_complete) + self._cover_extract_thread.start() + + def on_cover_extraction_complete(self): + """Called when background cover extraction finishes.""" + self._cover_extract_thread = None + + self.widgets.queue_button.setEnabled(True) + self.widgets.queue_button.setToolTip("") + self.widgets.convert_button.setEnabled(True) + self.widgets.convert_button.setToolTip("") + + if ( + self.app.fastflix.current_video + and self.app.fastflix.current_video.source == self._cover_extract_video_source + ): + self.video_options.attachments.covers_extracted() + self._cover_extract_video_source = None + def set_convert_button(self): if not self.app.fastflix.currently_encoding: self.widgets.convert_button.setText(f"{t('Convert')} ") diff --git a/fastflix/widgets/panels/cover_panel.py b/fastflix/widgets/panels/cover_panel.py index 240a5ce2..986c5d77 100644 --- a/fastflix/widgets/panels/cover_panel.py +++ b/fastflix/widgets/panels/cover_panel.py @@ -77,6 +77,12 @@ def __init__(self, parent, app: FastFlixApp): layout.addLayout(self.init_landscape_cover(), 9, 6, 1, 4) layout.columnStretch(5) + self._extracting_label = QtWidgets.QLabel(t("Extracting cover images...")) + self._extracting_label.setAlignment(QtCore.Qt.AlignCenter) + self._extracting_label.setStyleSheet("font-style: italic; color: gray;") + layout.addWidget(self._extracting_label, 5, 0, 1, 10, QtCore.Qt.AlignCenter) + self._extracting_label.hide() + self.setLayout(layout) def init_cover(self): @@ -329,41 +335,75 @@ def new_source(self, attachments): for attachment in attachments: filename = attachment.get("tags", {}).get("filename", "") base_name = filename.rsplit(".", 1)[0] - file_path = self.app.fastflix.current_video.work_path / filename - if base_name == "cover" and file_path.exists(): - self.cover_passthrough_checkbox.setChecked(True) - self.cover_passthrough_checkbox.setDisabled(False) - self.update_cover(str(file_path)) - self.cover_path.setDisabled(True) - self.cover_path.setText("") - self.cover_button.setDisabled(True) + if base_name == "cover": self.attachments.cover = {"name": filename, "stream": attachment.index, "tags": attachment.tags} - if base_name == "cover_land" and file_path.exists(): - self.cover_land_passthrough_checkbox.setChecked(True) - self.cover_land_passthrough_checkbox.setDisabled(False) - self.update_landscape_cover(str(file_path)) - self.cover_land_path.setDisabled(True) - self.cover_land_path.setText("") - self.landscape_button.setDisabled(True) + self.cover_passthrough_checkbox.setDisabled(False) + if base_name == "cover_land": self.attachments.cover_land = {"name": filename, "stream": attachment.index, "tags": attachment.tags} - if base_name == "small_cover" and file_path.exists(): - self.small_cover_passthrough_checkbox.setChecked(True) - self.small_cover_passthrough_checkbox.setDisabled(False) + self.cover_land_passthrough_checkbox.setDisabled(False) + if base_name == "small_cover": self.attachments.small_cover = {"name": filename, "stream": attachment.index, "tags": attachment.tags} - if base_name == "small_cover_land" and file_path.exists(): - self.small_cover_land_passthrough_checkbox.setChecked(True) - self.small_cover_land_passthrough_checkbox.setDisabled(False) + self.small_cover_passthrough_checkbox.setDisabled(False) + if base_name == "small_cover_land": self.attachments.small_cover_land = { "name": filename, "stream": attachment.index, "tags": attachment.tags, } + self.small_cover_land_passthrough_checkbox.setDisabled(False) self.cover_passthrough_checkbox.toggled.connect(lambda: self.cover_passthrough_check()) self.small_cover_passthrough_checkbox.toggled.connect(lambda: self.small_cover_passthrough_check()) self.cover_land_passthrough_checkbox.toggled.connect(lambda: self.cover_land_passthrough_check()) self.small_cover_land_passthrough_checkbox.toggled.connect(lambda: self.small_cover_land_passthrough_check()) + def set_extracting(self, extracting: bool): + """Show or hide the loading indicator while covers are being extracted.""" + if extracting: + self._extracting_label.show() + else: + self._extracting_label.hide() + + def covers_extracted(self): + """Called after background extraction completes. Updates previews for extracted cover files.""" + self.set_extracting(False) + if not self.app.fastflix.current_video: + return + + work_path = self.app.fastflix.current_video.work_path + + if "cover" in self.attachments: + file_path = work_path / self.attachments.cover.name + if file_path.exists(): + if not self.cover_passthrough_checkbox.isChecked(): + self.cover_passthrough_checkbox.setChecked(True) + self.cover_path.setDisabled(True) + self.cover_path.setText("") + self.cover_button.setDisabled(True) + self.update_cover(str(file_path)) + + if "cover_land" in self.attachments: + file_path = work_path / self.attachments.cover_land.name + if file_path.exists(): + if not self.cover_land_passthrough_checkbox.isChecked(): + self.cover_land_passthrough_checkbox.setChecked(True) + self.cover_land_path.setDisabled(True) + self.cover_land_path.setText("") + self.landscape_button.setDisabled(True) + self.update_landscape_cover(str(file_path)) + + if "small_cover" in self.attachments: + file_path = work_path / self.attachments.small_cover.name + if file_path.exists(): + if not self.small_cover_passthrough_checkbox.isChecked(): + self.small_cover_passthrough_checkbox.setChecked(True) + + if "small_cover_land" in self.attachments: + file_path = work_path / self.attachments.small_cover_land.name + if file_path.exists(): + if not self.small_cover_land_passthrough_checkbox.isChecked(): + self.small_cover_land_passthrough_checkbox.setChecked(True) + def reload_from_queue(self, streams, attachment_tracks): self.new_source(streams.attachment) self.cover_passthrough_checkbox.setChecked(False) From 975b7c0c94eee65b67257b846796a9997b096cf4 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Tue, 10 Mar 2026 00:00:38 -0500 Subject: [PATCH 02/12] * Adding FFmpeg 8.0+ version check on startup with option to download latest FFmpeg on Windows * Adding rotation and flip buttons to the visual crop window, allowing users to change rotation (0/90/180/270) and toggle horizontal/vertical flip without leaving the crop view * Removing -strict experimental from SVT-AV1 encoders (no longer needed with FFmpeg 8+) * Always using -fps_mode instead of deprecated -vsync for frame rate control * Updating visual crop window to show the video frame with rotation and flip applied, matching the final output so crop edges can be set intuitively in the rotated view * Updating Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers * Simplifying non-copy encoder rotation handling to use FFmpeg's built-in auto-rotation instead of manual display_rotation overrides, which also properly handles source flips from the display matrix * Fixing visual crop window showing incorrect bounds and dimensions when user rotation is applied, by showing the frame in pre-rotation space where crop actually operates * Fixing video crop and dimension detection for rotated videos where display matrix rotation was not found when other side data (e.g., HDR mastering display) preceded it --- CHANGES | 9 + CLAUDE.md | 2 +- fastflix/application.py | 59 ++++++ fastflix/encoders/av1_aom/command_builder.py | 8 +- fastflix/encoders/av1_aom/settings_panel.py | 8 +- fastflix/encoders/common/helpers.py | 22 +- fastflix/encoders/copy/command_builder.py | 33 +-- fastflix/encoders/copy/settings_panel.py | 11 +- fastflix/encoders/gif/command_builder.py | 3 +- fastflix/encoders/gifski/command_builder.py | 3 +- fastflix/encoders/svt_av1/command_builder.py | 2 +- .../encoders/svt_av1_avif/command_builder.py | 2 +- fastflix/flix.py | 3 - fastflix/models/config.py | 1 + fastflix/models/video.py | 29 ++- fastflix/widgets/settings.py | 6 + fastflix/widgets/windows/crop_window.py | 196 ++++++++++++++++-- tests/encoders/test_helpers.py | 9 +- .../test_svt_av1_avif_command_builder.py | 3 +- .../encoders/test_svt_av1_command_builder.py | 3 +- 20 files changed, 329 insertions(+), 83 deletions(-) diff --git a/CHANGES b/CHANGES index 3884f257..69c22a1a 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,15 @@ ## Version 6.1.2 +* Adding FFmpeg 8.0+ version check on startup with option to download latest FFmpeg on Windows +* Adding rotation and flip buttons to the visual crop window, allowing users to change rotation (0/90/180/270) and toggle horizontal/vertical flip without leaving the crop view +* Removing -strict experimental from SVT-AV1 encoders (no longer needed with FFmpeg 8+) +* Always using -fps_mode instead of deprecated -vsync for frame rate control +* Updating visual crop window to show the video frame with rotation and flip applied, matching the final output so crop edges can be set intuitively in the rotated view +* Updating Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers +* Simplifying non-copy encoder rotation handling to use FFmpeg's built-in auto-rotation instead of manual display_rotation overrides, which also properly handles source flips from the display matrix +* Fixing visual crop window showing incorrect bounds and dimensions when user rotation is applied, by showing the frame in pre-rotation space where crop actually operates +* Fixing video crop and dimension detection for rotated videos where display matrix rotation was not found when other side data (e.g., HDR mastering display) preceded it * Fixing cover extraction to not be during video load and blocking, but a background task ## Version 6.1.1 diff --git a/CLAUDE.md b/CLAUDE.md index 693e83a8..cb176a07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co FastFlix is a Python GUI application for video encoding/transcoding using PySide6 (Qt6). It wraps FFmpeg and supports 25+ encoder backends including x264, x265, AV1 variants, VP9, VVC, and hardware encoders (NVIDIA NVEncC, Intel QSVEncC, AMD VCEEncC). -**Requirements:** Python 3.13+, FFmpeg 4.3+ (5.0+ recommended) +**Requirements:** Python 3.13+, FFmpeg 8.0+ ## Build & Development Commands diff --git a/fastflix/application.py b/fastflix/application.py index bacd25b1..5078af50 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -183,6 +183,58 @@ def init_fastflix_directories(app: FastFlixApp): app.fastflix.log_path.mkdir(parents=True, exist_ok=True) +def _handle_ffmpeg_version_warning_windows(app: FastFlixApp, container: Container): + msg = QtWidgets.QMessageBox(container) + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setWindowTitle(t("FFmpeg Version Warning")) + msg.setText( + t( + "Your FFmpeg (libavcodec {version}) is older than the required FFmpeg 8.0+ (libavcodec 62+)." + " Some features may not work correctly." + ).format(version=app.fastflix.libavcodec_version) + ) + cb = QtWidgets.QCheckBox(t("Don't show this warning again")) + msg.setCheckBox(cb) + download_btn = msg.addButton(t("Download Latest"), QtWidgets.QMessageBox.AcceptRole) + msg.addButton(t("Ignore"), QtWidgets.QMessageBox.RejectRole) + msg.exec() + + if msg.clickedButton() == download_btn: + try: + container.status_bar.run_tasks( + [Task(t("Downloading FFmpeg"), grab_stable_ffmpeg)], + signal_task=True, + can_cancel=True, + ) + ffmpeg_configuration(app) + except Exception: + logger.exception("Failed to download FFmpeg") + + if cb.isChecked(): + app.fastflix.config.suppress_ffmpeg_version_warning = True + app.fastflix.config.save() + + +def _handle_ffmpeg_version_warning_other(app: FastFlixApp): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setWindowTitle(t("FFmpeg Version Warning")) + msg.setText( + t( + "Your FFmpeg (libavcodec {version}) is older than the required FFmpeg 8.0+ (libavcodec 62+)." + " Please update FFmpeg. Visit https://ffmpeg.org/download.html" + ).format(version=app.fastflix.libavcodec_version) + ) + cb = QtWidgets.QCheckBox(t("Don't show this warning again")) + msg.setCheckBox(cb) + msg.addButton(t("OK"), QtWidgets.QMessageBox.AcceptRole) + msg.exec() + + if cb.isChecked(): + app.fastflix.config.suppress_ffmpeg_version_warning = True + app.fastflix.config.save() + + def app_setup( enable_scaling: bool = True, portable_mode: bool = False, @@ -366,6 +418,13 @@ def app_setup( container.setEnabled(True) return app + # Check FFmpeg version (libavcodec 62 = FFmpeg 8.x) + if not app.fastflix.config.suppress_ffmpeg_version_warning and 0 < app.fastflix.libavcodec_version < 62: + if reusables.win_based: + _handle_ffmpeg_version_warning_windows(app, container) + else: + _handle_ffmpeg_version_warning_other(app) + # Encoders are now populated — initialize the encoder UI container.main.init_encoders_ui() diff --git a/fastflix/encoders/av1_aom/command_builder.py b/fastflix/encoders/av1_aom/command_builder.py index 8fe1a638..2702caec 100644 --- a/fastflix/encoders/av1_aom/command_builder.py +++ b/fastflix/encoders/av1_aom/command_builder.py @@ -15,13 +15,7 @@ def build(fastflix: FastFlix): beginning, ending, output_fps = generate_all(fastflix, "libaom-av1") if fastflix.current_video.hdr10_plus and "10" in settings.pix_fmt: - if fastflix.libavcodec_version >= 62: - logger.info("HDR10+ detected — passthrough will be handled automatically by FFmpeg 8.0+") - else: - logger.warning( - "HDR10+ detected but FFmpeg 8.0+ (libavcodec 62+) is required for AV1 HDR10+ passthrough. " - f"Current libavcodec version: {fastflix.libavcodec_version}" - ) + logger.info("HDR10+ detected — passthrough will be handled automatically by FFmpeg 8.0+") beginning.extend( [ diff --git a/fastflix/encoders/av1_aom/settings_panel.py b/fastflix/encoders/av1_aom/settings_panel.py index ba81dac9..d607ed25 100644 --- a/fastflix/encoders/av1_aom/settings_panel.py +++ b/fastflix/encoders/av1_aom/settings_panel.py @@ -298,12 +298,8 @@ def new_source(self): super().new_source() if self.app.fastflix.current_video.hdr10_plus: self.extract_button.show() - if self.app.fastflix.libavcodec_version >= 62: - self.hdr10plus_status_label.setStyleSheet("") - self.hdr10plus_status_label.setText(t("HDR10+ detected — will be preserved via FFmpeg passthrough")) - else: - self.hdr10plus_status_label.setStyleSheet("") - self.hdr10plus_status_label.setText(t("HDR10+ detected but requires FFmpeg 8.0+ for AV1 passthrough")) + self.hdr10plus_status_label.setStyleSheet("") + self.hdr10plus_status_label.setText(t("HDR10+ detected — will be preserved via FFmpeg passthrough")) self.hdr10plus_status_label.show() else: self.extract_button.hide() diff --git a/fastflix/encoders/common/helpers.py b/fastflix/encoders/common/helpers.py index 7305330a..99b97831 100644 --- a/fastflix/encoders/common/helpers.py +++ b/fastflix/encoders/common/helpers.py @@ -53,7 +53,6 @@ def generate_ffmpeg_start( ffmpeg, encoder, selected_track, - ffmpeg_version, start_time=0, end_time=None, pix_fmt="yuv420p10le", @@ -117,15 +116,8 @@ def generate_ffmpeg_start( if not filters: command.extend(["-map", f"0:{selected_track}"]) - vsync_type = "vsync" - try: - if ffmpeg_version.startswith("n") and int(ffmpeg_version[1:].split(".")[0]) >= 5: - vsync_type = "fps_mode" - except Exception: - pass - if vsync: - command.extend([f"-{vsync_type}", str(vsync)]) + command.extend(["-fps_mode", str(vsync)]) if filters: command.extend(filters) @@ -165,14 +157,16 @@ def generate_ending( remove_metadata=True, null_ending=False, output_fps: Union[str, None] = None, - disable_rotate_metadata=False, + source_has_rotation=False, copy_data=False, data_tracks=None, **_, ): command = [] - if not disable_rotate_metadata and not remove_metadata: + # When source had rotation and metadata is preserved, clear the legacy rotate tag + # to prevent players from double-rotating (the tag gets copied via -map_metadata 0) + if source_has_rotation and not remove_metadata: command.extend(["-metadata:s:v", "rotate=0"]) if remove_metadata: @@ -358,6 +352,9 @@ def generate_all( ) -> Tuple[List[str], List[str], List[str]]: settings = fastflix.current_video.video_settings.video_encoder_settings + # Detect source rotation for metadata clearing (FFmpeg auto-rotates during re-encoding) + source_rotation_degrees = fastflix.current_video.source_rotation + audio_cmd = build_audio(fastflix.current_video.audio_tracks) if audio else [] # Assign file_index to external subtitle tracks and collect unique external file paths @@ -426,7 +423,7 @@ def generate_all( subtitles=subtitles_cmd, cover=attachments_cmd, output_video=fastflix.current_video.video_settings.output_path, - disable_rotate_metadata=encoder == "copy", + source_has_rotation=bool(source_rotation_degrees) and encoder != "copy", data_tracks=fastflix.current_video.data_tracks, **fastflix.current_video.video_settings.model_dump(), ) @@ -451,7 +448,6 @@ def generate_all( filters=filters_cmd, concat=fastflix.current_video.concat, enable_opencl=enable_opencl if not disable_filters else False, - ffmpeg_version=fastflix.ffmpeg_version, start_extra=start_extra, extra_inputs=extra_inputs if extra_inputs else None, **fastflix.current_video.video_settings.model_dump(), diff --git a/fastflix/encoders/copy/command_builder.py b/fastflix/encoders/copy/command_builder.py index ccfc24c4..40333787 100644 --- a/fastflix/encoders/copy/command_builder.py +++ b/fastflix/encoders/copy/command_builder.py @@ -4,22 +4,31 @@ from fastflix.encoders.common.helpers import Command, generate_all from fastflix.models.fastflix import FastFlix +# Containers that support display matrix metadata (rotation/flip) without re-encoding +DISPLAY_MATRIX_CONTAINERS = {".mp4", ".mov", ".mkv", ".m4v"} + def build(fastflix: FastFlix): - beginning, ending, output_fps = generate_all(fastflix, "copy", disable_filters=True) - rotation = 0 if not fastflix.current_video.current_video_stream: return [] - if "rotate" in fastflix.current_video.current_video_stream.get("tags", {}): - rotation = abs(int(fastflix.current_video.current_video_stream.tags.rotate)) - elif "rotation" in fastflix.current_video.current_video_stream.get("side_data_list", [{}])[0]: - rotation = abs(int(fastflix.current_video.current_video_stream.side_data_list[0].rotation)) - rot = [] - # if fastflix.current_video.video_settings.rotate != 0: - # rot = ["-display_rotation:s:v", str(rotation + (fastflix.current_video.video_settings.rotate * 90))] - if fastflix.current_video.video_settings.output_path.name.lower().endswith("mp4"): - rot = ["-metadata:s:v", f"rotate={rotation + (fastflix.current_video.video_settings.rotate * 90)}"] + rotation = fastflix.current_video.source_rotation + + # Build display matrix input options (placed before -i via start_extra). + # These use modern FFmpeg display matrix side data (requires FFmpeg 6.0+). + display_args = [] + output_ext = fastflix.current_video.video_settings.output_path.suffix.lower() + + if output_ext in DISPLAY_MATRIX_CONTAINERS: + desired_rotation = (rotation + (fastflix.current_video.video_settings.rotate * 90)) % 360 + display_args = ["-display_rotation:v:0", str(desired_rotation)] + + if fastflix.current_video.video_settings.horizontal_flip: + display_args.extend(["-display_hflip:v:0", "1"]) + if fastflix.current_video.video_settings.vertical_flip: + display_args.extend(["-display_vflip:v:0", "1"]) + + beginning, ending, output_fps = generate_all(fastflix, "copy", disable_filters=True, start_extra=display_args) extra = ( shlex.split(fastflix.current_video.video_settings.video_encoder_settings.extra) @@ -29,7 +38,7 @@ def build(fastflix: FastFlix): return [ Command( - command=beginning + rot + extra + ending, + command=beginning + extra + ending, name="No Video Encoding", exe="ffmpeg", ) diff --git a/fastflix/encoders/copy/settings_panel.py b/fastflix/encoders/copy/settings_panel.py index f82fb1ed..9a4fe94f 100644 --- a/fastflix/encoders/copy/settings_panel.py +++ b/fastflix/encoders/copy/settings_panel.py @@ -22,11 +22,16 @@ def __init__(self, parent, main, app: FastFlixApp): grid = QtWidgets.QGridLayout() grid.addWidget(QtWidgets.QLabel(t("This will just copy the video track as is.")), 0, 0) + grid.addWidget(QtWidgets.QLabel(t("No crop, scale, nor any other filters will be applied.")), 1, 0) grid.addWidget( - QtWidgets.QLabel(t("No crop, scale, rotation,flip nor any other filters will be applied.")), 1, 0 + QtWidgets.QLabel( + t("Rotation and flip will be set as display metadata for supported containers (MP4, MOV, MKV).") + ), + 2, + 0, ) - grid.addWidget(QtWidgets.QWidget(), 2, 0, 10, 1) - grid.addLayout(self._add_custom(disable_both_passes=True), 11, 0, 1, 6) + grid.addWidget(QtWidgets.QWidget(), 3, 0, 10, 1) + grid.addLayout(self._add_custom(disable_both_passes=True), 12, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/gif/command_builder.py b/fastflix/encoders/gif/command_builder.py index 02a80a20..7c46a8e6 100644 --- a/fastflix/encoders/gif/command_builder.py +++ b/fastflix/encoders/gif/command_builder.py @@ -65,7 +65,8 @@ def build(fastflix: FastFlix): output_video = str(sanitize(fastflix.current_video.video_settings.output_path)) - beginning = [str(fastflix.config.ffmpeg), "-y"] + beginning = [str(fastflix.config.ffmpeg)] + beginning.append("-y") if video_settings.start_time: beginning.extend(["-ss", str(video_settings.start_time)]) if video_settings.end_time: diff --git a/fastflix/encoders/gifski/command_builder.py b/fastflix/encoders/gifski/command_builder.py index 7eddb069..e1371e6b 100644 --- a/fastflix/encoders/gifski/command_builder.py +++ b/fastflix/encoders/gifski/command_builder.py @@ -40,7 +40,8 @@ def build(fastflix: FastFlix): output_video = str(sanitize(fastflix.current_video.video_settings.output_path)) # Build FFmpeg command to output yuv4mpegpipe to stdout - ffmpeg_cmd = [str(fastflix.config.ffmpeg), "-y"] + ffmpeg_cmd = [str(fastflix.config.ffmpeg)] + ffmpeg_cmd.append("-y") if video_settings.start_time: ffmpeg_cmd.extend(["-ss", str(video_settings.start_time)]) if video_settings.end_time: diff --git a/fastflix/encoders/svt_av1/command_builder.py b/fastflix/encoders/svt_av1/command_builder.py index 41ff80c7..572c5718 100644 --- a/fastflix/encoders/svt_av1/command_builder.py +++ b/fastflix/encoders/svt_av1/command_builder.py @@ -19,7 +19,7 @@ def build(fastflix: FastFlix): settings: SVTAV1Settings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "libsvtav1") - beginning.extend(["-strict", "experimental", "-preset", str(settings.speed)]) + beginning.extend(["-preset", str(settings.speed)]) beginning.extend(generate_color_details(fastflix)) svtav1_params = settings.svtav1_params.copy() diff --git a/fastflix/encoders/svt_av1_avif/command_builder.py b/fastflix/encoders/svt_av1_avif/command_builder.py index ac0751e4..0261f70e 100644 --- a/fastflix/encoders/svt_av1_avif/command_builder.py +++ b/fastflix/encoders/svt_av1_avif/command_builder.py @@ -18,7 +18,7 @@ def build(fastflix: FastFlix): settings: SVTAVIFSettings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "libsvtav1", audio=False) - beginning.extend(["-strict", "experimental", "-preset", str(settings.speed)]) + beginning.extend(["-preset", str(settings.speed)]) beginning.extend(generate_color_details(fastflix)) svtav1_params = settings.svtav1_params.copy() diff --git a/fastflix/flix.py b/fastflix/flix.py index fb3b0a18..c6e24505 100644 --- a/fastflix/flix.py +++ b/fastflix/flix.py @@ -124,9 +124,6 @@ def guess_bit_depth(pix_fmt: str, color_primaries: str = None) -> int: "yuva420p", "yuva422p", "yuva444p", - "yuvj420p", - "yuvj422p", - "yuvj444p", ) ten = ("yuv420p10le", "yuv422p10le", "yuv444p10le", "gbrp10le", "gray10le", "p010le") diff --git a/fastflix/models/config.py b/fastflix/models/config.py index b071388b..c3af3c19 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -296,6 +296,7 @@ class Config(BaseModel): show_error_message: bool = True disable_cover_extraction: bool = False + suppress_ffmpeg_version_warning: bool = False # PGS to SRT OCR Settings enable_pgs_ocr: bool = False diff --git a/fastflix/models/video.py b/fastflix/models/video.py index 10d3c88c..24a33647 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -46,6 +46,16 @@ __all__ = ["VideoSettings", "Status", "Video", "Crop", "Status"] +def get_stream_rotation(video_stream) -> int: + """Extract rotation angle in degrees (0, 90, 180, 270) from a video stream.""" + if "rotate" in video_stream.get("tags", {}): + return abs(int(video_stream.tags.rotate)) + for side_data in video_stream.get("side_data_list", []): + if "rotation" in side_data: + return abs(int(side_data.rotation)) + return 0 + + def determine_rotation(streams, track: int = 0) -> Tuple[int, int]: for stream in streams.video: if int(track) == stream["index"]: @@ -54,11 +64,7 @@ def determine_rotation(streams, track: int = 0) -> Tuple[int, int]: else: return 0, 0 - rotation = 0 - if "rotate" in streams.video[0].get("tags", {}): - rotation = abs(int(video_stream.tags.rotate)) - elif "rotation" in streams.video[0].get("side_data_list", [{}])[0]: - rotation = abs(int(streams.video[0].side_data_list[0].rotation)) + rotation = get_stream_rotation(video_stream) if rotation in (90, 270): video_width = video_stream.height @@ -238,6 +244,19 @@ def height(self): _, h = determine_rotation(self.streams, track) return h + @property + def source_rotation(self) -> int: + """Return source rotation in degrees (0, 90, 180, 270).""" + track = 0 + if hasattr(self, "video_settings"): + track = self.video_settings.selected_track + if not self.streams or not self.streams.video: + return 0 + for stream in self.streams.video: + if int(track) == stream["index"]: + return get_stream_rotation(stream) + return 0 + @property def master_display(self) -> Optional[Box]: for track in self.hdr10_streams: diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index f04e5f44..e791a64c 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -201,6 +201,11 @@ def _build_settings_tab(self): layout.addWidget(self.disable_deinterlace_button, row, 0, 1, 3) row += 1 + self.suppress_ffmpeg_version_warning = QtWidgets.QCheckBox(t("Suppress FFmpeg version warning on startup")) + self.suppress_ffmpeg_version_warning.setChecked(self.app.fastflix.config.suppress_ffmpeg_version_warning) + layout.addWidget(self.suppress_ffmpeg_version_warning, row, 0, 1, 3) + row += 1 + self.use_keyframes_for_preview = QtWidgets.QCheckBox(t("Use keyframes for preview images")) self.use_keyframes_for_preview.setChecked(self.app.fastflix.config.use_keyframes_for_preview) layout.addWidget(self.use_keyframes_for_preview, row, 0, 1, 3) @@ -689,6 +694,7 @@ def save(self): self.app.fastflix.config.show_complete_message = self.show_complete_message.isChecked() self.app.fastflix.config.show_error_message = self.show_error_message.isChecked() self.app.fastflix.config.disable_deinterlace_check = self.disable_deinterlace_button.isChecked() + self.app.fastflix.config.suppress_ffmpeg_version_warning = self.suppress_ffmpeg_version_warning.isChecked() self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() self.app.fastflix.config.auto_detect_subtitles = self.auto_detect_subtitles.isChecked() diff --git a/fastflix/widgets/windows/crop_window.py b/fastflix/widgets/windows/crop_window.py index 07b1cfd8..c493dc63 100644 --- a/fastflix/widgets/windows/crop_window.py +++ b/fastflix/widgets/windows/crop_window.py @@ -316,6 +316,9 @@ def __init__(self, parent: "Main"): self.crop_values = {"top": 0, "bottom": 0, "left": 0, "right": 0} self.video_width = 0 self.video_height = 0 + self.rotate = 0 # 0-3 matching main UI (0=0°, 1=90°CW, 2=180°, 3=270°CW) + self.vertical_flip = False + self.horizontal_flip = False self.start_time: Optional[float] = None # None means not set by user in this window self.end_time: Optional[float] = None self.last_path: Optional[Path] = None @@ -359,6 +362,24 @@ def _build_ui(self): self.crop_btn.clicked.connect(lambda: self.switch_mode("crop")) self.preview_btn.clicked.connect(lambda: self.switch_mode("preview")) + self.rotate_btn = QtWidgets.QPushButton("0\u00b0") + self.rotate_btn.setFixedHeight(scaler.scale(28)) + self.rotate_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.rotate_btn.setToolTip(t("Rotate 90\u00b0 clockwise")) + self.rotate_btn.clicked.connect(self._cycle_rotate) + + self.hflip_btn = QtWidgets.QPushButton(t("H Flip")) + self.hflip_btn.setFixedHeight(scaler.scale(28)) + self.hflip_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.hflip_btn.setToolTip(t("Toggle horizontal flip")) + self.hflip_btn.clicked.connect(self._toggle_hflip) + + self.vflip_btn = QtWidgets.QPushButton(t("V Flip")) + self.vflip_btn.setFixedHeight(scaler.scale(28)) + self.vflip_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.vflip_btn.setToolTip(t("Toggle vertical flip")) + self.vflip_btn.clicked.connect(self._toggle_vflip) + self.set_start_btn = QtWidgets.QPushButton(t("Set Start Time")) self.set_start_btn.setFixedHeight(scaler.scale(28)) self.set_start_btn.setCursor(QtCore.Qt.PointingHandCursor) @@ -386,6 +407,9 @@ def _build_ui(self): top_layout.addWidget(self.reset_btn) top_layout.addWidget(self.crop_btn) top_layout.addWidget(self.preview_btn) + top_layout.addWidget(self.rotate_btn) + top_layout.addWidget(self.hflip_btn) + top_layout.addWidget(self.vflip_btn) top_layout.addWidget(self.set_start_btn) top_layout.addWidget(self.set_end_btn) top_layout.addStretch(1) @@ -492,6 +516,7 @@ def _update_button_styles(self): self.set_end_btn.setStyleSheet(end_style) self.save_btn.setStyleSheet(save_style) self.close_btn.setStyleSheet(close_style) + self._update_transform_button_styles() def resizeEvent(self, event): super().resizeEvent(event) @@ -526,19 +551,33 @@ def open_window(self): if not video: return - self.video_width = video.width - self.video_height = video.height + # Read rotation/flip from main UI + self.rotate = self.main.widgets.rotate.currentIndex() + self.vertical_flip, self.horizontal_flip = self.main.get_flips() - # Read current crop values from main UI + # Compute post-rotation dimensions (swap width/height for 90°/270°) + if self.rotate in (1, 3): + self.video_width = video.height + self.video_height = video.width + else: + self.video_width = video.width + self.video_height = video.height + + # Read current crop values from main UI (unrotated space) try: - self.crop_values = { + unrotated_crop = { "top": int(self.main.widgets.crop.top.text() or 0), "bottom": int(self.main.widgets.crop.bottom.text() or 0), "left": int(self.main.widgets.crop.left.text() or 0), "right": int(self.main.widgets.crop.right.text() or 0), } except (ValueError, AttributeError): - self.crop_values = {"top": 0, "bottom": 0, "left": 0, "right": 0} + unrotated_crop = {"top": 0, "bottom": 0, "left": 0, "right": 0} + + # Transform crop from unrotated (main UI / FFmpeg) to rotated (display) space + self.crop_values = self._unrotated_to_rotated_crop( + unrotated_crop, self.rotate, self.vertical_flip, self.horizontal_flip + ) # Read current start/end times from main UI self.start_time = time_to_number(self.main.widgets.start_time.text()) @@ -610,11 +649,14 @@ def generate_image(self, with_crop=False): settings["color_transfer"] = video.color_transfer if with_crop: - # Apply current visual crop values for preview - crop = self.crop_values - cw = self.video_width - crop["left"] - crop["right"] - ch = self.video_height - crop["top"] - crop["bottom"] - settings["crop"] = {"width": cw, "height": ch, "left": crop["left"], "top": crop["top"]} + # Transform rotated crop values to unrotated space for FFmpeg + # (FFmpeg filter order: crop → scale → rotate → flip) + unrotated_crop = self._rotated_to_unrotated_crop( + self.crop_values, self.rotate, self.vertical_flip, self.horizontal_flip + ) + cw = video.width - unrotated_crop["left"] - unrotated_crop["right"] + ch = video.height - unrotated_crop["top"] - unrotated_crop["bottom"] + settings["crop"] = {"width": cw, "height": ch, "left": unrotated_crop["left"], "top": unrotated_crop["top"]} else: # No crop for the base frame settings["crop"] = None @@ -622,6 +664,11 @@ def generate_image(self, with_crop=False): # Don't apply scale for the crop window - we want full resolution frame settings["scale"] = None + # Apply rotation/flip so the preview matches the final output + settings["rotate"] = self.rotate + settings["vertical_flip"] = self.vertical_flip + settings["horizontal_flip"] = self.horizontal_flip + filters = helpers.generate_filters( enable_opencl=False, start_filters="select=eq(pict_type\\,I)" @@ -691,10 +738,16 @@ def reset_crop(self): def save_crop(self): """Snap crop to divisible-by-8 dimensions and write back to main UI.""" - crop = self.crop_values + video = self.main.app.fastflix.current_video + if not video: + return + + # Transform rotated crop to unrotated space for FFmpeg + crop = self._rotated_to_unrotated_crop(self.crop_values, self.rotate, self.vertical_flip, self.horizontal_flip) - w = self.video_width - crop["left"] - crop["right"] - h = self.video_height - crop["top"] - crop["bottom"] + # Snap to divisible-by-8 in unrotated space + w = video.width - crop["left"] - crop["right"] + h = video.height - crop["top"] - crop["bottom"] target_w = (w // 8) * 8 target_h = (h // 8) * 8 @@ -708,12 +761,16 @@ def save_crop(self): crop["top"] += h_diff // 2 crop["bottom"] += h_diff - h_diff // 2 - # Write to main UI crop fields + # Write unrotated crop to main UI self.main.widgets.crop.top.setText(str(crop["top"])) self.main.widgets.crop.bottom.setText(str(crop["bottom"])) self.main.widgets.crop.left.setText(str(crop["left"])) self.main.widgets.crop.right.setText(str(crop["right"])) + # Write rotation/flip back to main UI + self.main.widgets.rotate.setCurrentIndex(self.rotate) + self.main.widgets.flip.setCurrentIndex(self.main.flip_to_int(self.vertical_flip, self.horizontal_flip)) + # Write start/end times back to main UI if self.start_time is not None: self.main.widgets.start_time.setText(self.main.number_to_time(self.start_time)) @@ -721,13 +778,118 @@ def save_crop(self): self.main.widgets.end_time.setText(self.main.number_to_time(self.end_time)) logger.info( - f"Crop saved: top={crop['top']} bottom={crop['bottom']} " + f"Crop saved (unrotated): top={crop['top']} bottom={crop['bottom']} " f"left={crop['left']} right={crop['right']} " - f"({self.video_width - crop['left'] - crop['right']}x" - f"{self.video_height - crop['top'] - crop['bottom']})" + f"({video.width - crop['left'] - crop['right']}x" + f"{video.height - crop['top'] - crop['bottom']})" + f" | rotate={self.rotate} vflip={self.vertical_flip} hflip={self.horizontal_flip}" ) self.hide() + @staticmethod + def _unrotated_to_rotated_crop(crop, rotate, vflip, hflip): + """Transform crop from unrotated (FFmpeg) space to rotated (display) space. + + FFmpeg filter order: crop → scale → rotate → flip. + Forward: apply rotation mapping, then flip. + """ + ct, cr, cb, cl = crop["top"], crop["right"], crop["bottom"], crop["left"] + if rotate == 1: + ct, cr, cb, cl = cl, ct, cr, cb + elif rotate == 2: + ct, cr, cb, cl = cb, cl, ct, cr + elif rotate == 3: + ct, cr, cb, cl = cr, cb, cl, ct + if hflip: + cl, cr = cr, cl + if vflip: + ct, cb = cb, ct + return {"top": ct, "right": cr, "bottom": cb, "left": cl} + + @staticmethod + def _rotated_to_unrotated_crop(crop, rotate, vflip, hflip): + """Transform crop from rotated (display) space to unrotated (FFmpeg) space. + + Inverse of _unrotated_to_rotated_crop: undo flips first, then undo rotation. + """ + ct, cr, cb, cl = crop["top"], crop["right"], crop["bottom"], crop["left"] + # Undo flips first + if hflip: + cl, cr = cr, cl + if vflip: + ct, cb = cb, ct + # Undo rotation (apply inverse rotation) + inv = (4 - rotate) % 4 + if inv == 1: + ct, cr, cb, cl = cl, ct, cr, cb + elif inv == 2: + ct, cr, cb, cl = cb, cl, ct, cr + elif inv == 3: + ct, cr, cb, cl = cr, cb, cl, ct + return {"top": ct, "right": cr, "bottom": cb, "left": cl} + + def _update_transform_button_styles(self): + """Update rotate/flip button text and style to reflect current state.""" + labels = ["0\u00b0", "90\u00b0", "180\u00b0", "270\u00b0"] + self.rotate_btn.setText(labels[self.rotate]) + + active = ( + "QPushButton { background: #567781; color: white; border: none; border-radius: 4px; padding: 4px 12px; }" + ) + inactive = ( + "QPushButton { background: #4a555e; color: #b5b5b5; border: none; border-radius: 4px; padding: 4px 12px; }" + "QPushButton:hover { background: #566068; }" + ) + self.rotate_btn.setStyleSheet(active if self.rotate != 0 else inactive) + self.hflip_btn.setStyleSheet(active if self.horizontal_flip else inactive) + self.vflip_btn.setStyleSheet(active if self.vertical_flip else inactive) + + def _cycle_rotate(self): + """Cycle rotation 0 -> 90 -> 180 -> 270 -> 0, transforming crop accordingly.""" + old_rotate = self.rotate + self.rotate = (self.rotate + 1) % 4 + + # Transform crop: old rotated space -> unrotated -> new rotated space + unrotated = self._rotated_to_unrotated_crop( + self.crop_values, old_rotate, self.vertical_flip, self.horizontal_flip + ) + self.crop_values = self._unrotated_to_rotated_crop( + unrotated, self.rotate, self.vertical_flip, self.horizontal_flip + ) + + # Swap dimensions if transposition changed (0/2 vs 1/3) + if (old_rotate in (1, 3)) != (self.rotate in (1, 3)): + self.video_width, self.video_height = self.video_height, self.video_width + + self._update_button_styles() + self.update_size_label() + self.generate_image(with_crop=False) + if self.mode == "preview": + self.generate_image(with_crop=True) + self.image_widget.update() + + def _toggle_hflip(self): + """Toggle horizontal flip, swapping left/right crop in display space.""" + self.horizontal_flip = not self.horizontal_flip + self.crop_values["left"], self.crop_values["right"] = self.crop_values["right"], self.crop_values["left"] + + self._update_button_styles() + self.generate_image(with_crop=False) + if self.mode == "preview": + self.generate_image(with_crop=True) + self.image_widget.update() + + def _toggle_vflip(self): + """Toggle vertical flip, swapping top/bottom crop in display space.""" + self.vertical_flip = not self.vertical_flip + self.crop_values["top"], self.crop_values["bottom"] = self.crop_values["bottom"], self.crop_values["top"] + + self._update_button_styles() + self.generate_image(with_crop=False) + if self.mode == "preview": + self.generate_image(with_crop=True) + self.image_widget.update() + def hideEvent(self, event): # Clean up temp file on close if self.last_path: diff --git a/tests/encoders/test_helpers.py b/tests/encoders/test_helpers.py index c0021e86..53150041 100644 --- a/tests/encoders/test_helpers.py +++ b/tests/encoders/test_helpers.py @@ -38,7 +38,6 @@ def test_generate_ffmpeg_start_basic(fastflix_instance): ffmpeg=Path("ffmpeg"), encoder="libx265", selected_track=0, - ffmpeg_version="n5.0", pix_fmt="yuv420p10le", ) @@ -62,7 +61,6 @@ def test_generate_ffmpeg_start_with_options(fastflix_instance): ffmpeg=Path("ffmpeg"), encoder="libx265", selected_track=0, - ffmpeg_version="n5.0", pix_fmt="yuv420p10le", start_time=10, end_time=60, @@ -121,7 +119,6 @@ def test_generate_ffmpeg_start_with_list_start_extra(fastflix_instance): ffmpeg=Path("ffmpeg"), encoder="hevc_vaapi", selected_track=0, - ffmpeg_version="n5.0", pix_fmt="vaapi", start_extra=start_extra_list, ) @@ -148,7 +145,6 @@ def test_generate_ffmpeg_start_with_empty_list_start_extra(fastflix_instance): ffmpeg=Path("ffmpeg"), encoder="libx265", selected_track=0, - ffmpeg_version="n5.0", pix_fmt="yuv420p10le", start_extra=[], ) @@ -165,7 +161,6 @@ def test_generate_ffmpeg_start_numeric_times_are_strings(fastflix_instance): ffmpeg=Path("ffmpeg"), encoder="libx265", selected_track=0, - ffmpeg_version="n5.0", pix_fmt="yuv420p10le", start_time=10.5, end_time=120.0, @@ -207,7 +202,7 @@ def test_generate_ending_with_options(): copy_chapters=False, remove_metadata=False, output_fps="24", - disable_rotate_metadata=False, + source_has_rotation=True, copy_data=True, ) @@ -403,7 +398,6 @@ def test_generate_ffmpeg_start_with_extra_inputs(fastflix_instance): ffmpeg=Path("ffmpeg"), encoder="libx265", selected_track=0, - ffmpeg_version="n5.0", pix_fmt="yuv420p10le", extra_inputs=["-i", "/path/to/subs.srt", "-i", "/path/to/subs2.ass"], ) @@ -426,7 +420,6 @@ def test_generate_ffmpeg_start_no_extra_inputs(fastflix_instance): ffmpeg=Path("ffmpeg"), encoder="libx265", selected_track=0, - ffmpeg_version="n5.0", pix_fmt="yuv420p10le", ) diff --git a/tests/encoders/test_svt_av1_avif_command_builder.py b/tests/encoders/test_svt_av1_avif_command_builder.py index c3185d4b..fcae2bc9 100644 --- a/tests/encoders/test_svt_av1_avif_command_builder.py +++ b/tests/encoders/test_svt_av1_avif_command_builder.py @@ -40,8 +40,7 @@ def test_svt_av1_avif_basic(): assert "avif" in cmd assert "-qp" in cmd assert "24" in cmd - assert "-strict" in cmd - assert "experimental" in cmd + assert "-strict" not in cmd def test_svt_av1_avif_with_tune(): diff --git a/tests/encoders/test_svt_av1_command_builder.py b/tests/encoders/test_svt_av1_command_builder.py index adf1f4a8..27154ce4 100644 --- a/tests/encoders/test_svt_av1_command_builder.py +++ b/tests/encoders/test_svt_av1_command_builder.py @@ -80,8 +80,7 @@ def test_svt_av1_single_pass_qp(): assert isinstance(cmd, list), f"Expected command to be a list, got {type(cmd)}" # Check key elements are present in the command list - assert "-strict" in cmd - assert "experimental" in cmd + assert "-strict" not in cmd assert "-preset" in cmd assert "7" in cmd assert "-crf" in cmd From f5bc9465fdcc4c37cb2bf3c2fb1c657d774a8e96 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 11 Mar 2026 20:48:07 -0500 Subject: [PATCH 03/12] * Fixing #725 encoder detection to check `ffmpeg -encoders` output in addition to compilation flags, so encoders like VAAPI are shown even when the build flag is absent (thanks to Davius and Generator) * Fixing #730 6.1.1 arm no subtitles tab with VideoToolBox (Apple M1 and above) HEVC & H264 (thanks to enaveso) * Adding #731 OpenCL Support setting (Auto/Disable) with re-detection button in Application Locations settings (thanks to sks2012) --- .claude/skills/changelog.md | 56 +++++++++++++++++++ CHANGES | 16 ++++-- fastflix/application.py | 24 +++++++- fastflix/encoders/h264_videotoolbox/main.py | 2 +- fastflix/encoders/hevc_videotoolbox/main.py | 2 +- fastflix/encoders/modify/settings_panel.py | 8 ++- fastflix/flix.py | 15 +++++ fastflix/models/fastflix.py | 1 + fastflix/version.py | 2 +- fastflix/widgets/background_tasks.py | 59 +++++++++++++++++++- fastflix/widgets/main.py | 2 + fastflix/widgets/panels/debug_panel.py | 1 + fastflix/widgets/settings.py | 61 +++++++++++++++++++++ 13 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 .claude/skills/changelog.md diff --git a/.claude/skills/changelog.md b/.claude/skills/changelog.md new file mode 100644 index 00000000..cffa1230 --- /dev/null +++ b/.claude/skills/changelog.md @@ -0,0 +1,56 @@ +--- +name: changelog +description: Update the CHANGES changelog file with new entries +user_invocable: true +--- + +# Changelog Skill + +When updating the `CHANGES` file, follow these rules: + +## Entry Format + +Each entry is a single bullet point starting with `* `: + +``` +* {Verb} {description} +``` + +## Verbs and Ordering + +Entries MUST use one of these four starting verbs, and MUST appear in this order within each version section: + +1. **Adding** — new features +2. **Changing** — modifications to existing behavior +3. **Fixing** — bug fixes +4. **Removing** — removed features or deprecated items + +## GitHub Issue Entries + +- Entries that reference a GitHub issue include the issue number after the verb: `* Fixing #725 description...` +- Within each verb group, entries WITH issue numbers come FIRST, sorted by issue number ascending (smallest to largest) +- Entries WITHOUT issue numbers follow after + +## Thanks Attribution + +- When an entry references a GitHub issue, thank the issue author by their **GitHub display name** (not username) +- Look up the display name via `gh api users/{username} --jq '.name // .login'` +- Format: `(thanks to {display name})` +- If multiple people contributed (e.g., reporter and commenter with the fix), thank all of them +- The thanks attribution goes at the end of the entry + +## Example + +``` +## Version 6.2.0 + +* Adding #731 OpenCL Support setting (thanks to sks2012) +* Adding FFmpeg 8.0+ version check on startup +* Adding "Keep source format" option to Audio Normalize +* Changing visual crop window to show rotated frame +* Changing -fps_mode to be used instead of deprecated -vsync +* Fixing #725 encoder detection to use ffmpeg -encoders (thanks to Davius and Generator) +* Fixing #730 subtitles tab missing on ARM (thanks to enaveso) +* Fixing cover extraction blocking video load +* Removing -strict experimental from SVT-AV1 encoders +``` diff --git a/CHANGES b/CHANGES index 69c22a1a..09d2346b 100644 --- a/CHANGES +++ b/CHANGES @@ -1,17 +1,21 @@ # Changelog -## Version 6.1.2 +## Version 6.2.0 +* Adding #731 OpenCL Support setting (Auto/Disable) with re-detection button in Application Locations settings (thanks to sks2012) * Adding FFmpeg 8.0+ version check on startup with option to download latest FFmpeg on Windows +* Adding "Keep source format" option to Audio Normalize, which detects and uses the same audio codec and bitrate as the source video * Adding rotation and flip buttons to the visual crop window, allowing users to change rotation (0/90/180/270) and toggle horizontal/vertical flip without leaving the crop view -* Removing -strict experimental from SVT-AV1 encoders (no longer needed with FFmpeg 8+) -* Always using -fps_mode instead of deprecated -vsync for frame rate control -* Updating visual crop window to show the video frame with rotation and flip applied, matching the final output so crop edges can be set intuitively in the rotated view -* Updating Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers -* Simplifying non-copy encoder rotation handling to use FFmpeg's built-in auto-rotation instead of manual display_rotation overrides, which also properly handles source flips from the display matrix +* Changing visual crop window to show the video frame with rotation and flip applied, matching the final output so crop edges can be set intuitively in the rotated view +* Changing Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers +* Changing non-copy encoder rotation handling to use FFmpeg's built-in auto-rotation instead of manual display_rotation overrides, which also properly handles source flips from the display matrix +* Changing -fps_mode to be used instead of deprecated -vsync for frame rate control +* Fixing #725 encoder detection to check `ffmpeg -encoders` output in addition to compilation flags, so encoders like VAAPI are shown even when the build flag is absent (thanks to Davius and Generator) +* Fixing #730 6.1.1 arm no subtitles tab with VideoToolBox (Apple M1 and above) HEVC & H264 (thanks to enaveso) * Fixing visual crop window showing incorrect bounds and dimensions when user rotation is applied, by showing the frame in pre-rotation space where crop actually operates * Fixing video crop and dimension detection for rotated videos where display matrix rotation was not found when other side data (e.g., HDR mastering display) preceded it * Fixing cover extraction to not be during video load and blocking, but a background task +* Removing -strict experimental from SVT-AV1 encoders (no longer needed with FFmpeg 8+) ## Version 6.1.1 diff --git a/fastflix/application.py b/fastflix/application.py index 5078af50..0e3c6c8b 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -7,7 +7,13 @@ import reusables from PySide6 import QtGui, QtWidgets, QtCore -from fastflix.flix import ffmpeg_audio_encoders, ffmpeg_configuration, ffprobe_configuration, ffmpeg_opencl_support +from fastflix.flix import ( + ffmpeg_audio_encoders, + ffmpeg_video_encoders, + ffmpeg_configuration, + ffprobe_configuration, + ffmpeg_opencl_support, +) from fastflix.language import t from fastflix.models.config import Config, MissingFF from fastflix.models.fastflix import FastFlix @@ -171,10 +177,23 @@ def init_encoders(app: FastFlixApp, **_): # if "H.264/AVC" in app.fastflix.config.vceencc_encoders: encoders.insert(encoders.index(avc_plugin), vceencc_avc_plugin) + # Mapping from requires values to search terms for ffmpeg -encoders output. + # Most requires values (e.g. "vaapi", "libx264") appear directly in encoder names, + # but some compilation flags don't match encoder names and need explicit mapping. + requires_to_encoder = { + "cuda-llvm": "nvenc", + } + + def _encoder_available(requires: str) -> bool: + if requires in app.fastflix.ffmpeg_config: + return True + search_term = requires_to_encoder.get(requires, requires) + return any(search_term in enc for enc in (app.fastflix.video_encoders or [])) + app.fastflix.encoders = { encoder.name: encoder for encoder in encoders - if (not getattr(encoder, "requires", None)) or encoder.requires in app.fastflix.ffmpeg_config or DEVMODE + if (not getattr(encoder, "requires", None)) or _encoder_available(encoder.requires) or DEVMODE } @@ -406,6 +425,7 @@ def app_setup( Task(t("Gather FFmpeg version"), ffmpeg_configuration), Task(t("Gather FFprobe version"), ffprobe_configuration), Task(t("Gather FFmpeg audio encoders"), ffmpeg_audio_encoders), + Task(t("Gather FFmpeg video encoders"), ffmpeg_video_encoders), Task(t("Determine OpenCL Support"), ffmpeg_opencl_support), Task(t("Initialize Encoders"), init_encoders), ] diff --git a/fastflix/encoders/h264_videotoolbox/main.py b/fastflix/encoders/h264_videotoolbox/main.py index 3ac30cea..a2757155 100644 --- a/fastflix/encoders/h264_videotoolbox/main.py +++ b/fastflix/encoders/h264_videotoolbox/main.py @@ -13,7 +13,7 @@ with importlib.resources.as_file(ref) as icon_file: icon = str(icon_file.resolve()) -enable_subtitles = False +enable_subtitles = True enable_audio = True enable_attachments = False enable_concat = True diff --git a/fastflix/encoders/hevc_videotoolbox/main.py b/fastflix/encoders/hevc_videotoolbox/main.py index 6d24c719..e0a37916 100644 --- a/fastflix/encoders/hevc_videotoolbox/main.py +++ b/fastflix/encoders/hevc_videotoolbox/main.py @@ -13,7 +13,7 @@ with importlib.resources.as_file(ref) as icon_file: icon = str(icon_file.resolve()) -enable_subtitles = False +enable_subtitles = True enable_audio = True enable_attachments = False enable_concat = True diff --git a/fastflix/encoders/modify/settings_panel.py b/fastflix/encoders/modify/settings_panel.py index a25d644c..f7d5fbee 100644 --- a/fastflix/encoders/modify/settings_panel.py +++ b/fastflix/encoders/modify/settings_panel.py @@ -40,7 +40,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addWidget(self.extract_label, 2, 2, 1, 1) self.audio_format_combo = QtWidgets.QComboBox() - self.audio_format_combo.addItems(self.app.fastflix.config.sane_audio_selection) + self.audio_format_combo.addItems([t("Keep source format")] + self.app.fastflix.config.sane_audio_selection) grid.addWidget(self.audio_format_combo, 2, 1, 1, 1) add_audio_track = QtWidgets.QPushButton(t("Add Audio Track")) @@ -99,17 +99,19 @@ def select_run_audio_normalize(self): message(t("Please make sure the source and output files are specified")) return + keep_source = self.audio_format_combo.currentIndex() == 0 audio_type = self.audio_format_combo.currentText() + display_type = t("same as source") if keep_source else audio_type resp = yes_no_message( t("This will run the audio normalization process on all streams of") + f"\n{in_path}\n" + t("and create an output file with audio format ") - + f"{audio_type}\n@ {out_path}\n", + + f"{display_type}\n@ {out_path}\n", title="Audio Normalization", ) if not resp: return - self.norm_thread = AudioNoramlize(self.app, self.main, audio_type, self.signal) + self.norm_thread = AudioNoramlize(self.app, self.main, audio_type, self.signal, keep_source=keep_source) self.norm_thread.start() self.movie.start() self.extract_label.show() diff --git a/fastflix/flix.py b/fastflix/flix.py index c6e24505..1c6dd41a 100644 --- a/fastflix/flix.py +++ b/fastflix/flix.py @@ -529,6 +529,21 @@ def ffmpeg_audio_encoders(app, config: Config) -> List: return encoders +def ffmpeg_video_encoders(app, config: Config) -> List: + cmd = execute([f"{config.ffmpeg}", "-hide_banner", "-encoders"]) + encoders = [] + start_line = " ------" + started = False + for line in cmd.stdout.splitlines(): + if started: + if line.strip().startswith("V"): + encoders.append(line.strip().split(" ")[1]) + elif line.startswith(start_line): + started = True + app.fastflix.video_encoders = encoders + return encoders + + def ffmpeg_opencl_support(app, config: Config) -> bool: if app.fastflix.config.opencl_support is not None: app.fastflix.opencl_support = app.fastflix.config.opencl_support diff --git a/fastflix/models/fastflix.py b/fastflix/models/fastflix.py index 01ca63a1..f9844b18 100644 --- a/fastflix/models/fastflix.py +++ b/fastflix/models/fastflix.py @@ -11,6 +11,7 @@ class FastFlix(BaseModel): audio_encoders: list[str] = None + video_encoders: list[str] = None encoders: dict = None config: Config = None data_path: Path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) diff --git a/fastflix/version.py b/fastflix/version.py index 6129e179..e69f10f6 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "6.1.2" +__version__ = "6.2.0" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py index 3dec9b59..0641ae5f 100644 --- a/fastflix/widgets/background_tasks.py +++ b/fastflix/widgets/background_tasks.py @@ -388,12 +388,56 @@ def _convert_sup_to_srt(self, sup_filepath: str) -> bool: class AudioNoramlize(QtCore.QThread): - def __init__(self, app: FastFlixApp, main, audio_type, signal): + # Map FFprobe codec names to FFmpeg encoder names + codec_name_to_encoder = { + "aac": "aac", + "ac3": "ac3", + "eac3": "eac3", + "truehd": "truehd", + "dts": "dca", + "flac": "flac", + "alac": "alac", + "opus": "libopus", + "vorbis": "libvorbis", + "mp3": "libmp3lame", + "pcm_s16le": "pcm_s16le", + "pcm_s24le": "pcm_s24le", + "pcm_s32le": "pcm_s32le", + "wavpack": "libwavpack", + "tta": "tta", + "mp2": "mp2", + } + + def __init__(self, app: FastFlixApp, main, audio_type, signal, keep_source=False): super().__init__(main) self.main = main self.app = app self.signal = signal self.audio_type = audio_type + self.keep_source = keep_source + + def _detect_source_audio(self): + """Detect the source audio codec and bitrate from the first audio stream.""" + streams = self.app.fastflix.current_video.streams + if not streams or not streams.audio: + return "aac", None + + first_audio = streams.audio[0] + codec_name = first_audio.get("codec_name", "aac") + encoder = self.codec_name_to_encoder.get(codec_name, codec_name) + + # Get source bitrate to encode at similar quality + bit_rate = first_audio.get("bit_rate") + if bit_rate: + try: + audio_bitrate = int(bit_rate) / 1000 # Convert to kbps + except (ValueError, TypeError): + audio_bitrate = None + else: + audio_bitrate = None + + logger.info(f"Detected source audio: codec={codec_name}, encoder={encoder}, bitrate={audio_bitrate}k") + return encoder, audio_bitrate def run(self): try: @@ -401,8 +445,19 @@ def run(self): out_file = self.app.fastflix.current_video.video_settings.output_path if not out_file: self.signal.emit("No source video provided") + + audio_codec = self.audio_type + audio_bitrate = None + + if self.keep_source: + audio_codec, audio_bitrate = self._detect_source_audio() + normalizer = FFmpegNormalize( - audio_codec=self.audio_type, extension=out_file.suffix.lstrip("."), video_codec="copy", progress=True + audio_codec=audio_codec, + audio_bitrate=audio_bitrate, + extension=out_file.suffix.lstrip("."), + video_codec="copy", + progress=True, ) logger.info(f"Running audio normalization - will output video to {str(out_file)}") normalizer.add_media_file(str(self.app.fastflix.current_video.source), str(out_file)) diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index a893ff3f..c6419acf 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -576,6 +576,7 @@ def reload_encoders(self): from fastflix.application import init_encoders from fastflix.flix import ( ffmpeg_audio_encoders, + ffmpeg_video_encoders, ffmpeg_configuration, ffmpeg_opencl_support, ffprobe_configuration, @@ -591,6 +592,7 @@ def reload_encoders(self): Task(t("Gather FFmpeg version"), ffmpeg_configuration), Task(t("Gather FFprobe version"), ffprobe_configuration), Task(t("Gather FFmpeg audio encoders"), ffmpeg_audio_encoders), + Task(t("Gather FFmpeg video encoders"), ffmpeg_video_encoders), Task(t("Determine OpenCL Support"), ffmpeg_opencl_support), Task(t("Initialize Encoders"), init_encoders), ] diff --git a/fastflix/widgets/panels/debug_panel.py b/fastflix/widgets/panels/debug_panel.py index 206354d3..62707ffb 100644 --- a/fastflix/widgets/panels/debug_panel.py +++ b/fastflix/widgets/panels/debug_panel.py @@ -42,6 +42,7 @@ def get_ffmpeg_details(self): "ffmpeg version": self.app.fastflix.ffmpeg_version, "ffprobe version": self.app.fastflix.ffprobe_version, "ffmpeg config": self.app.fastflix.ffmpeg_config, + "ffmpeg video encoders": self.app.fastflix.video_encoders, } return data diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index e791a64c..87b0ee65 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -547,6 +547,31 @@ def _build_locations_tab(self): layout.addWidget(gifski_path_button, row, 2) row += 1 + # OpenCL Support + opencl_label = QtWidgets.QLabel(t("OpenCL Support")) + self.opencl_combo = QtWidgets.QComboBox() + self.opencl_combo.addItems([t("Auto"), t("Disable")]) + if self.app.fastflix.config.opencl_support is False: + self.opencl_combo.setCurrentIndex(1) + else: + self.opencl_combo.setCurrentIndex(0) + + self.opencl_status_label = QtWidgets.QLabel() + self._update_opencl_status() + + opencl_detect_button = QtWidgets.QPushButton(t("Re-detect")) + opencl_detect_button.setFixedWidth(80) + opencl_detect_button.clicked.connect(self._run_opencl_detection) + + opencl_row_layout = QtWidgets.QHBoxLayout() + opencl_row_layout.addWidget(self.opencl_combo) + opencl_row_layout.addWidget(self.opencl_status_label) + opencl_row_layout.addWidget(opencl_detect_button) + + layout.addWidget(opencl_label, row, 0) + layout.addLayout(opencl_row_layout, row, 1, 1, 2) + row += 1 + # Detected External Programs section detected_group = QtWidgets.QGroupBox(t("Detected External Programs")) detected_layout = QtWidgets.QGridLayout() @@ -698,6 +723,13 @@ def save(self): self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() self.app.fastflix.config.auto_detect_subtitles = self.auto_detect_subtitles.isChecked() + if self.opencl_combo.currentIndex() == 1: # Disable + self.app.fastflix.config.opencl_support = False + self.app.fastflix.opencl_support = False + else: # Auto + self._run_opencl_detection() + self.app.fastflix.config.opencl_support = None + self.main.config_update(encoder_reload_needed=encoder_reload_needed) self.app.fastflix.config.save() if old_lang != self.app.fastflix.config.language or restart_needed: @@ -758,6 +790,35 @@ def select_gifski(self): return self.gifski_path.setText(str(Path(filename[0]).absolute())) + def _update_opencl_status(self): + """Update the OpenCL status icon based on current detection state.""" + if self.opencl_combo.currentIndex() == 1: # Disable + self.opencl_status_label.setText("") + elif self.app.fastflix.opencl_support: + self.opencl_status_label.setText('\u2714') + else: + self.opencl_status_label.setText('\u2718') + + def _run_opencl_detection(self): + """Run OpenCL detection using FFmpeg and update the status icon.""" + from fastflix.flix import execute + + cmd = execute( + [ + str(self.app.fastflix.config.ffmpeg), + "-hide_banner", + "-log_level", + "error", + "-init_hw_device", + "opencl:0.0", + "-h", + ] + ) + detected = cmd.returncode == 0 + self.app.fastflix.opencl_support = detected + self._update_opencl_status() + logger.info(f"OpenCL re-detection result: {'supported' if detected else 'not supported'}") + def select_output_directory(self): dirname = Path(self.output_path_line_edit.text()).parent if not dirname.exists(): From 45d0085e4237a50ff0b13f42f35edaead475d904 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 11 Mar 2026 22:43:45 -0500 Subject: [PATCH 04/12] * Adding encoding history feature with browsable history window, "Apply Last Used Settings" menu action, and startup opt-in prompt * Adding #724 "exit" option to the After Conversion dropdown, which closes FastFlix after all queue items complete (thanks to jrff123) --- CHANGES | 3 + fastflix/application.py | 16 + fastflix/data/languages.yaml | 454 +++++++++++++++++++++++++ fastflix/models/config.py | 2 + fastflix/shared.py | 49 ++- fastflix/widgets/container.py | 78 +++++ fastflix/widgets/main.py | 101 ++++++ fastflix/widgets/panels/queue_panel.py | 7 + fastflix/widgets/settings.py | 124 +++++-- 9 files changed, 800 insertions(+), 34 deletions(-) diff --git a/CHANGES b/CHANGES index 09d2346b..a0f5bf89 100644 --- a/CHANGES +++ b/CHANGES @@ -2,9 +2,12 @@ ## Version 6.2.0 +* Adding #724 "exit" option to the After Conversion dropdown, which closes FastFlix after all queue items complete (thanks to jrff123) * Adding #731 OpenCL Support setting (Auto/Disable) with re-detection button in Application Locations settings (thanks to sks2012) +* Adding encoding history feature with browsable history window, "Apply Last Used Settings" menu action, and startup opt-in prompt * Adding FFmpeg 8.0+ version check on startup with option to download latest FFmpeg on Windows * Adding "Keep source format" option to Audio Normalize, which detects and uses the same audio codec and bitrate as the source video +* Adding Audio Encoders tab in Settings to view and select which FFmpeg audio encoders appear in audio codec dropdowns * Adding rotation and flip buttons to the visual crop window, allowing users to change rotation (0/90/180/270) and toggle horizontal/vertical flip without leaving the crop view * Changing visual crop window to show the video frame with rotation and flip applied, matching the final output so crop edges can be set intuitively in the rotated view * Changing Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers diff --git a/fastflix/application.py b/fastflix/application.py index 0e3c6c8b..6744cc10 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -418,6 +418,22 @@ def app_setup( except Exception: logger.exception("Failed to download HDR10+ tool") + if app.fastflix.config.enable_history is None: + history_choice = yes_no_message( + t("Would you like to enable encoding history?") + + "\n\n" + + t( + "This keeps a local record of your completed encodings, letting you review the settings used for any past video and quickly re-apply them to new ones." + ) + + "\n\n" + + t("All data is stored locally on your computer. Nothing is sent to the internet."), + title=t("Enable Encoding History"), + ) + if history_choice is not None: + app.fastflix.config.enable_history = history_choice + if history_choice: + container.rebuild_menu() + app.fastflix.config.save() # Run startup tasks (FFmpeg config, encoder init) through status bar diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index ea865a12..90a5b0c7 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -14172,3 +14172,457 @@ Cover extraction complete: ukr: Зняття кришки завершено kor: 표지 추출 완료 ron: Extragerea capacului completă +Suppress FFmpeg version warning on startup: + eng: Suppress FFmpeg version warning on startup + deu: FFmpeg-Versionswarnung beim Start unterdrücken + fra: Suppression de l'avertissement de version de FFmpeg au démarrage + ita: Sopprimere l'avviso di versione di FFmpeg all'avvio + spa: Suprimir la advertencia de versión de FFmpeg al inicio + jpn: 起動時の FFmpeg のバージョン警告を抑制する + rus: Подавление предупреждения о версии FFmpeg при запуске + por: Suprimir o aviso de versão do FFmpeg na inicialização + swe: Undertrycka FFmpeg-versionens varning vid start + pol: Wyłączenie ostrzeżenia o wersji FFmpeg podczas uruchamiania + chs: 抑制启动时的 FFmpeg 版本警告 + ukr: Прибрати попередження про версію FFmpeg під час запуску + kor: 시작 시 FFmpeg 버전 경고 표시 안 함 + ron: Suprimarea avertismentului privind versiunea FFmpeg la pornire +Enable encoding history: + eng: Enable encoding history + deu: Kodierungsverlauf aktivieren + fra: Activer l'historique de l'encodage + ita: Abilita la cronologia di codifica + spa: Activar el historial de codificación + jpn: エンコード履歴を有効にする + rus: Включить историю кодирования + por: Ativar o histórico de codificação + swe: Aktivera kodningshistorik + pol: Włącz historię kodowania + chs: 启用编码历史记录 + ukr: Увімкнути історію кодування + kor: 인코딩 기록 활성화 + ron: Activați istoricul codării +OpenCL Support: + eng: OpenCL Support + deu: OpenCL-Unterstützung + fra: Support OpenCL + ita: Supporto OpenCL + spa: Compatibilidad con OpenCL + jpn: OpenCLサポート + rus: Поддержка OpenCL + por: Suporte OpenCL + swe: OpenCL-stöd + pol: Obsługa OpenCL + chs: 支持 OpenCL + ukr: Підтримка OpenCL + kor: OpenCL 지원 + ron: Suport OpenCL +Disable: + eng: Disable + deu: Deaktivieren Sie + fra: Désactiver + ita: Disattivare + spa: Desactivar + jpn: 無効 + rus: Отключить + por: Desativar + swe: Avaktivera + pol: Wyłącz + chs: 禁用 + ukr: Вимкнути + kor: 비활성화 + ron: Dezactivare +Re-detect: + eng: Re-detect + deu: Erneutes Aufspüren + fra: Re-détecter + ita: Rilevare nuovamente + spa: Vuelva a detectar + jpn: 再検出 + rus: Повторное обнаружение + por: Re-detetar + swe: Återupptäck + pol: Ponowne wykrywanie + chs: 重新检测 + ukr: Повторне виявлення + kor: 다시 감지 + ron: Re-detectați +Select which audio encoders appear in audio codec dropdown lists.: + eng: Select which audio encoders appear in audio codec dropdown lists. + deu: Wählen Sie aus, welche Audio-Encoder in den Audio-Codec-Dropdown-Listen erscheinen. + fra: Sélectionner les encodeurs audio qui apparaissent dans les listes déroulantes des codecs audio. + ita: Selezionare quali codificatori audio appaiono negli elenchi a discesa dei codec audio. + spa: Selecciona qué codificadores de audio aparecen en las listas desplegables de codecs de audio. + jpn: オーディオコーデックのドロップダウンリストに表示されるオーディオエンコーダを選択します。 + rus: Выберите, какие аудиокодеки будут отображаться в выпадающих списках аудиокодеков. + por: Selecionar quais os codificadores de áudio que aparecem nas listas pendentes de codecs de áudio. + swe: Välj vilka ljudkodare som ska visas i rullgardinslistorna för ljudkodare. + pol: Wybór koderów audio wyświetlanych na listach rozwijanych kodeków audio. + chs: 选择哪些音频编码器会出现在音频编解码器下拉列表中。 + ukr: Виберіть, які аудіокодеки відображатимуться у випадаючих списках аудіокодеків. + kor: 오디오 코덱 드롭다운 목록에 표시되는 오디오 인코더를 선택합니다. + ron: Selectați codurile audio care apar în listele derulante de codecuri audio. +Only encoders supported by your FFmpeg build are shown.: + eng: Only encoders supported by your FFmpeg build are shown. + deu: Es werden nur Encoder angezeigt, die von Ihrem FFmpeg-Build unterstützt werden. + fra: Seuls les encodeurs supportés par votre version de FFmpeg sont affichés. + ita: Vengono mostrati solo gli encoder supportati dalla propria versione di FFmpeg. + spa: Sólo se muestran los codificadores compatibles con su versión de FFmpeg. + jpn: FFmpegのビルドでサポートされているエンコーダのみが表示されます。 + rus: Отображаются только кодировщики, поддерживаемые вашей сборкой FFmpeg. + por: Apenas são mostrados os codificadores suportados pela sua compilação do FFmpeg. + swe: Endast kodare som stöds av din FFmpeg-version visas. + pol: Wyświetlane są tylko kodery obsługiwane przez kompilację FFmpeg. + chs: 仅显示您的 FFmpeg 版本所支持的编码器。 + ukr: Відображаються лише ті кодери, які підтримуються у вашій збірці FFmpeg. + kor: FFmpeg 빌드에서 지원하는 인코더만 표시됩니다. + ron: Sunt afișate numai codificatoarele acceptate de versiunea FFmpeg. +Select All: + eng: Select All + deu: Alle auswählen + fra: Sélectionner tout + ita: Seleziona tutti + spa: Seleccionar todo + jpn: すべて選択 + rus: Выбрать все + por: Selecionar tudo + swe: Välj alla + pol: Wybierz wszystko + chs: 全部选择 + ukr: Вибрати все + kor: 모두 선택 + ron: Selectați toate +Deselect All: + eng: Deselect All + deu: Alle abwählen + fra: Désélectionner tout + ita: Deselezionare tutto + spa: Deseleccionar todo + jpn: すべて選択解除 + rus: Отменить выбор всех + por: Desmarcar tudo + swe: Avmarkera alla + pol: Odznacz wszystko + chs: 全部取消选择 + ukr: Зніміть позначку з усіх + kor: 모두 선택 해제 + ron: Deselectați toate +Audio Encoders: + eng: Audio Encoders + deu: Audio-Codierer + fra: Encodeurs audio + ita: Codificatori audio + spa: Codificadores de audio + jpn: オーディオ・エンコーダ + rus: Аудиокодеры + por: Codificadores de áudio + swe: Ljudkodare + pol: Kodery audio + chs: 音频编码器 + ukr: Аудіокодери + kor: 오디오 인코더 + ron: Codificatoare audio +Rotate 90° clockwise: + eng: Rotate 90° clockwise + deu: 90° im Uhrzeigersinn drehen + fra: Rotation de 90° dans le sens des aiguilles d'une montre + ita: Ruotare di 90° in senso orario + spa: Girar 90° en el sentido de las agujas del reloj + jpn: 時計回りに90°回転 + rus: Поверните на 90° по часовой стрелке + por: Rodar 90° no sentido dos ponteiros do relógio + swe: Rotera 90° medurs + pol: Obrót o 90° zgodnie z ruchem wskazówek zegara + chs: 顺时针旋转 90 + ukr: Поверніть на 90° за годинниковою стрілкою + kor: 시계 방향으로 90° 회전 + ron: Rotiți 90° în sensul acelor de ceasornic +Toggle horizontal flip: + eng: Toggle horizontal flip + deu: Horizontal spiegeln umschalten + fra: Basculer à l'horizontale + ita: Alterna il capovolgimento orizzontale + spa: Voltear horizontalmente + jpn: トグル水平フリップ + rus: Переключение горизонтального переворота + por: Alternar inversão horizontal + swe: Växla horisontell flip + pol: Przełącz poziomą klapkę + chs: 切换水平翻转 + ukr: Горизонтальне перевертання + kor: 수평 뒤집기 토글 + ron: Comutare flip orizontal +Toggle vertical flip: + eng: Toggle vertical flip + deu: Vertikal spiegeln umschalten + fra: Basculement vertical + ita: Alterna il capovolgimento verticale + spa: Alternar volteo vertical + jpn: トグル垂直フリップ + rus: Переключение вертикального переворота + por: Alternar inversão vertical + swe: Växla vertikal flip + pol: Przełącz przerzucanie w pionie + chs: 切换垂直翻转 + ukr: Перемикач вертикального перегортання + kor: 세로 뒤집기 토글 + ron: Toggle vertical flip +Extracting cover images...: + eng: Extracting cover images... + deu: Extrahieren von Titelbildern... + fra: Extraction d'images de couverture... + ita: Estrazione di immagini di copertura... + spa: Extracción de imágenes de portada... + jpn: カバー画像の抽出... + rus: Извлечение изображений обложки... + por: Extração de imagens de cobertura... + swe: Extrahera täckbilder... + pol: Wyodrębnianie obrazów okładek... + chs: 提取封面图像... + ukr: Витягування зображень обкладинки... + kor: 표지 이미지 추출하기... + ron: Extragerea imaginilor de acoperire... +Would you like to enable encoding history?: + eng: Would you like to enable encoding history? + deu: Möchten Sie den Kodierungsverlauf aktivieren? + fra: Souhaitez-vous activer l'historique de l'encodage ? + ita: Si desidera abilitare la cronologia di codifica? + spa: ¿Desea activar el historial de codificación? + jpn: エンコード履歴を有効にしますか? + rus: Хотите включить историю кодирования? + por: Gostaria de ativar o histórico de codificação? + swe: Vill du aktivera kodningshistorik? + pol: Czy chcesz włączyć historię kodowania? + chs: 您想启用编码历史记录吗? + ukr: Бажаєте увімкнути історію кодування? + kor: 기록 인코딩을 사용하시겠습니까? + ron: Doriți să activați istoricul codării? +Enable Encoding History: + eng: Enable Encoding History + deu: Kodierungsverlauf aktivieren + fra: Activer l'historique du codage + ita: Abilita la cronologia di codifica + spa: Activar el historial de codificación + jpn: エンコード履歴を有効にする + rus: Включить историю кодирования + por: Ativar o histórico de codificação + swe: Aktivera kodningshistorik + pol: Włącz historię kodowania + chs: 启用编码历史记录 + ukr: Увімкнути історію кодування + kor: 인코딩 기록 활성화 + ron: Activați istoricul codării +Gather FFmpeg video encoders: + eng: Gather FFmpeg video encoders + deu: Sammeln Sie FFmpeg-Video-Encoder + fra: Rassembler les encodeurs vidéo FFmpeg + ita: Raccogliere i codificatori video FFmpeg + spa: Reúne codificadores de vídeo FFmpeg + jpn: FFmpegビデオエンコーダを集める + rus: Соберите кодировщики видео FFmpeg + por: Reunir codificadores de vídeo FFmpeg + swe: Samla FFmpeg-videokodare + pol: Zbierz kodery wideo FFmpeg + chs: 收集 FFmpeg 视频编码器 + ukr: Зібрати відеокодери FFmpeg + kor: FFmpeg 비디오 인코더 수집 + ron: Gather FFmpeg coduri video +Encoding History: + eng: Encoding History + deu: Geschichte der Kodierung + fra: Historique du codage + ita: Storia della codifica + spa: Historia de la codificación + jpn: エンコーディングの歴史 + rus: История кодирования + por: Histórico de codificação + swe: Kodningshistorik + pol: Historia kodowania + chs: 编码历史 + ukr: Історія кодування + kor: 인코딩 기록 + ron: Istoricul codificării +Clear History: + eng: Clear History + deu: Geschichte löschen + fra: Historique clair + ita: Storia chiara + spa: Historia clara + jpn: 明確な歴史 + rus: Чистая история + por: Limpar histórico + swe: Tydlig historia + pol: Wyczyść historię + chs: 清除历史记录 + ukr: Чиста історія + kor: 기록 지우기 + ron: Istoric clar +No encoding history yet.: + eng: No encoding history yet. + deu: Noch keine Kodierungsgeschichte. + fra: Pas encore d'historique d'encodage. + ita: Non c'è ancora una storia di codifica. + spa: Aún no hay historial de codificación. + jpn: エンコード履歴はまだない。 + rus: История кодирования пока отсутствует. + por: Ainda não há historial de codificação. + swe: Ingen kodningshistorik ännu. + pol: Brak historii kodowania. + chs: 还没有编码历史。 + ukr: Історії кодування поки що немає. + kor: 아직 인코딩 기록이 없습니다. + ron: Nu există încă un istoric al codării. +History: + eng: History + deu: Geschichte + fra: L'histoire + ita: La storia + spa: Historia + jpn: 歴史 + rus: История + por: História + swe: Historia + pol: Historia + chs: 历史 + ukr: Історія + kor: 역사 + ron: Istoric +Apply Last Used Settings: + eng: Apply Last Used Settings + deu: Zuletzt verwendete Einstellungen übernehmen + fra: Appliquer les derniers réglages utilisés + ita: Applica le ultime impostazioni utilizzate + spa: Aplicar la última configuración utilizada + jpn: 最後に使用した設定を適用する + rus: Применить последние использованные настройки + por: Aplicar as últimas definições utilizadas + swe: Tillämpa senast använda inställningar + pol: Zastosuj ostatnio używane ustawienia + chs: 应用上次使用的设置 + ukr: Застосувати останні використані налаштування + kor: 마지막으로 사용한 설정 적용 + ron: Aplicați ultimele setări utilizate +View History: + eng: View History + deu: Geschichte ansehen + fra: Voir l'historique + ita: Visualizza la storia + spa: Ver historial + jpn: 履歴を見る + rus: Посмотреть историю + por: Ver história + swe: Visa historik + pol: Wyświetl historię + chs: 查看历史 + ukr: Переглянути історію + kor: 기록 보기 + ron: Vezi istoricul +Apply Settings: + eng: Apply Settings + deu: Einstellungen anwenden + fra: Appliquer les paramètres + ita: Applicare le impostazioni + spa: Aplicar ajustes + jpn: 設定を適用する + rus: Применить настройки + por: Aplicar definições + swe: Tillämpa inställningar + pol: Zastosuj ustawienia + chs: 应用设置 + ukr: Застосувати налаштування + kor: 설정 적용 + ron: Aplicați setările +Details: + eng: Details + deu: Einzelheiten + fra: Détails + ita: Dettagli + spa: Detalles + jpn: 詳細 + rus: Подробности + por: Detalhes + swe: Detaljer + pol: Szczegóły + chs: 详细信息 + ukr: Деталі + kor: 세부 정보 + ron: Detalii +Duration: + eng: Duration + deu: Dauer + fra: Durée de l'accord + ita: Durata + spa: Duración + jpn: 期間 + rus: Продолжительность + por: Duração + swe: Varaktighet + pol: Czas trwania + chs: 持续时间 + ukr: Тривалість + kor: 기간 + ron: Durată +File Size: + eng: File Size + deu: Größe der Datei + fra: Taille du fichier + ita: Dimensione del file + spa: Tamaño del archivo + jpn: ファイルサイズ + rus: Размер файла + por: Tamanho do ficheiro + swe: Filstorlek + pol: Rozmiar pliku + chs: 文件大小 + ukr: Розмір файлу + kor: 파일 크기 + ron: Dimensiunea fișierului +? This keeps a local record of your completed encodings, letting you review the settings used for any past video and quickly re-apply them to new ones. +: eng: This keeps a local record of your completed encodings, letting you review the settings used for any past video and quickly re-apply them to new ones. + deu: Auf diese Weise wird ein lokaler Datensatz Ihrer abgeschlossenen Kodierungen gespeichert, so dass Sie die für frühere Videos verwendeten Einstellungen überprüfen und schnell auf neue Videos + anwenden können. + fra: Cela permet de conserver un enregistrement local de vos encodages terminés, ce qui vous permet de revoir les paramètres utilisés pour toute vidéo antérieure et de les réappliquer rapidement aux + nouvelles vidéos. + ita: In questo modo si mantiene una registrazione locale delle codifiche completate, consentendo di rivedere le impostazioni utilizzate per qualsiasi video passato e di riapplicarle rapidamente a + quelli nuovi. + spa: Esto mantiene un registro local de tus codificaciones completadas, permitiéndote revisar los ajustes utilizados para cualquier vídeo anterior y volver a aplicarlos rápidamente a los nuevos. + jpn: これにより、完成したエンコーディングのローカル記録が保持され、過去の動画に使用した設定を確認したり、新しい動画に素早く再適用したりすることができます。 + rus: При этом сохраняется локальная запись о выполненных кодировках, что позволяет просмотреть настройки, использованные для любого прошлого видео, и быстро применить их к новым. + por: Isto mantém um registo local das suas codificações concluídas, permitindo-lhe rever as definições utilizadas em qualquer vídeo anterior e reaplicá-las rapidamente a novos vídeos. + swe: På så sätt sparas ett lokalt register över dina slutförda kodningar, så att du kan granska de inställningar som använts för en tidigare video och snabbt tillämpa dem på nya. + pol: Przechowuje to lokalny zapis ukończonych kodowań, umożliwiając przeglądanie ustawień używanych w poprzednich filmach i szybkie ponowne zastosowanie ich do nowych. + chs: 这将保留您已完成编码的本地记录,让您可以查看过去任何视频所使用的设置,并快速将其重新应用到新视频中。 + ukr: Це дозволяє зберігати локальний запис завершених кодувань, що дає змогу переглядати налаштування, використані для будь-якого попереднього відео, і швидко застосовувати їх до нових. + kor: 이렇게 하면 완료된 인코딩의 로컬 기록이 유지되므로 과거 동영상에 사용된 설정을 검토하고 새 동영상에 빠르게 다시 적용할 수 있습니다. + ron: Acest lucru păstrează o înregistrare locală a codificărilor finalizate, permițându-vă să revizuiți setările utilizate pentru orice videoclip trecut și să le aplicați din nou rapid pentru cele + noi. +All data is stored locally on your computer. Nothing is sent to the internet.: + eng: All data is stored locally on your computer. Nothing is sent to the internet. + deu: Alle Daten werden lokal auf Ihrem Computer gespeichert. Nichts wird an das Internet gesendet. + fra: Toutes les données sont stockées localement sur votre ordinateur. Rien n'est envoyé sur l'internet. + ita: Tutti i dati vengono memorizzati localmente sul computer. Nulla viene inviato a Internet. + spa: Todos los datos se almacenan localmente en tu ordenador. Nada se envía a Internet. + jpn: すべてのデータはあなたのコンピューターにローカルに保存されます。インターネットには何も送信されません。 + rus: Все данные хранятся локально на вашем компьютере. Ничего не отправляется в Интернет. + por: Todos os dados são armazenados localmente no seu computador. Nada é enviado para a Internet. + swe: All data lagras lokalt på din dator. Ingenting skickas till internet. + pol: Wszystkie dane są przechowywane lokalnie na komputerze. Nic nie jest wysyłane do Internetu. + chs: 所有数据都存储在电脑本地。不会向互联网发送任何数据。 + ukr: Всі дані зберігаються локально на вашому комп'ютері. Нічого не надсилається в інтернет. + kor: 모든 데이터는 컴퓨터에 로컬로 저장됩니다. 인터넷으로 전송되지 않습니다. + ron: Toate datele sunt stocate local pe computerul dvs. Nimic nu este trimis la internet. +Max items: + eng: Max items + deu: Maximale Artikel + fra: Nombre maximal d'articles + ita: Articoli massimi + spa: Número máximo de artículos + jpn: 最大アイテム + rus: Максимум предметов + por: Máximo de artigos + swe: Max antal artiklar + pol: Maksymalna liczba elementów + chs: 最多项目 + ukr: Максимум елементів + kor: 최대 항목 + ron: Articole maxime diff --git a/fastflix/models/config.py b/fastflix/models/config.py index c3af3c19..e9081662 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -307,6 +307,8 @@ class Config(BaseModel): use_keyframes_for_preview: bool = True terms_accepted: bool = False auto_detect_subtitles: bool = True + enable_history: bool | None = None + history_max_items: int = 50 @property def pgs_ocr_available(self) -> bool: diff --git a/fastflix/shared.py b/fastflix/shared.py index 56481095..c2d55239 100644 --- a/fastflix/shared.py +++ b/fastflix/shared.py @@ -148,17 +148,50 @@ def error_message(msg, details=None, traceback=False, title=None, parent=None): def yes_no_message(msg, title=None, yes_text=t("Yes"), no_text=t("No"), yes_action=None, no_action=None, parent=None): - sm = QtWidgets.QMessageBox(parent) - sm.setWindowTitle(t(title)) - sm.setText(msg) - sm.addButton(yes_text, QtWidgets.QMessageBox.YesRole) - sm.addButton(no_text, QtWidgets.QMessageBox.NoRole) - sm.exec_() - if sm.clickedButton().text() == yes_text: + dialog = QtWidgets.QDialog(parent) + dialog.setWindowTitle(t(title)) + dialog._button_clicked = None + + layout = QtWidgets.QVBoxLayout() + label = QtWidgets.QLabel(msg) + label.setWordWrap(True) + layout.addWidget(label) + + button_layout = QtWidgets.QHBoxLayout() + + no_button = QtWidgets.QPushButton(no_text) + no_button.setMinimumHeight(30) + no_button.setStyleSheet("QPushButton { background-color: #F44336; color: white; padding: 6px 20px; }") + + def on_no(): + dialog._button_clicked = False + dialog.reject() + + no_button.clicked.connect(on_no) + + yes_button = QtWidgets.QPushButton(yes_text) + yes_button.setMinimumHeight(30) + yes_button.setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 6px 20px; }") + + def on_yes(): + dialog._button_clicked = True + dialog.accept() + + yes_button.clicked.connect(on_yes) + + button_layout.addWidget(no_button) + button_layout.addStretch() + button_layout.addWidget(yes_button) + layout.addLayout(button_layout) + + dialog.setLayout(layout) + dialog.exec() + + if dialog._button_clicked is True: if yes_action: return yes_action() return True - elif sm.clickedButton().text() == no_text: + elif dialog._button_clicked is False: if no_action: return no_action() return False diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index e1b74ec2..b591cbc8 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -38,6 +38,7 @@ from fastflix.widgets.settings import Settings from fastflix.widgets.status_bar import StatusBarWidget, STATE_COMPLETE, STATE_ERROR from fastflix.widgets.windows.concat import ConcatWindow +from fastflix.widgets.windows.history_window import HistoryWindow from fastflix.widgets.windows.multiple_files import MultipleFilesWindow # from fastflix.widgets.windows.hdr10plus_inject import HDR10PlusInjectWindow @@ -346,6 +347,21 @@ def init_menu(self): # hdr10p_inject_action.triggered.connect(self.show_hdr10p_inject) # tools_menu.addAction(hdr10p_inject_action) + if self.app.fastflix.config.enable_history: + history_menu = menubar.addMenu(t("History")) + + self.apply_last_action = QAction(t("Apply Last Used Settings"), self) + self.apply_last_action.triggered.connect(self.apply_last_history) + history_menu.addAction(self.apply_last_action) + + view_history_action = QAction(t("View History"), self) + view_history_action.triggered.connect(self.show_history) + history_menu.addAction(view_history_action) + + history_menu.aboutToShow.connect(self._update_history_menu_state) + else: + self.apply_last_action = None + wiki_action = QAction(self.si(QtWidgets.QStyle.SP_FileDialogInfoView), t("FastFlix Wiki"), self) wiki_action.triggered.connect(self.show_wiki) @@ -404,6 +420,68 @@ def init_menu(self): help_menu.addSeparator() help_menu.addAction(about_action) + def rebuild_menu(self): + self.menuBar().clear() + self.init_menu() + + def show_history(self): + self.history_window = HistoryWindow(app=self.app) + self.history_window.apply_settings_requested.connect(self._apply_history_entry) + self.history_window.show() + + def _update_history_menu_state(self): + if self.apply_last_action is None: + return + from fastflix.models.history import load_history + + has_video = self.app.fastflix.current_video is not None + has_history = bool(load_history(self.app.fastflix.data_path)) + self.apply_last_action.setEnabled(has_video and has_history) + + def apply_last_history(self): + from fastflix.models.history import load_history + + entries = load_history(self.app.fastflix.data_path) + if not entries: + error_message(t("No encoding history available")) + return + self._apply_history_entry(entries[-1]) + + def _apply_history_entry(self, entry): + from fastflix.models.encode import setting_types + + if not self.app.fastflix.current_video: + error_message(t("Please load a video first")) + return + + # Find the settings class matching the encoder name + settings_class = None + for cls in setting_types.values(): + if cls().name == entry.encoder_name: + settings_class = cls + break + if not settings_class: + error_message(f"{t('Encoder not available')}: {entry.encoder_name}") + return + + # Check if encoder is available + if entry.encoder_name not in [e.name for e in self.app.fastflix.encoders.values()]: + error_message(f"{t('Encoder not available')}: {entry.encoder_name}") + return + + try: + new_settings = settings_class(**entry.encoder_settings) + # Switch encoder first (creates panel from profile defaults) + self.main.widgets.convert_to.setCurrentText(entry.encoder_name) + # Then set our settings on the model + self.app.fastflix.current_video.video_settings.video_encoder_settings = new_settings + # Reload the panel widgets from the model settings + self.main.video_options.current_settings.reload() + self.main.page_update(build_thumbnail=False) + except Exception: + logger.exception("Failed to apply history settings") + error_message(t("Failed to apply settings from history")) + def show_wiki(self): QtGui.QDesktopServices.openUrl(QtCore.QUrl("https://github.com/cdgriffith/FastFlix/wiki")) diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index c6419acf..2a6c2cd5 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -569,6 +569,7 @@ def config_update(self, encoder_reload_needed=False): self.reload_encoders() else: self.change_output_types() + self.container.rebuild_menu() self.page_update(build_thumbnail=True) def reload_encoders(self): @@ -2797,6 +2798,11 @@ def status_update(self, status_response): if response.status == "error": video.status.error = True errored = True + if self.app.fastflix.config.enable_history: + try: + self._record_history(video, success=False) + except Exception: + logger.exception("Failed to record history entry for errored encode") break if errored and not self.video_options.queue.ignore_errors.isChecked(): @@ -2870,9 +2876,104 @@ def _post_encode_process(self, video: Video): if has_post_encode_placeholders(output_path.stem): self._rename_with_post_encode_vars(video, probe_data) + # Record to history if enabled + if self.app.fastflix.config.enable_history: + try: + self._record_history(video) + except Exception: + logger.exception("Failed to record history entry") + except Exception: logger.exception("Post-encode processing failed (encode itself succeeded)") + def _record_history(self, video: Video, success: bool = True): + """Record a completed or failed encoding to history.""" + import uuid as uuid_mod + from datetime import datetime + + from fastflix.models.history import ( + HistoryEntry, + add_history_entry, + build_settings_summary, + get_history_thumbnails_dir, + ) + + output_path = video.video_settings.output_path + encoder_settings = video.video_settings.video_encoder_settings + + # Build audio summary + audio_parts = [] + for track in video.audio_tracks: + if track.enabled: + codec = track.conversion_codec if track.conversion_codec else track.codec + audio_parts.append(f"{track.language} ({codec})") + audio_summary = ", ".join(audio_parts) if audio_parts else "" + + # Build subtitle summary + sub_parts = [] + for track in video.subtitle_tracks: + if track.enabled: + sub_parts.append(f"{track.language} ({track.subtitle_type})") + subtitle_summary = ", ".join(sub_parts) if sub_parts else "" + + # Resolution + resolution = "" + if video.width and video.height: + resolution = f"{video.width}x{video.height}" + + # File size + file_size = 0 + try: + if output_path and output_path.exists(): + file_size = output_path.stat().st_size + except Exception: + pass + + # Duration + duration = 0.0 + if video.duration: + duration = video.duration + + # Encode duration + encode_duration_secs = 0.0 + if video.status.encode_started_at: + encode_duration_secs = ( + datetime.now(video.status.encode_started_at.tzinfo) - video.status.encode_started_at + ).total_seconds() + + entry_uuid = str(uuid_mod.uuid4()) + thumbnail_filename = f"{entry_uuid}.jpg" + + entry = HistoryEntry( + uuid=entry_uuid, + source=str(video.source), + output=str(output_path) if output_path else "", + encoder_name=encoder_settings.name, + encoder_settings=encoder_settings.model_dump(), + encoder_settings_summary=build_settings_summary(encoder_settings.model_dump()), + audio_summary=audio_summary, + subtitle_summary=subtitle_summary, + resolution=resolution, + duration=duration, + file_size=file_size, + completed_at=datetime.now().isoformat(), + thumbnail_filename=thumbnail_filename, + success=success, + encode_duration_secs=encode_duration_secs, + ) + + # Copy thumbnail + thumbs_dir = get_history_thumbnails_dir(self.app.fastflix.data_path) + thumbs_dir.mkdir(parents=True, exist_ok=True) + if self.thumb_file.exists(): + try: + shutil.copy2(str(self.thumb_file), str(thumbs_dir / thumbnail_filename)) + except Exception: + logger.warning("Failed to copy thumbnail for history entry") + entry.thumbnail_filename = "" + + add_history_entry(self.app.fastflix.data_path, entry, max_items=self.app.fastflix.config.history_max_items) + def _validate_output(self, output_path: Path, probe_data): """Quick sanity check on the output file.""" if not output_path.exists(): diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index f5228079..4f1177f1 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -274,6 +274,7 @@ def __init__(self, parent, app: FastFlixApp): self.after_done_combo = QtWidgets.QComboBox() self.after_done_combo.addItem("None") + self.after_done_combo.addItem("exit") actions = set() if reusables.win_based: actions.update(done_actions["windows"].keys()) @@ -517,6 +518,8 @@ def set_after_done(self): if option == "None": command = None + elif option == "exit": + command = "__exit__" elif option in self.app.fastflix.config.custom_after_run_scripts: command = self.app.fastflix.config.custom_after_run_scripts[option] elif reusables.win_based: @@ -594,6 +597,10 @@ def add_to_queue(self): def run_after_done(self): if not self.after_done_action: return + if self.after_done_action == "__exit__": + logger.info("Exiting FastFlix after conversion complete") + self.app.quit() + return logger.info(f"Running after done action: {self.after_done_action}") BackgroundRunner(self.app.fastflix.log_queue).start_exec( self.after_done_action, str(after_done_path), shell=True diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 87b0ee65..28393389 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -18,7 +18,7 @@ generate_preview, validate_template, ) -from fastflix.shared import error_message, link +from fastflix.shared import error_message, link, yes_no_message from fastflix.widgets.flow_layout import FlowLayout logger = logging.getLogger("fastflix") @@ -62,6 +62,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): tab_widget = QtWidgets.QTabWidget() tab_widget.addTab(self._build_settings_tab(), t("Settings")) tab_widget.addTab(self._build_output_naming_tab(), t("Output Naming")) + tab_widget.addTab(self._build_audio_encoders_tab(), t("Audio Encoders")) tab_widget.addTab(self._build_locations_tab(), t("Application Locations")) main_layout.addWidget(tab_widget) @@ -221,6 +222,36 @@ def _build_settings_tab(self): layout.addWidget(self.auto_detect_subtitles, row, 0, 1, 3) row += 1 + self.enable_history = QtWidgets.QCheckBox(t("Enable encoding history")) + self.enable_history.setChecked(bool(self.app.fastflix.config.enable_history)) + layout.addWidget(self.enable_history, row, 0, 1, 3) + row += 1 + + # OpenCL Support + opencl_label = QtWidgets.QLabel(t("OpenCL Support")) + self.opencl_combo = QtWidgets.QComboBox() + self.opencl_combo.addItems([t("Auto"), t("Disable")]) + if self.app.fastflix.config.opencl_support is False: + self.opencl_combo.setCurrentIndex(1) + else: + self.opencl_combo.setCurrentIndex(0) + + self.opencl_status_label = QtWidgets.QLabel() + self._update_opencl_status() + + opencl_detect_button = QtWidgets.QPushButton(t("Re-detect")) + opencl_detect_button.setFixedWidth(80) + opencl_detect_button.clicked.connect(self._run_opencl_detection) + + opencl_row_layout = QtWidgets.QHBoxLayout() + opencl_row_layout.addWidget(self.opencl_combo) + opencl_row_layout.addWidget(self.opencl_status_label) + opencl_row_layout.addWidget(opencl_detect_button) + + layout.addWidget(opencl_label, row, 0) + layout.addLayout(opencl_row_layout, row, 1, 1, 2) + row += 1 + # Default Output Directory self.default_output_dir = QtWidgets.QCheckBox(t("Use same output directory as source file")) layout.addWidget(self.default_output_dir, row, 0, 1, 2) @@ -439,6 +470,57 @@ def _update_template_preview(self): chip.setStyleSheet(self._CHIP_STYLE_POST if is_post else self._CHIP_STYLE_PRE) chip.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) + def _build_audio_encoders_tab(self): + tab = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout() + + description = QtWidgets.QLabel( + t("Select which audio encoders appear in audio codec dropdown lists.") + + " " + + t("Only encoders supported by your FFmpeg build are shown.") + ) + description.setWordWrap(True) + layout.addWidget(description) + + # Select All / Deselect All buttons + button_row = QtWidgets.QHBoxLayout() + select_all_button = QtWidgets.QPushButton(t("Select All")) + select_all_button.clicked.connect(lambda: self._set_all_audio_encoders(True)) + deselect_all_button = QtWidgets.QPushButton(t("Deselect All")) + deselect_all_button.clicked.connect(lambda: self._set_all_audio_encoders(False)) + button_row.addWidget(select_all_button) + button_row.addWidget(deselect_all_button) + button_row.addStretch() + layout.addLayout(button_row) + + # Scrollable list of checkboxes + scroll_area = QtWidgets.QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_widget = QtWidgets.QWidget() + scroll_layout = QtWidgets.QVBoxLayout() + + sane_set = set(self.app.fastflix.config.sane_audio_selection) + all_encoders = sorted(self.app.fastflix.audio_encoders or []) + + self.audio_encoder_checkboxes = {} + for encoder_name in all_encoders: + cb = QtWidgets.QCheckBox(encoder_name) + cb.setChecked(encoder_name in sane_set) + self.audio_encoder_checkboxes[encoder_name] = cb + scroll_layout.addWidget(cb) + + scroll_layout.addStretch() + scroll_widget.setLayout(scroll_layout) + scroll_area.setWidget(scroll_widget) + layout.addWidget(scroll_area) + + tab.setLayout(layout) + return tab + + def _set_all_audio_encoders(self, checked: bool): + for cb in self.audio_encoder_checkboxes.values(): + cb.setChecked(checked) + def _build_locations_tab(self): tab = QtWidgets.QWidget() layout = QtWidgets.QGridLayout() @@ -547,31 +629,6 @@ def _build_locations_tab(self): layout.addWidget(gifski_path_button, row, 2) row += 1 - # OpenCL Support - opencl_label = QtWidgets.QLabel(t("OpenCL Support")) - self.opencl_combo = QtWidgets.QComboBox() - self.opencl_combo.addItems([t("Auto"), t("Disable")]) - if self.app.fastflix.config.opencl_support is False: - self.opencl_combo.setCurrentIndex(1) - else: - self.opencl_combo.setCurrentIndex(0) - - self.opencl_status_label = QtWidgets.QLabel() - self._update_opencl_status() - - opencl_detect_button = QtWidgets.QPushButton(t("Re-detect")) - opencl_detect_button.setFixedWidth(80) - opencl_detect_button.clicked.connect(self._run_opencl_detection) - - opencl_row_layout = QtWidgets.QHBoxLayout() - opencl_row_layout.addWidget(self.opencl_combo) - opencl_row_layout.addWidget(self.opencl_status_label) - opencl_row_layout.addWidget(opencl_detect_button) - - layout.addWidget(opencl_label, row, 0) - layout.addLayout(opencl_row_layout, row, 1, 1, 2) - row += 1 - # Detected External Programs section detected_group = QtWidgets.QGroupBox(t("Detected External Programs")) detected_layout = QtWidgets.QGridLayout() @@ -636,6 +693,9 @@ def save(self): else: self.app.fastflix.config.work_path = new_work_dir self.app.fastflix.config.use_sane_audio = self.use_sane_audio.isChecked() + self.app.fastflix.config.sane_audio_selection = [ + name for name, cb in self.audio_encoder_checkboxes.items() if cb.isChecked() + ] if self.theme.currentText() != self.app.fastflix.config.theme: restart_needed = True self.app.fastflix.config.theme = self.theme.currentText() @@ -723,6 +783,18 @@ def save(self): self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() self.app.fastflix.config.auto_detect_subtitles = self.auto_detect_subtitles.isChecked() + new_history = self.enable_history.isChecked() + old_history = bool(self.app.fastflix.config.enable_history) + if old_history and not new_history: + from fastflix.models.history import clear_history + + if yes_no_message( + t("Would you like to delete your encoding history data?"), + title=t("Delete History"), + ): + clear_history(self.app.fastflix.data_path) + self.app.fastflix.config.enable_history = new_history + if self.opencl_combo.currentIndex() == 1: # Disable self.app.fastflix.config.opencl_support = False self.app.fastflix.opencl_support = False From 5a318f7a748ee6c210e371dbc6df1c5371710536 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 12 Mar 2026 16:04:07 -0500 Subject: [PATCH 05/12] * Fixing #728 rigaya encoders (NVEncC, QSVEncC, VCEEncC) now pass --dolby-vision-rpu-prm crop=true when Dolby Vision RPU copy is enabled and a crop is applied (thanks to izzy697) --- .claude/skills/changelog.md | 3 ++- CHANGES | 1 + fastflix/encoders/nvencc_av1/command_builder.py | 2 ++ fastflix/encoders/nvencc_hevc/command_builder.py | 2 ++ fastflix/encoders/qsvencc_av1/command_builder.py | 2 ++ fastflix/encoders/qsvencc_hevc/command_builder.py | 2 ++ fastflix/encoders/vceencc_av1/command_builder.py | 2 ++ fastflix/encoders/vceencc_hevc/command_builder.py | 2 ++ 8 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.claude/skills/changelog.md b/.claude/skills/changelog.md index cffa1230..b518092a 100644 --- a/.claude/skills/changelog.md +++ b/.claude/skills/changelog.md @@ -1,7 +1,8 @@ --- name: changelog -description: Update the CHANGES changelog file with new entries +description: Update the CHANGES changelog file with new entries. MUST be consulted whenever adding, modifying, or removing entries in the CHANGES file, including when referencing GitHub issues. user_invocable: true +trigger: Always read this skill BEFORE writing any changelog entry. Triggered by any task that involves updating the CHANGES file, adding a fix/feature note, or referencing a GitHub issue in the changelog. --- # Changelog Skill diff --git a/CHANGES b/CHANGES index a0f5bf89..bca317d6 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,7 @@ * Changing non-copy encoder rotation handling to use FFmpeg's built-in auto-rotation instead of manual display_rotation overrides, which also properly handles source flips from the display matrix * Changing -fps_mode to be used instead of deprecated -vsync for frame rate control * Fixing #725 encoder detection to check `ffmpeg -encoders` output in addition to compilation flags, so encoders like VAAPI are shown even when the build flag is absent (thanks to Davius and Generator) +* Fixing #728 rigaya encoders (NVEncC, QSVEncC, VCEEncC) now pass --dolby-vision-rpu-prm crop=true when Dolby Vision RPU copy is enabled and a crop is applied (thanks to izzy697) * Fixing #730 6.1.1 arm no subtitles tab with VideoToolBox (Apple M1 and above) HEVC & H264 (thanks to enaveso) * Fixing visual crop window showing incorrect bounds and dimensions when user rotation is applied, by showing the frame in pre-rotation space where crop actually operates * Fixing video crop and dimension detection for rotated videos where display matrix rotation was not found when other side data (e.g., HDR mastering display) preceded it diff --git a/fastflix/encoders/nvencc_av1/command_builder.py b/fastflix/encoders/nvencc_av1/command_builder.py index cf21ae40..53959cec 100644 --- a/fastflix/encoders/nvencc_av1/command_builder.py +++ b/fastflix/encoders/nvencc_av1/command_builder.py @@ -148,6 +148,8 @@ def build(fastflix: FastFlix): if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) + if video.video_settings.crop: + command.extend(["--dolby-vision-rpu-prm", "crop=true"]) command.extend(["--output-depth", bit_depth]) command.extend(["--multipass", settings.multipass]) diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py index b0359f29..e4621aed 100644 --- a/fastflix/encoders/nvencc_hevc/command_builder.py +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -148,6 +148,8 @@ def build(fastflix: FastFlix): if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) + if video.video_settings.crop: + command.extend(["--dolby-vision-rpu-prm", "crop=true"]) command.extend(["--output-depth", bit_depth]) command.extend(["--multipass", settings.multipass]) diff --git a/fastflix/encoders/qsvencc_av1/command_builder.py b/fastflix/encoders/qsvencc_av1/command_builder.py index 48acd5a8..37d40fd0 100644 --- a/fastflix/encoders/qsvencc_av1/command_builder.py +++ b/fastflix/encoders/qsvencc_av1/command_builder.py @@ -142,6 +142,8 @@ def build(fastflix: FastFlix): if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) + if video.video_settings.crop: + command.extend(["--dolby-vision-rpu-prm", "crop=true"]) command.extend(["--output-depth", bit_depth]) diff --git a/fastflix/encoders/qsvencc_hevc/command_builder.py b/fastflix/encoders/qsvencc_hevc/command_builder.py index f555796c..442868a4 100644 --- a/fastflix/encoders/qsvencc_hevc/command_builder.py +++ b/fastflix/encoders/qsvencc_hevc/command_builder.py @@ -142,6 +142,8 @@ def build(fastflix: FastFlix): if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) + if video.video_settings.crop: + command.extend(["--dolby-vision-rpu-prm", "crop=true"]) command.extend(["--output-depth", bit_depth]) diff --git a/fastflix/encoders/vceencc_av1/command_builder.py b/fastflix/encoders/vceencc_av1/command_builder.py index 8fcac1b5..83657803 100644 --- a/fastflix/encoders/vceencc_av1/command_builder.py +++ b/fastflix/encoders/vceencc_av1/command_builder.py @@ -118,6 +118,8 @@ def build(fastflix: FastFlix): if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) + if video.video_settings.crop: + command.extend(["--dolby-vision-rpu-prm", "crop=true"]) command.extend(["--output-depth", output_depth]) command.extend(["--motion-est", settings.mv_precision]) diff --git a/fastflix/encoders/vceencc_hevc/command_builder.py b/fastflix/encoders/vceencc_hevc/command_builder.py index 9cc41fcb..496cc54a 100644 --- a/fastflix/encoders/vceencc_hevc/command_builder.py +++ b/fastflix/encoders/vceencc_hevc/command_builder.py @@ -119,6 +119,8 @@ def build(fastflix: FastFlix): if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) + if video.video_settings.crop: + command.extend(["--dolby-vision-rpu-prm", "crop=true"]) command.extend(["--output-depth", output_depth]) command.extend(["--motion-est", settings.mv_precision]) From a81e8a779ee8de5ba8782bd384d7a9b6ef9a2543 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 13 Mar 2026 16:43:22 -0500 Subject: [PATCH 06/12] * Adding Data tab to profile settings with passthrough all or remove all options for data and attachment streams * Adding clear current video X button next to source path and "Clear Current Video" option in File menu --- CHANGES | 2 + fastflix/models/profiles.py | 1 + fastflix/widgets/container.py | 5 +++ fastflix/widgets/main.py | 46 +++++++++++++++++++--- fastflix/widgets/panels/data_panel.py | 8 ++++ fastflix/widgets/video_options.py | 3 ++ fastflix/widgets/windows/profile_window.py | 36 +++++++++++++++++ 7 files changed, 95 insertions(+), 6 deletions(-) diff --git a/CHANGES b/CHANGES index bca317d6..8b639d91 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,8 @@ * Adding FFmpeg 8.0+ version check on startup with option to download latest FFmpeg on Windows * Adding "Keep source format" option to Audio Normalize, which detects and uses the same audio codec and bitrate as the source video * Adding Audio Encoders tab in Settings to view and select which FFmpeg audio encoders appear in audio codec dropdowns +* Adding Data tab to profile settings with passthrough all or remove all options for data and attachment streams +* Adding clear current video X button next to source path and "Clear Current Video" option in File menu * Adding rotation and flip buttons to the visual crop window, allowing users to change rotation (0/90/180/270) and toggle horizontal/vertical flip without leaving the crop view * Changing visual crop window to show the video frame with rotation and flip applied, matching the final output so crop edges can be set intuitively in the rotated view * Changing Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers diff --git a/fastflix/models/profiles.py b/fastflix/models/profiles.py index a30bd2c9..abfa6b97 100644 --- a/fastflix/models/profiles.py +++ b/fastflix/models/profiles.py @@ -158,6 +158,7 @@ class Profile(BaseModel): output_type: str = ".mkv" audio_filters: Optional[list[AudioMatch] | bool] = None + data_passthrough: Optional[bool] = None # None = passthrough all, True = passthrough all, False = remove all # subtitle_filters: Optional[list[SubtitleMatch]] = None # Legacy Audio, here to properly import old profiles diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index b591cbc8..9523a172 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -313,8 +313,13 @@ def init_menu(self): exit_action.setStatusTip(t("Exit application")) exit_action.triggered.connect(self.close) + clear_video_action = QAction(self.si(QtWidgets.QStyle.SP_DialogCloseButton), t("Clear Current Video"), self) + clear_video_action.triggered.connect(lambda: self.main.clear_current_video()) + file_menu.addAction(load_folder) file_menu.addSeparator() + file_menu.addAction(clear_video_action) + file_menu.addSeparator() file_menu.addAction(setting_action) file_menu.addSeparator() file_menu.addAction(self.stay_on_top_action) diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 2a6c2cd5..64107676 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -632,8 +632,24 @@ def init_video_area(self): source_label.setStyleSheet("color: white;") shrink_text_to_fit(source_label) self.source_video_path_widget.setMinimumHeight(scaler.scale(HEIGHTS.COMBO_BOX)) + self.clear_source_button = QtWidgets.QPushButton("✕") + self.clear_source_button.setFixedSize(scaler.scale(HEIGHTS.COMBO_BOX), scaler.scale(HEIGHTS.COMBO_BOX)) + self.clear_source_button.setToolTip(t("Clear Current Video")) + self.clear_source_button.clicked.connect(self.clear_current_video) + self.clear_source_button.setDisabled(True) + if self.app.fastflix.config.theme == "onyx": + self.clear_source_button.setStyleSheet( + "QPushButton { color: #F44336; border: none; font-weight: bold; }" + "QPushButton:hover { background-color: #3a3a3a; border-radius: 4px; }" + ) + else: + self.clear_source_button.setStyleSheet( + "QPushButton { color: #F44336; border: none; font-weight: bold; }" + "QPushButton:hover { background-color: #ddd; border-radius: 4px; }" + ) source_layout.addWidget(source_label) source_layout.addWidget(self.source_video_path_widget, stretch=True) + source_layout.addWidget(self.clear_source_button) output_layout = QtWidgets.QHBoxLayout() output_label = QtWidgets.QLabel(t("Filename")) @@ -1897,6 +1913,7 @@ def disable_all(self): self.output_path_button.setDisabled(True) self.output_video_path_widget.setDisabled(True) self.add_profile.setDisabled(True) + self.clear_source_button.setDisabled(True) def enable_all(self): for name, widget in self.widgets.items(): @@ -1913,6 +1930,7 @@ def enable_all(self): self.output_path_button.setEnabled(True) self.output_video_path_widget.setEnabled(True) self.add_profile.setEnabled(True) + self.clear_source_button.setEnabled(True) self.update_resolution() def clear_current_video(self): @@ -2962,15 +2980,31 @@ def _record_history(self, video: Video, success: bool = True): encode_duration_secs=encode_duration_secs, ) - # Copy thumbnail + # Generate thumbnail directly from source video thumbs_dir = get_history_thumbnails_dir(self.app.fastflix.data_path) thumbs_dir.mkdir(parents=True, exist_ok=True) - if self.thumb_file.exists(): - try: - shutil.copy2(str(self.thumb_file), str(thumbs_dir / thumbnail_filename)) - except Exception: - logger.warning("Failed to copy thumbnail for history entry") + thumb_output = thumbs_dir / thumbnail_filename + try: + from subprocess import PIPE, STDOUT + from subprocess import run as subprocess_run + + from fastflix.flix import generate_thumbnail_command + + thumb_command = generate_thumbnail_command( + config=self.app.fastflix.config, + source=video.source, + output=thumb_output, + filters=["-vf", "scale='min(440\\,iw):-8'"], + start_time=video.video_settings.start_time or 0, + input_track=video.video_settings.selected_track, + ) + result = subprocess_run(thumb_command, stdin=PIPE, stdout=PIPE, stderr=STDOUT) + if result.returncode != 0 or not thumb_output.exists(): + logger.warning("Failed to generate thumbnail for history entry") entry.thumbnail_filename = "" + except Exception: + logger.warning("Failed to generate thumbnail for history entry") + entry.thumbnail_filename = "" add_history_entry(self.app.fastflix.data_path, entry, max_items=self.app.fastflix.config.history_max_items) diff --git a/fastflix/widgets/panels/data_panel.py b/fastflix/widgets/panels/data_panel.py index 364738e5..16878595 100644 --- a/fastflix/widgets/panels/data_panel.py +++ b/fastflix/widgets/panels/data_panel.py @@ -216,6 +216,7 @@ def _is_cover_attachment(self, stream): def new_source(self): self.tracks = [] video = self.app.fastflix.current_video + video.data_tracks = [] # Add data streams for stream in getattr(video.streams, "data", []): @@ -292,6 +293,13 @@ def new_source(self): super()._new_source(self.tracks) + def apply_profile_settings(self, profile): + """Apply profile data passthrough settings to data tracks.""" + if profile.data_passthrough is False: + self.select_all(False) + else: + self.select_all(True) + def get_settings(self): # Widget state is already written to data_tracks via set_outdex / update_enable pass diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index 724f7195..60c2d852 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -286,6 +286,7 @@ def new_source(self): self.subtitles.new_source() if getattr(self.main.current_encoder, "enable_data", False): self.data.new_source() + self.data.apply_profile_settings(profile) if getattr(self.main.current_encoder, "enable_attachments", False): self.attachments.new_source(self.app.fastflix.current_video.streams.attachment) self.current_settings.new_source() @@ -326,6 +327,8 @@ def update_profile(self): self.audio.update_audio_settings() if getattr(self.main.current_encoder, "enable_subtitles", False): self.subtitles.apply_profile_settings() + if getattr(self.main.current_encoder, "enable_data", False): + self.data.apply_profile_settings(profile) if getattr(self.main.current_encoder, "enable_attachments", False): self.attachments.update_cover_settings() self.advanced.update_settings() diff --git a/fastflix/widgets/windows/profile_window.py b/fastflix/widgets/windows/profile_window.py index b108f6ad..87e3a3df 100644 --- a/fastflix/widgets/windows/profile_window.py +++ b/fastflix/widgets/windows/profile_window.py @@ -352,6 +352,39 @@ def __init__(self, app, parent): self.setLayout(layout) +class DataSelect(QtWidgets.QWidget): + def __init__(self, app, parent): + super().__init__() + + self.app = app + self.parent = parent + + self.data_select_type = QtWidgets.QButtonGroup() + + self.passthrough_name = t("Passthrough All") + self.remove_all_name = t("Remove All") + + self.passthrough_checkbox = QtWidgets.QRadioButton(self.passthrough_name) + self.remove_all_checkbox = QtWidgets.QRadioButton(self.remove_all_name) + + self.data_select_type.addButton(self.passthrough_checkbox) + self.data_select_type.addButton(self.remove_all_checkbox) + + self.passthrough_checkbox.setChecked(True) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.passthrough_checkbox) + layout.addWidget(self.remove_all_checkbox) + layout.addStretch(1) + self.setLayout(layout) + + def get_settings(self): + """Return None for passthrough all, False for remove all.""" + if self.remove_all_checkbox.isChecked(): + return False + return None + + class AdvancedTab(QtWidgets.QTabWidget): def __init__(self, advanced_settings): super().__init__() @@ -494,6 +527,7 @@ def __init__(self, app: FastFlixApp, main, container, *args, **kwargs): self.tab_area.setMinimumWidth(500) self.audio_select = AudioSelect(self.app, self, self.main) self.subtitle_select = SubtitleSelect(self.app, self) + self.data_select = DataSelect(self.app, self) self.advanced_tab = AdvancedTab(self.advanced_options) self.primary_tab = PrimaryOptions(self.main_settings) self.encoder_tab = EncoderOptions(self.app, self) @@ -501,6 +535,7 @@ def __init__(self, app: FastFlixApp, main, container, *args, **kwargs): self.tab_area.addTab(self.encoder_tab, t("Video")) self.tab_area.addTab(self.audio_select, t("Audio")) self.tab_area.addTab(self.subtitle_select, t("Subtitles")) + self.tab_area.addTab(self.data_select, t("Data")) self.tab_area.addTab(self.advanced_tab, t("Advanced Options")) # self.tab_area.addTab(self.subtitle_select, "Subtitles") # self.tab_area.addTab(SubtitleSelect(self.app, self, "Subtitle Select", "subtitles"), "Subtitle Select") @@ -586,6 +621,7 @@ def save(self): remove_metadata=self.main_settings.remove_metadata, remove_hdr=self.main_settings.remove_hdr, audio_filters=audio_settings, + data_passthrough=self.data_select.get_settings(), resolution_method=self.main_settings.resolution_method, resolution_custom=self.main_settings.resolution_custom, output_type=self.main.widgets.output_type_combo.currentText(), From e13115aac720f715035142f3f4c80c6c35f26a41 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 13 Mar 2026 17:17:52 -0500 Subject: [PATCH 07/12] Queue fixes, adding the history window code --- .claude/settings.local.json | 117 ++++++++ fastflix/data/languages.yaml | 75 ++++++ fastflix/ff_queue.py | 58 ++-- fastflix/models/history.py | 142 ++++++++++ fastflix/widgets/windows/history_window.py | 293 +++++++++++++++++++++ tests/test_history.py | 177 +++++++++++++ 6 files changed, 837 insertions(+), 25 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 fastflix/models/history.py create mode 100644 fastflix/widgets/windows/history_window.py create mode 100644 tests/test_history.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..88b7e75f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,117 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch(domain:doc.qt.io)", + "WebFetch(domain:forum.qt.io)", + "WebFetch(domain:github.com)", + "Bash(uv run ruff check:*)", + "Bash(uv run:*)", + "Bash(python:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git blame:*)", + "Bash(git ls-files:*)", + "Bash(git ls-tree:*)", + "Bash(git rev-parse:*)", + "Bash(git reflog:*)", + "Bash(git describe:*)", + "Bash(git shortlog:*)", + "Bash(git stash list:*)", + "Bash(git cat-file:*)", + "Bash(git remote -v:*)", + "Bash(git config --get:*)", + "Bash(git config --list:*)", + "Bash(gh pr list:*)", + "Bash(gh pr view:*)", + "Bash(gh pr status:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr diff:*)", + "Bash(gh issue list:*)", + "Bash(gh issue view:*)", + "Bash(gh issue status:*)", + "Bash(gh repo view:*)", + "Bash(gh repo list:*)", + "Bash(gh release list:*)", + "Bash(gh release view:*)", + "Bash(gh run list:*)", + "Bash(gh run view:*)", + "Bash(gh workflow list:*)", + "Bash(gh workflow view:*)", + "Bash(gh search:*)", + "Bash(gh status:*)", + "Bash(gh auth status:*)", + "Bash(gh label list:*)", + "Bash(gh gist list:*)", + "Bash(gh gist view:*)", + "Bash(gh cache list:*)", + "Bash(gh ruleset list:*)", + "Bash(gh ruleset view:*)", + "Bash(gh variable list:*)", + "Bash(gh secret list:*)", + "Bash(gh project list:*)", + "Bash(gh project view:*)", + "Bash(dir:*)", + "Bash(find:*)", + "Bash(findstr:*)", + "Bash(type:*)", + "Bash(where:*)", + "Bash(tree:*)", + "Bash(whoami:*)", + "Bash(hostname:*)", + "Bash(ver:*)", + "Bash(fc:*)", + "Bash(echo:*)", + "Bash(Get-Content:*)", + "Bash(Get-ChildItem:*)", + "Bash(Get-Item:*)", + "Bash(Get-ItemProperty:*)", + "Bash(Get-Location:*)", + "Bash(Get-Process:*)", + "Bash(Get-Command:*)", + "Bash(Get-Help:*)", + "Bash(Get-Date:*)", + "Bash(Get-Host:*)", + "Bash(Get-Module:*)", + "Bash(Get-Variable:*)", + "Bash(Get-Alias:*)", + "Bash(Get-Service:*)", + "Bash(Select-String:*)", + "Bash(Select-Object:*)", + "Bash(Where-Object:*)", + "Bash(Sort-Object:*)", + "Bash(Format-Table:*)", + "Bash(Format-List:*)", + "Bash(Measure-Object:*)", + "Bash(Compare-Object:*)", + "Bash(Test-Path:*)", + "Bash(Resolve-Path:*)", + "Bash(Split-Path:*)", + "Bash(Join-Path:*)", + "Bash(ls:*)", + "Bash(xargs:*)", + "WebFetch(domain:ffmpeg.org)", + "Bash(ffmpeg -h:*)", + "Bash(ffmpeg:*)", + "WebFetch(domain:gitlab.com)", + "WebFetch(domain:wiki.x266.mov)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(grep:*)", + "WebFetch(domain:docs.rs)", + "WebFetch(domain:gist.github.com)", + "WebFetch(domain:manpages.debian.org)", + "WebFetch(domain:api.github.com)", + "WebFetch(domain:codecalamity.com)", + "WebFetch(domain:www.mail-archive.com)", + "WebFetch(domain:trac.ffmpeg.org)", + "WebFetch(domain:dev.to)", + "WebFetch(domain:www.ffmpeg.org)", + "WebFetch(domain:www.phoronix.com)", + "Bash(copy:*)", + "Bash(ffprobe:*)", + "Bash(gh api:*)" + ] + } +} diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 90a5b0c7..4dc96cb8 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -14626,3 +14626,78 @@ Max items: ukr: Максимум елементів kor: 최대 항목 ron: Articole maxime +Clear Current Video: + eng: Clear Current Video + deu: Aktuelles Video löschen + fra: Effacer la vidéo actuelle + ita: Cancella il video corrente + spa: Borrar vídeo actual + jpn: クリア・カレント・ビデオ + rus: Очистить текущее видео + por: Limpar vídeo atual + swe: Rensa aktuell video + pol: Wyczyść bieżące wideo + chs: 清除当前视频 + ukr: Очистити поточне відео + kor: 현재 비디오 지우기 + ron: Ștergeți videoclipul curent +Data Passthrough: + eng: Data Passthrough + deu: Daten-Passthrough + fra: Transmission des données + ita: Passaggio di dati + spa: Paso de datos + jpn: データ・パススルー + rus: Пропуск данных + por: Passagem de dados + swe: Genomströmning av data + pol: Przekazywanie danych + chs: 数据直通 + ukr: Проходження даних + kor: 데이터 패스스루 + ron: Passthrough de date +Keep source format: + eng: Keep source format + deu: Quellformat beibehalten + fra: Conserver le format de la source + ita: Mantenere il formato della fonte + spa: Mantener el formato de origen + jpn: ソース形式を保持 + rus: Сохраните формат источника + por: Manter o formato de origem + swe: Behåll källans format + pol: Zachowaj format źródła + chs: 保留源格式 + ukr: Зберігати вихідний формат + kor: 소스 형식 유지 + ron: Păstrați formatul sursei +No crop, scale, nor any other filters will be applied.: + eng: No crop, scale, nor any other filters will be applied. + deu: Es werden weder Zuschneiden noch Skalieren noch andere Filter angewendet. + fra: Aucun recadrage, aucune mise à l'échelle, ni aucun autre filtre ne sera appliqué. + ita: Non verranno applicati ritagli, scale o altri filtri. + spa: No se aplicarán filtros de recorte, escala ni de ningún otro tipo. + jpn: クロップ、スケール、その他のフィルターは適用されない。 + rus: Никакие обрезки, масштабирование или другие фильтры применяться не будут. + por: Não serão aplicados filtros de corte, escala ou outros. + swe: Inga beskärnings-, skalnings- eller andra filter kommer att tillämpas. + pol: Nie zostaną zastosowane żadne filtry przycinania, skalowania ani inne. + chs: 不会应用裁剪、缩放或任何其他滤镜。 + ukr: Обрізання, масштабування та інші фільтри не застосовуються. + kor: 자르기, 스케일 또는 기타 필터가 적용되지 않습니다. + ron: Nu vor fi aplicate filtre de tăiere, de scalare sau orice alte filtre. +Rotation and flip will be set as display metadata for supported containers (MP4, MOV, MKV).: + eng: Rotation and flip will be set as display metadata for supported containers (MP4, MOV, MKV). + deu: Drehung und Spiegelung werden für unterstützte Container (MP4, MOV, MKV) als Anzeigemetadaten festgelegt. + fra: La rotation et l'inversion seront définies comme métadonnées d'affichage pour les conteneurs pris en charge (MP4, MOV, MKV). + ita: Rotazione e capovolgimento saranno impostati come metadati di visualizzazione per i contenitori supportati (MP4, MOV, MKV). + spa: La rotación y el giro se establecerán como metadatos de visualización para los contenedores compatibles (MP4, MOV, MKV). + jpn: 回転と反転は、サポートされているコンテナ(MP4、MOV、MKV)の表示メタデータとして設定されます。 + rus: Вращение и переворот будут заданы в качестве метаданных для отображения в поддерживаемых контейнерах (MP4, MOV, MKV). + por: A rotação e a inversão serão definidas como metadados de visualização para os contentores suportados (MP4, MOV, MKV). + swe: Rotation och flip kommer att ställas in som visningsmetadata för containrar som stöds (MP4, MOV, MKV). + pol: Obracanie i przerzucanie zostanie ustawione jako metadane wyświetlania dla obsługiwanych kontenerów (MP4, MOV, MKV). + chs: 旋转和翻转将被设置为受支持容器(MP4、MOV、MKV)的显示元数据。 + ukr: Обертання та перевертання буде встановлено як метадані відображення для підтримуваних контейнерів (MP4, MOV, MKV). + kor: 회전 및 뒤집기는 지원되는 컨테이너(MP4, MOV, MKV)의 표시 메타데이터로 설정됩니다. + ron: Rotirea și întoarcerea vor fi setate ca metadate de afișare pentru containerele acceptate (MP4, MOV, MKV). diff --git a/fastflix/ff_queue.py b/fastflix/ff_queue.py index 047c944f..848eb739 100644 --- a/fastflix/ff_queue.py +++ b/fastflix/ff_queue.py @@ -9,7 +9,6 @@ import uuid from contextlib import contextmanager from pathlib import Path -from queue import Queue, Empty from typing import Optional from box import Box, BoxError @@ -99,7 +98,8 @@ class AsyncQueueSaver: """ def __init__(self): - self._queue = Queue() + self._pending: dict[str, tuple] = {} # keyed by str(queue_file) + self._event = threading.Event() self._shutdown = False self._thread = None self._lock = threading.Lock() @@ -114,44 +114,51 @@ def start(self): def _worker(self): """Background worker that processes save requests.""" while not self._shutdown: - try: - request = self._queue.get(timeout=0.5) - except Empty: - continue + self._event.wait(timeout=0.5) + self._event.clear() - if request is None: # Shutdown signal - break + while True: + with self._lock: + if not self._pending: + break + # Pop one item (the latest save for that file supersedes any earlier ones) + key, request = self._pending.popitem() - queue_data, queue_file, config, expected_generation = request - try: - save_queue(queue_data, queue_file, config, expected_generation=expected_generation) - except Exception: - logger.exception("Async queue save failed") + queue_data, queue_file, config = request + try: + # No expected_generation — the latest queued save always has the most + # up-to-date data (deep-copied from the live conversion_list), so it + # is always authoritative and should never be skipped. + save_queue(queue_data, queue_file, config, expected_generation=None) + except Exception: + logger.exception("Async queue save failed") def save(self, queue: list, queue_file: Path, config: Optional["Config"] = None): """ Queue a save operation to be performed asynchronously. + Only the latest save per queue file is kept — if a previous save for the + same file is still pending, it is superseded by this one (the newest save + always has the complete, current state). + Args: queue: List of Video objects to save queue_file: Path to save the queue YAML file config: Optional Config object for work paths """ - # Capture the expected generation at the time of queueing - # This allows us to detect if another save completed between queueing and execution - expected_generation = get_current_generation(queue_file) - - # Make a deep copy of the queue data to avoid race conditions import copy try: queue_copy = copy.deepcopy(queue) except Exception: logger.warning("Could not deep copy queue for async save, falling back to sync save") - save_queue(queue, queue_file, config, expected_generation=expected_generation) + save_queue(queue, queue_file, config) return - self._queue.put((queue_copy, queue_file, config, expected_generation)) + key = str(queue_file) + with self._lock: + self._pending[key] = (queue_copy, queue_file, config) + self._event.set() def shutdown(self, timeout: float = 5.0): """ @@ -161,7 +168,7 @@ def shutdown(self, timeout: float = 5.0): timeout: Maximum time to wait for pending saves to complete """ self._shutdown = True - self._queue.put(None) # Signal worker to exit + self._event.set() # Wake worker so it sees the shutdown flag if self._thread and self._thread.is_alive(): self._thread.join(timeout=timeout) @@ -173,12 +180,13 @@ def wait_for_pending(self, timeout: float = 10.0): timeout: Maximum time to wait """ # Shutdown and restart the thread to ensure all pending saves complete - self._queue.put(None) # Flush marker + self._shutdown = True + self._event.set() if self._thread and self._thread.is_alive(): self._thread.join(timeout=timeout) - # Restart the thread for future saves - self._shutdown = False - self.start() + # Restart the thread for future saves + self._shutdown = False + self.start() # Global async saver instance diff --git a/fastflix/models/history.py b/fastflix/models/history.py new file mode 100644 index 00000000..6513770d --- /dev/null +++ b/fastflix/models/history.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import logging +import shutil +from pathlib import Path +from box import Box +from pydantic import BaseModel, Field + +logger = logging.getLogger("fastflix") + +DEFAULT_MAX_HISTORY = 50 + +HISTORY_MAX_OPTIONS = { + "10": 10, + "20": 20, + "50": 50, + "100": 100, + "Unlimited": -1, +} + + +class HistoryEntry(BaseModel): + uuid: str + source: str + output: str + encoder_name: str + encoder_settings_summary: str = "" + encoder_settings: dict = Field(default_factory=dict) + audio_summary: str = "" + subtitle_summary: str = "" + resolution: str = "" + duration: float = 0.0 + file_size: int = 0 + completed_at: str = "" + thumbnail_filename: str = "" + success: bool = True + encode_duration_secs: float = 0.0 + + +def get_history_file(data_path: Path) -> Path: + return data_path / "history.yaml" + + +def get_history_thumbnails_dir(data_path: Path) -> Path: + return data_path / "history_thumbnails" + + +def load_history(data_path: Path) -> list[HistoryEntry]: + history_file = get_history_file(data_path) + if not history_file.exists(): + return [] + try: + data = Box.from_yaml(filename=history_file) + except Exception: + logger.exception("Failed to load history file") + return [] + entries = [] + for item in data.get("entries", []): + try: + entries.append(HistoryEntry(**item)) + except Exception: + logger.warning(f"Skipping invalid history entry: {item}") + return entries + + +def save_history(data_path: Path, entries: list[HistoryEntry]): + history_file = get_history_file(data_path) + data = Box(entries=[e.model_dump() for e in entries]) + data.to_yaml(filename=history_file, default_flow_style=False) + + +def add_history_entry(data_path: Path, entry: HistoryEntry, max_items: int = DEFAULT_MAX_HISTORY): + entries = load_history(data_path) + entries.append(entry) + if max_items > 0 and len(entries) > max_items: + removed = entries[: len(entries) - max_items] + entries = entries[len(entries) - max_items :] + thumbs_dir = get_history_thumbnails_dir(data_path) + for old_entry in removed: + if old_entry.thumbnail_filename: + thumb_path = thumbs_dir / old_entry.thumbnail_filename + thumb_path.unlink(missing_ok=True) + save_history(data_path, entries) + + +def trim_history(data_path: Path, max_items: int): + """Trim history to max_items, removing oldest entries. Negative means unlimited.""" + if max_items <= 0: + return + entries = load_history(data_path) + if len(entries) <= max_items: + return + removed = entries[: len(entries) - max_items] + entries = entries[len(entries) - max_items :] + thumbs_dir = get_history_thumbnails_dir(data_path) + for old_entry in removed: + if old_entry.thumbnail_filename: + (thumbs_dir / old_entry.thumbnail_filename).unlink(missing_ok=True) + save_history(data_path, entries) + + +def delete_history_entry(data_path: Path, uuid: str): + entries = load_history(data_path) + thumbs_dir = get_history_thumbnails_dir(data_path) + entries_new = [] + for entry in entries: + if entry.uuid == uuid: + if entry.thumbnail_filename: + (thumbs_dir / entry.thumbnail_filename).unlink(missing_ok=True) + else: + entries_new.append(entry) + save_history(data_path, entries_new) + + +def clear_history(data_path: Path): + history_file = get_history_file(data_path) + history_file.unlink(missing_ok=True) + thumbs_dir = get_history_thumbnails_dir(data_path) + if thumbs_dir.exists(): + shutil.rmtree(thumbs_dir, ignore_errors=True) + + +def build_settings_summary(encoder_settings: dict) -> str: + """Extract key settings into a human-readable summary string.""" + parts = [] + # Quality settings + for key in ("crf", "qp", "cqp", "q", "qscale"): + if key in encoder_settings and encoder_settings[key] is not None: + parts.append(f"{key.upper()}={encoder_settings[key]}") + break + # Bitrate + if encoder_settings.get("bitrate"): + parts.append(f"Bitrate={encoder_settings['bitrate']}") + # Preset/speed + for key in ("preset", "speed"): + if key in encoder_settings and encoder_settings[key] not in (None, "default", ""): + parts.append(f"{key.capitalize()}={encoder_settings[key]}") + break + # Profile + if encoder_settings.get("profile") and encoder_settings["profile"] not in ("default", "auto", "Auto"): + parts.append(f"Profile={encoder_settings['profile']}") + return ", ".join(parts) if parts else "Default settings" diff --git a/fastflix/widgets/windows/history_window.py b/fastflix/widgets/windows/history_window.py new file mode 100644 index 00000000..4c3dda5a --- /dev/null +++ b/fastflix/widgets/windows/history_window.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +import logging +from pathlib import Path + +from PySide6 import QtCore, QtGui, QtWidgets + +from fastflix.language import t +from fastflix.models.history import ( + HISTORY_MAX_OPTIONS, + HistoryEntry, + load_history, + clear_history, + delete_history_entry, + get_history_thumbnails_dir, + trim_history, +) +from fastflix.shared import yes_no_message + +logger = logging.getLogger("fastflix") + + +class ElidedLabel(QtWidgets.QLabel): + """A QLabel that elides text with an ellipsis when it doesn't fit.""" + + def __init__(self, text="", parent=None): + super().__init__(text, parent) + self._full_text = text + self.setMinimumWidth(0) + self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + + def setText(self, text): + self._full_text = text + super().setText(text) + self.update() + + def resizeEvent(self, event): + self._update_elided_text() + super().resizeEvent(event) + + def _update_elided_text(self): + metrics = QtGui.QFontMetrics(self.font()) + elided = metrics.elidedText(self._full_text, QtCore.Qt.ElideRight, self.width()) + super().setText(elided) + + +def _format_encode_duration(seconds: float) -> str: + """Format encode duration into a human-readable string.""" + if seconds <= 0: + return "" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + if hours: + return f"{hours}h {minutes}m {secs}s" + if minutes: + return f"{minutes}m {secs}s" + return f"{secs}s" + + +class HistoryItemWidget(QtWidgets.QFrame): + apply_settings_signal = QtCore.Signal(HistoryEntry) + delete_signal = QtCore.Signal(str) + + def __init__(self, entry: HistoryEntry, thumbnails_dir: Path, parent=None): + super().__init__(parent) + self.entry = entry + self.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.setStyleSheet("QFrame { margin: 2px; padding: 4px; }") + self.setSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Preferred) + + main_layout = QtWidgets.QVBoxLayout() + main_layout.setContentsMargins(4, 4, 4, 4) + + # Collapsed row + collapsed_layout = QtWidgets.QHBoxLayout() + + # Thumbnail + thumb_label = QtWidgets.QLabel() + thumb_label.setFixedSize(120, 68) + thumb_label.setAlignment(QtCore.Qt.AlignCenter) + thumb_label.setStyleSheet("background-color: #222;") + thumb_path = thumbnails_dir / entry.thumbnail_filename if entry.thumbnail_filename else None + if thumb_path and thumb_path.exists(): + pixmap = QtGui.QPixmap(str(thumb_path)) + if not pixmap.isNull(): + pixmap = pixmap.scaled(120, 68, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + thumb_label.setPixmap(pixmap) + else: + thumb_label.setText(t("No Preview")) + thumb_label.setAlignment(QtCore.Qt.AlignCenter) + thumb_label.setStyleSheet("background-color: #333; color: #888;") + collapsed_layout.addWidget(thumb_label) + + # Info section + info_layout = QtWidgets.QVBoxLayout() + source_name = Path(entry.source).name + output_name = Path(entry.output).name + + source_label = ElidedLabel(source_name) + bold_font = source_label.font() + bold_font.setBold(True) + source_label.setFont(bold_font) + source_label.setToolTip(entry.source) + info_layout.addWidget(source_label) + + output_label = ElidedLabel(f"\u2192 {output_name}") + output_label.setToolTip(entry.output) + output_label.setStyleSheet("color: #aaa;") + info_layout.addWidget(output_label) + + summary_text = f"{entry.encoder_name}" + if entry.encoder_settings_summary: + summary_text += f" \u2014 {entry.encoder_settings_summary}" + summary_label = ElidedLabel(summary_text) + summary_label.setToolTip(summary_text) + info_layout.addWidget(summary_label) + + # Status + date + encode time line + meta_parts = [] + if entry.success: + meta_parts.append("Success") + else: + meta_parts.append("Failed") + if entry.resolution: + meta_parts.append(entry.resolution) + encode_dur = _format_encode_duration(entry.encode_duration_secs) + if encode_dur: + meta_parts.append(f"Encode time: {encode_dur}") + date_text = entry.completed_at[:19].replace("T", " ") if entry.completed_at else "" + if date_text: + meta_parts.append(date_text) + meta_label = ElidedLabel(" | ".join(meta_parts)) + status_color = "#4CAF50" if entry.success else "#F44336" + meta_label.setStyleSheet(f"color: {status_color}; font-size: 11px;") + info_layout.addWidget(meta_label) + + collapsed_layout.addLayout(info_layout, stretch=1) + + # Buttons + button_layout = QtWidgets.QVBoxLayout() + apply_button = QtWidgets.QPushButton(t("Apply Settings")) + apply_button.setFixedWidth(110) + apply_button.clicked.connect(lambda: self.apply_settings_signal.emit(self.entry)) + button_layout.addWidget(apply_button) + + self.details_button = QtWidgets.QPushButton(t("Details")) + self.details_button.setFixedWidth(110) + self.details_button.setCheckable(True) + self.details_button.clicked.connect(self._toggle_details) + button_layout.addWidget(self.details_button) + + delete_button = QtWidgets.QPushButton(t("Delete")) + delete_button.setFixedWidth(110) + delete_button.setStyleSheet("QPushButton { color: #F44336; }") + delete_button.clicked.connect(lambda: self.delete_signal.emit(self.entry.uuid)) + button_layout.addWidget(delete_button) + button_layout.addStretch() + + collapsed_layout.addLayout(button_layout) + main_layout.addLayout(collapsed_layout) + + # Details section (hidden by default) + self.details_frame = QtWidgets.QFrame() + self.details_frame.setVisible(False) + details_layout = QtWidgets.QVBoxLayout() + details_layout.setContentsMargins(8, 4, 8, 4) + + # Encoder settings — one per line for readability + if entry.encoder_settings: + details_layout.addWidget(QtWidgets.QLabel(f"{t('Encoder Settings')}:")) + for k, v in entry.encoder_settings.items(): + if v is not None and k != "name": + setting_label = QtWidgets.QLabel(f" {k}: {v}") + setting_label.setStyleSheet("font-family: monospace; color: #ccc;") + details_layout.addWidget(setting_label) + + if entry.audio_summary: + details_layout.addWidget(QtWidgets.QLabel(f"{t('Audio')}: {entry.audio_summary}")) + if entry.subtitle_summary: + details_layout.addWidget(QtWidgets.QLabel(f"{t('Subtitles')}: {entry.subtitle_summary}")) + if entry.duration: + minutes = int(entry.duration // 60) + seconds = int(entry.duration % 60) + details_layout.addWidget(QtWidgets.QLabel(f"{t('Duration')}: {minutes}m {seconds}s")) + if entry.file_size: + size_mb = entry.file_size / (1024 * 1024) + if size_mb >= 1024: + details_layout.addWidget(QtWidgets.QLabel(f"{t('File Size')}: {size_mb / 1024:.2f} GB")) + else: + details_layout.addWidget(QtWidgets.QLabel(f"{t('File Size')}: {size_mb:.1f} MB")) + + self.details_frame.setLayout(details_layout) + main_layout.addWidget(self.details_frame) + + self.setLayout(main_layout) + + def _toggle_details(self): + self.details_frame.setVisible(self.details_button.isChecked()) + + +class HistoryWindow(QtWidgets.QWidget): + apply_settings_requested = QtCore.Signal(HistoryEntry) + + def __init__(self, app, parent=None): + super().__init__(None) + self.app = app + self.setWindowTitle(t("Encoding History")) + self.setMinimumSize(1000, 500) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window) + + layout = QtWidgets.QVBoxLayout() + + # Header + header_layout = QtWidgets.QHBoxLayout() + title = QtWidgets.QLabel(f"

{t('Encoding History')}

") + header_layout.addWidget(title) + header_layout.addStretch() + + header_layout.addWidget(QtWidgets.QLabel(t("Max items") + ":")) + self.max_items_combo = QtWidgets.QComboBox() + self.max_items_combo.addItems(list(HISTORY_MAX_OPTIONS.keys())) + # Set current from config + current_max = self.app.fastflix.config.history_max_items + for label, value in HISTORY_MAX_OPTIONS.items(): + if value == current_max: + self.max_items_combo.setCurrentText(label) + break + self.max_items_combo.currentTextChanged.connect(self._max_items_changed) + header_layout.addWidget(self.max_items_combo) + + clear_button = QtWidgets.QPushButton(t("Clear History")) + clear_button.clicked.connect(self._clear_history) + header_layout.addWidget(clear_button) + layout.addLayout(header_layout) + + # Scroll area with history items + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.scroll_widget = QtWidgets.QWidget() + self.scroll_layout = QtWidgets.QVBoxLayout() + self.scroll_layout.setAlignment(QtCore.Qt.AlignTop) + self.scroll_widget.setLayout(self.scroll_layout) + scroll.setWidget(self.scroll_widget) + layout.addWidget(scroll) + + self.setLayout(layout) + self._load_entries() + + def _load_entries(self): + # Clear existing items + while self.scroll_layout.count(): + item = self.scroll_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + entries = load_history(self.app.fastflix.data_path) + thumbnails_dir = get_history_thumbnails_dir(self.app.fastflix.data_path) + + if not entries: + empty_label = QtWidgets.QLabel(t("No encoding history yet.")) + empty_label.setAlignment(QtCore.Qt.AlignCenter) + empty_label.setStyleSheet("color: #888; font-size: 14px; padding: 40px;") + self.scroll_layout.addWidget(empty_label) + return + + # Most recent first + for entry in reversed(entries): + widget = HistoryItemWidget(entry, thumbnails_dir) + widget.apply_settings_signal.connect(self.apply_settings_requested.emit) + widget.delete_signal.connect(self._delete_entry) + self.scroll_layout.addWidget(widget) + + def _max_items_changed(self, text: str): + new_max = HISTORY_MAX_OPTIONS.get(text, 50) + self.app.fastflix.config.history_max_items = new_max + self.app.fastflix.config.save() + if new_max > 0: + trim_history(self.app.fastflix.data_path, new_max) + self._load_entries() + + def _delete_entry(self, uuid: str): + delete_history_entry(self.app.fastflix.data_path, uuid) + self._load_entries() + + def _clear_history(self): + if yes_no_message( + t("Are you sure you want to delete all encoding history?"), + title=t("Clear History"), + ): + clear_history(self.app.fastflix.data_path) + self._load_entries() diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 00000000..f792db9d --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import pytest + +from fastflix.models.history import ( + HistoryEntry, + add_history_entry, + build_settings_summary, + clear_history, + get_history_file, + get_history_thumbnails_dir, + load_history, + save_history, + trim_history, +) + + +@pytest.fixture +def tmp_data_path(tmp_path): + return tmp_path + + +def _make_entry(**overrides): + defaults = { + "uuid": "test-uuid-1234", + "source": "/videos/input.mkv", + "output": "/videos/output.mkv", + "encoder_name": "HEVC (x265)", + "encoder_settings_summary": "CRF=22, Preset=medium", + "encoder_settings": {"crf": 22, "preset": "medium", "name": "HEVC (x265)"}, + "audio_summary": "English (aac)", + "subtitle_summary": "", + "resolution": "1920x1080", + "duration": 120.5, + "file_size": 50_000_000, + "completed_at": "2026-03-11T10:00:00", + "thumbnail_filename": "test-uuid-1234.jpg", + } + defaults.update(overrides) + return HistoryEntry(**defaults) + + +def test_history_entry_creation(): + entry = _make_entry() + assert entry.uuid == "test-uuid-1234" + assert entry.encoder_name == "HEVC (x265)" + assert entry.file_size == 50_000_000 + + +def test_save_and_load_history(tmp_data_path): + entries = [_make_entry(uuid="a"), _make_entry(uuid="b")] + save_history(tmp_data_path, entries) + + loaded = load_history(tmp_data_path) + assert len(loaded) == 2 + assert loaded[0].uuid == "a" + assert loaded[1].uuid == "b" + + +def test_add_history_entry(tmp_data_path): + add_history_entry(tmp_data_path, _make_entry(uuid="first")) + add_history_entry(tmp_data_path, _make_entry(uuid="second")) + + loaded = load_history(tmp_data_path) + assert len(loaded) == 2 + assert loaded[0].uuid == "first" + assert loaded[1].uuid == "second" + + +def test_load_history_empty(tmp_data_path): + loaded = load_history(tmp_data_path) + assert loaded == [] + + +def test_clear_history(tmp_data_path): + add_history_entry(tmp_data_path, _make_entry()) + + # Create thumbnails dir with a file + thumbs_dir = get_history_thumbnails_dir(tmp_data_path) + thumbs_dir.mkdir(parents=True, exist_ok=True) + (thumbs_dir / "test.jpg").write_text("fake") + + clear_history(tmp_data_path) + + assert not get_history_file(tmp_data_path).exists() + assert not thumbs_dir.exists() + + +def test_history_trimming(tmp_data_path): + """Test that history is trimmed to max_items.""" + max_items = 20 + for i in range(max_items + 5): + add_history_entry(tmp_data_path, _make_entry(uuid=f"entry-{i}"), max_items=max_items) + + loaded = load_history(tmp_data_path) + assert len(loaded) == max_items + assert loaded[0].uuid == "entry-5" + assert loaded[-1].uuid == f"entry-{max_items + 4}" + + +def test_history_unlimited(tmp_data_path): + """Test that max_items=-1 means unlimited.""" + for i in range(60): + add_history_entry(tmp_data_path, _make_entry(uuid=f"entry-{i}"), max_items=-1) + + loaded = load_history(tmp_data_path) + assert len(loaded) == 60 + + +def test_build_settings_summary_crf(): + result = build_settings_summary({"crf": 22, "preset": "slow", "profile": "main"}) + assert "CRF=22" in result + assert "Preset=slow" in result + assert "Profile=main" in result + + +def test_build_settings_summary_bitrate(): + result = build_settings_summary({"bitrate": "5000k", "preset": "medium"}) + assert "Bitrate=5000k" in result + assert "Preset=medium" in result + + +def test_build_settings_summary_qp(): + result = build_settings_summary({"qp": 26, "speed": "4"}) + assert "QP=26" in result + assert "Speed=4" in result + + +def test_build_settings_summary_default(): + result = build_settings_summary({"name": "Copy"}) + assert result == "Default settings" + + +def test_build_settings_summary_skips_defaults(): + result = build_settings_summary({"preset": "default", "profile": "auto"}) + assert "Profile" not in result + + +def test_history_entry_success_default(): + entry = _make_entry() + assert entry.success is True + assert entry.encode_duration_secs == 0.0 + + +def test_history_entry_failed(): + entry = _make_entry(success=False, encode_duration_secs=123.4) + assert entry.success is False + assert entry.encode_duration_secs == 123.4 + + +def test_save_and_load_preserves_new_fields(tmp_data_path): + entry = _make_entry(success=False, encode_duration_secs=300.5) + add_history_entry(tmp_data_path, entry) + + loaded = load_history(tmp_data_path) + assert len(loaded) == 1 + assert loaded[0].success is False + assert loaded[0].encode_duration_secs == 300.5 + + +def test_trim_history(tmp_data_path): + for i in range(30): + add_history_entry(tmp_data_path, _make_entry(uuid=f"e-{i}"), max_items=-1) + assert len(load_history(tmp_data_path)) == 30 + + trim_history(tmp_data_path, 10) + loaded = load_history(tmp_data_path) + assert len(loaded) == 10 + assert loaded[0].uuid == "e-20" + + +def test_trim_history_unlimited_noop(tmp_data_path): + for i in range(15): + add_history_entry(tmp_data_path, _make_entry(uuid=f"e-{i}"), max_items=-1) + + trim_history(tmp_data_path, -1) + assert len(load_history(tmp_data_path)) == 15 From 163437779c513c085042aed5a78421f78aaf6535 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 13 Mar 2026 20:42:36 -0500 Subject: [PATCH 08/12] * Fixing page_update() busy-wait that could deadlock the GUI thread when called reentrantly * Fixing shutdown-while-encoding bug where the worker would lose the shutdown intent after the current encode finished, requiring a forceful GUI kill instead of graceful shutdown --- CHANGES | 2 + fastflix/command_runner.py | 4 +- fastflix/conversion_worker.py | 18 +- fastflix/data/languages.yaml | 15 + fastflix/models/video.py | 4 +- fastflix/widgets/main.py | 989 +------------------------ fastflix/widgets/main_encoding.py | 317 ++++++++ fastflix/widgets/main_post_encode.py | 229 ++++++ fastflix/widgets/main_video_load.py | 477 ++++++++++++ fastflix/widgets/panels/queue_panel.py | 7 - 10 files changed, 1064 insertions(+), 998 deletions(-) create mode 100644 fastflix/widgets/main_encoding.py create mode 100644 fastflix/widgets/main_post_encode.py create mode 100644 fastflix/widgets/main_video_load.py diff --git a/CHANGES b/CHANGES index 8b639d91..ed17d330 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,8 @@ * Changing Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers * Changing non-copy encoder rotation handling to use FFmpeg's built-in auto-rotation instead of manual display_rotation overrides, which also properly handles source flips from the display matrix * Changing -fps_mode to be used instead of deprecated -vsync for frame rate control +* Fixing page_update() busy-wait that could deadlock the GUI thread when called reentrantly +* Fixing shutdown-while-encoding bug where the worker would lose the shutdown intent after the current encode finished, requiring a forceful GUI kill instead of graceful shutdown * Fixing #725 encoder detection to check `ffmpeg -encoders` output in addition to compilation flags, so encoders like VAAPI are shown even when the build flag is absent (thanks to Davius and Generator) * Fixing #728 rigaya encoders (NVEncC, QSVEncC, VCEEncC) now pass --dolby-vision-rpu-prm crop=true when Dolby Vision RPU copy is enabled and a crop is applied (thanks to izzy697) * Fixing #730 6.1.1 arm no subtitles tab with VideoToolBox (Apple M1 and above) HEVC & H264 (thanks to enaveso) diff --git a/fastflix/command_runner.py b/fastflix/command_runner.py index 98a63c39..35a9768d 100644 --- a/fastflix/command_runner.py +++ b/fastflix/command_runner.py @@ -133,9 +133,9 @@ def change_priority( def _safe_log_put(self, msg): """Put message to log queue with timeout to prevent blocking if GUI is dead.""" try: - self.log_queue.put(msg, timeout=1.0) + self.log_queue.put(msg, timeout=0.1) except Full: - pass # GUI likely dead, ignore + pass # GUI likely dead or log queue full, skip def read_output(self): with ( diff --git a/fastflix/conversion_worker.py b/fastflix/conversion_worker.py index 44f042a1..64209c97 100644 --- a/fastflix/conversion_worker.py +++ b/fastflix/conversion_worker.py @@ -54,6 +54,8 @@ def start_command(): ) runner.change_priority(priority) + shutdown_requested = False + while True: if currently_encoding and not runner.is_alive(): reusables.remove_file_handlers(logger) @@ -74,12 +76,12 @@ def start_command(): logger.info(t("Error detected while converting")) status_queue.put(("error", video_uuid, command_uuid)) - if gui_died: + if gui_died or shutdown_requested: return continue status_queue.put(("complete", video_uuid, command_uuid)) - if gui_died: + if gui_died or shutdown_requested: return if not gui_died and not gui_proc.is_alive(): @@ -102,7 +104,7 @@ def start_command(): _, video_uuid, command_uuid, command, work_dir, log_name, shell = request start_command() - if request[0] == "cancel": + elif request[0] == "cancel": logger.debug(t("Cancel has been requested, killing encoding")) runner.kill() currently_encoding = False @@ -112,30 +114,30 @@ def start_command(): except Full: pass # GUI likely dead, ignore - if request[0] == "pause encode": + elif request[0] == "pause encode": logger.debug(t("Command worker received request to pause current encode")) try: runner.pause() except Exception: logger.exception("Could not pause command") - if request[0] == "resume encode": + elif request[0] == "resume encode": logger.debug(t("Command worker received request to resume paused encode")) try: runner.resume() except Exception: logger.exception("Could not resume command") - if request[0] == "priority": + elif request[0] == "priority": priority = request[1] if runner.is_alive(): runner.change_priority(priority) - if request[0] == "shutdown": + elif request[0] == "shutdown": logger.debug(t("Shutdown signal received from GUI")) if runner.is_alive(): logger.info(t("Waiting for current encode to finish before shutdown")) - # Don't kill current encode, let it finish + shutdown_requested = True continue logger.debug(t("Worker shutting down gracefully")) return diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 4dc96cb8..1845302b 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -14701,3 +14701,18 @@ Rotation and flip will be set as display metadata for supported containers (MP4, ukr: Обертання та перевертання буде встановлено як метадані відображення для підтримуваних контейнерів (MP4, MOV, MKV). kor: 회전 및 뒤집기는 지원되는 컨테이너(MP4, MOV, MKV)의 표시 메타데이터로 설정됩니다. ron: Rotirea și întoarcerea vor fi setate ca metadate de afișare pentru containerele acceptate (MP4, MOV, MKV). +Are you sure you want to delete all encoding history?: + eng: Are you sure you want to delete all encoding history? + deu: Sind Sie sicher, dass Sie den gesamten Kodierungsverlauf löschen wollen? + fra: Êtes-vous sûr de vouloir supprimer tout l'historique d'encodage ? + ita: Siete sicuri di voler cancellare tutta la cronologia delle codifiche? + spa: ¿Estás seguro de que quieres borrar todo el historial de codificación? + jpn: エンコード履歴をすべて削除してもよろしいですか? + rus: Вы уверены, что хотите удалить всю историю кодирования? + por: Tem a certeza de que pretende apagar todo o histórico de codificação? + swe: Är du säker på att du vill radera all kodningshistorik? + pol: Czy na pewno chcesz usunąć całą historię kodowania? + chs: 您确定要删除所有编码历史记录吗? + ukr: Ви впевнені, що хочете видалити всю історію кодування? + kor: 모든 인코딩 기록을 삭제하시겠습니까? + ron: Sunteți sigur că doriți să ștergeți tot istoricul de codare? diff --git a/fastflix/models/video.py b/fastflix/models/video.py index 24a33647..a3a21f5b 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -182,7 +182,6 @@ def saturation_to_str(cls, value): class Status(BaseModel): - success: bool = False error: bool = False complete: bool = False running: bool = False @@ -193,10 +192,9 @@ class Status(BaseModel): @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 + return 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 diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 64107676..a1a42dae 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import datetime import logging import math import os @@ -8,11 +7,9 @@ import secrets import shutil import time -from collections import namedtuple from datetime import timedelta from pathlib import Path -from queue import Empty -from typing import Tuple, Union, Optional +from typing import Tuple, Union import importlib.resources import reusables @@ -21,22 +18,18 @@ from PySide6 import QtCore, QtGui, QtWidgets from fastflix.encoders.common import helpers -from fastflix.exceptions import FastFlixInternalException, FlixError +from fastflix.exceptions import FastFlixInternalException from fastflix.ui_scale import scaler from fastflix.ui_constants import WIDTHS, HEIGHTS, ICONS from fastflix.ui_styles import ONYX_COLORS, get_onyx_combobox_style, get_onyx_button_style from fastflix.flix import ( - detect_hdr10_plus, - detect_interlaced, generate_thumbnail_command, get_auto_crop, - parse, - parse_hdr_details, get_concat_item, ) from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp -from fastflix.models.video import Status, Video, VideoSettings, Crop +from fastflix.models.video import Video, VideoSettings, Crop from fastflix.resources import ( get_icon, group_box_style, @@ -46,16 +39,17 @@ ) from fastflix.shared import ( error_message, - message, time_to_number, yes_no_message, clean_file_string, get_filesafe_datetime, shrink_text_to_fit, ) -from fastflix.windows_tools import prevent_sleep_mode, allow_sleep_mode -from fastflix.widgets.background_tasks import ThumbnailCreator, ExtractCovers -from fastflix.widgets.status_bar import Task, STATE_ENCODING, STATE_ERROR, STATE_COMPLETE, STATE_IDLE +from fastflix.widgets.background_tasks import ThumbnailCreator +from fastflix.widgets.main_encoding import EncodingMixin, Notifier +from fastflix.widgets.main_post_encode import PostEncodeMixin +from fastflix.widgets.main_video_load import VideoLoadMixin +from fastflix.widgets.status_bar import Task, STATE_ERROR, STATE_IDLE from fastflix.widgets.video_options import VideoOptions from fastflix.widgets.windows.crop_window import CropPreviewWindow @@ -65,13 +59,6 @@ only_int = QtGui.QIntValidator() -Request = namedtuple( - "Request", - ["request", "video_uuid", "command_uuid", "command", "work_dir", "log_name", "shell"], - defaults=[None, None, None, None, None, False], -) - -Response = namedtuple("Response", ["status", "video_uuid", "command_uuid"]) resolutions = { t("Auto"): {"method": "auto"}, @@ -162,7 +149,7 @@ def items(self): yield key, getattr(self, key) -class Main(QtWidgets.QWidget): +class Main(VideoLoadMixin, EncodingMixin, PostEncodeMixin, QtWidgets.QWidget): completed = QtCore.Signal(int) thumbnail_complete = QtCore.Signal(int) close_event = QtCore.Signal() @@ -1618,89 +1605,6 @@ def modify_int(self, widget, method="add", time_field=False): widget.setText(str(new_value) if not time_field else self.number_to_time(new_value)) self.build_commands() - @reusables.log_exception("fastflix", show_traceback=False) - def open_file(self): - filename = QtWidgets.QFileDialog.getOpenFileName( - self, - caption="Open Video", - filter="Video Files (*.mkv *.mp4 *.m4v *.mov *.avi *.divx *.webm *.mpg *.mp2 *.mpeg *.mpe *.mpv *.ogg *.m4p" - " *.wmv *.mov *.qt *.flv *.hevc *.gif *.webp *.vob *.ogv *.ts *.mts *.m2ts *.yuv *.rm *.svi *.3gp *.3g2" - " *.y4m *.avs *.vpy);;" - "Concatenation Text File (*.txt *.concat);; All Files (*)", - dir=str( - self.app.fastflix.config.source_directory - or (self.app.fastflix.current_video.source.parent if self.app.fastflix.current_video else Path.home()) - ), - ) - if not filename or not filename[0]: - return - - if self.app.fastflix.current_video: - discard = yes_no_message( - f"{t('There is already a video being processed')}
{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])) - if not self.input_video.exists(): - logger.error(f"Could not find the input file, does it exist at: {self.input_video}") - return - self.source_video_path_widget.setText(str(self.input_video)) - self.video_path_widget.setText(str(self.input_video)) - try: - self.update_video_info() - except Exception: - logger.exception(f"Could not load video {self.input_video}") - self.video_path_widget.setText("") - self.output_video_path_widget.setText("") - self.output_video_path_widget.setDisabled(True) - self.widgets.output_directory.setText("") - self.output_path_button.setDisabled(True) - self.filename_truncation_warning.hide() - self.page_update() - - def open_many(self, paths: list): - if self.app.fastflix.current_video: - discard = yes_no_message( - f"{t('There is already a video being processed')}
{t('Are you sure you want to discard it?')}", - title="Discard current video", - ) - if not discard: - return - - def open_em(signal, stop_signal, paths, **_): - stop = False - - def stop_me(): - nonlocal stop - stop = True - - stop_signal.connect(stop_me) - - total_items = len(paths) - for i, path in enumerate(paths): - if stop: - return - self.input_video = path - self.source_video_path_widget.setText(str(self.input_video)) - self.video_path_widget.setText(str(self.input_video)) - try: - self.update_video_info(hide_progress=True) - except Exception: - logger.exception(f"Could not load video {self.input_video}") - else: - self.page_update(build_thumbnail=False) - self.add_to_queue() - signal.emit(int((i / total_items) * 100)) - - self.disable_all() - self.container.status_bar.run_tasks( - [Task(t("Loading Videos"), open_em, {"paths": paths})], signal_task=True, can_cancel=True - ) - self.enable_all() - @property def generate_output_filename(self): from fastflix.naming import resolve_pre_encode_variables, truncate_filename @@ -1933,326 +1837,6 @@ def enable_all(self): self.clear_source_button.setEnabled(True) self.update_resolution() - def clear_current_video(self): - if self._cover_extract_thread and self._cover_extract_thread.isRunning(): - self._cover_extract_thread.wait(2000) - self._cover_extract_thread = None - self.widgets.queue_button.setEnabled(True) - self.widgets.queue_button.setToolTip("") - self.widgets.convert_button.setEnabled(True) - self.widgets.convert_button.setToolTip("") - - self.loading_video = True - self.app.fastflix.current_video = None - self.input_video = None - self.source_video_path_widget.setText("") - self.video_path_widget.setText(t("No Source Selected")) - self.output_video_path_widget.setText("") - self.widgets.output_directory.setText("") - self.output_path_button.setDisabled(True) - self.output_video_path_widget.setDisabled(True) - self.filename_truncation_warning.hide() - for i in range(self.widgets.video_track.count()): - self.widgets.video_track.removeItem(0) - self.widgets.preview.setText(t("No Video File")) - - # self.widgets.deinterlace.setChecked(False) - # self.widgets.remove_hdr.setChecked(False) - # self.widgets.remove_metadata.setChecked(True) - # self.widgets.chapters.setChecked(True) - - self.widgets.flip.setCurrentIndex(0) - self.widgets.rotate.setCurrentIndex(0) - # self.widgets.video_title.setText("") - - 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(0)) - self.widgets.end_time.setText(self.number_to_time(0)) - # self.widgets.scale.width.setText("0") - # self.widgets.scale.height.setText("Auto") - self.widgets.preview.setPixmap(QtGui.QPixmap()) - self.video_options.clear_tracks() - self.video_bit_depth_label.hide() - self.video_chroma_label.hide() - self.video_hdr10_label.hide() - self.video_hdr10plus_label.hide() - self.disable_all() - self.loading_video = False - - @reusables.log_exception("fastflix", show_traceback=True) - def reload_video_from_queue(self, video: Video): - if video.video_settings.video_encoder_settings.name not in self.app.fastflix.encoders: - error_message( - t("That video was added with an encoder that is no longer available, unable to load from queue") - ) - raise FastFlixInternalException( - t("That video was added with an encoder that is no longer available, unable to load from queue") - ) - - self.loading_video = True - - self.app.fastflix.current_video = video - self.app.fastflix.current_video.work_path.mkdir(parents=True, exist_ok=True) - self.input_video = video.source - self.source_video_path_widget.setText(str(self.input_video)) - hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams] - text_video_tracks = [ - ( - 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) - # Show video track selector only when there's more than one video track - if len(self.app.fastflix.current_video.streams.video) > 1: - self.widgets.video_track_widget.show() - else: - self.widgets.video_track_widget.hide() - for i, track in enumerate(text_video_tracks): - if int(track.split(":")[0]) == self.app.fastflix.current_video.video_settings.selected_track: - self.widgets.video_track.setCurrentIndex(i) - break - else: - logger.warning( - f"Could not find selected track {self.app.fastflix.current_video.video_settings.selected_track} " - f"in {text_video_tracks}" - ) - - end_time = self.app.fastflix.current_video.video_settings.end_time or video.duration - if self.app.fastflix.current_video.video_settings.crop: - 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: - 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) - - fn = Path(video.video_settings.output_path) - self.widgets.output_directory.setText(str(fn.parent.absolute()).rstrip("/").rstrip("\\")) - self.output_video_path_widget.setText(fn.stem) - self.widgets.output_type_combo.setCurrentText(fn.suffix) - - self.widgets.deinterlace.setChecked(self.app.fastflix.current_video.video_settings.deinterlace) - 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(video.video_settings.rotate) - self.widgets.fast_time.setCurrentIndex(0 if video.video_settings.fast_seek else 1) - if video.video_settings.vertical_flip and video.video_settings.horizontal_flip: - self.widgets.flip.setCurrentIndex(3) - elif video.video_settings.vertical_flip: - self.widgets.flip.setCurrentIndex(1) - elif video.video_settings.horizontal_flip: - self.widgets.flip.setCurrentIndex(2) - - self.video_options.advanced.video_title.setText(video.video_settings.video_title) - self.video_options.advanced.video_track_title.setText(video.video_settings.video_track_title) - - self.video_options.reload() - self.enable_all() - - self._start_cover_extraction() - - self.app.fastflix.current_video.status = Status() - self.update_video_info_labels() - self.loading_video = False - self.page_update(build_thumbnail=True, force_build_thumbnail=True) - - @reusables.log_exception("fastflix", show_traceback=False) - def update_video_info(self, hide_progress=False): - self.loading_video = True - folder, name = self.generate_output_filename - self.output_video_path_widget.setText(name) - self.widgets.output_directory.setText(folder.rstrip("/").rstrip("\\")) - self._update_truncation_warning() - self.output_video_path_widget.setDisabled(False) - self.output_path_button.setDisabled(False) - self.app.fastflix.current_video = Video(source=self.input_video, work_path=self.get_temp_work_path()) - self.app.fastflix.current_video.video_settings.template_generated_name = name - tasks = [ - Task(t("Parse Video details"), parse), - Task(t("Determine HDR details"), parse_hdr_details), - Task(t("Detect HDR10+"), detect_hdr10_plus), - ] - if not self.app.fastflix.config.disable_deinterlace_check: - tasks.append(Task(t("Detecting Interlace"), detect_interlaced, dict(source=self.source_material))) - - try: - self.container.status_bar.run_tasks(tasks) - except FlixError: - 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}: {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.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("0:00:00") - - self.widgets.video_track.addItems(text_video_tracks) - - # Show video track selector only when there's more than one video track - if len(self.app.fastflix.current_video.streams.video) > 1: - self.widgets.video_track_widget.show() - else: - self.widgets.video_track_widget.hide() - - logger.debug(f"{len(self.app.fastflix.current_video.streams['video'])} {t('video tracks found')}") - logger.debug(f"{len(self.app.fastflix.current_video.streams['audio'])} {t('audio tracks found')}") - - if self.app.fastflix.current_video.streams["subtitle"]: - logger.debug(f"{len(self.app.fastflix.current_video.streams['subtitle'])} {t('subtitle tracks found')}") - if self.app.fastflix.current_video.streams["attachment"]: - logger.debug(f"{len(self.app.fastflix.current_video.streams['attachment'])} {t('attachment tracks found')}") - if self.app.fastflix.current_video.streams["data"]: - logger.debug(f"{len(self.app.fastflix.current_video.streams['data'])} {t('data tracks found')}") - - self.widgets.end_time.setText(self.number_to_time(self.app.fastflix.current_video.duration)) - title_name = [ - v for k, v in self.app.fastflix.current_video.format.get("tags", {}).items() if k.lower() == "title" - ] - if title_name: - self.video_options.advanced.video_title.setText(title_name[0]) - else: - self.video_options.advanced.video_title.setText("") - - video_track_title_name = [ - v - for k, v in self.app.fastflix.current_video.streams.video[0].get("tags", {}).items() - if k.upper() == "TITLE" - ] - - if video_track_title_name: - self.video_options.advanced.video_track_title.setText(video_track_title_name[0]) - else: - self.video_options.advanced.video_track_title.setText("") - - self.widgets.deinterlace.setChecked(self.app.fastflix.current_video.video_settings.deinterlace) - - logger.info("Updating video info") - self.video_options.new_source() - self.enable_all() - # self.widgets.convert_button.setDisabled(False) - # self.widgets.convert_button.setStyleSheet("background-color:green;") - - self._start_cover_extraction() - - self.loading_video = False - self.update_resolution_labels() - self.update_video_info_labels() - - # Set preview slider steps: ~1 per 10 seconds, minimum 100 - slider_steps = max(100, int(self.app.fastflix.current_video.duration / 10)) - self.widgets.thumb_time.setMaximum(slider_steps) - self.widgets.thumb_time.setPageStep(max(1, slider_steps // 20)) - self.widgets.thumb_time.setValue(max(1, slider_steps // 4)) - - if self.app.fastflix.config.opt("auto_crop"): - self.get_auto_crop() - - encoder = self.current_encoder - if encoder and not getattr(encoder, "enable_concat", False) and self.app.fastflix.current_video.concat: - error_message(f"{encoder.name} {t('does not support concatenating files together')}") - - @staticmethod - def _chroma_from_pix_fmt(pix_fmt: str) -> str: - if not pix_fmt: - return "" - fmt = pix_fmt.lower() - if "444" in fmt: - return "4:4:4" - if "422" in fmt: - return "4:2:2" - if "420" in fmt or fmt in ("nv12", "nv12m", "nv21", "p010le"): - return "4:2:0" - if "411" in fmt: - return "4:1:1" - if "410" in fmt: - return "4:1:0" - if "440" in fmt: - return "4:4:0" - return "" - - def update_video_info_labels(self): - if not self.app.fastflix.current_video: - self.video_info_label.hide() - self.video_codec_label.hide() - self.video_bit_depth_label.hide() - self.video_chroma_label.hide() - self.video_hdr10_label.hide() - self.video_hdr10plus_label.hide() - return - - track_index = self.widgets.video_track.currentIndex() - if track_index < 0: - return - stream = self.app.fastflix.current_video.streams.video[track_index] - stream_idx = stream.index - - codec = stream.get("codec_name", "") - if codec: - self.video_codec_label.setText(codec.upper()) - self.video_codec_label.show() - else: - self.video_codec_label.hide() - - bit_depth = stream.get("bit_depth", "8") - self.video_bit_depth_label.setText(f"{bit_depth}-bit") - self.video_bit_depth_label.show() - self.video_info_label.show() - - chroma = self._chroma_from_pix_fmt(stream.get("pix_fmt", "")) - if chroma: - self.video_chroma_label.setText(chroma) - self.video_chroma_label.show() - else: - self.video_chroma_label.hide() - - hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams] - if stream_idx in hdr10_indexes: - self.video_hdr10_label.setText("\u2714 HDR10") - self.video_hdr10_label.setStyleSheet("color: #00cc00;") - self.video_hdr10_label.show() - else: - self.video_hdr10_label.hide() - - if self.app.fastflix.config.hdr10plus_parser and stream_idx in self.app.fastflix.current_video.hdr10_plus: - self.video_hdr10plus_label.setText("\u2714 HDR10+") - self.video_hdr10plus_label.setStyleSheet("color: #00cc00;") - self.video_hdr10plus_label.show() - else: - self.video_hdr10plus_label.hide() - @property def video_track(self) -> int: return self.widgets.video_track.currentIndex() @@ -2486,8 +2070,8 @@ def video_track_update(self): self.page_update(build_thumbnail=True) def page_update(self, build_thumbnail=True, force_build_thumbnail=False): - while self.page_updating: - time.sleep(0.1) + if self.page_updating: + return self.page_updating = True try: if not self.initialized or self.loading_video or not self.app.fastflix.current_video: @@ -2551,186 +2135,9 @@ def convert_to(self): return list(self.app.fastflix.encoders.keys())[0] return None - def encoding_checks(self): - if not self.input_video: - error_message(t("Have to select a video first")) - return False - if not self.output_video: - error_message(t("Please specify output video")) - return False - try: - if self.input_video.resolve().absolute() == Path(self.output_video).resolve().absolute(): - error_message(t("Output video path is same as source!")) - return False - except OSError: - # file system may not support resolving - pass - - out_file_path = Path(self.output_video) - if out_file_path.exists() and out_file_path.stat().st_size > 0: - sm = QtWidgets.QMessageBox() - sm.setText("That output file already exists and is not empty!") - sm.addButton("Cancel", QtWidgets.QMessageBox.DestructiveRole) - sm.addButton("Overwrite", QtWidgets.QMessageBox.RejectRole) - sm.exec() - if sm.clickedButton().text() == "Cancel": - return False - return True - - def _start_cover_extraction(self): - """Start background cover extraction thread.""" - if self.app.fastflix.config.disable_cover_extraction: - return - if not self.app.fastflix.current_video: - return - has_covers = any( - track.get("tags", {}).get("filename", "").rsplit(".", 1)[0] - in ("cover", "small_cover", "cover_land", "small_cover_land") - for track in self.app.fastflix.current_video.streams.attachment - ) - if not has_covers: - return - - self.widgets.queue_button.setDisabled(True) - self.widgets.queue_button.setToolTip(t("Extracting cover images...")) - self.widgets.convert_button.setDisabled(True) - self.widgets.convert_button.setToolTip(t("Extracting cover images...")) - - self.video_options.attachments.set_extracting(True) - - self._cover_extract_video_source = self.app.fastflix.current_video.source - self._cover_extract_thread = ExtractCovers(app=self.app, main=self, signal=self.cover_extraction_complete) - self._cover_extract_thread.start() - - def on_cover_extraction_complete(self): - """Called when background cover extraction finishes.""" - self._cover_extract_thread = None - - self.widgets.queue_button.setEnabled(True) - self.widgets.queue_button.setToolTip("") - self.widgets.convert_button.setEnabled(True) - self.widgets.convert_button.setToolTip("") - - if ( - self.app.fastflix.current_video - and self.app.fastflix.current_video.source == self._cover_extract_video_source - ): - self.video_options.attachments.covers_extracted() - self._cover_extract_video_source = None - - def set_convert_button(self): - if not self.app.fastflix.currently_encoding: - self.widgets.convert_button.setText(f"{t('Convert')} ") - self.widgets.convert_button.setIcon(QtGui.QIcon(self.get_icon("play-round"))) - self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) - - else: - self.widgets.convert_button.setText(f"{t('Cancel')} ") - self.widgets.convert_button.setIcon(QtGui.QIcon(self.get_icon("black-x"))) - self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) - def get_icon(self, name): return get_icon(name, self.app.fastflix.config.theme) - @reusables.log_exception("fastflix", show_traceback=True) - def encode_video(self): - if self.app.fastflix.currently_encoding: - sure = yes_no_message(t("Are you sure you want to stop the current encode?"), title="Confirm Stop Encode") - if not sure: - return - logger.info(t("Canceling current encode")) - self.app.fastflix.worker_queue.put(["cancel"]) - self.video_options.queue.reset_pause_encode() - return - - if self.app.fastflix.conversion_paused: - return error_message("Queue is currently paused") - - if self.app.fastflix.current_video: - add_current = True - if self.app.fastflix.conversion_list and self.app.fastflix.current_video: - add_current = yes_no_message("Add current video to queue?", yes_text="Yes", no_text="No") - if add_current: - if not self.add_to_queue(): - return - - for video in self.app.fastflix.conversion_list: - if video.status.ready: - video_to_send: Video = video - break - else: - error_message(t("There are no videos to start converting")) - return - - logger.debug(t("Starting conversion process")) - - self.app.fastflix.currently_encoding = True - prevent_sleep_mode() - self.set_convert_button() - self.send_video_request_to_worker_queue(video_to_send) - self.disable_all() - self.video_options.show_status() - video_name = video_to_send.video_settings.video_title or video_to_send.video_settings.output_path.stem - self.encoding_status_signal.emit(f"{t('Encoding')}: {video_name}", STATE_ENCODING) - self.encoding_progress_signal.emit(0) - - def add_to_queue(self): - try: - code = self.video_options.queue.add_to_queue() - except FastFlixInternalException as err: - error_message(str(err)) - return - else: - if code is not None: - return code - # No update_queue() needed - add_to_queue() already called new_source() - self.video_options.show_queue() - - # if self.converting: - # commands = self.get_commands() - # requests = ["add_items", str(self.app.fastflix.log_path), tuple(commands)] - # self.app.fastflix.worker_queue.put(tuple(requests)) - - self.clear_current_video() - return True - - # @reusables.log_exception("fastflix", show_traceback=False) - def conversion_complete(self, success: bool): - self.paused = False - allow_sleep_mode() - self.set_convert_button() - - if not success: - self.encoding_status_signal.emit(t("Encoding error"), STATE_ERROR) - if self.app.fastflix.config.show_error_message: - error_message(t("There was an error during conversion and the queue has stopped"), title=t("Error")) - self.video_options.queue.new_source() - else: - self.encoding_status_signal.emit(t("All conversions complete"), STATE_COMPLETE) - self.video_options.show_queue() - if self.app.fastflix.config.show_complete_message: - message(t("All queue items have completed"), title=t("Success")) - - # - # @reusables.log_exception("fastflix", show_traceback=False) - def conversion_cancelled(self, video: Video): - self.set_convert_button() - - exists = video.video_settings.output_path.exists() - - if exists: - sm = QtWidgets.QMessageBox() - sm.setWindowTitle(t("Cancelled")) - sm.setText(f"{t('Conversion cancelled, delete incomplete file')}\n{video.video_settings.output_path}?") - sm.addButton(t("Delete"), QtWidgets.QMessageBox.YesRole) - sm.addButton(t("Keep"), QtWidgets.QMessageBox.NoRole) - sm.exec() - if sm.clickedButton().text() == t("Delete"): - try: - video.video_settings.output_path.unlink(missing_ok=True) - except OSError: - pass - @reusables.log_exception("fastflix", show_traceback=True) def dropEvent(self, event): if not event.mimeData().hasUrls: @@ -2761,382 +2168,8 @@ def dropEvent(self, event): # releasing the Windows drag-drop COM lock (unfreezes Explorer). QtCore.QTimer.singleShot(0, self._load_dropped_video) - def _load_dropped_video(self): - self.source_video_path_widget.setText(str(self.input_video)) - self.video_path_widget.setText(str(self.input_video)) - try: - self.update_video_info() - except Exception: - logger.exception(f"Could not load video {self.input_video}") - self.video_path_widget.setText("") - self.output_video_path_widget.setText("") - self.output_video_path_widget.setDisabled(True) - self.widgets.output_directory.setText("") - self.output_path_button.setDisabled(True) - self.filename_truncation_warning.hide() - self.page_update() - def dragEnterEvent(self, event): event.accept() if event.mimeData().hasUrls else event.ignore() def dragMoveEvent(self, event): event.accept() if event.mimeData().hasUrls else event.ignore() - - def status_update(self, status_response): - response = Response(*status_response) - logger.debug(f"Updating queue from command worker: {response}") - - video_to_send: Optional[Video] = None - errored = False - same_video = False - - for video in self.app.fastflix.conversion_list: - if response.video_uuid == video.uuid: - video.status.running = False - - if response.status == "cancelled": - video.status.cancelled = True - self.encoding_status_signal.emit(t("Encoding cancelled"), STATE_IDLE) - self.encoding_progress_signal.emit(0) - self.end_encoding() - self.conversion_cancelled(video) - self.video_options.update_queue() - return - - if response.status == "complete": - video.status.current_command += 1 - if len(video.video_settings.conversion_commands) > video.status.current_command: - same_video = True - video_to_send = video - break - else: - video.status.complete = True - self._post_encode_process(video) - - if response.status == "error": - video.status.error = True - errored = True - if self.app.fastflix.config.enable_history: - try: - self._record_history(video, success=False) - except Exception: - logger.exception("Failed to record history entry for errored encode") - break - - if errored and not self.video_options.queue.ignore_errors.isChecked(): - self.end_encoding() - self.conversion_complete(success=False) - return - - if not video_to_send: - for video in self.app.fastflix.conversion_list: - if video.status.ready: - video_to_send = video - # TODO ensure command int is in command list? - break - - if not video_to_send: - self.end_encoding() - self.conversion_complete(success=True) - return - - self.app.fastflix.currently_encoding = True - if not same_video and self.app.fastflix.conversion_paused: - return self.end_encoding() - - self.send_video_request_to_worker_queue(video_to_send) - - def end_encoding(self): - self.app.fastflix.currently_encoding = False - allow_sleep_mode() - self.video_options.queue.run_after_done() - self.video_options.update_queue() - self.set_convert_button() - self.encoding_progress_signal.emit(0) - - def send_next_video(self) -> bool: - if not self.app.fastflix.currently_encoding: - for video in self.app.fastflix.conversion_list: - if video.status.ready: - video.status.running = True - self.send_video_request_to_worker_queue(video) - self.app.fastflix.currently_encoding = True - prevent_sleep_mode() - self.set_convert_button() - return True - self.app.fastflix.currently_encoding = False - allow_sleep_mode() - self.set_convert_button() - return False - - def _post_encode_process(self, video: Video): - """Run ffprobe validation and post-encode rename on completed video.""" - try: - from fastflix.naming import has_post_encode_placeholders - - output_path = video.video_settings.output_path - if not output_path or not output_path.exists(): - logger.warning(f"Post-encode: output file not found at {output_path}") - return - - # Always run ffprobe for validation - try: - from fastflix.flix import probe - - probe_data = probe(self.app, output_path) - except Exception: - logger.exception(f"Post-encode: ffprobe failed on {output_path}") - probe_data = None - - self._validate_output(output_path, probe_data) - - # Rename if post-encode placeholders exist in filename - if has_post_encode_placeholders(output_path.stem): - self._rename_with_post_encode_vars(video, probe_data) - - # Record to history if enabled - if self.app.fastflix.config.enable_history: - try: - self._record_history(video) - except Exception: - logger.exception("Failed to record history entry") - - except Exception: - logger.exception("Post-encode processing failed (encode itself succeeded)") - - def _record_history(self, video: Video, success: bool = True): - """Record a completed or failed encoding to history.""" - import uuid as uuid_mod - from datetime import datetime - - from fastflix.models.history import ( - HistoryEntry, - add_history_entry, - build_settings_summary, - get_history_thumbnails_dir, - ) - - output_path = video.video_settings.output_path - encoder_settings = video.video_settings.video_encoder_settings - - # Build audio summary - audio_parts = [] - for track in video.audio_tracks: - if track.enabled: - codec = track.conversion_codec if track.conversion_codec else track.codec - audio_parts.append(f"{track.language} ({codec})") - audio_summary = ", ".join(audio_parts) if audio_parts else "" - - # Build subtitle summary - sub_parts = [] - for track in video.subtitle_tracks: - if track.enabled: - sub_parts.append(f"{track.language} ({track.subtitle_type})") - subtitle_summary = ", ".join(sub_parts) if sub_parts else "" - - # Resolution - resolution = "" - if video.width and video.height: - resolution = f"{video.width}x{video.height}" - - # File size - file_size = 0 - try: - if output_path and output_path.exists(): - file_size = output_path.stat().st_size - except Exception: - pass - - # Duration - duration = 0.0 - if video.duration: - duration = video.duration - - # Encode duration - encode_duration_secs = 0.0 - if video.status.encode_started_at: - encode_duration_secs = ( - datetime.now(video.status.encode_started_at.tzinfo) - video.status.encode_started_at - ).total_seconds() - - entry_uuid = str(uuid_mod.uuid4()) - thumbnail_filename = f"{entry_uuid}.jpg" - - entry = HistoryEntry( - uuid=entry_uuid, - source=str(video.source), - output=str(output_path) if output_path else "", - encoder_name=encoder_settings.name, - encoder_settings=encoder_settings.model_dump(), - encoder_settings_summary=build_settings_summary(encoder_settings.model_dump()), - audio_summary=audio_summary, - subtitle_summary=subtitle_summary, - resolution=resolution, - duration=duration, - file_size=file_size, - completed_at=datetime.now().isoformat(), - thumbnail_filename=thumbnail_filename, - success=success, - encode_duration_secs=encode_duration_secs, - ) - - # Generate thumbnail directly from source video - thumbs_dir = get_history_thumbnails_dir(self.app.fastflix.data_path) - thumbs_dir.mkdir(parents=True, exist_ok=True) - thumb_output = thumbs_dir / thumbnail_filename - try: - from subprocess import PIPE, STDOUT - from subprocess import run as subprocess_run - - from fastflix.flix import generate_thumbnail_command - - thumb_command = generate_thumbnail_command( - config=self.app.fastflix.config, - source=video.source, - output=thumb_output, - filters=["-vf", "scale='min(440\\,iw):-8'"], - start_time=video.video_settings.start_time or 0, - input_track=video.video_settings.selected_track, - ) - result = subprocess_run(thumb_command, stdin=PIPE, stdout=PIPE, stderr=STDOUT) - if result.returncode != 0 or not thumb_output.exists(): - logger.warning("Failed to generate thumbnail for history entry") - entry.thumbnail_filename = "" - except Exception: - logger.warning("Failed to generate thumbnail for history entry") - entry.thumbnail_filename = "" - - add_history_entry(self.app.fastflix.data_path, entry, max_items=self.app.fastflix.config.history_max_items) - - def _validate_output(self, output_path: Path, probe_data): - """Quick sanity check on the output file.""" - if not output_path.exists(): - logger.warning(f"Output validation: file does not exist: {output_path}") - return - - file_size = output_path.stat().st_size - if file_size < 1024: - logger.warning(f"Output validation: file is suspiciously small ({file_size} bytes): {output_path}") - - if not probe_data: - logger.warning(f"Output validation: no probe data available for {output_path}") - return - - # Check for video stream - has_video = False - if hasattr(probe_data, "streams"): - for stream in probe_data.streams: - if stream.get("codec_type") == "video": - has_video = True - break - if not has_video: - logger.warning(f"Output validation: no video stream found in {output_path}") - - # Check duration - if hasattr(probe_data, "format") and probe_data.format: - duration = probe_data.format.get("duration") - if duration: - try: - if float(duration) <= 0: - logger.warning(f"Output validation: duration is 0 or negative for {output_path}") - except (ValueError, TypeError): - pass - - def _rename_with_post_encode_vars(self, video: Video, probe_data): - """Resolve post-encode placeholders and rename the output file.""" - from fastflix.naming import resolve_post_encode_variables - - output_path = video.video_settings.output_path - encode_end = datetime.datetime.now(datetime.timezone.utc) - encode_start = video.status.encode_started_at - - old_stem = output_path.stem - new_stem = resolve_post_encode_variables( - old_stem, - output_path, - probe_data, - encode_start=encode_start, - encode_end=encode_end, - ) - - if new_stem == old_stem: - return - - new_path = output_path.with_stem(new_stem) - - # Handle collision - if new_path.exists(): - rand_suffix = secrets.token_hex(2) - new_path = output_path.with_stem(f"{new_stem}-{rand_suffix}") - - try: - output_path.rename(new_path) - video.video_settings.output_path = new_path - logger.info(f"Post-encode rename: {output_path.name} -> {new_path.name}") - except OSError: - logger.exception(f"Post-encode rename failed: {output_path} -> {new_path}") - - def send_video_request_to_worker_queue(self, video: Video): - command = video.video_settings.conversion_commands[video.status.current_command] - self.app.fastflix.currently_encoding = True - prevent_sleep_mode() - if video.status.current_command == 0: - video.status.encode_started_at = datetime.datetime.now(datetime.timezone.utc) - - # logger.info(f"Sending video {video.uuid} command {command.uuid} called from {inspect.stack()}") - - self.app.fastflix.worker_queue.put( - Request( - request="execute", - video_uuid=video.uuid, - command_uuid=command.uuid, - command=command.command, - work_dir=str(video.work_path), - log_name=video.video_settings.video_title or video.video_settings.output_path.stem, - shell=command.shell, - ) - ) - video.status.running = True - self.video_options.update_queue() - video_name = video.video_settings.video_title or video.video_settings.output_path.stem - self.encoding_status_signal.emit(f"{t('Encoding')}: {video_name}", STATE_ENCODING) - - def find_video(self, uuid) -> Video: - for video in self.app.fastflix.conversion_list: - if uuid == video.uuid: - return video - raise FlixError(f"{t('No video found for')} {uuid}") - - def find_command(self, video: Video, uuid) -> int: - for i, command in enumerate(video.video_settings.conversion_commands, start=1): - if uuid == command.uuid: - return i - raise FlixError(f"{t('No command found for')} {uuid}") - - -class Notifier(QtCore.QThread): - def __init__(self, parent, app, status_queue): - super().__init__(parent) - self.app = app - self.main: Main = parent - self.status_queue = status_queue - self._shutdown = False - - def request_shutdown(self): - """Request graceful shutdown of the thread.""" - self._shutdown = True - - def run(self): - while not self._shutdown: - # Message looks like (command, video_uuid, command_uuid) - try: - status = self.status_queue.get(timeout=0.5) - except Empty: - continue - self.app.processEvents() - if status[0] == "exit": - logger.debug("GUI received ask to exit") - self.main.close_event.emit() - return - self.main.status_update_signal.emit(status) - self.app.processEvents() diff --git a/fastflix/widgets/main_encoding.py b/fastflix/widgets/main_encoding.py new file mode 100644 index 00000000..c6f8f73e --- /dev/null +++ b/fastflix/widgets/main_encoding.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import annotations + +import datetime +import logging +from collections import namedtuple +from pathlib import Path +from queue import Empty +from typing import TYPE_CHECKING, Optional + +import reusables +from PySide6 import QtCore, QtGui, QtWidgets + +from fastflix.exceptions import FastFlixInternalException, FlixError +from fastflix.language import t +from fastflix.models.video import Video +from fastflix.resources import get_icon +from fastflix.shared import error_message, message, yes_no_message +from fastflix.ui_constants import ICONS +from fastflix.ui_scale import scaler +from fastflix.widgets.status_bar import STATE_COMPLETE, STATE_ENCODING, STATE_ERROR, STATE_IDLE +from fastflix.windows_tools import allow_sleep_mode, prevent_sleep_mode + +if TYPE_CHECKING: + from fastflix.widgets.main import Main + +logger = logging.getLogger("fastflix") + +Request = namedtuple( + "Request", + ["request", "video_uuid", "command_uuid", "command", "work_dir", "log_name", "shell"], + defaults=[None, None, None, None, None, False], +) + +Response = namedtuple("Response", ["status", "video_uuid", "command_uuid"]) + + +class Notifier(QtCore.QThread): + def __init__(self, parent, app, status_queue): + super().__init__(parent) + self.app = app + self.main: Main = parent + self.status_queue = status_queue + self._shutdown = False + + def request_shutdown(self): + """Request graceful shutdown of the thread.""" + self._shutdown = True + + def run(self): + while not self._shutdown: + # Message looks like (command, video_uuid, command_uuid) + try: + status = self.status_queue.get(timeout=0.5) + except Empty: + continue + self.app.processEvents() + if status[0] == "exit": + logger.debug("GUI received ask to exit") + self.main.close_event.emit() + return + self.main.status_update_signal.emit(status) + self.app.processEvents() + + +class EncodingMixin: + """Mixin for Main: encoding orchestration, queue dispatch, and worker communication.""" + + self: Main + + def encoding_checks(self): + if not self.input_video: + error_message(t("Have to select a video first")) + return False + if not self.output_video: + error_message(t("Please specify output video")) + return False + try: + if self.input_video.resolve().absolute() == Path(self.output_video).resolve().absolute(): + error_message(t("Output video path is same as source!")) + return False + except OSError: + # file system may not support resolving + pass + + out_file_path = Path(self.output_video) + if out_file_path.exists() and out_file_path.stat().st_size > 0: + sm = QtWidgets.QMessageBox() + sm.setText("That output file already exists and is not empty!") + sm.addButton("Cancel", QtWidgets.QMessageBox.DestructiveRole) + sm.addButton("Overwrite", QtWidgets.QMessageBox.RejectRole) + sm.exec() + if sm.clickedButton().text() == "Cancel": + return False + return True + + def set_convert_button(self): + if not self.app.fastflix.currently_encoding: + self.widgets.convert_button.setText(f"{t('Convert')} ") + self.widgets.convert_button.setIcon(QtGui.QIcon(get_icon("play-round", self.app.fastflix.config.theme))) + self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) + else: + self.widgets.convert_button.setText(f"{t('Cancel')} ") + self.widgets.convert_button.setIcon(QtGui.QIcon(get_icon("black-x", self.app.fastflix.config.theme))) + self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) + + @reusables.log_exception("fastflix", show_traceback=True) + def encode_video(self): + if self.app.fastflix.currently_encoding: + sure = yes_no_message(t("Are you sure you want to stop the current encode?"), title="Confirm Stop Encode") + if not sure: + return + logger.info(t("Canceling current encode")) + self.app.fastflix.worker_queue.put(["cancel"]) + self.video_options.queue.reset_pause_encode() + return + + if self.app.fastflix.conversion_paused: + return error_message("Queue is currently paused") + + if self.app.fastflix.current_video: + add_current = True + if self.app.fastflix.conversion_list and self.app.fastflix.current_video: + add_current = yes_no_message("Add current video to queue?", yes_text="Yes", no_text="No") + if add_current: + if not self.add_to_queue(): + return + + for video in self.app.fastflix.conversion_list: + if video.status.ready: + video_to_send: Video = video + break + else: + error_message(t("There are no videos to start converting")) + return + + logger.debug(t("Starting conversion process")) + + self.app.fastflix.currently_encoding = True + prevent_sleep_mode() + self.set_convert_button() + self.send_video_request_to_worker_queue(video_to_send) + self.disable_all() + self.video_options.show_status() + video_name = video_to_send.video_settings.video_title or video_to_send.video_settings.output_path.stem + self.encoding_status_signal.emit(f"{t('Encoding')}: {video_name}", STATE_ENCODING) + self.encoding_progress_signal.emit(0) + + def add_to_queue(self): + try: + code = self.video_options.queue.add_to_queue() + except FastFlixInternalException as err: + error_message(str(err)) + return + else: + if code is not None: + return code + self.video_options.show_queue() + + self.clear_current_video() + return True + + def conversion_complete(self, success: bool): + self.paused = False + allow_sleep_mode() + self.set_convert_button() + + if not success: + self.encoding_status_signal.emit(t("Encoding error"), STATE_ERROR) + if self.app.fastflix.config.show_error_message: + error_message(t("There was an error during conversion and the queue has stopped"), title=t("Error")) + self.video_options.queue.new_source() + else: + self.encoding_status_signal.emit(t("All conversions complete"), STATE_COMPLETE) + self.video_options.show_queue() + if self.app.fastflix.config.show_complete_message: + message(t("All queue items have completed"), title=t("Success")) + + def conversion_cancelled(self, video: Video): + self.set_convert_button() + + exists = video.video_settings.output_path.exists() + + if exists: + sm = QtWidgets.QMessageBox() + sm.setWindowTitle(t("Cancelled")) + sm.setText(f"{t('Conversion cancelled, delete incomplete file')}\n{video.video_settings.output_path}?") + sm.addButton(t("Delete"), QtWidgets.QMessageBox.YesRole) + sm.addButton(t("Keep"), QtWidgets.QMessageBox.NoRole) + sm.exec() + if sm.clickedButton().text() == t("Delete"): + try: + video.video_settings.output_path.unlink(missing_ok=True) + except OSError: + pass + + def status_update(self, status_response): + response = Response(*status_response) + logger.debug(f"Updating queue from command worker: {response}") + + video_to_send: Optional[Video] = None + errored = False + same_video = False + + for video in self.app.fastflix.conversion_list: + if response.video_uuid == video.uuid: + video.status.running = False + + if response.status == "cancelled": + video.status.cancelled = True + self.encoding_status_signal.emit(t("Encoding cancelled"), STATE_IDLE) + self.encoding_progress_signal.emit(0) + self.end_encoding() + self.conversion_cancelled(video) + self.video_options.update_queue() + return + + if response.status == "complete": + video.status.current_command += 1 + if len(video.video_settings.conversion_commands) > video.status.current_command: + same_video = True + video_to_send = video + break + else: + video.status.complete = True + self._post_encode_process(video) + + if response.status == "error": + video.status.error = True + errored = True + if self.app.fastflix.config.enable_history: + try: + self._record_history(video, success=False) + except Exception: + logger.exception("Failed to record history entry for errored encode") + break + + if errored and not self.video_options.queue.ignore_errors.isChecked(): + self.end_encoding() + self.conversion_complete(success=False) + return + + if not video_to_send: + for video in self.app.fastflix.conversion_list: + if video.status.ready: + video_to_send = video + break + + if not video_to_send: + self.end_encoding() + self.conversion_complete(success=True) + return + + self.app.fastflix.currently_encoding = True + if not same_video and self.app.fastflix.conversion_paused: + return self.end_encoding() + + self.send_video_request_to_worker_queue(video_to_send) + + def end_encoding(self): + self.app.fastflix.currently_encoding = False + allow_sleep_mode() + self.video_options.queue.run_after_done() + self.video_options.update_queue() + self.set_convert_button() + self.encoding_progress_signal.emit(0) + + def send_next_video(self) -> bool: + if not self.app.fastflix.currently_encoding: + for video in self.app.fastflix.conversion_list: + if video.status.ready: + video.status.running = True + self.send_video_request_to_worker_queue(video) + self.app.fastflix.currently_encoding = True + prevent_sleep_mode() + self.set_convert_button() + return True + self.app.fastflix.currently_encoding = False + allow_sleep_mode() + self.set_convert_button() + return False + + def send_video_request_to_worker_queue(self, video: Video): + command = video.video_settings.conversion_commands[video.status.current_command] + self.app.fastflix.currently_encoding = True + prevent_sleep_mode() + if video.status.current_command == 0: + video.status.encode_started_at = datetime.datetime.now(datetime.timezone.utc) + + self.app.fastflix.worker_queue.put( + Request( + request="execute", + video_uuid=video.uuid, + command_uuid=command.uuid, + command=command.command, + work_dir=str(video.work_path), + log_name=video.video_settings.video_title or video.video_settings.output_path.stem, + shell=command.shell, + ) + ) + video.status.running = True + self.video_options.update_queue() + video_name = video.video_settings.video_title or video.video_settings.output_path.stem + self.encoding_status_signal.emit(f"{t('Encoding')}: {video_name}", STATE_ENCODING) + + def find_video(self, uuid) -> Video: + for video in self.app.fastflix.conversion_list: + if uuid == video.uuid: + return video + raise FlixError(f"{t('No video found for')} {uuid}") + + def find_command(self, video: Video, uuid) -> int: + for i, command in enumerate(video.video_settings.conversion_commands, start=1): + if uuid == command.uuid: + return i + raise FlixError(f"{t('No command found for')} {uuid}") diff --git a/fastflix/widgets/main_post_encode.py b/fastflix/widgets/main_post_encode.py new file mode 100644 index 00000000..38ce1b2e --- /dev/null +++ b/fastflix/widgets/main_post_encode.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import annotations + +import datetime +import logging +import secrets +from pathlib import Path +from typing import TYPE_CHECKING + +from fastflix.models.video import Video + +if TYPE_CHECKING: + from fastflix.widgets.main import Main + +logger = logging.getLogger("fastflix") + + +class PostEncodeMixin: + """Mixin for Main: post-encode validation, history recording, and file renaming.""" + + self: Main + + def _post_encode_process(self, video: Video): + """Run ffprobe validation and post-encode rename on completed video.""" + try: + from fastflix.naming import has_post_encode_placeholders + + output_path = video.video_settings.output_path + if not output_path or not output_path.exists(): + logger.warning(f"Post-encode: output file not found at {output_path}") + return + + # Always run ffprobe for validation + try: + from fastflix.flix import probe + + probe_data = probe(self.app, output_path) + except Exception: + logger.exception(f"Post-encode: ffprobe failed on {output_path}") + probe_data = None + + self._validate_output(output_path, probe_data) + + # Rename if post-encode placeholders exist in filename + if has_post_encode_placeholders(output_path.stem): + self._rename_with_post_encode_vars(video, probe_data) + + # Record to history if enabled + if self.app.fastflix.config.enable_history: + try: + self._record_history(video) + except Exception: + logger.exception("Failed to record history entry") + + except Exception: + logger.exception("Post-encode processing failed (encode itself succeeded)") + + def _record_history(self, video: Video, success: bool = True): + """Record a completed or failed encoding to history.""" + import uuid as uuid_mod + from datetime import datetime as dt + + from fastflix.models.history import ( + HistoryEntry, + add_history_entry, + build_settings_summary, + get_history_thumbnails_dir, + ) + + output_path = video.video_settings.output_path + encoder_settings = video.video_settings.video_encoder_settings + + # Build audio summary + audio_parts = [] + for track in video.audio_tracks: + if track.enabled: + codec = track.conversion_codec if track.conversion_codec else track.codec + audio_parts.append(f"{track.language} ({codec})") + audio_summary = ", ".join(audio_parts) if audio_parts else "" + + # Build subtitle summary + sub_parts = [] + for track in video.subtitle_tracks: + if track.enabled: + sub_parts.append(f"{track.language} ({track.subtitle_type})") + subtitle_summary = ", ".join(sub_parts) if sub_parts else "" + + # Resolution + resolution = "" + if video.width and video.height: + resolution = f"{video.width}x{video.height}" + + # File size + file_size = 0 + try: + if output_path and output_path.exists(): + file_size = output_path.stat().st_size + except Exception: + pass + + # Duration + duration = 0.0 + if video.duration: + duration = video.duration + + # Encode duration + encode_duration_secs = 0.0 + if video.status.encode_started_at: + encode_duration_secs = ( + dt.now(video.status.encode_started_at.tzinfo) - video.status.encode_started_at + ).total_seconds() + + entry_uuid = str(uuid_mod.uuid4()) + thumbnail_filename = f"{entry_uuid}.jpg" + + entry = HistoryEntry( + uuid=entry_uuid, + source=str(video.source), + output=str(output_path) if output_path else "", + encoder_name=encoder_settings.name, + encoder_settings=encoder_settings.model_dump(), + encoder_settings_summary=build_settings_summary(encoder_settings.model_dump()), + audio_summary=audio_summary, + subtitle_summary=subtitle_summary, + resolution=resolution, + duration=duration, + file_size=file_size, + completed_at=dt.now().isoformat(), + thumbnail_filename=thumbnail_filename, + success=success, + encode_duration_secs=encode_duration_secs, + ) + + # Generate thumbnail directly from source video + thumbs_dir = get_history_thumbnails_dir(self.app.fastflix.data_path) + thumbs_dir.mkdir(parents=True, exist_ok=True) + thumb_output = thumbs_dir / thumbnail_filename + try: + from subprocess import PIPE, STDOUT + from subprocess import run as subprocess_run + + from fastflix.flix import generate_thumbnail_command + + thumb_command = generate_thumbnail_command( + config=self.app.fastflix.config, + source=video.source, + output=thumb_output, + filters=["-vf", "scale='min(440\\,iw):-8'"], + start_time=video.video_settings.start_time or 0, + input_track=video.video_settings.selected_track, + ) + result = subprocess_run(thumb_command, stdin=PIPE, stdout=PIPE, stderr=STDOUT) + if result.returncode != 0 or not thumb_output.exists(): + logger.warning("Failed to generate thumbnail for history entry") + entry.thumbnail_filename = "" + except Exception: + logger.warning("Failed to generate thumbnail for history entry") + entry.thumbnail_filename = "" + + add_history_entry(self.app.fastflix.data_path, entry, max_items=self.app.fastflix.config.history_max_items) + + def _validate_output(self, output_path: Path, probe_data): + """Quick sanity check on the output file.""" + if not output_path.exists(): + logger.warning(f"Output validation: file does not exist: {output_path}") + return + + file_size = output_path.stat().st_size + if file_size < 1024: + logger.warning(f"Output validation: file is suspiciously small ({file_size} bytes): {output_path}") + + if not probe_data: + logger.warning(f"Output validation: no probe data available for {output_path}") + return + + # Check for video stream + has_video = False + if hasattr(probe_data, "streams"): + for stream in probe_data.streams: + if stream.get("codec_type") == "video": + has_video = True + break + if not has_video: + logger.warning(f"Output validation: no video stream found in {output_path}") + + # Check duration + if hasattr(probe_data, "format") and probe_data.format: + duration = probe_data.format.get("duration") + if duration: + try: + if float(duration) <= 0: + logger.warning(f"Output validation: duration is 0 or negative for {output_path}") + except (ValueError, TypeError): + pass + + def _rename_with_post_encode_vars(self, video: Video, probe_data): + """Resolve post-encode placeholders and rename the output file.""" + from fastflix.naming import resolve_post_encode_variables + + output_path = video.video_settings.output_path + encode_end = datetime.datetime.now(datetime.timezone.utc) + encode_start = video.status.encode_started_at + + old_stem = output_path.stem + new_stem = resolve_post_encode_variables( + old_stem, + output_path, + probe_data, + encode_start=encode_start, + encode_end=encode_end, + ) + + if new_stem == old_stem: + return + + new_path = output_path.with_stem(new_stem) + + # Handle collision + if new_path.exists(): + rand_suffix = secrets.token_hex(2) + new_path = output_path.with_stem(f"{new_stem}-{rand_suffix}") + + try: + output_path.rename(new_path) + video.video_settings.output_path = new_path + logger.info(f"Post-encode rename: {output_path.name} -> {new_path.name}") + except OSError: + logger.exception(f"Post-encode rename failed: {output_path} -> {new_path}") diff --git a/fastflix/widgets/main_video_load.py b/fastflix/widgets/main_video_load.py new file mode 100644 index 00000000..0b0022af --- /dev/null +++ b/fastflix/widgets/main_video_load.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import reusables +from PySide6 import QtGui, QtWidgets + +from fastflix.exceptions import FastFlixInternalException, FlixError +from fastflix.flix import detect_hdr10_plus, detect_interlaced, parse, parse_hdr_details +from fastflix.language import t +from fastflix.models.video import Status, Video +from fastflix.shared import clean_file_string, error_message, yes_no_message +from fastflix.widgets.background_tasks import ExtractCovers +from fastflix.widgets.status_bar import Task + +if TYPE_CHECKING: + from fastflix.widgets.main import Main + +logger = logging.getLogger("fastflix") + + +class VideoLoadMixin: + """Mixin for Main: video loading, file operations, and video info display.""" + + self: Main + + @reusables.log_exception("fastflix", show_traceback=False) + def open_file(self): + filename = QtWidgets.QFileDialog.getOpenFileName( + self, + caption="Open Video", + filter="Video Files (*.mkv *.mp4 *.m4v *.mov *.avi *.divx *.webm *.mpg *.mp2 *.mpeg *.mpe *.mpv *.ogg *.m4p" + " *.wmv *.mov *.qt *.flv *.hevc *.gif *.webp *.vob *.ogv *.ts *.mts *.m2ts *.yuv *.rm *.svi *.3gp *.3g2" + " *.y4m *.avs *.vpy);;" + "Concatenation Text File (*.txt *.concat);; All Files (*)", + dir=str( + self.app.fastflix.config.source_directory + or (self.app.fastflix.current_video.source.parent if self.app.fastflix.current_video else Path.home()) + ), + ) + if not filename or not filename[0]: + return + + if self.app.fastflix.current_video: + discard = yes_no_message( + f"{t('There is already a video being processed')}
{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])) + if not self.input_video.exists(): + logger.error(f"Could not find the input file, does it exist at: {self.input_video}") + return + self.source_video_path_widget.setText(str(self.input_video)) + self.video_path_widget.setText(str(self.input_video)) + try: + self.update_video_info() + except Exception: + logger.exception(f"Could not load video {self.input_video}") + self.video_path_widget.setText("") + self.output_video_path_widget.setText("") + self.output_video_path_widget.setDisabled(True) + self.widgets.output_directory.setText("") + self.output_path_button.setDisabled(True) + self.filename_truncation_warning.hide() + self.page_update() + + def open_many(self, paths: list): + if self.app.fastflix.current_video: + discard = yes_no_message( + f"{t('There is already a video being processed')}
{t('Are you sure you want to discard it?')}", + title="Discard current video", + ) + if not discard: + return + + def open_em(signal, stop_signal, paths, **_): + stop = False + + def stop_me(): + nonlocal stop + stop = True + + stop_signal.connect(stop_me) + + total_items = len(paths) + for i, path in enumerate(paths): + if stop: + return + self.input_video = path + self.source_video_path_widget.setText(str(self.input_video)) + self.video_path_widget.setText(str(self.input_video)) + try: + self.update_video_info(hide_progress=True) + except Exception: + logger.exception(f"Could not load video {self.input_video}") + else: + self.page_update(build_thumbnail=False) + self.add_to_queue() + signal.emit(int((i / total_items) * 100)) + + self.disable_all() + self.container.status_bar.run_tasks( + [Task(t("Loading Videos"), open_em, {"paths": paths})], signal_task=True, can_cancel=True + ) + self.enable_all() + + def clear_current_video(self): + if self._cover_extract_thread and self._cover_extract_thread.isRunning(): + self._cover_extract_thread.wait(2000) + self._cover_extract_thread = None + self.widgets.queue_button.setEnabled(True) + self.widgets.queue_button.setToolTip("") + self.widgets.convert_button.setEnabled(True) + self.widgets.convert_button.setToolTip("") + + self.loading_video = True + self.app.fastflix.current_video = None + self.input_video = None + self.source_video_path_widget.setText("") + self.video_path_widget.setText(t("No Source Selected")) + self.output_video_path_widget.setText("") + self.widgets.output_directory.setText("") + self.output_path_button.setDisabled(True) + self.output_video_path_widget.setDisabled(True) + self.filename_truncation_warning.hide() + for i in range(self.widgets.video_track.count()): + self.widgets.video_track.removeItem(0) + self.widgets.preview.setText(t("No Video File")) + + self.widgets.flip.setCurrentIndex(0) + self.widgets.rotate.setCurrentIndex(0) + + 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(0)) + self.widgets.end_time.setText(self.number_to_time(0)) + self.widgets.preview.setPixmap(QtGui.QPixmap()) + self.video_options.clear_tracks() + self.video_bit_depth_label.hide() + self.video_chroma_label.hide() + self.video_hdr10_label.hide() + self.video_hdr10plus_label.hide() + self.disable_all() + self.loading_video = False + + @reusables.log_exception("fastflix", show_traceback=True) + def reload_video_from_queue(self, video: Video): + if video.video_settings.video_encoder_settings.name not in self.app.fastflix.encoders: + error_message( + t("That video was added with an encoder that is no longer available, unable to load from queue") + ) + raise FastFlixInternalException( + t("That video was added with an encoder that is no longer available, unable to load from queue") + ) + + self.loading_video = True + + self.app.fastflix.current_video = video + self.app.fastflix.current_video.work_path.mkdir(parents=True, exist_ok=True) + self.input_video = video.source + self.source_video_path_widget.setText(str(self.input_video)) + hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams] + text_video_tracks = [ + ( + 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) + # Show video track selector only when there's more than one video track + if len(self.app.fastflix.current_video.streams.video) > 1: + self.widgets.video_track_widget.show() + else: + self.widgets.video_track_widget.hide() + for i, track in enumerate(text_video_tracks): + if int(track.split(":")[0]) == self.app.fastflix.current_video.video_settings.selected_track: + self.widgets.video_track.setCurrentIndex(i) + break + else: + logger.warning( + f"Could not find selected track {self.app.fastflix.current_video.video_settings.selected_track} " + f"in {text_video_tracks}" + ) + + end_time = self.app.fastflix.current_video.video_settings.end_time or video.duration + if self.app.fastflix.current_video.video_settings.crop: + 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: + 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)) + + fn = Path(video.video_settings.output_path) + self.widgets.output_directory.setText(str(fn.parent.absolute()).rstrip("/").rstrip("\\")) + self.output_video_path_widget.setText(fn.stem) + self.widgets.output_type_combo.setCurrentText(fn.suffix) + + self.widgets.deinterlace.setChecked(self.app.fastflix.current_video.video_settings.deinterlace) + 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(video.video_settings.rotate) + self.widgets.fast_time.setCurrentIndex(0 if video.video_settings.fast_seek else 1) + if video.video_settings.vertical_flip and video.video_settings.horizontal_flip: + self.widgets.flip.setCurrentIndex(3) + elif video.video_settings.vertical_flip: + self.widgets.flip.setCurrentIndex(1) + elif video.video_settings.horizontal_flip: + self.widgets.flip.setCurrentIndex(2) + + self.video_options.advanced.video_title.setText(video.video_settings.video_title) + self.video_options.advanced.video_track_title.setText(video.video_settings.video_track_title) + + self.video_options.reload() + self.enable_all() + + self._start_cover_extraction() + + self.app.fastflix.current_video.status = Status() + self.update_video_info_labels() + self.loading_video = False + self.page_update(build_thumbnail=True, force_build_thumbnail=True) + + @reusables.log_exception("fastflix", show_traceback=False) + def update_video_info(self, hide_progress=False): + self.loading_video = True + folder, name = self.generate_output_filename + self.output_video_path_widget.setText(name) + self.widgets.output_directory.setText(folder.rstrip("/").rstrip("\\")) + self._update_truncation_warning() + self.output_video_path_widget.setDisabled(False) + self.output_path_button.setDisabled(False) + self.app.fastflix.current_video = Video(source=self.input_video, work_path=self.get_temp_work_path()) + self.app.fastflix.current_video.video_settings.template_generated_name = name + tasks = [ + Task(t("Parse Video details"), parse), + Task(t("Determine HDR details"), parse_hdr_details), + Task(t("Detect HDR10+"), detect_hdr10_plus), + ] + if not self.app.fastflix.config.disable_deinterlace_check: + tasks.append(Task(t("Detecting Interlace"), detect_interlaced, dict(source=self.source_material))) + + try: + self.container.status_bar.run_tasks(tasks) + except FlixError: + 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}: {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.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("0:00:00") + + self.widgets.video_track.addItems(text_video_tracks) + + # Show video track selector only when there's more than one video track + if len(self.app.fastflix.current_video.streams.video) > 1: + self.widgets.video_track_widget.show() + else: + self.widgets.video_track_widget.hide() + + logger.debug(f"{len(self.app.fastflix.current_video.streams['video'])} {t('video tracks found')}") + logger.debug(f"{len(self.app.fastflix.current_video.streams['audio'])} {t('audio tracks found')}") + + if self.app.fastflix.current_video.streams["subtitle"]: + logger.debug(f"{len(self.app.fastflix.current_video.streams['subtitle'])} {t('subtitle tracks found')}") + if self.app.fastflix.current_video.streams["attachment"]: + logger.debug(f"{len(self.app.fastflix.current_video.streams['attachment'])} {t('attachment tracks found')}") + if self.app.fastflix.current_video.streams["data"]: + logger.debug(f"{len(self.app.fastflix.current_video.streams['data'])} {t('data tracks found')}") + + self.widgets.end_time.setText(self.number_to_time(self.app.fastflix.current_video.duration)) + title_name = [ + v for k, v in self.app.fastflix.current_video.format.get("tags", {}).items() if k.lower() == "title" + ] + if title_name: + self.video_options.advanced.video_title.setText(title_name[0]) + else: + self.video_options.advanced.video_title.setText("") + + video_track_title_name = [ + v + for k, v in self.app.fastflix.current_video.streams.video[0].get("tags", {}).items() + if k.upper() == "TITLE" + ] + + if video_track_title_name: + self.video_options.advanced.video_track_title.setText(video_track_title_name[0]) + else: + self.video_options.advanced.video_track_title.setText("") + + self.widgets.deinterlace.setChecked(self.app.fastflix.current_video.video_settings.deinterlace) + + logger.info("Updating video info") + self.video_options.new_source() + self.enable_all() + + self._start_cover_extraction() + + self.loading_video = False + self.update_resolution_labels() + self.update_video_info_labels() + + # Set preview slider steps: ~1 per 10 seconds, minimum 100 + slider_steps = max(100, int(self.app.fastflix.current_video.duration / 10)) + self.widgets.thumb_time.setMaximum(slider_steps) + self.widgets.thumb_time.setPageStep(max(1, slider_steps // 20)) + self.widgets.thumb_time.setValue(max(1, slider_steps // 4)) + + if self.app.fastflix.config.opt("auto_crop"): + self.get_auto_crop() + + encoder = self.current_encoder + if encoder and not getattr(encoder, "enable_concat", False) and self.app.fastflix.current_video.concat: + error_message(f"{encoder.name} {t('does not support concatenating files together')}") + + @staticmethod + def _chroma_from_pix_fmt(pix_fmt: str) -> str: + if not pix_fmt: + return "" + fmt = pix_fmt.lower() + if "444" in fmt: + return "4:4:4" + if "422" in fmt: + return "4:2:2" + if "420" in fmt or fmt in ("nv12", "nv12m", "nv21", "p010le"): + return "4:2:0" + if "411" in fmt: + return "4:1:1" + if "410" in fmt: + return "4:1:0" + if "440" in fmt: + return "4:4:0" + return "" + + def update_video_info_labels(self): + if not self.app.fastflix.current_video: + self.video_info_label.hide() + self.video_codec_label.hide() + self.video_bit_depth_label.hide() + self.video_chroma_label.hide() + self.video_hdr10_label.hide() + self.video_hdr10plus_label.hide() + return + + track_index = self.widgets.video_track.currentIndex() + if track_index < 0: + return + stream = self.app.fastflix.current_video.streams.video[track_index] + stream_idx = stream.index + + codec = stream.get("codec_name", "") + if codec: + self.video_codec_label.setText(codec.upper()) + self.video_codec_label.show() + else: + self.video_codec_label.hide() + + bit_depth = stream.get("bit_depth", "8") + self.video_bit_depth_label.setText(f"{bit_depth}-bit") + self.video_bit_depth_label.show() + self.video_info_label.show() + + chroma = self._chroma_from_pix_fmt(stream.get("pix_fmt", "")) + if chroma: + self.video_chroma_label.setText(chroma) + self.video_chroma_label.show() + else: + self.video_chroma_label.hide() + + hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams] + if stream_idx in hdr10_indexes: + self.video_hdr10_label.setText("\u2714 HDR10") + self.video_hdr10_label.setStyleSheet("color: #00cc00;") + self.video_hdr10_label.show() + else: + self.video_hdr10_label.hide() + + if self.app.fastflix.config.hdr10plus_parser and stream_idx in self.app.fastflix.current_video.hdr10_plus: + self.video_hdr10plus_label.setText("\u2714 HDR10+") + self.video_hdr10plus_label.setStyleSheet("color: #00cc00;") + self.video_hdr10plus_label.show() + else: + self.video_hdr10plus_label.hide() + + def _load_dropped_video(self): + self.source_video_path_widget.setText(str(self.input_video)) + self.video_path_widget.setText(str(self.input_video)) + try: + self.update_video_info() + except Exception: + logger.exception(f"Could not load video {self.input_video}") + self.video_path_widget.setText("") + self.output_video_path_widget.setText("") + self.output_video_path_widget.setDisabled(True) + self.widgets.output_directory.setText("") + self.output_path_button.setDisabled(True) + self.filename_truncation_warning.hide() + self.page_update() + + def _start_cover_extraction(self): + """Start background cover extraction thread.""" + if self.app.fastflix.config.disable_cover_extraction: + return + if not self.app.fastflix.current_video: + return + has_covers = any( + track.get("tags", {}).get("filename", "").rsplit(".", 1)[0] + in ("cover", "small_cover", "cover_land", "small_cover_land") + for track in self.app.fastflix.current_video.streams.attachment + ) + if not has_covers: + return + + self.widgets.queue_button.setDisabled(True) + self.widgets.queue_button.setToolTip(t("Extracting cover images...")) + self.widgets.convert_button.setDisabled(True) + self.widgets.convert_button.setToolTip(t("Extracting cover images...")) + + self.video_options.attachments.set_extracting(True) + + self._cover_extract_video_source = self.app.fastflix.current_video.source + self._cover_extract_thread = ExtractCovers(app=self.app, main=self, signal=self.cover_extraction_complete) + self._cover_extract_thread.start() + + def on_cover_extraction_complete(self): + """Called when background cover extraction finishes.""" + self._cover_extract_thread = None + + self.widgets.queue_button.setEnabled(True) + self.widgets.queue_button.setToolTip("") + self.widgets.convert_button.setEnabled(True) + self.widgets.convert_button.setToolTip("") + + if ( + self.app.fastflix.current_video + and self.app.fastflix.current_video.source == self._cover_extract_video_source + ): + self.video_options.attachments.covers_extracted() + self._cover_extract_video_source = None diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 4f1177f1..2d7aadb7 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -269,7 +269,6 @@ def __init__(self, parent, app: FastFlixApp): self.pause_encode.setToolTip(t("Pause / Resume the current command")) self.ignore_errors = QtWidgets.QCheckBox(t("Ignore Errors")) - self.ignore_errors.toggled.connect(self.ignore_failures) self.ignore_errors.setFixedWidth(150) self.after_done_combo = QtWidgets.QComboBox() @@ -506,12 +505,6 @@ def pause_resume_encode(self): self.app.fastflix.worker_queue.put(["pause encode"]) self.encode_paused = not self.encode_paused - def ignore_failures(self): - if self.ignore_errors.isChecked(): - self.app.fastflix.worker_queue.put(["ignore error"]) - else: - self.app.fastflix.worker_queue.put(["stop on error"]) - @reusables.log_exception("fastflix", show_traceback=False) def set_after_done(self): option = self.after_done_combo.currentText() From 538c646d7f76cafcced0b19445b00f2aa1c9d3e5 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sun, 15 Mar 2026 22:06:25 -0500 Subject: [PATCH 09/12] * Adding favicon to root of repo so it shows up on fastflix.org (thanks to Balthazar) --- .claude/settings.local.json | 3 ++- CHANGES | 1 + favicon.ico | Bin 0 -> 270398 bytes 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 favicon.ico diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 88b7e75f..5813574f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -111,7 +111,8 @@ "WebFetch(domain:www.phoronix.com)", "Bash(copy:*)", "Bash(ffprobe:*)", - "Bash(gh api:*)" + "Bash(gh api:*)", + "Bash(git:*)" ] } } diff --git a/CHANGES b/CHANGES index ed17d330..6e8e67ae 100644 --- a/CHANGES +++ b/CHANGES @@ -4,6 +4,7 @@ * Adding #724 "exit" option to the After Conversion dropdown, which closes FastFlix after all queue items complete (thanks to jrff123) * Adding #731 OpenCL Support setting (Auto/Disable) with re-detection button in Application Locations settings (thanks to sks2012) +* Adding favicon to root of repo so it shows up on fastflix.org (thanks to Balthazar) * Adding encoding history feature with browsable history window, "Apply Last Used Settings" menu action, and startup opt-in prompt * Adding FFmpeg 8.0+ version check on startup with option to download latest FFmpeg on Windows * Adding "Keep source format" option to Audio Normalize, which detects and uses the same audio codec and bitrate as the source video diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7fe27dfb375517b75b1183eb457ae41246831675 GIT binary patch literal 270398 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 R>LfdHLGHL?knx`J`5&C6pNRkf literal 0 HcmV?d00001 From d36dc332b77b4c207a2d3d5806f0d7985f8ad2b6 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 19 Mar 2026 20:55:19 -0500 Subject: [PATCH 10/12] * Adding AV1 (NVENC) encoder for FFmpeg-based AV1 hardware encoding on NVIDIA GPUs (RTX 4000+) with quality-focused defaults including spatial/temporal AQ, lookahead, and multipass support --- .claude/settings.local.json | 3 +- CHANGES | 1 + fastflix/application.py | 2 + fastflix/data/languages.yaml | 180 ++++++++++ .../encoders/ffmpeg_av1_nvenc/__init__.py | 0 .../ffmpeg_av1_nvenc/command_builder.py | 97 ++++++ fastflix/encoders/ffmpeg_av1_nvenc/main.py | 23 ++ .../ffmpeg_av1_nvenc/settings_panel.py | 316 ++++++++++++++++++ fastflix/models/encode.py | 28 ++ fastflix/models/profiles.py | 2 + fastflix/models/video.py | 2 + .../test_ffmpeg_av1_nvenc_command_builder.py | 267 +++++++++++++++ 12 files changed, 920 insertions(+), 1 deletion(-) create mode 100644 fastflix/encoders/ffmpeg_av1_nvenc/__init__.py create mode 100644 fastflix/encoders/ffmpeg_av1_nvenc/command_builder.py create mode 100644 fastflix/encoders/ffmpeg_av1_nvenc/main.py create mode 100644 fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py create mode 100644 tests/encoders/test_ffmpeg_av1_nvenc_command_builder.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5813574f..fbe8ed07 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -112,7 +112,8 @@ "Bash(copy:*)", "Bash(ffprobe:*)", "Bash(gh api:*)", - "Bash(git:*)" + "Bash(git:*)", + "WebFetch(domain:docs.nvidia.com)" ] } } diff --git a/CHANGES b/CHANGES index 6e8e67ae..7d65ec20 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,7 @@ ## Version 6.2.0 +* Adding AV1 (NVENC) encoder for FFmpeg-based AV1 hardware encoding on NVIDIA GPUs (RTX 4000+) with quality-focused defaults including spatial/temporal AQ, lookahead, and multipass support * Adding #724 "exit" option to the After Conversion dropdown, which closes FastFlix after all queue items complete (thanks to jrff123) * Adding #731 OpenCL Support setting (Auto/Disable) with re-detection button in Application Locations settings (thanks to sks2012) * Adding favicon to root of repo so it shows up on fastflix.org (thanks to Balthazar) diff --git a/fastflix/application.py b/fastflix/application.py index 6744cc10..1066d43d 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -92,6 +92,7 @@ def init_encoders(app: FastFlixApp, **_): from fastflix.encoders.gif import main as gif_plugin from fastflix.encoders.gifski import main as gifski_plugin from fastflix.encoders.ffmpeg_hevc_nvenc import main as nvenc_plugin + from fastflix.encoders.ffmpeg_av1_nvenc import main as ffmpeg_av1_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 @@ -121,6 +122,7 @@ def init_encoders(app: FastFlixApp, **_): nvenc_plugin, hevc_videotoolbox_plugin, h264_videotoolbox_plugin, + ffmpeg_av1_nvenc_plugin, av1_plugin, rav1e_plugin, svt_av1_plugin, diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 1845302b..b9d93652 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -14716,3 +14716,183 @@ Are you sure you want to delete all encoding history?: ukr: Ви впевнені, що хочете видалити всю історію кодування? kor: 모든 인코딩 기록을 삭제하시겠습니까? ron: Sunteți sigur că doriți să ștergeți tot istoricul de codare? +"preset: p1 (fastest) to p7 (slowest/best quality)": + eng: "preset: p1 (fastest) to p7 (slowest/best quality)" + deu: 'Voreinstellung: p1 (schnellste) bis p7 (langsamste/beste Qualität)' + fra: 'préréglage : p1 (le plus rapide) à p7 (le plus lent/la meilleure qualité)' + ita: 'preimpostazione: da p1 (più veloce) a p7 (più lenta/migliore qualità)' + spa: 'preajuste: p1 (más rápido) a p7 (más lento/mejor calidad)' + jpn: プリセット:P1(最速)~P7(最遅/最高品質) + rus: 'предустановки: от p1 (самый быстрый) до p7 (самый медленный/лучшее качество)' + por: 'predefinição: p1 (mais rápido) a p7 (mais lento/melhor qualidade)' + swe: 'förinställning: p1 (snabbast) till p7 (långsammast/bästa kvalitet)' + pol: 'ustawienie wstępne: p1 (najszybsze) do p7 (najwolniejsze/najlepsza jakość)' + chs: 预设:P1(最快)至 P7(最慢/最佳质量) + ukr: 'попереднє налаштування: p1 (найшвидше) - p7 (найповільніше/найкраща якість)' + kor: '프리셋: P1(가장 빠름)~P7(가장 느림/최고 품질)' + ron: 'presetare: p1 (cel mai rapid) până la p7 (cel mai lent/cea mai bună calitate)' +Set the encoding tier (0 = main, 1 = high): + eng: Set the encoding tier (0 = main, 1 = high) + deu: Einstellen der Kodierungsebene (0 = Haupt, 1 = Hoch) + fra: Définir le niveau d'encodage (0 = principal, 1 = élevé) + ita: Impostare il livello di codifica (0 = principale, 1 = alto) + spa: Establezca el nivel de codificación (0 = principal, 1 = alto) + jpn: エンコーディングの階層を設定する(0=メイン、1=ハイ) + rus: Установите уровень кодирования (0 = основной, 1 = высокий). + por: Definir o nível de codificação (0 = principal, 1 = elevado) + swe: Ställ in kodningsnivån (0 = huvud, 1 = hög) + pol: Ustaw poziom kodowania (0 = główny, 1 = wysoki) + chs: 设置编码层(0 = 主层,1 = 高层) + ukr: Встановіть рівень кодування (0 = основний, 1 = високий) + kor: 인코딩 계층 설정(0 = 기본, 1 = 높음) + ron: Setați nivelul de codare (0 = principal, 1 = înalt) +Set multipass encoding: + eng: Set multipass encoding + deu: Multipass-Codierung einstellen + fra: Définir l'encodage multipasse + ita: Impostare la codifica multipass + spa: Establecer codificación multipase + jpn: マルチパスエンコーディングの設定 + rus: Установка многопроходного кодирования + por: Definir codificação multipasse + swe: Ställ in multipass-kodning + pol: Ustaw kodowanie wieloprzebiegowe + chs: 设置多通道编码 + ukr: Встановити багатопрохідне кодування + kor: 멀티패스 인코딩 설정 + ron: Setați codificarea multipasaj +disabled - Single pass: + eng: disabled - Single pass + deu: deaktiviert - Einfacher Durchgang + fra: handicapés - Pass unique + ita: disabilitato - Passaggio singolo + spa: desactivado - Pase único + jpn: 無効 - シングルパス + rus: инвалид - однократный проход + por: com deficiência - Passe único + swe: funktionshindrad - Enkelbiljett + pol: wyłączone - pojedyncze przejście + chs: 禁用 - 单程 + ukr: інваліди - одноразова перепустка + kor: 비활성화 - 단일 패스 + ron: cu handicap - O singură trecere +qres - Two pass (quarter resolution first pass): + eng: qres - Two pass (quarter resolution first pass) + deu: qres - Zwei Durchgänge (Viertelauflösung im ersten Durchgang) + fra: qres - Two pass (quart de résolution première passe) + ita: qres - Due passaggi (risoluzione di un quarto prima del passaggio) + spa: qres - Dos pasadas (primera pasada de la resolución del cuarto) + jpn: qres - 2パス(クォーター・レゾリューション・ファースト・パス) + rus: qres - Two pass (четверть разрешения первого прохода) + por: qres - Two pass (resolução de um quarto de hora na primeira passagem) + swe: qres - Two pass (kvartsupplösning första passet) + pol: qres - Two pass (pierwsze przejście w rozdzielczości ćwiartki) + chs: qres - 两次传递(四分之一分辨率第一次传递) + ukr: qres - Two pass (перший прохід з роздільною здатністю кварталу) + kor: qres - 투 패스(분기 해상도 첫 번째 패스) + ron: qres - Two pass (rezoluție trimestrială prima trecere) +fullres - Two pass (full resolution first pass): + eng: fullres - Two pass (full resolution first pass) + deu: fullres - Zwei Durchgänge (erster Durchgang mit voller Auflösung) + fra: fullres - Two pass (première passe en pleine résolution) + ita: fullres - Due passaggi (primo passaggio a piena risoluzione) + spa: fullres - Dos pasadas (resolución completa en la primera pasada) + jpn: fullres - 2パス(フル解像度の1パス目) + rus: fullres - два прохода (первый проход с полным разрешением) + por: fullres - Duas passagens (resolução total na primeira passagem) + swe: fullres - Två pass (full upplösning första passet) + pol: fullres - Dwa przejścia (pierwsze przejście w pełnej rozdzielczości) + chs: fullres - 两通道(第一通道全分辨率) + ukr: fullres - два проходження (перший прохід з повною роздільною здатністю) + kor: 풀 해상도 - 투 패스(전체 해상도 첫 번째 패스) + ron: fullres - Două treceri (rezoluție completă la prima trecere) +Enable spatial adaptive quantization for better quality in complex areas: + eng: Enable spatial adaptive quantization for better quality in complex areas + deu: Ermöglicht räumlich adaptive Quantisierung für bessere Qualität in komplexen Bereichen + fra: Quantification spatiale adaptative pour une meilleure qualité dans les zones complexes + ita: Abilitazione della quantizzazione spaziale adattiva per una migliore qualità in aree complesse + spa: Cuantificación espacial adaptativa para mejorar la calidad en zonas complejas + jpn: 空間適応型量子化により、複雑な領域での品質向上が可能 + rus: Возможность пространственного адаптивного квантования для повышения качества в сложных областях + por: Ativar a quantização adaptativa espacial para uma melhor qualidade em áreas complexas + swe: Möjliggör rumslig adaptiv kvantisering för bättre kvalitet i komplexa områden + pol: Włączanie przestrzennej kwantyzacji adaptacyjnej dla lepszej jakości w złożonych obszarach + chs: 启用空间自适应量化,提高复杂区域的质量 + ukr: Увімкніть просторове адаптивне квантування для кращої якості на складних ділянках + kor: 복잡한 영역에서 품질 향상을 위한 공간 적응형 양자화 지원 + ron: Activați cuantizarea adaptivă spațială pentru o calitate mai bună în zonele complexe +Temporal AQ: + eng: Temporal AQ + deu: Zeitliche AQ + fra: QA temporel + ita: QA temporale + spa: Temporal AQ + jpn: 時間的AQ + rus: Темпоральный AQ + por: QA temporal + swe: Temporär AQ + pol: Czasowe AQ + chs: 时间性 AQ + ukr: Темпоральна амплітуда + kor: 시간적 AQ + ron: Temporal AQ +Enable temporal adaptive quantization for better quality in scenes with motion: + eng: Enable temporal adaptive quantization for better quality in scenes with motion + deu: Aktivieren der zeitlich adaptiven Quantisierung für bessere Qualität in Szenen mit Bewegung + fra: Activer la quantification adaptative temporelle pour une meilleure qualité dans les scènes avec mouvement + ita: Abilitazione della quantizzazione adattiva temporale per una migliore qualità nelle scene con movimento + spa: Activar la cuantificación temporal adaptativa para mejorar la calidad en escenas con movimiento + jpn: 動きのあるシーンの画質を向上させるために、時間適応量子化を有効にする + rus: Включение временного адаптивного квантования для повышения качества в сценах с движением + por: Ativar a quantização adaptativa temporal para uma melhor qualidade em cenas com movimento + swe: Aktivera temporal adaptiv kvantisering för bättre kvalitet i scener med rörelse + pol: Włącz czasową adaptacyjną kwantyzację dla lepszej jakości w scenach z ruchem + chs: 启用时间自适应量化,提高运动场景的质量 + ukr: Увімкніть часове адаптивне квантування для кращої якості у сценах з рухом + kor: 움직임이 있는 장면의 화질 개선을 위한 시간적 적응형 양자화 활성화 + ron: Activați cuantizarea adaptivă temporală pentru o calitate mai bună în scenele cu mișcare +Number of frames to look ahead for rate-control (0 = disabled, 10-20 recommended): + eng: Number of frames to look ahead for rate-control (0 = disabled, 10-20 recommended) + deu: Anzahl der Frames, die für die Ratenkontrolle vorausgeschaut werden (0 = deaktiviert, 10-20 empfohlen) + fra: Nombre de trames à anticiper pour le contrôle du débit (0 = désactivé, 10-20 recommandé) + ita: Numero di fotogrammi da guardare avanti per il controllo della velocità (0 = disabilitato, 10-20 consigliato) + spa: Número de fotogramas que se adelantan para el control de velocidad (0 = desactivado, se recomienda 10-20) + jpn: レート制御のために先読みするフレーム数(0 = 無効、10~20を推奨) + rus: Количество кадров для контроля скорости (0 = отключено, рекомендуется 10-20) + por: Número de fotogramas a considerar para o controlo do débito (0 = desativado, 10-20 recomendado) + swe: Antal bildrutor att titta framåt för hastighetskontroll (0 = inaktiverad, 10-20 rekommenderas) + pol: Liczba ramek do sprawdzenia w celu kontroli szybkości (0 = wyłączone, zalecane 10-20) + chs: 速率控制的前瞻帧数(0 = 禁用,建议 10-20 帧) + ukr: Кількість кадрів, які потрібно переглянути для регулювання швидкості (0 = вимкнено, рекомендується 10-20) + kor: 속도 제어를 위해 미리 볼 프레임 수(0 = 비활성화, 10~20개 권장) + ron: Numărul de cadre pe care trebuie să le așteptați pentru controlul vitezei (0 = dezactivat, 10-20 recomandat) +When Spatial AQ is enabled, sets AQ strength (1 = low, 15 = aggressive, default 8): + eng: When Spatial AQ is enabled, sets AQ strength (1 = low, 15 = aggressive, default 8) + deu: Wenn Spatial AQ aktiviert ist, legt die AQ-Stärke fest (1 = niedrig, 15 = aggressiv, Standardwert 8) + fra: Lorsque le QA spatial est activé, définit la force du QA (1 = faible, 15 = agressif, par défaut 8). + ita: Quando l'AQ spaziale è abilitato, imposta l'intensità dell'AQ (1 = bassa, 15 = aggressiva, valore predefinito 8). + spa: Cuando está activada la opción AQ Espacial, establece la intensidad de AQ (1 = baja, 15 = agresiva, por defecto 8) + jpn: 空間AQが有効な場合、AQの強さを設定する(1 = 低い、15 = 攻撃的、デフォルトは8) + rus: Если включена функция Spatial AQ, задает силу AQ (1 = низкая, 15 = агрессивная, по умолчанию 8). + por: Quando o AQ espacial está ativado, define a intensidade do AQ (1 = baixa, 15 = agressiva, predefinição 8) + swe: När Spatial AQ är aktiverat, ställer in AQ-styrkan (1 = låg, 15 = aggressiv, standard 8) + pol: Gdy funkcja AQ przestrzennego jest włączona, ustawia siłę AQ (1 = niska, 15 = agresywna, domyślnie 8). + chs: 启用空间 AQ 时,设置 AQ 强度(1 = 低,15 = 高,默认为 8)。 + ukr: Якщо увімкнено Spatial AQ, встановлює силу AQ (1 = низька, 15 = агресивна, за замовчуванням 8) + kor: 공간 AQ가 활성화된 경우, AQ 강도를 설정합니다(1 = 낮음, 15 = 공격적, 기본값 8). + ron: Atunci când AQ spațial este activat, setează puterea AQ (1 = scăzută, 15 = agresivă, implicit 8) +Please load a video first: + eng: Please load a video first + deu: Bitte laden Sie zuerst ein Video + fra: Veuillez d'abord charger une vidéo + ita: Caricare prima un video + spa: Cargue primero un vídeo + jpn: 最初にビデオをロードしてください。 + rus: Пожалуйста, сначала загрузите видео + por: Carregue um vídeo primeiro + swe: Vänligen ladda en video först + pol: Najpierw załaduj wideo + chs: 请先加载视频 + ukr: Будь ласка, спочатку завантажте відео + kor: 먼저 동영상을 로드하세요. + ron: Vă rugăm să încărcați mai întâi un videoclip diff --git a/fastflix/encoders/ffmpeg_av1_nvenc/__init__.py b/fastflix/encoders/ffmpeg_av1_nvenc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/ffmpeg_av1_nvenc/command_builder.py b/fastflix/encoders/ffmpeg_av1_nvenc/command_builder.py new file mode 100644 index 00000000..aa7b833b --- /dev/null +++ b/fastflix/encoders/ffmpeg_av1_nvenc/command_builder.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +import secrets +import shlex + +from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null +from fastflix.models.encode import FFmpegAV1NVENCSettings +from fastflix.models.fastflix import FastFlix + + +def build(fastflix: FastFlix): + settings: FFmpegAV1NVENCSettings = fastflix.current_video.video_settings.video_encoder_settings + + beginning, ending, output_fps = generate_all( + fastflix, "av1_nvenc", start_extra="-hwaccel auto" if settings.hw_accel else "" + ) + + if settings.tune: + beginning.extend(["-tune:v", settings.tune]) + beginning.extend(generate_color_details(fastflix)) + beginning.extend( + [ + "-spatial-aq:v", + str(settings.spatial_aq), + "-temporal-aq:v", + str(settings.temporal_aq), + "-tier:v", + str(settings.tier), + "-rc-lookahead:v", + str(settings.rc_lookahead), + "-gpu", + str(settings.gpu), + "-b_ref_mode", + str(settings.b_ref_mode), + ] + ) + + if settings.multipass != "disabled": + beginning.extend(["-multipass:v", settings.multipass]) + + if settings.rc: + beginning.extend(["-rc:v", settings.rc]) + + if settings.level: + beginning.extend(["-level:v", settings.level]) + + if settings.aq_strength != 8: + beginning.extend(["-aq-strength:v", str(settings.aq_strength)]) + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + + if not settings.bitrate: + command = beginning + ["-qp:v", str(settings.qp), "-preset:v", settings.preset] + extra + ending + return [Command(command=command, name="Single QP encode", exe="ffmpeg")] + + pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" + + command_1 = ( + beginning + + [ + "-pass", + "1", + "-passlogfile", + str(pass_log_file), + "-b:v", + settings.bitrate, + "-preset:v", + settings.preset, + "-2pass", + "1", + ] + + extra_both + + ["-an", "-sn", "-dn"] + + output_fps + + ["-f", "mp4", null] + ) + command_2 = ( + beginning + + [ + "-pass", + "2", + "-passlogfile", + str(pass_log_file), + "-2pass", + "1", + "-b:v", + settings.bitrate, + "-preset:v", + settings.preset, + ] + + 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_av1_nvenc/main.py b/fastflix/encoders/ffmpeg_av1_nvenc/main.py new file mode 100644 index 00000000..518e9178 --- /dev/null +++ b/fastflix/encoders/ffmpeg_av1_nvenc/main.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +import importlib.resources + +name = "AV1 (NVENC)" +requires = "cuda-llvm" + +video_extensions = [".mkv", ".mp4", ".ts", ".mov", ".avi", ".mts", ".m2ts", ".m4v", ".webm"] +video_dimension_divisor = 1 + +ref = importlib.resources.files("fastflix") / "data/encoders/icon_nvenc.png" +with importlib.resources.as_file(ref) as icon_file: + icon = str(icon_file.resolve()) + +enable_subtitles = True +enable_audio = True +enable_attachments = True +enable_concat = True +enable_data = True + +from fastflix.encoders.ffmpeg_av1_nvenc.command_builder import build # noqa: F401,E402 +from fastflix.encoders.ffmpeg_av1_nvenc.settings_panel import AV1NVENC as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py b/fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py new file mode 100644 index 00000000..4382d1a5 --- /dev/null +++ b/fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +import logging + +from box import Box +from PySide6 import QtWidgets + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.models.encode import FFmpegAV1NVENCSettings +from fastflix.models.fastflix_app import FastFlixApp + +logger = logging.getLogger("fastflix") + + +presets = [ + "p1", + "p2", + "p3", + "p4", + "p5", + "p6", + "p7", +] + +recommended_bitrates = [ + "800k (320x240p @ 30fps)", + "1000k (640x360p @ 30fps)", + "1500k (640x480p @ 30fps)", + "2000k (1280x720p @ 30fps)", + "4000k (1280x720p @ 60fps)", + "5000k (1080p @ 30fps)", + "8000k (1080p @ 60fps)", + "12000k (1440p @ 30fps)", + "20000k (1440p @ 60fps)", + "30000k (2160p @ 30fps)", + "40000k (2160p @ 60fps)", + "Custom", +] + +recommended_crfs = [ + "38", + "35", + "33", + "30", + "28", + "26", + "24", + "22", + "20", + "18", + "16", + "14", + "Custom", +] + +pix_fmts = ["8-bit: yuv420p", "10-bit: p010le"] + + +class AV1NVENC(SettingPanel): + profile_name = "ffmpeg_av1_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_tune(), 1, 0, 1, 2) + grid.addLayout(self.init_pix_fmt(), 2, 0, 1, 2) + grid.addLayout(self.init_tier(), 3, 0, 1, 2) + grid.addLayout(self.init_rc(), 4, 0, 1, 2) + grid.addLayout(self.init_multipass(), 5, 0, 1, 2) + grid.addLayout(self.init_spatial_aq(), 6, 0, 1, 2) + grid.addLayout(self.init_temporal_aq(), 7, 0, 1, 2) + grid.addLayout(self.init_max_mux(), 8, 0, 1, 2) + + grid.addLayout(self.init_hw_accel(), 2, 2, 1, 1) + + 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()) + a.addStretch(1) + a.addLayout(self.init_aq_strength()) + grid.addLayout(a, 4, 2, 1, 4) + + grid.setRowStretch(9, 1) + + self.setLayout(grid) + self.hide() + + def init_preset(self): + return self._add_combo_box( + label="Preset", + widget_name="preset", + options=presets, + tooltip="preset: p1 (fastest) to p7 (slowest/best 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, uhq - Ultra High Quality, ll - Low Latency, ull - Ultra Low Latency", + options=["hq", "uhq", "ll", "ull", "lossless"], + opt="tune", + ) + + 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 (0 = main, 1 = high)", + widget_name="tier", + options=["0", "1"], + 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", "constqp", "vbr", "cbr"], + opt="rc", + ) + + def init_multipass(self): + return self._add_combo_box( + label="Multipass", + tooltip="Set multipass encoding\ndisabled - Single pass\nqres - Two pass (quarter resolution first pass)\nfullres - Two pass (full resolution first pass)", + widget_name="multipass", + options=["disabled", "qres", "fullres"], + opt="multipass", + ) + + def init_hw_accel(self): + return self._add_check_box( + opt="hw_accel", + label="Hardware Decoding", + tooltip="Use hardware decoding", + widget_name="hw_accel", + ) + + def init_spatial_aq(self): + return self._add_combo_box( + label="Spatial AQ", + tooltip="Enable spatial adaptive quantization for better quality in complex areas", + widget_name="spatial_aq", + options=["off", "on"], + opt="spatial_aq", + ) + + def init_temporal_aq(self): + return self._add_combo_box( + label="Temporal AQ", + tooltip="Enable temporal adaptive quantization for better quality in scenes with motion", + widget_name="temporal_aq", + options=["off", "on"], + opt="temporal_aq", + ) + + def init_rc_lookahead(self): + return self._add_text_box( + label="RC Lookahead", + tooltip="Number of frames to look ahead for rate-control (0 = disabled, 10-20 recommended)", + widget_name="rc_lookahead", + opt="rc_lookahead", + validator="int", + default="16", + width=30, + ) + + def init_level(self): + layout = self._add_combo_box( + label="Level", + tooltip="Set the encoding level restriction", + widget_name="level", + options=[ + "auto", + "2.0", + "2.1", + "2.2", + "2.3", + "3.0", + "3.1", + "3.2", + "3.3", + "4.0", + "4.1", + "4.2", + "4.3", + "5.0", + "5.1", + "5.2", + "5.3", + "6.0", + "6.1", + "6.2", + "6.3", + "7.0", + "7.1", + "7.2", + "7.3", + ], + 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.b_ref_mode.setMinimumWidth(50) + return layout + + def init_aq_strength(self): + layout = self._add_combo_box( + label="AQ Strength", + tooltip="When Spatial AQ is enabled, sets AQ strength (1 = low, 15 = aggressive, default 8)", + widget_name="aq_strength", + opt="aq_strength", + options=[str(x) for x in range(1, 16)], + ) + self.widgets.aq_strength.setMinimumWidth(50) + return layout + + def init_modes(self): + layout = self._add_modes(recommended_bitrates, recommended_crfs, qp_name="qp") + self.qp_radio.setChecked(True) + self.bitrate_radio.setChecked(False) + 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 = FFmpegAV1NVENCSettings( + preset=self.widgets.preset.currentText(), + max_muxing_queue_size=self.widgets.max_mux.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, + multipass=self.widgets.multipass.currentText(), + spatial_aq=self.widgets.spatial_aq.currentIndex(), + temporal_aq=self.widgets.temporal_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(), + aq_strength=int(self.widgets.aq_strength.currentText()), + tier=self.widgets.tier.currentText(), + hw_accel=self.widgets.hw_accel.isChecked(), + ) + 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/models/encode.py b/fastflix/models/encode.py index 9e3c3d6b..5746f661 100644 --- a/fastflix/models/encode.py +++ b/fastflix/models/encode.py @@ -156,6 +156,33 @@ def qp_to_int(cls, value): return value +class FFmpegAV1NVENCSettings(EncoderSettings): + name: str = "AV1 (NVENC)" + preset: str = "p5" + tune: str = "hq" + pix_fmt: str = "p010le" + bitrate: Optional[str] = None + qp: Optional[Union[int, float]] = 28 + spatial_aq: int = 1 + temporal_aq: int = 1 + rc_lookahead: int = 16 + rc: Optional[str] = None + multipass: str = "fullres" + aq_strength: int = 8 + tier: str = "0" + level: Optional[str] = None + gpu: int = -1 + b_ref_mode: str = "middle" + hw_accel: bool = False + + @field_validator("qp", mode="before") + @classmethod + def qp_to_int(cls, value): + if isinstance(value, str): + return int(value) + return value + + class NVEncCSettings(EncoderSettings): name: str = "HEVC (NVEncC)" preset: str = "quality" @@ -759,6 +786,7 @@ class VAAPIMPEG2Settings(EncoderSettings): "copy_settings": CopySettings, "modify_settings": ModifySettings, "ffmpeg_hevc_nvenc": FFmpegNVENCSettings, + "ffmpeg_av1_nvenc": FFmpegAV1NVENCSettings, "qsvencc_hevc": QSVEncCSettings, "qsvencc_av1": QSVEncCAV1Settings, "qsvencc_avc": QSVEncCH264Settings, diff --git a/fastflix/models/profiles.py b/fastflix/models/profiles.py index abfa6b97..60c76fb1 100644 --- a/fastflix/models/profiles.py +++ b/fastflix/models/profiles.py @@ -12,6 +12,7 @@ GIFSettings, GifskiSettings, FFmpegNVENCSettings, + FFmpegAV1NVENCSettings, SVTAV1Settings, VP9Settings, WebPSettings, @@ -189,6 +190,7 @@ class Profile(BaseModel): modify_settings: Optional[ModifySettings] = None copy_settings: Optional[CopySettings] = None ffmpeg_hevc_nvenc: Optional[FFmpegNVENCSettings] = None + ffmpeg_av1_nvenc: Optional[FFmpegAV1NVENCSettings] = None qsvencc_hevc: Optional[QSVEncCSettings] = None qsvencc_av1: Optional[QSVEncCAV1Settings] = None qsvencc_avc: Optional[QSVEncCH264Settings] = None diff --git a/fastflix/models/video.py b/fastflix/models/video.py index a3a21f5b..08af2242 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -16,6 +16,7 @@ GIFSettings, GifskiSettings, FFmpegNVENCSettings, + FFmpegAV1NVENCSettings, SubtitleTrack, SVTAV1Settings, VP9Settings, @@ -134,6 +135,7 @@ class VideoSettings(BaseModel): WebPSettings, CopySettings, FFmpegNVENCSettings, + FFmpegAV1NVENCSettings, QSVEncCSettings, QSVEncCAV1Settings, QSVEncCH264Settings, diff --git a/tests/encoders/test_ffmpeg_av1_nvenc_command_builder.py b/tests/encoders/test_ffmpeg_av1_nvenc_command_builder.py new file mode 100644 index 00000000..752c20ba --- /dev/null +++ b/tests/encoders/test_ffmpeg_av1_nvenc_command_builder.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from fastflix.encoders.common.helpers import null +from fastflix.encoders.ffmpeg_av1_nvenc.command_builder import build +from fastflix.models.encode import FFmpegAV1NVENCSettings +from fastflix.models.video import VideoSettings + +from tests.conftest import create_fastflix_instance + + +def test_ffmpeg_av1_nvenc_qp(): + """Test the build function with QP settings.""" + fastflix = create_fastflix_instance( + encoder_settings=FFmpegAV1NVENCSettings( + qp=28, + preset="p5", + tune="hq", + pix_fmt="p010le", + bitrate=None, + spatial_aq=1, + temporal_aq=1, + rc_lookahead=16, + tier="0", + level=None, + gpu=-1, + b_ref_mode="middle", + multipass="fullres", + aq_strength=8, + hw_accel=False, + ), + video_settings=VideoSettings( + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_all") as mock_generate_all: + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) + + with mock.patch( + "fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_color_details" + ) as mock_generate_color_details: + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] + + result = build(fastflix) + + assert isinstance(result, list) + assert len(result) == 1 + + cmd = result[0].command + assert isinstance(cmd, list) + + assert "-tune:v" in cmd + assert "hq" in cmd + assert "-qp:v" in cmd + assert "28" in cmd + assert "-preset:v" in cmd + assert "p5" in cmd + assert "-spatial-aq:v" in cmd + assert "1" in cmd + assert "-temporal-aq:v" in cmd + assert "-tier:v" in cmd + assert "-rc-lookahead:v" in cmd + assert "16" in cmd + assert "-multipass:v" in cmd + assert "fullres" in cmd + assert "-b_ref_mode" in cmd + assert "middle" in cmd + assert "output.mkv" in cmd + + +def test_ffmpeg_av1_nvenc_bitrate(): + """Test the build function with bitrate encoding.""" + fastflix = create_fastflix_instance( + encoder_settings=FFmpegAV1NVENCSettings( + qp=None, + preset="p5", + tune="hq", + pix_fmt="p010le", + bitrate="5000k", + spatial_aq=1, + temporal_aq=1, + rc_lookahead=16, + tier="0", + level=None, + gpu=-1, + b_ref_mode="middle", + multipass="fullres", + aq_strength=8, + hw_accel=False, + ), + video_settings=VideoSettings( + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_all") as mock_generate_all: + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) + + with mock.patch( + "fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_color_details" + ) as mock_generate_color_details: + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] + + with mock.patch("fastflix.encoders.ffmpeg_av1_nvenc.command_builder.secrets.token_hex") as mock_token_hex: + mock_token_hex.return_value = "abcdef1234" + + result = build(fastflix) + + assert isinstance(result, list) + assert len(result) == 2 + + cmd1 = result[0].command + cmd2 = result[1].command + assert isinstance(cmd1, list) + assert isinstance(cmd2, list) + + # First pass + assert "-pass" in cmd1 + assert "1" in cmd1[cmd1.index("-pass") + 1 :][:1] + assert "-b:v" in cmd1 + assert "5000k" in cmd1 + assert "-2pass" in cmd1 + assert "-an" in cmd1 + assert "-sn" in cmd1 + assert "-dn" in cmd1 + assert "-f" in cmd1 + assert "mp4" in cmd1 + assert null in cmd1 + + # Second pass + assert "-pass" in cmd2 + assert "2" in cmd2[cmd2.index("-pass") + 1 :][:1] + assert "-b:v" in cmd2 + assert "5000k" in cmd2 + assert "output.mkv" in cmd2 + + +def test_ffmpeg_av1_nvenc_with_rc_level(): + """Test the build function with RC and level settings.""" + fastflix = create_fastflix_instance( + encoder_settings=FFmpegAV1NVENCSettings( + qp=24, + preset="p7", + tune="uhq", + pix_fmt="p010le", + bitrate=None, + spatial_aq=1, + temporal_aq=1, + rc_lookahead=20, + tier="1", + level="5.1", + gpu=0, + b_ref_mode="each", + multipass="fullres", + aq_strength=12, + hw_accel=True, + rc="vbr", + ), + video_settings=VideoSettings( + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_all") as mock_generate_all: + mock_generate_all.return_value = ( + ["ffmpeg", "-hwaccel", "auto", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) + + with mock.patch( + "fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_color_details" + ) as mock_generate_color_details: + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] + + result = build(fastflix) + + assert isinstance(result, list) + assert len(result) == 1 + + cmd = result[0].command + assert isinstance(cmd, list) + + assert "-tune:v" in cmd + assert "uhq" in cmd + assert "-rc:v" in cmd + assert "vbr" in cmd + assert "-level:v" in cmd + assert "5.1" in cmd + assert "-spatial-aq:v" in cmd + assert "-temporal-aq:v" in cmd + assert "-tier:v" in cmd + assert "1" in cmd + assert "-rc-lookahead:v" in cmd + assert "20" in cmd + assert "-gpu" in cmd + assert "0" in cmd + assert "-b_ref_mode" in cmd + assert "each" in cmd + assert "-aq-strength:v" in cmd + assert "12" in cmd + assert "-multipass:v" in cmd + assert "fullres" in cmd + assert "-qp:v" in cmd + assert "24" in cmd + assert "output.mkv" in cmd + + +def test_ffmpeg_av1_nvenc_multipass_disabled(): + """Test that multipass flag is omitted when disabled.""" + fastflix = create_fastflix_instance( + encoder_settings=FFmpegAV1NVENCSettings( + qp=28, + preset="p4", + tune="hq", + pix_fmt="yuv420p", + bitrate=None, + spatial_aq=0, + temporal_aq=0, + rc_lookahead=0, + tier="0", + level=None, + gpu=-1, + b_ref_mode="disabled", + multipass="disabled", + aq_strength=8, + hw_accel=False, + ), + video_settings=VideoSettings( + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_all") as mock_generate_all: + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) + + with mock.patch( + "fastflix.encoders.ffmpeg_av1_nvenc.command_builder.generate_color_details" + ) as mock_generate_color_details: + mock_generate_color_details.return_value = [] + + result = build(fastflix) + + cmd = result[0].command + assert "-multipass:v" not in cmd + assert "-aq-strength:v" not in cmd From 9f3c823f29e5746ecf8ce1683a467c363fa608a5 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 21 Mar 2026 08:38:48 -0500 Subject: [PATCH 11/12] * Fixing 6.1.1 > 6.2.0 mixed queue saving bug (thanks to Norbert) --- CHANGES | 5 +- .../encoders/svt_av1_avif/command_builder.py | 7 ++- fastflix/ff_queue.py | 10 ++-- fastflix/models/history.py | 7 ++- fastflix/widgets/container.py | 2 + fastflix/widgets/main.py | 18 +------ tests/test_crop_transforms.py | 54 +++++++++++++++++++ tests/test_encoder_settings_serialization.py | 32 +++++++++++ tests/test_history_roundtrip.py | 27 ++++++++++ 9 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 tests/test_crop_transforms.py create mode 100644 tests/test_encoder_settings_serialization.py create mode 100644 tests/test_history_roundtrip.py diff --git a/CHANGES b/CHANGES index 7d65ec20..a32f16b9 100644 --- a/CHANGES +++ b/CHANGES @@ -17,14 +17,15 @@ * Changing Copy encoder to use modern FFmpeg display_rotation, display_hflip, and display_vflip for lossless rotation and flip metadata instead of deprecated rotate metadata tag, with support for MP4, MOV, MKV, and M4V containers * Changing non-copy encoder rotation handling to use FFmpeg's built-in auto-rotation instead of manual display_rotation overrides, which also properly handles source flips from the display matrix * Changing -fps_mode to be used instead of deprecated -vsync for frame rate control -* Fixing page_update() busy-wait that could deadlock the GUI thread when called reentrantly -* Fixing shutdown-while-encoding bug where the worker would lose the shutdown intent after the current encode finished, requiring a forceful GUI kill instead of graceful shutdown * Fixing #725 encoder detection to check `ffmpeg -encoders` output in addition to compilation flags, so encoders like VAAPI are shown even when the build flag is absent (thanks to Davius and Generator) * Fixing #728 rigaya encoders (NVEncC, QSVEncC, VCEEncC) now pass --dolby-vision-rpu-prm crop=true when Dolby Vision RPU copy is enabled and a crop is applied (thanks to izzy697) * Fixing #730 6.1.1 arm no subtitles tab with VideoToolBox (Apple M1 and above) HEVC & H264 (thanks to enaveso) +* Fixing page_update() busy-wait that could deadlock the GUI thread when called reentrantly +* Fixing shutdown-while-encoding bug where the worker would lose the shutdown intent after the current encode finished, requiring a forceful GUI kill instead of graceful shutdown * Fixing visual crop window showing incorrect bounds and dimensions when user rotation is applied, by showing the frame in pre-rotation space where crop actually operates * Fixing video crop and dimension detection for rotated videos where display matrix rotation was not found when other side data (e.g., HDR mastering display) preceded it * Fixing cover extraction to not be during video load and blocking, but a background task +* Fixing 6.1.1 > 6.2.0 mixed queue saving bug (thanks to Norbert) * Removing -strict experimental from SVT-AV1 encoders (no longer needed with FFmpeg 8+) ## Version 6.1.1 diff --git a/fastflix/encoders/svt_av1_avif/command_builder.py b/fastflix/encoders/svt_av1_avif/command_builder.py index 0261f70e..12e185d3 100644 --- a/fastflix/encoders/svt_av1_avif/command_builder.py +++ b/fastflix/encoders/svt_av1_avif/command_builder.py @@ -51,8 +51,11 @@ def build(fastflix: FastFlix): if settings.pix_fmt in ("yuv420p10le", "yuv420p12le"): def convert_me(two_numbers, conversion_rate=50_000) -> str: - num_one, num_two = map(int, two_numbers.strip("()").split(",")) - return f"{num_one / conversion_rate:0.4f},{num_two / conversion_rate:0.4f}" + try: + num_one, num_two = map(float, two_numbers.strip("()").split(",")) + return f"{num_one / conversion_rate:0.4f},{num_two / conversion_rate:0.4f}" + except ValueError: + return two_numbers.strip("()") if fastflix.current_video.master_display: svtav1_params.append( diff --git a/fastflix/ff_queue.py b/fastflix/ff_queue.py index 848eb739..027f4a1b 100644 --- a/fastflix/ff_queue.py +++ b/fastflix/ff_queue.py @@ -324,9 +324,13 @@ def save_queue( 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"]}') + cmd = command["command"] + if isinstance(cmd, list): + new_command = [arg.replace(old_path, new_path) for arg in cmd] + else: + new_command = cmd.replace(old_path, new_path) + if new_command == cmd: + logger.error(f'Could not replace "{old_path}" with "{new_path}" in {cmd}') command["command"] = new_command for video in queue: diff --git a/fastflix/models/history.py b/fastflix/models/history.py index 6513770d..3d8c95a9 100644 --- a/fastflix/models/history.py +++ b/fastflix/models/history.py @@ -65,8 +65,11 @@ def load_history(data_path: Path) -> list[HistoryEntry]: def save_history(data_path: Path, entries: list[HistoryEntry]): history_file = get_history_file(data_path) - data = Box(entries=[e.model_dump() for e in entries]) - data.to_yaml(filename=history_file, default_flow_style=False) + try: + data = Box(entries=[e.model_dump() for e in entries]) + data.to_yaml(filename=history_file, default_flow_style=False) + except Exception: + logger.exception("Failed to save history file") def add_history_entry(data_path: Path, entry: HistoryEntry, max_items: int = DEFAULT_MAX_HISTORY): diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 9523a172..dc8a8e0e 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -430,6 +430,8 @@ def rebuild_menu(self): self.init_menu() def show_history(self): + if hasattr(self, "history_window") and self.history_window is not None: + self.history_window.close() self.history_window = HistoryWindow(app=self.app) self.history_window.apply_settings_requested.connect(self._apply_history_entry) self.history_window.show() diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index a1a42dae..a0f55879 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -536,20 +536,6 @@ def get_temp_work_path(self): new_temp.mkdir() return new_temp - def pause_resume(self): - if not self.paused: - self.paused = True - self.app.fastflix.worker_queue.put(["pause"]) - self.widgets.pause_resume.setText("Resume") - self.widgets.pause_resume.setStyleSheet("background-color: green;") - logger.info("Pausing FFmpeg conversion via pustils") - else: - self.paused = False - self.app.fastflix.worker_queue.put(["resume"]) - self.widgets.pause_resume.setText("Pause") - self.widgets.pause_resume.setStyleSheet("background-color: orange;") - logger.info("Resuming FFmpeg conversion") - def config_update(self, encoder_reload_needed=False): self.thumb_file = Path(self.app.fastflix.config.work_path, "thumbnail_preview.jpg") if encoder_reload_needed: @@ -1804,7 +1790,7 @@ def build_crop(self) -> Union[Crop, None]: def disable_all(self): for name, widget in self.widgets.items(): - if name in ("preview", "convert_button", "queue_button", "pause_resume", "convert_to", "profile_box"): + if name in ("preview", "convert_button", "queue_button", "convert_to", "profile_box"): continue if isinstance(widget, dict): for sub_widget in widget.values(): @@ -1821,7 +1807,7 @@ def disable_all(self): def enable_all(self): for name, widget in self.widgets.items(): - if name in {"preview", "convert_button", "queue_button", "pause_resume", "convert_to", "profile_box"}: + if name in {"preview", "convert_button", "queue_button", "convert_to", "profile_box"}: continue if isinstance(widget, dict): for sub_widget in widget.values(): diff --git a/tests/test_crop_transforms.py b/tests/test_crop_transforms.py new file mode 100644 index 00000000..bfe0551e --- /dev/null +++ b/tests/test_crop_transforms.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import pytest + +from fastflix.widgets.windows.crop_window import CropPreviewWindow + + +forward = CropPreviewWindow._unrotated_to_rotated_crop +inverse = CropPreviewWindow._rotated_to_unrotated_crop + +SAMPLE_CROP = {"top": 10, "right": 20, "bottom": 30, "left": 40} + + +def test_identity_no_transform(): + result = forward(SAMPLE_CROP, rotate=0, vflip=False, hflip=False) + assert result == SAMPLE_CROP + + +@pytest.mark.parametrize("rotate", [1, 2, 3]) +def test_rotation_only(rotate): + rotated = forward(SAMPLE_CROP, rotate=rotate, vflip=False, hflip=False) + assert rotated != SAMPLE_CROP # rotation should change something + + +def test_hflip_only(): + result = forward(SAMPLE_CROP, rotate=0, vflip=False, hflip=True) + assert result["left"] == SAMPLE_CROP["right"] + assert result["right"] == SAMPLE_CROP["left"] + assert result["top"] == SAMPLE_CROP["top"] + assert result["bottom"] == SAMPLE_CROP["bottom"] + + +def test_vflip_only(): + result = forward(SAMPLE_CROP, rotate=0, vflip=True, hflip=False) + assert result["top"] == SAMPLE_CROP["bottom"] + assert result["bottom"] == SAMPLE_CROP["top"] + assert result["left"] == SAMPLE_CROP["left"] + assert result["right"] == SAMPLE_CROP["right"] + + +def test_rotation_plus_flip(): + result = forward(SAMPLE_CROP, rotate=1, vflip=False, hflip=True) + # Just verify it returns a valid crop dict with all keys + assert set(result.keys()) == {"top", "right", "bottom", "left"} + + +@pytest.mark.parametrize("rotate", [0, 1, 2, 3]) +@pytest.mark.parametrize("hflip", [False, True]) +@pytest.mark.parametrize("vflip", [False, True]) +def test_round_trip(rotate, hflip, vflip): + """Forward then inverse should return the original crop for all 16 combos.""" + rotated = forward(SAMPLE_CROP, rotate=rotate, vflip=vflip, hflip=hflip) + recovered = inverse(rotated, rotate=rotate, vflip=vflip, hflip=hflip) + assert recovered == SAMPLE_CROP, f"Round-trip failed for rotate={rotate}, hflip={hflip}, vflip={vflip}" diff --git a/tests/test_encoder_settings_serialization.py b/tests/test_encoder_settings_serialization.py new file mode 100644 index 00000000..85152ef2 --- /dev/null +++ b/tests/test_encoder_settings_serialization.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import warnings + +import pytest + +from fastflix.models.encode import setting_types +from fastflix.models.video import VideoSettings + + +@pytest.mark.parametrize("encoder_name,settings_cls", list(setting_types.items()), ids=list(setting_types.keys())) +def test_encoder_settings_serialization_no_warnings(encoder_name, settings_cls): + """Every encoder settings type must serialize through VideoSettings without Pydantic warnings. + + This catches missing entries in the VideoSettings.video_encoder_settings Union type, + which is exactly the bug that occurred when FFmpegAV1NVENCSettings was added to + models/encode.py but not to the Union in models/video.py. + """ + settings = settings_cls() + vs = VideoSettings(video_encoder_settings=settings) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + data = vs.model_dump() + + pydantic_warnings = [w for w in caught if "PydanticSerializationUnexpectedValue" in str(w.message)] + assert not pydantic_warnings, ( + f"Pydantic serialization warnings for {encoder_name} ({settings_cls.__name__}). " + f"Did you forget to add it to the Union in VideoSettings.video_encoder_settings (models/video.py)?" + ) + + restored = VideoSettings.model_validate(data) + assert restored.video_encoder_settings is not None diff --git a/tests/test_history_roundtrip.py b/tests/test_history_roundtrip.py new file mode 100644 index 00000000..d791867c --- /dev/null +++ b/tests/test_history_roundtrip.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Test that encoder settings survive a model_dump() → reconstruct round-trip, +which is the path used by history apply-settings.""" + +import pytest + +from fastflix.models.encode import CopySettings, SVTAV1Settings, x265Settings + + +@pytest.mark.parametrize( + "settings_class,overrides", + [ + (x265Settings, {}), + (x265Settings, {"crf": 18, "preset": "slow", "x265_params": ["aq-mode=3", "psy-rd=1.5"]}), + (SVTAV1Settings, {}), + (SVTAV1Settings, {"qp": 30, "speed": "4", "svtav1_params": ["tune=0"], "film_grain": 8}), + (CopySettings, {}), + ], + ids=["x265-default", "x265-custom", "svtav1-default", "svtav1-custom", "copy-default"], +) +def test_settings_roundtrip(settings_class, overrides): + original = settings_class(**overrides) + dumped = original.model_dump() + restored = settings_class(**dumped) + assert restored == original + assert restored.model_dump() == dumped From c2709d6db60a908123481a9251654f6ddd2052fd Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 21 Mar 2026 08:42:28 -0500 Subject: [PATCH 12/12] fixing placement in panel for av1 ffmpeg nvenc --- fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py b/fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py index 4382d1a5..c3bf2439 100644 --- a/fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py +++ b/fastflix/encoders/ffmpeg_av1_nvenc/settings_panel.py @@ -83,7 +83,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_temporal_aq(), 7, 0, 1, 2) grid.addLayout(self.init_max_mux(), 8, 0, 1, 2) - grid.addLayout(self.init_hw_accel(), 2, 2, 1, 1) + grid.addLayout(self.init_hw_accel(), 3, 2, 1, 1) a = QtWidgets.QHBoxLayout() a.addLayout(self.init_rc_lookahead())