diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..fbe8ed07 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,119 @@ +{ + "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:*)", + "Bash(git:*)", + "WebFetch(domain:docs.nvidia.com)" + ] + } +} diff --git a/.claude/skills/changelog.md b/.claude/skills/changelog.md new file mode 100644 index 00000000..b518092a --- /dev/null +++ b/.claude/skills/changelog.md @@ -0,0 +1,57 @@ +--- +name: changelog +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 + +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 54038609..a32f16b9 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,33 @@ # Changelog +## 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) +* 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 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 +* 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 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 * 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/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..1066d43d 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 @@ -86,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 @@ -115,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, @@ -171,10 +179,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 } @@ -183,6 +204,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, @@ -347,6 +420,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 @@ -354,6 +443,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), ] @@ -366,6 +456,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/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 cab4c78f..b9d93652 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -14157,3 +14157,742 @@ 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ă +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 +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). +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? +"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/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/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..c3bf2439 --- /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(), 3, 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/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/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/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/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..12e185d3 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() @@ -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/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]) diff --git a/fastflix/ff_queue.py b/fastflix/ff_queue.py index 047c944f..027f4a1b 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 @@ -316,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/flix.py b/fastflix/flix.py index fb3b0a18..1c6dd41a 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") @@ -532,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/config.py b/fastflix/models/config.py index b071388b..e9081662 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 @@ -306,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/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/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/models/history.py b/fastflix/models/history.py new file mode 100644 index 00000000..3d8c95a9 --- /dev/null +++ b/fastflix/models/history.py @@ -0,0 +1,145 @@ +#!/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) + 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): + 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/models/profiles.py b/fastflix/models/profiles.py index a30bd2c9..60c76fb1 100644 --- a/fastflix/models/profiles.py +++ b/fastflix/models/profiles.py @@ -12,6 +12,7 @@ GIFSettings, GifskiSettings, FFmpegNVENCSettings, + FFmpegAV1NVENCSettings, SVTAV1Settings, VP9Settings, WebPSettings, @@ -158,6 +159,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 @@ -188,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 10d3c88c..08af2242 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -16,6 +16,7 @@ GIFSettings, GifskiSettings, FFmpegNVENCSettings, + FFmpegAV1NVENCSettings, SubtitleTrack, SVTAV1Settings, VP9Settings, @@ -46,6 +47,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 +65,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 @@ -128,6 +135,7 @@ class VideoSettings(BaseModel): WebPSettings, CopySettings, FFmpegNVENCSettings, + FFmpegAV1NVENCSettings, QSVEncCSettings, QSVEncCAV1Settings, QSVEncCH264Settings, @@ -176,7 +184,6 @@ def saturation_to_str(cls, value): class Status(BaseModel): - success: bool = False error: bool = False complete: bool = False running: bool = False @@ -187,10 +194,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 @@ -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/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/version.py b/fastflix/version.py index 18dc9462..e69f10f6 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.2.0" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py index 22b8f1ca..0641ae5f 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): @@ -387,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: @@ -400,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)) @@ -496,3 +552,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/container.py b/fastflix/widgets/container.py index e1b74ec2..dc8a8e0e 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 @@ -312,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) @@ -346,6 +352,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 +425,70 @@ 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): + 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() + + 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 1a99f019..a0f55879 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,23 +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, - extract_attachments, 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, @@ -47,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 -from fastflix.widgets.status_bar import Task, STATE_ENCODING, STATE_ERROR, STATE_COMPLETE, STATE_IDLE +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 @@ -66,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"}, @@ -134,6 +120,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 @@ -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() @@ -170,6 +157,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 +174,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 +265,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 +423,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 +439,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 @@ -545,26 +536,13 @@ 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: self.reload_encoders() else: self.change_output_types() + self.container.rebuild_menu() self.page_update(build_thumbnail=True) def reload_encoders(self): @@ -572,6 +550,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, @@ -587,6 +566,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), ] @@ -625,8 +605,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")) @@ -1595,89 +1591,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 @@ -1877,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", "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(): @@ -1890,10 +1803,11 @@ 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(): - if name in {"preview", "convert_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(): @@ -1906,318 +1820,9 @@ 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): - 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) - 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] - 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.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("Extract covers"), extract_attachments), - 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.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() @@ -2451,8 +2056,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: @@ -2495,6 +2100,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 @@ -2514,145 +2121,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 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: @@ -2683,266 +2154,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 - 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) - - except Exception: - logger.exception("Post-encode processing failed (encode itself succeeded)") - - 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/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) 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/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/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index f5228079..2d7aadb7 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -269,11 +269,11 @@ 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() self.after_done_combo.addItem("None") + self.after_done_combo.addItem("exit") actions = set() if reusables.win_based: actions.update(done_actions["windows"].keys()) @@ -505,18 +505,14 @@ 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() 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 +590,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 f04e5f44..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) @@ -201,6 +202,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) @@ -216,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) @@ -434,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() @@ -606,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() @@ -689,9 +779,29 @@ 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() + 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 + 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: @@ -752,6 +862,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(): 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/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/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/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(), diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 00000000..7fe27dfb Binary files /dev/null and b/favicon.ico differ 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 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 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.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 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