diff --git a/CHANGES b/CHANGES index c6ed6b31..54038609 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,12 @@ # Changelog +## 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) +* Adding #703 precise frame-based trimming (--trim) for rigaya encoders (NVEncC, QSVEncC, VCEEncC) when using Exact seek mode (thanks to Augusto7743) +* Fixing #722 Timeout while extracting covers (thanks to larsk2) +* Fixing #722 Bottom Crop words cut off (thanks to larsk2) + ## Version 6.1.0 * Adding #717 Output Naming settings tab with template editor, clickable variable chips, live preview, and validation for customizing output filenames with 23 pre-encode variables (thanks to roxerqermik) diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 33d0fa0d..cab4c78f 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -13947,3 +13947,213 @@ top: ukr: верхній kor: top ron: top +Output Filename Template: + eng: Output Filename Template + deu: Vorlage für Ausgabedateinamen + fra: Modèle de nom de fichier de sortie + ita: Modello di nome del file di output + spa: Plantilla de nombre de archivo de salida + jpn: 出力ファイル名テンプレート + rus: Шаблон имени выходного файла + por: Modelo de nome de ficheiro de saída + swe: Mall för utdatafilens namn + pol: Szablon nazwy pliku wyjściowego + chs: 输出文件名模板 + ukr: Шаблон імені вихідного файлу + kor: 출력 파일 이름 템플릿 + ron: Șablon nume fișier de ieșire +Reset to default template: + eng: Reset to default template + deu: Auf Standardvorlage zurücksetzen + fra: Réinitialiser le modèle par défaut + ita: Ripristino del modello predefinito + spa: Restablecer plantilla por defecto + jpn: デフォルトテンプレートにリセット + rus: Сброс к шаблону по умолчанию + por: Repor o modelo predefinido + swe: Återställ till standardmall + pol: Przywróć domyślny szablon + chs: 重置为默认模板 + ukr: Скидання до шаблону за замовчуванням + kor: 기본 템플릿으로 재설정 + ron: Resetare la șablonul implicit +"Preview:": + eng: "Preview:" + deu: 'Vorschau:' + fra: 'Avant-première :' + ita: 'Anteprima:' + spa: 'Vista previa:' + jpn: プレビュー + rus: 'Предпросмотр:' + por: 'Pré-visualização:' + swe: 'Förhandsgranskning:' + pol: 'Podgląd:' + chs: 预览: + ukr: 'Попередній перегляд:' + kor: '미리 보기:' + ron: 'Previzualizare:' +Pre-Encode Variables: + eng: Pre-Encode Variables + deu: Variablen vorcodieren + fra: Pré-codage des variables + ita: Pre-codifica delle variabili + spa: Precodificación de variables + jpn: 変数の事前エンコード + rus: Предварительное кодирование переменных + por: Pré-codificar variáveis + swe: Förkoda variabler + pol: Wstępne kodowanie zmiennych + chs: 预编码变量 + ukr: Змінні попереднього кодування + kor: 사전 인코딩 변수 + ron: Pre-codificarea variabilelor +Example: + eng: Example + deu: Beispiel + fra: Exemple + ita: Esempio + spa: Ejemplo + jpn: 例 + rus: Пример + por: Exemplo + swe: Exempel + pol: Przykład + chs: 示例 + ukr: Приклад + kor: 예 + ron: Exemplu +Post-Encode Variables: + eng: Post-Encode Variables + deu: Post-Encode-Variablen + fra: Variables post-codées + ita: Variabili post-codifica + spa: Variables postcodificación + jpn: ポスト・エンコード変数 + rus: Переменные после кодирования + por: Variáveis pós-codificadas + swe: Variabler efter kodning + pol: Zmienne po zakodowaniu + chs: 编码后变量 + ukr: Змінні після кодування + kor: 인코딩 후 변수 + ron: Variabilele Post-Encode +Resolved after encoding completes; file will be renamed automatically: + eng: Resolved after encoding completes; file will be renamed automatically + deu: Nach Abschluss der Kodierung behoben; die Datei wird automatisch umbenannt + fra: Résolu une fois l'encodage terminé ; le fichier sera renommé automatiquement. + ita: Risolto al termine della codifica; il file verrà rinominato automaticamente. + spa: Resuelto una vez finalizada la codificación; el archivo se renombrará automáticamente. + jpn: エンコード完了後に解決。ファイル名は自動的に変更されます。 + rus: Устранено после завершения кодирования; файл будет переименован автоматически + por: Resolvido após a conclusão da codificação; o ficheiro será renomeado automaticamente + swe: Löses efter att kodningen är klar; filen kommer att döpas om automatiskt + pol: Rozwiązane po zakończeniu kodowania; nazwa pliku zostanie zmieniona automatycznie. + chs: 编码完成后已解决;文件将自动重命名 + ukr: Вирішено після завершення кодування; файл буде перейменовано автоматично + kor: 인코딩이 완료된 후 해결되며 파일 이름이 자동으로 변경됩니다. + ron: Rezolvat după finalizarea codării; fișierul va fi redenumit automat +Description: + eng: Description + deu: Beschreibung + fra: Description + ita: Descrizione + spa: Descripción + jpn: 説明 + rus: Описание + por: Descrição + swe: Beskrivning + pol: Opis + chs: 说明 + ukr: Опис + kor: 설명 + ron: Descriere +Phase: + eng: Phase + deu: Phase + fra: Phase + ita: Fase + spa: Fase + jpn: フェーズ + rus: Фаза + por: Fase + swe: Fas + pol: Faza + chs: 阶段 + ukr: Фаза + kor: 단계 + ron: Faza +Pre-Encode: + eng: Pre-Encode + deu: Vor-Codierung + fra: Pré-encodage + ita: Pre-codifica + spa: Precodificación + jpn: プリ・エンコード + rus: Предварительное кодирование + por: Pré-codificar + swe: Förkodning + pol: Wstępne kodowanie + chs: 预编码 + ukr: Попередній код + kor: 사전 인코딩 + ron: Pre-codificare +Post-Encode: + eng: Post-Encode + deu: Post-Encode + fra: Post-Encode + ita: Post-codifica + spa: Post-Encode + jpn: ポスト・エンコード + rus: Post-Encode + por: Pós-codificação + swe: Post-kodning + pol: Post-Encode + chs: 编码后 + ukr: Пост-енкод + kor: 인코딩 후 + ron: Post-codificare +Output Naming: + eng: Output Naming + deu: Benennung der Ausgabe + fra: Désignation des sorties + ita: Denominazione dell'uscita + spa: Nombres de salida + jpn: 出力ネーミング + rus: Именование выходных данных + por: Nomeação de saída + swe: Namngivning av utdata + pol: Nazewnictwo wyjścia + chs: 输出命名 + ukr: Іменування виходів + kor: 출력 이름 지정 + ron: Denumiri de ieșire +Show completion popup message: + eng: Show completion popup message + deu: Popup-Meldung zum Abschluss anzeigen + fra: Afficher le message d'achèvement + ita: Mostra il messaggio popup di completamento + spa: Mostrar mensaje emergente de finalización + jpn: 完了ポップアップメッセージの表示 + rus: Показать всплывающее сообщение о завершении + por: Mostrar mensagem pop-up de conclusão + swe: Visa popup-meddelande om slutförande + pol: Pokaż wyskakujący komunikat ukończenia + chs: 弹出显示完成信息 + ukr: Показати спливаюче повідомлення про завершення + kor: 완료 팝업 메시지 표시 + ron: Afișați mesajul pop-up de finalizare +Show error popup message: + eng: Show error popup message + deu: Fehler-Popup-Meldung anzeigen + fra: Afficher un message d'erreur + ita: Mostra il messaggio popup di errore + spa: Mostrar mensaje emergente de error + jpn: エラーのポップアップメッセージを表示する + rus: Показать всплывающее сообщение об ошибке + por: Mostrar mensagem pop-up de erro + swe: Visa popup-meddelande för fel + pol: Pokaż wyskakujący komunikat o błędzie + chs: 弹出错误信息 + ukr: Показати спливаюче повідомлення про помилку + kor: 오류 팝업 메시지 표시 + ron: Afișați mesajul pop-up de eroare diff --git a/fastflix/encoders/common/encc_helpers.py b/fastflix/encoders/common/encc_helpers.py index 6db0e894..9262c86f 100644 --- a/fastflix/encoders/common/encc_helpers.py +++ b/fastflix/encoders/common/encc_helpers.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- import logging -from typing import List +from typing import List, Optional -from fastflix.models.video import SubtitleTrack, AudioTrack, DataTrack +from fastflix.models.video import SubtitleTrack, AudioTrack, DataTrack, Video from fastflix.encoders.common.audio import lossless from fastflix.models.fastflix import FastFlix from fastflix.models.encode import VCEEncCAVCSettings, VCEEncCAV1Settings, VCEEncCSettings @@ -72,6 +72,56 @@ def rigaya_auto_options(fastflix: FastFlix) -> List[str]: ] +def _parse_frame_rate(frame_rate_str: str) -> Optional[float]: + """Parse a frame rate string like '24000/1001' or '30' into a float. + + Returns None if the string is empty or cannot be parsed. + """ + if not frame_rate_str: + return None + try: + if "/" in frame_rate_str: + num, den = frame_rate_str.split("/", 1) + denominator = float(den) + if denominator == 0: + return None + return float(num) / denominator + return float(frame_rate_str) + except (ValueError, ZeroDivisionError): + return None + + +def rigaya_trim_or_seek(video: Video) -> List[str]: + """Build time trimming arguments for rigaya encoders. + + In exact mode (fast_seek=False), uses --trim with frame numbers for precise cutting. + In fast mode (fast_seek=True), uses --seek/--seekto with timestamps. + Falls back to --seek/--seekto if frame rate is unavailable in exact mode. + """ + start_time = video.video_settings.start_time + end_time = video.video_settings.end_time + + if not start_time and not end_time: + return [] + + if not video.video_settings.fast_seek: + fps = _parse_frame_rate(video.frame_rate) + if fps: + start_frame = int(start_time * fps) if start_time else 0 + if end_time: + end_frame = int(end_time * fps) + else: + end_frame = int(video.duration * fps) + return ["--trim", f"{start_frame}:{end_frame}"] + + result = [] + if start_time: + result.extend(["--seek", str(start_time)]) + if end_time: + result.extend(["--seekto", str(end_time)]) + return result + + def pa_builder(settings: VCEEncCAVCSettings | VCEEncCAV1Settings | VCEEncCSettings): if not settings.pre_analysis: return "" diff --git a/fastflix/encoders/nvencc_av1/command_builder.py b/fastflix/encoders/nvencc_av1/command_builder.py index d1a2d31e..cf21ae40 100644 --- a/fastflix/encoders/nvencc_av1/command_builder.py +++ b/fastflix/encoders/nvencc_av1/command_builder.py @@ -12,6 +12,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, ) logger = logging.getLogger("fastflix") @@ -63,10 +64,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) if video.video_settings.rotate: diff --git a/fastflix/encoders/nvencc_avc/command_builder.py b/fastflix/encoders/nvencc_avc/command_builder.py index 763eabe8..39ab7286 100644 --- a/fastflix/encoders/nvencc_avc/command_builder.py +++ b/fastflix/encoders/nvencc_avc/command_builder.py @@ -12,6 +12,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, ) logger = logging.getLogger("fastflix") @@ -57,10 +58,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) if video.video_settings.rotate: diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py index 5482dc4f..b0359f29 100644 --- a/fastflix/encoders/nvencc_hevc/command_builder.py +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -12,6 +12,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, ) logger = logging.getLogger("fastflix") @@ -63,10 +64,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) if video.video_settings.rotate: diff --git a/fastflix/encoders/qsvencc_av1/command_builder.py b/fastflix/encoders/qsvencc_av1/command_builder.py index 60ca7207..48acd5a8 100644 --- a/fastflix/encoders/qsvencc_av1/command_builder.py +++ b/fastflix/encoders/qsvencc_av1/command_builder.py @@ -13,6 +13,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, ) logger = logging.getLogger("fastflix") @@ -58,11 +59,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) diff --git a/fastflix/encoders/qsvencc_avc/command_builder.py b/fastflix/encoders/qsvencc_avc/command_builder.py index dcf4cfdc..e3d8a23f 100644 --- a/fastflix/encoders/qsvencc_avc/command_builder.py +++ b/fastflix/encoders/qsvencc_avc/command_builder.py @@ -13,6 +13,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, ) logger = logging.getLogger("fastflix") @@ -58,11 +59,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) diff --git a/fastflix/encoders/qsvencc_hevc/command_builder.py b/fastflix/encoders/qsvencc_hevc/command_builder.py index f41f4043..f555796c 100644 --- a/fastflix/encoders/qsvencc_hevc/command_builder.py +++ b/fastflix/encoders/qsvencc_hevc/command_builder.py @@ -13,6 +13,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, ) logger = logging.getLogger("fastflix") @@ -58,11 +59,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) diff --git a/fastflix/encoders/vceencc_av1/command_builder.py b/fastflix/encoders/vceencc_av1/command_builder.py index 7c2010fc..8fcac1b5 100644 --- a/fastflix/encoders/vceencc_av1/command_builder.py +++ b/fastflix/encoders/vceencc_av1/command_builder.py @@ -12,6 +12,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, pa_builder, ) @@ -53,10 +54,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) if video.video_settings.rotate: diff --git a/fastflix/encoders/vceencc_avc/command_builder.py b/fastflix/encoders/vceencc_avc/command_builder.py index ed7f4470..933f2d88 100644 --- a/fastflix/encoders/vceencc_avc/command_builder.py +++ b/fastflix/encoders/vceencc_avc/command_builder.py @@ -12,6 +12,7 @@ build_data, rigaya_auto_options, rigaya_avformat_reader, + rigaya_trim_or_seek, pa_builder, ) @@ -53,10 +54,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) if video.video_settings.rotate: diff --git a/fastflix/encoders/vceencc_hevc/command_builder.py b/fastflix/encoders/vceencc_hevc/command_builder.py index 77e541a6..9cc41fcb 100644 --- a/fastflix/encoders/vceencc_hevc/command_builder.py +++ b/fastflix/encoders/vceencc_hevc/command_builder.py @@ -12,6 +12,7 @@ build_data, rigaya_avformat_reader, rigaya_auto_options, + rigaya_trim_or_seek, pa_builder, ) @@ -53,10 +54,7 @@ def build(fastflix: FastFlix): if stream_id: command.extend(["--video-streamid", str(stream_id)]) - if video.video_settings.start_time: - command.extend(["--seek", str(video.video_settings.start_time)]) - if video.video_settings.end_time: - command.extend(["--seekto", str(video.video_settings.end_time)]) + command.extend(rigaya_trim_or_seek(video)) if video.video_settings.source_fps: command.extend(["--fps", str(video.video_settings.source_fps)]) if video.video_settings.rotate: diff --git a/fastflix/flix.py b/fastflix/flix.py index 385e440f..fb3b0a18 100644 --- a/fastflix/flix.py +++ b/fastflix/flix.py @@ -307,8 +307,32 @@ def extract_attachments(app: FastFlixApp, **_): def extract_attachment(ffmpeg: Path, source: Path, stream: int, work_dir: Path, file_name: str): + output_path = work_dir / file_name try: + # First try -dump_attachment which works for true container attachments (fonts, etc.) execute( + [ + f"{ffmpeg}", + "-y", + f"-dump_attachment:{stream}", + clean_file_string(file_name), + "-i", + clean_file_string(source), + ], + work_dir=work_dir, + timeout=10, + ) + if output_path.exists() and output_path.stat().st_size > 0: + return + except TimeoutExpired: + pass + except Exception: + pass + + # For attached_pic streams (cover art stored as video streams), pipe the image data out. + # Using Popen + communicate(timeout) avoids FFmpeg reading the entire file before exiting. + try: + proc = Popen( [ f"{ffmpeg}", "-y", @@ -318,15 +342,29 @@ def extract_attachment(ffmpeg: Path, source: Path, stream: int, work_dir: Path, f"0:{stream}", "-c", "copy", - "-vframes", + "-frames:v", "1", - clean_file_string(file_name), + "-f", + "image2pipe", + "pipe:1", ], - work_dir=work_dir, - timeout=5, + stdout=PIPE, + stderr=PIPE, + stdin=PIPE, + cwd=work_dir, ) - except TimeoutExpired: - logger.warning(f"WARNING Timeout while extracting cover file {file_name}") + try: + stdout, _ = proc.communicate(timeout=3) + except TimeoutExpired: + proc.kill() + stdout, _ = proc.communicate() + + if stdout: + output_path.write_bytes(stdout) + else: + logger.warning(f"No image data received when extracting cover file {file_name}") + except Exception: + logger.warning(f"Failed to extract cover file {file_name}", exc_info=True) def generate_thumbnail_command( diff --git a/fastflix/models/config.py b/fastflix/models/config.py index d8a2ac3a..b071388b 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -292,7 +292,8 @@ class Config(BaseModel): nvencc_devices: dict = Field(default_factory=dict) sticky_tabs: bool = False - disable_complete_message: bool = False + show_complete_message: bool = False + show_error_message: bool = True disable_cover_extraction: bool = False @@ -447,6 +448,13 @@ def load(self, portable_mode=False): if key in self and key not in ("config_path", "version"): setattr(self, key, Path(value) if key in paths and value else value) + # Migrate old disable_complete_message (inverted) to show_complete_message and show_error_message + if "disable_complete_message" in data: + if "show_complete_message" not in data: + self.show_complete_message = not data["disable_complete_message"] + if "show_error_message" not in data: + self.show_error_message = not data["disable_complete_message"] + if self.output_directory is False: self.output_directory = None diff --git a/fastflix/version.py b/fastflix/version.py index 854e0118..18dc9462 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "6.1.0" +__version__ = "6.1.1" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 13a29094..1a99f019 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -47,6 +47,7 @@ ) from fastflix.shared import ( error_message, + message, time_to_number, yes_no_message, clean_file_string, @@ -893,13 +894,11 @@ def init_options_tabs(self): # Tab 3: Crop (3-column layout) crop_tab = QtWidgets.QWidget() - crop_layout = QtWidgets.QHBoxLayout(crop_tab) + crop_layout = QtWidgets.QGridLayout(crop_tab) crop_layout.setSpacing(scaler.scale(12)) crop_layout.setContentsMargins(scaler.scale(8), scaler.scale(8), scaler.scale(8), scaler.scale(8)) - # Column 1: Auto and Reset buttons - col1 = QtWidgets.QVBoxLayout() - col1.setSpacing(scaler.scale(10)) + # Buttons in left column (column 0) auto_crop = QtWidgets.QPushButton(t("Auto")) auto_crop.setFixedHeight(scaler.scale(28)) auto_crop.setToolTip(t("Automatically detect black borders")) @@ -921,10 +920,9 @@ def init_options_tabs(self): if self.app.fastflix.config.theme == "onyx": visual_crop.setStyleSheet(get_onyx_button_style()) self.buttons.append(visual_crop) - col1.addWidget(auto_crop) - col1.addWidget(reset) - col1.addWidget(visual_crop) - col1.addStretch(1) + crop_layout.addWidget(auto_crop, 0, 0) + crop_layout.addWidget(reset, 1, 0) + crop_layout.addWidget(visual_crop, 2, 0) # Crop input fields field_width = scaler.scale(65) @@ -958,36 +956,35 @@ def init_options_tabs(self): self.widgets.crop.right.setAlignment(QtCore.Qt.AlignCenter) self.widgets.crop.right.textChanged.connect(lambda: self.page_update()) - # Column 2: Top and Bottom - col2 = QtWidgets.QVBoxLayout() - col2.setSpacing(scaler.scale(12)) + # Row 0, Cols 1-2: Top (centered) top_row = QtWidgets.QHBoxLayout() + top_row.addStretch(1) top_row.addWidget(QtWidgets.QLabel(t("Top"))) top_row.addWidget(self.widgets.crop.top) - bottom_row = QtWidgets.QHBoxLayout() - bottom_row.addWidget(QtWidgets.QLabel(t("Bottom"))) - bottom_row.addWidget(self.widgets.crop.bottom) - col2.addLayout(top_row) - col2.addLayout(bottom_row) - col2.addStretch(1) + top_row.addStretch(1) + crop_layout.addLayout(top_row, 0, 1, 1, 2) - # Column 3: Left and Right - col3 = QtWidgets.QVBoxLayout() - col3.setSpacing(scaler.scale(12)) + # Row 1, Col 1: Left left_row = QtWidgets.QHBoxLayout() left_row.addWidget(QtWidgets.QLabel(t("Left"))) left_row.addWidget(self.widgets.crop.left) + crop_layout.addLayout(left_row, 1, 1) + + # Row 1, Col 2: Right right_row = QtWidgets.QHBoxLayout() right_row.addWidget(QtWidgets.QLabel(t("Right"))) right_row.addWidget(self.widgets.crop.right) - col3.addLayout(left_row) - col3.addLayout(right_row) - col3.addStretch(1) + crop_layout.addLayout(right_row, 1, 2) + + # Row 2, Cols 1-2: Bottom (centered) + bottom_row = QtWidgets.QHBoxLayout() + bottom_row.addStretch(1) + bottom_row.addWidget(QtWidgets.QLabel(t("Bottom"))) + bottom_row.addWidget(self.widgets.crop.bottom) + bottom_row.addStretch(1) + crop_layout.addLayout(bottom_row, 2, 1, 1, 2) - crop_layout.addLayout(col1) - crop_layout.addLayout(col2) - crop_layout.addLayout(col3) - crop_layout.addStretch(1) + crop_layout.setColumnStretch(3, 1) tabs.addTab(crop_tab, t("Crop")) @@ -2627,12 +2624,14 @@ def conversion_complete(self, success: bool): if not success: self.encoding_status_signal.emit(t("Encoding error"), STATE_ERROR) - if not self.app.fastflix.config.disable_complete_message: + 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) diff --git a/fastflix/widgets/panels/data_panel.py b/fastflix/widgets/panels/data_panel.py index 4d304460..364738e5 100644 --- a/fastflix/widgets/panels/data_panel.py +++ b/fastflix/widgets/panels/data_panel.py @@ -192,6 +192,16 @@ def select_all(self, select=True): if track.widgets.enable_check.isEnabled(): track.widgets.enable_check.setChecked(select) + @staticmethod + def _get_name_tags(stream): + """Return a display string for any tags containing 'name' in their key.""" + tags = stream.get("tags", {}) + parts = [] + for key, value in tags.items(): + if "name" in key.lower() and value: + parts.append(f"{key}: {value}") + return ", ".join(parts) + def _is_cover_attachment(self, stream): """Check if an attachment stream is a cover image (handled by cover panel).""" filename = stream.get("tags", {}).get("filename", "") @@ -211,10 +221,16 @@ def new_source(self): for stream in getattr(video.streams, "data", []): codec_name = stream.get("codec_name", "") codec_long_name = stream.get("codec_long_name", codec_name) + codec_tag_string = stream.get("codec_tag_string", "") title = stream.get("tags", {}).get("title", "") friendly = codec_long_name or codec_name + if codec_tag_string: + friendly = f"{friendly} [{codec_tag_string}]" if title: friendly = f"{title} ({friendly})" + name_tags = self._get_name_tags(stream) + if name_tags: + friendly = f"{friendly} - {name_tags}" video.data_tracks.append( DataTrack( @@ -234,11 +250,17 @@ def new_source(self): if self._is_cover_attachment(stream): continue codec_name = stream.get("codec_name", "") + codec_tag_string = stream.get("codec_tag_string", "") filename = stream.get("tags", {}).get("filename", "") mimetype = stream.get("tags", {}).get("mimetype", "") friendly = filename if filename else codec_name + if codec_tag_string: + friendly = f"{friendly} [{codec_tag_string}]" if mimetype: friendly = f"{friendly} ({mimetype})" + name_tags = self._get_name_tags(stream) + if name_tags: + friendly = f"{friendly} - {name_tags}" video.data_tracks.append( DataTrack( diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 60d0ead0..f04e5f44 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -179,10 +179,14 @@ def _build_settings_tab(self): layout.addWidget(self.disable_version_check, row, 0, 1, 2) row += 1 - self.disable_end_message = QtWidgets.QCheckBox(t("Disable completion and error messages")) - if self.app.fastflix.config.disable_complete_message: - self.disable_end_message.setChecked(True) - layout.addWidget(self.disable_end_message, row, 0, 1, 2) + self.show_complete_message = QtWidgets.QCheckBox(t("Show completion popup message")) + self.show_complete_message.setChecked(self.app.fastflix.config.show_complete_message) + layout.addWidget(self.show_complete_message, row, 0, 1, 2) + row += 1 + + self.show_error_message = QtWidgets.QCheckBox(t("Show error popup message")) + self.show_error_message.setChecked(self.app.fastflix.config.show_error_message) + layout.addWidget(self.show_error_message, row, 0, 1, 2) row += 1 self.clean_old_logs_button = QtWidgets.QCheckBox( @@ -682,7 +686,8 @@ def save(self): self.app.fastflix.config.clean_old_logs = self.clean_old_logs_button.isChecked() self.app.fastflix.config.sticky_tabs = self.sticky_tabs.isChecked() - self.app.fastflix.config.disable_complete_message = self.disable_end_message.isChecked() + 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.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/pyproject.toml b/pyproject.toml index a3225524..03e6d3ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "psutil>=5.9,<6.0", "pydantic>=2.0,<3.0", "pyside6==6.10.1", - "python-box[all]>=6.0,<7.0", + "python-box[all]>=7.4,<8.0", "requests>=2.28,<3.0", "setuptools>=75.8", "wmi>=1.5.1; sys_platform == 'win32'", diff --git a/tests/encoders/test_encc_helpers.py b/tests/encoders/test_encc_helpers.py index 10eb926a..47464bba 100644 --- a/tests/encoders/test_encc_helpers.py +++ b/tests/encoders/test_encc_helpers.py @@ -7,6 +7,8 @@ audio_quality_converter, rigaya_avformat_reader, rigaya_auto_options, + rigaya_trim_or_seek, + _parse_frame_rate, pa_builder, get_stream_pos, build_audio, @@ -517,3 +519,216 @@ def test_build_subtitle_with_4k_scaling(sample_subtitle_tracks): # Check that the burn-in track includes scale parameter assert "--vpp-subburn" in result and "track=1,scale=2.0" in result + + +# --- _parse_frame_rate tests --- + + +def test_parse_frame_rate_rational(): + """Test parsing a rational frame rate string like '24000/1001'.""" + result = _parse_frame_rate("24000/1001") + assert result == pytest.approx(23.976, rel=1e-3) + + +def test_parse_frame_rate_integer_string(): + """Test parsing a plain integer frame rate string.""" + result = _parse_frame_rate("30") + assert result == 30.0 + + +def test_parse_frame_rate_float_string(): + """Test parsing a plain float frame rate string.""" + result = _parse_frame_rate("29.97") + assert result == pytest.approx(29.97) + + +def test_parse_frame_rate_empty(): + """Test parsing an empty string returns None.""" + assert _parse_frame_rate("") is None + + +def test_parse_frame_rate_invalid(): + """Test parsing an invalid string returns None.""" + assert _parse_frame_rate("abc") is None + + +def test_parse_frame_rate_zero_denominator(): + """Test parsing a rational with zero denominator returns None.""" + assert _parse_frame_rate("24000/0") is None + + +# --- rigaya_trim_or_seek tests --- + + +def test_rigaya_trim_or_seek_no_times(encc_fastflix_instance): + """Test that no arguments are returned when no start/end time is set.""" + video = encc_fastflix_instance.current_video + video.video_settings.start_time = 0 + video.video_settings.end_time = 0 + result = rigaya_trim_or_seek(video) + assert result == [] + + +def test_rigaya_trim_or_seek_fast_mode(encc_fastflix_instance): + """Test fast mode (fast_seek=True) uses --seek and --seekto.""" + video = encc_fastflix_instance.current_video + video.video_settings.fast_seek = True + video.video_settings.start_time = 10.5 + video.video_settings.end_time = 120.0 + result = rigaya_trim_or_seek(video) + assert "--seek" in result + assert "10.5" in result + assert "--seekto" in result + assert "120.0" in result + + +def test_rigaya_trim_or_seek_fast_mode_start_only(encc_fastflix_instance): + """Test fast mode with only start_time set.""" + video = encc_fastflix_instance.current_video + video.video_settings.fast_seek = True + video.video_settings.start_time = 5.0 + video.video_settings.end_time = 0 + result = rigaya_trim_or_seek(video) + assert result == ["--seek", "5.0"] + assert "--seekto" not in result + + +def test_rigaya_trim_or_seek_fast_mode_end_only(encc_fastflix_instance): + """Test fast mode with only end_time set.""" + video = encc_fastflix_instance.current_video + video.video_settings.fast_seek = True + video.video_settings.start_time = 0 + video.video_settings.end_time = 30.0 + result = rigaya_trim_or_seek(video) + assert "--seek" not in result + assert result == ["--seekto", "30.0"] + + +def test_rigaya_trim_or_seek_exact_mode_with_frame_rate(encc_fastflix_instance): + """Test exact mode (fast_seek=False) with a parseable frame rate uses --trim.""" + video = encc_fastflix_instance.current_video + video.video_settings.fast_seek = False + video.video_settings.start_time = 10.0 + video.video_settings.end_time = 20.0 + # Set a known frame rate on the stream + video.streams = Box( + { + "video": [ + Box( + { + "index": 0, + "codec_name": "hevc", + "codec_type": "video", + "pix_fmt": "yuv420p10le", + "bit_depth": 10, + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "width": 3840, + "height": 2160, + } + ) + ], + "audio": [], + "subtitle": [], + } + ) + result = rigaya_trim_or_seek(video) + assert result[0] == "--trim" + # 10.0 * (24000/1001) ≈ 239.76 → int = 239 + # 20.0 * (24000/1001) ≈ 479.52 → int = 479 + assert result[1] == "239:479" + + +def test_rigaya_trim_or_seek_exact_mode_start_only(encc_fastflix_instance): + """Test exact mode with only start_time uses --trim from start_frame to end of video.""" + video = encc_fastflix_instance.current_video + video.video_settings.fast_seek = False + video.video_settings.start_time = 10.0 + video.video_settings.end_time = 0 + video.duration = 60 + video.streams = Box( + { + "video": [ + Box( + { + "index": 0, + "codec_name": "hevc", + "codec_type": "video", + "pix_fmt": "yuv420p10le", + "bit_depth": 10, + "r_frame_rate": "30", + "avg_frame_rate": "30", + "width": 3840, + "height": 2160, + } + ) + ], + "audio": [], + "subtitle": [], + } + ) + result = rigaya_trim_or_seek(video) + assert result == ["--trim", "300:1800"] + + +def test_rigaya_trim_or_seek_exact_mode_end_only(encc_fastflix_instance): + """Test exact mode with only end_time uses --trim from frame 0.""" + video = encc_fastflix_instance.current_video + video.video_settings.fast_seek = False + video.video_settings.start_time = 0 + video.video_settings.end_time = 20.0 + video.streams = Box( + { + "video": [ + Box( + { + "index": 0, + "codec_name": "hevc", + "codec_type": "video", + "pix_fmt": "yuv420p10le", + "bit_depth": 10, + "r_frame_rate": "30", + "avg_frame_rate": "30", + "width": 3840, + "height": 2160, + } + ) + ], + "audio": [], + "subtitle": [], + } + ) + result = rigaya_trim_or_seek(video) + assert result == ["--trim", "0:600"] + + +def test_rigaya_trim_or_seek_exact_mode_no_frame_rate_fallback(encc_fastflix_instance): + """Test exact mode without frame rate falls back to --seek/--seekto.""" + video = encc_fastflix_instance.current_video + video.video_settings.fast_seek = False + video.video_settings.start_time = 10.0 + video.video_settings.end_time = 20.0 + # Stream without r_frame_rate + video.streams = Box( + { + "video": [ + Box( + { + "index": 0, + "codec_name": "hevc", + "codec_type": "video", + "pix_fmt": "yuv420p10le", + "bit_depth": 10, + "width": 3840, + "height": 2160, + } + ) + ], + "audio": [], + "subtitle": [], + } + ) + result = rigaya_trim_or_seek(video) + assert "--seek" in result + assert "--seekto" in result + assert "--trim" not in result diff --git a/tests/encoders/test_nvencc_hevc_command_builder.py b/tests/encoders/test_nvencc_hevc_command_builder.py index 5750e08a..514d532d 100644 --- a/tests/encoders/test_nvencc_hevc_command_builder.py +++ b/tests/encoders/test_nvencc_hevc_command_builder.py @@ -162,6 +162,57 @@ def test_nvencc_hevc_with_crop_scale(): assert "1280x720" in cmd +def test_nvencc_hevc_exact_mode_uses_trim(): + """Test that exact mode (fast_seek=False) uses --trim with frame numbers.""" + fastflix = _make_fastflix( + encoder_settings=NVEncCSettings(bitrate=None, cqp=20), + video_settings=VideoSettings( + start_time=10.0, + end_time=20.0, + fast_seek=False, + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--trim" in cmd + # 10.0 * (24000/1001) ≈ 239.76 → 239, 20.0 * (24000/1001) ≈ 479.52 → 479 + assert "239:479" in cmd + assert "--seek" not in cmd + assert "--seekto" not in cmd + + +def test_nvencc_hevc_fast_mode_uses_seek(): + """Test that fast mode (fast_seek=True) still uses --seek/--seekto.""" + fastflix = _make_fastflix( + encoder_settings=NVEncCSettings(bitrate=None, cqp=20), + video_settings=VideoSettings( + start_time=10.0, + end_time=20.0, + fast_seek=True, + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--seek" in cmd + assert "--seekto" in cmd + assert "--trim" not in cmd + + def test_nvencc_hevc_all_elements_are_strings(): """Comprehensive test: build with many options and verify all elements are strings. diff --git a/tests/encoders/test_vceencc_hevc_command_builder.py b/tests/encoders/test_vceencc_hevc_command_builder.py index 8b24ae36..491f4347 100644 --- a/tests/encoders/test_vceencc_hevc_command_builder.py +++ b/tests/encoders/test_vceencc_hevc_command_builder.py @@ -137,6 +137,57 @@ def test_vceencc_hevc_with_bitrate(): assert "--cqp" not in cmd +def test_vceencc_hevc_exact_mode_uses_trim(): + """Test that exact mode (fast_seek=False) uses --trim with frame numbers.""" + fastflix = _make_fastflix( + encoder_settings=VCEEncCSettings(bitrate=None, cqp=20), + video_settings=VideoSettings( + start_time=10.0, + end_time=20.0, + fast_seek=False, + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--trim" in cmd + # 10.0 * (24000/1001) ≈ 239.76 → 239, 20.0 * (24000/1001) ≈ 479.52 → 479 + assert "239:479" in cmd + assert "--seek" not in cmd + assert "--seekto" not in cmd + + +def test_vceencc_hevc_fast_mode_uses_seek(): + """Test that fast mode (fast_seek=True) still uses --seek/--seekto.""" + fastflix = _make_fastflix( + encoder_settings=VCEEncCSettings(bitrate=None, cqp=20), + video_settings=VideoSettings( + start_time=10.0, + end_time=20.0, + fast_seek=True, + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + + with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--seek" in cmd + assert "--seekto" in cmd + assert "--trim" not in cmd + + def test_vceencc_hevc_cqp_float_coercion(): """Test that CQP as float (e.g., from Qt spinbox) is handled properly. diff --git a/uv.lock b/uv.lock index 75528d3f..905e7d92 100644 --- a/uv.lock +++ b/uv.lock @@ -249,7 +249,7 @@ requires-dist = [ { name = "pyside6", specifier = "==6.10.1" }, { name = "pysrt", specifier = ">=1.1.0" }, { name = "pytesseract", specifier = ">=0.3.0" }, - { name = "python-box", extras = ["all"], specifier = ">=6.0,<7.0" }, + { name = "python-box", extras = ["all"], specifier = ">=7.4,<8.0" }, { name = "requests", specifier = ">=2.28,<3.0" }, { name = "reusables", specifier = ">=1.0.0" }, { name = "setuptools", specifier = ">=75.8" }, @@ -886,11 +886,16 @@ wheels = [ [[package]] name = "python-box" -version = "6.1.0" +version = "7.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/85/b02b80d74bdb95bfe491d49ad1627e9833c73d331edbe6eed0bdfe170361/python-box-6.1.0.tar.gz", hash = "sha256:6e7c243b356cb36e2c0f0e5ed7850969fede6aa812a7f501de7768996c7744d7", size = 41443, upload-time = "2022-10-29T22:30:45.515Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/0f/34e7ee0a72f1464b4c7a2e8bafb389f230477256af586bc82bcfad85295a/python_box-7.4.1.tar.gz", hash = "sha256:e412e36c25fca8223560516d53ef6c7993591c3b0ec8bb4ec582bf7defdd79f0", size = 49859, upload-time = "2026-02-21T16:21:16.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/c6/6d1e368710cb6c458ed692d179d7e101ebce80a3e640b2e74cc7ae886d6f/python_box-6.1.0-py3-none-any.whl", hash = "sha256:bdec0a5f5a17b01fc538d292602a077aa8c641fb121e1900dff0591791af80e8", size = 27277, upload-time = "2022-10-29T22:30:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e9/48d1b1eb21efc3f82a31b037b6903c9139018f686d96d251faa4cb0d593a/python_box-7.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:85db37b43094bf6c4884b931fb149a7850db5ce331f6e191edf98b453e6cf2d6", size = 1845195, upload-time = "2026-02-21T16:21:46.235Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/48d38c855f277223caf3aa79518476f95abc07f04386940855b7bd3d95f6/python_box-7.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb204822c7638bd2dbed5c55d6ab264c6903c37d18dee5c45bdbda58b2e1e17a", size = 4468245, upload-time = "2026-02-21T16:26:05.701Z" }, + { url = "https://files.pythonhosted.org/packages/17/1d/7a1e04f37674399e0f3076cfe1fa358f6a51540ae98299a06f2c0424c471/python_box-7.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:615da3fafd41572aec1b905832555c0ea08b6fbc27cc917356e257a9a5721af7", size = 1295564, upload-time = "2026-02-21T16:22:36.547Z" }, + { url = "https://files.pythonhosted.org/packages/94/a2/771b5e526bba2214ac2d30e321209a66680c40788616a45cf01005e95204/python_box-7.4.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:33c6701faa51fd87f0dcc538873c0fad2b3a1cc3750eab85835cd071cadf1948", size = 1875508, upload-time = "2026-02-21T16:21:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5f/0e7ea7640ba60ff459ce37e340d816ac5e91b7a9a7c3c161f9dabe622be6/python_box-7.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:ae8c540a0457f52350211d24690211251912018e1e0c1857f50792729d6f562c", size = 1314304, upload-time = "2026-02-21T16:22:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/06/a6/5d3f3abf46b37aa44b1f6788d287c8b4f2319b55013191dddf25b9e6d62c/python_box-7.4.1-py3-none-any.whl", hash = "sha256:a3b0d84d003882fb6abe505b1b883b3a5dcbf226b0fe168d24bc5ff75d9826e5", size = 30402, upload-time = "2026-02-21T16:21:14.78Z" }, ] [package.optional-dependencies]