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"