diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f26bea13..5a7afbca 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,7 +18,7 @@ jobs: path: ${{ env.pythonLocation }} key: ${{ env.pythonLocation }}-black - - run: pip install black==21.8b0 + - run: pip install black==22.3.0 - run: python -m black --check . test: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 370484cf..3a8ab472 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,15 +18,12 @@ repos: - id: check-toml - id: detect-private-key - id: end-of-file-fixer -# - repo: https://github.com/PyCQA/isort -# rev: master -# hooks: -# - id: isort - repo: https://github.com/psf/black - rev: 21.8b0 + rev: 22.3.0 hooks: - id: black # - repo: https://github.com/pre-commit/mirrors-mypy -# rev: 'v0.780' +# rev: 'v0.942' # hooks: # - id: mypy +# additional_dependencies: [types-pkg-resources, types-requests] diff --git a/CHANGES b/CHANGES index fcdc9e94..a0f8bb59 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,18 @@ # Changelog +## Version 4.9.0 + +* Adding #109 Support for AVC and HEVC QSV encoding with rigaya's QSVEncC (thanks to msaintauret) +* Adding #196 Support for AVC and HEVC Apple Videotoolbox encoder (thanks to Kay Singh) +* Adding #323 ignore errors options options for queue (thanks to Don Gafford) +* Adding #331 NVEncC API v10 Quality presets: P1 to P7 (thanks to Wontell) +* Fixing #321 dhdr10_opt not added for x265 commands (thanks to GizmoDudex) +* Fixing #327 FastFlix Duplicates encoding task and encodes same movie to infinity (thanks to Wontell) +* Fixing #324 NVEncC wrong Interlace Value set by FastFlix (thanks to Wontell) +* Fixing #278 FastFlix occasionally getting stuck on a single video in a queue (thanks to kamild1996) +* Fixing #330 "Remove Metadata" only removes video metadata for Rigaya's hardware encoders (thanks to wynterca) +* Fixing level was not being passed to hardware encoders + ## Version 4.8.1 * Fixing #315 HDR10 info not parsed from subsequent video tracks than the first, again (thanks to msaintauret) diff --git a/fastflix/application.py b/fastflix/application.py index 6decb7cb..2717b02c 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -60,14 +60,20 @@ def init_encoders(app: FastFlixApp, **_): from fastflix.encoders.svt_av1 import main as svt_av1_plugin from fastflix.encoders.vp9 import main as vp9_plugin from fastflix.encoders.webp import main as webp_plugin + from fastflix.encoders.qsvencc_hevc import main as qsvencc_plugin + from fastflix.encoders.qsvencc_avc import main as qsvencc_avc_plugin from fastflix.encoders.nvencc_hevc import main as nvencc_plugin from fastflix.encoders.nvencc_avc import main as nvencc_avc_plugin from fastflix.encoders.vceencc_hevc import main as vceencc_hevc_plugin from fastflix.encoders.vceencc_avc import main as vceencc_avc_plugin + from fastflix.encoders.hevc_videotoolbox import main as hevc_videotoolbox_plugin + from fastflix.encoders.h264_videotoolbox import main as h264_videotoolbox_plugin encoders = [ hevc_plugin, nvenc_plugin, + hevc_videotoolbox_plugin, + h264_videotoolbox_plugin, av1_plugin, rav1e_plugin, svt_av1_plugin, @@ -78,9 +84,13 @@ def init_encoders(app: FastFlixApp, **_): copy_plugin, ] + if app.fastflix.config.qsvencc: + encoders.insert(1, qsvencc_plugin) + encoders.insert(8, qsvencc_avc_plugin) + if app.fastflix.config.nvencc: encoders.insert(1, nvencc_plugin) - encoders.insert(7, nvencc_avc_plugin) + encoders.insert(8, nvencc_avc_plugin) if app.fastflix.config.vceencc: if reusables.win_based: diff --git a/fastflix/conversion_worker.py b/fastflix/conversion_worker.py index eeb77ba6..84730637 100644 --- a/fastflix/conversion_worker.py +++ b/fastflix/conversion_worker.py @@ -13,6 +13,7 @@ from fastflix.language import t from fastflix.shared import file_date from fastflix.models.video import Video +from fastflix.ff_queue import save_queue logger = logging.getLogger("fastflix-core") @@ -20,10 +21,6 @@ log_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "logs" after_done_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "after_done_logs" -queue_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "queue.yaml" -queue_lock_file = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "queue.lock" - - CONTINUOUS = 0x80000000 SYSTEM_REQUIRED = 0x00000001 @@ -53,91 +50,24 @@ def allow_sleep_mode(): logger.debug("System has been allowed to enter sleep mode again") -def get_next_video(queue_list, queue_lock) -> Optional[Video]: - with queue_lock: - logger.debug(f"Retrieving next video from {queue_list}") - for video in queue_list: - if ( - not video.status.complete - and not video.status.success - and not video.status.cancelled - and not video.status.error - and not video.status.running - ): - logger.debug(f"Next video is {video.uuid} - {video.status}") - return video.copy() - - -def set_status( - current_video: Video, - queue_list, - queue_lock, - complete=None, - success=None, - cancelled=None, - errored=None, - running=None, - next_command=False, - reset_commands=False, -): - if not current_video: - return - - with queue_lock: - for i, video in enumerate(queue_list): - if video.uuid == current_video.uuid: - video_pos = i - break - else: - logger.error(f"Can't find video {current_video.uuid} in queue to update its status: {queue_list}") - return - - video_copy = queue_list.pop(video_pos) - - if complete is not None: - video_copy.status.complete = complete - if cancelled is not None: - video_copy.status.cancelled = cancelled - if errored is not None: - video_copy.status.error = errored - if success is not None: - video_copy.status.success = success - if running is not None: - video_copy.status.running = running - - if complete or cancelled or errored or success: - video_copy.status.running = False - - if next_command: - video_copy.status.current_command += 1 - if reset_commands: - video_copy.status.current_command = 0 - - queue_list.insert(video_pos, video_copy) - - @reusables.log_exception(log="fastflix-core") -def queue_worker(gui_proc, worker_queue, status_queue, log_queue, queue_list, queue_lock: Lock): +def queue_worker(gui_proc, worker_queue, status_queue, log_queue): runner = BackgroundRunner(log_queue=log_queue) - - # Command looks like (video_uuid, command_uuid, command, work_dir) after_done_command = "" gui_died = False currently_encoding = False - paused = False - video: Optional[Video] = None + video_uuid = None + command_uuid = None + command = None + work_dir = None + log_name = "" def start_command(): nonlocal currently_encoding - log_queue.put( - f"CLEAR_WINDOW:{video.uuid}:{video.video_settings.conversion_commands[video.status.current_command].uuid}" - ) + log_queue.put(f"CLEAR_WINDOW:{video_uuid}:{command_uuid}") reusables.remove_file_handlers(logger) new_file_handler = reusables.get_file_handler( - log_path - / sanitize_filename( - f"flix_conversion_{video.video_settings.video_title or video.video_settings.output_path.stem}_{file_date()}.log" - ), + log_path / sanitize_filename(f"flix_conversion_{log_name}_{file_date()}.log"), level=logging.DEBUG, log_format="%(asctime)s - %(message)s", encoding="utf-8", @@ -146,67 +76,33 @@ def start_command(): prevent_sleep_mode() currently_encoding = True runner.start_exec( - video.video_settings.conversion_commands[video.status.current_command].command, - work_dir=str(video.work_path), + command, + work_dir=work_dir, ) - set_status(video, queue_list=queue_list, queue_lock=queue_lock, running=True) - status_queue.put(("queue",)) - - # status_queue.put(("running", commands_to_run[0][0], commands_to_run[0][1], runner.started_at.isoformat())) while True: if currently_encoding and not runner.is_alive(): reusables.remove_file_handlers(logger) + log_queue.put("STOP_TIMER") + allow_sleep_mode() + currently_encoding = False + if runner.error_detected: logger.info(t("Error detected while converting")) - # Stop working! - currently_encoding = False - set_status(video, queue_list=queue_list, queue_lock=queue_lock, errored=True) - status_queue.put(("error",)) - allow_sleep_mode() + status_queue.put(("error", video_uuid, command_uuid)) if gui_died: return continue - # Successfully encoded, do next one if it exists - # First check if the current video has more commands - video.status.current_command += 1 - log_queue.put("STOP_TIMER") - - if len(video.video_settings.conversion_commands) > video.status.current_command: - logger.debug("About to run next command for this video") - set_status(video, queue_list=queue_list, queue_lock=queue_lock, next_command=True) - status_queue.put(("queue",)) - start_command() - continue - else: - logger.debug(f"{video.uuid} has been completed") - set_status(video, queue_list=queue_list, queue_lock=queue_lock, next_command=True, complete=True) - status_queue.put(("queue",)) - video = None - - if paused: - currently_encoding = False - allow_sleep_mode() - logger.debug(t("Queue has been paused")) - continue - - if video := get_next_video(queue_list=queue_list, queue_lock=queue_lock): - start_command() - continue - else: - currently_encoding = False - allow_sleep_mode() - logger.info(t("all conversions complete")) - status_queue.put(("complete",)) - if after_done_command: - logger.info(f"{t('Running after done command:')} {after_done_command}") - try: - runner.start_exec(after_done_command, str(after_done_path)) - except Exception: - logger.exception("Error occurred while running after done command") - continue + status_queue.put(("complete", video_uuid, command_uuid)) + if after_done_command: + logger.info(f"{t('Running after done command:')} {after_done_command}") + try: + runner.start_exec(after_done_command, str(after_done_path)) + except Exception: + logger.exception("Error occurred while running after done command") + continue if gui_died: return @@ -218,7 +114,6 @@ def start_command(): else: logger.debug(t("Conversion worker shutting down")) return - try: request = worker_queue.get(block=True, timeout=0.05) except Empty: @@ -228,39 +123,19 @@ def start_command(): allow_sleep_mode() return else: - if request[0] == "add_items": - - # Request looks like (queue command, log_dir, (commands)) - log_path = Path(request[1]) - if not currently_encoding and not paused: - video = get_next_video(queue_list=queue_list, queue_lock=queue_lock) - if video: - start_command() + if request[0] == "execute": + _, video_uuid, command_uuid, command, work_dir, log_name = request + start_command() if request[0] == "cancel": logger.debug(t("Cancel has been requested, killing encoding")) runner.kill() - if video: - set_status(video, queue_list=queue_list, queue_lock=queue_lock, reset_commands=True, cancelled=True) currently_encoding = False allow_sleep_mode() - status_queue.put(("cancelled", video.uuid if video else "")) + status_queue.put(("cancelled", video_uuid, command_uuid)) log_queue.put("STOP_TIMER") video = None - if request[0] == "pause queue": - logger.debug(t("Command worker received request to pause encoding after the current item completes")) - paused = True - - if request[0] == "resume queue": - paused = False - logger.debug(t("Command worker received request to resume encoding")) - if not currently_encoding: - if not video: - video = get_next_video(queue_list=queue_list, queue_lock=queue_lock) - if video: - start_command() - if request[0] == "set after done": after_done_command = request[1] if after_done_command: @@ -274,13 +149,10 @@ def start_command(): runner.pause() except Exception: logger.exception("Could not pause command") - else: - status_queue.put(("paused encode",)) + if 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") - else: - status_queue.put(("resumed encode",)) diff --git a/fastflix/data/encoders/icon_h264_toolbox.png b/fastflix/data/encoders/icon_h264_toolbox.png new file mode 100644 index 00000000..f9fe57d1 Binary files /dev/null and b/fastflix/data/encoders/icon_h264_toolbox.png differ diff --git a/fastflix/data/encoders/icon_hevc_toolbox.png b/fastflix/data/encoders/icon_hevc_toolbox.png new file mode 100644 index 00000000..8545ce99 Binary files /dev/null and b/fastflix/data/encoders/icon_hevc_toolbox.png differ diff --git a/fastflix/data/encoders/icon_qsvencc.png b/fastflix/data/encoders/icon_qsvencc.png new file mode 100644 index 00000000..7dffe956 Binary files /dev/null and b/fastflix/data/encoders/icon_qsvencc.png differ diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 1e896838..799de8d7 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -6142,3 +6142,291 @@ Not supported by rigaya's hardware encoders: por: Não suportado pelos codificadores de hardware da rigaya swe: Stöds inte av Rigayas hårdvarukodare pol: Nieobsługiwane przez nadajniki sprzętowe firmy rigaya +AVC coding profile: + eng: AVC coding profile + deu: AVC-Codierungsprofil + fra: Profil de codage AVC + ita: Profilo di codifica AVC + spa: Perfil de codificación AVC + zho: AVC编码简介 + jpn: AVC符号化プロファイル + rus: Профиль кодирования AVC + por: Perfil de codificação AVC + swe: AVC-kodningsprofil + pol: Profil kodowania AVC +Allow Software Encoding: + eng: Allow Software Encoding + deu: Software-Kodierung zulassen + fra: Autoriser le codage logiciel + ita: Permettere la codifica del software + spa: Permitir la codificación del software + zho: 允许软件编码 + jpn: ソフトウェアエンコードを許可する + rus: Разрешить программное кодирование + por: Permitir a codificação de software + swe: Tillåt kodning av programvara + pol: Zezwalaj na kodowanie oprogramowania +Require Software Encoding: + eng: Require Software Encoding + deu: Software-Kodierung erforderlich + fra: Exiger le codage du logiciel + ita: Richiedere la codifica del software + spa: Requiere codificación de software + zho: 要求软件编码 + jpn: ソフトウェアエンコードを要求する + rus: Требуется программное кодирование + por: Exigir codificação de software + swe: Kräver kodning av programvara + pol: Wymagane kodowanie oprogramowania +Realtime Encoding: + eng: Realtime Encoding + deu: Kodierung in Echtzeit + fra: Encodage en temps réel + ita: Codifica in tempo reale + spa: Codificación en tiempo real + zho: 实时编码 + jpn: リアルタイムエンコード + rus: Кодирование в реальном времени + por: Codificação em tempo real + swe: Kodning i realtid + pol: Kodowanie w czasie rzeczywistym +Hint that encoding should happen in real-time if not faster: + eng: Hint that encoding should happen in real-time if not faster + deu: Hinweis, dass die Kodierung in Echtzeit oder sogar schneller erfolgen sollte + fra: Indiquez que l'encodage doit se faire en temps réel, sinon plus rapidement. + ita: 'Suggerimento: la codifica dovrebbe avvenire in tempo reale, se non più velocemente' + spa: Indicación de que la codificación debe realizarse en tiempo real, si no más rápido + zho: 提示编码应该实时发生,如果不是更快的话 + jpn: エンコードをリアルタイムで行うことを推奨します。 + rus: Намек на то, что кодирование должно происходить в реальном времени, если не быстрее + por: Dica de que a codificação deve acontecer em tempo real, se não mais rápido + swe: En antydan om att kodning bör ske i realtid, om inte snabbare. + pol: Wskazówka, że kodowanie powinno odbywać się w czasie rzeczywistym, jeśli nie szybciej +Frames Before: + eng: Frames Before + deu: Frames Vorher + fra: Cadres avant + ita: Cornici Prima + spa: Marcos Antes + zho: 之前的帧数 + jpn: フレーム数 前 + rus: Кадры до + por: Quadros antes + swe: Frames före + pol: Ramki Przed +Other frames will come before the frames in this session. This helps smooth concatenation issues.: + eng: Other frames will come before the frames in this session. This helps smooth concatenation issues. + deu: Andere Rahmen werden vor den Rahmen in dieser Sitzung angezeigt. Dies hilft, Verkettungsprobleme zu vermeiden. + fra: Les autres cadres viendront avant les cadres de cette session. Cela permet d'atténuer les problèmes de concaténation. + ita: Gli altri fotogrammi verranno prima dei fotogrammi di questa sessione. Questo aiuta a smussare i problemi di concatenazione. + spa: Los demás fotogramas vendrán antes que los fotogramas de esta sesión. Esto ayuda a suavizar los problemas de concatenación. + zho: 其他的帧会在这个会话中的帧之前出现。这有助于平滑串联问题。 + jpn: 他のフレームは、このセッションのフレームより前に来ます。これは、連結の問題をスムーズにするのに役立ちます。 + rus: Другие кадры будут идти перед кадрами в этой сессии. Это помогает сгладить проблемы конкатенации. + por: Outros quadros virão antes dos quadros nesta sessão. Isto ajuda a suavizar as questões de concatenação. + swe: Andra ramar kommer att komma före ramarna under denna session. Detta underlättar problem med sammanlänkning. + pol: Inne ramki będą się pojawiać przed ramkami w tej sesji. Ułatwia to rozwiązywanie problemów związanych z konkatenacją. +Frames After: + eng: Frames After + deu: Frames nach + fra: Cadres après + ita: Fotogrammi dopo + spa: Fotogramas después + zho: 之后的帧数 + jpn: フレーム数 + rus: Кадры после + por: Quadros após + swe: Bilder efter + pol: Ramki Po +Other frames will come after the frames in this session. This helps smooth concatenation issues.: + eng: Other frames will come after the frames in this session. This helps smooth concatenation issues. + deu: Andere Frames kommen nach den Frames in dieser Sitzung. Dies hilft, Verkettungsprobleme zu vermeiden. + fra: Les autres cadres viendront après les cadres de cette session. Cela permet d'atténuer les problèmes de concaténation. + ita: Gli altri fotogrammi verranno dopo i fotogrammi di questa sessione. Questo aiuta a smussare i problemi di concatenazione. + spa: Otros marcos vendrán después de los marcos de esta sesión. Esto ayuda a suavizar los problemas de concatenación. + zho: 其他的帧将在这个会话中的帧之后出现。这有助于平滑连接问题。 + jpn: 他のフレームは、このセッションのフレームの後に来ます。これは、連結の問題をスムーズにするのに役立ちます。 + rus: Другие кадры будут идти после кадров этой сессии. Это помогает сгладить проблемы с конкатенацией. + por: Outros quadros virão após os quadros nesta sessão. Isto ajuda a suavizar as questões de concatenação. + swe: Andra ramar kommer att följa efter ramarna i denna session. Detta bidrar till att underlätta problem med sammanlänkningar. + pol: Inne ramki będą pojawiać się po ramkach z tej sesji. Ułatwia to rozwiązywanie problemów związanych z konkatenacją. +HEVC coding profile - must match bit depth: + eng: HEVC coding profile - must match bit depth + deu: HEVC-Codierungsprofil - muss der Bittiefe entsprechen + fra: Profil de codage HEVC - doit correspondre à la profondeur de bit + ita: Profilo di codifica HEVC - deve corrispondere alla profondità di bit + spa: 'Perfil de codificación HEVC: debe coincidir con la profundidad de bits' + zho: HEVC编码配置文件 - 必须与比特深度相匹配 + jpn: HEVCコーディングプロファイル - ビット深度との一致が必要 + rus: Профиль кодирования HEVC - должен соответствовать битовой глубине + por: Perfil de codificação HEVC - deve corresponder à profundidade do bit + swe: HEVC-kodningsprofil - måste matcha bitdjupet + pol: Profil kodowania HEVC - musi być zgodny z głębią bitową +Ignore Errors: + eng: Ignore Errors + deu: Fehler ignorieren + fra: Ignorer les erreurs + ita: Ignorare gli errori + spa: Ignorar errores + zho: 忽略错误 + jpn: エラーを無視する + rus: Игнорировать ошибки + por: Ignorar erros + swe: Ignorera fel + pol: Ignoruj błędy +Custom QSVEncC options: + eng: Custom QSVEncC options + deu: Benutzerdefinierte QSVEncC-Optionen + fra: Options personnalisées de QSVEncC + ita: Opzioni QSVEncC personalizzate + spa: Opciones personalizadas de QSVEncC + zho: 定制QSVEncC选项 + jpn: QSVEncCのカスタムオプション + rus: Пользовательские опции QSVEncC + por: Opções personalizadas de QSVEncC + swe: Anpassade QSVEncC-alternativ + pol: Niestandardowe opcje QSVEncC +QSVEncC Options: + eng: QSVEncC Options + deu: QSVEncC-Optionen + fra: Options QSVEncC + ita: Opzioni QSVEncC + spa: Opciones de QSVEncC + zho: QSVEncC选项 + jpn: QSVEncCオプション + rus: Опции QSVEncC + por: Opções QSVEncC + swe: QSVEncC-alternativ + pol: Opcje QSVEncC +QSVEncC Encoder support is still experimental!: + eng: QSVEncC Encoder support is still experimental! + deu: QSVEncC Encoder Unterstützung ist noch experimentell! + fra: Le support de l'encodeur QSVEncC est encore expérimental ! + ita: Il supporto di QSVEncC Encoder è ancora sperimentale! + spa: La compatibilidad con el codificador QSVEncC es todavía experimental. + zho: QSVEncC编码器的支持仍然是试验性的! + jpn: QSVEncC エンコーダのサポートはまだ実験的です。 + rus: Поддержка кодировщика QSVEncC все еще является экспериментальной! + por: O suporte ao codificador QSVEncC ainda é experimental! + swe: Stödet för QSVEncC Encoder är fortfarande experimentellt! + pol: Obsługa kodera QSVEncC jest wciąż eksperymentalna! +Save Queue to File: + eng: Save Queue to File + deu: Warteschlange in Datei speichern + fra: Enregistrer la file d'attente dans un fichier + ita: Salvare la coda su file + spa: Guardar la cola en un archivo + zho: 保存队列到文件 + jpn: キューをファイルに保存する + rus: Сохранить очередь в файл + por: Salvar fila no arquivo + swe: Spara kö till en fil + pol: Zapisz kolejkę do pliku +Load Queue from File: + eng: Load Queue from File + deu: Warteschlange aus Datei laden + fra: Chargement de la file d'attente à partir d'un fichier + ita: Caricare la coda da file + spa: Cargar la cola desde un archivo + zho: 从文件中加载队列 + jpn: ファイルからキューを読み込む + rus: Загрузка очереди из файла + por: Fila de carga do arquivo + swe: Ladda kö från en fil + pol: Wczytaj kolejkę z pliku +This will remove all items in the queue currently: + eng: This will remove all items in the queue currently + deu: Damit werden alle Einträge in der Warteschlange entfernt. + fra: Cela supprimera tous les éléments de la file d'attente actuellement + ita: Questo rimuoverà tutti gli elementi nella coda attualmente + spa: Esto eliminará todos los elementos en la cola actualmente + zho: 这将删除当前队列中的所有项目。 + jpn: これにより、現在キューにあるすべてのアイテムが削除されます。 + rus: Это приведет к удалению всех элементов в очереди в настоящее время + por: Isto removerá todos os itens da fila atualmente + swe: Detta kommer att ta bort alla objekt i kön för närvarande + pol: Spowoduje to usunięcie wszystkich elementów znajdujących się aktualnie w kolejce. +It will update it with the contents of: + eng: It will update it with the contents of + deu: Er aktualisiert sie mit dem Inhalt von + fra: Il le mettra à jour avec le contenu de + ita: Lo aggiornerà con il contenuto di + spa: Lo actualizará con el contenido de + zho: 它将用以下内容更新它 + jpn: の内容で更新されます。 + rus: Он обновит его содержимым + por: Ele o atualizará com o conteúdo de + swe: Den kommer att uppdatera den med innehållet i + pol: Zostanie ona zaktualizowana o zawartość +Are you sure you want to proceed?: + eng: Are you sure you want to proceed? + deu: Sind Sie sicher, dass Sie fortfahren wollen? + fra: Vous êtes sûr de vouloir continuer ? + ita: Sei sicuro di voler procedere? + spa: ¿Estás seguro de que quieres continuar? + zho: 你确定你要继续吗? + jpn: 本当に進めていいんですか? + rus: Вы уверены, что хотите продолжить? + por: Você tem certeza de que quer prosseguir? + swe: Är du säker på att du vill fortsätta? + pol: Czy jesteś pewien, że chcesz kontynuować? +Overwrite existing queue?: + eng: Overwrite existing queue? + deu: Vorhandene Warteschlange überschreiben? + fra: Écraser la file d'attente existante ? + ita: Sovrascrivere la coda esistente? + spa: ¿Sobreescribir la cola existente? + zho: 覆盖现有队列? + jpn: 既存のキューを上書きしますか? + rus: Перезаписать существующую очередь? + por: Sobregravar a fila existente? + swe: Skriva över befintlig kö? + pol: Nadpisać istniejącą kolejkę? +Save Queue: + eng: Save Queue + deu: Warteschlange speichern + fra: Sauvegarder la file d'attente + ita: Salvare la coda + spa: Guardar cola + zho: 保存队列 + jpn: キューを保存する + rus: Сохранить очередь + por: Salvar fila + swe: Spara kö + pol: Zapisz kolejkę +Load Queue: + eng: Load Queue + deu: Warteschlange laden + fra: File d'attente de chargement + ita: Coda di carico + spa: Cola de carga + zho: 加载队列 + jpn: ロードキュー + rus: Очередь загрузки + por: Fila de carga + swe: Belastningskö + pol: Kolejka ładunkowa +Queue saved to: + eng: Queue saved to + deu: Warteschlange gespeichert in + fra: File d'attente enregistrée dans + ita: Coda salvata in + spa: Cola guardada en + zho: 队列保存到 + jpn: に保存されたキュー + rus: Очередь сохранена в + por: Fila salva para + swe: Kö sparad till + pol: Kolejka zapisana do +That file doesn't exist: + eng: That file doesn't exist + deu: Diese Datei existiert nicht + fra: Ce fichier n'existe pas + ita: Quel file non esiste + spa: Ese archivo no existe + zho: 该文件不存在 + jpn: そのファイルは存在しません + rus: Этот файл не существует + por: Esse arquivo não existe + swe: Den filen finns inte + pol: Ten plik nie istnieje diff --git a/fastflix/encoders/common/setting_panel.py b/fastflix/encoders/common/setting_panel.py index a95b31cc..92fd74b4 100644 --- a/fastflix/encoders/common/setting_panel.py +++ b/fastflix/encoders/common/setting_panel.py @@ -79,6 +79,7 @@ def _add_combo_box( default=0, tooltip="", min_width=None, + width=None, ): layout = QtWidgets.QHBoxLayout() if label: @@ -90,6 +91,8 @@ def _add_combo_box( self.widgets[widget_name].addItems(options) if min_width: self.widgets[widget_name].setMinimumWidth(min_width) + if width: + self.widgets[widget_name].setFixedWidth(width) if opt: default = self.determine_default( @@ -98,7 +101,7 @@ def _add_combo_box( self.opts[widget_name] = opt self.widgets[widget_name].setCurrentIndex(default or 0) self.widgets[widget_name].setDisabled(not enabled) - new_width = self.widgets[widget_name].minimumSizeHint().width() + 50 + new_width = self.widgets[widget_name].minimumSizeHint().width() + 20 if new_width > self.widgets[widget_name].view().width(): self.widgets[widget_name].view().setFixedWidth(new_width) if tooltip: @@ -272,13 +275,7 @@ def dhdr10_update(self): self.widgets.hdr10plus_metadata.setText(filename[0]) self.main.page_update() - def _add_modes( - self, - recommended_bitrates, - recommended_qps, - qp_name="crf", - add_qp=True, - ): + def _add_modes(self, recommended_bitrates, recommended_qps, qp_name="crf", add_qp=True, disable_custom_qp=False): self.recommended_bitrates = recommended_bitrates self.recommended_qps = recommended_qps self.qp_name = qp_name @@ -341,18 +338,20 @@ def _add_modes( try: default_qp_index = self.determine_default(qp_name, qp_value, recommended_qps, raise_error=True) except FastFlixInternalException: - custom_qp = True - self.widgets[qp_name].setCurrentText("Custom") + if not disable_custom_qp: + custom_qp = True + self.widgets[qp_name].setCurrentText("Custom") else: if default_qp_index is not None: self.widgets[qp_name].setCurrentIndex(default_qp_index) self.widgets[qp_name].currentIndexChanged.connect(lambda: self.mode_update()) - self.widgets[f"custom_{qp_name}"] = QtWidgets.QLineEdit("30" if not custom_qp else str(qp_value)) - self.widgets[f"custom_{qp_name}"].setFixedWidth(100) - self.widgets[f"custom_{qp_name}"].setEnabled(custom_qp) - self.widgets[f"custom_{qp_name}"].setValidator(self.only_float) - self.widgets[f"custom_{qp_name}"].textChanged.connect(lambda: self.main.build_commands()) + if not disable_custom_qp: + self.widgets[f"custom_{qp_name}"] = QtWidgets.QLineEdit("30" if not custom_qp else str(qp_value)) + self.widgets[f"custom_{qp_name}"].setFixedWidth(100) + self.widgets[f"custom_{qp_name}"].setEnabled(custom_qp) + self.widgets[f"custom_{qp_name}"].setValidator(self.only_float) + self.widgets[f"custom_{qp_name}"].textChanged.connect(lambda: self.main.build_commands()) if config_opt: self.mode = "Bitrate" @@ -362,8 +361,11 @@ def _add_modes( qp_box_layout.addWidget(self.widgets[qp_name], 1) qp_box_layout.addStretch(1) qp_box_layout.addStretch(1) - qp_box_layout.addWidget(QtWidgets.QLabel("Custom:")) - qp_box_layout.addWidget(self.widgets[f"custom_{qp_name}"]) + if disable_custom_qp: + qp_box_layout.addStretch(1) + else: + qp_box_layout.addWidget(QtWidgets.QLabel("Custom:")) + qp_box_layout.addWidget(self.widgets[f"custom_{qp_name}"]) qp_box_layout.addWidget(QtWidgets.QLabel(" ")) bitrate_group_box.setLayout(bitrate_box_layout) diff --git a/fastflix/encoders/h264_videotoolbox/__init__.py b/fastflix/encoders/h264_videotoolbox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/h264_videotoolbox/command_builder.py b/fastflix/encoders/h264_videotoolbox/command_builder.py new file mode 100644 index 00000000..01c96ff6 --- /dev/null +++ b/fastflix/encoders/h264_videotoolbox/command_builder.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +import secrets + +from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null +from fastflix.models.encode import HEVCVideoToolboxSettings +from fastflix.models.fastflix import FastFlix + + +def build(fastflix: FastFlix): + settings: HEVCVideoToolboxSettings = fastflix.current_video.video_settings.video_encoder_settings + beginning, ending = generate_all(fastflix, "h264_videotoolbox") + + beginning += generate_color_details(fastflix) + + def clean_bool(item): + return "true" if item else "false" + + details = ( + f"-profile:v {settings.profile} " + f"-allow_sw {clean_bool(settings.allow_sw)} " + f"-require_sw {clean_bool(settings.require_sw)} " + f"-realtime {clean_bool(settings.realtime)} " + f"-frames_before {clean_bool(settings.frames_before)} " + f"-frames_after {clean_bool(settings.frames_after)} " + ) + + if settings.bitrate: + pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" + beginning += f" " + + command_1 = f"{beginning} -b:v {settings.bitrate} {details} -pass 1 -passlogfile \"{pass_log_file}\" {settings.extra if settings.extra_both_passes else ''} -an -f mp4 {null}" + command_2 = f'{beginning} -b:v {settings.bitrate} {details} -pass 2 -passlogfile "{pass_log_file}" {settings.extra} {ending}' + return [ + Command(command=command_1, name=f"First pass bitrate", exe="ffmpeg"), + Command(command=command_2, name=f"Second pass bitrate", exe="ffmpeg"), + ] + command_1 = f"{beginning} -q:v {settings.q} {details} {settings.extra} {ending}" + + return [ + Command(command=command_1, name=f"Single pass constant quality", exe="ffmpeg"), + ] diff --git a/fastflix/encoders/h264_videotoolbox/main.py b/fastflix/encoders/h264_videotoolbox/main.py new file mode 100644 index 00000000..18a150fa --- /dev/null +++ b/fastflix/encoders/h264_videotoolbox/main.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +from pathlib import Path + +import pkg_resources + +name = "H264 (Video Toolbox)" +requires = "h264_videotoolbox" +icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_h264_toolbox.png")).resolve()) + + +video_extension = "mkv" +video_dimension_divisor = 2 + +enable_subtitles = False +enable_audio = True +enable_attachments = False +enable_concat = True + +from fastflix.encoders.h264_videotoolbox.command_builder import build +from fastflix.encoders.h264_videotoolbox.settings_panel import H264VideoToolbox as settings_panel diff --git a/fastflix/encoders/h264_videotoolbox/settings_panel.py b/fastflix/encoders/h264_videotoolbox/settings_panel.py new file mode 100644 index 00000000..0cf6321b --- /dev/null +++ b/fastflix/encoders/h264_videotoolbox/settings_panel.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +import logging + +from box import Box +from PySide2 import QtCore, QtWidgets + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.models.encode import H264VideoToolboxSettings +from fastflix.models.fastflix_app import FastFlixApp + + +logger = logging.getLogger("fastflix") + +recommended_bitrates = [ + "150k (320x240p @ 24,25,30)", + "276k (640x360p @ 24,25,30)", + "512k (640x480p @ 24,25,30)", + "1024k (1280x720p @ 24,25,30)", + "1800k (1280x720p @ 50,60)", + "1800k (1920x1080p @ 24,25,30)", + "3000k (1920x1080p @ 50,60)", + "6000k (2560x1440p @ 24,25,30)", + "9000k (2560x1440p @ 50,60)", + "12000k (3840x2160p @ 24,25,30)", + "18000k (3840x2160p @ 50,60)", + "Custom", +] + +recommended_crfs = [ + "37 (240p)", + "36 (360p)", + "33 (480p)", + "32 (720p)", + "31 (1080p)", + "24 (1440p)", + "15 (2160p)", + "Custom", +] + +pix_fmts = ["8-bit: yuv420p"] + + +class H264VideoToolbox(SettingPanel): + profile_name = "h264_videotoolbox" + + def __init__(self, parent, main, app: FastFlixApp): + super().__init__(parent, main, app) + self.main = main + self.app = app + + grid = QtWidgets.QGridLayout() + + self.widgets = Box(fps=None, mode=None) + + self.mode = "Q" + + grid.addLayout(self.init_pix_fmt(), 0, 0, 1, 2) + grid.addLayout(self.init_profile(), 1, 0, 1, 2) + grid.addLayout(self.init_allow_sw(), 2, 0, 1, 2) + grid.addLayout(self.init_require_sw(), 3, 0, 1, 2) + grid.addLayout(self.init_realtime(), 4, 0, 1, 2) + grid.addLayout(self.init_frames_before(), 5, 0, 1, 2) + grid.addLayout(self.init_frames_after(), 6, 0, 1, 2) + grid.addLayout(self.init_max_mux(), 7, 0, 1, 2) + + grid.addLayout(self.init_modes(), 0, 2, 5, 4) + + # grid.addWidget(QtWidgets.QWidget(), 8, 0) + grid.setRowStretch(8, 1) + grid.addLayout(self._add_custom(), 10, 0, 1, 6) + + # link_1 = link( + # "https://trac.ffmpeg.org/wiki/Encode/VP9", t("FFMPEG VP9 Encoding Guide"), app.fastflix.config.theme + # ) + # link_2 = link( + # "https://developers.google.com/media/vp9/hdr-encoding/", + # t("Google's VP9 HDR Encoding Guide"), + # app.fastflix.config.theme, + # ) + # + # guide_label = QtWidgets.QLabel(f"{link_1} | {link_2}") + # guide_label.setAlignment(QtCore.Qt.AlignBottom) + # guide_label.setOpenExternalLinks(True) + # grid.addWidget(guide_label, 11, 0, 1, 6) + self.setLayout(grid) + self.hide() + + def init_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_profile(self): + return self._add_combo_box( + label="Profile", + tooltip="AVC coding profile", + widget_name="profile", + options=[ + "Auto", + "baseline", + "main", + "high", + "extended", + ], + opt="profile", + ) + + def init_allow_sw(self): + return self._add_check_box( + label="Allow Software Encoding", + widget_name="allow_sw", + opt="allow_sw", + ) + + def init_require_sw(self): + return self._add_check_box( + label="Require Software Encoding", + widget_name="require_sw", + opt="require_sw", + ) + + def init_realtime(self): + return self._add_check_box( + label="Realtime Encoding", + tooltip="Hint that encoding should happen in real-time if not faster", + widget_name="realtime", + opt="realtime", + ) + + def init_frames_before(self): + return self._add_check_box( + label="Frames Before", + tooltip="Other frames will come before the frames in this session. This helps smooth concatenation issues.", + widget_name="frames_before", + opt="frames_before", + ) + + def init_frames_after(self): + return self._add_check_box( + label="Frames After", + tooltip="Other frames will come after the frames in this session. This helps smooth concatenation issues.", + widget_name="frames_after", + opt="frames_after", + ) + + def init_modes(self): + return self._add_modes( + recommended_bitrates, [str(x) for x in range(1, 101)], qp_name="q", disable_custom_qp=True + ) + + def mode_update(self): + self.widgets.custom_q.setDisabled(self.widgets.crf.currentText() != "Custom") + self.widgets.custom_bitrate.setDisabled(self.widgets.bitrate.currentText() != "Custom") + self.main.build_commands() + + def update_video_encoder_settings(self): + settings = H264VideoToolboxSettings( + pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(), + max_muxing_queue_size=self.widgets.max_mux.currentText(), + profile=self.widgets.profile.currentIndex(), + extra=self.ffmpeg_extras, + extra_both_passes=self.widgets.extra_both_passes.isChecked(), + allow_sw=self.widgets.allow_sw.isChecked(), + require_sw=self.widgets.require_sw.isChecked(), + realtime=self.widgets.realtime.isChecked(), + frames_before=self.widgets.frames_before.isChecked(), + frames_after=self.widgets.frames_after.isChecked(), + ) + encode_type, q_value = self.get_mode_settings() + settings.q = 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/hevc_videotoolbox/__init__.py b/fastflix/encoders/hevc_videotoolbox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/hevc_videotoolbox/command_builder.py b/fastflix/encoders/hevc_videotoolbox/command_builder.py new file mode 100644 index 00000000..63bd09df --- /dev/null +++ b/fastflix/encoders/hevc_videotoolbox/command_builder.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +import secrets + +from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null +from fastflix.models.encode import HEVCVideoToolboxSettings +from fastflix.models.fastflix import FastFlix + + +def build(fastflix: FastFlix): + settings: HEVCVideoToolboxSettings = fastflix.current_video.video_settings.video_encoder_settings + beginning, ending = generate_all(fastflix, "hevc_videotoolbox") + + beginning += generate_color_details(fastflix) + + def clean_bool(item): + return "true" if item else "false" + + details = ( + f"-profile:v {settings.profile} " + f"-allow_sw {clean_bool(settings.allow_sw)} " + f"-require_sw {clean_bool(settings.require_sw)} " + f"-realtime {clean_bool(settings.realtime)} " + f"-frames_before {clean_bool(settings.frames_before)} " + f"-frames_after {clean_bool(settings.frames_after)} " + ) + + if settings.bitrate: + pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" + beginning += f" " + + command_1 = f"{beginning} -b:v {settings.bitrate} {details} -pass 1 -passlogfile \"{pass_log_file}\" {settings.extra if settings.extra_both_passes else ''} -an -f mp4 {null}" + command_2 = f'{beginning} -b:v {settings.bitrate} {details} -pass 2 -passlogfile "{pass_log_file}" {settings.extra} {ending}' + return [ + Command(command=command_1, name=f"First pass bitrate", exe="ffmpeg"), + Command(command=command_2, name=f"Second pass bitrate", exe="ffmpeg"), + ] + command_1 = f"{beginning} -q:v {settings.q} {details} {settings.extra} {ending}" + + return [ + Command(command=command_1, name=f"Single pass constant quality", exe="ffmpeg"), + ] diff --git a/fastflix/encoders/hevc_videotoolbox/main.py b/fastflix/encoders/hevc_videotoolbox/main.py new file mode 100644 index 00000000..17c50060 --- /dev/null +++ b/fastflix/encoders/hevc_videotoolbox/main.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +from pathlib import Path + +import pkg_resources + +name = "HEVC (Video Toolbox)" +requires = "hevc_videotoolbox" +icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_hevc_toolbox.png")).resolve()) + + +video_extension = "mkv" +video_dimension_divisor = 2 + +enable_subtitles = False +enable_audio = True +enable_attachments = False +enable_concat = True + +from fastflix.encoders.hevc_videotoolbox.command_builder import build +from fastflix.encoders.hevc_videotoolbox.settings_panel import HEVCVideoToolbox as settings_panel diff --git a/fastflix/encoders/hevc_videotoolbox/settings_panel.py b/fastflix/encoders/hevc_videotoolbox/settings_panel.py new file mode 100644 index 00000000..9f56f61c --- /dev/null +++ b/fastflix/encoders/hevc_videotoolbox/settings_panel.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +import logging + +from box import Box +from PySide2 import QtCore, QtWidgets + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.language import t +from fastflix.models.encode import HEVCVideoToolboxSettings +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.shared import link + +logger = logging.getLogger("fastflix") + +recommended_bitrates = [ + "150k (320x240p @ 24,25,30)", + "276k (640x360p @ 24,25,30)", + "512k (640x480p @ 24,25,30)", + "1024k (1280x720p @ 24,25,30)", + "1800k (1280x720p @ 50,60)", + "1800k (1920x1080p @ 24,25,30)", + "3000k (1920x1080p @ 50,60)", + "6000k (2560x1440p @ 24,25,30)", + "9000k (2560x1440p @ 50,60)", + "12000k (3840x2160p @ 24,25,30)", + "18000k (3840x2160p @ 50,60)", + "Custom", +] + +recommended_crfs = [ + "37 (240p)", + "36 (360p)", + "33 (480p)", + "32 (720p)", + "31 (1080p)", + "24 (1440p)", + "15 (2160p)", + "Custom", +] + +pix_fmts = [ + "8-bit: yuv420p", + "10-bit: p010le", +] + + +class HEVCVideoToolbox(SettingPanel): + profile_name = "hevc_videotoolbox" + + def __init__(self, parent, main, app: FastFlixApp): + super().__init__(parent, main, app) + self.main = main + self.app = app + + grid = QtWidgets.QGridLayout() + + self.widgets = Box(fps=None, mode=None) + + self.mode = "Q" + + grid.addLayout(self.init_pix_fmt(), 0, 0, 1, 2) + grid.addLayout(self.init_profile(), 1, 0, 1, 2) + grid.addLayout(self.init_allow_sw(), 2, 0, 1, 2) + grid.addLayout(self.init_require_sw(), 3, 0, 1, 2) + grid.addLayout(self.init_realtime(), 4, 0, 1, 2) + grid.addLayout(self.init_frames_before(), 5, 0, 1, 2) + grid.addLayout(self.init_frames_after(), 6, 0, 1, 2) + grid.addLayout(self.init_max_mux(), 7, 0, 1, 2) + + grid.addLayout(self.init_modes(), 0, 2, 5, 4) + + # grid.addWidget(QtWidgets.QWidget(), 8, 0) + grid.setRowStretch(8, 1) + grid.addLayout(self._add_custom(), 10, 0, 1, 6) + + # link_1 = link( + # "https://trac.ffmpeg.org/wiki/Encode/VP9", t("FFMPEG VP9 Encoding Guide"), app.fastflix.config.theme + # ) + # link_2 = link( + # "https://developers.google.com/media/vp9/hdr-encoding/", + # t("Google's VP9 HDR Encoding Guide"), + # app.fastflix.config.theme, + # ) + # + # guide_label = QtWidgets.QLabel(f"{link_1} | {link_2}") + # guide_label.setAlignment(QtCore.Qt.AlignBottom) + # guide_label.setOpenExternalLinks(True) + # grid.addWidget(guide_label, 11, 0, 1, 6) + self.setLayout(grid) + self.hide() + + def init_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_profile(self): + return self._add_combo_box( + label="Profile", + tooltip="HEVC coding profile - must match bit depth", + widget_name="profile", + options=[ + "Auto", + "Main", + "Main10", + ], + opt="profile", + ) + + def init_allow_sw(self): + return self._add_check_box( + label="Allow Software Encoding", + widget_name="allow_sw", + opt="allow_sw", + ) + + def init_require_sw(self): + return self._add_check_box( + label="Require Software Encoding", + widget_name="require_sw", + opt="require_sw", + ) + + def init_realtime(self): + return self._add_check_box( + label="Realtime Encoding", + tooltip="Hint that encoding should happen in real-time if not faster", + widget_name="realtime", + opt="realtime", + ) + + def init_frames_before(self): + return self._add_check_box( + label="Frames Before", + tooltip="Other frames will come before the frames in this session. This helps smooth concatenation issues.", + widget_name="frames_before", + opt="frames_before", + ) + + def init_frames_after(self): + return self._add_check_box( + label="Frames After", + tooltip="Other frames will come after the frames in this session. This helps smooth concatenation issues.", + widget_name="frames_after", + opt="frames_after", + ) + + def init_modes(self): + return self._add_modes( + recommended_bitrates, [str(x) for x in range(1, 101)], qp_name="q", disable_custom_qp=True + ) + + def mode_update(self): + self.widgets.custom_q.setDisabled(self.widgets.crf.currentText() != "Custom") + self.widgets.custom_bitrate.setDisabled(self.widgets.bitrate.currentText() != "Custom") + self.main.build_commands() + + def update_video_encoder_settings(self): + settings = HEVCVideoToolboxSettings( + pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(), + max_muxing_queue_size=self.widgets.max_mux.currentText(), + profile=self.widgets.profile.currentIndex(), + extra=self.ffmpeg_extras, + extra_both_passes=self.widgets.extra_both_passes.isChecked(), + allow_sw=self.widgets.allow_sw.isChecked(), + require_sw=self.widgets.require_sw.isChecked(), + realtime=self.widgets.realtime.isChecked(), + frames_before=self.widgets.frames_before.isChecked(), + frames_after=self.widgets.frames_after.isChecked(), + ) + encode_type, q_value = self.get_mode_settings() + settings.q = 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/hevc_x265/command_builder.py b/fastflix/encoders/hevc_x265/command_builder.py index cee524e5..279b9061 100644 --- a/fastflix/encoders/hevc_x265/command_builder.py +++ b/fastflix/encoders/hevc_x265/command_builder.py @@ -149,6 +149,8 @@ def build(fastflix: FastFlix): if settings.hdr10plus_metadata: x265_params.append(f"dhdr10-info='{settings.hdr10plus_metadata}'") + if settings.dhdr10_opt: + x265_params.append(f"dhdr10_opt=1") if settings.intra_encoding: x265_params.append("keyint=1") diff --git a/fastflix/encoders/nvencc_avc/command_builder.py b/fastflix/encoders/nvencc_avc/command_builder.py index 47ea697e..2016398e 100644 --- a/fastflix/encoders/nvencc_avc/command_builder.py +++ b/fastflix/encoders/nvencc_avc/command_builder.py @@ -100,7 +100,11 @@ def build(fastflix: FastFlix): transform, (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), crop, - (f"--video-metadata clear" if video.video_settings.remove_metadata else "--video-metadata copy"), + ( + f"--video-metadata clear --metadata clear" + if video.video_settings.remove_metadata + else "--video-metadata copy --metadata copy" + ), (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), ("--chapter-copy" if video.video_settings.copy_chapters else ""), "-c", @@ -118,6 +122,8 @@ def build(fastflix: FastFlix): settings.preset, (f"--lookahead {settings.lookahead}" if settings.lookahead else ""), aq, + "--level", + (settings.level or "auto"), "--colormatrix", (video.video_settings.color_space or "auto"), "--transfer", @@ -133,7 +139,7 @@ def build(fastflix: FastFlix): "--colorrange", "auto", f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced else ""), + (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), ("--vpp-yadif" if video.video_settings.deinterlace else ""), (f"--vpp-colorspace hdr2sdr=mobius" if video.video_settings.remove_hdr else ""), remove_hdr, diff --git a/fastflix/encoders/nvencc_avc/settings_panel.py b/fastflix/encoders/nvencc_avc/settings_panel.py index 0f44dddc..ebbb67da 100644 --- a/fastflix/encoders/nvencc_avc/settings_panel.py +++ b/fastflix/encoders/nvencc_avc/settings_panel.py @@ -15,7 +15,7 @@ logger = logging.getLogger("fastflix") -presets = ["default", "performance", "quality"] +presets = ["default", "performance", "quality", "P1", "P2", "P3", "P4", "P5", "P6", "P7"] recommended_bitrates = [ "200k (320x240p @ 30fps)", @@ -279,13 +279,13 @@ def init_min_q(self): layout = QtWidgets.QHBoxLayout() layout.addWidget(QtWidgets.QLabel(t("Min Q"))) layout.addWidget( - self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), min_width=45, opt="min_q_i") + self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), width=15, opt="min_q_i") ) layout.addWidget( - self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), min_width=45, opt="min_q_p") + self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), width=15, opt="min_q_p") ) layout.addWidget( - self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), min_width=45, opt="min_q_b") + self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), width=15, opt="min_q_b") ) return layout @@ -293,13 +293,13 @@ def init_init_q(self): layout = QtWidgets.QHBoxLayout() layout.addWidget(QtWidgets.QLabel(t("Init Q"))) layout.addWidget( - self._add_combo_box(widget_name="init_q_i", options=["I"] + self._qp_range(), min_width=45, opt="init_q_i") + self._add_combo_box(widget_name="init_q_i", options=["I"] + self._qp_range(), width=15, opt="init_q_i") ) layout.addWidget( - self._add_combo_box(widget_name="init_q_p", options=["P"] + self._qp_range(), min_width=45, opt="init_q_p") + self._add_combo_box(widget_name="init_q_p", options=["P"] + self._qp_range(), width=15, opt="init_q_p") ) layout.addWidget( - self._add_combo_box(widget_name="init_q_b", options=["B"] + self._qp_range(), min_width=45, opt="init_q_b") + self._add_combo_box(widget_name="init_q_b", options=["B"] + self._qp_range(), width=15, opt="init_q_b") ) return layout @@ -307,13 +307,13 @@ def init_max_q(self): layout = QtWidgets.QHBoxLayout() layout.addWidget(QtWidgets.QLabel(t("Max Q"))) layout.addWidget( - self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), min_width=45, opt="max_q_i") + self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), width=15, opt="max_q_i") ) layout.addWidget( - self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), min_width=45, opt="max_q_p") + self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), width=15, opt="max_q_p") ) layout.addWidget( - self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), min_width=45, opt="max_q_b") + self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), width=15, opt="max_q_b") ) return layout diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py index 5e3828f0..3b195ba3 100644 --- a/fastflix/encoders/nvencc_hevc/command_builder.py +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -124,7 +124,11 @@ def build(fastflix: FastFlix): transform, (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), crop, - (f"--video-metadata clear" if video.video_settings.remove_metadata else "--video-metadata copy"), + ( + f"--video-metadata clear --metadata clear" + if video.video_settings.remove_metadata + else "--video-metadata copy --metadata copy" + ), (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), ("--chapter-copy" if video.video_settings.copy_chapters else ""), "-c", @@ -144,6 +148,8 @@ def build(fastflix: FastFlix): settings.tier, (f"--lookahead {settings.lookahead}" if settings.lookahead else ""), aq, + "--level", + (settings.level or "auto"), "--colormatrix", (video.video_settings.color_space or "auto"), "--transfer", @@ -164,7 +170,7 @@ def build(fastflix: FastFlix): "--colorrange", "auto", f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced else ""), + (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), ("--vpp-yadif" if video.video_settings.deinterlace else ""), (f"--vpp-colorspace hdr2sdr=mobius" if video.video_settings.remove_hdr else ""), remove_hdr, diff --git a/fastflix/encoders/nvencc_hevc/settings_panel.py b/fastflix/encoders/nvencc_hevc/settings_panel.py index e9ef3231..cd027724 100644 --- a/fastflix/encoders/nvencc_hevc/settings_panel.py +++ b/fastflix/encoders/nvencc_hevc/settings_panel.py @@ -15,7 +15,7 @@ logger = logging.getLogger("fastflix") -presets = ["default", "performance", "quality"] +presets = ["default", "performance", "quality", "P1", "P2", "P3", "P4", "P5", "P6", "P7"] recommended_bitrates = [ "200k (320x240p @ 30fps)", @@ -173,7 +173,7 @@ def init_tune(self): ) # def init_profile(self): - # # TODO auto + # # return self._add_combo_box( # label="Profile_encoderopt", # widget_name="profile", @@ -281,13 +281,13 @@ def init_min_q(self): layout = QtWidgets.QHBoxLayout() layout.addWidget(QtWidgets.QLabel(t("Min Q"))) layout.addWidget( - self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), min_width=45, opt="min_q_i") + self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), width=15, opt="min_q_i") ) layout.addWidget( - self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), min_width=45, opt="min_q_p") + self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), width=15, opt="min_q_p") ) layout.addWidget( - self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), min_width=45, opt="min_q_b") + self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), width=15, opt="min_q_b") ) return layout @@ -295,13 +295,13 @@ def init_init_q(self): layout = QtWidgets.QHBoxLayout() layout.addWidget(QtWidgets.QLabel(t("Init Q"))) layout.addWidget( - self._add_combo_box(widget_name="init_q_i", options=["I"] + self._qp_range(), min_width=45, opt="init_q_i") + self._add_combo_box(widget_name="init_q_i", options=["I"] + self._qp_range(), width=15, opt="init_q_i") ) layout.addWidget( - self._add_combo_box(widget_name="init_q_p", options=["P"] + self._qp_range(), min_width=45, opt="init_q_p") + self._add_combo_box(widget_name="init_q_p", options=["P"] + self._qp_range(), width=15, opt="init_q_p") ) layout.addWidget( - self._add_combo_box(widget_name="init_q_b", options=["B"] + self._qp_range(), min_width=45, opt="init_q_b") + self._add_combo_box(widget_name="init_q_b", options=["B"] + self._qp_range(), width=15, opt="init_q_b") ) return layout @@ -309,13 +309,13 @@ def init_max_q(self): layout = QtWidgets.QHBoxLayout() layout.addWidget(QtWidgets.QLabel(t("Max Q"))) layout.addWidget( - self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), min_width=45, opt="max_q_i") + self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), width=15, opt="max_q_i") ) layout.addWidget( - self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), min_width=45, opt="max_q_p") + self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), width=15, opt="max_q_p") ) layout.addWidget( - self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), min_width=45, opt="max_q_b") + self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), width=15, opt="max_q_b") ) return layout diff --git a/fastflix/encoders/qsvencc_avc/__init__.py b/fastflix/encoders/qsvencc_avc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/qsvencc_avc/command_builder.py b/fastflix/encoders/qsvencc_avc/command_builder.py new file mode 100644 index 00000000..93281237 --- /dev/null +++ b/fastflix/encoders/qsvencc_avc/command_builder.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +import logging + +from fastflix.encoders.common.helpers import Command +from fastflix.models.encode import QSVEncCH264Settings +from fastflix.models.video import Video +from fastflix.models.fastflix import FastFlix +from fastflix.encoders.common.encc_helpers import build_subtitle, build_audio +from fastflix.flix import clean_file_string + +logger = logging.getLogger("fastflix") + + +def build(fastflix: FastFlix): + video: Video = fastflix.current_video + settings: QSVEncCH264Settings = fastflix.current_video.video_settings.video_encoder_settings + + master_display = None + if fastflix.current_video.master_display: + master_display = ( + f'--master-display "G{fastflix.current_video.master_display.green}' + f"B{fastflix.current_video.master_display.blue}" + f"R{fastflix.current_video.master_display.red}" + f"WP{fastflix.current_video.master_display.white}" + f'L{fastflix.current_video.master_display.luminance}"' + ) + + max_cll = None + if fastflix.current_video.cll: + max_cll = f'--max-cll "{fastflix.current_video.cll}"' + + dhdr = None + if settings.hdr10plus_metadata: + dhdr = f'--dhdr10-info "{settings.hdr10plus_metadata}"' + + trim = "" + try: + if "/" in video.frame_rate: + over, under = [int(x) for x in video.frame_rate.split("/")] + rate = over / under + else: + rate = float(video.frame_rate) + except Exception: + logger.exception("Could not get framerate of this movie!") + else: + if video.video_settings.end_time: + end_frame = int(video.video_settings.end_time * rate) + start_frame = 0 + if video.video_settings.start_time: + start_frame = int(video.video_settings.start_time * rate) + trim = f"--trim {start_frame}:{end_frame}" + elif video.video_settings.start_time: + trim = f"--seek {video.video_settings.start_time}" + + if (video.frame_rate != video.average_frame_rate) and trim: + logger.warning("Cannot use 'trim' when working with variable frame rate videos") + trim = "" + + transform = "" + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" + + remove_hdr = "" + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + + crop = "" + if video.video_settings.crop: + crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + + vbv = "" + if video.video_settings.maxrate: + vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + + min_q = settings.min_q_i + if settings.min_q_i and settings.min_q_p and settings.min_q_b: + min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + + max_q = settings.max_q_i + if settings.max_q_i and settings.max_q_p and settings.max_q_b: + max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None + + bit_depth = "8" + if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: + bit_depth = "10" + if settings.force_ten_bit: + bit_depth = "10" + + vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" + if video.video_settings.vsync == "cfr": + vsync_setting = "forcecfr" + elif video.video_settings.vsync == "vfr": + vsync_setting = "vfr" + + command = [ + f'"{clean_file_string(fastflix.config.qsvencc)}"', + "-i", + f'"{clean_file_string(video.source)}"', + (f"--video-streamid {stream_id}" if stream_id else ""), + trim, + (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), + transform, + (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), + crop, + ( + f"--video-metadata clear --metadata clear" + if video.video_settings.remove_metadata + else "--video-metadata copy --metadata copy" + ), + (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), + ("--chapter-copy" if video.video_settings.copy_chapters else ""), + "-c", + "h264", + (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), + vbv, + (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), + (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), + (f"--bframes {settings.b_frames}" if settings.b_frames else ""), + (f"--ref {settings.ref}" if settings.ref else ""), + "--quality", + settings.preset, + "--profile", + settings.profile, + (f"--la-depth {settings.lookahead}" if settings.lookahead else ""), + "--level", + (settings.level or "auto"), + "--colormatrix", + (video.video_settings.color_space or "auto"), + "--transfer", + (video.video_settings.color_transfer or "auto"), + "--colorprim", + (video.video_settings.color_primaries or "auto"), + "--output-depth", + bit_depth, + "--chromaloc", + "auto", + "--colorrange", + "auto", + f"--avsync {vsync_setting}", + (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), + ("--vpp-yadif" if video.video_settings.deinterlace else ""), + (f"--vpp-colorspace hdr2sdr=mobius" if video.video_settings.remove_hdr else ""), + remove_hdr, + "--psnr --ssim" if settings.metrics else "", + build_audio(video.video_settings.audio_tracks, video.streams.audio), + build_subtitle(video.video_settings.subtitle_tracks, video.streams.subtitle), + settings.extra, + "-o", + f'"{clean_file_string(video.video_settings.output_path)}"', + ] + + return [Command(command=" ".join(x for x in command if x), name="QSVEncC Encode", exe="QSVEncC")] diff --git a/fastflix/encoders/qsvencc_avc/main.py b/fastflix/encoders/qsvencc_avc/main.py new file mode 100644 index 00000000..73dcc1df --- /dev/null +++ b/fastflix/encoders/qsvencc_avc/main.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +from pathlib import Path + +import pkg_resources + +name = "AVC (QSVEncC)" + +video_extension = "mkv" +video_dimension_divisor = 1 +icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_qsvencc.png")).resolve()) + +enable_subtitles = True +enable_audio = True +enable_attachments = False + +# Taken from NVEncC64.exe --check-encoders +audio_formats = [ + "aac", + "ac3", + "ac3_fixed", + "adpcm_adx", + "adpcm_ima_apm", + "adpcm_ima_qt", + "adpcm_ima_ssi", + "adpcm_ima_wav", + "adpcm_ms", + "adpcm_swf", + "adpcm_yamaha", + "alac", + "aptx", + "aptx_hd", + "comfortnoise", + "dca", + "eac3", + "flac", + "g722", + "g723_1", + "g726", + "g726le", + "libmp3lame", + "libopus", + "libspeex", + "libtwolame", + "libvorbis", + "libwavpack", + "mlp", + "mp2", + "mp2fixed", + "nellymoser", + "opus", + "pcm_alaw", + "pcm_dvd", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", + "pcm_mulaw", + "pcm_s16be", + "pcm_s16be_planar", + "pcm_s16le", + "pcm_s16le_planar", + "pcm_s24be", + "pcm_s24daud", + "pcm_s24le", + "pcm_s24le_planar", + "pcm_s32be", + "pcm_s32le", + "pcm_s32le_planar", + "pcm_s64be", + "pcm_s64le", + "pcm_s8", + "pcm_s8_planar", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "pcm_u8", + "pcm_vidc", + "real_144", + "roq_dpcm", + "s302m", + "sbc", + "sonic", + "sonicls", + "truehd", + "tta", + "vorbis", + "wavpack", + "wmav1", + "wmav2", +] + +from fastflix.encoders.qsvencc_avc.command_builder import build +from fastflix.encoders.qsvencc_avc.settings_panel import QSVEncH264 as settings_panel diff --git a/fastflix/encoders/qsvencc_avc/settings_panel.py b/fastflix/encoders/qsvencc_avc/settings_panel.py new file mode 100644 index 00000000..6fb08baf --- /dev/null +++ b/fastflix/encoders/qsvencc_avc/settings_panel.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- +import logging +from typing import List, Optional + +from box import Box +from PySide2 import QtCore, QtWidgets, QtGui + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.language import t +from fastflix.models.encode import QSVEncCH264Settings +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.shared import link +from fastflix.exceptions import FastFlixInternalException +from fastflix.resources import loading_movie, get_icon + +logger = logging.getLogger("fastflix") + +presets = [ + "best", + "higher", + "high", + "balanced", + "fast", + "faster", + "fastest", +] + +recommended_bitrates = [ + "200k (320x240p @ 30fps)", + "300k (640x360p @ 30fps)", + "1000k (640x480p @ 30fps)", + "1750k (1280x720p @ 30fps)", + "2500k (1280x720p @ 60fps)", + "4000k (1920x1080p @ 30fps)", + "5000k (1920x1080p @ 60fps)", + "7000k (2560x1440p @ 30fps)", + "10000k (2560x1440p @ 60fps)", + "15000k (3840x2160p @ 30fps)", + "20000k (3840x2160p @ 60fps)", + "Custom", +] + +recommended_crfs = [ + "28", + "27", + "26", + "25", + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", + "15", + "14", + "Custom", +] + + +def get_breaker(): + breaker_line = QtWidgets.QWidget() + breaker_line.setMaximumHeight(2) + breaker_line.setStyleSheet("background-color: #ccc; margin: auto 0; padding: auto 0;") + return breaker_line + + +class QSVEncH264(SettingPanel): + profile_name = "qsvencc_avc" + + def __init__(self, parent, main, app: FastFlixApp): + super().__init__(parent, main, app) + self.main = main + self.app = app + + grid = QtWidgets.QGridLayout() + + self.widgets = Box(mode=None) + + self.mode = "Bitrate" + self.updating_settings = False + + grid.addLayout(self.init_modes(), 0, 2, 4, 4) + grid.addLayout(self._add_custom(title="Custom QSVEncC options", disable_both_passes=True), 10, 0, 1, 6) + + grid.addLayout(self.init_preset(), 0, 0, 1, 2) + grid.addLayout(self.init_profile(), 1, 0, 1, 2) + grid.addLayout(self.init_lookahead(), 2, 0, 1, 2) + + breaker = QtWidgets.QHBoxLayout() + breaker_label = QtWidgets.QLabel(t("Advanced")) + breaker_label.setFont(QtGui.QFont("helvetica", 8, weight=55)) + + breaker.addWidget(get_breaker(), stretch=1) + breaker.addWidget(breaker_label, alignment=QtCore.Qt.AlignHCenter) + breaker.addWidget(get_breaker(), stretch=1) + + grid.addLayout(breaker, 4, 0, 1, 6) + + qp_line = QtWidgets.QHBoxLayout() + qp_line.addLayout(self.init_min_q()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_max_q()) + + grid.addLayout(qp_line, 5, 2, 1, 4) + + advanced = QtWidgets.QHBoxLayout() + advanced.addLayout(self.init_10_bit()) + advanced.addStretch(1) + advanced.addLayout(self.init_ref()) + advanced.addStretch(1) + advanced.addLayout(self.init_b_frames()) + advanced.addStretch(1) + advanced.addLayout(self.init_level()) + advanced.addStretch(1) + advanced.addLayout(self.init_metrics()) + grid.addLayout(advanced, 6, 2, 1, 4) + + self.ffmpeg_level = QtWidgets.QLabel() + grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4) + + grid.setRowStretch(9, 1) + + guide_label = QtWidgets.QLabel( + link( + "https://github.com/rigaya/QSVEnc/blob/master/QSVEncC_Options.en.md", + t("QSVEncC Options"), + app.fastflix.config.theme, + ) + ) + + warning_label = QtWidgets.QLabel() + warning_label.setPixmap(QtGui.QIcon(get_icon("onyx-warning", self.app.fastflix.config.theme)).pixmap(22)) + + guide_label.setAlignment(QtCore.Qt.AlignBottom) + guide_label.setOpenExternalLinks(True) + grid.addWidget(guide_label, 11, 0, 1, 4) + grid.addWidget(warning_label, 11, 4, 1, 1, alignment=QtCore.Qt.AlignRight) + grid.addWidget(QtWidgets.QLabel(t("QSVEncC Encoder support is still experimental!")), 11, 5, 1, 1) + + self.setLayout(grid) + self.hide() + + def init_preset(self): + return self._add_combo_box( + label="Preset", + widget_name="preset", + options=presets, + tooltip="preset: The slower the preset, the better the compression and quality", + connect="default", + opt="preset", + ) + + def init_tune(self): + return self._add_combo_box( + label="Tune", + widget_name="tune", + tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", + options=["hq", "ll", "ull", "lossless"], + opt="tune", + ) + + def init_profile(self): + return self._add_combo_box( + label="Profile_encoderopt", + widget_name="profile", + tooltip="Enforce an encode profile", + options=["auto", "baseline", "main", "high"], + opt="profile", + ) + + def init_lookahead(self): + return self._add_combo_box( + label="Lookahead", + tooltip="", + widget_name="lookahead", + opt="lookahead", + options=["off"] + [str(x) for x in range(10, 100)], + ) + + def init_level(self): + layout = self._add_combo_box( + label="Level", + tooltip="Set the encoding level restriction", + widget_name="level", + options=[ + t("Auto"), + "1", + "1.1", + "1.2", + "1.3", + "2", + "2.1", + "2.2", + "3.0", + "3.1", + "3.2", + "4", + "4.1", + "4.2", + "5", + "5.1", + "5.2", + "6", + "6.1", + "6.2", + ], + opt="level", + ) + self.widgets.level.setMinimumWidth(60) + return layout + + @staticmethod + def _qp_range(): + return [str(x) for x in range(0, 52)] + + def init_min_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Min Q"))) + layout.addWidget( + self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), min_width=45, opt="min_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), min_width=45, opt="min_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), min_width=45, opt="min_q_b") + ) + return layout + + def init_max_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Max Q"))) + layout.addWidget( + self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), min_width=45, opt="max_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), min_width=45, opt="max_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), min_width=45, opt="max_q_b") + ) + return layout + + def init_b_frames(self): + return self._add_combo_box( + widget_name="b_frames", + label="B Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="b_frames", + min_width=60, + ) + + def init_ref(self): + return self._add_combo_box( + widget_name="ref", + label="Ref Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="ref", + min_width=60, + ) + + def init_10_bit(self): + return self._add_check_box(label="10-bit", widget_name="force_ten_bit", opt="force_ten_bit") + + def init_metrics(self): + return self._add_check_box( + widget_name="metrics", + opt="metrics", + label="Metrics", + tooltip="Calculate PSNR and SSIM and show in the encoder output", + ) + + def init_modes(self): + layout = self._add_modes(recommended_bitrates, recommended_crfs, qp_name="cqp") + return layout + + def mode_update(self): + self.widgets.custom_cqp.setDisabled(self.widgets.cqp.currentText() != "Custom") + self.widgets.custom_bitrate.setDisabled(self.widgets.bitrate.currentText() != "Custom") + self.main.build_commands() + + def setting_change(self, update=True): + if self.updating_settings: + return + self.updating_settings = True + + if self.app.fastflix.current_video.current_video_stream.bit_depth > 8 and not self.main.remove_hdr: + self.widgets.force_ten_bit.setChecked(True) + self.widgets.force_ten_bit.setDisabled(True) + else: + self.widgets.force_ten_bit.setDisabled(False) + + if update: + self.main.page_update() + self.updating_settings = False + + def update_video_encoder_settings(self): + settings = QSVEncCH264Settings( + preset=self.widgets.preset.currentText().split("-")[0].strip(), + force_ten_bit=self.widgets.force_ten_bit.isChecked(), + lookahead=self.widgets.lookahead.currentText() if self.widgets.lookahead.currentIndex() > 0 else None, + profile=self.widgets.profile.currentText(), + max_q_i=self.widgets.max_q_i.currentText() if self.widgets.max_q_i.currentIndex() != 0 else None, + max_q_p=self.widgets.max_q_p.currentText() if self.widgets.max_q_p.currentIndex() != 0 else None, + max_q_b=self.widgets.max_q_b.currentText() if self.widgets.max_q_b.currentIndex() != 0 else None, + min_q_i=self.widgets.min_q_i.currentText() if self.widgets.min_q_i.currentIndex() != 0 else None, + min_q_p=self.widgets.min_q_p.currentText() if self.widgets.min_q_p.currentIndex() != 0 else None, + min_q_b=self.widgets.min_q_b.currentText() if self.widgets.min_q_b.currentIndex() != 0 else None, + extra=self.ffmpeg_extras, + metrics=self.widgets.metrics.isChecked(), + level=self.widgets.level.currentText() if self.widgets.level.currentIndex() != 0 else None, + b_frames=self.widgets.b_frames.currentText() if self.widgets.b_frames.currentIndex() != 0 else None, + ref=self.widgets.ref.currentText() if self.widgets.ref.currentIndex() != 0 else None, + ) + + encode_type, q_value = self.get_mode_settings() + settings.cqp = q_value if encode_type == "qp" else None + settings.bitrate = q_value if encode_type == "bitrate" else None + self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + + def set_mode(self, x): + self.mode = x.text() + for group in ("init", "max", "min"): + for frame_type in ("i", "p", "b"): + self.widgets[f"{group}_q_{frame_type}"].setEnabled(self.mode.lower() == "bitrate") + self.widgets.vbr_target.setEnabled(self.mode.lower() == "bitrate") + self.main.build_commands() + + def new_source(self): + if not self.app.fastflix.current_video: + return + super().new_source() + if self.app.fastflix.current_video.current_video_stream.bit_depth > 8 and not self.main.remove_hdr: + self.widgets.force_ten_bit.setChecked(True) + self.widgets.force_ten_bit.setDisabled(True) + else: + self.widgets.force_ten_bit.setDisabled(False) diff --git a/fastflix/encoders/qsvencc_hevc/__init__.py b/fastflix/encoders/qsvencc_hevc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastflix/encoders/qsvencc_hevc/command_builder.py b/fastflix/encoders/qsvencc_hevc/command_builder.py new file mode 100644 index 00000000..3312c6ab --- /dev/null +++ b/fastflix/encoders/qsvencc_hevc/command_builder.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +import logging + +from fastflix.encoders.common.helpers import Command +from fastflix.models.encode import QSVEncCSettings +from fastflix.models.video import Video +from fastflix.models.fastflix import FastFlix +from fastflix.encoders.common.encc_helpers import build_subtitle, build_audio +from fastflix.flix import clean_file_string + +logger = logging.getLogger("fastflix") + + +def build(fastflix: FastFlix): + video: Video = fastflix.current_video + settings: QSVEncCSettings = fastflix.current_video.video_settings.video_encoder_settings + + master_display = None + if fastflix.current_video.master_display: + master_display = ( + f'--master-display "G{fastflix.current_video.master_display.green}' + f"B{fastflix.current_video.master_display.blue}" + f"R{fastflix.current_video.master_display.red}" + f"WP{fastflix.current_video.master_display.white}" + f'L{fastflix.current_video.master_display.luminance}"' + ) + + max_cll = None + if fastflix.current_video.cll: + max_cll = f'--max-cll "{fastflix.current_video.cll}"' + + dhdr = None + if settings.hdr10plus_metadata: + dhdr = f'--dhdr10-info "{settings.hdr10plus_metadata}"' + + trim = "" + try: + if "/" in video.frame_rate: + over, under = [int(x) for x in video.frame_rate.split("/")] + rate = over / under + else: + rate = float(video.frame_rate) + except Exception: + logger.exception("Could not get framerate of this movie!") + else: + if video.video_settings.end_time: + end_frame = int(video.video_settings.end_time * rate) + start_frame = 0 + if video.video_settings.start_time: + start_frame = int(video.video_settings.start_time * rate) + trim = f"--trim {start_frame}:{end_frame}" + elif video.video_settings.start_time: + trim = f"--seek {video.video_settings.start_time}" + + if (video.frame_rate != video.average_frame_rate) and trim: + logger.warning("Cannot use 'trim' when working with variable frame rate videos") + trim = "" + + transform = "" + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" + + remove_hdr = "" + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + + crop = "" + if video.video_settings.crop: + crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + + vbv = "" + if video.video_settings.maxrate: + vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + + min_q = settings.min_q_i + if settings.min_q_i and settings.min_q_p and settings.min_q_b: + min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + + max_q = settings.max_q_i + if settings.max_q_i and settings.max_q_p and settings.max_q_b: + max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None + + bit_depth = "8" + if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: + bit_depth = "10" + if settings.force_ten_bit: + bit_depth = "10" + + vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" + if video.video_settings.vsync == "cfr": + vsync_setting = "forcecfr" + elif video.video_settings.vsync == "vfr": + vsync_setting = "vfr" + + command = [ + f'"{clean_file_string(fastflix.config.qsvencc)}"', + "-i", + f'"{clean_file_string(video.source)}"', + (f"--video-streamid {stream_id}" if stream_id else ""), + trim, + (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), + transform, + (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), + crop, + ( + f"--video-metadata clear --metadata clear" + if video.video_settings.remove_metadata + else "--video-metadata copy --metadata copy" + ), + (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), + ("--chapter-copy" if video.video_settings.copy_chapters else ""), + "-c", + "hevc", + (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), + vbv, + (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), + (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), + (f"--bframes {settings.b_frames}" if settings.b_frames else ""), + (f"--ref {settings.ref}" if settings.ref else ""), + "--quality", + settings.preset, + (f"--la-depth {settings.lookahead}" if settings.lookahead else ""), + "--level", + (settings.level or "auto"), + "--colormatrix", + (video.video_settings.color_space or "auto"), + "--transfer", + (video.video_settings.color_transfer or "auto"), + "--colorprim", + (video.video_settings.color_primaries or "auto"), + (master_display if master_display else ""), + (max_cll if max_cll else ""), + (dhdr if dhdr else ""), + "--output-depth", + bit_depth, + "--chromaloc", + "auto", + "--colorrange", + "auto", + f"--avsync {vsync_setting}", + (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), + ("--vpp-yadif" if video.video_settings.deinterlace else ""), + (f"--vpp-colorspace hdr2sdr=mobius" if video.video_settings.remove_hdr else ""), + remove_hdr, + "--psnr --ssim" if settings.metrics else "", + build_audio(video.video_settings.audio_tracks, video.streams.audio), + build_subtitle(video.video_settings.subtitle_tracks, video.streams.subtitle), + settings.extra, + "-o", + f'"{clean_file_string(video.video_settings.output_path)}"', + ] + + return [Command(command=" ".join(x for x in command if x), name="QSVEncC Encode", exe="QSVEncC")] diff --git a/fastflix/encoders/qsvencc_hevc/main.py b/fastflix/encoders/qsvencc_hevc/main.py new file mode 100644 index 00000000..ef6e58c5 --- /dev/null +++ b/fastflix/encoders/qsvencc_hevc/main.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Chris Griffith" +from pathlib import Path + +import pkg_resources + +name = "HEVC (QSVEncC)" + +video_extension = "mkv" +video_dimension_divisor = 1 +icon = str(Path(pkg_resources.resource_filename(__name__, f"../../data/encoders/icon_qsvencc.png")).resolve()) + +enable_subtitles = True +enable_audio = True +enable_attachments = False + +# Taken from NVEncC64.exe --check-encoders +audio_formats = [ + "aac", + "ac3", + "ac3_fixed", + "adpcm_adx", + "adpcm_ima_apm", + "adpcm_ima_qt", + "adpcm_ima_ssi", + "adpcm_ima_wav", + "adpcm_ms", + "adpcm_swf", + "adpcm_yamaha", + "alac", + "aptx", + "aptx_hd", + "comfortnoise", + "dca", + "eac3", + "flac", + "g722", + "g723_1", + "g726", + "g726le", + "libmp3lame", + "libopus", + "libspeex", + "libtwolame", + "libvorbis", + "libwavpack", + "mlp", + "mp2", + "mp2fixed", + "nellymoser", + "opus", + "pcm_alaw", + "pcm_dvd", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", + "pcm_mulaw", + "pcm_s16be", + "pcm_s16be_planar", + "pcm_s16le", + "pcm_s16le_planar", + "pcm_s24be", + "pcm_s24daud", + "pcm_s24le", + "pcm_s24le_planar", + "pcm_s32be", + "pcm_s32le", + "pcm_s32le_planar", + "pcm_s64be", + "pcm_s64le", + "pcm_s8", + "pcm_s8_planar", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "pcm_u8", + "pcm_vidc", + "real_144", + "roq_dpcm", + "s302m", + "sbc", + "sonic", + "sonicls", + "truehd", + "tta", + "vorbis", + "wavpack", + "wmav1", + "wmav2", +] + +from fastflix.encoders.qsvencc_hevc.command_builder import build +from fastflix.encoders.qsvencc_hevc.settings_panel import QSVEnc as settings_panel diff --git a/fastflix/encoders/qsvencc_hevc/settings_panel.py b/fastflix/encoders/qsvencc_hevc/settings_panel.py new file mode 100644 index 00000000..28329bae --- /dev/null +++ b/fastflix/encoders/qsvencc_hevc/settings_panel.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +import logging +from typing import List, Optional + +from box import Box +from PySide2 import QtCore, QtWidgets, QtGui + +from fastflix.encoders.common.setting_panel import SettingPanel +from fastflix.language import t +from fastflix.models.encode import QSVEncCSettings +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.shared import link +from fastflix.exceptions import FastFlixInternalException +from fastflix.resources import loading_movie, get_icon + +logger = logging.getLogger("fastflix") + +presets = [ + "best", + "higher", + "high", + "balanced", + "fast", + "faster", + "fastest", +] + +recommended_bitrates = [ + "200k (320x240p @ 30fps)", + "300k (640x360p @ 30fps)", + "1000k (640x480p @ 30fps)", + "1750k (1280x720p @ 30fps)", + "2500k (1280x720p @ 60fps)", + "4000k (1920x1080p @ 30fps)", + "5000k (1920x1080p @ 60fps)", + "7000k (2560x1440p @ 30fps)", + "10000k (2560x1440p @ 60fps)", + "15000k (3840x2160p @ 30fps)", + "20000k (3840x2160p @ 60fps)", + "Custom", +] + +recommended_crfs = [ + "28", + "27", + "26", + "25", + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", + "15", + "14", + "Custom", +] + + +def get_breaker(): + breaker_line = QtWidgets.QWidget() + breaker_line.setMaximumHeight(2) + breaker_line.setStyleSheet("background-color: #ccc; margin: auto 0; padding: auto 0;") + return breaker_line + + +class QSVEnc(SettingPanel): + profile_name = "qsvencc_hevc" + hdr10plus_signal = QtCore.Signal(str) + hdr10plus_ffmpeg_signal = QtCore.Signal(str) + + def __init__(self, parent, main, app: FastFlixApp): + super().__init__(parent, main, app) + self.main = main + self.app = app + + grid = QtWidgets.QGridLayout() + + self.widgets = Box(mode=None) + + self.mode = "Bitrate" + self.updating_settings = False + + grid.addLayout(self.init_modes(), 0, 2, 4, 4) + grid.addLayout(self._add_custom(title="Custom QSVEncC options", disable_both_passes=True), 10, 0, 1, 6) + + grid.addLayout(self.init_preset(), 0, 0, 1, 2) + # grid.addLayout(self.init_profile(), 1, 0, 1, 2) + grid.addLayout(self.init_lookahead(), 1, 0, 1, 2) + + breaker = QtWidgets.QHBoxLayout() + breaker_label = QtWidgets.QLabel(t("Advanced")) + breaker_label.setFont(QtGui.QFont("helvetica", 8, weight=55)) + + breaker.addWidget(get_breaker(), stretch=1) + breaker.addWidget(breaker_label, alignment=QtCore.Qt.AlignHCenter) + breaker.addWidget(get_breaker(), stretch=1) + + grid.addLayout(breaker, 4, 0, 1, 6) + + qp_line = QtWidgets.QHBoxLayout() + qp_line.addLayout(self.init_min_q()) + qp_line.addStretch(1) + qp_line.addLayout(self.init_max_q()) + + grid.addLayout(qp_line, 5, 2, 1, 4) + + advanced = QtWidgets.QHBoxLayout() + advanced.addLayout(self.init_10_bit()) + advanced.addStretch(1) + advanced.addLayout(self.init_ref()) + advanced.addStretch(1) + advanced.addLayout(self.init_b_frames()) + advanced.addStretch(1) + advanced.addLayout(self.init_level()) + advanced.addStretch(1) + advanced.addLayout(self.init_metrics()) + grid.addLayout(advanced, 6, 2, 1, 4) + + grid.addLayout(self.init_dhdr10_info(), 7, 2, 1, 4) + + self.ffmpeg_level = QtWidgets.QLabel() + grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4) + + grid.setRowStretch(9, 1) + + guide_label = QtWidgets.QLabel( + link( + "https://github.com/rigaya/QSVEnc/blob/master/QSVEncC_Options.en.md", + t("QSVEncC Options"), + app.fastflix.config.theme, + ) + ) + + warning_label = QtWidgets.QLabel() + warning_label.setPixmap(QtGui.QIcon(get_icon("onyx-warning", self.app.fastflix.config.theme)).pixmap(22)) + + guide_label.setAlignment(QtCore.Qt.AlignBottom) + guide_label.setOpenExternalLinks(True) + grid.addWidget(guide_label, 11, 0, 1, 4) + grid.addWidget(warning_label, 11, 4, 1, 1, alignment=QtCore.Qt.AlignRight) + grid.addWidget(QtWidgets.QLabel(t("QSVEncC Encoder support is still experimental!")), 11, 5, 1, 1) + + self.setLayout(grid) + self.hide() + self.hdr10plus_signal.connect(self.done_hdr10plus_extract) + self.hdr10plus_ffmpeg_signal.connect(lambda x: self.ffmpeg_level.setText(x)) + + def init_preset(self): + return self._add_combo_box( + label="Preset", + widget_name="preset", + options=presets, + tooltip="preset: The slower the preset, the better the compression and quality", + connect="default", + opt="preset", + ) + + def init_tune(self): + return self._add_combo_box( + label="Tune", + widget_name="tune", + tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", + options=["hq", "ll", "ull", "lossless"], + opt="tune", + ) + + # def init_profile(self): + # # TODO auto + # return self._add_combo_box( + # label="Profile_encoderopt", + # widget_name="profile", + # tooltip="Enforce an encode profile", + # options=["main", "main10"], + # opt="profile", + # ) + + def init_lookahead(self): + return self._add_combo_box( + label="Lookahead", + tooltip="", + widget_name="lookahead", + opt="lookahead", + options=["off"] + [str(x) for x in range(10, 100)], + ) + + def init_level(self): + layout = self._add_combo_box( + label="Level", + tooltip="Set the encoding level restriction", + widget_name="level", + options=[ + t("Auto"), + "1.0", + "2.0", + "2.1", + "3.0", + "3.1", + "4.0", + "4.1", + "5.0", + "5.1", + "5.2", + "6.0", + "6.1", + "6.2", + ], + opt="level", + ) + self.widgets.level.setMinimumWidth(60) + return layout + + @staticmethod + def _qp_range(): + return [str(x) for x in range(0, 52)] + + def init_min_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Min Q"))) + layout.addWidget( + self._add_combo_box(widget_name="min_q_i", options=["I"] + self._qp_range(), min_width=45, opt="min_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_p", options=["P"] + self._qp_range(), min_width=45, opt="min_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="min_q_b", options=["B"] + self._qp_range(), min_width=45, opt="min_q_b") + ) + return layout + + def init_max_q(self): + layout = QtWidgets.QHBoxLayout() + layout.addWidget(QtWidgets.QLabel(t("Max Q"))) + layout.addWidget( + self._add_combo_box(widget_name="max_q_i", options=["I"] + self._qp_range(), min_width=45, opt="max_q_i") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_p", options=["P"] + self._qp_range(), min_width=45, opt="max_q_p") + ) + layout.addWidget( + self._add_combo_box(widget_name="max_q_b", options=["B"] + self._qp_range(), min_width=45, opt="max_q_b") + ) + return layout + + def init_b_frames(self): + return self._add_combo_box( + widget_name="b_frames", + label="B Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="b_frames", + min_width=60, + ) + + def init_ref(self): + return self._add_combo_box( + widget_name="ref", + label="Ref Frames", + options=[t("Auto"), "0", "1", "2", "3", "4", "5", "6"], + opt="ref", + min_width=60, + ) + + def init_10_bit(self): + return self._add_check_box(label="10-bit", widget_name="force_ten_bit", opt="force_ten_bit") + + def init_metrics(self): + return self._add_check_box( + widget_name="metrics", + opt="metrics", + label="Metrics", + tooltip="Calculate PSNR and SSIM and show in the encoder output", + ) + + def init_dhdr10_info(self): + layout = self._add_file_select( + label="HDR10+ Metadata", + widget_name="hdr10plus_metadata", + button_action=lambda: self.dhdr10_update(), + tooltip="dhdr10_info: Path to HDR10+ JSON metadata file", + ) + self.labels["hdr10plus_metadata"].setFixedWidth(200) + self.extract_button = QtWidgets.QPushButton(t("Extract HDR10+")) + self.extract_button.hide() + self.extract_button.clicked.connect(self.extract_hdr10plus) + + self.extract_label = QtWidgets.QLabel(self) + self.extract_label.hide() + self.movie = QtGui.QMovie(loading_movie) + self.movie.setScaledSize(QtCore.QSize(25, 25)) + self.extract_label.setMovie(self.movie) + + layout.addWidget(self.extract_button) + layout.addWidget(self.extract_label) + + return layout + + def init_modes(self): + layout = self._add_modes(recommended_bitrates, recommended_crfs, qp_name="cqp") + return layout + + def mode_update(self): + self.widgets.custom_cqp.setDisabled(self.widgets.cqp.currentText() != "Custom") + self.widgets.custom_bitrate.setDisabled(self.widgets.bitrate.currentText() != "Custom") + self.main.build_commands() + + def setting_change(self, update=True): + if self.updating_settings: + return + self.updating_settings = True + + if self.app.fastflix.current_video.current_video_stream.bit_depth > 8 and not self.main.remove_hdr: + self.widgets.force_ten_bit.setChecked(True) + self.widgets.force_ten_bit.setDisabled(True) + else: + self.widgets.force_ten_bit.setDisabled(False) + + if update: + self.main.page_update() + self.updating_settings = False + + def update_video_encoder_settings(self): + settings = QSVEncCSettings( + preset=self.widgets.preset.currentText().split("-")[0].strip(), + force_ten_bit=self.widgets.force_ten_bit.isChecked(), + lookahead=self.widgets.lookahead.currentText() if self.widgets.lookahead.currentIndex() > 0 else None, + hdr10plus_metadata=self.widgets.hdr10plus_metadata.text().strip(), # .replace("\\", "/"), + max_q_i=self.widgets.max_q_i.currentText() if self.widgets.max_q_i.currentIndex() != 0 else None, + max_q_p=self.widgets.max_q_p.currentText() if self.widgets.max_q_p.currentIndex() != 0 else None, + max_q_b=self.widgets.max_q_b.currentText() if self.widgets.max_q_b.currentIndex() != 0 else None, + min_q_i=self.widgets.min_q_i.currentText() if self.widgets.min_q_i.currentIndex() != 0 else None, + min_q_p=self.widgets.min_q_p.currentText() if self.widgets.min_q_p.currentIndex() != 0 else None, + min_q_b=self.widgets.min_q_b.currentText() if self.widgets.min_q_b.currentIndex() != 0 else None, + extra=self.ffmpeg_extras, + metrics=self.widgets.metrics.isChecked(), + level=self.widgets.level.currentText() if self.widgets.level.currentIndex() != 0 else None, + b_frames=self.widgets.b_frames.currentText() if self.widgets.b_frames.currentIndex() != 0 else None, + ref=self.widgets.ref.currentText() if self.widgets.ref.currentIndex() != 0 else None, + ) + + encode_type, q_value = self.get_mode_settings() + settings.cqp = q_value if encode_type == "qp" else None + settings.bitrate = q_value if encode_type == "bitrate" else None + self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + + def set_mode(self, x): + self.mode = x.text() + for group in ("init", "max", "min"): + for frame_type in ("i", "p", "b"): + self.widgets[f"{group}_q_{frame_type}"].setEnabled(self.mode.lower() == "bitrate") + self.widgets.vbr_target.setEnabled(self.mode.lower() == "bitrate") + self.main.build_commands() + + def new_source(self): + if not self.app.fastflix.current_video: + return + super().new_source() + if self.app.fastflix.current_video.hdr10_plus: + self.extract_button.show() + else: + self.extract_button.hide() + if self.app.fastflix.current_video.current_video_stream.bit_depth > 8 and not self.main.remove_hdr: + self.widgets.force_ten_bit.setChecked(True) + self.widgets.force_ten_bit.setDisabled(True) + else: + self.widgets.force_ten_bit.setDisabled(False) diff --git a/fastflix/encoders/vceencc_avc/command_builder.py b/fastflix/encoders/vceencc_avc/command_builder.py index 8e25e0c5..f55779d1 100644 --- a/fastflix/encoders/vceencc_avc/command_builder.py +++ b/fastflix/encoders/vceencc_avc/command_builder.py @@ -87,7 +87,11 @@ def build(fastflix: FastFlix): transform, (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), crop, - (f"--video-metadata clear" if video.video_settings.remove_metadata else "--video-metadata copy"), + ( + f"--video-metadata clear --metadata clear" + if video.video_settings.remove_metadata + else "--video-metadata copy --metadata copy" + ), (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), ("--chapter-copy" if video.video_settings.copy_chapters else ""), "-c", @@ -101,6 +105,8 @@ def build(fastflix: FastFlix): "--preset", settings.preset, profile_opt, + "--level", + (settings.level or "auto"), "--colormatrix", (video.video_settings.color_space or "auto"), "--transfer", diff --git a/fastflix/encoders/vceencc_hevc/command_builder.py b/fastflix/encoders/vceencc_hevc/command_builder.py index d9af1b18..ffab99a0 100644 --- a/fastflix/encoders/vceencc_hevc/command_builder.py +++ b/fastflix/encoders/vceencc_hevc/command_builder.py @@ -101,7 +101,11 @@ def build(fastflix: FastFlix): transform, (f'--output-res {video.video_settings.scale.replace(":", "x")}' if video.video_settings.scale else ""), crop, - (f"--video-metadata clear" if video.video_settings.remove_metadata else "--video-metadata copy"), + ( + f"--video-metadata clear --metadata clear" + if video.video_settings.remove_metadata + else "--video-metadata copy --metadata copy" + ), (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), ("--chapter-copy" if video.video_settings.copy_chapters else ""), "-c", @@ -115,6 +119,8 @@ def build(fastflix: FastFlix): settings.preset, "--tier", settings.tier, + "--level", + (settings.level or "auto"), "--colormatrix", (video.video_settings.color_space or "auto"), "--transfer", diff --git a/fastflix/entry.py b/fastflix/entry.py index 3e04d9cf..13f4b616 100644 --- a/fastflix/entry.py +++ b/fastflix/entry.py @@ -136,7 +136,7 @@ def main(): return exit_status try: - queue_worker(gui_proc, worker_queue, status_queue, log_queue, queue_list, queue_lock) + queue_worker(gui_proc, worker_queue, status_queue, log_queue) exit_status = 0 except Exception: logger.exception("Exception occurred while running FastFlix core") diff --git a/fastflix/ff_queue.py b/fastflix/ff_queue.py index 440c9209..7334d85f 100644 --- a/fastflix/ff_queue.py +++ b/fastflix/ff_queue.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from typing import List +from typing import List, Optional import os from pathlib import Path import logging @@ -17,7 +17,7 @@ logger = logging.getLogger("fastflix") -def get_queue(queue_file: Path, config: Config) -> List[Video]: +def get_queue(queue_file: Path) -> List[Video]: if not queue_file.exists(): return [] @@ -68,12 +68,17 @@ def get_queue(queue_file: Path, config: Config) -> List[Video]: return queue -def save_queue(queue: List[Video], queue_file: Path, config: Config): +def save_queue(queue: List[Video], queue_file: Path, config: Optional[Config] = None): items = [] - queue_covers = config.work_path / "covers" - queue_covers.mkdir(parents=True, exist_ok=True) - queue_data = config.work_path / "queue_extras" - queue_data.mkdir(parents=True, exist_ok=True) + + if config is not None: + queue_covers = config.work_path / "covers" + queue_covers.mkdir(parents=True, exist_ok=True) + queue_data = config.work_path / "queue_extras" + queue_data.mkdir(parents=True, exist_ok=True) + else: + queue_data = Path() + queue_covers = Path() def update_conversion_command(vid, old_path: str, new_path: str): for command in vid["video_settings"]["conversion_commands"]: @@ -87,29 +92,34 @@ def update_conversion_command(vid, old_path: str, new_path: str): video["source"] = os.fspath(video["source"]) video["work_path"] = os.fspath(video["work_path"]) video["video_settings"]["output_path"] = os.fspath(video["video_settings"]["output_path"]) - if metadata := video["video_settings"]["video_encoder_settings"].get("hdr10plus_metadata"): - new_metadata_file = queue_data / f"{uuid.uuid4().hex}_metadata.json" - try: - shutil.copy(metadata, new_metadata_file) - except OSError: - logger.exception("Could not save HDR10+ metadata file to queue recovery location, removing HDR10+") - - update_conversion_command( - video, - str(metadata), - str(new_metadata_file), - ) - video["video_settings"]["video_encoder_settings"]["hdr10plus_metadata"] = str(new_metadata_file) - for track in video["video_settings"]["attachment_tracks"]: - if track.get("file_path"): - new_file = queue_covers / f'{uuid.uuid4().hex}_{track["file_path"].name}' + if config: + if metadata := video["video_settings"]["video_encoder_settings"].get("hdr10plus_metadata"): + new_metadata_file = queue_data / f"{uuid.uuid4().hex}_metadata.json" try: - shutil.copy(track["file_path"], new_file) + shutil.copy(metadata, new_metadata_file) except OSError: - logger.exception("Could not save cover to queue recovery location, removing cover") - update_conversion_command(video, str(track["file_path"]), str(new_file)) - track["file_path"] = str(new_file) + logger.exception("Could not save HDR10+ metadata file to queue recovery location, removing HDR10+") + + update_conversion_command( + video, + str(metadata), + str(new_metadata_file), + ) + video["video_settings"]["video_encoder_settings"]["hdr10plus_metadata"] = str(new_metadata_file) + for track in video["video_settings"]["attachment_tracks"]: + if track.get("file_path"): + new_file = queue_covers / f'{uuid.uuid4().hex}_{track["file_path"].name}' + try: + shutil.copy(track["file_path"], new_file) + except OSError: + logger.exception("Could not save cover to queue recovery location, removing cover") + update_conversion_command(video, str(track["file_path"]), str(new_file)) + track["file_path"] = str(new_file) items.append(video) - Box(queue=items).to_yaml(filename=queue_file) - logger.debug(f"queue saved to recovery file {queue_file}") + try: + Box(queue=items).to_yaml(filename=queue_file) + except Exception as err: + logger.warning(items) + logger.exception(f"Could not save queue! {err.__class__.__name__}: {err}") + raise err from None diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 6578d0e0..f28d3bd4 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -97,6 +97,7 @@ class Config(BaseModel): hdr10plus_parser: Optional[Path] = Field(default_factory=find_hdr10plus_tool) nvencc: Optional[Path] = Field(default_factory=lambda: where("NVEncC64") or where("NVEncC")) vceencc: Optional[Path] = Field(default_factory=lambda: where("VCEEncC64") or where("VCEEncC")) + qsvencc: Optional[Path] = Field(default_factory=lambda: where("QSVEncC64") or where("QSVEncC")) output_directory: Optional[Path] = False source_directory: Optional[Path] = False output_name_format: str = "{source}-fastflix-{rand_4}.{ext}" diff --git a/fastflix/models/encode.py b/fastflix/models/encode.py index 6a58ae1c..11a5b2de 100644 --- a/fastflix/models/encode.py +++ b/fastflix/models/encode.py @@ -130,6 +130,46 @@ class NVEncCSettings(EncoderSettings): force_ten_bit: bool = False +class QSVEncCSettings(EncoderSettings): + name = "HEVC (QSVEncC)" + preset: str = "best" + bitrate: Optional[str] = "5000k" + cqp: Optional[str] = None + lookahead: Optional[str] = None + level: Optional[str] = None + hdr10plus_metadata: str = "" + min_q_i: Optional[str] = None + min_q_p: Optional[str] = None + min_q_b: Optional[str] = None + max_q_i: Optional[str] = None + max_q_p: Optional[str] = None + max_q_b: Optional[str] = None + b_frames: Optional[str] = None + ref: Optional[str] = None + metrics: bool = False + force_ten_bit: bool = False + + +class QSVEncCH264Settings(EncoderSettings): + name = "AVC (QSVEncC)" + preset: str = "best" + profile: str = "auto" + bitrate: Optional[str] = "5000k" + cqp: Optional[str] = None + lookahead: Optional[str] = None + level: Optional[str] = None + min_q_i: Optional[str] = None + min_q_p: Optional[str] = None + min_q_b: Optional[str] = None + max_q_i: Optional[str] = None + max_q_p: Optional[str] = None + max_q_b: Optional[str] = None + b_frames: Optional[str] = None + ref: Optional[str] = None + metrics: bool = False + force_ten_bit: bool = False + + class NVEncCAVCSettings(EncoderSettings): name = "AVC (NVEncC)" preset: str = "quality" @@ -157,7 +197,7 @@ class NVEncCAVCSettings(EncoderSettings): ref: Optional[str] = None metrics: bool = False b_frames: Optional[str] = None - b_ref_mode: str = "Hardware" + b_ref_mode: str = "disabled" class VCEEncCSettings(EncoderSettings): @@ -241,6 +281,32 @@ class VP9Settings(EncoderSettings): tile_rows: str = "-1" +class HEVCVideoToolboxSettings(EncoderSettings): + name = "HEVC (Video Toolbox)" + profile: int = 0 + allow_sw: bool = False + require_sw: bool = False + realtime: bool = False + frames_before: bool = False + frames_after: bool = False + q: Optional[int] = 50 + bitrate: Optional[str] = None + pix_fmt: str = "p010le" + + +class H264VideoToolboxSettings(EncoderSettings): + name = "H264 (Video Toolbox)" + profile: int = 0 + allow_sw: bool = False + require_sw: bool = False + realtime: bool = False + frames_before: bool = False + frames_after: bool = False + q: Optional[int] = 50 + bitrate: Optional[str] = None + pix_fmt: str = "yuv420p" + + class AOMAV1Settings(EncoderSettings): name = "AV1 (AOM)" tile_columns: str = "0" @@ -283,8 +349,12 @@ class CopySettings(EncoderSettings): "webp": WebPSettings, "copy_settings": CopySettings, "ffmpeg_hevc_nvenc": FFmpegNVENCSettings, + "qsvencc_hevc": QSVEncCSettings, + "qsvencc_avc": QSVEncCH264Settings, "nvencc_hevc": NVEncCSettings, "nvencc_avc": NVEncCAVCSettings, "vceencc_hevc": VCEEncCSettings, "vceencc_avc": VCEEncCAVCSettings, + "hevc_videotoolbox": HEVCVideoToolboxSettings, + "h264_videotoolbox": H264VideoToolboxSettings, } diff --git a/fastflix/models/fastflix.py b/fastflix/models/fastflix.py index 9302f6ed..04f06d1e 100644 --- a/fastflix/models/fastflix.py +++ b/fastflix/models/fastflix.py @@ -1,7 +1,4 @@ # -*- coding: utf-8 -*- -from dataclasses import dataclass, field - -from multiprocessing import Lock from pathlib import Path from typing import Any, Dict, List, Optional @@ -30,5 +27,10 @@ class FastFlix(BaseModel): log_queue: Any = None current_video: Optional[Video] = None - queue: Any = None - queue_lock: Any = None + + # Conversion + currently_encoding: bool = False + conversion_paused: bool = False + conversion_list: List[Video] = Field(default_factory=list) + current_video_encode_index = 0 + current_command_encode_index = 0 diff --git a/fastflix/models/profiles.py b/fastflix/models/profiles.py index 45c7e89f..82b0bc20 100644 --- a/fastflix/models/profiles.py +++ b/fastflix/models/profiles.py @@ -18,10 +18,14 @@ rav1eSettings, x264Settings, x265Settings, + QSVEncCSettings, + QSVEncCH264Settings, NVEncCSettings, NVEncCAVCSettings, VCEEncCAVCSettings, VCEEncCSettings, + HEVCVideoToolboxSettings, + H264VideoToolboxSettings, ) from fastflix.encoders.common.audio import channel_list @@ -148,7 +152,11 @@ class Profile(BaseModel): webp: Optional[WebPSettings] = None copy_settings: Optional[CopySettings] = None ffmpeg_hevc_nvenc: Optional[FFmpegNVENCSettings] = None + qsvencc_hevc: Optional[QSVEncCSettings] = None + qsvencc_avc: Optional[QSVEncCH264Settings] = None nvencc_hevc: Optional[NVEncCSettings] = None nvencc_avc: Optional[NVEncCAVCSettings] = None vceencc_hevc: Optional[VCEEncCSettings] = None vceencc_avc: Optional[VCEEncCAVCSettings] = None + hevc_videotoolbox: Optional[HEVCVideoToolboxSettings] = None + h264_videotoolbox: Optional[H264VideoToolboxSettings] = None diff --git a/fastflix/models/video.py b/fastflix/models/video.py index 086bec84..b29c248d 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -20,10 +20,14 @@ rav1eSettings, x264Settings, x265Settings, + QSVEncCSettings, + QSVEncCH264Settings, NVEncCSettings, NVEncCAVCSettings, VCEEncCSettings, VCEEncCAVCSettings, + HEVCVideoToolboxSettings, + H264VideoToolboxSettings, ) __all__ = ["VideoSettings", "Status", "Video", "Crop", "Status"] @@ -102,10 +106,14 @@ class VideoSettings(BaseModel): WebPSettings, CopySettings, FFmpegNVENCSettings, + QSVEncCSettings, + QSVEncCH264Settings, NVEncCSettings, NVEncCAVCSettings, VCEEncCSettings, VCEEncCAVCSettings, + HEVCVideoToolboxSettings, + H264VideoToolboxSettings, ] = None audio_tracks: List[AudioTrack] = Field(default_factory=list) subtitle_tracks: List[SubtitleTrack] = Field(default_factory=list) diff --git a/fastflix/shared.py b/fastflix/shared.py index 93bdfcd2..86208d24 100644 --- a/fastflix/shared.py +++ b/fastflix/shared.py @@ -174,7 +174,7 @@ def time_to_number(string_time: str) -> float: except ValueError: logger.info(f"{t('Not a valid int for time conversion')}: {v}") else: - total += v * (60 ** i) + total += v * (60**i) return total diff --git a/fastflix/version.py b/fastflix/version.py index 25de4466..edd1671e 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "4.8.1" +__version__ = "4.9.0" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 2a3eb1b2..b55d1921 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -91,7 +91,7 @@ def closeEvent(self, a0: QtGui.QCloseEvent) -> None: self.pb.stop_signal.emit() except Exception: pass - if self.main.converting: + if self.app.fastflix.currently_encoding: sm = QtWidgets.QMessageBox() sm.setText(f"

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

") sm.addButton(t("Cancel Conversion"), QtWidgets.QMessageBox.RejectRole) diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 03061f2c..d55be65c 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -11,7 +11,9 @@ import time from datetime import timedelta from pathlib import Path -from typing import Tuple, Union +from typing import Tuple, Union, Optional +from collections import namedtuple +import inspect import pkg_resources import reusables @@ -34,7 +36,6 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.video import Status, Video, VideoSettings, Crop -from fastflix.ff_queue import save_queue from fastflix.resources import ( get_icon, main_icon, @@ -57,6 +58,14 @@ only_int = QtGui.QIntValidator() +Request = namedtuple( + "Request", + ["request", "video_uuid", "command_uuid", "command", "work_dir", "log_name"], + defaults=[None, None, None, None, None], +) + +Response = namedtuple("Response", ["status", "video_uuid", "command_uuid"]) + class CropWidgets(BaseModel): top: QtWidgets.QLineEdit = None @@ -117,9 +126,8 @@ def items(self): class Main(QtWidgets.QWidget): completed = QtCore.Signal(int) thumbnail_complete = QtCore.Signal(int) - cancelled = QtCore.Signal(str) close_event = QtCore.Signal() - status_update_signal = QtCore.Signal() + status_update_signal = QtCore.Signal(tuple) thread_logging_signal = QtCore.Signal(str) def __init__(self, parent, app: FastFlixApp): @@ -209,15 +217,14 @@ def __init__(self, parent, app: FastFlixApp): available_audio_encoders=self.app.fastflix.audio_encoders, ) - self.completed.connect(self.conversion_complete) - self.cancelled.connect(self.conversion_cancelled) + # self.completed.connect(self.conversion_complete) + # self.cancelled.connect(self.conversion_cancelled) self.close_event.connect(self.close) self.thumbnail_complete.connect(self.thumbnail_generated) self.status_update_signal.connect(self.status_update) self.thread_logging_signal.connect(self.thread_logger) self.encoding_worker = None self.command_runner = None - self.converting = False self.side_data = Box() self.default_options = Box() @@ -263,7 +270,7 @@ def fade_loop(self, percent=90): op = QtWidgets.QGraphicsOpacityEffect() op.setOpacity(percent) self.source_video_path_widget.setStyleSheet( - f"color: rgba({get_text_color(self.app.fastflix.config.theme)}, {percent/100}); padding: 0 0 -1px 5px;" + f"color: rgba({get_text_color(self.app.fastflix.config.theme)}, {percent / 100}); padding: 0 0 -1px 5px;" ) self.source_video_path_widget.setGraphicsEffect(op) QtCore.QTimer.singleShot(200, lambda: self.fade_loop(percent - 10)) @@ -278,7 +285,7 @@ def init_top_bar(self): source = QtWidgets.QPushButton(QtGui.QIcon(self.get_icon("onyx-source")), f" {t('Source')}") source.setIconSize(QtCore.QSize(22, 22)) - source.setFixedHeight(40) + source.setFixedHeight(50) source.setDefault(True) source.clicked.connect(lambda: self.open_file()) @@ -289,7 +296,7 @@ def init_top_bar(self): self.widgets.profile_box.setCurrentText(self.app.fastflix.config.selected_profile) self.widgets.profile_box.currentIndexChanged.connect(self.set_profile) self.widgets.profile_box.setFixedWidth(250) - self.widgets.profile_box.setFixedHeight(40) + self.widgets.profile_box.setFixedHeight(50) top_bar.addWidget(source) top_bar.addWidget(QtWidgets.QSplitter(QtCore.Qt.Horizontal)) @@ -302,7 +309,7 @@ def init_top_bar(self): QtGui.QIcon(self.get_icon("onyx-new-profile")), f' {t("New Profile")}' ) # add_profile.setFixedSize(QtCore.QSize(40, 40)) - self.add_profile.setFixedHeight(40) + self.add_profile.setFixedHeight(50) self.add_profile.setIconSize(QtCore.QSize(20, 20)) self.add_profile.setToolTip(t("Profile_newprofiletooltip")) # add_profile.setLayoutDirection(QtCore.Qt.RightToLeft) @@ -336,14 +343,14 @@ def init_top_bar_right(self): queue = QtWidgets.QPushButton(QtGui.QIcon(onyx_queue_add_icon), f"{t('Add to Queue')} ") queue.setIconSize(QtCore.QSize(26, 26)) - queue.setFixedHeight(40) + queue.setFixedHeight(50) queue.setStyleSheet(theme) queue.setLayoutDirection(QtCore.Qt.RightToLeft) queue.clicked.connect(lambda: self.add_to_queue()) self.widgets.convert_button = QtWidgets.QPushButton(QtGui.QIcon(onyx_convert_icon), f"{t('Convert')} ") self.widgets.convert_button.setIconSize(QtCore.QSize(26, 26)) - self.widgets.convert_button.setFixedHeight(40) + self.widgets.convert_button.setFixedHeight(50) self.widgets.convert_button.setStyleSheet(theme) self.widgets.convert_button.setLayoutDirection(QtCore.Qt.RightToLeft) self.widgets.convert_button.clicked.connect(lambda: self.encode_video()) @@ -414,18 +421,21 @@ def init_video_area(self): source_layout = QtWidgets.QHBoxLayout() source_label = QtWidgets.QLabel(t("Source")) - source_label.setFixedWidth(75) + source_label.setFixedWidth(85) + self.source_video_path_widget.setFixedHeight(23) source_layout.addWidget(source_label) source_layout.addWidget(self.source_video_path_widget, stretch=True) output_layout = QtWidgets.QHBoxLayout() output_label = QtWidgets.QLabel(t("Output")) - output_label.setFixedWidth(75) + output_label.setFixedWidth(85) + self.output_video_path_widget.setFixedHeight(23) output_layout.addWidget(output_label) output_layout.addWidget(self.output_video_path_widget, stretch=True) self.output_path_button = QtWidgets.QPushButton(icon=QtGui.QIcon(self.get_icon("onyx-output"))) self.output_path_button.clicked.connect(lambda: self.save_file()) self.output_path_button.setDisabled(True) + self.output_path_button.setFixedHeight(23) # self.output_path_button.setFixedHeight(12) self.output_path_button.setIconSize(QtCore.QSize(16, 16)) self.output_path_button.setFixedSize(QtCore.QSize(16, 16)) @@ -438,10 +448,10 @@ def init_video_area(self): title_layout = QtWidgets.QHBoxLayout() title_label = QtWidgets.QLabel(t("Title")) - title_label.setFixedWidth(75) + title_label.setFixedWidth(85) title_label.setToolTip(t('Set the "title" tag, sometimes shown as "Movie Name"')) self.widgets.video_title = QtWidgets.QLineEdit() - self.widgets.video_title.setFixedHeight(20) + self.widgets.video_title.setFixedHeight(23) self.widgets.video_title.setToolTip(t('Set the "title" tag, sometimes shown as "Movie Name"')) self.widgets.video_title.textChanged.connect(lambda: self.page_update(build_thumbnail=False)) @@ -525,14 +535,14 @@ def init_video_track_select(self): layout = QtWidgets.QHBoxLayout() self.widgets.video_track = QtWidgets.QComboBox() self.widgets.video_track.addItems([]) - self.widgets.video_track.setFixedHeight(20) + self.widgets.video_track.setFixedHeight(23) self.widgets.video_track.currentIndexChanged.connect(self.video_track_update) self.widgets.video_track.setStyleSheet("height: 5px") if self.app.fastflix.config.theme == "onyx": self.widgets.video_track.setStyleSheet("background-color: #707070; border-radius: 10px; color: black") track_label = QtWidgets.QLabel(t("Video Track")) - track_label.setFixedWidth(70) + track_label.setFixedWidth(80) layout.addWidget(track_label) layout.addWidget(self.widgets.video_track, stretch=1) layout.setSpacing(10) @@ -633,7 +643,7 @@ def init_encoder_drop_down(self): layout = QtWidgets.QHBoxLayout() self.widgets.convert_to = QtWidgets.QComboBox() self.widgets.convert_to.setMinimumWidth(180) - self.widgets.convert_to.setFixedHeight(40) + self.widgets.convert_to.setFixedHeight(50) self.change_output_types() self.widgets.convert_to.view().setFixedWidth(self.widgets.convert_to.minimumSizeHint().width() + 50) self.widgets.convert_to.currentTextChanged.connect(self.change_encoder) @@ -845,7 +855,7 @@ def title(self): def build_hoz_int_field( self, name, - button_size=22, + button_size=28, left_stretch=True, right_stretch=True, layout=None, @@ -1113,7 +1123,7 @@ def build_crop(self) -> Union[Crop, None]: ) except AssertionError as err: error_message(f"{t('Invalid Crop')}: {err}") - return + return None return crop def keep_aspect_update(self) -> None: @@ -1784,7 +1794,7 @@ def get_icon(self, name): @reusables.log_exception("fastflix", show_traceback=True) def encode_video(self): - if self.converting: + 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 @@ -1793,17 +1803,20 @@ def encode_video(self): self.video_options.queue.reset_pause_encode() return - if not self.app.fastflix.queue or self.app.fastflix.current_video: + if self.app.fastflix.conversion_paused: + return error_message("Queue is currently paused") + + if not self.app.fastflix.conversion_list or self.app.fastflix.current_video: add_current = True - if self.app.fastflix.queue and self.app.fastflix.current_video: + 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 - requests = ["add_items", str(self.app.fastflix.log_path)] - for video in self.app.fastflix.queue: + 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")) @@ -1811,72 +1824,55 @@ def encode_video(self): logger.debug(t("Starting conversion process")) - self.converting = True + self.app.fastflix.currently_encoding = True self.set_convert_button(False) - self.app.fastflix.worker_queue.put(tuple(requests)) + self.send_video_request_to_worker_queue(video_to_send) self.disable_all() self.video_options.show_status() - def get_commands(self): - commands = [] - for video in self.app.fastflix.queue: - if video.status.complete or video.status.error: - continue - for command in video.video_settings.conversion_commands: - commands.append( - ( - video.uuid, - command.uuid, - command.command, - str(video.work_path), - str(video.video_settings.output_path.stem), - ) - ) - return commands + # def get_commands(self): + # commands = [] + # for video in self.get_queue_list(): + # if video.status.complete or video.status.error: + # continue + # for command in video.video_settings.conversion_commands: + # commands.append( + # ( + # video.uuid, + # command.uuid, + # command.command, + # str(video.work_path), + # str(video.video_settings.output_path.stem), + # ) + # ) + # return commands def add_to_queue(self): - if not self.encoding_checks(): - return False - - if not self.build_commands(): - return False - - source_in_queue = False - for video in self.app.fastflix.queue: - if video.status.complete: - continue - if self.app.fastflix.current_video.source == video.source: - source_in_queue = True - if self.app.fastflix.current_video.video_settings.output_path == video.video_settings.output_path: - error_message(f"{video.video_settings.output_path} {t('out file is already in queue')}") - return False - - # if source_in_queue: - # TODO ask if ok - # return - - with self.app.fastflix.queue_lock: - self.app.fastflix.queue.append(copy.deepcopy(self.app.fastflix.current_video)) + 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.update_queue() 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)) + # 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() - with self.app.fastflix.queue_lock: - save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) return True - @reusables.log_exception("fastflix", show_traceback=False) - def conversion_complete(self, return_code): - self.converting = False + # @reusables.log_exception("fastflix", show_traceback=False) + def conversion_complete(self, success: bool): self.paused = False self.set_convert_button() - if return_code: + if not success: error_message(t("There was an error during conversion and the queue has stopped"), title=t("Error")) self.video_options.queue.new_source() else: @@ -1889,35 +1885,25 @@ def conversion_complete(self, return_code): else: message(t("All queue items have completed"), title=t("Success")) - @reusables.log_exception("fastflix", show_traceback=False) - def conversion_cancelled(self, data): - self.converting = False + # + # @reusables.log_exception("fastflix", show_traceback=False) + def conversion_cancelled(self, video: Video): + self.app.fastflix.worker_queue.put(Request("cancel")) + self.app.fastflix.currently_encoding = False self.set_convert_button() - if not data: - return - - try: - video_uuid, *_ = data.split("|") - cancelled_video = self.find_video(video_uuid) - exists = cancelled_video.video_settings.output_path.exists() - except Exception: - return + 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" - f"{cancelled_video.video_settings.output_path}?" - ) + sm.setText(f"{t('Conversion cancelled, delete incomplete file')}\n" f"{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: - cancelled_video = self.find_video(video_uuid) - cancelled_video.video_settings.output_path.unlink(missing_ok=True) + video.video_settings.output_path.unlink(missing_ok=True) except OSError: pass @@ -1959,14 +1945,92 @@ def dragEnterEvent(self, event): def dragMoveEvent(self, event): event.accept() if event.mimeData().hasUrls else event.ignoreAF() - def status_update(self): - logger.debug(f"Updating queue from command worker") - with self.app.fastflix.queue_lock: - save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) + 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.app.fastflix.currently_encoding = False + 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 + if response.status == "error": + video.status.error = True + errored = True + break + + if errored and not self.video_options.queue.ignore_errors.isChecked(): + self.app.fastflix.currently_encoding = False + self.conversion_complete(success=False) + self.video_options.update_queue() + 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.app.fastflix.currently_encoding = False + self.conversion_complete(success=True) + self.video_options.update_queue() + return + + self.app.fastflix.currently_encoding = True + if not same_video and self.app.fastflix.conversion_paused: + self.app.fastflix.currently_encoding = False + self.video_options.update_queue() + return + + self.send_video_request_to_worker_queue(video_to_send) + + 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 + return True + 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 + + # 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, + ) + ) + video.status.running = True self.video_options.update_queue() def find_video(self, uuid) -> Video: - for video in self.app.fastflix.queue: + for video in self.app.fastflix.conversion_list: if uuid == video.uuid: return video raise FlixError(f'{t("No video found for")} {uuid}') @@ -1993,17 +2057,21 @@ def run(self): # Message looks like (command, video_uuid, command_uuid) status = self.status_queue.get() self.app.processEvents() - self.main.status_update_signal.emit() - self.app.processEvents() - if status[0] == "complete": - self.main.completed.emit(0) - elif status[0] == "error": - self.main.completed.emit(1) - elif status[0] == "cancelled": - self.main.cancelled.emit("|".join(status[1:])) - elif status[0] == "exit": + if status[0] == "exit": + logger.debug("GUI received ask to exit") try: self.terminate() finally: self.main.close_event.emit() return + self.main.status_update_signal.emit(status) + self.app.processEvents() + # if status[0] == "complete": + # logger.debug("GUI received status queue complete") + # self.main.completed.emit(0) + # elif status[0] == "error": + # logger.debug("GUI received status queue errored") + # self.main.completed.emit(1) + # elif status[0] == "cancelled": + # logger.debug("GUI received status queue errored") + # self.main.cancelled.emit("|".join(status[1:])) diff --git a/fastflix/widgets/panels/debug_panel.py b/fastflix/widgets/panels/debug_panel.py index 339c3035..935aa28a 100644 --- a/fastflix/widgets/panels/debug_panel.py +++ b/fastflix/widgets/panels/debug_panel.py @@ -46,7 +46,7 @@ def reset(self): self.addTab(self.get_textbox(Box(self.app.fastflix.config.dict())), "Config") self.addTab(self.get_textbox(Box(self.get_ffmpeg_details())), "FFmpeg Details") - self.addTab(self.get_textbox(BoxList(self.app.fastflix.queue)), "Queue") + self.addTab(self.get_textbox(BoxList(self.app.fastflix.conversion_list)), "Queue") self.addTab(self.get_textbox(Box(self.app.fastflix.encoders)), "Encoders") self.addTab(self.get_textbox(BoxList(self.app.fastflix.audio_encoders)), "Audio Encoders") if self.app.fastflix.current_video: diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index a51ff497..794c640a 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -4,6 +4,8 @@ import copy import sys import logging +import os +from pathlib import Path import reusables from box import Box @@ -14,8 +16,9 @@ from fastflix.models.video import Video from fastflix.ff_queue import get_queue, save_queue from fastflix.resources import get_icon, get_bool_env -from fastflix.shared import no_border, open_folder, yes_no_message +from fastflix.shared import no_border, open_folder, yes_no_message, message, error_message from fastflix.widgets.panels.abstract_list import FlixList +from fastflix.exceptions import FastFlixInternalException logger = logging.getLogger("fastflix") @@ -135,7 +138,7 @@ def __init__(self, parent, video: Video, index, first=False): grid.addWidget(QtWidgets.QLabel(f"{t('Audio Tracks')}: {len(video.video_settings.audio_tracks)}"), 0, 5) grid.addWidget(QtWidgets.QLabel(f"{t('Subtitles')}: {len(video.video_settings.subtitle_tracks)}"), 0, 6) grid.addWidget(QtWidgets.QLabel(status), 0, 7) - if video.status.complete and not get_bool_env("FF_DOCKERMODE"): + if not video.status.error and video.status.complete and not get_bool_env("FF_DOCKERMODE"): grid.addWidget(view_button, 0, 8) grid.addWidget(open_button, 0, 9) elif add_retry: @@ -189,7 +192,6 @@ class EncodingQueue(FlixList): def __init__(self, parent, app: FastFlixApp): self.main = parent.main self.app = app - self.paused = False self.encode_paused = False self.encoding = False top_layout = QtWidgets.QHBoxLayout() @@ -197,11 +199,19 @@ def __init__(self, parent, app: FastFlixApp): top_layout.addWidget(QtWidgets.QLabel(t("Queue"))) top_layout.addStretch(1) + self.save_queue_button = QtWidgets.QPushButton(t("Save Queue to File")) + self.save_queue_button.clicked.connect(self.manually_save_queue) + self.save_queue_button.setFixedWidth(150) + + self.load_queue_button = QtWidgets.QPushButton(t("Load Queue from File")) + self.load_queue_button.clicked.connect(self.manually_load_queue) + self.load_queue_button.setFixedWidth(160) + self.clear_queue = QtWidgets.QPushButton( QtGui.QIcon(get_icon("onyx-clear-queue", self.app.fastflix.config.theme)), t("Clear Completed") ) self.clear_queue.clicked.connect(self.clear_complete) - self.clear_queue.setFixedWidth(120) + self.clear_queue.setFixedWidth(150) self.clear_queue.setToolTip(t("Remove completed tasks")) self.pause_queue = QtWidgets.QPushButton( @@ -209,7 +219,7 @@ def __init__(self, parent, app: FastFlixApp): ) self.pause_queue.clicked.connect(self.pause_resume_queue) # pause_queue.setFixedHeight(40) - self.pause_queue.setFixedWidth(120) + self.pause_queue.setFixedWidth(130) self.pause_queue.setToolTip( t("Wait for the current command to finish," " and stop the next command from processing") ) @@ -219,9 +229,13 @@ def __init__(self, parent, app: FastFlixApp): ) self.pause_encode.clicked.connect(self.pause_resume_encode) # pause_queue.setFixedHeight(40) - self.pause_encode.setFixedWidth(120) + self.pause_encode.setFixedWidth(130) 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") actions = set() @@ -239,8 +253,13 @@ def __init__(self, parent, app: FastFlixApp): self.after_done_combo.setToolTip("Run a command after conversion completes") self.after_done_combo.currentIndexChanged.connect(lambda: self.set_after_done()) self.after_done_combo.setMaximumWidth(150) + + top_layout.addWidget(self.load_queue_button, QtCore.Qt.AlignRight) + top_layout.addWidget(self.save_queue_button, QtCore.Qt.AlignRight) + top_layout.addStretch(1) top_layout.addWidget(QtWidgets.QLabel(t("After Conversion"))) top_layout.addWidget(self.after_done_combo, QtCore.Qt.AlignRight) + top_layout.addWidget(self.ignore_errors, QtCore.Qt.AlignRight) top_layout.addWidget(self.pause_encode, QtCore.Qt.AlignRight) top_layout.addWidget(self.pause_queue, QtCore.Qt.AlignRight) top_layout.addWidget(self.clear_queue, QtCore.Qt.AlignRight) @@ -250,50 +269,79 @@ def __init__(self, parent, app: FastFlixApp): self.queue_startup_check() except Exception: logger.exception("Could not load queue as it is outdated or malformed. Deleting for safety.") - with self.app.fastflix.queue_lock: - save_queue([], queue_file=self.app.fastflix.queue_path, config=self.app.fastflix.config) + # with self.app.fastflix.queue_lock: + # save_queue([], queue_file=self.app.fastflix.queue_path, config=self.app.fastflix.config) + + def queue_startup_check(self, queue_file=None): + new_queue = get_queue(queue_file or self.app.fastflix.queue_path) - def queue_startup_check(self): - new_queue = get_queue(self.app.fastflix.queue_path, self.app.fastflix.config) - # self.app.fastflix.queue.append(item) - reset_vids = [] remove_vids = [] for i, video in enumerate(new_queue): - if video.status.running: - reset_vids.append(i) if video.status.complete: remove_vids.append(video) - - for index in reset_vids: - vid: Video = new_queue.pop(index) - vid.status.clear() - new_queue.insert(index, vid) + else: + video.status.clear() for video in remove_vids: new_queue.remove(video) - if new_queue: + if queue_file: + self.app.fastflix.conversion_list = new_queue + elif new_queue: if yes_no_message( f"{t('Not all items in the queue were completed')}\n" f"{t('Would you like to keep them in the queue?')}", title="Recover Queue Items", ): - with self.app.fastflix.queue_lock: - for item in new_queue: - self.app.fastflix.queue.append(item) - # self.app.fastflix.queue = [] - with self.app.fastflix.queue_lock: - save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) - self.new_source() + + self.app.fastflix.conversion_list = new_queue + self.new_source() + save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) + + def manually_save_queue(self): + filename = QtWidgets.QFileDialog.getSaveFileName( + self, + caption=t("Save Queue"), + dir=os.path.expanduser("~"), + filter=f"FastFlix Queue File (*.yaml)", + ) + if filename and filename[0]: + save_queue(self.app.fastflix.conversion_list, filename[0], self.app.fastflix.config) + message(t("Queue saved to") + f"{filename[0]}") + + def manually_load_queue(self): + filename = QtWidgets.QFileDialog.getOpenFileName( + self, caption=t("Load Queue"), dir=os.path.expanduser("~"), filter=f"FastFlix Queue File (*.yaml)" + ) + if filename and filename[0]: + is_yes = True + if self.app.fastflix.conversion_list: + is_yes = yes_no_message( + ( + t("This will remove all items in the queue currently") + + "\n" + + t(f"It will update it with the contents of") + + f":\n\n {filename[0]}\n\n" + + t("Are you sure you want to proceed?") + ), + title="Overwrite existing queue?", + ) + filename = Path(filename[0]) + if not filename.exists(): + error_message(t("That file doesn't exist")) + if is_yes: + self.queue_startup_check(filename) def reorder(self, update=True): + if self.app.fastflix.currently_encoding: + # TODO error? + logger.warning("Reorder queue called while encoding") + return super().reorder(update=update) - - with self.app.fastflix.queue_lock: - for i in range(len(self.app.fastflix.queue)): - self.app.fastflix.queue.pop() - for track in self.tracks: - self.app.fastflix.queue.append(track.video) + # TODO find better reorder method + self.app.fastflix.conversion_list = [] + for track in self.tracks: + self.app.fastflix.conversion_list.append(track.video) for track in self.tracks: track.widgets.up_button.setDisabled(False) @@ -301,12 +349,14 @@ def reorder(self, update=True): if self.tracks: self.tracks[0].widgets.up_button.setDisabled(True) self.tracks[-1].widgets.down_button.setDisabled(True) + save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) def new_source(self): for track in self.tracks: track.close() self.tracks = [] - for i, video in enumerate(self.app.fastflix.queue, start=1): + + for i, video in enumerate(self.app.fastflix.conversion_list, start=1): self.tracks.append(EncodeItem(self, video, index=i)) if self.tracks: self.tracks[0].widgets.up_button.setDisabled(True) @@ -317,24 +367,25 @@ def clear_complete(self): for queued_item in self.tracks: if queued_item.video.status.complete: self.remove_item(queued_item.video, part_of_clear=True) - with self.app.fastflix.queue_lock: - save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) self.new_source() def remove_item(self, video, part_of_clear=False): - with self.app.fastflix.queue_lock: - for i, vid in enumerate(self.app.fastflix.queue): - if vid.uuid == video.uuid: - pos = i - break - else: - logger.error("No matching video found to remove from queue") - return - self.app.fastflix.queue.pop(pos) - if not part_of_clear: - save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) + if self.app.fastflix.currently_encoding: + # TODO error + return + + for i, vid in enumerate(self.app.fastflix.conversion_list): + if vid.uuid == video.uuid: + pos = i + break + else: + logger.error("No matching video found to remove from queue") + return + self.app.fastflix.conversion_list.pop(pos) + if not part_of_clear: self.new_source() + save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) def reload_from_queue(self, video): self.main.reload_video_from_queue(video) @@ -346,20 +397,18 @@ def reset_pause_encode(self): self.encode_paused = False def pause_resume_queue(self): - if self.paused: + if self.app.fastflix.conversion_paused: self.pause_queue.setText(t("Pause Queue")) - self.pause_queue.setIcon(self.app.style().standardIcon(QtWidgets.QStyle.SP_MediaPause)) - for i, video in enumerate(self.app.fastflix.queue): - if video.status.ready: - self.main.converting = True - self.main.set_convert_button(False) - break - self.app.fastflix.worker_queue.put(["resume queue"]) + self.pause_queue.setIcon(QtGui.QIcon(get_icon("onyx-pause", self.app.fastflix.config.theme))) + send_next = self.main.send_next_video() + if send_next: + self.main.set_convert_button(False) + logger.debug(f"queue resumed, will I send next? {send_next}") else: self.pause_queue.setText(t("Resume Queue")) - self.pause_queue.setIcon(self.app.style().standardIcon(QtWidgets.QStyle.SP_MediaPlay)) - self.app.fastflix.worker_queue.put(["pause queue"]) - self.paused = not self.paused + self.pause_queue.setIcon(QtGui.QIcon(get_icon("play", self.app.fastflix.config.theme))) + # self.app.fastflix.worker_queue.put(["pause queue"]) + self.app.fastflix.conversion_paused = not self.app.fastflix.conversion_paused def pause_resume_encode(self): if self.encode_paused: @@ -381,6 +430,12 @@ 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() @@ -397,20 +452,45 @@ def set_after_done(self): self.app.fastflix.worker_queue.put(["set after done", command]) def retry_video(self, current_video): - with self.app.fastflix.queue_lock: - for i, video in enumerate(self.app.fastflix.queue): - if video.uuid == current_video.uuid: - video_pos = i - break - else: - logger.error(f"Can't find video {current_video.uuid} in queue to update its status") - return - video = self.app.fastflix.queue.pop(video_pos) - video.status.cancelled = False - video.status.current_command = 0 + for i, video in enumerate(self.app.fastflix.conversion_list): + if video.uuid == current_video.uuid: + video.status.clear() + break + else: + logger.error(f"Can't find video {current_video.uuid} in queue to update its status") + return + self.new_source() + + def move_up(self, widget): + if not self.app.fastflix.currently_encoding: + super().move_up(widget) - self.app.fastflix.queue.insert(video_pos, video) - save_queue(self.app.fastflix.queue, self.app.fastflix.queue_path, self.app.fastflix.config) + def move_down(self, widget): + if not self.app.fastflix.currently_encoding: + super().move_down(widget) + def add_to_queue(self): + if not self.main.encoding_checks(): + return False + + if not self.main.build_commands(): + return False + + for video in self.app.fastflix.conversion_list: + if video.status.complete: + continue + if self.app.fastflix.current_video.source == video.source: + source_in_queue = True + if self.app.fastflix.current_video.video_settings.output_path == video.video_settings.output_path: + raise FastFlixInternalException( + f"{video.video_settings.output_path} {t('out file is already in queue')}" + ) + + # if source_in_queue: + # TODO ask if ok + # return + + self.app.fastflix.conversion_list.append(copy.deepcopy(self.app.fastflix.current_video)) self.new_source() + save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 0025711c..8260e06e 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -149,15 +149,25 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): layout.addWidget(self.vceenc_path, 13, 1) layout.addWidget(vceenc_path_button, 13, 2) + qsvencc_label = QtWidgets.QLabel("QSVEncC") + self.qsvenc_path = QtWidgets.QLineEdit() + if self.app.fastflix.config.qsvencc: + self.qsvenc_path.setText(str(self.app.fastflix.config.qsvencc)) + qsvencc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + qsvencc_path_button.clicked.connect(lambda: self.select_qsvencc()) + layout.addWidget(qsvencc_label, 14, 0) + layout.addWidget(self.qsvenc_path, 14, 1) + layout.addWidget(qsvencc_path_button, 14, 2) + hdr10_parser_label = QtWidgets.QLabel(t("HDR10+ Parser")) self.hdr10_parser_path = QtWidgets.QLineEdit() if self.app.fastflix.config.hdr10plus_parser: self.hdr10_parser_path.setText(str(self.app.fastflix.config.hdr10plus_parser)) hdr10_parser_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) hdr10_parser_path_button.clicked.connect(lambda: self.select_hdr10_parser()) - layout.addWidget(hdr10_parser_label, 14, 0) - layout.addWidget(self.hdr10_parser_path, 14, 1) - layout.addWidget(hdr10_parser_path_button, 14, 2) + layout.addWidget(hdr10_parser_label, 15, 0) + layout.addWidget(self.hdr10_parser_path, 15, 1) + layout.addWidget(hdr10_parser_path_button, 15, 2) layout.addWidget(self.use_sane_audio, 7, 0, 1, 2) layout.addWidget(self.disable_version_check, 8, 0, 1, 2) @@ -173,7 +183,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): button_layout.addWidget(cancel) button_layout.addWidget(save) - layout.addLayout(button_layout, 16, 0, 1, 3) + layout.addLayout(button_layout, 17, 0, 1, 3) self.setLayout(layout) @@ -217,6 +227,11 @@ def save(self): restart_needed = True self.app.fastflix.config.nvencc = new_nvencc + new_qsvencc = Path(self.qsvenc_path.text()) if self.qsvenc_path.text() else None + if self.app.fastflix.config.qsvencc != new_qsvencc: + restart_needed = True + self.app.fastflix.config.qsvencc = new_qsvencc + new_vce = Path(self.vceenc_path.text()) if self.vceenc_path.text() else None if self.app.fastflix.config.vceencc != new_vce: restart_needed = True @@ -251,6 +266,15 @@ def select_nvencc(self): return self.nvencc_path.setText(filename[0]) + def select_qsvencc(self): + dirname = Path(self.qsvenc_path.text()).parent + if not dirname.exists(): + dirname = Path() + filename = QtWidgets.QFileDialog.getOpenFileName(self, caption="QSVEncC location", dir=str(dirname)) + if not filename or not filename[0]: + return + self.qsvenc_path.setText(filename[0]) + def select_vceenc(self): dirname = Path(self.vceenc_path.text()).parent if not dirname.exists(): diff --git a/fastflix/widgets/windows/profile_window.py b/fastflix/widgets/windows/profile_window.py index 14502535..608f7863 100644 --- a/fastflix/widgets/windows/profile_window.py +++ b/fastflix/widgets/windows/profile_window.py @@ -21,11 +21,15 @@ rav1eSettings, x264Settings, x265Settings, + QSVEncCSettings, + QSVEncCH264Settings, NVEncCSettings, NVEncCAVCSettings, FFmpegNVENCSettings, VCEEncCAVCSettings, VCEEncCSettings, + H264VideoToolboxSettings, + HEVCVideoToolboxSettings, ) from fastflix.models.profiles import AudioMatch, Profile, MatchItem, MatchType, AdvancedOptions from fastflix.shared import error_message @@ -510,6 +514,14 @@ def save(self): new_profile.copy_settings = self.encoder elif isinstance(self.encoder, NVEncCSettings): new_profile.nvencc_hevc = self.encoder + elif isinstance(self.encoder, QSVEncCSettings): + new_profile.qsvencc_hevc = self.encoder + elif isinstance(self.encoder, QSVEncCH264Settings): + new_profile.qsvencc_avc = self.encoder + elif isinstance(self.encoder, H264VideoToolboxSettings): + new_profile.h264_videotoolbox = self.encoder + elif isinstance(self.encoder, HEVCVideoToolboxSettings): + new_profile.hevc_videotoolbox = self.encoder elif isinstance(self.encoder, NVEncCAVCSettings): new_profile.nvencc_avc = self.encoder elif isinstance(self.encoder, FFmpegNVENCSettings): diff --git a/requirements.txt b/requirements.txt index 39f0b76d..0a3a674a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,6 @@ pathvalidate~=2.4.1 psutil~=5.8.0 pydantic~=1.8.2 PySide2~=5.15.2.1 -python-box[all]~=6.0.0rc4 +python-box[all]~=6.0.1 requests~=2.25.1 reusables~=0.9.6