From e5befe620df70a2361b44f5e0d8f96c84241f89b Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 11 Feb 2026 07:46:13 -0600 Subject: [PATCH 01/10] * Adding Terms and Agreements dialog shown on first startup requiring user acceptance before proceeding * Fixing #349 NVEncC audio conversion losing multichannel layout for EAC3 (thanks to Wontell) * Fixing #384 Remove HDR leaving Dolby Vision metadata traces in Rigaya encoder output (thanks to end2endzone) * Fixing #511 UI labels and buttons truncated in non-English translations by auto-shrinking text to fit (thanks to PegHorse) * Fixing #514 excessive memory usage when adding directory of files (thanks to gxcreator) * Fixing #548 incorrect aspect ratio for DVD sources with non-square pixels on auto resolution (thanks to DCNerds) * Fixing #600 anime subtitle size increasing during burn-in encoding (thanks to TinderboxUK) * Fixing #693 subtitle tracks losing title metadata during encoding (thanks to mpissarello) * Fixing #715 WINDOWS_BUILD.md needed updated to show Python 3.13 (thanks to Jack L) * Fixing Settings window staying on top and freezing when notification dialogs appeared behind it * Fixing tab bar scroll arrows being too small and both stuck on the right side - now larger with left arrow on far left --- CHANGES | 14 + WINDOWS_BUILD.md | 2 +- fastflix/application.py | 10 + fastflix/data/languages.yaml | 311 +++++++++++++++++- fastflix/encoders/common/encc_helpers.py | 13 +- fastflix/encoders/common/helpers.py | 11 +- fastflix/encoders/common/subtitles.py | 6 + .../encoders/nvencc_av1/command_builder.py | 6 +- .../encoders/nvencc_hevc/command_builder.py | 6 +- .../encoders/qsvencc_av1/command_builder.py | 6 +- .../encoders/qsvencc_hevc/command_builder.py | 6 +- .../encoders/vceencc_av1/command_builder.py | 6 +- .../encoders/vceencc_hevc/command_builder.py | 6 +- fastflix/models/config.py | 1 + fastflix/models/encode.py | 1 + fastflix/models/video.py | 7 + fastflix/shared.py | 72 +++- fastflix/ui_constants.py | 2 +- fastflix/version.py | 2 +- fastflix/widgets/main.py | 18 +- fastflix/widgets/panels/advanced_panel.py | 4 +- fastflix/widgets/panels/queue_panel.py | 13 +- fastflix/widgets/panels/subtitle_panel.py | 6 +- fastflix/widgets/settings.py | 9 +- fastflix/widgets/terms_agreement.py | 100 ++++++ fastflix/widgets/video_options.py | 44 +++ fastflix/widgets/windows/multiple_files.py | 4 + 27 files changed, 642 insertions(+), 44 deletions(-) create mode 100644 fastflix/widgets/terms_agreement.py diff --git a/CHANGES b/CHANGES index b7837481..36451e32 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,19 @@ # Changelog +## Version 6.1.0 + +* Adding Terms and Agreements dialog shown on first startup requiring user acceptance before proceeding +* Fixing #349 NVEncC audio conversion losing multichannel layout for EAC3 (thanks to Wontell) +* Fixing #384 Remove HDR leaving Dolby Vision metadata traces in Rigaya encoder output (thanks to end2endzone) +* Fixing #511 UI labels and buttons truncated in non-English translations by auto-shrinking text to fit (thanks to PegHorse) +* Fixing #514 excessive memory usage when adding directory of files (thanks to gxcreator) +* Fixing #548 incorrect aspect ratio for DVD sources with non-square pixels on auto resolution (thanks to DCNerds) +* Fixing #600 anime subtitle size increasing during burn-in encoding (thanks to TinderboxUK) +* Fixing #693 subtitle tracks losing title metadata during encoding (thanks to mpissarello) +* Fixing #715 WINDOWS_BUILD.md needed updated to show Python 3.13 (thanks to Jack L) +* Fixing Settings window staying on top and freezing when notification dialogs appeared behind it +* Fixing tab bar scroll arrows being too small and both stuck on the right side - now larger with left arrow on far left + ## Version 6.0.1 * Fixing Dolby Vision copy for Rigaya encoders (NVEncC, QSVEncC, VCEEncC) by adding --dolby-vision-profile copy alongside --dolby-vision-rpu copy diff --git a/WINDOWS_BUILD.md b/WINDOWS_BUILD.md index e34465e2..8f46effa 100644 --- a/WINDOWS_BUILD.md +++ b/WINDOWS_BUILD.md @@ -4,7 +4,7 @@ This guide explains how to build FastFlix executables on Windows. ## Prerequisites -1. **Python 3.12 or higher** +1. **Python 3.13 or higher** - Download from [python.org](https://www.python.org/downloads/) - Make sure to check "Add Python to PATH" during installation diff --git a/fastflix/application.py b/fastflix/application.py index b98c7bb0..086ea981 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -221,6 +221,16 @@ def app_setup( logger.exception(t("Could not load config file!")) sys.exit(1) + if not app.fastflix.config.terms_accepted: + from fastflix.widgets.terms_agreement import TermsAgreementDialog + + dialog = TermsAgreementDialog() + if dialog.exec() == QtWidgets.QDialog.Accepted: + app.fastflix.config.terms_accepted = True + app.fastflix.config.save() + else: + sys.exit(0) + if app.fastflix.config.theme != "system": file = QtCore.QFile(str(breeze_styles_path / app.fastflix.config.theme / "stylesheet.qss")) file.open(QtCore.QFile.OpenModeFlag.ReadOnly | QtCore.QFile.OpenModeFlag.Text) diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index b4a6a1a9..1ffc19f1 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -1831,7 +1831,7 @@ Encoder Settings: Encoding Queue: deu: Kodierer-Warteschlange eng: Encoding Queue - fra: File d'attente d'encodage + fra: Attente de chargement ita: Coda codifica spa: Cola de codificación chs: 编码队列 @@ -12438,3 +12438,312 @@ Video Info: ukr: Інформація про відео kor: 비디오 정보 ron: Informații video +Accept: + eng: Accept + deu: Akzeptieren + fra: Accepter + ita: Accetta + spa: Aceptar + chs: 接受 + jpn: 同意する + rus: Принять + por: Aceitar + swe: Acceptera + pol: Akceptuj + ukr: Прийняти + kor: 동의 + ron: Acceptă +Reject: + eng: Reject + deu: Ablehnen + fra: Refuser + ita: Rifiuta + spa: Rechazar + chs: 拒绝 + jpn: 拒否する + rus: Отклонить + por: Rejeitar + swe: Avvisa + pol: Odrzuć + ukr: Відхилити + kor: 거부 + ron: Respinge +FastFlix Terms and Agreements: + eng: FastFlix Terms and Agreements + deu: FastFlix Nutzungsbedingungen + fra: Conditions d'utilisation de FastFlix + ita: Termini e condizioni di FastFlix + spa: Términos y acuerdos de FastFlix + chs: FastFlix 条款和协议 + jpn: FastFlix 利用規約 + rus: Условия и соглашения FastFlix + por: Termos e acordos do FastFlix + swe: FastFlix villkor och avtal + pol: Warunki i umowy FastFlix + ukr: Умови та угоди FastFlix + kor: FastFlix 이용 약관 + ron: Termeni și acorduri FastFlix +"I have read and agree to the Terms and Agreements": + eng: I have read and agree to the Terms and Agreements + deu: Ich habe die Nutzungsbedingungen gelesen und stimme ihnen zu + fra: J'ai lu et j'accepte les conditions d'utilisation + ita: Ho letto e accetto i termini e le condizioni + spa: He leído y acepto los términos y acuerdos + chs: 我已阅读并同意条款和协议 + jpn: 利用規約を読み、同意します + rus: Я прочитал и согласен с условиями и соглашениями + por: Li e concordo com os termos e acordos + swe: Jag har läst och godkänner villkoren och avtalen + pol: Przeczytałem i zgadzam się z warunkami i umowami + ukr: Я прочитав і погоджуюся з умовами та угодами + kor: 이용 약관을 읽었으며 동의합니다 + ron: Am citit și sunt de acord cu termenii și acordurile +"By using FastFlix, you agree to the following terms:": + eng: "By using FastFlix, you agree to the following terms:" + deu: "Durch die Nutzung von FastFlix stimmen Sie den folgenden Bedingungen zu:" + fra: "En utilisant FastFlix, vous acceptez les conditions suivantes :" + ita: "Utilizzando FastFlix, accetti i seguenti termini:" + spa: "Al usar FastFlix, aceptas los siguientes términos:" + chs: "使用 FastFlix 即表示您同意以下条款:" + jpn: "FastFlixを使用することにより、以下の規約に同意するものとします:" + rus: "Используя FastFlix, вы соглашаетесь со следующими условиями:" + por: "Ao usar o FastFlix, você concorda com os seguintes termos:" + swe: "Genom att använda FastFlix godkänner du följande villkor:" + pol: "Korzystając z FastFlix, zgadzasz się na następujące warunki:" + ukr: "Використовуючи FastFlix, ви погоджуєтеся з наступними умовами:" + kor: "FastFlix를 사용함으로써 다음 약관에 동의하게 됩니다:" + ron: "Prin utilizarea FastFlix, sunteți de acord cu următorii termeni:" +1. Authorized Use Only: + eng: 1. Authorized Use Only + deu: 1. Nur autorisierte Nutzung + fra: 1. Utilisation autorisée uniquement + ita: 1. Solo uso autorizzato + spa: 1. Solo uso autorizado + chs: 1. 仅限授权使用 + jpn: 1. 許可された使用のみ + rus: 1. Только авторизованное использование + por: 1. Apenas uso autorizado + swe: 1. Endast auktoriserad användning + pol: 1. Tylko autoryzowane użycie + ukr: 1. Лише авторизоване використання + kor: 1. 승인된 사용만 허용 + ron: 1. Doar utilizare autorizată +? "You must only use FastFlix to encode, transcode, or otherwise process video content that you own or have the legal rights to use. Unauthorized copying, encoding, or distribution of copyrighted material + is strictly prohibited." +: eng: "You must only use FastFlix to encode, transcode, or otherwise process video content that you own or have the legal rights to use. Unauthorized copying, encoding, or distribution of copyrighted material + is strictly prohibited." + deu: "Sie dürfen FastFlix nur zum Kodieren, Transkodieren oder anderweitigen Verarbeiten von Videoinhalten verwenden, die Ihnen gehören oder für die Sie die gesetzlichen Nutzungsrechte besitzen. Das unbefugte + Kopieren, Kodieren oder Verbreiten von urheberrechtlich geschütztem Material ist strengstens untersagt." + fra: "Vous ne devez utiliser FastFlix que pour encoder, transcoder ou traiter des contenus vidéo dont vous êtes propriétaire ou pour lesquels vous disposez des droits légaux d'utilisation. La copie, l'encodage + ou la distribution non autorisés de matériel protégé par le droit d'auteur sont strictement interdits." + ita: "Devi utilizzare FastFlix solo per codificare, transcodificare o elaborare contenuti video di cui sei proprietario o per i quali possiedi i diritti legali di utilizzo. La copia, la codifica o la + distribuzione non autorizzata di materiale protetto da copyright è severamente vietata." + spa: "Solo debes usar FastFlix para codificar, transcodificar o procesar contenido de video del cual seas propietario o tengas los derechos legales de uso. La copia, codificación o distribución no autorizada + de material protegido por derechos de autor está estrictamente prohibida." + chs: "您只能使用 FastFlix 来编码、转码或以其他方式处理您拥有或具有合法使用权的视频内容。严禁未经授权复制、编码或分发受版权保护的材料。" + jpn: "FastFlixは、お客様が所有する、または使用する法的権利を有するビデオコンテンツのエンコード、トランスコード、またはその他の処理にのみ使用してください。著作権で保護された素材の無断複製、エンコード、または配布は固く禁じられています。" + rus: "Вы должны использовать FastFlix только для кодирования, перекодирования или иной обработки видеоконтента, который принадлежит вам или на использование которого у вас есть законные права. Несанкционированное + копирование, кодирование или распространение материалов, защищённых авторским правом, строго запрещено." + por: "Você deve usar o FastFlix apenas para codificar, transcodificar ou processar conteúdo de vídeo que você possui ou tem os direitos legais de uso. A cópia, codificação ou distribuição não autorizada + de material protegido por direitos autorais é estritamente proibida." + swe: "Du får bara använda FastFlix för att koda, transkoda eller på annat sätt bearbeta videoinnehåll som du äger eller har laglig rätt att använda. Otillåten kopiering, kodning eller distribution av + upphovsrättsskyddat material är strängt förbjuden." + pol: "FastFlix należy używać wyłącznie do kodowania, transkodowania lub innego przetwarzania treści wideo, których jesteś właścicielem lub do których masz prawa do użytkowania. Nieautoryzowane kopiowanie, + kodowanie lub dystrybucja materiałów chronionych prawem autorskim jest surowo zabronione." + ukr: "Ви повинні використовувати FastFlix лише для кодування, перекодування або іншої обробки відеоконтенту, який належить вам або на використання якого ви маєте законні права. Несанкціоноване копіювання, + кодування або розповсюдження матеріалів, захищених авторським правом, суворо заборонено." + kor: "FastFlix는 본인이 소유하거나 사용할 법적 권리가 있는 비디오 콘텐츠를 인코딩, 트랜스코딩 또는 처리하는 데에만 사용해야 합니다. 저작권이 있는 자료의 무단 복제, 인코딩 또는 배포는 엄격히 금지됩니다." + ron: "Trebuie să utilizați FastFlix doar pentru a codifica, transcoda sau prelucra conținut video pe care îl dețineți sau pentru care aveți drepturi legale de utilizare. Copierea, codificarea sau distribuirea + neautorizată a materialelor protejate de drepturi de autor este strict interzisă." +2. No Developer Liability for Misuse: + eng: 2. No Developer Liability for Misuse + deu: 2. Keine Haftung der Entwickler bei Missbrauch + fra: 2. Aucune responsabilité des développeurs en cas d'utilisation abusive + ita: 2. Nessuna responsabilità degli sviluppatori per uso improprio + spa: 2. Sin responsabilidad del desarrollador por uso indebido + chs: 2. 开发者不对滥用承担责任 + jpn: 2. 悪用に対する開発者の免責 + rus: 2. Отсутствие ответственности разработчиков за злоупотребление + por: 2. Sem responsabilidade do desenvolvedor por uso indevido + swe: 2. Inget utvecklaransvar vid missbruk + pol: 2. Brak odpowiedzialności deweloperów za nadużycia + ukr: 2. Відсутність відповідальності розробників за зловживання + kor: 2. 오용에 대한 개발자 면책 + ron: 2. Nicio responsabilitate a dezvoltatorilor pentru utilizarea abuzivă +? "The developers and contributors of FastFlix are not responsible for any misuse of this software, including but not limited to the unauthorized reproduction or distribution of copyrighted content. You + assume full responsibility for how you use FastFlix." +: eng: "The developers and contributors of FastFlix are not responsible for any misuse of this software, including but not limited to the unauthorized reproduction or distribution of copyrighted content. + You assume full responsibility for how you use FastFlix." + deu: "Die Entwickler und Mitwirkenden von FastFlix sind nicht verantwortlich für jeglichen Missbrauch dieser Software, einschließlich, aber nicht beschränkt auf die unbefugte Vervielfältigung oder Verbreitung + urheberrechtlich geschützter Inhalte. Sie übernehmen die volle Verantwortung für die Art und Weise, wie Sie FastFlix nutzen." + fra: "Les développeurs et contributeurs de FastFlix ne sont pas responsables de toute utilisation abusive de ce logiciel, y compris, mais sans s'y limiter, la reproduction ou la distribution non autorisées + de contenus protégés par le droit d'auteur. Vous assumez l'entière responsabilité de la manière dont vous utilisez FastFlix." + ita: "Gli sviluppatori e i contributori di FastFlix non sono responsabili per qualsiasi uso improprio di questo software, inclusa, ma non limitata alla riproduzione o distribuzione non autorizzata di + contenuti protetti da copyright. Ti assumi la piena responsabilità di come utilizzi FastFlix." + spa: "Los desarrolladores y colaboradores de FastFlix no son responsables de ningún uso indebido de este software, incluyendo, entre otros, la reproducción o distribución no autorizada de contenido protegido + por derechos de autor. Asumes toda la responsabilidad por cómo usas FastFlix." + chs: "FastFlix 的开发者和贡献者不对本软件的任何滥用行为承担责任,包括但不限于未经授权复制或分发受版权保护的内容。您对使用 FastFlix 的方式承担全部责任。" + jpn: "FastFlixの開発者および貢献者は、著作権で保護されたコンテンツの無断複製または配布を含むがこれに限定されない、本ソフトウェアのいかなる悪用についても責任を負いません。FastFlixの使用方法については、お客様が全責任を負うものとします。" + rus: "Разработчики и участники FastFlix не несут ответственности за любое злоупотребление данным программным обеспечением, включая, но не ограничиваясь, несанкционированное воспроизведение или распространение + контента, защищённого авторским правом. Вы несёте полную ответственность за то, как вы используете FastFlix." + por: "Os desenvolvedores e colaboradores do FastFlix não são responsáveis por qualquer uso indevido deste software, incluindo, mas não se limitando à reprodução ou distribuição não autorizada de conteúdo + protegido por direitos autorais. Você assume total responsabilidade pela forma como usa o FastFlix." + swe: "Utvecklarna och bidragsgivarna till FastFlix ansvarar inte för något missbruk av denna programvara, inklusive men inte begränsat till otillåten reproduktion eller distribution av upphovsrättsskyddat + innehåll. Du tar fullt ansvar för hur du använder FastFlix." + pol: "Deweloperzy i współtwórcy FastFlix nie ponoszą odpowiedzialności za jakiekolwiek nadużycia tego oprogramowania, w tym między innymi za nieautoryzowane powielanie lub dystrybucję treści chronionych + prawem autorskim. Ponosisz pełną odpowiedzialność za sposób korzystania z FastFlix." + ukr: "Розробники та учасники FastFlix не несуть відповідальності за будь-яке зловживання цим програмним забезпеченням, включаючи, але не обмежуючись, несанкціоноване відтворення або розповсюдження контенту, + захищеного авторським правом. Ви несете повну відповідальність за те, як ви використовуєте FastFlix." + kor: "FastFlix의 개발자 및 기여자는 저작권으로 보호되는 콘텐츠의 무단 복제 또는 배포를 포함하되 이에 국한되지 않는 본 소프트웨어의 오용에 대해 책임을 지지 않습니다. FastFlix 사용 방법에 대한 전적인 책임은 사용자에게 있습니다." + ron: "Dezvoltatorii și contribuitorii FastFlix nu sunt responsabili pentru nicio utilizare abuzivă a acestui software, inclusiv, dar fără a se limita la reproducerea sau distribuirea neautorizată a conținutului + protejat de drepturi de autor. Vă asumați întreaga responsabilitate pentru modul în care utilizați FastFlix." +3. No Warranty: + eng: 3. No Warranty + deu: 3. Keine Gewährleistung + fra: 3. Aucune garantie + ita: 3. Nessuna garanzia + spa: 3. Sin garantía + chs: 3. 无保证 + jpn: 3. 無保証 + rus: 3. Отсутствие гарантий + por: 3. Sem garantia + swe: 3. Ingen garanti + pol: 3. Brak gwarancji + ukr: 3. Відсутність гарантій + kor: 3. 무보증 + ron: 3. Fără garanție +? 'FastFlix is provided "as-is" without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement.' +: eng: 'FastFlix is provided "as-is" without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement.' + deu: 'FastFlix wird „wie besehen" ohne jegliche ausdrückliche oder stillschweigende Gewährleistung bereitgestellt, einschließlich, aber nicht beschränkt auf die Gewährleistung der Marktgängigkeit, der + Eignung für einen bestimmten Zweck und der Nichtverletzung von Rechten.' + fra: 'FastFlix est fourni « tel quel » sans garantie d''aucune sorte, expresse ou implicite, y compris, mais sans s''y limiter, les garanties de qualité marchande, d''adéquation à un usage particulier + et de non-contrefaçon.' + ita: 'FastFlix è fornito "così com''è" senza garanzia di alcun tipo, espressa o implicita, incluse, ma non limitate alle garanzie di commerciabilità, idoneità per uno scopo particolare e non violazione.' + spa: 'FastFlix se proporciona "tal cual" sin garantía de ningún tipo, expresa o implícita, incluyendo, entre otras, las garantías de comerciabilidad, idoneidad para un propósito particular y no infracción.' + chs: 'FastFlix 按"原样"提供,不提供任何明示或暗示的保证,包括但不限于适销性、特定用途适用性和不侵权的保证。' + jpn: FastFlixは、商品性、特定目的への適合性、および非侵害の保証を含むがこれに限定されない、明示または黙示のいかなる保証もなく「現状のまま」提供されます。 + rus: FastFlix предоставляется «как есть» без каких-либо гарантий, явных или подразумеваемых, включая, но не ограничиваясь, гарантии товарной пригодности, пригодности для определённой цели и + ненарушения прав. + por: 'O FastFlix é fornecido "como está" sem garantia de qualquer tipo, expressa ou implícita, incluindo, mas não se limitando às garantias de comercialização, adequação a um propósito específico e não + violação.' + swe: 'FastFlix tillhandahålls "i befintligt skick" utan garanti av något slag, uttrycklig eller underförstådd, inklusive men inte begränsat till garantier om säljbarhet, lämplighet för ett visst ändamål + och icke-intrång.' + pol: 'FastFlix jest dostarczany „tak jak jest" bez jakiejkolwiek gwarancji, wyraźnej lub dorozumianej, w tym między innymi gwarancji przydatności handlowej, przydatności do określonego celu i nienaruszalności + praw.' + ukr: FastFlix надається «як є» без будь-яких гарантій, явних або неявних, включаючи, але не обмежуючись, гарантії придатності для продажу, придатності для певної мети та непорушення прав. + kor: 'FastFlix는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증을 포함하되 이에 국한되지 않는 어떠한 종류의 명시적 또는 묵시적 보증 없이 "있는 그대로" 제공됩니다.' + ron: 'FastFlix este furnizat „ca atare" fără garanție de niciun fel, expresă sau implicită, inclusiv, dar fără a se limita la garanțiile de vandabilitate, adecvare pentru un anumit scop și neîncălcare.' +4. Copyright Compliance: + eng: 4. Copyright Compliance + deu: 4. Einhaltung des Urheberrechts + fra: 4. Conformité au droit d'auteur + ita: 4. Conformità al diritto d'autore + spa: 4. Cumplimiento de derechos de autor + chs: 4. 版权合规 + jpn: 4. 著作権の遵守 + rus: 4. Соблюдение авторских прав + por: 4. Conformidade com direitos autorais + swe: 4. Upphovsrättsefterlevnad + pol: 4. Zgodność z prawem autorskim + ukr: 4. Дотримання авторських прав + kor: 4. 저작권 준수 + ron: 4. Conformitatea cu drepturile de autor +? "You assume all responsibility for compliance with applicable copyright laws and regulations in your jurisdiction. It is your obligation to ensure that your use of FastFlix does not violate any third-party + rights." +: eng: "You assume all responsibility for compliance with applicable copyright laws and regulations in your jurisdiction. It is your obligation to ensure that your use of FastFlix does not violate any third-party + rights." + deu: "Sie übernehmen die volle Verantwortung für die Einhaltung der geltenden Urheberrechtsgesetze und -vorschriften in Ihrem Rechtsgebiet. Es ist Ihre Pflicht sicherzustellen, dass Ihre Nutzung von FastFlix + keine Rechte Dritter verletzt." + fra: "Vous assumez l'entière responsabilité du respect des lois et réglementations applicables en matière de droit d'auteur dans votre juridiction. Il vous incombe de vous assurer que votre utilisation + de FastFlix ne viole aucun droit de tiers." + ita: "Ti assumi la piena responsabilità per la conformità alle leggi e ai regolamenti applicabili sul diritto d'autore nella tua giurisdizione. È tuo obbligo assicurarti che il tuo utilizzo di FastFlix + non violi alcun diritto di terzi." + spa: "Asumes toda la responsabilidad del cumplimiento de las leyes y regulaciones de derechos de autor aplicables en tu jurisdicción. Es tu obligación asegurarte de que tu uso de FastFlix no viole ningún + derecho de terceros." + chs: "您对遵守您所在司法管辖区适用的版权法律法规承担全部责任。确保您对 FastFlix 的使用不侵犯任何第三方权利是您的义务。" + jpn: "お客様の管轄区域で適用される著作権法および規制の遵守について、お客様が全責任を負うものとします。FastFlixの使用が第三者の権利を侵害しないことを確認するのは、お客様の義務です。" + rus: "Вы несёте полную ответственность за соблюдение применимых законов и нормативных актов об авторском праве в вашей юрисдикции. Вы обязаны убедиться, что ваше использование FastFlix не нарушает права + третьих лиц." + por: "Você assume toda a responsabilidade pelo cumprimento das leis e regulamentos de direitos autorais aplicáveis em sua jurisdição. É sua obrigação garantir que seu uso do FastFlix não viole nenhum + direito de terceiros." + swe: "Du tar fullt ansvar för efterlevnad av tillämpliga upphovsrättslagar och förordningar i din jurisdiktion. Det är din skyldighet att säkerställa att din användning av FastFlix inte kränker någon + tredje parts rättigheter." + pol: "Ponosisz pełną odpowiedzialność za przestrzeganie obowiązujących przepisów prawa autorskiego w Twojej jurysdykcji. Twoim obowiązkiem jest upewnienie się, że korzystanie z FastFlix nie narusza praw + osób trzecich." + ukr: "Ви несете повну відповідальність за дотримання чинних законів та нормативних актів про авторське право у вашій юрисдикції. Ви зобов'язані переконатися, що ваше використання FastFlix не порушує права + третіх осіб." + kor: "귀하의 관할권에서 적용되는 저작권법 및 규정을 준수할 모든 책임은 사용자에게 있습니다. FastFlix 사용이 제3자의 권리를 침해하지 않도록 하는 것은 사용자의 의무입니다." + ron: "Vă asumați întreaga responsabilitate pentru respectarea legilor și reglementărilor aplicabile privind drepturile de autor din jurisdicția dumneavoastră. Este obligația dumneavoastră să vă asigurați + că utilizarea FastFlix nu încalcă drepturile niciunui terț." +5. Limitation of Liability: + eng: 5. Limitation of Liability + deu: 5. Haftungsbeschränkung + fra: 5. Limitation de responsabilité + ita: 5. Limitazione di responsabilità + spa: 5. Limitación de responsabilidad + chs: 5. 责任限制 + jpn: 5. 責任の制限 + rus: 5. Ограничение ответственности + por: 5. Limitação de responsabilidade + swe: 5. Ansvarsbegränsning + pol: 5. Ograniczenie odpowiedzialności + ukr: 5. Обмеження відповідальності + kor: 5. 책임 제한 + ron: 5. Limitarea răspunderii +? "In no event shall the developers or contributors of FastFlix be liable for any damages arising from the use of this software, including but not limited to direct, indirect, incidental, special, or consequential + damages." +: eng: "In no event shall the developers or contributors of FastFlix be liable for any damages arising from the use of this software, including but not limited to direct, indirect, incidental, special, + or consequential damages." + deu: "Die Entwickler oder Mitwirkenden von FastFlix haften in keinem Fall für Schäden, die aus der Nutzung dieser Software entstehen, einschließlich, aber nicht beschränkt auf direkte, indirekte, zufällige, + besondere oder Folgeschäden." + fra: "En aucun cas, les développeurs ou contributeurs de FastFlix ne pourront être tenus responsables de tout dommage résultant de l'utilisation de ce logiciel, y compris, mais sans s'y limiter, les dommages + directs, indirects, accessoires, spéciaux ou consécutifs." + ita: "In nessun caso gli sviluppatori o i contributori di FastFlix saranno responsabili per eventuali danni derivanti dall'uso di questo software, inclusi, ma non limitati a danni diretti, indiretti, + incidentali, speciali o consequenziali." + spa: "En ningún caso los desarrolladores o colaboradores de FastFlix serán responsables de ningún daño derivado del uso de este software, incluyendo, entre otros, daños directos, indirectos, incidentales, + especiales o consecuentes." + chs: "在任何情况下,FastFlix 的开发者或贡献者均不对因使用本软件而产生的任何损害承担责任,包括但不限于直接、间接、附带、特殊或后果性损害。" + jpn: "いかなる場合も、FastFlixの開発者または貢献者は、直接的、間接的、偶発的、特別、または結果的損害を含むがこれに限定されない、本ソフトウェアの使用から生じるいかなる損害についても責任を負わないものとします。" + rus: "Ни при каких обстоятельствах разработчики или участники FastFlix не несут ответственности за любой ущерб, возникший в результате использования данного программного обеспечения, включая, но не ограничиваясь, + прямой, косвенный, случайный, особый или последующий ущерб." + por: "Em nenhum caso os desenvolvedores ou colaboradores do FastFlix serão responsáveis por quaisquer danos decorrentes do uso deste software, incluindo, mas não se limitando a danos diretos, indiretos, + incidentais, especiais ou consequenciais." + swe: "Under inga omständigheter ska utvecklarna eller bidragsgivarna till FastFlix hållas ansvariga för eventuella skador som uppstår till följd av användningen av denna programvara, inklusive men inte + begränsat till direkta, indirekta, tillfälliga, speciella eller följdskador." + pol: "W żadnym wypadku deweloperzy ani współtwórcy FastFlix nie ponoszą odpowiedzialności za jakiekolwiek szkody wynikające z użytkowania tego oprogramowania, w tym między innymi za szkody bezpośrednie, + pośrednie, przypadkowe, szczególne lub wynikowe." + ukr: "За жодних обставин розробники або учасники FastFlix не несуть відповідальності за будь-які збитки, що виникли внаслідок використання цього програмного забезпечення, включаючи, але не обмежуючись, + прямі, непрямі, випадкові, особливі або побічні збитки." + kor: "어떠한 경우에도 FastFlix의 개발자 또는 기여자는 직접적, 간접적, 부수적, 특별 또는 결과적 손해를 포함하되 이에 국한되지 않는 본 소프트웨어 사용으로 인해 발생하는 어떠한 손해에 대해서도 책임을 지지 않습니다." + ron: "În niciun caz dezvoltatorii sau contribuitorii FastFlix nu vor fi răspunzători pentru niciun fel de daune rezultate din utilizarea acestui software, inclusiv, dar fără a se limita la daune directe, + indirecte, incidentale, speciale sau consecutive." +Copy Dolby Vision: + eng: Copy Dolby Vision + deu: Dolby Vision kopieren + fra: Copie Dolby Vision + ita: Copia Dolby Vision + spa: Copia Dolby Vision + jpn: ドルビービジョンをコピーする + rus: Копия Dolby Vision + por: Copiar Dolby Vision + swe: Kopia Dolby Vision + pol: Kopiowanie Dolby Vision + chs: 复制杜比视界 + ukr: Копіювати Dolby Vision + kor: 돌비 비전 복사 + ron: Copiere Dolby Vision +Copy Dolby Vision RPU metadata from input file: + eng: Copy Dolby Vision RPU metadata from input file + deu: Dolby Vision RPU-Metadaten aus Eingabedatei kopieren + fra: Copier les métadonnées Dolby Vision RPU du fichier d'entrée + ita: Copia dei metadati Dolby Vision RPU dal file di ingresso + spa: Copiar metadatos Dolby Vision RPU del archivo de entrada + jpn: 入力ファイルからDolby Vision RPUメタデータをコピーする + rus: Копирование метаданных Dolby Vision RPU из входного файла + por: Copiar metadados Dolby Vision RPU do ficheiro de entrada + swe: Kopiera Dolby Vision RPU-metadata från inmatningsfilen + pol: Kopiowanie metadanych Dolby Vision RPU z pliku wejściowego + chs: 从输入文件复制杜比视界 RPU 元数据 + ukr: Скопіюйте метадані Dolby Vision RPU з вхідного файлу + kor: 입력 파일에서 Dolby Vision RPU 메타데이터 복사하기 + ron: Copierea metadatelor Dolby Vision RPU din fișierul de intrare diff --git a/fastflix/encoders/common/encc_helpers.py b/fastflix/encoders/common/encc_helpers.py index 22250b5a..f22c32dd 100644 --- a/fastflix/encoders/common/encc_helpers.py +++ b/fastflix/encoders/common/encc_helpers.py @@ -122,11 +122,14 @@ def build_audio(audio_tracks: list[AudioTrack], audio_streams) -> List[str]: if not track.conversion_codec or track.conversion_codec == "none": copies.append(str(audio_id)) elif track.conversion_codec: - downmix = ( - ["--audio-stream", f"{audio_id}?:{track.downmix}"] - if track.downmix and track.downmix != "No Downmix" - else [] - ) + if track.downmix and track.downmix != "No Downmix": + downmix = ["--audio-stream", f"{audio_id}?:{track.downmix}"] + else: + raw_layout = track.raw_info.get("channel_layout", "") if track.raw_info else "" + if raw_layout: + downmix = ["--audio-stream", f"{audio_id}?:{raw_layout}"] + else: + downmix = [] bitrate_parts = [] if track.conversion_codec not in lossless: if track.conversion_bitrate: diff --git a/fastflix/encoders/common/helpers.py b/fastflix/encoders/common/helpers.py index 0012c8a9..293f3e5c 100644 --- a/fastflix/encoders/common/helpers.py +++ b/fastflix/encoders/common/helpers.py @@ -213,6 +213,7 @@ def generate_filters( crop: Optional[dict] = None, scale=None, scale_filter="lanczos", + sar=None, remove_hdr=False, vaapi: bool = False, rotate=0, @@ -221,6 +222,8 @@ def generate_filters( burn_in_subtitle_track=None, burn_in_subtitle_type=None, burn_in_file_index: int = 0, + source_width: Optional[int] = None, + source_height: Optional[int] = None, custom_filters=None, start_filters=None, raw_filters=False, @@ -247,6 +250,8 @@ def generate_filters( if scale: if not vaapi: filter_list.append(f"scale={scale}:flags={scale_filter},setsar=1:1") + elif sar and sar != "1:1" and sar != "1/1": + filter_list.append("setsar=1:1") if rotate: if rotate == 1: filter_list.append("transpose=1") @@ -310,7 +315,8 @@ def generate_filters( filter_complex = f"[0:{selected_track}][{burn_in_file_index}:{burn_in_subtitle_track}]overlay[v]" else: filter_prefix = f"{filters}," if filters else "" - filter_complex = f"[0:{selected_track}]{filter_prefix}subtitles='{quoted_path(str(source))}':si={burn_in_subtitle_track}[v]" + original_size = f":original_size={source_width}x{source_height}" if source_width and source_height else "" + filter_complex = f"[0:{selected_track}]{filter_prefix}subtitles='{quoted_path(str(source))}':si={burn_in_subtitle_track}{original_size}[v]" elif filters: filter_complex = f"[0:{selected_track}]{filters}[v]" else: @@ -390,7 +396,10 @@ def generate_all( burn_in_subtitle_track=burn_in_track, burn_in_subtitle_type=burn_in_type, burn_in_file_index=burn_in_file_index, + source_width=fastflix.current_video.width, + source_height=fastflix.current_video.height, scale=fastflix.current_video.scale, + sar=fastflix.current_video.sar, enable_opencl=enable_opencl, vaapi=vaapi, **filter_details, diff --git a/fastflix/encoders/common/subtitles.py b/fastflix/encoders/common/subtitles.py index 16be1b5f..5bb1fb7d 100644 --- a/fastflix/encoders/common/subtitles.py +++ b/fastflix/encoders/common/subtitles.py @@ -47,6 +47,12 @@ def build_subtitle( else: command_list.extend([f"-disposition:{outdex}", "0"]) command_list.extend([f"-metadata:s:{outdex}", f"language={track.language}"]) + if track.title: + command_list.extend([f"-metadata:s:{outdex}", f"title={track.title}"]) + command_list.extend([f"-metadata:s:{outdex}", f"handler={track.title}"]) + else: + command_list.extend([f"-metadata:s:{outdex}", "title="]) + command_list.extend([f"-metadata:s:{outdex}", "handler="]) if not subs_enabled: command_list.extend(["-default_mode", "infer_no_subs"]) return command_list, burn_in_track, burn_in_type diff --git a/fastflix/encoders/nvencc_av1/command_builder.py b/fastflix/encoders/nvencc_av1/command_builder.py index 1634c025..c69d79c0 100644 --- a/fastflix/encoders/nvencc_av1/command_builder.py +++ b/fastflix/encoders/nvencc_av1/command_builder.py @@ -82,6 +82,8 @@ def build(fastflix: FastFlix): if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + elif video.video_settings.remove_hdr: + command.extend(["--video-metadata", "clear", "--metadata", "copy"]) else: command.extend(["--video-metadata", "copy", "--metadata", "copy"]) @@ -142,9 +144,9 @@ def build(fastflix: FastFlix): ) if fastflix.current_video.cll: command.extend(["--max-cll", str(fastflix.current_video.cll)]) - if settings.copy_hdr10: + if settings.copy_hdr10 and not video.video_settings.remove_hdr: command.extend(["--dhdr10-info", "copy"]) - if settings.copy_dv: + if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py index 2fb08075..bbeb8fa8 100644 --- a/fastflix/encoders/nvencc_hevc/command_builder.py +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -82,6 +82,8 @@ def build(fastflix: FastFlix): if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + elif video.video_settings.remove_hdr: + command.extend(["--video-metadata", "clear", "--metadata", "copy"]) else: command.extend(["--video-metadata", "copy", "--metadata", "copy"]) @@ -142,9 +144,9 @@ def build(fastflix: FastFlix): ) if fastflix.current_video.cll: command.extend(["--max-cll", str(fastflix.current_video.cll)]) - if settings.copy_hdr10: + if settings.copy_hdr10 and not video.video_settings.remove_hdr: command.extend(["--dhdr10-info", "copy"]) - if settings.copy_dv: + if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) diff --git a/fastflix/encoders/qsvencc_av1/command_builder.py b/fastflix/encoders/qsvencc_av1/command_builder.py index 9ad9e85e..e546ecc2 100644 --- a/fastflix/encoders/qsvencc_av1/command_builder.py +++ b/fastflix/encoders/qsvencc_av1/command_builder.py @@ -83,6 +83,8 @@ def build(fastflix: FastFlix): if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + elif video.video_settings.remove_hdr: + command.extend(["--video-metadata", "clear", "--metadata", "copy"]) else: command.extend(["--video-metadata", "copy", "--metadata", "copy"]) @@ -137,9 +139,9 @@ def build(fastflix: FastFlix): if fastflix.current_video.cll: command.extend(["--max-cll", str(fastflix.current_video.cll)]) - if settings.copy_hdr10: + if settings.copy_hdr10 and not video.video_settings.remove_hdr: command.extend(["--dhdr10-info", "copy"]) - if settings.copy_dv: + if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) diff --git a/fastflix/encoders/qsvencc_hevc/command_builder.py b/fastflix/encoders/qsvencc_hevc/command_builder.py index f6d9a097..ee80e886 100644 --- a/fastflix/encoders/qsvencc_hevc/command_builder.py +++ b/fastflix/encoders/qsvencc_hevc/command_builder.py @@ -83,6 +83,8 @@ def build(fastflix: FastFlix): if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + elif video.video_settings.remove_hdr: + command.extend(["--video-metadata", "clear", "--metadata", "copy"]) else: command.extend(["--video-metadata", "copy", "--metadata", "copy"]) @@ -137,9 +139,9 @@ def build(fastflix: FastFlix): if fastflix.current_video.cll: command.extend(["--max-cll", str(fastflix.current_video.cll)]) - if settings.copy_hdr10: + if settings.copy_hdr10 and not video.video_settings.remove_hdr: command.extend(["--dhdr10-info", "copy"]) - if settings.copy_dv: + if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) diff --git a/fastflix/encoders/vceencc_av1/command_builder.py b/fastflix/encoders/vceencc_av1/command_builder.py index 0abd031c..ce8da2cd 100644 --- a/fastflix/encoders/vceencc_av1/command_builder.py +++ b/fastflix/encoders/vceencc_av1/command_builder.py @@ -72,6 +72,8 @@ def build(fastflix: FastFlix): if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + elif video.video_settings.remove_hdr: + command.extend(["--video-metadata", "clear", "--metadata", "copy"]) else: command.extend(["--video-metadata", "copy", "--metadata", "copy"]) @@ -112,9 +114,9 @@ def build(fastflix: FastFlix): ) if fastflix.current_video.cll: command.extend(["--max-cll", str(fastflix.current_video.cll)]) - if settings.copy_hdr10: + if settings.copy_hdr10 and not video.video_settings.remove_hdr: command.extend(["--dhdr10-info", "copy"]) - if settings.copy_dv: + if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) diff --git a/fastflix/encoders/vceencc_hevc/command_builder.py b/fastflix/encoders/vceencc_hevc/command_builder.py index 3751bd96..a995741f 100644 --- a/fastflix/encoders/vceencc_hevc/command_builder.py +++ b/fastflix/encoders/vceencc_hevc/command_builder.py @@ -72,6 +72,8 @@ def build(fastflix: FastFlix): if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + elif video.video_settings.remove_hdr: + command.extend(["--video-metadata", "clear", "--metadata", "copy"]) else: command.extend(["--video-metadata", "copy", "--metadata", "copy"]) @@ -113,9 +115,9 @@ def build(fastflix: FastFlix): ) if fastflix.current_video.cll: command.extend(["--max-cll", str(fastflix.current_video.cll)]) - if settings.copy_hdr10: + if settings.copy_hdr10 and not video.video_settings.remove_hdr: command.extend(["--dhdr10-info", "copy"]) - if settings.copy_dv: + if settings.copy_dv and not video.video_settings.remove_hdr: command.extend(["--dolby-vision-rpu", "copy"]) command.extend(["--dolby-vision-profile", "copy"]) diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 93dd8569..80b9f514 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -302,6 +302,7 @@ class Config(BaseModel): pgs_ocr_language: str = "eng" use_keyframes_for_preview: bool = True + terms_accepted: bool = False @property def pgs_ocr_available(self) -> bool: diff --git a/fastflix/models/encode.py b/fastflix/models/encode.py index fdb97d2d..981f940c 100644 --- a/fastflix/models/encode.py +++ b/fastflix/models/encode.py @@ -33,6 +33,7 @@ class SubtitleTrack(BaseModel): disposition: Optional[str] = "" burn_in: bool = False language: str = "" + title: str = "" subtitle_type: str = "" dispositions: dict = Field(default_factory=dict) enabled: bool = True diff --git a/fastflix/models/video.py b/fastflix/models/video.py index ebfd175a..dc338462 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -295,6 +295,13 @@ def average_frame_rate(self): return "" return stream.get("avg_frame_rate", "") + @property + def sar(self): + stream = self.current_video_stream + if not stream: + return "" + return stream.get("sample_aspect_ratio", "1:1") + @property def scale(self): if self.video_settings.resolution_method == "auto": diff --git a/fastflix/shared.py b/fastflix/shared.py index 8afaa7a9..f3cbee68 100644 --- a/fastflix/shared.py +++ b/fastflix/shared.py @@ -43,9 +43,62 @@ ) +def _measure_text_width(metrics, text): + """Measure the widest line in potentially multi-line text.""" + lines = text.split("\n") + return max(metrics.horizontalAdvance(line) for line in lines) + + +def shrink_text_to_fit(widget, padding=10, min_point_size=6): + """Shrink a widget's font until its text fits within its fixed width. + + Works with QLabel, QPushButton, QCheckBox, and similar widgets. + Call after setting the widget's text and fixed width. + + Uses widget-level stylesheet to set font-size, because the global parent + stylesheet (QWidget { font-size: Xpt; }) overrides widget.setFont() calls. + A widget's own stylesheet takes precedence over inherited stylesheets. + """ + import re + + from fastflix.ui_constants import FONTS + from fastflix.ui_scale import scaler + + width = widget.maximumWidth() if widget.maximumWidth() < 16777215 else widget.width() + if width <= 0: + return + text = widget.text() + if not text: + return + + # Start from the app's actual styled font size (matches the global stylesheet). + app_font_size_pt = max(6, round(scaler.scale_font(FONTS.LARGE) * 0.75)) + font = widget.font() + font.setPointSizeF(app_font_size_pt) + + metrics = QtGui.QFontMetrics(font) + available = width - padding + if _measure_text_width(metrics, text) <= available: + return + + size = float(app_font_size_pt) + while size > min_point_size: + size -= 0.5 + font.setPointSizeF(size) + metrics = QtGui.QFontMetrics(font) + if _measure_text_width(metrics, text) <= available: + break + + # Apply via widget stylesheet to override the global parent stylesheet. + existing = widget.styleSheet() or "" + existing = re.sub(r"font-size:\s*[^;]+;?\s*", "", existing).strip() + sep = " " if existing else "" + widget.setStyleSheet(f"{existing}{sep}font-size: {size}pt;") + + class MyMessageBox(QtWidgets.QMessageBox): - def __init__(self): - QtWidgets.QMessageBox.__init__(self) + def __init__(self, parent=None): + QtWidgets.QMessageBox.__init__(self, parent) self.setSizeGripEnabled(True) def event(self, e): @@ -68,11 +121,10 @@ def event(self, e): return result -def message(msg, title=None): - sm = QtWidgets.QMessageBox() +def message(msg, title=None, parent=None): + sm = QtWidgets.QMessageBox(parent) sm.setStyleSheet("font-size: 14px") sm.setText(msg) - # Removed WindowStaysOnTopHint to allow minimizing dialog (#687) if title: sm.setWindowTitle(title) sm.setStandardButtons(QtWidgets.QMessageBox.Ok) @@ -80,12 +132,11 @@ def message(msg, title=None): sm.exec_() -def error_message(msg, details=None, traceback=False, title=None): - em = MyMessageBox() +def error_message(msg, details=None, traceback=False, title=None, parent=None): + em = MyMessageBox(parent) em.setStyleSheet("font-size: 14px") em.setText(msg) em.setWindowIcon(QtGui.QIcon(my_data)) - # Removed WindowStaysOnTopHint to allow minimizing dialog (#687) if title: em.setWindowTitle(title) if details: @@ -98,14 +149,13 @@ def error_message(msg, details=None, traceback=False, title=None): em.exec_() -def yes_no_message(msg, title=None, yes_text=t("Yes"), no_text=t("No"), yes_action=None, no_action=None): - sm = QtWidgets.QMessageBox() +def yes_no_message(msg, title=None, yes_text=t("Yes"), no_text=t("No"), yes_action=None, no_action=None, parent=None): + sm = QtWidgets.QMessageBox(parent) sm.setStyleSheet("font-size: 14px") sm.setWindowTitle(t(title)) sm.setText(msg) sm.addButton(yes_text, QtWidgets.QMessageBox.YesRole) sm.addButton(no_text, QtWidgets.QMessageBox.NoRole) - # Removed WindowStaysOnTopHint to allow minimizing dialog (#687) sm.exec_() if sm.clickedButton().text() == yes_text: if yes_action: diff --git a/fastflix/ui_constants.py b/fastflix/ui_constants.py index 689526e9..b939ef23 100644 --- a/fastflix/ui_constants.py +++ b/fastflix/ui_constants.py @@ -17,7 +17,7 @@ class BaseWidths: PROFILE_BOX: int = 190 ENCODER_MIN: int = 165 CROP_BOX_MIN: int = 280 - SOURCE_LABEL: int = 65 + SOURCE_LABEL: int = 90 RESOLUTION_CUSTOM: int = 115 FLIP_DROPDOWN: int = 120 ROTATE_DROPDOWN: int = 130 diff --git a/fastflix/version.py b/fastflix/version.py index 82a7fc36..854e0118 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "6.0.1" +__version__ = "6.1.0" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index a0eae41b..3d028145 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -52,6 +52,7 @@ yes_no_message, clean_file_string, get_filesafe_datetime, + shrink_text_to_fit, ) from fastflix.windows_tools import prevent_sleep_mode, allow_sleep_mode from fastflix.widgets.background_tasks import ThumbnailCreator @@ -276,14 +277,14 @@ def __init__(self, parent, app: FastFlixApp): self.grid = QtWidgets.QGridLayout() # Set column stretch factors: - # Left (cols 0-5) and Right (cols 11-13) stay fixed (stretch=0) - # Preview area (cols 6-10) expands to fill available space (stretch=1) + # Left (cols 0-5) stays fixed (stretch=0) + # Preview area (cols 6-10) and Right (cols 11-13) expand to fill space for col in range(6): self.grid.setColumnStretch(col, 0) for col in range(6, 11): self.grid.setColumnStretch(col, 1) for col in range(11, 14): - self.grid.setColumnStretch(col, 0) + self.grid.setColumnStretch(col, 1) # row: int, column: int, rowSpan: int, columnSpan: int @@ -553,6 +554,7 @@ def init_video_area(self): source_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) if self.app.fastflix.config.theme == "onyx": source_label.setStyleSheet("color: white;") + shrink_text_to_fit(source_label) self.source_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) source_layout.addWidget(source_label) source_layout.addWidget(self.source_video_path_widget, stretch=True) @@ -562,6 +564,7 @@ def init_video_area(self): output_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) if self.app.fastflix.config.theme == "onyx": output_label.setStyleSheet("color: white;") + shrink_text_to_fit(output_label) self.output_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) output_layout.addWidget(output_label) output_layout.addWidget(self.output_video_path_widget, stretch=True) @@ -579,6 +582,7 @@ def init_video_area(self): out_dir_label = QtWidgets.QLabel(t("Folder")) out_dir_label.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) out_dir_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) + shrink_text_to_fit(out_dir_label) self.widgets.output_directory = QtWidgets.QPushButton() self.widgets.output_directory.setFixedHeight(scaler.scale(HEIGHTS.OUTPUT_DIR)) self.widgets.output_directory.clicked.connect(self.save_directory) @@ -616,6 +620,7 @@ def init_video_area(self): self.video_info_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) if self.app.fastflix.config.theme == "onyx": self.video_info_label.setStyleSheet("color: white;") + shrink_text_to_fit(self.video_info_label) self.video_info_label.hide() info_layout.addWidget(self.video_info_label) info_layout.addWidget(self.video_bit_depth_label) @@ -667,6 +672,7 @@ def init_options_tabs(self): res_row.setSpacing(scaler.scale(4)) res_label = QtWidgets.QLabel(t("Resolution")) res_label.setFixedWidth(scaler.scale(68)) + shrink_text_to_fit(res_label, padding=4) res_row.addWidget(res_label) self.widgets.resolution_drop_down = QtWidgets.QComboBox() @@ -689,11 +695,13 @@ def init_options_tabs(self): rot_label = QtWidgets.QLabel(t("Rotate")) rot_label.setFixedWidth(scaler.scale(68)) + shrink_text_to_fit(rot_label, padding=4) transform_row.addWidget(rot_label) transform_row.addWidget(self.init_rotate()) flip_label = QtWidgets.QLabel(t("Flip")) - flip_label.setFixedWidth(scaler.scale(30)) + flip_label.setFixedWidth(scaler.scale(50)) + shrink_text_to_fit(flip_label, padding=4) transform_row.addWidget(flip_label) transform_row.addWidget(self.init_flip()) transform_row.addStretch(1) @@ -928,6 +936,7 @@ def init_video_track_select(self): track_label = QtWidgets.QLabel(t("Video Track")) track_label.setFixedWidth(scaler.scale(WIDTHS.VIDEO_TRACK_LABEL)) + shrink_text_to_fit(track_label) layout.addWidget(track_label) layout.addWidget(self.widgets.video_track, stretch=1) layout.setSpacing(10) @@ -1096,6 +1105,7 @@ def init_encoder_drop_down(self): encoder_label = QtWidgets.QLabel(f"{t('Encoder')}: ") encoder_label.setFixedWidth(scaler.scale(54)) + shrink_text_to_fit(encoder_label, padding=4) layout.addWidget(self.widgets.convert_to, stretch=0) layout.setSpacing(10) diff --git a/fastflix/widgets/panels/advanced_panel.py b/fastflix/widgets/panels/advanced_panel.py index e059976d..de2a59b0 100644 --- a/fastflix/widgets/panels/advanced_panel.py +++ b/fastflix/widgets/panels/advanced_panel.py @@ -7,6 +7,7 @@ from PySide6 import QtCore, QtGui, QtWidgets from fastflix.language import t +from fastflix.shared import shrink_text_to_fit from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.video import VideoSettings from fastflix.resources import get_icon @@ -137,9 +138,10 @@ def add_spacer(self): def add_row_label(self, label, row_number): label = QtWidgets.QLabel(label) - label.setFixedWidth(100) + label.setFixedWidth(140) if self.app.fastflix.config.theme == "onyx": label.setStyleSheet(get_onyx_label_style(muted=True)) + shrink_text_to_fit(label, padding=4) self.layout.addWidget(label, row_number, 0, alignment=QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) def init_fps(self): diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 8837f233..62cb7c8c 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -18,7 +18,7 @@ from fastflix.models.video import Video from fastflix.ff_queue import get_queue, save_queue, save_queue_async from fastflix.resources import get_icon, get_bool_env -from fastflix.shared import no_border, open_folder, yes_no_message, message, error_message +from fastflix.shared import no_border, open_folder, yes_no_message, message, error_message, shrink_text_to_fit from fastflix.ui_scale import scaler from fastflix.widgets.panels.abstract_list import FlixList from fastflix.exceptions import FastFlixInternalException @@ -293,6 +293,17 @@ def __init__(self, parent, app: FastFlixApp): priority_label = QtWidgets.QLabel(t("Priority")) priority_label.setFixedWidth(55) + for w in ( + self.save_queue_button, + self.load_queue_button, + self.clear_queue, + self.pause_queue, + self.pause_encode, + self.ignore_errors, + priority_label, + ): + shrink_text_to_fit(w) + top_layout.addWidget(self.load_queue_button, QtCore.Qt.AlignRight) top_layout.addWidget(self.save_queue_button, QtCore.Qt.AlignRight) top_layout.addStretch(1) diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index bcedb44e..bce2b590 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -12,7 +12,7 @@ from fastflix.models.encode import SubtitleTrack from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import loading_movie, get_icon -from fastflix.shared import error_message, no_border, clear_list +from fastflix.shared import error_message, no_border, clear_list, shrink_text_to_fit from fastflix.ui_scale import scaler from fastflix.ui_styles import get_onyx_disposition_style from fastflix.widgets.background_tasks import ExtractSubtitleSRT @@ -580,6 +580,9 @@ def __init__(self, parent, app: FastFlixApp): self.save_all_button.setFixedWidth(150) self.save_all_button.clicked.connect(lambda: self.select_all(True)) + for w in (self.add_subtitle_button, self.remove_all_button, self.save_all_button): + shrink_text_to_fit(w) + top_layout.addWidget(self.add_subtitle_button) top_layout.addWidget(self.remove_all_button) top_layout.addWidget(self.save_all_button) @@ -688,6 +691,7 @@ def new_source(self): dispositions={k: bool(v) for k, v in track.disposition.items()}, burn_in=False, language=track.get("tags", {}).get("language", ""), + title=track.get("tags", {}).get("title", ""), subtitle_type=subtitle_type, enabled=enabled, long_name=track.get("codec_long_name", f"{t('Subtitle Type')}:{subtitle_type}"), diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 20b9efe2..4b180aa8 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -47,7 +47,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): self.config_file = self.app.fastflix.config.config_path self.setWindowTitle(t("Settings")) self.setMinimumSize(600, 200) - self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window) main_layout = QtWidgets.QVBoxLayout() @@ -429,7 +429,7 @@ def save(self): try: new_work_dir.mkdir(exist_ok=True, parents=True) except OSError: - error_message(f'{t("Could not create / access work directory")} "{new_work_dir}"') + error_message(f'{t("Could not create / access work directory")} "{new_work_dir}"', parent=self) else: self.app.fastflix.config.work_path = new_work_dir self.app.fastflix.config.use_sane_audio = self.use_sane_audio.isChecked() @@ -451,7 +451,8 @@ def save(self): self.app.fastflix.config.language = Language(self.language_combo.currentText()).pt3 except InvalidLanguageValue: error_message( - f"{t('Could not set language to')} {self.language_combo.currentText()}\n {t('Please report this issue')}" + f"{t('Could not set language to')} {self.language_combo.currentText()}\n {t('Please report this issue')}", + parent=self, ) self.app.fastflix.config.disable_version_check = self.disable_version_check.isChecked() log_level = (self.logger_level_widget.currentIndex() + 1) * 10 @@ -508,7 +509,7 @@ def save(self): self.main.config_update() self.app.fastflix.config.save() if updated_ffmpeg or old_lang != self.app.fastflix.config.language or restart_needed: - error_message(t("Please restart FastFlix to apply settings")) + error_message(t("Please restart FastFlix to apply settings"), parent=self) self.close() def select_ffmpeg(self): diff --git a/fastflix/widgets/terms_agreement.py b/fastflix/widgets/terms_agreement.py new file mode 100644 index 00000000..8738ff96 --- /dev/null +++ b/fastflix/widgets/terms_agreement.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +from PySide6 import QtWidgets, QtCore + +from fastflix.language import t, language + +TERMS_SECTIONS = [ + ("FastFlix Terms and Agreements", ""), + ("By using FastFlix, you agree to the following terms:", ""), + ( + "1. Authorized Use Only", + "You must only use FastFlix to encode, transcode, or otherwise process video " + "content that you own or have the legal rights to use. Unauthorized copying, " + "encoding, or distribution of copyrighted material is strictly prohibited.", + ), + ( + "2. No Developer Liability for Misuse", + "The developers and contributors of FastFlix are not responsible for any misuse " + "of this software, including but not limited to the unauthorized reproduction or " + "distribution of copyrighted content. You assume full responsibility for how you " + "use FastFlix.", + ), + ( + "3. No Warranty", + 'FastFlix is provided "as-is" without warranty of any kind, express or implied, ' + "including but not limited to the warranties of merchantability, fitness for a " + "particular purpose, and non-infringement.", + ), + ( + "4. Copyright Compliance", + "You assume all responsibility for compliance with applicable copyright laws and " + "regulations in your jurisdiction. It is your obligation to ensure that your use " + "of FastFlix does not violate any third-party rights.", + ), + ( + "5. Limitation of Liability", + "In no event shall the developers or contributors of FastFlix be liable for any " + "damages arising from the use of this software, including but not limited to " + "direct, indirect, incidental, special, or consequential damages.", + ), +] + + +def _build_terms_text(translate_fn): + parts = [] + for header, body in TERMS_SECTIONS: + if body: + parts.append(f"{translate_fn(header)}\n{translate_fn(body)}") + else: + parts.append(translate_fn(header)) + return "\n\n".join(parts) + + +class TermsAgreementDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(t("FastFlix Terms and Agreements")) + self.setMinimumSize(550, 450) + self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) + + layout = QtWidgets.QVBoxLayout(self) + + english_text = _build_terms_text(lambda x: x) + if language != "eng": + translated_text = _build_terms_text(t) + display_text = translated_text + "\n\n" + ("─" * 58) + "\n\n" + english_text + else: + display_text = english_text + + text_edit = QtWidgets.QTextEdit() + text_edit.setReadOnly(True) + text_edit.setPlainText(display_text) + layout.addWidget(text_edit) + + self.agree_checkbox = QtWidgets.QCheckBox(t("I have read and agree to the Terms and Agreements")) + self.agree_checkbox.stateChanged.connect(self._on_checkbox_changed) + layout.addWidget(self.agree_checkbox) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + + self.ok_button = QtWidgets.QPushButton(t("Accept")) + self.ok_button.setEnabled(False) + self.ok_button.clicked.connect(self.accept) + button_layout.addWidget(self.ok_button) + + cancel_button = QtWidgets.QPushButton(t("Reject")) + cancel_button.clicked.connect(self.reject) + button_layout.addWidget(cancel_button) + + layout.addLayout(button_layout) + + def _on_checkbox_changed(self, state): + self.ok_button.setEnabled(state == QtCore.Qt.CheckState.Checked.value) + + def reject(self): + super().reject() + + def closeEvent(self, event): + self.reject() + event.accept() diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index d35d1555..c1a5d699 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -41,12 +41,48 @@ } +class ScrollableTabBar(QtWidgets.QTabBar): + """Custom tab bar that positions scroll buttons at far left and far right.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setElideMode(QtCore.Qt.ElideRight) + self._left_btn = None + self._right_btn = None + + def _find_scroll_buttons(self): + if self._left_btn and self._right_btn: + return + for child in self.findChildren(QtWidgets.QToolButton): + if child.arrowType() == QtCore.Qt.LeftArrow: + self._left_btn = child + elif child.arrowType() == QtCore.Qt.RightArrow: + self._right_btn = child + + def resizeEvent(self, event): + super().resizeEvent(event) + self._find_scroll_buttons() + btn_size = max(28, self.height()) + if self._left_btn and self._left_btn.isVisible(): + self._left_btn.setFixedSize(btn_size, self.height()) + self._left_btn.move(0, 0) + self._left_btn.raise_() + if self._right_btn and self._right_btn.isVisible(): + self._right_btn.setFixedSize(btn_size, self.height()) + self._right_btn.move(self.width() - btn_size, 0) + self._right_btn.raise_() + + class VideoOptions(QtWidgets.QTabWidget): def __init__(self, parent, app: FastFlixApp, available_audio_encoders): super().__init__(parent) self.main: "Main" = parent self.app = app + # Install custom tab bar with better scroll buttons + custom_tab_bar = ScrollableTabBar(self) + self.setTabBar(custom_tab_bar) + self.reloading = False self.selected = 0 @@ -61,6 +97,11 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): self.advanced = AdvancedPanel(self, self.app) self.info = InfoPanel(self, self.app) self.debug = DebugPanel(self, self.app) + scroll_btn_size = max(28, self.tabBar().height()) + scroll_btn_style = ( + f"QTabBar QToolButton{{ min-width: {scroll_btn_size}px; min-height: {scroll_btn_size}px; " + f"font-size: 16px; font-weight: bold; }}" + ) if self.app.fastflix.config.theme == "onyx": self.setStyleSheet( "QTabBar{ font-size: 13px; } " @@ -72,7 +113,10 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): f"QComboBox{{ min-height: 1.1em; {get_onyx_combobox_style()} }}" "QComboBox:hover{ background-color: #6a8a96; } " f"QComboBox QAbstractItemView{{ background-color: {ONYX_COLORS['dark_bg']}; border: 2px solid {ONYX_COLORS['input_bg']}; }} " + + scroll_btn_style ) + else: + self.setStyleSheet(scroll_btn_style) self.setIconSize(scaler.scale_size(20, 20)) self.addTab( diff --git a/fastflix/widgets/windows/multiple_files.py b/fastflix/widgets/windows/multiple_files.py index 8a207ba8..b0cfed67 100644 --- a/fastflix/widgets/windows/multiple_files.py +++ b/fastflix/widgets/windows/multiple_files.py @@ -230,12 +230,14 @@ def select_folder(self): self.set_folder_name(folder_name) def check_to_add(file, list_of_items, bad_items, **_): + details = None try: data = None details = probe(self.app, file) for stream in details.streams: if stream.codec_type == "video": data = (file.name, f"{stream.width}x{stream.height}", stream.codec_name) + break if not data: raise Exception() except Exception: @@ -243,6 +245,8 @@ def check_to_add(file, list_of_items, bad_items, **_): bad_items.append(file.name) else: list_of_items.append(data) + finally: + del details items = [] skipped = [] From 6148451fc77fb443ba7f0093cd86239e5008e2e6 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 12 Feb 2026 07:39:15 -0600 Subject: [PATCH 02/10] * Adding bottom status bar with animated icon showing encoding state, progress bar, and status messages * Adding startup tasks (FFmpeg config, GPU detect, HDR10+ download) running through the status bar with main window visible * Fixing #716 Maximize button not working (thanks to roxerqermik and 19Battlestar65) --- CHANGES | 3 + fastflix/application.py | 98 ++++++--- fastflix/data/languages.yaml | 60 ++++++ fastflix/shared.py | 3 - fastflix/ui_constants.py | 1 + fastflix/widgets/container.py | 97 ++++++--- fastflix/widgets/main.py | 221 ++++++++++++++++----- fastflix/widgets/panels/status_panel.py | 11 + fastflix/widgets/progress_bar.py | 111 ----------- fastflix/widgets/settings.py | 19 +- fastflix/widgets/video_options.py | 35 +++- fastflix/widgets/windows/concat.py | 5 +- fastflix/widgets/windows/multiple_files.py | 5 +- fastflix/widgets/windows/profile_window.py | 3 +- tests/test_pyside6_fixes.py | 10 +- 15 files changed, 435 insertions(+), 247 deletions(-) delete mode 100644 fastflix/widgets/progress_bar.py diff --git a/CHANGES b/CHANGES index 36451e32..bd3f9ffd 100644 --- a/CHANGES +++ b/CHANGES @@ -2,7 +2,10 @@ ## Version 6.1.0 +* Adding bottom status bar with animated icon showing encoding state, progress bar, and status messages +* Adding startup tasks (FFmpeg config, GPU detect, HDR10+ download) running through the status bar with main window visible * Adding Terms and Agreements dialog shown on first startup requiring user acceptance before proceeding +* Fixing #716 Maximize button not working (thanks to roxerqermik and 19Battlestar65) * Fixing #349 NVEncC audio conversion losing multichannel layout for EAC3 (thanks to Wontell) * Fixing #384 Remove HDR leaving Dolby Vision metadata traces in Rigaya encoder output (thanks to end2endzone) * Fixing #511 UI labels and buttons truncated in non-English translations by auto-shrinking text to fit (thanks to PegHorse) diff --git a/fastflix/application.py b/fastflix/application.py index 086ea981..40f49399 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -15,8 +15,9 @@ from fastflix.program_downloads import ask_for_ffmpeg, grab_stable_ffmpeg, download_hdr10plus_tool from fastflix.resources import main_icon, breeze_styles_path from fastflix.shared import file_date, message, latest_fastflix, DEVMODE, yes_no_message +from fastflix.ui_constants import FONTS from fastflix.widgets.container import Container -from fastflix.widgets.progress_bar import ProgressBar, Task +from fastflix.widgets.status_bar import Task, STATE_IDLE, STATE_ERROR from fastflix.gpu_detect import automatic_rigaya_download logger = logging.getLogger("fastflix") @@ -44,7 +45,7 @@ def create_app(enable_scaling): available_fonts = QtGui.QFontDatabase().families() font_preference = ["Roboto", "Segoe UI", "Ubuntu", "Open Sans", "Sans Serif"] selected_font = next((f for f in font_preference if f in available_fonts), "Sans Serif") - my_font = QtGui.QFont(selected_font, 9) + my_font = QtGui.QFont(selected_font, FONTS.SMALL) main_app.setFont(my_font) icon = QtGui.QIcon() icon.addFile(main_icon, QtCore.QSize(16, 16)) @@ -203,21 +204,17 @@ def app_setup( f"{app.fastflix.config.config_path}", title="Upgraded", ) + missing_ff = False try: app.fastflix.config.load(portable_mode=portable_mode) except MissingFF as err: if reusables.win_based and ask_for_ffmpeg(): - try: - ProgressBar(app, [Task(t("Downloading FFmpeg"), grab_stable_ffmpeg)], signal_task=True) - app.fastflix.config.load() - except Exception as err: - logger.exception(str(err)) - sys.exit(1) + # User wants to download FFmpeg — will be handled after Container is shown + missing_ff = "download" else: - logger.error(f"Could not find {err} location, please manually set in {app.fastflix.config.config_path}") - sys.exit(1) + missing_ff = str(err) + logger.warning(f"FFmpeg not found during config load: {err}") except Exception: - # TODO give edit / delete options logger.exception(t("Could not load config file!")) sys.exit(1) @@ -251,6 +248,54 @@ def app_setup( logger.setLevel(app.fastflix.config.logging_level) + # Initialize empty encoder/audio lists so Container can be created before startup tasks + if app.fastflix.encoders is None: + app.fastflix.encoders = {} + if app.fastflix.audio_encoders is None: + app.fastflix.audio_encoders = [] + + # Create and show Container immediately (UI starts disabled via Main.__init__) + container = Container(app) + container.show() + + screen_geometry = QtGui.QGuiApplication.primaryScreen().availableGeometry() + container.move(screen_geometry.center() - container.rect().center()) + + # Disable entire window during startup tasks + container.setEnabled(False) + app.processEvents() + + # Handle missing FFmpeg + if missing_ff: + if missing_ff == "download": + # Download FFmpeg through status bar + try: + container.status_bar.run_tasks( + [Task(t("Downloading FFmpeg"), grab_stable_ffmpeg)], + signal_task=True, + persist_complete=True, + ) + app.fastflix.config.load() + except Exception as err: + logger.exception(str(err)) + container.status_bar.set_state( + STATE_ERROR, + t("FFmpeg not found") + " — " + t("configure in Settings") + " (Ctrl+S)", + ) + container.setEnabled(True) + return app + else: + logger.error( + f"Could not find {missing_ff} location, please manually set in {app.fastflix.config.config_path}" + ) + container.status_bar.set_state( + STATE_ERROR, + t("FFmpeg not found") + " — " + t("configure in Settings") + " (Ctrl+S)", + ) + container.setEnabled(True) + return app + + # GPU detect and HDR10+ download (Windows only, with user permission dialogs) if platform.system() == "Windows": if app.fastflix.config.auto_gpu_check is None: app.fastflix.config.auto_gpu_check = yes_no_message( @@ -260,9 +305,14 @@ def app_setup( title="Allow Optional Downloads", ) if app.fastflix.config.auto_gpu_check: - ProgressBar( - app, [Task(name=t("Detect GPUs"), command=automatic_rigaya_download)], signal_task=True, can_cancel=True - ) + try: + container.status_bar.run_tasks( + [Task(name=t("Detect GPUs"), command=automatic_rigaya_download)], + signal_task=True, + can_cancel=True, + ) + except Exception: + logger.exception("Failed to detect GPUs") if app.fastflix.config.auto_hdr10plus_check is None and not app.fastflix.config.hdr10plus_parser: app.fastflix.config.auto_hdr10plus_check = yes_no_message( @@ -273,8 +323,7 @@ def app_setup( ) if app.fastflix.config.auto_hdr10plus_check: try: - ProgressBar( - app, + container.status_bar.run_tasks( [Task(t("Downloading HDR10+ Tool"), download_hdr10plus_tool)], signal_task=True, can_cancel=True, @@ -287,6 +336,7 @@ def app_setup( app.fastflix.config.save() + # Run startup tasks (FFmpeg config, encoder init) through status bar startup_tasks = [ Task(t("Gather FFmpeg version"), ffmpeg_configuration), Task(t("Gather FFprobe version"), ffprobe_configuration), @@ -296,17 +346,19 @@ def app_setup( ] try: - ProgressBar(app, startup_tasks) + container.status_bar.run_tasks(startup_tasks, persist_complete=True) except Exception: logger.exception(f"{t('Could not start FastFlix')}!") - sys.exit(1) + container.status_bar.set_state(STATE_ERROR, t("Could not start FastFlix")) + container.setEnabled(True) + return app - container = Container(app) - container.show() + # Encoders are now populated — initialize the encoder UI + container.main.init_encoders_ui() - # container.move(QtGui.QGuiApplication.primaryScreen().availableGeometry().center() - container.rect().center()) - screen_geometry = QtGui.QGuiApplication.primaryScreen().availableGeometry() - container.move(screen_geometry.center() - container.rect().center()) + # Re-enable UI after startup tasks complete + container.setEnabled(True) + container.status_bar.set_state(STATE_IDLE) if not app.fastflix.config.disable_version_check: QtCore.QTimer.singleShot(500, lambda: latest_fastflix(app=app, show_new_dialog=False)) diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 1ffc19f1..1266c053 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -12747,3 +12747,63 @@ Copy Dolby Vision RPU metadata from input file: ukr: Скопіюйте метадані Dolby Vision RPU з вхідного файлу kor: 입력 파일에서 Dolby Vision RPU 메타데이터 복사하기 ron: Copierea metadatelor Dolby Vision RPU din fișierul de intrare +All conversions complete: + eng: All conversions complete + deu: Alle Konvertierungen abgeschlossen + fra: Toutes les conversions sont terminées + ita: Tutte le conversioni sono state completate + spa: Todas las conversiones completadas + jpn: すべてのコンバージョンが完了 + rus: Все преобразования завершены + por: Todas as conversões concluídas + swe: Alla konverteringar slutförda + pol: Wszystkie konwersje zakończone + chs: 完成所有转换 + ukr: Усі перетворення завершено + kor: 모든 전환 완료 + ron: Toate conversiile sunt finalizate +Encoding: + eng: Encoding + deu: Kodierung + fra: Encodage + ita: Codifica + spa: Codificación + jpn: エンコーディング + rus: Кодирование + por: Codificação + swe: Kodning + pol: Kodowanie + chs: 编码 + ukr: Кодування + kor: 인코딩 + ron: Codificare +Complete: + eng: Complete + deu: Vollständig + fra: Compléter + ita: Completo + spa: Complete + jpn: 完全 + rus: Полный + por: Completo + swe: Komplett + pol: Kompletny + chs: 完整 + ukr: Завершено + kor: 완료 + ron: Completați +Ready: + eng: Ready + deu: Bereit + fra: Prêt + ita: Pronto + spa: Listo + jpn: 準備完了 + rus: Готовые + por: Pronto + swe: Redo + pol: Gotowy + chs: 准备就绪 + ukr: Готово. + kor: 준비 + ron: Gata diff --git a/fastflix/shared.py b/fastflix/shared.py index f3cbee68..56481095 100644 --- a/fastflix/shared.py +++ b/fastflix/shared.py @@ -123,7 +123,6 @@ def event(self, e): def message(msg, title=None, parent=None): sm = QtWidgets.QMessageBox(parent) - sm.setStyleSheet("font-size: 14px") sm.setText(msg) if title: sm.setWindowTitle(title) @@ -134,7 +133,6 @@ def message(msg, title=None, parent=None): def error_message(msg, details=None, traceback=False, title=None, parent=None): em = MyMessageBox(parent) - em.setStyleSheet("font-size: 14px") em.setText(msg) em.setWindowIcon(QtGui.QIcon(my_data)) if title: @@ -151,7 +149,6 @@ def error_message(msg, details=None, traceback=False, title=None, parent=None): def yes_no_message(msg, title=None, yes_text=t("Yes"), no_text=t("No"), yes_action=None, no_action=None, parent=None): sm = QtWidgets.QMessageBox(parent) - sm.setStyleSheet("font-size: 14px") sm.setWindowTitle(t(title)) sm.setText(msg) sm.addButton(yes_text, QtWidgets.QMessageBox.YesRole) diff --git a/fastflix/ui_constants.py b/fastflix/ui_constants.py index b939ef23..70c99bf7 100644 --- a/fastflix/ui_constants.py +++ b/fastflix/ui_constants.py @@ -51,6 +51,7 @@ class BaseHeights: SPACER_TINY: int = 2 SPACER_SMALL: int = 4 BUTTON_SIZE: int = 22 + STATUS_BAR: int = 28 @dataclass(frozen=True, slots=True) diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 99d0d599..ceb1c423 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -23,7 +23,6 @@ clean_logs, error_message, latest_fastflix, - message, yes_no_message, parse_filesafe_datetime, is_date_older_than_7days, @@ -36,8 +35,8 @@ # from fastflix.widgets.logs import Logs from fastflix.widgets.main import Main from fastflix.widgets.windows.profile_window import ProfileWindow -from fastflix.widgets.progress_bar import ProgressBar, Task from fastflix.widgets.settings import Settings +from fastflix.widgets.status_bar import StatusBarWidget, STATE_COMPLETE, STATE_ERROR from fastflix.widgets.windows.concat import ConcatWindow from fastflix.widgets.windows.multiple_files import MultipleFilesWindow @@ -55,7 +54,6 @@ class Container(QtWidgets.QMainWindow): def __init__(self, app: FastFlixApp, **kwargs): super().__init__(None) self.app = app - self.pb = None self.profile_window = None self.setMinimumSize(self.MIN_WIDTH, self.MIN_HEIGHT) @@ -94,7 +92,18 @@ def __init__(self, app: FastFlixApp, **kwargs): self.main = Main(self, app) - self.setCentralWidget(self.main) + self.status_bar = StatusBarWidget(self.app, parent=self) + + # Wrap Main + StatusBar in a vertical layout as the central widget + central = QtWidgets.QWidget(self) + central_layout = QtWidgets.QVBoxLayout(central) + central_layout.setContentsMargins(0, 0, 0, 0) + central_layout.setSpacing(0) + central_layout.addWidget(self.main) + central_layout.addWidget(self.status_bar) + central.setLayout(central_layout) + + self.setCentralWidget(central) self.setBaseSize(QtCore.QSize(self.BASE_WIDTH, self.BASE_HEIGHT)) # Set initial window size to base dimensions self.resize(self.BASE_WIDTH, self.BASE_HEIGHT) @@ -103,6 +112,11 @@ def __init__(self, app: FastFlixApp, **kwargs): self._constrain_to_screen() self.main.set_profile() + # Connect encoding signals to status bar + self.main.encoding_status_signal.connect(lambda msg, state: self.status_bar.set_state(state, msg)) + self.main.encoding_progress_signal.connect(self.status_bar.set_progress) + self.main.video_options.status.progress_percent.connect(self.status_bar.set_progress) + self._update_scaled_styles() # self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) self.moveFlag = False @@ -117,13 +131,15 @@ def _constrain_to_screen(self): if screen is None: return available = screen.availableGeometry() - # Set maximum size to screen available geometry with some margin - max_width = available.width() - 20 - max_height = available.height() - 20 - self.setMaximumSize(max_width, max_height) + # Clamp current size to fit screen if needed, but don't set a maximum + # so the window can still be maximized via the title bar button + if self.width() > available.width() or self.height() > available.height(): + self.resize(min(self.width(), available.width()), min(self.height(), available.height())) def ensure_window_in_bounds(self): """Public method to ensure window stays within screen bounds after content changes.""" + if self.isMaximized() or self.isFullScreen(): + return self._constrain_to_screen() screen = QtGui.QGuiApplication.primaryScreen() if screen is None: @@ -155,10 +171,15 @@ def ensure_window_in_bounds(self): def resizeEvent(self, event: QtGui.QResizeEvent) -> None: """Handle resize events to ensure window stays within screen bounds and update scaling.""" super().resizeEvent(event) - # Update scale factors based on new size + + # Always update scale factors and styles so the UI adapts to any window size scaler.calculate_factors(event.size().width(), event.size().height()) self._update_scaled_styles() + # Don't adjust position when maximized/fullscreen — the OS manages geometry + if self.isMaximized() or self.isFullScreen(): + return + screen = QtGui.QGuiApplication.primaryScreen() if screen is None: return @@ -211,11 +232,10 @@ def resizeEvent(self, event: QtGui.QResizeEvent) -> None: def closeEvent(self, a0: QtGui.QCloseEvent) -> None: self.app.fastflix.shutting_down = True - if self.pb: - try: - self.pb.stop_signal.emit() - except Exception: - pass + try: + self.status_bar.cancel() + except Exception: + pass if self.app.fastflix.currently_encoding: sm = QtWidgets.QMessageBox() sm.setText(f"

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

") @@ -251,6 +271,7 @@ def closeEvent(self, a0: QtGui.QCloseEvent) -> None: if self.app.fastflix.config.clean_old_logs: self.clean_old_logs(show_errors=False) + self.status_bar.cleanup() self.main.close(from_container=True) super(Container, self).closeEvent(a0) @@ -444,47 +465,56 @@ def download_stable_ffmpeg(self): self.download_ffmpeg(ffmpeg_version="stable") def download_ffmpeg(self, ffmpeg_version="latest"): + from fastflix.widgets.status_bar import Task as SBTask + ffmpeg_folder = Path(user_data_dir("FFmpeg", appauthor=False, roaming=True)) / "bin" ffmpeg = ffmpeg_folder / "ffmpeg.exe" ffprobe = ffmpeg_folder / "ffprobe.exe" try: - self.pb = ProgressBar( - self.app, - [Task(t("Downloading FFmpeg"), grab_stable_ffmpeg if ffmpeg_version == "stable" else latest_ffmpeg)], + self.status_bar.run_tasks( + [SBTask(t("Downloading FFmpeg"), grab_stable_ffmpeg if ffmpeg_version == "stable" else latest_ffmpeg)], signal_task=True, can_cancel=True, + persist_complete=True, ) except FastFlixInternalException: pass except Exception as err: - message(f"{t('Could not download the newest FFmpeg')}: {err}") + self.status_bar.set_state(STATE_ERROR, f"{t('Could not download the newest FFmpeg')}: {err}") else: if not ffmpeg.exists() or not ffprobe.exists(): - message(f"{t('Could not locate the downloaded files at')} {ffmpeg_folder}!") + self.status_bar.set_state( + STATE_ERROR, f"{t('Could not locate the downloaded files at')} {ffmpeg_folder}!" + ) else: self.app.fastflix.config.ffmpeg = ffmpeg self.app.fastflix.config.ffprobe = ffprobe - self.pb = None + self.status_bar.set_state(STATE_COMPLETE, t("FFmpeg downloaded successfully")) def download_rigaya(self): + from fastflix.widgets.status_bar import Task as SBTask + try: - self.pb = ProgressBar( - self.app, - [Task(t("Updating Rigaya's encoders"), update_rigaya_encoders)], + self.status_bar.run_tasks( + [SBTask(t("Updating Rigaya's encoders"), update_rigaya_encoders)], signal_task=True, can_cancel=True, + persist_complete=True, ) except Exception: error_message(t("Could not update Rigaya's encoders"), traceback=True) - self.pb = None + else: + self.status_bar.set_state(STATE_COMPLETE, t("Rigaya's encoders updated")) def download_hdr10plus_tool(self): + from fastflix.widgets.status_bar import Task as SBTask + try: - self.pb = ProgressBar( - self.app, - [Task(t("Downloading HDR10+ Tool"), download_hdr10plus_tool)], + self.status_bar.run_tasks( + [SBTask(t("Downloading HDR10+ Tool"), download_hdr10plus_tool)], signal_task=True, can_cancel=True, + persist_complete=True, ) except Exception: error_message(t("Could not download HDR10+ tool"), traceback=True) @@ -495,18 +525,23 @@ def download_hdr10plus_tool(self): if result: self.app.fastflix.config.hdr10plus_parser = result self.app.fastflix.config.save() - message(f"{t('HDR10+ tool has been downloaded to')} {result}") + self.status_bar.set_state(STATE_COMPLETE, f"{t('HDR10+ tool has been downloaded to')} {result}") else: error_message(t("Could not locate the downloaded HDR10+ tool")) - self.pb = None def clean_old_logs(self, show_errors=True): + from fastflix.widgets.status_bar import Task as SBTask + try: - self.pb = ProgressBar(self.app, [Task(t("Clean Old Logs"), clean_logs)], signal_task=True, can_cancel=False) + self.status_bar.run_tasks( + [SBTask(t("Clean Old Logs"), clean_logs)], + signal_task=True, + can_cancel=False, + persist_complete=True, + ) except Exception: if show_errors: error_message(t("Could not compress old logs"), traceback=True) - self.pb = None def set_stay_top(self): if self.app.fastflix.config.stay_on_top: diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 3d028145..b897bf1e 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -47,7 +47,6 @@ ) from fastflix.shared import ( error_message, - message, time_to_number, yes_no_message, clean_file_string, @@ -56,7 +55,7 @@ ) from fastflix.windows_tools import prevent_sleep_mode, allow_sleep_mode from fastflix.widgets.background_tasks import ThumbnailCreator -from fastflix.widgets.progress_bar import ProgressBar, Task +from fastflix.widgets.status_bar import Task, STATE_ENCODING, STATE_ERROR, STATE_COMPLETE, STATE_IDLE from fastflix.widgets.video_options import VideoOptions from fastflix.widgets.windows.large_preview import LargePreview @@ -169,6 +168,8 @@ class Main(QtWidgets.QWidget): close_event = QtCore.Signal() status_update_signal = QtCore.Signal(tuple) thread_logging_signal = QtCore.Signal(str) + encoding_progress_signal = QtCore.Signal(int) + encoding_status_signal = QtCore.Signal(str, str) # (message, state) def __init__(self, parent, app: FastFlixApp): super().__init__(parent) @@ -180,6 +181,7 @@ def __init__(self, parent, app: FastFlixApp): self.initialized = False self.loading_video = True self.scale_updating = False + self._top_bar_widgets = [] # widgets that share the same height in the top bar self.last_thumb_hash = "" self.page_updating = False self.previous_encoder_no_audio = False @@ -231,7 +233,6 @@ def __init__(self, parent, app: FastFlixApp): ) self.source_video_path_widget = QtWidgets.QLineEdit(motto) self.source_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.PATH_WIDGET)) - self.source_video_path_widget.setFont(QtGui.QFont(self.app.font().family(), 9)) self.source_video_path_widget.setDisabled(True) self.source_video_path_widget.setStyleSheet( f"padding: 0 0 -1px 5px; color: rgb({get_text_color(self.app.fastflix.config.theme)})" @@ -240,7 +241,6 @@ def __init__(self, parent, app: FastFlixApp): self.output_video_path_widget = QtWidgets.QLineEdit("") self.output_video_path_widget.setDisabled(True) self.output_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.PATH_WIDGET)) - self.output_video_path_widget.setFont(QtGui.QFont(self.app.font().family(), 9)) self.output_video_path_widget.setStyleSheet( f"padding: 0 0 -1px 5px; color: rgb({get_text_color(self.app.fastflix.config.theme)})" ) @@ -279,12 +279,13 @@ def __init__(self, parent, app: FastFlixApp): # Set column stretch factors: # Left (cols 0-5) stays fixed (stretch=0) # Preview area (cols 6-10) and Right (cols 11-13) expand to fill space + # Right columns get more stretch so preview is smaller for col in range(6): self.grid.setColumnStretch(col, 0) for col in range(6, 11): self.grid.setColumnStretch(col, 1) for col in range(11, 14): - self.grid.setColumnStretch(col, 1) + self.grid.setColumnStretch(col, 2) # row: int, column: int, rowSpan: int, columnSpan: int @@ -312,6 +313,9 @@ def __init__(self, parent, app: FastFlixApp): self.disable_all() self.setLayout(self.grid) + # Keep all top bar widgets at the same height when the window scales + scaler.add_listener(self._on_scale_changed) + if self.app.fastflix.config.theme == "onyx": self.setStyleSheet( "QLabel{ color: white; } " @@ -325,6 +329,12 @@ def __init__(self, parent, app: FastFlixApp): self.loading_video = False self.last_page_update = time.time() + def _on_scale_changed(self, _factors): + """Update all top bar widgets to the same height when the window scale changes.""" + h = scaler.scale(HEIGHTS.TOP_BAR_BUTTON) + for w in self._top_bar_widgets: + w.setFixedHeight(h) + def fade_loop(self, percent=90): if self.input_video: self.source_video_path_widget.setStyleSheet( @@ -348,23 +358,26 @@ def fade_loop(self, percent=90): def init_top_bar(self): top_bar = QtWidgets.QHBoxLayout() + top_bar_h = scaler.scale(HEIGHTS.TOP_BAR_BUTTON) + source = QtWidgets.QPushButton(QtGui.QIcon(self.get_icon("onyx-source")), f" {t('Source')}") source.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) - source.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) - source.setStyleSheet("font-size: 14px;") + source.setFixedHeight(top_bar_h) source.setDefault(True) source.clicked.connect(lambda: self.open_file()) + self._top_bar_widgets.append(source) self.widgets.profile_box = QtWidgets.QComboBox() - self.widgets.profile_box.setStyleSheet("text-align: center; font-size: 14px;") + self.widgets.profile_box.setStyleSheet("text-align: center;") self.widgets.profile_box.addItems(self.app.fastflix.config.profiles.keys()) self.widgets.profile_box.view().setFixedWidth( self.widgets.profile_box.minimumSizeHint().width() + scaler.scale(50) ) 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(scaler.scale(WIDTHS.PROFILE_BOX)) - self.widgets.profile_box.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) + self.widgets.profile_box.setMinimumWidth(scaler.scale(WIDTHS.PROFILE_BOX)) + self.widgets.profile_box.setFixedHeight(top_bar_h) + self._top_bar_widgets.append(self.widgets.profile_box) top_bar.addWidget(source) top_bar.addWidget(QtWidgets.QSplitter(QtCore.Qt.Horizontal)) @@ -374,8 +387,9 @@ def init_top_bar(self): top_bar.addWidget(QtWidgets.QSplitter(QtCore.Qt.Horizontal)) self.add_profile = QtWidgets.QPushButton(QtGui.QIcon(self.get_icon("onyx-new-profile")), "") - self.add_profile.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) + self.add_profile.setFixedHeight(top_bar_h) self.add_profile.setIconSize(scaler.scale_size(ICONS.SMALL + 4, ICONS.SMALL + 4)) + self._top_bar_widgets.append(self.add_profile) self.add_profile.setToolTip(t("New Profile")) # add_profile.setLayoutDirection(QtCore.Qt.RightToLeft) self.add_profile.clicked.connect(lambda: self.container.new_profile()) @@ -394,29 +408,32 @@ def init_top_bar(self): def init_top_bar_right(self): top_bar_right = QtWidgets.QHBoxLayout() - theme = "QPushButton{ padding: 0 10px; font-size: 14px; }" + theme = "QPushButton{ padding: 0 10px; }" if self.app.fastflix.config.theme in ("dark", "onyx"): theme = """ QPushButton { padding: 0 10px; - font-size: 14px; background-color: #4f4f4f; border: none; color: white; } QPushButton:hover { background-color: #6b6b6b; }""" + top_bar_h = scaler.scale(HEIGHTS.TOP_BAR_BUTTON) + queue = QtWidgets.QPushButton(QtGui.QIcon(onyx_queue_add_icon), f"{t('Add to Queue')} ") queue.setIconSize(scaler.scale_size(ICONS.LARGE, ICONS.LARGE)) - queue.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) + queue.setFixedHeight(top_bar_h) queue.setStyleSheet(theme) queue.setLayoutDirection(QtCore.Qt.RightToLeft) queue.clicked.connect(lambda: self.add_to_queue()) + self._top_bar_widgets.append(queue) self.widgets.convert_button = QtWidgets.QPushButton(QtGui.QIcon(onyx_convert_icon), f"{t('Convert')} ") self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.LARGE, ICONS.LARGE)) - self.widgets.convert_button.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) + self.widgets.convert_button.setFixedHeight(top_bar_h) self.widgets.convert_button.setStyleSheet(theme) + self._top_bar_widgets.append(self.widgets.convert_button) self.widgets.convert_button.setLayoutDirection(QtCore.Qt.RightToLeft) self.widgets.convert_button.clicked.connect(lambda: self.encode_video()) top_bar_right.addStretch(1) @@ -531,11 +548,52 @@ def pause_resume(self): self.widgets.pause_resume.setStyleSheet("background-color: orange;") logger.info("Resuming FFmpeg conversion") - def config_update(self): + def config_update(self, encoder_reload_needed=False): self.thumb_file = Path(self.app.fastflix.config.work_path, "thumbnail_preview.jpg") - self.change_output_types() + if encoder_reload_needed: + self.reload_encoders() + else: + self.change_output_types() self.page_update(build_thumbnail=True) + def reload_encoders(self): + """Re-run FFmpeg/encoder init after settings change, via status bar.""" + from fastflix.application import init_encoders + from fastflix.flix import ( + ffmpeg_audio_encoders, + ffmpeg_configuration, + ffmpeg_opencl_support, + ffprobe_configuration, + ) + + if self.app.fastflix.currently_encoding: + error_message(t("Cannot reload encoders while encoding is in progress")) + return + + previous_encoder = self.convert_to + + tasks = [ + Task(t("Gather FFmpeg version"), ffmpeg_configuration), + Task(t("Gather FFprobe version"), ffprobe_configuration), + Task(t("Gather FFmpeg audio encoders"), ffmpeg_audio_encoders), + Task(t("Determine OpenCL Support"), ffmpeg_opencl_support), + Task(t("Initialize Encoders"), init_encoders), + ] + + try: + self.container.status_bar.run_tasks(tasks, persist_complete=True) + except Exception: + logger.exception("Failed to reload encoders after settings change") + self.container.status_bar.set_state(STATE_ERROR, t("Failed to reload encoders")) + return + + self.init_encoders_ui() + + if previous_encoder and previous_encoder in self.app.fastflix.encoders: + self.widgets.convert_to.setCurrentText(previous_encoder) + + self.container.status_bar.set_state(STATE_IDLE) + def init_video_area(self): layout = QtWidgets.QVBoxLayout() spacer = QtWidgets.QLabel() @@ -570,7 +628,8 @@ def init_video_area(self): output_layout.addWidget(self.output_video_path_widget, stretch=True) self.widgets.output_type_combo.setFixedWidth(scaler.scale(WIDTHS.OUTPUT_TYPE)) - self.widgets.output_type_combo.addItems(self.current_encoder.video_extensions) + if self.current_encoder: + self.widgets.output_type_combo.addItems(self.current_encoder.video_extensions) self.widgets.output_type_combo.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) if self.app.fastflix.config.theme == "onyx": self.widgets.output_type_combo.setStyleSheet(get_onyx_combobox_style()) @@ -602,12 +661,14 @@ def init_video_area(self): file_group_layout.addLayout(out_dir_layout) file_group_layout.addLayout(output_layout) - # Video info bar (bit depth, color space, chroma subsampling, HDR10, HDR10+) + # Video info bar (codec, bit depth, color space, chroma subsampling, HDR10, HDR10+) + self.video_codec_label = QtWidgets.QLabel() self.video_bit_depth_label = QtWidgets.QLabel() self.video_chroma_label = QtWidgets.QLabel() self.video_hdr10_label = QtWidgets.QLabel() self.video_hdr10plus_label = QtWidgets.QLabel() for lbl in ( + self.video_codec_label, self.video_bit_depth_label, self.video_chroma_label, self.video_hdr10_label, @@ -623,6 +684,8 @@ def init_video_area(self): shrink_text_to_fit(self.video_info_label) self.video_info_label.hide() info_layout.addWidget(self.video_info_label) + info_layout.addWidget(self.video_codec_label) + info_layout.addSpacing(scaler.scale(12)) info_layout.addWidget(self.video_bit_depth_label) info_layout.addSpacing(scaler.scale(12)) info_layout.addWidget(self.video_chroma_label) @@ -698,13 +761,13 @@ def init_options_tabs(self): shrink_text_to_fit(rot_label, padding=4) transform_row.addWidget(rot_label) transform_row.addWidget(self.init_rotate()) + transform_row.addStretch(1) flip_label = QtWidgets.QLabel(t("Flip")) flip_label.setFixedWidth(scaler.scale(50)) shrink_text_to_fit(flip_label, padding=4) transform_row.addWidget(flip_label) transform_row.addWidget(self.init_flip()) - transform_row.addStretch(1) size_layout.addLayout(transform_row) size_layout.addStretch(1) @@ -758,13 +821,6 @@ def init_options_tabs(self): time_field=True, ) self.widgets.start_time.textChanged.connect(lambda: self.page_update()) - start_from_preview = QtWidgets.QPushButton() - start_from_preview.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon)) - start_from_preview.setFixedSize(scaler.scale(24), scaler.scale(28)) - start_from_preview.setToolTip(t("Set start time from preview position")) - start_from_preview.clicked.connect(lambda: self.set_time_from_preview(self.widgets.start_time)) - self.buttons.append(start_from_preview) - start_row.addWidget(start_from_preview) self.widgets.end_time, end_row = self.build_hoz_int_field( t("End"), @@ -773,21 +829,38 @@ def init_options_tabs(self): time_field=True, ) self.widgets.end_time.textChanged.connect(lambda: self.page_update()) + + time_col2.addLayout(start_row) + time_col2.addLayout(end_row) + time_col2.addStretch(1) + + # Column 3: "Set from preview" buttons + time_col3 = QtWidgets.QVBoxLayout() + time_col3.setSpacing(scaler.scale(4)) + + start_from_preview = QtWidgets.QPushButton() + start_from_preview.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon)) + start_from_preview.setFixedSize(scaler.scale(24), scaler.scale(28)) + start_from_preview.setToolTip(t("Set start time from preview position")) + start_from_preview.clicked.connect(lambda: self.set_time_from_preview(self.widgets.start_time)) + self.buttons.append(start_from_preview) + end_from_preview = QtWidgets.QPushButton() end_from_preview.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon)) end_from_preview.setFixedSize(scaler.scale(24), scaler.scale(28)) end_from_preview.setToolTip(t("Set end time from preview position")) end_from_preview.clicked.connect(lambda: self.set_time_from_preview(self.widgets.end_time)) self.buttons.append(end_from_preview) - end_row.addWidget(end_from_preview) - time_col2.addLayout(start_row) - time_col2.addLayout(end_row) - time_col2.addStretch(1) + time_col3.addWidget(start_from_preview) + time_col3.addWidget(end_from_preview) + time_col3.addStretch(1) time_layout.addLayout(time_col1) + time_layout.addStretch(1) time_layout.addLayout(time_col2) time_layout.addStretch(1) + time_layout.addLayout(time_col3) tabs.addTab(time_tab, t("Start/End Time")) @@ -1090,21 +1163,43 @@ def change_output_types(self): self.widgets.convert_to.setItemIcon(i, QtGui.QIcon(plugin.icon)) icon_size = scaler.scale(33) if self.app.fastflix.config.flat_ui else scaler.scale(ICONS.XLARGE) self.widgets.convert_to.setIconSize(QtCore.QSize(icon_size, icon_size)) + self._size_encoder_combo() + + def init_encoders_ui(self): + """Populate the encoder dropdown and initialize the encoder settings panel. + + Called after startup tasks have populated app.fastflix.encoders. + """ + if not self.app.fastflix.encoders: + return + self.change_output_types() + if self.current_encoder: + self.video_options.change_conversion(self.convert_to) + self.set_profile() + + def _size_encoder_combo(self): + """Size the encoder combo box to fit the longest possible encoder name.""" + longest = "HEVC (Video Toolbox)" + if self.app.fastflix.encoders: + names = list(self.app.fastflix.encoders.keys()) + longest = max(names, key=len) if names else longest + fm = self.widgets.convert_to.fontMetrics() + text_width = fm.horizontalAdvance(longest) + # Add padding for icon + dropdown arrow + margins + padding = scaler.scale(70) + self.widgets.convert_to.setMinimumWidth(text_width + padding) def init_encoder_drop_down(self): layout = QtWidgets.QHBoxLayout() self.widgets.convert_to = QtWidgets.QComboBox() - self.widgets.convert_to.setFixedWidth(scaler.scale(WIDTHS.ENCODER_MIN)) self.widgets.convert_to.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) - self.widgets.convert_to.setStyleSheet("font-size: 14px;") + self._top_bar_widgets.append(self.widgets.convert_to) + self._size_encoder_combo() self.change_output_types() - self.widgets.convert_to.view().setMinimumWidth( - self.widgets.convert_to.minimumSizeHint().width() + scaler.scale(50) - ) self.widgets.convert_to.currentTextChanged.connect(self.change_encoder) encoder_label = QtWidgets.QLabel(f"{t('Encoder')}: ") - encoder_label.setFixedWidth(scaler.scale(54)) + encoder_label.setMinimumWidth(scaler.scale(54)) shrink_text_to_fit(encoder_label, padding=4) layout.addWidget(self.widgets.convert_to, stretch=0) layout.setSpacing(10) @@ -1120,17 +1215,23 @@ def change_encoder(self): def update_output_type(self): self.widgets.output_type_combo.clear() + if not self.current_encoder: + return self.widgets.output_type_combo.addItems(self.current_encoder.video_extensions) self.widgets.output_type_combo.setCurrentText(self.app.fastflix.config.opt("output_type")) @property def current_encoder(self): + if not self.app.fastflix.encoders: + return None try: return self.app.fastflix.encoders[ self.app.fastflix.current_video.video_settings.video_encoder_settings.name ] except (AttributeError, KeyError): - return self.app.fastflix.encoders[self.convert_to] + if self.convert_to: + return self.app.fastflix.encoders.get(self.convert_to) + return None def reset_time(self): self.widgets.start_time.setText(self.number_to_time(0)) @@ -1532,7 +1633,9 @@ def stop_me(): signal.emit(int((i / total_items) * 100)) self.disable_all() - ProgressBar(self.app, [Task(t("Loading Videos"), open_em, {"paths": paths})], signal_task=True, can_cancel=True) + self.container.status_bar.run_tasks( + [Task(t("Loading Videos"), open_em, {"paths": paths})], signal_task=True, can_cancel=True + ) self.enable_all() @property @@ -1623,7 +1726,7 @@ def get_auto_crop(self): ) for x in times ] - ProgressBar(self.app, tasks) + self.container.status_bar.run_tasks(tasks) if not result_list: logger.warning("Autocrop did not return crop points, please use a ffmpeg version with cropdetect filter") return @@ -1880,7 +1983,7 @@ def update_video_info(self, hide_progress=False): tasks.append(Task(t("Detecting Interlace"), detect_interlaced, dict(source=self.source_material))) try: - ProgressBar(self.app, tasks, hidden=hide_progress) + self.container.status_bar.run_tasks(tasks) except FlixError: error_message(f"{t('Not a video file')}
{self.input_video}") self.clear_current_video() @@ -1967,8 +2070,9 @@ def update_video_info(self, hide_progress=False): if self.app.fastflix.config.opt("auto_crop"): self.get_auto_crop() - if not getattr(self.current_encoder, "enable_concat", False) and self.app.fastflix.current_video.concat: - error_message(f"{self.current_encoder.name} {t('does not support concatenating files together')}") + encoder = self.current_encoder + if encoder and not getattr(encoder, "enable_concat", False) and self.app.fastflix.current_video.concat: + error_message(f"{encoder.name} {t('does not support concatenating files together')}") @staticmethod def _chroma_from_pix_fmt(pix_fmt: str) -> str: @@ -1992,6 +2096,7 @@ def _chroma_from_pix_fmt(pix_fmt: str) -> str: def update_video_info_labels(self): if not self.app.fastflix.current_video: self.video_info_label.hide() + self.video_codec_label.hide() self.video_bit_depth_label.hide() self.video_chroma_label.hide() self.video_hdr10_label.hide() @@ -2004,6 +2109,13 @@ def update_video_info_labels(self): stream = self.app.fastflix.current_video.streams.video[track_index] stream_idx = stream.index + codec = stream.get("codec_name", "") + if codec: + self.video_codec_label.setText(codec.upper()) + self.video_codec_label.show() + else: + self.video_codec_label.hide() + bit_depth = stream.get("bit_depth", "8") self.video_bit_depth_label.setText(f"{bit_depth}-bit") self.video_bit_depth_label.show() @@ -2216,6 +2328,8 @@ def build_commands(self) -> bool: or self.loading_video ): return False + if not self.current_encoder: + return False try: self.get_all_settings() except FastFlixInternalException as err: @@ -2288,6 +2402,7 @@ def page_update(self, build_thumbnail=True, force_build_thumbnail=False): self.page_updating = False def close(self, no_cleanup=False, from_container=False): + scaler.remove_listener(self._on_scale_changed) self.app.fastflix.shutting_down = True # Signal worker process to shutdown gracefully @@ -2318,8 +2433,12 @@ def close(self, no_cleanup=False, from_container=False): @property def convert_to(self): if self.widgets.convert_to: - return self.widgets.convert_to.currentText().strip() - return list(self.app.fastflix.encoders.keys())[0] + text = self.widgets.convert_to.currentText().strip() + if text: + return text + if self.app.fastflix.encoders: + return list(self.app.fastflix.encoders.keys())[0] + return None def encoding_checks(self): if not self.input_video: @@ -2399,6 +2518,9 @@ def encode_video(self): self.send_video_request_to_worker_queue(video_to_send) self.disable_all() self.video_options.show_status() + video_name = video_to_send.video_settings.video_title or video_to_send.video_settings.output_path.stem + self.encoding_status_signal.emit(f"{t('Encoding')}: {video_name}", STATE_ENCODING) + self.encoding_progress_signal.emit(0) def add_to_queue(self): try: @@ -2427,13 +2549,13 @@ def conversion_complete(self, success: bool): self.set_convert_button() if not success: + self.encoding_status_signal.emit(t("Encoding error"), STATE_ERROR) if not self.app.fastflix.config.disable_complete_message: error_message(t("There was an error during conversion and the queue has stopped"), title=t("Error")) self.video_options.queue.new_source() else: + self.encoding_status_signal.emit(t("All conversions complete"), STATE_COMPLETE) self.video_options.show_queue() - if not self.app.fastflix.config.disable_complete_message: - message(t("All queue items have completed"), title=t("Success")) # # @reusables.log_exception("fastflix", show_traceback=False) @@ -2514,6 +2636,8 @@ def status_update(self, status_response): if response.status == "cancelled": video.status.cancelled = True + self.encoding_status_signal.emit(t("Encoding cancelled"), STATE_IDLE) + self.encoding_progress_signal.emit(0) self.end_encoding() self.conversion_cancelled(video) self.video_options.update_queue() @@ -2562,6 +2686,7 @@ def end_encoding(self): self.video_options.queue.run_after_done() self.video_options.update_queue() self.set_convert_button() + self.encoding_progress_signal.emit(0) def send_next_video(self) -> bool: if not self.app.fastflix.currently_encoding: @@ -2598,6 +2723,8 @@ def send_video_request_to_worker_queue(self, video: Video): ) video.status.running = True self.video_options.update_queue() + video_name = video.video_settings.video_title or video.video_settings.output_path.stem + self.encoding_status_signal.emit(f"{t('Encoding')}: {video_name}", STATE_ENCODING) def find_video(self, uuid) -> Video: for video in self.app.fastflix.conversion_list: diff --git a/fastflix/widgets/panels/status_panel.py b/fastflix/widgets/panels/status_panel.py index 33048588..f1f9c71f 100644 --- a/fastflix/widgets/panels/status_panel.py +++ b/fastflix/widgets/panels/status_panel.py @@ -25,6 +25,7 @@ class StatusPanel(QtWidgets.QWidget): bitrate = QtCore.Signal(str) nvencc_signal = QtCore.Signal(str) tick_signal = QtCore.Signal() + progress_percent = QtCore.Signal(int) def __init__(self, parent, app: FastFlixApp): super().__init__(parent) @@ -110,6 +111,8 @@ def update_speed(self, combined): if not speed: self.eta_label.setText(f"{t('Time Left')}: N/A") self.eta_label.setText(f"{t('Time Left')}: {timedelta_to_str(data)}") + if length: + self.progress_percent.emit(min(100, int(time_passed / length * 100))) def update_bitrate(self, bitrate): if not bitrate or bitrate.strip() == "N/A": @@ -135,6 +138,14 @@ def update_nvencc(self, raw_line): Example line: [53.1%] 19/35 frames: 150.57 fps, 5010 kb/s, remain 0:01:55, GPU 10%, VE 96%, VD 42%, est out size 920.6MB """ + # Parse percentage from start of line like [53.1%] + if raw_line.startswith("[") and "%" in raw_line: + try: + pct_str = raw_line.split("]")[0].lstrip("[").rstrip("%") + self.progress_percent.emit(min(100, int(float(pct_str)))) + except (ValueError, IndexError): + pass + for section in raw_line.split(","): section = section.strip() if section.startswith("remain"): diff --git a/fastflix/widgets/progress_bar.py b/fastflix/widgets/progress_bar.py deleted file mode 100644 index 261193d2..00000000 --- a/fastflix/widgets/progress_bar.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -from dataclasses import dataclass, field -from typing import Callable - -import reusables -from PySide6 import QtCore, QtWidgets - -from fastflix.language import t -from fastflix.models.fastflix_app import FastFlixApp -from fastflix.ui_scale import scaler - -logger = logging.getLogger("fastflix") - - -@dataclass -class Task: - name: str - command: Callable - kwargs: dict = field(default_factory=dict) - - -class ProgressBar(QtWidgets.QFrame): - progress_signal = QtCore.Signal(int) - stop_signal = QtCore.Signal() - - def __init__( - self, - app: FastFlixApp, - tasks: list[Task], - signal_task: bool = False, - auto_run: bool = True, - can_cancel: bool = False, - hidden: bool = False, - ): - super().__init__(None) - self.app = app - - self.tasks = tasks - self.signal_task = signal_task - self.cancelled = False - - self.setObjectName("ProgressBar") - self.setStyleSheet("#ProgressBar{border: 1px solid #aaa}") - - self.setMinimumWidth(scaler.scale(333)) - self.setWindowFlags(QtCore.Qt.SplashScreen | QtCore.Qt.FramelessWindowHint) - self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) - self.status = QtWidgets.QLabel() - self.progress_bar = QtWidgets.QProgressBar(self) - self.progress_bar.setGeometry(scaler.scale(25), scaler.scale(33), scaler.scale(417), scaler.scale(63)) - - self.layout = QtWidgets.QVBoxLayout() - self.layout.addWidget(self.status) - self.layout.addWidget(self.progress_bar) - if can_cancel: - cancel_button = QtWidgets.QPushButton(t("Cancel")) - cancel_button.clicked.connect(self.cancel) - self.layout.addWidget(cancel_button) - self.setLayout(self.layout) - - if not hidden: - self.show() - if auto_run: - self.run() - - def cancel(self): - if self.signal_task: - self.stop_signal.emit() - else: - self.cancelled = True - self.close() - - @reusables.log_exception("fastflix") - def run(self): - if not self.tasks: - logger.error("Progress bar RUN called without any tasks") - return - ratio = 100 / len(self.tasks) - self.progress_bar.setValue(0) - - if self.signal_task: - self.status.setText(self.tasks[0].name) - self.progress_signal.connect(self.update_progress) - self.tasks[0].kwargs["signal"] = self.progress_signal - self.tasks[0].kwargs["stop_signal"] = self.stop_signal - self.tasks[0].command(config=self.app.fastflix.config, app=self.app, **self.tasks[0].kwargs) - - else: - for i, task in enumerate(self.tasks, start=1): - logger.info(f"Running task {task.name}") - self.status.setText(task.name) - self.app.processEvents() - if self.app.fastflix.shutting_down: - self.close() - try: - task.command(config=self.app.fastflix.config, app=self.app, **task.kwargs) - except Exception: - logger.exception(f"Could not run task {task.name} with config {self.app.fastflix.config}") - self.close() - raise - self.progress_bar.setValue(int(i * ratio)) - if self.cancelled: - return - - def update_progress(self, value): - self.progress_bar.setValue(value) - self.app.processEvents() - if self.app.fastflix.shutting_down: - self.stop_signal.emit() - self.close() diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 4b180aa8..79a0f2db 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -389,7 +389,7 @@ def _build_locations_tab(self): for det_row, (detected, name, description) in enumerate(programs): icon = "\u2714" if detected else "\u2718" color = "green" if detected else "red" - status_label = QtWidgets.QLabel(f'{icon}') + status_label = QtWidgets.QLabel(f'{icon}') detected_layout.addWidget(status_label, det_row, 0) detected_layout.addWidget(QtWidgets.QLabel(f"{name}"), det_row, 1) detected_layout.addWidget(QtWidgets.QLabel(description), det_row, 2) @@ -420,8 +420,11 @@ def save(self): new_ffprobe = Path(self.ffprobe_path.text()) new_work_dir = Path(self.work_dir.text()) restart_needed = False + encoder_reload_needed = False try: updated_ffmpeg = self.update_ffmpeg(new_ffmpeg) + if updated_ffmpeg: + encoder_reload_needed = True self.update_ffprobe(new_ffprobe) except FastFlixInternalException: return @@ -462,27 +465,27 @@ def save(self): new_nvencc = Path(self.nvencc_path.text()) if self.nvencc_path.text().strip() else None if str(self.app.fastflix.config.nvencc) != str(new_nvencc): - restart_needed = True + encoder_reload_needed = True self.app.fastflix.config.nvencc = new_nvencc new_qsvencc = Path(self.qsvenc_path.text()) if self.qsvenc_path.text().strip() else None if str(self.app.fastflix.config.qsvencc) != str(new_qsvencc): - restart_needed = True + encoder_reload_needed = True self.app.fastflix.config.qsvencc = new_qsvencc new_vce = Path(self.vceenc_path.text()) if self.vceenc_path.text().strip() else None if str(self.app.fastflix.config.vceencc) != str(new_vce): - restart_needed = True + encoder_reload_needed = True self.app.fastflix.config.vceencc = new_vce new_hdr10_parser = Path(self.hdr10_parser_path.text()) if self.hdr10_parser_path.text().strip() else None if str(self.app.fastflix.config.hdr10plus_parser) != str(new_hdr10_parser): - restart_needed = True + encoder_reload_needed = True self.app.fastflix.config.hdr10plus_parser = new_hdr10_parser new_gifski = Path(self.gifski_path.text()) if self.gifski_path.text().strip() else None if str(self.app.fastflix.config.gifski) != str(new_gifski): - restart_needed = True + encoder_reload_needed = True self.app.fastflix.config.gifski = new_gifski new_output_path = None @@ -506,9 +509,9 @@ def save(self): self.app.fastflix.config.disable_deinterlace_check = self.disable_deinterlace_button.isChecked() self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() - self.main.config_update() + self.main.config_update(encoder_reload_needed=encoder_reload_needed) self.app.fastflix.config.save() - if updated_ffmpeg or old_lang != self.app.fastflix.config.language or restart_needed: + if old_lang != self.app.fastflix.config.language or restart_needed: error_message(t("Please restart FastFlix to apply settings"), parent=self) self.close() diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index c1a5d699..e17f5820 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -9,6 +9,7 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import get_icon +from fastflix.ui_constants import FONTS from fastflix.ui_scale import scaler from fastflix.ui_styles import ONYX_COLORS, get_onyx_combobox_style from fastflix.shared import DEVMODE, error_message @@ -87,7 +88,11 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): self.selected = 0 self.commands = CommandList(self, self.app) - self.current_settings = self.main.current_encoder.settings_panel(self, self.main, self.app) + encoder = self.main.current_encoder + if encoder: + self.current_settings = encoder.settings_panel(self, self.main, self.app) + else: + self.current_settings = QtWidgets.QWidget(self) self.tabBar().tabBarClicked.connect(self.change_tab) self.audio = AudioList(self, self.app) self.subtitles = SubtitleList(self, self.app) @@ -98,13 +103,15 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): self.info = InfoPanel(self, self.app) self.debug = DebugPanel(self, self.app) scroll_btn_size = max(28, self.tabBar().height()) + scroll_btn_font_pt = max(6, round(scaler.scale_font(FONTS.XLARGE) * 0.75)) + tab_font_pt = max(6, round(scaler.scale_font(FONTS.LARGE) * 0.75)) scroll_btn_style = ( f"QTabBar QToolButton{{ min-width: {scroll_btn_size}px; min-height: {scroll_btn_size}px; " - f"font-size: 16px; font-weight: bold; }}" + f"font-size: {scroll_btn_font_pt}pt; font-weight: bold; }}" ) if self.app.fastflix.config.theme == "onyx": self.setStyleSheet( - "QTabBar{ font-size: 13px; } " + f"QTabBar{{ font-size: {tab_font_pt}pt; }} " "QTabBar::tab{ border-top: 2px solid transparent; } " f"QTabBar::tab:selected{{ border-top: 2px solid {ONYX_COLORS['primary']}; }} " "QLineEdit{ color: white; } " @@ -189,7 +196,12 @@ def audio_formats(self): def change_conversion(self, conversion, previous_encoder_no_audio=False): conversion = conversion.strip() - encoder = self.app.fastflix.encoders[conversion] + if not conversion or not self.app.fastflix.encoders: + return + encoder = self.app.fastflix.encoders.get(conversion) + if encoder is None: + logger.warning(f"Encoder '{conversion}' not found in available encoders") + return self.current_settings.close() self.current_settings = encoder.settings_panel(self, self.main, self.app) self.current_settings.show() @@ -282,6 +294,8 @@ def refresh(self): # self.main.container.profile.update_settings() def update_profile(self): + if not hasattr(self.current_settings, "update_profile"): + return self.current_settings.update_profile() if self.app.fastflix.current_video: streams = copy.deepcopy(self.app.fastflix.current_video.streams) @@ -308,12 +322,13 @@ def update_profile(self): def reload(self): self.reloading = True try: - self.change_conversion(self.app.fastflix.current_video.video_settings.video_encoder_settings.name) - self.main.widgets.convert_to.setCurrentIndex( - list(self.app.fastflix.encoders.keys()).index( - self.app.fastflix.current_video.video_settings.video_encoder_settings.name - ) - ) + encoder_name = self.app.fastflix.current_video.video_settings.video_encoder_settings.name + if encoder_name not in self.app.fastflix.encoders: + logger.warning(f"Encoder '{encoder_name}' not found during reload, skipping") + return + self.change_conversion(encoder_name) + encoder_keys = list(self.app.fastflix.encoders.keys()) + self.main.widgets.convert_to.setCurrentIndex(encoder_keys.index(encoder_name)) finally: self.reloading = False try: diff --git a/fastflix/widgets/windows/concat.py b/fastflix/widgets/windows/concat.py index 73849272..a7e87c40 100644 --- a/fastflix/widgets/windows/concat.py +++ b/fastflix/widgets/windows/concat.py @@ -10,7 +10,7 @@ from fastflix.language import t from fastflix.flix import probe from fastflix.shared import yes_no_message, error_message -from fastflix.widgets.progress_bar import ProgressBar, Task +from fastflix.widgets.status_bar import Task logger = logging.getLogger("fastflix") @@ -235,7 +235,6 @@ def __init__(self, app, main, items=None): super().__init__(None) self.app = app self.main = main - self.setStyleSheet("font-size: 14px") self.folder_name = str(self.app.fastflix.config.source_directory) or str(Path.home()) self.setWindowTitle(t("Concatenation Builder")) @@ -321,7 +320,7 @@ def check_to_add(file, list_of_items, bad_items, **_): error_message(t("There are more than 100 files, skipping pre-processing.")) return self.save((x.name for x in file_list if x.name not in BAD_FILES)) - ProgressBar(self.app, tasks, can_cancel=True, auto_run=True) + self.main.container.status_bar.run_tasks(tasks, can_cancel=True) self.concat_area.table.update_items(items) if skipped: diff --git a/fastflix/widgets/windows/multiple_files.py b/fastflix/widgets/windows/multiple_files.py index b0cfed67..1df28a78 100644 --- a/fastflix/widgets/windows/multiple_files.py +++ b/fastflix/widgets/windows/multiple_files.py @@ -8,7 +8,7 @@ from fastflix.language import t from fastflix.flix import probe from fastflix.shared import yes_no_message, error_message -from fastflix.widgets.progress_bar import ProgressBar, Task +from fastflix.widgets.status_bar import Task logger = logging.getLogger("fastflix") @@ -155,7 +155,6 @@ def __init__(self, app, main, items=None): super().__init__(None) self.app = app self.main = main - self.setStyleSheet("font-size: 14px") self.folder_name = str(self.app.fastflix.config.source_directory) or str(Path.home()) self.setWindowTitle(t("Multiple Files")) @@ -264,7 +263,7 @@ def check_to_add(file, list_of_items, bad_items, **_): ) ) - ProgressBar(self.app, tasks, can_cancel=True, auto_run=True) + self.main.container.status_bar.run_tasks(tasks, can_cancel=True) self.files_area.table.update_items(items) if skipped: diff --git a/fastflix/widgets/windows/profile_window.py b/fastflix/widgets/windows/profile_window.py index 8f531870..b108f6ad 100644 --- a/fastflix/widgets/windows/profile_window.py +++ b/fastflix/widgets/windows/profile_window.py @@ -459,12 +459,11 @@ def __init__(self, app: FastFlixApp, main, container, *args, **kwargs): self.encoder = x265Settings(crf=18) - theme = "QPushButton{ padding: 0 10px; font-size: 14px; }" + theme = "QPushButton{ padding: 0 10px; }" if self.app.fastflix.config.theme in ("dark", "onyx"): theme = """ QPushButton { padding: 0 10px; - font-size: 14px; background-color: #4f4f4f; border: none; border-radius: 10px; diff --git a/tests/test_pyside6_fixes.py b/tests/test_pyside6_fixes.py index c9a0e4d8..ea05a145 100644 --- a/tests/test_pyside6_fixes.py +++ b/tests/test_pyside6_fixes.py @@ -141,13 +141,11 @@ def test_close_events_handle_event_parameter(self): class TestWidgetParentAssignment: """Verify widgets are created with proper parent assignment.""" - def test_progress_bar_is_toplevel_widget(self): - """ProgressBar should work as a top-level widget (None parent).""" - # This is intentional - ProgressBar is a splash screen - from fastflix.widgets.progress_bar import ProgressBar + def test_status_bar_can_be_imported(self): + """StatusBarWidget should be importable.""" + from fastflix.widgets.status_bar import StatusBarWidget - # Just verify the class can be imported without errors - assert ProgressBar is not None + assert StatusBarWidget is not None class TestSignalDisconnection: From f1497bed094c485934e91fc2ec8f57219589a2b9 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 12 Feb 2026 18:20:00 -0600 Subject: [PATCH 03/10] * Adding Visual Crop window for dragging crop edges directly on a video frame preview, with live overlay and divisible-by-8 snapping on save * Adding Data & Attachments tab for per-track control of data streams (timecodes, navigation) and non-image attachments (fonts) * Fixing UI scaling for Source/Folder/Filename text boxes, file extension dropdown, Resolution label, Start/End Time controls, and Crop input fields being too small * Adding language and disposition metadata parsing from auto-detected external subtitle filenames (e.g., video.forced.deu.srt) * Adding auto-detection of external subtitle files (.srt, .ass, .ssa, .vtt, .sup, .sub, .idx) when loading a video, configurable in Settings * Adding external subtitle support for Rigaya encoders (NVEncC, QSVEncC, VCEEncC) via --sub-source --- CHANGES | 6 + fastflix/application.py | 4 + fastflix/data/icons/black/crop.svg | 15 ++ fastflix/data/icons/black/onyx-data.svg | 1 + fastflix/data/icons/selected/onyx-data.svg | 1 + fastflix/data/icons/white/onyx-data.png | Bin 0 -> 8234 bytes fastflix/encoders/av1_aom/main.py | 1 + fastflix/encoders/av1_aom/settings_panel.py | 8 +- fastflix/encoders/avc_x264/main.py | 1 + fastflix/encoders/avc_x264/settings_panel.py | 8 +- fastflix/encoders/common/encc_helpers.py | 45 +++++- fastflix/encoders/common/helpers.py | 19 ++- fastflix/encoders/copy/main.py | 1 + fastflix/encoders/ffmpeg_hevc_nvenc/main.py | 1 + fastflix/encoders/gif/main.py | 1 + fastflix/encoders/gifski/main.py | 1 + fastflix/encoders/h264_videotoolbox/main.py | 1 + fastflix/encoders/hevc_videotoolbox/main.py | 1 + fastflix/encoders/hevc_x265/main.py | 1 + fastflix/encoders/hevc_x265/settings_panel.py | 22 +-- fastflix/encoders/modify/main.py | 1 + .../encoders/nvencc_av1/command_builder.py | 4 + fastflix/encoders/nvencc_av1/main.py | 1 + .../encoders/nvencc_av1/settings_panel.py | 7 +- .../encoders/nvencc_avc/command_builder.py | 4 + fastflix/encoders/nvencc_avc/main.py | 1 + .../encoders/nvencc_avc/settings_panel.py | 7 +- .../encoders/nvencc_hevc/command_builder.py | 4 + fastflix/encoders/nvencc_hevc/main.py | 1 + .../encoders/nvencc_hevc/settings_panel.py | 7 +- .../encoders/qsvencc_av1/command_builder.py | 4 + fastflix/encoders/qsvencc_av1/main.py | 1 + .../encoders/qsvencc_av1/settings_panel.py | 7 +- .../encoders/qsvencc_avc/command_builder.py | 4 + fastflix/encoders/qsvencc_avc/main.py | 1 + .../encoders/qsvencc_avc/settings_panel.py | 7 +- .../encoders/qsvencc_hevc/command_builder.py | 4 + fastflix/encoders/qsvencc_hevc/main.py | 1 + .../encoders/qsvencc_hevc/settings_panel.py | 7 +- fastflix/encoders/rav1e/main.py | 1 + fastflix/encoders/rav1e/settings_panel.py | 9 +- fastflix/encoders/svt_av1/main.py | 1 + fastflix/encoders/svt_av1/settings_panel.py | 8 +- fastflix/encoders/svt_av1_avif/main.py | 1 + .../encoders/svt_av1_avif/settings_panel.py | 8 +- fastflix/encoders/vaapi_h264/main.py | 1 + .../encoders/vaapi_h264/settings_panel.py | 8 +- fastflix/encoders/vaapi_hevc/main.py | 1 + .../encoders/vaapi_hevc/settings_panel.py | 8 +- fastflix/encoders/vaapi_mpeg2/main.py | 1 + .../encoders/vaapi_mpeg2/settings_panel.py | 8 +- fastflix/encoders/vaapi_vp9/main.py | 1 + fastflix/encoders/vaapi_vp9/settings_panel.py | 8 +- .../encoders/vceencc_av1/command_builder.py | 4 + fastflix/encoders/vceencc_av1/main.py | 1 + .../encoders/vceencc_av1/settings_panel.py | 7 +- .../encoders/vceencc_avc/command_builder.py | 4 + fastflix/encoders/vceencc_avc/main.py | 1 + .../encoders/vceencc_avc/settings_panel.py | 7 +- .../encoders/vceencc_hevc/command_builder.py | 4 + fastflix/encoders/vceencc_hevc/main.py | 1 + .../encoders/vceencc_hevc/settings_panel.py | 7 +- fastflix/encoders/vp9/main.py | 1 + fastflix/encoders/vp9/settings_panel.py | 8 +- fastflix/encoders/vvc/main.py | 1 + fastflix/encoders/webp/main.py | 1 + fastflix/models/config.py | 1 + fastflix/models/encode.py | 15 ++ fastflix/models/video.py | 2 + fastflix/ui_constants.py | 6 +- fastflix/ui_styles.py | 7 +- fastflix/widgets/main.py | 141 +++++++++++------- fastflix/widgets/panels/abstract_list.py | 8 + fastflix/widgets/panels/subtitle_panel.py | 128 +++++++++++++++- fastflix/widgets/settings.py | 6 + fastflix/widgets/video_options.py | 42 ++++-- tests/conftest.py | 2 + tests/encoders/test_encc_helpers.py | 101 ++++++++++++- 78 files changed, 608 insertions(+), 173 deletions(-) create mode 100644 fastflix/data/icons/black/crop.svg create mode 100644 fastflix/data/icons/black/onyx-data.svg create mode 100644 fastflix/data/icons/selected/onyx-data.svg create mode 100644 fastflix/data/icons/white/onyx-data.png diff --git a/CHANGES b/CHANGES index bd3f9ffd..847588be 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,12 @@ ## Version 6.1.0 +* Adding Visual Crop window for dragging crop edges directly on a video frame preview, with live overlay and divisible-by-8 snapping on save +* Adding Data & Attachments tab for per-track control of data streams (timecodes, navigation) and non-image attachments (fonts) +* Fixing UI scaling for Source/Folder/Filename text boxes, file extension dropdown, Resolution label, Start/End Time controls, and Crop input fields being too small +* Adding language and disposition metadata parsing from auto-detected external subtitle filenames (e.g., video.forced.deu.srt) +* Adding auto-detection of external subtitle files (.srt, .ass, .ssa, .vtt, .sup, .sub, .idx) when loading a video, configurable in Settings +* Adding external subtitle support for Rigaya encoders (NVEncC, QSVEncC, VCEEncC) via --sub-source * Adding bottom status bar with animated icon showing encoding state, progress bar, and status messages * Adding startup tasks (FFmpeg config, GPU detect, HDR10+ download) running through the status bar with main window visible * Adding Terms and Agreements dialog shown on first startup requiring user acceptance before proceeding diff --git a/fastflix/application.py b/fastflix/application.py index 40f49399..12d33aba 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -42,6 +42,10 @@ def create_app(enable_scaling): main_app = FastFlixApp(sys.argv) main_app.allWindows() main_app.setApplicationDisplayName("FastFlix") + + # On Linux, ensure an icon theme is set so QFileDialog toolbar icons appear + if sys.platform == "linux" and not QtGui.QIcon.themeName(): + QtGui.QIcon.setThemeName("breeze") available_fonts = QtGui.QFontDatabase().families() font_preference = ["Roboto", "Segoe UI", "Ubuntu", "Open Sans", "Sans Serif"] selected_font = next((f for f in font_preference if f in available_fonts), "Sans Serif") diff --git a/fastflix/data/icons/black/crop.svg b/fastflix/data/icons/black/crop.svg new file mode 100644 index 00000000..ad9d78cc --- /dev/null +++ b/fastflix/data/icons/black/crop.svg @@ -0,0 +1,15 @@ + + + + + + + diff --git a/fastflix/data/icons/black/onyx-data.svg b/fastflix/data/icons/black/onyx-data.svg new file mode 100644 index 00000000..6b1b2c30 --- /dev/null +++ b/fastflix/data/icons/black/onyx-data.svg @@ -0,0 +1 @@ + diff --git a/fastflix/data/icons/selected/onyx-data.svg b/fastflix/data/icons/selected/onyx-data.svg new file mode 100644 index 00000000..9dc3836f --- /dev/null +++ b/fastflix/data/icons/selected/onyx-data.svg @@ -0,0 +1 @@ + diff --git a/fastflix/data/icons/white/onyx-data.png b/fastflix/data/icons/white/onyx-data.png new file mode 100644 index 0000000000000000000000000000000000000000..6f4b0a99196b58daa5f6f15557292631a5264a48 GIT binary patch literal 8234 zcmeHMeOyd=7r*n;luBj2ltj1jmg+X|Gxv_tOf_4B7!?&#cV_O?q-Ms<)D%fcl)PI@ z9&A{Pke9V&*)^yQl@JM8A$ci;jgUNdiew+`v!BmC&tK{@ckaEvbH3+y&hK}==iECv zl5qh&EgdZZ0O%{D;TLyjhKB2dQ3I&asujw34AiG+Fc3>t zDgYq)%7fJrD{i}5i{3JH~ctkNINyX#{O?+J3xHO{+!j zCaZ1JD)vNWJS-0rMC64Pv`-zz#t?py1WBpIaYwHLLU(5gqu|?$O}M!-k)vYs)NwBwdz{~0a{Vixq2C6G+R~2K+q6W~BZn)IGrM<;@H>~fYuhhN?%I$_ zNh`|Ipw0bNB|koxbk?i72H0BK&vyUUSq}Fr1v&hQYuV3gwrwmf&g?l@N7{zz7@oC) z9Kd|>umRbLGw9R3=311uZOcmfecw}#R{oG2HjGSaesr*T^Qr4f^vr=Fo9^1~lGyKF zmCNjR=;8@0LF}|zy;bVL(jOL&k2WJGmNm5+9hb6-H~w+pfLzzFb;NJFFv*@YvvmKH zyIkj!$80OZePPXXt!VC+HmGi@d{N*LGJLdO4Y+@NBVir zWl6?(PCD{Uu=|3A{(bklwYhtkJ8Qr9Tbf#PBLImz@kF^@Syr+3;Q6>ao&!t$E3f_h z{Cs0RT@$>Bt*=K<1oTgo?h49N-Y>8Ep_18{c}3I`@~UT?e!{JwxEGf*9d{mUvJp1I z=Sr-L;a=HSse8ulDe>TMDbB5x_Op_OxNZ)NjQ8CtvbLxKtHriS>iV>tDUvrsRc$OS zy7AS3dg9sH4bRMe`ZcZcr=X_VtT9!tHM5(yHxUer?zZbw&1QD{fBD9IMUKy)=JD~Z zwhnvNi`KLq+71AOc}hP&iP+EY-JZvH`nuW6y#r4OoJyjmYze+^9xf?%@k`tXy4~D1 z(bYFg;=ccWwDz7oTNxNU)yeGA+)U@(iRSh(kprii37bj96&2!hqS0+p!Lhw=9h>3v z`?XoMzmVEmm?0HQW(Y2N3|^IOmDf~YmnyPA|A+==f5mD+CX79KZB14U&2;jhwYu7JuS~(KCYs7 zKz5ezfU<=0h4V*yiJT^eE`IX5|LVSHrmVZDUB!8HD$3!E(!FfzR%8*m-(GSrqC9kh zZ~uPM{c+!~2|Slpd%~x_Rir&t@0PJ#M(De3nUhbebKAq}^6}$}#5drxFQiwU2MaXz zu@~ZBy{b7dV7Q>Jf6@8L?V|^-c`@Xd>hqfd1dq6y8Q0F&d)H~r9a7H^_0xV21kD*` zd*r)wJS?Mq3b?=FS#@pcJ<_rt3Rm!q$6V->^P3Wv8Vo-@fA%O(F~`T%Pn+|&Z3qki zeGVvb4hjz%%a^GWJW;t?ig_j{XmE}K056|p4JwNCpHE18c2Bw%_JOiqYb>G;Xs)DB!eer~!=qk<2T-2J(7{6fS{@?;MCY=sLbSA_^Xx;Vk(V(C%ZI$kA6!Cb{l2Hwf z;Yp_@Bz&Nu(~FYc+xw`7E)*{Xv|vo9PSnaUQ4*%o5AJSM6Q8K-ZZlDbnQnE=J6^7! z;gjmH++8O?93<(|F@-TknV{*=FiCfpl*_v0G>O{y4jH+OhQ(tE_(XI#GUEezO{`k4 z*2StdU1}dV)iwMdQ^E~@T;*NlI%d@oVZOgwmS{3B_J6mbmt9Wt?t+VwFyhs$`f65I5ne#K9DDIxJ&jEC@wdT!_s= z=n%r>q7a>}V6qvol*^H_yFrZCD)G!fP^N+o zDG&z2kz+8I%j0%Hnbw6bl!(2lOi%jzJCb-*uTX0fys2ZAs>I~?1EI7)_&!=@@t)LqB!A zS{tO6W8PHOpN4SOmUK2M-c((1fG;Q(`EeNtmkZ%GI%ILz`F>gq)noqn2e(aUG9Wqv zF?peME}w(&nY_s#3aYiCYIVFf)f5BJG}W%*Ae<`Xs2=?j5)#Vb@HtFAo%tRKpRQ7o z@)^W5Nt;0RcCnOl^wJR zf*l0LM`flcdQ)W`D=zO?IXuptgUXL2*MFT6T_`%WLT^B|m@o!Ud~d4IRG&b}JGKFt zOivc*8m3gq)dt;&4<}%V#Z0^ZV>_^Ojo(&?PdKGRldwOzB#T8=F&JL{-g4jPT;*a+ zR3XQS1A(P-NWo)sAy|g+AQ{S)GP!IiOU^+)obNy9YWEcRKe&o26jBCTW=eDuW%3j} z2v$gO&*eNOp20FU%tkgsT@^6 zY&n+)Asj9XVsbegwhWchWoRec{e%8^(M-+ByL9t1mG7V+e)oj*GL?6}*u>f)i5gA3 z64QQ|sPBmD6LQ`3J9tn1m+IZcI<@`OniRa?#p=f!RG&5f5y4J`u}T@H(tXzU2O?hY z3)8z2E5s|diKlek+{bzQL!KJN;&FWGY=4#rW^(-K95$1SaM>^$ua-gv4@T%7U}sPI z=N5vFw+alDjX*HQ<3cFP=0GsQl0!V0$%NT-1&7O#eM%Q$Fz_NTQ$U!UjzKKEWq|O+ zU_&fehRIoY&1Wf?pVF1_m{LT+V?rDz!iHdcuJ{ZTFl6c=WIPtE;4nUE7w>G?a+Zt* zAqWp|doVV>b!7@U#FR2Q7>5C)bPnc$H@lr}2_0eJO$KP{x))FayO8OsR~= z;UNl?&U-KI$Km+%;!>rvwE>f#yqzx;b!-Ts#*K=6x^zZKsv;rbG;zZHSMCH!S~ zeF@j!ioo9z{<6FNv2aS0m%Yf6e_Y^VtTe)HzdmdoE=!_Nr% z;Bg^{l_S{87)k15SyZ>%aG~)L z%O?4&@UiP#qoyprB6WnrCeo9LfCznMPu)T{zH_Xq7LFCa^6$&K4yi zwZ40eLjY%&TNzX!tj9^BW@Y-2^(RCCHK|uJ7-)0@+HE35gO<&0-asB?m#JBuPE7P8 zos{s{0K*~|@OMu)+enBO#scAXjz&4fl9@k6aDdq3l-4SH5(s8)G)s1`TXyaOaTrUo zQ$U$7>m4`=oJ>$YD;8#OWy5-xgs&h0>$f|V&C5bhT-~{Y~W4`2e zv!!wQ#U@MR%96X5;EJE>Ey0z!mVE>+m&kSLo2{;Bj8#Kl!*=&pq&Wrtao*bLr0EbH z_axfLNX(+w-rv$g*Z+iWS!AB!mz%aFbnq^T1@%c4@d<^^f~grkSG%IiyA z?ULqPd{8>rviS9?%EO>*#)%u{j^M!G=O-=5B)(`Kx7JxG$k4WiE}dDI9CgE4OeBO| zo9bPdZ5h5~b^Q-O>clr2Mvd%CzJK~bz8Q6f`q`YVvFW=WuIg_d9GM>Vji7Kfu&$jz zXn479l9OvoWSWGym6#TF{Ta!aw6K9fdUzyTXx{pixiENrAHZX4+OZ7Hw9zXHu%2ul7N-4j7<=1<&WGOcdG3{3mW9`v1 z8_4953!YqvaH{fJkut1o_EmYU?oRLR{VVQNY+oCbxIN|elfJ#H=*Eg&m5xUC;qhKk z%~4O%iIn3=*}0d?hHYPxo^obhlz)TRegml?V!$9Qz~{k<1+1q>dZh)-^tlys^n$t| zj8JrevCSvk(Rj~AU9f}fH){&#<)NpeEPnlE*W`j%kN3-VsUIzzClFj09JS9LINc?>(DPXPzHhk8 zZ0$3y{2UGhN9HD3&IO8Jj~HHXlT&s$Z+5#-+j#Q=rLFKP^-$topy(P>HN`#vjXF{~ zQFg%7hA8>od*@Ja%&hQPguwHIs>cDFiWYG89EzT0J00Dv8U7GmpXEKM#CzvTppVkF z5d?xKP+DrosqG3W;8kh!pm`;L z{}k(!$nzzj!?Y-$$BkCU6~u)QMJ)^u66`CfgDigRf$JSDv4}JBz8;XUKj0PV Y+v*#4uD17R!mAD-7LD^S^o^eTZ=8DaHvj+t literal 0 HcmV?d00001 diff --git a/fastflix/encoders/av1_aom/main.py b/fastflix/encoders/av1_aom/main.py index 59944634..b2f45a96 100644 --- a/fastflix/encoders/av1_aom/main.py +++ b/fastflix/encoders/av1_aom/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.av1_aom.command_builder import build # noqa: F401,E402 from fastflix.encoders.av1_aom.settings_panel import AV1 as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/av1_aom/settings_panel.py b/fastflix/encoders/av1_aom/settings_panel.py index 7bdced2c..eb48434b 100644 --- a/fastflix/encoders/av1_aom/settings_panel.py +++ b/fastflix/encoders/av1_aom/settings_panel.py @@ -88,14 +88,14 @@ def __init__(self, parent, main, app: FastFlixApp): self.ffmpeg_level = QtWidgets.QLabel() grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4) - grid.addLayout(self._add_custom(), 10, 0, 1, 6) - grid.setRowStretch(9, 1) + custom_layout = self._add_custom() guide_label = QtWidgets.QLabel( link("https://trac.ffmpeg.org/wiki/Encode/AV1", t("FFMPEG AV1 Encoding Guide"), app.fastflix.config.theme) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, -1, 1) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) + grid.setRowStretch(9, 1) self.hdr10plus_signal.connect(self.done_hdr10plus_extract) self.hdr10plus_ffmpeg_signal.connect(lambda x: self.ffmpeg_level.setText(x)) diff --git a/fastflix/encoders/avc_x264/main.py b/fastflix/encoders/avc_x264/main.py index af11606c..7b32827d 100644 --- a/fastflix/encoders/avc_x264/main.py +++ b/fastflix/encoders/avc_x264/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.avc_x264.command_builder import build # noqa: F401,E402 from fastflix.encoders.avc_x264.settings_panel import AVC as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/avc_x264/settings_panel.py b/fastflix/encoders/avc_x264/settings_panel.py index ab36a25f..43778fec 100644 --- a/fastflix/encoders/avc_x264/settings_panel.py +++ b/fastflix/encoders/avc_x264/settings_panel.py @@ -2,7 +2,7 @@ import logging from box import Box -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import SettingPanel from fastflix.language import t @@ -74,7 +74,6 @@ def __init__(self, parent, main, app: FastFlixApp): self.updating_settings = False grid.addLayout(self.init_modes(), 0, 2, 5, 4) - grid.addLayout(self._add_custom(), 10, 0, 1, 6) grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_max_mux(), 1, 0, 1, 2) @@ -89,6 +88,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.setRowStretch(9, 1) + custom_layout = self._add_custom() guide_label = QtWidgets.QLabel( link( "https://trac.ffmpeg.org/wiki/Encode/H.264", @@ -96,9 +96,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 6) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/common/encc_helpers.py b/fastflix/encoders/common/encc_helpers.py index f22c32dd..6db0e894 100644 --- a/fastflix/encoders/common/encc_helpers.py +++ b/fastflix/encoders/common/encc_helpers.py @@ -2,7 +2,7 @@ import logging from typing import List -from fastflix.models.video import SubtitleTrack, AudioTrack +from fastflix.models.video import SubtitleTrack, AudioTrack, DataTrack from fastflix.encoders.common.audio import lossless from fastflix.models.fastflix import FastFlix from fastflix.models.encode import VCEEncCAVCSettings, VCEEncCAV1Settings, VCEEncCSettings @@ -171,17 +171,15 @@ def build_audio(audio_tracks: list[AudioTrack], audio_streams) -> List[str]: def build_subtitle(subtitle_tracks: list[SubtitleTrack], subtitle_streams, video_height: int) -> List[str]: - # Rigaya encoders only support embedded streams, filter out external tracks - subtitle_tracks = [t for t in subtitle_tracks if not t.external] + embedded_tracks = [t for t in subtitle_tracks if not t.external] + external_tracks = [t for t in subtitle_tracks if t.external] command_list = [] copies = [] stream_ids = get_stream_pos(subtitle_streams) - if not subtitle_tracks: - return [] scale = ",scale=2.0" if video_height > 1800 else "" - for track in sorted(subtitle_tracks, key=lambda x: x.outdex): + for track in sorted(embedded_tracks, key=lambda x: x.outdex): if not track.enabled: continue sub_id = stream_ids[track.index] @@ -200,10 +198,43 @@ def build_subtitle(subtitle_tracks: list[SubtitleTrack], subtitle_streams, video command_list.extend(["--sub-metadata", f"{sub_id}?language={track.language}"]) - if not command_list: + for track in sorted(external_tracks, key=lambda x: x.outdex): + if not track.enabled: + continue + if track.burn_in: + ext_scale = ",scale=2.0" if video_height > 1800 else "" + command_list.extend(["--vpp-subburn", f"filename={track.file_path}{ext_scale}"]) + else: + command_list.extend(["--sub-source", track.file_path]) + + if not command_list and not copies: return [] result = [] if copies: result.extend(["--sub-copy", ",".join(copies)]) result.extend(command_list) return result + + +def build_data(data_tracks: list[DataTrack], data_streams, attachment_streams) -> List[str]: + if not data_tracks: + return [] + command_list = [] + data_copies = [] + attachment_copies = [] + data_stream_ids = get_stream_pos(data_streams) + attachment_stream_ids = get_stream_pos(attachment_streams) + + for track in data_tracks: + if not track.enabled: + continue + if track.codec_type == "data" and track.index in data_stream_ids: + data_copies.append(str(data_stream_ids[track.index])) + elif track.codec_type == "attachment" and track.index in attachment_stream_ids: + attachment_copies.append(str(attachment_stream_ids[track.index])) + + if data_copies: + command_list.extend(["--data-copy", ",".join(data_copies)]) + if attachment_copies: + command_list.extend(["--attachment-copy", ",".join(attachment_copies)]) + return command_list diff --git a/fastflix/encoders/common/helpers.py b/fastflix/encoders/common/helpers.py index 293f3e5c..7305330a 100644 --- a/fastflix/encoders/common/helpers.py +++ b/fastflix/encoders/common/helpers.py @@ -167,6 +167,7 @@ def generate_ending( output_fps: Union[str, None] = None, disable_rotate_metadata=False, copy_data=False, + data_tracks=None, **_, ): command = [] @@ -196,7 +197,22 @@ def generate_ending( if cover: command.extend(cover) - if copy_data: + if data_tracks: + has_data = False + has_attachment = False + for track in data_tracks: + if not track.enabled: + continue + command.extend(["-map", f"0:{track.index}"]) + if track.codec_type == "data": + has_data = True + elif track.codec_type == "attachment": + has_attachment = True + if has_data: + command.extend(["-c:d", "copy"]) + if has_attachment: + command.extend(["-c:t", "copy"]) + elif copy_data: command.extend(["-map", "0:d", "-c:d", "copy"]) if output_video and not null_ending: @@ -411,6 +427,7 @@ def generate_all( cover=attachments_cmd, output_video=fastflix.current_video.video_settings.output_path, disable_rotate_metadata=encoder == "copy", + data_tracks=fastflix.current_video.data_tracks, **fastflix.current_video.video_settings.model_dump(), ) diff --git a/fastflix/encoders/copy/main.py b/fastflix/encoders/copy/main.py index f93080f6..d5343663 100644 --- a/fastflix/encoders/copy/main.py +++ b/fastflix/encoders/copy/main.py @@ -16,6 +16,7 @@ enable_audio = True enable_attachments = True enable_advanced = False +enable_data = True from fastflix.encoders.copy.command_builder import build # noqa: F401,E402 from fastflix.encoders.copy.settings_panel import Copy as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/ffmpeg_hevc_nvenc/main.py b/fastflix/encoders/ffmpeg_hevc_nvenc/main.py index 1474e66f..7221636f 100644 --- a/fastflix/encoders/ffmpeg_hevc_nvenc/main.py +++ b/fastflix/encoders/ffmpeg_hevc_nvenc/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.ffmpeg_hevc_nvenc.command_builder import build # noqa: F401,E402 from fastflix.encoders.ffmpeg_hevc_nvenc.settings_panel import NVENC as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/gif/main.py b/fastflix/encoders/gif/main.py index 0b0e40e5..81850e0a 100644 --- a/fastflix/encoders/gif/main.py +++ b/fastflix/encoders/gif/main.py @@ -16,6 +16,7 @@ enable_audio = False enable_attachments = False enable_concat = False +enable_data = False audio_formats = [] diff --git a/fastflix/encoders/gifski/main.py b/fastflix/encoders/gifski/main.py index 38872f6a..04c0edc4 100644 --- a/fastflix/encoders/gifski/main.py +++ b/fastflix/encoders/gifski/main.py @@ -16,6 +16,7 @@ enable_audio = False enable_attachments = False enable_concat = False +enable_data = False audio_formats = [] diff --git a/fastflix/encoders/h264_videotoolbox/main.py b/fastflix/encoders/h264_videotoolbox/main.py index 22683aff..3ac30cea 100644 --- a/fastflix/encoders/h264_videotoolbox/main.py +++ b/fastflix/encoders/h264_videotoolbox/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = False enable_concat = True +enable_data = True from fastflix.encoders.h264_videotoolbox.command_builder import build # noqa: F401,E402 from fastflix.encoders.h264_videotoolbox.settings_panel import H264VideoToolbox as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/hevc_videotoolbox/main.py b/fastflix/encoders/hevc_videotoolbox/main.py index b414cd2c..6d24c719 100644 --- a/fastflix/encoders/hevc_videotoolbox/main.py +++ b/fastflix/encoders/hevc_videotoolbox/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = False enable_concat = True +enable_data = True from fastflix.encoders.hevc_videotoolbox.command_builder import build # noqa: F401,E402 from fastflix.encoders.hevc_videotoolbox.settings_panel import HEVCVideoToolbox as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/hevc_x265/main.py b/fastflix/encoders/hevc_x265/main.py index 43c62fb1..408a2b6f 100644 --- a/fastflix/encoders/hevc_x265/main.py +++ b/fastflix/encoders/hevc_x265/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.hevc_x265.command_builder import build # noqa: F401,E402 from fastflix.encoders.hevc_x265.settings_panel import HEVC as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/hevc_x265/settings_panel.py b/fastflix/encoders/hevc_x265/settings_panel.py index 914891ea..80ccdecd 100644 --- a/fastflix/encoders/hevc_x265/settings_panel.py +++ b/fastflix/encoders/hevc_x265/settings_panel.py @@ -128,7 +128,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.setRowStretch(11, True) - grid.addLayout(self._add_custom(), 12, 0, 1, 6) + custom_layout = self._add_custom() link_1 = link( "https://trac.ffmpeg.org/wiki/Encode/H.265", @@ -140,17 +140,12 @@ def __init__(self, parent, main, app: FastFlixApp): t("CodeCalamity UHD HDR Encoding Guide"), app.fastflix.config.theme, ) - link_3 = link( - "https://github.com/cdgriffith/FastFlix/wiki/HDR10-Plus-Metadata-Extraction", - t("HDR10+ Metadata Extraction"), - app.fastflix.config.theme, - ) - guide_label = QtWidgets.QLabel(f"{link_1} | {link_2} | {link_3}") - guide_label.setAlignment(QtCore.Qt.AlignBottom) + guide_label = QtWidgets.QLabel(f"{link_1} | {link_2}") guide_label.setOpenExternalLinks(True) + custom_layout.addWidget(guide_label) - grid.addWidget(guide_label, 13, 0, 1, 6) + grid.addLayout(custom_layout, 12, 0, 1, 6) self.hdr10plus_signal.connect(self.done_hdr10plus_extract) self.hdr10plus_ffmpeg_signal.connect(lambda x: self.ffmpeg_level.setText(x)) @@ -164,6 +159,15 @@ def init_dhdr10_info(self): button_action=lambda: self.dhdr10_update(), tooltip="dhdr10_info: Path to HDR10+ JSON metadata file", ) + # Replace plain label with clickable wiki link + self.labels["hdr10plus_metadata"].setText( + link( + "https://github.com/cdgriffith/FastFlix/wiki/HDR10-Plus-Metadata-Extraction", + t("HDR10+ Metadata"), + self.app.fastflix.config.theme, + ) + ) + self.labels["hdr10plus_metadata"].setOpenExternalLinks(True) self.labels["hdr10plus_metadata"].setFixedWidth(200) return layout diff --git a/fastflix/encoders/modify/main.py b/fastflix/encoders/modify/main.py index c60a2f08..7af8d531 100644 --- a/fastflix/encoders/modify/main.py +++ b/fastflix/encoders/modify/main.py @@ -16,6 +16,7 @@ enable_audio = False enable_attachments = False enable_advanced = False +enable_data = False from fastflix.encoders.modify.command_builder import build # noqa: F401,E402 from fastflix.encoders.modify.settings_panel import Modify as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/nvencc_av1/command_builder.py b/fastflix/encoders/nvencc_av1/command_builder.py index c69d79c0..d1a2d31e 100644 --- a/fastflix/encoders/nvencc_av1/command_builder.py +++ b/fastflix/encoders/nvencc_av1/command_builder.py @@ -9,6 +9,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, ) @@ -177,6 +178,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(settings.extra.split()) diff --git a/fastflix/encoders/nvencc_av1/main.py b/fastflix/encoders/nvencc_av1/main.py index 52cb3d2e..acc547b1 100644 --- a/fastflix/encoders/nvencc_av1/main.py +++ b/fastflix/encoders/nvencc_av1/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from NVEncC64.exe --check-encoders diff --git a/fastflix/encoders/nvencc_av1/settings_panel.py b/fastflix/encoders/nvencc_av1/settings_panel.py index 43d2e6e5..2123c389 100644 --- a/fastflix/encoders/nvencc_av1/settings_panel.py +++ b/fastflix/encoders/nvencc_av1/settings_panel.py @@ -74,7 +74,7 @@ def __init__(self, parent, main, app: FastFlixApp): self.updating_settings = False grid.addLayout(self.init_modes(), 0, 2, 4, 4) - grid.addLayout(self._add_custom(title="Custom NVEncC options", disable_both_passes=True), 10, 0, 1, 6) + custom_layout = self._add_custom(title="Custom NVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) # grid.addLayout(self.init_profile(), 1, 0, 1, 2) @@ -143,10 +143,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/nvencc_avc/command_builder.py b/fastflix/encoders/nvencc_avc/command_builder.py index 376b68c8..763eabe8 100644 --- a/fastflix/encoders/nvencc_avc/command_builder.py +++ b/fastflix/encoders/nvencc_avc/command_builder.py @@ -9,6 +9,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, ) @@ -149,6 +150,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(settings.extra.split()) diff --git a/fastflix/encoders/nvencc_avc/main.py b/fastflix/encoders/nvencc_avc/main.py index 310c63cb..df454090 100644 --- a/fastflix/encoders/nvencc_avc/main.py +++ b/fastflix/encoders/nvencc_avc/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from NVEncC64.exe --check-encoders diff --git a/fastflix/encoders/nvencc_avc/settings_panel.py b/fastflix/encoders/nvencc_avc/settings_panel.py index f947b983..88910a6a 100644 --- a/fastflix/encoders/nvencc_avc/settings_panel.py +++ b/fastflix/encoders/nvencc_avc/settings_panel.py @@ -74,7 +74,7 @@ def __init__(self, parent, main, app: FastFlixApp): self.updating_settings = False grid.addLayout(self.init_modes(), 0, 2, 4, 4) - grid.addLayout(self._add_custom(title="Custom NVEncC options", disable_both_passes=True), 10, 0, 1, 6) + custom_layout = self._add_custom(title="Custom NVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) # grid.addLayout(self.init_profile(), 1, 0, 1, 2) @@ -135,10 +135,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py index bbeb8fa8..5482dc4f 100644 --- a/fastflix/encoders/nvencc_hevc/command_builder.py +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -9,6 +9,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, ) @@ -177,6 +178,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(settings.extra.split()) diff --git a/fastflix/encoders/nvencc_hevc/main.py b/fastflix/encoders/nvencc_hevc/main.py index d1160df0..8eda77e7 100644 --- a/fastflix/encoders/nvencc_hevc/main.py +++ b/fastflix/encoders/nvencc_hevc/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from NVEncC64.exe --check-encoders diff --git a/fastflix/encoders/nvencc_hevc/settings_panel.py b/fastflix/encoders/nvencc_hevc/settings_panel.py index abe4b746..5e683cfd 100644 --- a/fastflix/encoders/nvencc_hevc/settings_panel.py +++ b/fastflix/encoders/nvencc_hevc/settings_panel.py @@ -74,7 +74,7 @@ def __init__(self, parent, main, app: FastFlixApp): self.updating_settings = False grid.addLayout(self.init_modes(), 0, 2, 4, 4) - grid.addLayout(self._add_custom(title="Custom NVEncC options", disable_both_passes=True), 10, 0, 1, 6) + custom_layout = self._add_custom(title="Custom NVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) # grid.addLayout(self.init_profile(), 1, 0, 1, 2) @@ -143,10 +143,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/qsvencc_av1/command_builder.py b/fastflix/encoders/qsvencc_av1/command_builder.py index e546ecc2..60ca7207 100644 --- a/fastflix/encoders/qsvencc_av1/command_builder.py +++ b/fastflix/encoders/qsvencc_av1/command_builder.py @@ -10,6 +10,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, ) @@ -180,6 +181,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(shlex.split(settings.extra)) diff --git a/fastflix/encoders/qsvencc_av1/main.py b/fastflix/encoders/qsvencc_av1/main.py index 418ee020..3aefc5e9 100644 --- a/fastflix/encoders/qsvencc_av1/main.py +++ b/fastflix/encoders/qsvencc_av1/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from NVEncC64.exe --check-encoders diff --git a/fastflix/encoders/qsvencc_av1/settings_panel.py b/fastflix/encoders/qsvencc_av1/settings_panel.py index e47e0c50..5795820e 100644 --- a/fastflix/encoders/qsvencc_av1/settings_panel.py +++ b/fastflix/encoders/qsvencc_av1/settings_panel.py @@ -82,7 +82,7 @@ def __init__(self, parent, main, app: FastFlixApp): 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) + custom_layout = self._add_custom(title="Custom QSVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_lookahead(), 1, 0, 1, 2) @@ -148,10 +148,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() self.hdr10plus_signal.connect(self.done_hdr10plus_extract) diff --git a/fastflix/encoders/qsvencc_avc/command_builder.py b/fastflix/encoders/qsvencc_avc/command_builder.py index 1ed7abe4..dcf4cfdc 100644 --- a/fastflix/encoders/qsvencc_avc/command_builder.py +++ b/fastflix/encoders/qsvencc_avc/command_builder.py @@ -10,6 +10,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, ) @@ -161,6 +162,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(shlex.split(settings.extra)) diff --git a/fastflix/encoders/qsvencc_avc/main.py b/fastflix/encoders/qsvencc_avc/main.py index e981a1b1..de3523ab 100644 --- a/fastflix/encoders/qsvencc_avc/main.py +++ b/fastflix/encoders/qsvencc_avc/main.py @@ -16,6 +16,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from NVEncC64.exe --check-encoders diff --git a/fastflix/encoders/qsvencc_avc/settings_panel.py b/fastflix/encoders/qsvencc_avc/settings_panel.py index 7b231118..9763fa26 100644 --- a/fastflix/encoders/qsvencc_avc/settings_panel.py +++ b/fastflix/encoders/qsvencc_avc/settings_panel.py @@ -80,7 +80,7 @@ def __init__(self, parent, main, app: FastFlixApp): 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) + custom_layout = self._add_custom(title="Custom QSVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_profile(), 1, 0, 1, 2) @@ -134,10 +134,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/qsvencc_hevc/command_builder.py b/fastflix/encoders/qsvencc_hevc/command_builder.py index ee80e886..f41f4043 100644 --- a/fastflix/encoders/qsvencc_hevc/command_builder.py +++ b/fastflix/encoders/qsvencc_hevc/command_builder.py @@ -10,6 +10,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, ) @@ -180,6 +181,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(shlex.split(settings.extra)) diff --git a/fastflix/encoders/qsvencc_hevc/main.py b/fastflix/encoders/qsvencc_hevc/main.py index b3fe0bd6..8a0c041e 100644 --- a/fastflix/encoders/qsvencc_hevc/main.py +++ b/fastflix/encoders/qsvencc_hevc/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from NVEncC64.exe --check-encoders diff --git a/fastflix/encoders/qsvencc_hevc/settings_panel.py b/fastflix/encoders/qsvencc_hevc/settings_panel.py index 46d3fb70..423b7ef6 100644 --- a/fastflix/encoders/qsvencc_hevc/settings_panel.py +++ b/fastflix/encoders/qsvencc_hevc/settings_panel.py @@ -82,7 +82,7 @@ def __init__(self, parent, main, app: FastFlixApp): 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) + custom_layout = self._add_custom(title="Custom QSVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_qp_mode(), 2, 0, 1, 2) @@ -140,10 +140,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/rav1e/main.py b/fastflix/encoders/rav1e/main.py index c5430f95..f466da81 100644 --- a/fastflix/encoders/rav1e/main.py +++ b/fastflix/encoders/rav1e/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.rav1e.command_builder import build # noqa: F401,E402 from fastflix.encoders.rav1e.settings_panel import RAV1E as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/rav1e/settings_panel.py b/fastflix/encoders/rav1e/settings_panel.py index f0ebdc62..2934d87d 100644 --- a/fastflix/encoders/rav1e/settings_panel.py +++ b/fastflix/encoders/rav1e/settings_panel.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import logging -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import SettingPanel from fastflix.language import t @@ -87,15 +87,14 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_photon_noise(), 6, 2, 1, 4) grid.addLayout(self.init_rav1e_params(), 7, 2, 1, 4) - grid.addLayout(self._add_custom(), 10, 0, 1, 6) - grid.setRowStretch(9, 1) + custom_layout = self._add_custom() guide_label = QtWidgets.QLabel( link("https://github.com/xiph/rav1e/blob/master/README.md", t("rav1e github"), app.fastflix.config.theme) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 6) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/svt_av1/main.py b/fastflix/encoders/svt_av1/main.py index e3548704..312c2503 100644 --- a/fastflix/encoders/svt_av1/main.py +++ b/fastflix/encoders/svt_av1/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.svt_av1.command_builder import build # noqa: F401,E402 from fastflix.encoders.svt_av1.settings_panel import SVT_AV1 as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/svt_av1/settings_panel.py b/fastflix/encoders/svt_av1/settings_panel.py index a5825d5b..5fc3f212 100644 --- a/fastflix/encoders/svt_av1/settings_panel.py +++ b/fastflix/encoders/svt_av1/settings_panel.py @@ -3,7 +3,7 @@ import logging from box import Box -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import SettingPanel from fastflix.language import t @@ -98,6 +98,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_svtav1_params(), 7, 2, 1, 4) grid.setRowStretch(12, 1) + custom_layout = self._add_custom() guide_label = QtWidgets.QLabel( link( "https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Ffmpeg.md", @@ -105,10 +106,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addLayout(self._add_custom(), 14, 0, 1, 6) - grid.addWidget(guide_label, 15, 0, -1, 1) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 14, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/svt_av1_avif/main.py b/fastflix/encoders/svt_av1_avif/main.py index a7099b15..fab1e246 100644 --- a/fastflix/encoders/svt_av1_avif/main.py +++ b/fastflix/encoders/svt_av1_avif/main.py @@ -18,6 +18,7 @@ enable_audio = False enable_attachments = False enable_concat = True +enable_data = False from fastflix.encoders.svt_av1_avif.command_builder import build # noqa: F401,E402 from fastflix.encoders.svt_av1_avif.settings_panel import SVT_AV1_AVIF as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/svt_av1_avif/settings_panel.py b/fastflix/encoders/svt_av1_avif/settings_panel.py index 63501404..027d4d32 100644 --- a/fastflix/encoders/svt_av1_avif/settings_panel.py +++ b/fastflix/encoders/svt_av1_avif/settings_panel.py @@ -3,7 +3,7 @@ import logging from box import Box -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import SettingPanel from fastflix.language import t @@ -81,6 +81,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_svtav1_params(), 5, 2, 1, 4) grid.setRowStretch(8, 1) + custom_layout = self._add_custom() guide_label = QtWidgets.QLabel( link( "https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Ffmpeg.md", @@ -88,10 +89,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addLayout(self._add_custom(), 10, 0, 1, 6) - grid.addWidget(guide_label, 11, 0, -1, 1) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vaapi_h264/main.py b/fastflix/encoders/vaapi_h264/main.py index f6d5ec69..a3adb282 100644 --- a/fastflix/encoders/vaapi_h264/main.py +++ b/fastflix/encoders/vaapi_h264/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.vaapi_h264.command_builder import build # noqa: F401,E402 from fastflix.encoders.vaapi_h264.settings_panel import VAAPIH264 as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/vaapi_h264/settings_panel.py b/fastflix/encoders/vaapi_h264/settings_panel.py index ffbd7b87..7db9b26f 100644 --- a/fastflix/encoders/vaapi_h264/settings_panel.py +++ b/fastflix/encoders/vaapi_h264/settings_panel.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import logging -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import VAAPIPanel from fastflix.language import t @@ -61,14 +61,14 @@ def __init__(self, parent, main, app: FastFlixApp): more_line.addLayout(self.init_low_power()) grid.addLayout(more_line, 5, 0, 1, 6) - grid.addLayout(self._add_custom(disable_both_passes=True), 10, 0, 1, 6) grid.setRowStretch(9, 1) + custom_layout = self._add_custom(disable_both_passes=True) guide_label = QtWidgets.QLabel( link("https://trac.ffmpeg.org/wiki/Hardware/VAAPI", t("VAAPI FFmpeg encoding"), app.fastflix.config.theme) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 6) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vaapi_hevc/main.py b/fastflix/encoders/vaapi_hevc/main.py index d0fce048..d792c6f3 100644 --- a/fastflix/encoders/vaapi_hevc/main.py +++ b/fastflix/encoders/vaapi_hevc/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.vaapi_hevc.command_builder import build # noqa: F401,E402 from fastflix.encoders.vaapi_hevc.settings_panel import VAAPIHEVC as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/vaapi_hevc/settings_panel.py b/fastflix/encoders/vaapi_hevc/settings_panel.py index 445c4631..4a4e25ab 100644 --- a/fastflix/encoders/vaapi_hevc/settings_panel.py +++ b/fastflix/encoders/vaapi_hevc/settings_panel.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import logging -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import VAAPIPanel from fastflix.language import t @@ -61,14 +61,14 @@ def __init__(self, parent, main, app: FastFlixApp): more_line.addLayout(self.init_low_power()) grid.addLayout(more_line, 5, 0, 1, 6) - grid.addLayout(self._add_custom(disable_both_passes=True), 10, 0, 1, 6) grid.setRowStretch(9, 1) + custom_layout = self._add_custom(disable_both_passes=True) guide_label = QtWidgets.QLabel( link("https://trac.ffmpeg.org/wiki/Hardware/VAAPI", t("VAAPI FFmpeg encoding"), app.fastflix.config.theme) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 6) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vaapi_mpeg2/main.py b/fastflix/encoders/vaapi_mpeg2/main.py index 98ac6d28..fac1725b 100644 --- a/fastflix/encoders/vaapi_mpeg2/main.py +++ b/fastflix/encoders/vaapi_mpeg2/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.vaapi_mpeg2.command_builder import build # noqa: F401,E402 from fastflix.encoders.vaapi_mpeg2.settings_panel import VAAPIMPEG2 as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/vaapi_mpeg2/settings_panel.py b/fastflix/encoders/vaapi_mpeg2/settings_panel.py index a9d73365..20d3dae2 100644 --- a/fastflix/encoders/vaapi_mpeg2/settings_panel.py +++ b/fastflix/encoders/vaapi_mpeg2/settings_panel.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import logging -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import VAAPIPanel from fastflix.language import t @@ -55,14 +55,14 @@ def __init__(self, parent, main, app: FastFlixApp): more_line.addLayout(self.init_low_power()) grid.addLayout(more_line, 5, 0, 1, 6) - grid.addLayout(self._add_custom(disable_both_passes=True), 10, 0, 1, 6) grid.setRowStretch(9, 1) + custom_layout = self._add_custom(disable_both_passes=True) guide_label = QtWidgets.QLabel( link("https://trac.ffmpeg.org/wiki/Hardware/VAAPI", t("VAAPI FFmpeg encoding"), app.fastflix.config.theme) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 6) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vaapi_vp9/main.py b/fastflix/encoders/vaapi_vp9/main.py index 484009f4..610b8678 100644 --- a/fastflix/encoders/vaapi_vp9/main.py +++ b/fastflix/encoders/vaapi_vp9/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.vaapi_vp9.command_builder import build # noqa: F401,E402 from fastflix.encoders.vaapi_vp9.settings_panel import VAAPIVP9 as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/vaapi_vp9/settings_panel.py b/fastflix/encoders/vaapi_vp9/settings_panel.py index e5d4f35e..5bd88686 100644 --- a/fastflix/encoders/vaapi_vp9/settings_panel.py +++ b/fastflix/encoders/vaapi_vp9/settings_panel.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import logging -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import VAAPIPanel from fastflix.language import t @@ -55,14 +55,14 @@ def __init__(self, parent, main, app: FastFlixApp): more_line.addLayout(self.init_low_power()) grid.addLayout(more_line, 5, 0, 1, 6) - grid.addLayout(self._add_custom(disable_both_passes=True), 10, 0, 1, 6) grid.setRowStretch(9, 1) + custom_layout = self._add_custom(disable_both_passes=True) guide_label = QtWidgets.QLabel( link("https://trac.ffmpeg.org/wiki/Hardware/VAAPI", t("VAAPI FFmpeg encoding"), app.fastflix.config.theme) ) - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 11, 0, 1, 6) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 10, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vceencc_av1/command_builder.py b/fastflix/encoders/vceencc_av1/command_builder.py index ce8da2cd..7c2010fc 100644 --- a/fastflix/encoders/vceencc_av1/command_builder.py +++ b/fastflix/encoders/vceencc_av1/command_builder.py @@ -9,6 +9,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, pa_builder, @@ -152,6 +153,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(shlex.split(settings.extra)) diff --git a/fastflix/encoders/vceencc_av1/main.py b/fastflix/encoders/vceencc_av1/main.py index b21a8d8a..0a778d30 100644 --- a/fastflix/encoders/vceencc_av1/main.py +++ b/fastflix/encoders/vceencc_av1/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from VCEEncC64.exe --check-encoders diff --git a/fastflix/encoders/vceencc_av1/settings_panel.py b/fastflix/encoders/vceencc_av1/settings_panel.py index 949798c6..ec9ebb5a 100644 --- a/fastflix/encoders/vceencc_av1/settings_panel.py +++ b/fastflix/encoders/vceencc_av1/settings_panel.py @@ -75,7 +75,7 @@ def __init__(self, parent, main, app: FastFlixApp): self.updating_settings = False grid.addLayout(self.init_modes(), 0, 2, 4, 4) - grid.addLayout(self._add_custom(title="Custom VCEEncC options", disable_both_passes=True), 11, 0, 1, 6) + custom_layout = self._add_custom(title="Custom VCEEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_bitrate_mode(), 1, 0, 1, 2) grid.addLayout(self.init_mv_precision(), 2, 0, 1, 2) @@ -130,10 +130,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 12, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 11, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vceencc_avc/command_builder.py b/fastflix/encoders/vceencc_avc/command_builder.py index dc07c097..ed7f4470 100644 --- a/fastflix/encoders/vceencc_avc/command_builder.py +++ b/fastflix/encoders/vceencc_avc/command_builder.py @@ -9,6 +9,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_auto_options, rigaya_avformat_reader, pa_builder, @@ -140,6 +141,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(shlex.split(settings.extra)) diff --git a/fastflix/encoders/vceencc_avc/main.py b/fastflix/encoders/vceencc_avc/main.py index 42efa685..462250a7 100644 --- a/fastflix/encoders/vceencc_avc/main.py +++ b/fastflix/encoders/vceencc_avc/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from VCEEncC64.exe --check-encoders diff --git a/fastflix/encoders/vceencc_avc/settings_panel.py b/fastflix/encoders/vceencc_avc/settings_panel.py index 2f44001b..c731b57d 100644 --- a/fastflix/encoders/vceencc_avc/settings_panel.py +++ b/fastflix/encoders/vceencc_avc/settings_panel.py @@ -74,7 +74,7 @@ def __init__(self, parent, main, app: FastFlixApp): self.updating_settings = False grid.addLayout(self.init_modes(), 0, 2, 4, 4) - grid.addLayout(self._add_custom(title="Custom VCEEncC options", disable_both_passes=True), 11, 0, 1, 6) + custom_layout = self._add_custom(title="Custom VCEEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_profile(), 1, 0, 1, 2) grid.addLayout(self.init_mv_precision(), 2, 0, 1, 2) @@ -121,10 +121,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 12, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 11, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vceencc_hevc/command_builder.py b/fastflix/encoders/vceencc_hevc/command_builder.py index a995741f..77e541a6 100644 --- a/fastflix/encoders/vceencc_hevc/command_builder.py +++ b/fastflix/encoders/vceencc_hevc/command_builder.py @@ -9,6 +9,7 @@ from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, + build_data, rigaya_avformat_reader, rigaya_auto_options, pa_builder, @@ -153,6 +154,9 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + command.extend( + build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + ) if settings.extra: command.extend(shlex.split(settings.extra)) diff --git a/fastflix/encoders/vceencc_hevc/main.py b/fastflix/encoders/vceencc_hevc/main.py index 78206abd..a58ada4e 100644 --- a/fastflix/encoders/vceencc_hevc/main.py +++ b/fastflix/encoders/vceencc_hevc/main.py @@ -15,6 +15,7 @@ enable_subtitles = True enable_audio = True enable_attachments = False +enable_data = True original_audio_tracks_only = True # Taken from VCEEncC64.exe --check-encoders diff --git a/fastflix/encoders/vceencc_hevc/settings_panel.py b/fastflix/encoders/vceencc_hevc/settings_panel.py index 70327315..6d7c8b18 100644 --- a/fastflix/encoders/vceencc_hevc/settings_panel.py +++ b/fastflix/encoders/vceencc_hevc/settings_panel.py @@ -75,7 +75,7 @@ def __init__(self, parent, main, app: FastFlixApp): self.updating_settings = False grid.addLayout(self.init_modes(), 0, 2, 4, 4) - grid.addLayout(self._add_custom(title="Custom VCEEncC options", disable_both_passes=True), 11, 0, 1, 6) + custom_layout = self._add_custom(title="Custom VCEEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_tier(), 1, 0, 1, 2) grid.addLayout(self.init_mv_precision(), 2, 0, 1, 2) @@ -128,10 +128,9 @@ def __init__(self, parent, main, app: FastFlixApp): app.fastflix.config.theme, ) ) - - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 12, 0, 1, 4) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 11, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vp9/main.py b/fastflix/encoders/vp9/main.py index 03c123aa..0efb7c47 100644 --- a/fastflix/encoders/vp9/main.py +++ b/fastflix/encoders/vp9/main.py @@ -18,6 +18,7 @@ enable_audio = True enable_attachments = False enable_concat = True +enable_data = True from fastflix.encoders.vp9.command_builder import build # noqa: F401,E402 from fastflix.encoders.vp9.settings_panel import VP9 as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/vp9/settings_panel.py b/fastflix/encoders/vp9/settings_panel.py index fb289dad..86248355 100644 --- a/fastflix/encoders/vp9/settings_panel.py +++ b/fastflix/encoders/vp9/settings_panel.py @@ -2,7 +2,7 @@ import logging from box import Box -from PySide6 import QtCore, QtWidgets +from PySide6 import QtWidgets from fastflix.encoders.common.setting_panel import SettingPanel from fastflix.language import t @@ -95,7 +95,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_sharpness(), 9, 0, 1, 2) grid.setRowStretch(10, 1) - grid.addLayout(self._add_custom(), 11, 0, 1, 6) + custom_layout = self._add_custom() link_1 = link( "https://trac.ffmpeg.org/wiki/Encode/VP9", t("FFMPEG VP9 Encoding Guide"), app.fastflix.config.theme @@ -107,9 +107,9 @@ def __init__(self, parent, main, app: FastFlixApp): ) guide_label = QtWidgets.QLabel(f"{link_1} | {link_2}") - guide_label.setAlignment(QtCore.Qt.AlignBottom) guide_label.setOpenExternalLinks(True) - grid.addWidget(guide_label, 12, 0, 1, 6) + custom_layout.addWidget(guide_label) + grid.addLayout(custom_layout, 11, 0, 1, 6) self.setLayout(grid) self.hide() diff --git a/fastflix/encoders/vvc/main.py b/fastflix/encoders/vvc/main.py index 4efc9761..15c2934f 100644 --- a/fastflix/encoders/vvc/main.py +++ b/fastflix/encoders/vvc/main.py @@ -17,6 +17,7 @@ enable_audio = True enable_attachments = True enable_concat = True +enable_data = True from fastflix.encoders.vvc.command_builder import build # noqa: F401,E402 from fastflix.encoders.vvc.settings_panel import VVC as settings_panel # noqa: F401,E402 diff --git a/fastflix/encoders/webp/main.py b/fastflix/encoders/webp/main.py index 364afd15..d3674e33 100644 --- a/fastflix/encoders/webp/main.py +++ b/fastflix/encoders/webp/main.py @@ -17,6 +17,7 @@ enable_audio = False enable_attachments = False enable_concat = True +enable_data = False audio_formats = [] diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 80b9f514..6c25f2e3 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -303,6 +303,7 @@ class Config(BaseModel): use_keyframes_for_preview: bool = True terms_accepted: bool = False + auto_detect_subtitles: bool = True @property def pgs_ocr_available(self) -> bool: diff --git a/fastflix/models/encode.py b/fastflix/models/encode.py index 981f940c..9e3c3d6b 100644 --- a/fastflix/models/encode.py +++ b/fastflix/models/encode.py @@ -54,6 +54,21 @@ class AttachmentTrack(BaseModel): filename: Optional[str] = None +class DataTrack(BaseModel): + index: int # Source stream index from FFprobe + outdex: int # Output stream index + enabled: bool = True # Whether to include in output + codec_name: str = "" # e.g., "bin_data", "tmcd", "ttf" + codec_type: str = "" # "data" or "attachment" + title: str = "" # Track title from tags + mimetype: str = "" # MIME type (attachments only) + filename: str = "" # Filename (attachments only) + friendly_info: str = "" # Display string for UI + raw_info: Optional[Union[dict, Box]] = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + class EncoderSettings(BaseModel): max_muxing_queue_size: str = "1024" pix_fmt: str = "yuv420p10le" diff --git a/fastflix/models/video.py b/fastflix/models/video.py index dc338462..67d36caf 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -11,6 +11,7 @@ AttachmentTrack, AudioTrack, CopySettings, + DataTrack, GIFSettings, GifskiSettings, FFmpegNVENCSettings, @@ -212,6 +213,7 @@ class Video(BaseModel): audio_tracks: list[AudioTrack] = Field(default_factory=list) subtitle_tracks: list[SubtitleTrack] = Field(default_factory=list) attachment_tracks: list[AttachmentTrack] = Field(default_factory=list) + data_tracks: list[DataTrack] = Field(default_factory=list) status: Status = Field(default_factory=Status) uuid: str = Field(default_factory=lambda: str(uuid.uuid4())) diff --git a/fastflix/ui_constants.py b/fastflix/ui_constants.py index 70c99bf7..4d331c62 100644 --- a/fastflix/ui_constants.py +++ b/fastflix/ui_constants.py @@ -41,12 +41,12 @@ class BaseHeights: """Base height values (~25% smaller than original for better default scaling).""" TOP_BAR_BUTTON: int = 38 - PATH_WIDGET: int = 20 - COMBO_BOX: int = 22 + PATH_WIDGET: int = 28 + COMBO_BOX: int = 28 PANEL_ITEM: int = 62 SCROLL_MIN: int = 150 PREVIEW_MIN: int = 195 - OUTPUT_DIR: int = 18 + OUTPUT_DIR: int = 26 HEADER: int = 23 SPACER_TINY: int = 2 SPACER_SMALL: int = 4 diff --git a/fastflix/ui_styles.py b/fastflix/ui_styles.py index 3d3c1394..333ccf9d 100644 --- a/fastflix/ui_styles.py +++ b/fastflix/ui_styles.py @@ -29,6 +29,10 @@ def get_scaled_stylesheet(theme: str) -> str: # when fonts propagate to child widgets. Convert px to pt (at 96 DPI: pt = px * 0.75). font_size_pt = max(6, round(scaler.scale_font(FONTS.LARGE) * 0.75)) border_radius = scaler.scale(10) + # Ensure text inputs are always tall enough for their rounded edges. + # The min-height must be at least 2 * border_radius so the left/right + # semicircles have room to render without clipping. + input_min_height = 2 * border_radius + scaler.scale(4) base = f"QWidget {{ font-size: {font_size_pt}pt; }}" @@ -41,10 +45,11 @@ def get_scaled_stylesheet(theme: str) -> str: background-color: #4a555e; color: white; border-radius: {border_radius}px; + min-height: {input_min_height}px; }} QTextEdit {{ background-color: #4a555e; color: white; }} QTabBar::tab {{ background-color: #4f5962; }} - QComboBox {{ border-radius: {border_radius}px; }} + QComboBox {{ border-radius: {border_radius}px; min-height: {input_min_height}px; }} QScrollArea {{ border: 1px solid #919191; }} """ diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index b897bf1e..82f09d49 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -57,7 +57,7 @@ from fastflix.widgets.background_tasks import ThumbnailCreator from fastflix.widgets.status_bar import Task, STATE_ENCODING, STATE_ERROR, STATE_COMPLETE, STATE_IDLE from fastflix.widgets.video_options import VideoOptions -from fastflix.widgets.windows.large_preview import LargePreview +from fastflix.widgets.windows.crop_window import CropPreviewWindow logger = logging.getLogger("fastflix") @@ -147,7 +147,6 @@ class MainWidgets(BaseModel): output_type_combo: QtWidgets.QComboBox = Field(default_factory=QtWidgets.QComboBox) output_directory_select: QtWidgets.QPushButton = None model_config = ConfigDict(arbitrary_types_allowed=True) - copy_data: QtWidgets.QCheckBox = None def items(self): for key in dir(self): @@ -182,11 +181,12 @@ def __init__(self, parent, app: FastFlixApp): self.loading_video = True self.scale_updating = False self._top_bar_widgets = [] # widgets that share the same height in the top bar + self._preview_buttons = [] # square icon buttons that must stay fixed on scale change self.last_thumb_hash = "" self.page_updating = False self.previous_encoder_no_audio = False - self.large_preview = LargePreview(self) + self.crop_preview = CropPreviewWindow(self) self.notifier = Notifier(self, self.app, self.app.fastflix.status_queue) self.notifier.start() @@ -232,7 +232,7 @@ def __init__(self, parent, app: FastFlixApp): ] ) self.source_video_path_widget = QtWidgets.QLineEdit(motto) - self.source_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.PATH_WIDGET)) + self.source_video_path_widget.setMinimumHeight(scaler.scale(HEIGHTS.PATH_WIDGET)) self.source_video_path_widget.setDisabled(True) self.source_video_path_widget.setStyleSheet( f"padding: 0 0 -1px 5px; color: rgb({get_text_color(self.app.fastflix.config.theme)})" @@ -240,7 +240,7 @@ def __init__(self, parent, app: FastFlixApp): self.output_video_path_widget = QtWidgets.QLineEdit("") self.output_video_path_widget.setDisabled(True) - self.output_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.PATH_WIDGET)) + self.output_video_path_widget.setMinimumHeight(scaler.scale(HEIGHTS.PATH_WIDGET)) self.output_video_path_widget.setStyleSheet( f"padding: 0 0 -1px 5px; color: rgb({get_text_color(self.app.fastflix.config.theme)})" ) @@ -334,6 +334,11 @@ def _on_scale_changed(self, _factors): h = scaler.scale(HEIGHTS.TOP_BAR_BUTTON) for w in self._top_bar_widgets: w.setFixedHeight(h) + # Keep preview buttons square at the current scale + btn_size = scaler.scale(28) + for btn in self._preview_buttons: + btn.setFixedSize(btn_size, btn_size) + btn.setIconSize(QtCore.QSize(btn_size - 8, btn_size - 8)) def fade_loop(self, percent=90): if self.input_video: @@ -613,7 +618,7 @@ def init_video_area(self): if self.app.fastflix.config.theme == "onyx": source_label.setStyleSheet("color: white;") shrink_text_to_fit(source_label) - self.source_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) + self.source_video_path_widget.setMinimumHeight(scaler.scale(HEIGHTS.COMBO_BOX)) source_layout.addWidget(source_label) source_layout.addWidget(self.source_video_path_widget, stretch=True) @@ -623,14 +628,14 @@ def init_video_area(self): if self.app.fastflix.config.theme == "onyx": output_label.setStyleSheet("color: white;") shrink_text_to_fit(output_label) - self.output_video_path_widget.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) + self.output_video_path_widget.setMinimumHeight(scaler.scale(HEIGHTS.COMBO_BOX)) output_layout.addWidget(output_label) output_layout.addWidget(self.output_video_path_widget, stretch=True) self.widgets.output_type_combo.setFixedWidth(scaler.scale(WIDTHS.OUTPUT_TYPE)) if self.current_encoder: self.widgets.output_type_combo.addItems(self.current_encoder.video_extensions) - self.widgets.output_type_combo.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) + self.widgets.output_type_combo.setMinimumHeight(scaler.scale(HEIGHTS.COMBO_BOX)) if self.app.fastflix.config.theme == "onyx": self.widgets.output_type_combo.setStyleSheet(get_onyx_combobox_style()) self.widgets.output_type_combo.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) @@ -639,11 +644,11 @@ def init_video_area(self): out_dir_layout = QtWidgets.QHBoxLayout() out_dir_label = QtWidgets.QLabel(t("Folder")) - out_dir_label.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) + out_dir_label.setMinimumHeight(scaler.scale(HEIGHTS.COMBO_BOX)) out_dir_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) shrink_text_to_fit(out_dir_label) self.widgets.output_directory = QtWidgets.QPushButton() - self.widgets.output_directory.setFixedHeight(scaler.scale(HEIGHTS.OUTPUT_DIR)) + self.widgets.output_directory.setMinimumHeight(scaler.scale(HEIGHTS.OUTPUT_DIR)) self.widgets.output_directory.clicked.connect(self.save_directory) self.output_path_button = QtWidgets.QPushButton(icon=QtGui.QIcon(self.get_icon("onyx-output"))) @@ -716,7 +721,11 @@ def init_options_tabs(self): tabs = QtWidgets.QTabWidget() tabs.setIconSize(QtCore.QSize(scaler.scale(20), scaler.scale(20))) if self.app.fastflix.config.theme == "onyx": - tabs.setStyleSheet("QLabel{ color: white; } QCheckBox{ color: white; }") + tabs.setStyleSheet( + "QLabel{ color: white; } QCheckBox{ color: white; } " + "QLineEdit{ border-radius: 5px; min-height: 0px; } " + "QComboBox{ border-radius: 5px; min-height: 0px; }" + ) # Tab 1: Size (Resolution + Transforms) size_tab = QtWidgets.QWidget() @@ -734,8 +743,7 @@ def init_options_tabs(self): res_row = QtWidgets.QHBoxLayout() res_row.setSpacing(scaler.scale(4)) res_label = QtWidgets.QLabel(t("Resolution")) - res_label.setFixedWidth(scaler.scale(68)) - shrink_text_to_fit(res_label, padding=4) + res_label.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) res_row.addWidget(res_label) self.widgets.resolution_drop_down = QtWidgets.QComboBox() @@ -782,10 +790,11 @@ def init_options_tabs(self): # Column 1: Reset button and Seek mode time_col1 = QtWidgets.QVBoxLayout() - time_col1.setSpacing(scaler.scale(4)) + time_col1.setSpacing(scaler.scale(10)) time_reset = QtWidgets.QPushButton(t("Reset")) - time_reset.setFixedHeight(scaler.scale(22)) + time_reset.setFixedHeight(scaler.scale(28)) + time_reset.setFixedWidth(scaler.scale(80)) time_reset.setToolTip(t("Reset start and end times")) time_reset.clicked.connect(self.reset_time) if self.app.fastflix.config.theme == "onyx": @@ -795,7 +804,8 @@ def init_options_tabs(self): self.widgets.fast_time = QtWidgets.QComboBox() self.widgets.fast_time.addItems([t("Fast"), t("Exact")]) self.widgets.fast_time.setCurrentIndex(0) - self.widgets.fast_time.setFixedHeight(scaler.scale(22)) + self.widgets.fast_time.setFixedHeight(scaler.scale(28)) + self.widgets.fast_time.setFixedWidth(scaler.scale(80)) if self.app.fastflix.config.theme == "onyx": self.widgets.fast_time.setStyleSheet(get_onyx_combobox_style()) self.widgets.fast_time.setToolTip( @@ -812,10 +822,11 @@ def init_options_tabs(self): # Column 2: Start and End times stacked vertically time_col2 = QtWidgets.QVBoxLayout() - time_col2.setSpacing(scaler.scale(4)) + time_col2.setSpacing(scaler.scale(14)) self.widgets.start_time, start_row = self.build_hoz_int_field( t("Start"), + button_size=32, right_stretch=False, left_stretch=False, time_field=True, @@ -824,6 +835,7 @@ def init_options_tabs(self): self.widgets.end_time, end_row = self.build_hoz_int_field( t("End"), + button_size=32, left_stretch=False, right_stretch=False, time_field=True, @@ -834,30 +846,39 @@ def init_options_tabs(self): time_col2.addLayout(end_row) time_col2.addStretch(1) - # Column 3: "Set from preview" buttons + # Column 3: "Set from preview" buttons (must stay square on resize) time_col3 = QtWidgets.QVBoxLayout() - time_col3.setSpacing(scaler.scale(4)) + time_col3.setSpacing(scaler.scale(14)) + + preview_btn_size = scaler.scale(28) + preview_btn_style = "padding: 0; margin: 0;" start_from_preview = QtWidgets.QPushButton() start_from_preview.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon)) - start_from_preview.setFixedSize(scaler.scale(24), scaler.scale(28)) + start_from_preview.setFixedSize(preview_btn_size, preview_btn_size) + start_from_preview.setIconSize(QtCore.QSize(preview_btn_size - 8, preview_btn_size - 8)) + start_from_preview.setStyleSheet(preview_btn_style) start_from_preview.setToolTip(t("Set start time from preview position")) start_from_preview.clicked.connect(lambda: self.set_time_from_preview(self.widgets.start_time)) self.buttons.append(start_from_preview) + self._preview_buttons.append(start_from_preview) end_from_preview = QtWidgets.QPushButton() end_from_preview.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon)) - end_from_preview.setFixedSize(scaler.scale(24), scaler.scale(28)) + end_from_preview.setFixedSize(preview_btn_size, preview_btn_size) + end_from_preview.setIconSize(QtCore.QSize(preview_btn_size - 8, preview_btn_size - 8)) + end_from_preview.setStyleSheet(preview_btn_style) end_from_preview.setToolTip(t("Set end time from preview position")) end_from_preview.clicked.connect(lambda: self.set_time_from_preview(self.widgets.end_time)) self.buttons.append(end_from_preview) + self._preview_buttons.append(end_from_preview) time_col3.addWidget(start_from_preview) time_col3.addWidget(end_from_preview) time_col3.addStretch(1) time_layout.addLayout(time_col1) - time_layout.addStretch(1) + time_layout.addSpacing(scaler.scale(20)) time_layout.addLayout(time_col2) time_layout.addStretch(1) time_layout.addLayout(time_col3) @@ -872,56 +893,68 @@ def init_options_tabs(self): # Column 1: Auto and Reset buttons col1 = QtWidgets.QVBoxLayout() - col1.setSpacing(scaler.scale(4)) + col1.setSpacing(scaler.scale(10)) auto_crop = QtWidgets.QPushButton(t("Auto")) - auto_crop.setFixedHeight(scaler.scale(22)) + auto_crop.setFixedHeight(scaler.scale(28)) auto_crop.setToolTip(t("Automatically detect black borders")) auto_crop.clicked.connect(self.get_auto_crop) if self.app.fastflix.config.theme == "onyx": auto_crop.setStyleSheet(get_onyx_button_style()) self.buttons.append(auto_crop) reset = QtWidgets.QPushButton(t("Reset")) - reset.setFixedHeight(scaler.scale(22)) + reset.setFixedHeight(scaler.scale(28)) reset.setToolTip(t("Reset crop")) reset.clicked.connect(self.reset_crop) if self.app.fastflix.config.theme == "onyx": reset.setStyleSheet(get_onyx_button_style()) self.buttons.append(reset) + visual_crop = QtWidgets.QPushButton(t("Visual Crop")) + visual_crop.setFixedHeight(scaler.scale(28)) + visual_crop.setToolTip(t("Visual Crop")) + visual_crop.clicked.connect(self.open_crop_preview) + if self.app.fastflix.config.theme == "onyx": + visual_crop.setStyleSheet(get_onyx_button_style()) + self.buttons.append(visual_crop) col1.addWidget(auto_crop) col1.addWidget(reset) + col1.addWidget(visual_crop) col1.addStretch(1) # Crop input fields - field_width = scaler.scale(50) - field_height = scaler.scale(22) + field_width = scaler.scale(65) + field_height = scaler.scale(30) self.widgets.crop.top = QtWidgets.QLineEdit("0") self.widgets.crop.top.setValidator(only_int) - self.widgets.crop.top.setFixedSize(field_width, field_height) + self.widgets.crop.top.setFixedWidth(field_width) + self.widgets.crop.top.setFixedHeight(field_height) self.widgets.crop.top.setAlignment(QtCore.Qt.AlignCenter) self.widgets.crop.top.textChanged.connect(lambda: self.page_update()) self.widgets.crop.bottom = QtWidgets.QLineEdit("0") self.widgets.crop.bottom.setValidator(only_int) - self.widgets.crop.bottom.setFixedSize(field_width, field_height) + self.widgets.crop.bottom.setFixedWidth(field_width) + self.widgets.crop.bottom.setFixedHeight(field_height) self.widgets.crop.bottom.setAlignment(QtCore.Qt.AlignCenter) self.widgets.crop.bottom.textChanged.connect(lambda: self.page_update()) self.widgets.crop.left = QtWidgets.QLineEdit("0") self.widgets.crop.left.setValidator(only_int) - self.widgets.crop.left.setFixedSize(field_width, field_height) + self.widgets.crop.left.setFixedWidth(field_width) + self.widgets.crop.left.setFixedHeight(field_height) self.widgets.crop.left.setAlignment(QtCore.Qt.AlignCenter) self.widgets.crop.left.textChanged.connect(lambda: self.page_update()) self.widgets.crop.right = QtWidgets.QLineEdit("0") self.widgets.crop.right.setValidator(only_int) - self.widgets.crop.right.setFixedSize(field_width, field_height) + self.widgets.crop.right.setFixedWidth(field_width) + self.widgets.crop.right.setFixedHeight(field_height) self.widgets.crop.right.setAlignment(QtCore.Qt.AlignCenter) self.widgets.crop.right.textChanged.connect(lambda: self.page_update()) # Column 2: Top and Bottom col2 = QtWidgets.QVBoxLayout() - col2.setSpacing(scaler.scale(4)) + col2.setSpacing(scaler.scale(12)) top_row = QtWidgets.QHBoxLayout() top_row.addWidget(QtWidgets.QLabel(t("Top"))) top_row.addWidget(self.widgets.crop.top) @@ -934,7 +967,7 @@ def init_options_tabs(self): # Column 3: Left and Right col3 = QtWidgets.QVBoxLayout() - col3.setSpacing(scaler.scale(4)) + col3.setSpacing(scaler.scale(12)) left_row = QtWidgets.QHBoxLayout() left_row.addWidget(QtWidgets.QLabel(t("Left"))) left_row.addWidget(self.widgets.crop.left) @@ -1001,7 +1034,7 @@ def init_video_track_select(self): layout.setContentsMargins(0, 0, 0, 0) self.widgets.video_track = QtWidgets.QComboBox() self.widgets.video_track.addItems([]) - self.widgets.video_track.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) + self.widgets.video_track.setMinimumHeight(scaler.scale(HEIGHTS.COMBO_BOX)) self.widgets.video_track.currentIndexChanged.connect(self.video_track_update) self.widgets.video_track.setStyleSheet("height: 5px") if self.app.fastflix.config.theme == "onyx": @@ -1400,7 +1433,7 @@ def build_hoz_int_field( if not time_field: widget.setFixedWidth(scaler.scale(38)) else: - widget.setFixedWidth(scaler.scale(79)) + widget.setFixedWidth(scaler.scale(105)) widget.setStyleSheet("text-align: center") layout.addWidget(minus_button) layout.addWidget(widget) @@ -1493,29 +1526,29 @@ def showEvent(self, event): self.thumb_time_overlay.setParent(self.preview_container) self.thumb_time_overlay.raise_() - # Large preview button at top right - self.large_preview_button = QtWidgets.QPushButton(self.preview_container) + # Visual crop button at top right + self.crop_preview_button = QtWidgets.QPushButton(self.preview_container) btn_size = scaler.scale(24) - self.large_preview_button.setFixedSize(btn_size, btn_size) - self.large_preview_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DesktopIcon)) - self.large_preview_button.setToolTip(t("Large Preview")) - self.large_preview_button.clicked.connect(self.open_large_preview) - self.large_preview_button.setStyleSheet( + self.crop_preview_button.setFixedSize(btn_size, btn_size) + self.crop_preview_button.setIcon(QtGui.QIcon(get_icon("crop", self.app.fastflix.config.theme))) + self.crop_preview_button.setToolTip(t("Visual Crop")) + self.crop_preview_button.clicked.connect(self.open_crop_preview) + self.crop_preview_button.setStyleSheet( "QPushButton { background: rgba(0,0,0,128); border: none; border-radius: 4px; }" "QPushButton:hover { background: rgba(0,0,0,180); }" ) - self.large_preview_button.raise_() + self.crop_preview_button.raise_() return self.preview_container - def open_large_preview(self): - if not self.initialized or not self.app.fastflix.current_video or self.large_preview.isVisible(): + def open_crop_preview(self): + if not self.initialized or not self.app.fastflix.current_video or self.crop_preview.isVisible(): return - self.large_preview.generate_image() - self.large_preview.show() + self.crop_preview.open_window() + self.crop_preview.show() def reposition_thumb_overlay(self): - """Reposition the thumb time overlay and large preview button.""" + """Reposition the thumb time overlay and crop preview button.""" if hasattr(self, "thumb_time_overlay") and hasattr(self, "preview_container"): container_rect = self.preview_container.rect() overlay_height = self.thumb_time_overlay.height() @@ -1526,10 +1559,10 @@ def reposition_thumb_overlay(self): container_rect.width() - (2 * margin), overlay_height, ) - if hasattr(self, "large_preview_button") and hasattr(self, "preview_container"): + if hasattr(self, "crop_preview_button") and hasattr(self, "preview_container"): btn_margin = scaler.scale(15) - btn_size = self.large_preview_button.width() - self.large_preview_button.move( + btn_size = self.crop_preview_button.width() + self.crop_preview_button.move( self.preview_container.width() - btn_size - btn_margin, btn_margin, ) @@ -2315,7 +2348,6 @@ def get_all_settings(self): video_title=self.video_options.advanced.video_title.text(), video_track_title=self.video_options.advanced.video_track_title.text(), remove_hdr=self.remove_hdr, - # copy_data=self.widgets.copy_data.isChecked(), ) self.video_options.get_settings() @@ -2603,6 +2635,11 @@ def dropEvent(self, event): logger.error(f"File does not exist {self.input_video}") return event.ignore() + # Defer heavy video loading so dropEvent returns immediately, + # releasing the Windows drag-drop COM lock (unfreezes Explorer). + QtCore.QTimer.singleShot(0, self._load_dropped_video) + + def _load_dropped_video(self): self.source_video_path_widget.setText(str(self.input_video)) self.video_path_widget.setText(str(self.input_video)) try: diff --git a/fastflix/widgets/panels/abstract_list.py b/fastflix/widgets/panels/abstract_list.py index d825129c..5357226f 100644 --- a/fastflix/widgets/panels/abstract_list.py +++ b/fastflix/widgets/panels/abstract_list.py @@ -87,6 +87,14 @@ def reorder(self, update=True, height=66): and isinstance(self.app.fastflix.current_video.audio_tracks, list) ): start = len([x for x in self.app.fastflix.current_video.audio_tracks if x.enabled]) + 1 + elif self.list_type == "data": + # After video + audio + subtitles + if self.app.fastflix.current_video and self.app.fastflix.current_video.video_settings: + start = 1 # video + if isinstance(self.app.fastflix.current_video.audio_tracks, list): + start += len([x for x in self.app.fastflix.current_video.audio_tracks if x.enabled]) + if isinstance(self.app.fastflix.current_video.subtitle_tracks, list): + start += len([x for x in self.app.fastflix.current_video.subtitle_tracks if x.enabled]) for index, widget in enumerate(self.tracks, start): self.inner_layout.addWidget(widget) diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index bce2b590..972fa0ae 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import logging from pathlib import Path from typing import Union @@ -19,6 +20,8 @@ from fastflix.widgets.panels.abstract_list import FlixList from fastflix.widgets.windows.disposition import Disposition +logger = logging.getLogger("fastflix") + disposition_options = [ "none", "default", @@ -381,8 +384,77 @@ def check_dis_button(self): ".ssa": "text", ".vtt": "text", ".sup": "picture", + ".sub": "picture", + ".idx": "picture", +} + +SUBTITLE_DISPOSITION_TAGS = { + "forced": {"disposition": "forced", "title": "Forced"}, + "sdh": {"disposition": "hearing_impaired", "title": "SDH"}, + "hi": {"disposition": "hearing_impaired", "title": "SDH"}, + "cc": {"disposition": "hearing_impaired", "title": "CC"}, + "default": {"disposition": "default", "title": ""}, + "commentary": {"disposition": "comment", "title": "Commentary"}, + "comment": {"disposition": "comment", "title": "Commentary"}, } +SUBTITLE_DESCRIPTOR_TAGS = { + "normal": "Normal", + "full": "Full", +} + + +def parse_subtitle_filename_metadata(video_stem: str, subtitle_filename: str, subtitle_ext: str) -> dict: + """Parse language and disposition metadata from subtitle filename segments. + + Given a video stem like "my video" and subtitle filename "my video.forced.deu.srt", + extracts the middle segments ("forced.deu") and classifies each as a disposition tag, + descriptor tag, or language code. + + Returns dict with keys: language (str), title (str), dispositions (dict). + """ + result = {"language": "", "title": "", "dispositions": {}} + + if not subtitle_filename.startswith(video_stem): + return result + + # Strip video stem prefix and subtitle extension to get middle segment + middle = subtitle_filename[len(video_stem) :] + if middle.lower().endswith(subtitle_ext.lower()): + middle = middle[: -len(subtitle_ext)] + + # Strip leading dots + middle = middle.lstrip(".") + if not middle: + return result + + segments = middle.split(".") + for segment in segments: + lower = segment.lower() + + # Check disposition tags first (prevents "hi" → Hindi collision) + if lower in SUBTITLE_DISPOSITION_TAGS: + tag_info = SUBTITLE_DISPOSITION_TAGS[lower] + result["dispositions"][tag_info["disposition"]] = True + if tag_info["title"] and not result["title"]: + result["title"] = tag_info["title"] + continue + + # Check descriptor tags (title-only, no disposition) + if lower in SUBTITLE_DESCRIPTOR_TAGS: + if not result["title"]: + result["title"] = SUBTITLE_DESCRIPTOR_TAGS[lower] + continue + + # Try as a language code + try: + lang = Language(segment) + result["language"] = lang.pt2b + except InvalidLanguageValue: + pass + + return result + class ExternalSubtitle(QtWidgets.QTabWidget): def __init__(self, app, parent, index, enabled=True, first=False): @@ -602,24 +674,29 @@ def add_external_subtitle(self): filenames, _ = QtWidgets.QFileDialog.getOpenFileNames( self, caption=t("Select Subtitle File"), - filter=f"{t('Subtitle Files')} (*.srt *.ass *.ssa *.vtt *.sup)", + filter=f"{t('Subtitle Files')} (*.srt *.ass *.ssa *.vtt *.sup *.sub *.idx)", ) if not filenames: return + video_stem = Path(self.app.fastflix.current_video.source).stem for filename in filenames: - ext = Path(filename).suffix.lower() + sub_path = Path(filename) + ext = sub_path.suffix.lower() sub_type = ext_subtitle_types.get(ext, "text") index = len(self.app.fastflix.current_video.subtitle_tracks) audio_end = len([x for x in self.app.fastflix.current_video.audio_tracks if x.enabled]) + parsed = parse_subtitle_filename_metadata(video_stem, sub_path.name, sub_path.suffix) self.app.fastflix.current_video.subtitle_tracks.append( SubtitleTrack( index=0, outdex=audio_end + index + 1, burn_in=False, - language="", + language=parsed["language"], + title=parsed["title"], + dispositions=parsed["dispositions"], subtitle_type=sub_type, enabled=True, - long_name=f"[EXT] {Path(filename).name}", + long_name=f"[EXT] {sub_path.name}", external=True, file_path=str(filename), ) @@ -707,6 +784,49 @@ def new_source(self): enabled=enabled, ) self.tracks.append(new_item) + + if self.app.fastflix.config.auto_detect_subtitles: + try: + video_path = Path(self.app.fastflix.current_video.source) + video_dir = video_path.parent + video_stem = video_path.stem + detected_files = sorted( + f + for f in video_dir.iterdir() + if f.name.startswith(video_stem) and f.suffix.lower() in ext_subtitle_types and f != video_path + ) + for sub_file in detected_files: + ext = sub_file.suffix.lower() + sub_type = ext_subtitle_types.get(ext, "text") + idx = len(self.app.fastflix.current_video.subtitle_tracks) + audio_end = len(self.app.fastflix.current_video.audio_tracks) + parsed = parse_subtitle_filename_metadata(video_stem, sub_file.name, sub_file.suffix) + self.app.fastflix.current_video.subtitle_tracks.append( + SubtitleTrack( + index=0, + outdex=audio_end + idx + 1, + burn_in=False, + language=parsed["language"], + title=parsed["title"], + dispositions=parsed["dispositions"], + subtitle_type=sub_type, + enabled=True, + long_name=f"[EXT] {sub_file.name}", + external=True, + file_path=str(sub_file), + ) + ) + new_widget = ExternalSubtitle( + app=self.app, + parent=self, + index=idx, + first=False, + enabled=True, + ) + self.tracks.append(new_widget) + except OSError: + logger.warning("Failed to scan directory for external subtitle files", exc_info=True) + if self.tracks: self.tracks[0].set_first() self.tracks[-1].set_last() diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 79a0f2db..bb939d0e 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -198,6 +198,11 @@ def _build_settings_tab(self): layout.addWidget(self.sticky_tabs, row, 0, 1, 2) row += 1 + self.auto_detect_subtitles = QtWidgets.QCheckBox(t("Auto-detect external subtitle files")) + self.auto_detect_subtitles.setChecked(self.app.fastflix.config.auto_detect_subtitles) + layout.addWidget(self.auto_detect_subtitles, row, 0, 1, 3) + row += 1 + # Default Output Directory self.default_output_dir = QtWidgets.QCheckBox(t("Use same output directory as source file")) layout.addWidget(self.default_output_dir, row, 0, 1, 2) @@ -508,6 +513,7 @@ def save(self): self.app.fastflix.config.disable_complete_message = self.disable_end_message.isChecked() self.app.fastflix.config.disable_deinterlace_check = self.disable_deinterlace_button.isChecked() self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() + self.app.fastflix.config.auto_detect_subtitles = self.auto_detect_subtitles.isChecked() self.main.config_update(encoder_reload_needed=encoder_reload_needed) self.app.fastflix.config.save() diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index e17f5820..32ca50c2 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -17,6 +17,7 @@ from fastflix.widgets.panels.audio_panel import AudioList from fastflix.widgets.panels.command_panel import CommandList from fastflix.widgets.panels.cover_panel import CoverPanel +from fastflix.widgets.panels.data_panel import DataList from fastflix.widgets.panels.debug_panel import DebugPanel from fastflix.widgets.panels.info_panel import InfoPanel from fastflix.widgets.panels.queue_panel import EncodingQueue @@ -32,13 +33,14 @@ 0: "onyx-quality", 1: "onyx-audio", 2: "onyx-cc", - 3: "onyx-cover", - 4: "onyx-advanced", - 5: "onyx-source-details", - 6: "onyx-raw-commands", - 7: "onyx-status", - 8: "onyx-queue", - 9: "info", + 3: "onyx-data", + 4: "onyx-cover", + 5: "onyx-advanced", + 6: "onyx-source-details", + 7: "onyx-raw-commands", + 8: "onyx-status", + 9: "onyx-queue", + 10: "info", } @@ -96,6 +98,7 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): self.tabBar().tabBarClicked.connect(self.change_tab) self.audio = AudioList(self, self.app) self.subtitles = SubtitleList(self, self.app) + self.data = DataList(self, self.app) self.status = StatusPanel(self, self.app) self.attachments = CoverPanel(self, self.app) self.queue = EncodingQueue(self, self.app) @@ -114,10 +117,10 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): f"QTabBar{{ font-size: {tab_font_pt}pt; }} " "QTabBar::tab{ border-top: 2px solid transparent; } " f"QTabBar::tab:selected{{ border-top: 2px solid {ONYX_COLORS['primary']}; }} " - "QLineEdit{ color: white; } " + "QLineEdit{ color: white; border-radius: 5px; min-height: 0px; } " "QTextEdit{ color: white; } " "QPlainTextEdit{ color: white; } " - f"QComboBox{{ min-height: 1.1em; {get_onyx_combobox_style()} }}" + f"QComboBox{{ min-height: 1.1em; border-radius: 5px; {get_onyx_combobox_style()} }}" "QComboBox:hover{ background-color: #6a8a96; } " f"QComboBox QAbstractItemView{{ background-color: {ONYX_COLORS['dark_bg']}; border: 2px solid {ONYX_COLORS['input_bg']}; }} " + scroll_btn_style @@ -131,6 +134,7 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): ) self.addTab(self.audio, QtGui.QIcon(get_icon("onyx-audio", app.fastflix.config.theme)), t("Audio")) self.addTab(self.subtitles, QtGui.QIcon(get_icon("onyx-cc", app.fastflix.config.theme)), t("Subtitles")) + self.addTab(self.data, QtGui.QIcon(get_icon("onyx-data", app.fastflix.config.theme)), t("Data")) self.addTab(self.attachments, QtGui.QIcon(get_icon("onyx-cover", app.fastflix.config.theme)), t("Cover")) self.addTab(self.advanced, QtGui.QIcon(get_icon("onyx-advanced", app.fastflix.config.theme)), t("Advanced")) self.addTab( @@ -218,12 +222,14 @@ def change_conversion(self, conversion, previous_encoder_no_audio=False): self.setCurrentIndex(index) self.setTabEnabled(1, getattr(encoder, "enable_audio", True)) self.setTabEnabled(2, getattr(encoder, "enable_subtitles", True)) - self.setTabEnabled(3, getattr(encoder, "enable_attachments", True)) - self.setTabEnabled(4, getattr(encoder, "enable_advanced", True)) + self.setTabEnabled(3, getattr(encoder, "enable_data", False)) + self.setTabEnabled(4, getattr(encoder, "enable_attachments", True)) + self.setTabEnabled(5, getattr(encoder, "enable_advanced", True)) self.setTabVisible(1, getattr(encoder, "enable_audio", True)) self.setTabVisible(2, getattr(encoder, "enable_subtitles", True)) - self.setTabVisible(3, getattr(encoder, "enable_attachments", True)) - self.setTabVisible(4, getattr(encoder, "enable_advanced", True)) + self.setTabVisible(3, getattr(encoder, "enable_data", False)) + self.setTabVisible(4, getattr(encoder, "enable_attachments", True)) + self.setTabVisible(5, getattr(encoder, "enable_advanced", True)) self.selected = conversion self.current_settings.new_source() self.main.page_update(build_thumbnail=False) @@ -256,6 +262,8 @@ def get_settings(self): if getattr(self.main.current_encoder, "enable_audio", False): self.audio.update_audio_settings() + if getattr(self.main.current_encoder, "enable_data", False): + self.data.get_settings() if getattr(self.main.current_encoder, "enable_attachments", False): self.attachments.update_cover_settings() @@ -276,6 +284,8 @@ def new_source(self): ) if getattr(self.main.current_encoder, "enable_subtitles", False): self.subtitles.new_source() + if getattr(self.main.current_encoder, "enable_data", False): + self.data.new_source() if getattr(self.main.current_encoder, "enable_attachments", False): self.attachments.new_source(self.app.fastflix.current_video.streams.attachment) self.current_settings.new_source() @@ -290,6 +300,8 @@ def refresh(self): self.audio.refresh() if getattr(self.main.current_encoder, "enable_subtitles", False): self.subtitles.refresh() + if getattr(self.main.current_encoder, "enable_data", False): + self.data.refresh() self.advanced.update_settings() # self.main.container.profile.update_settings() @@ -342,11 +354,14 @@ def reload(self): audio_tracks = copy.deepcopy(self.app.fastflix.current_video.audio_tracks or []) subtitle_tracks = copy.deepcopy(self.app.fastflix.current_video.subtitle_tracks or []) attachment_tracks = copy.deepcopy(self.app.fastflix.current_video.attachment_tracks or []) + data_tracks = copy.deepcopy(self.app.fastflix.current_video.data_tracks or []) try: if getattr(self.main.current_encoder, "enable_audio", False): self.audio.reload(audio_tracks, self.audio_formats) if getattr(self.main.current_encoder, "enable_subtitles", False): self.subtitles.reload(subtitle_tracks) + if getattr(self.main.current_encoder, "enable_data", False): + self.data.reload(data_tracks) if getattr(self.main.current_encoder, "enable_attachments", False): self.attachments.reload_from_queue(streams, attachment_tracks) self.advanced.reset(settings=settings) @@ -360,6 +375,7 @@ def clear_tracks(self): # self.current_settings.update_profile() self.audio.remove_all() self.subtitles.remove_all() + self.data.remove_all() self.attachments.clear_covers() self.commands.update_commands([]) self.advanced.reset() diff --git a/tests/conftest.py b/tests/conftest.py index 5658a3b0..f242426a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -269,6 +269,8 @@ def fastflix_instance(sample_audio_tracks, sample_attachment_tracks, sample_subt track.raw_info if hasattr(track, "raw_info") else Box({"index": track.index}) for track in sample_subtitle_tracks ], + "data": [], + "attachment": [], } ), format=Box({}), diff --git a/tests/encoders/test_encc_helpers.py b/tests/encoders/test_encc_helpers.py index 6cef1aab..10eb926a 100644 --- a/tests/encoders/test_encc_helpers.py +++ b/tests/encoders/test_encc_helpers.py @@ -386,8 +386,8 @@ def test_build_subtitle_with_burn_in(sample_subtitle_tracks): assert "--sub-copy" in result and "2,3" in result -def test_build_subtitle_filters_external_tracks(sample_subtitle_tracks): - """Test that build_subtitle filters out external subtitle tracks for rigaya encoders.""" +def test_build_subtitle_with_external_tracks(sample_subtitle_tracks): + """Test that build_subtitle generates --sub-source for external subtitle tracks.""" from fastflix.models.encode import SubtitleTrack # Add an external track @@ -409,11 +409,100 @@ def test_build_subtitle_filters_external_tracks(sample_subtitle_tracks): result = build_subtitle(tracks_with_external, subtitle_streams, 1080) - # External track should not appear in the output - # Only embedded tracks should be processed - assert "french" not in str(result) + # External track should use --sub-source + assert "--sub-source" in result + assert "/path/to/french.srt" in result # Embedded tracks should still be present - assert "--sub-copy" in result or "--vpp-subburn" in result + assert "--sub-copy" in result + + +def test_build_subtitle_external_only(): + """Test build_subtitle with only external tracks.""" + from fastflix.models.encode import SubtitleTrack + + external_track = SubtitleTrack( + index=0, + outdex=0, + language="eng", + subtitle_type="text", + enabled=True, + burn_in=False, + long_name="[EXT] english.srt", + external=True, + file_path="/path/to/english.srt", + ) + + result = build_subtitle([external_track], [], 1080) + + assert "--sub-source" in result + assert "/path/to/english.srt" in result + assert "--sub-copy" not in result + + +def test_build_subtitle_external_burn_in(): + """Test build_subtitle with external track set to burn-in.""" + from fastflix.models.encode import SubtitleTrack + + external_track = SubtitleTrack( + index=0, + outdex=0, + language="eng", + subtitle_type="text", + enabled=True, + burn_in=True, + long_name="[EXT] english.srt", + external=True, + file_path="/path/to/english.srt", + ) + + result = build_subtitle([external_track], [], 1080) + + assert "--vpp-subburn" in result + assert "filename=/path/to/english.srt" in result + assert "--sub-source" not in result + + +def test_build_subtitle_external_burn_in_4k(): + """Test build_subtitle with external burn-in at 4K resolution includes scale.""" + from fastflix.models.encode import SubtitleTrack + + external_track = SubtitleTrack( + index=0, + outdex=0, + language="eng", + subtitle_type="text", + enabled=True, + burn_in=True, + long_name="[EXT] english.srt", + external=True, + file_path="/path/to/english.srt", + ) + + result = build_subtitle([external_track], [], 2160) + + assert "--vpp-subburn" in result + assert "filename=/path/to/english.srt,scale=2.0" in result + + +def test_build_subtitle_external_disabled(): + """Test build_subtitle with disabled external tracks returns empty.""" + from fastflix.models.encode import SubtitleTrack + + external_track = SubtitleTrack( + index=0, + outdex=0, + language="eng", + subtitle_type="text", + enabled=False, + burn_in=False, + long_name="[EXT] english.srt", + external=True, + file_path="/path/to/english.srt", + ) + + result = build_subtitle([external_track], [], 1080) + + assert result == [] def test_build_subtitle_with_4k_scaling(sample_subtitle_tracks): From 19a78bf8c5073b7b3c1c20277c25ea6d1d952100 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 12 Feb 2026 22:35:12 -0600 Subject: [PATCH 04/10] * Fixing #719 Unable to save/load film grain setting for SVT-AV1 (thanks to gabriel101x) --- CHANGES | 3 + fastflix/application.py | 7 + fastflix/data/languages.yaml | 1140 +++++++++++++++++ fastflix/encoders/av1_aom/settings_panel.py | 34 +- fastflix/encoders/common/setting_panel.py | 23 + fastflix/encoders/rav1e/settings_panel.py | 31 +- fastflix/encoders/svt_av1/settings_panel.py | 31 +- .../encoders/vceencc_av1/settings_panel.py | 2 +- .../encoders/vceencc_avc/settings_panel.py | 2 +- .../encoders/vceencc_hevc/settings_panel.py | 2 +- fastflix/encoders/vp9/settings_panel.py | 45 + fastflix/encoders/vvc/settings_panel.py | 37 + fastflix/widgets/video_options.py | 2 +- pyproject.toml | 14 +- tests/conftest.py | 4 + uv.lock | 491 ++++--- 16 files changed, 1591 insertions(+), 277 deletions(-) diff --git a/CHANGES b/CHANGES index 847588be..bc8a36a1 100644 --- a/CHANGES +++ b/CHANGES @@ -11,6 +11,9 @@ * Adding bottom status bar with animated icon showing encoding state, progress bar, and status messages * Adding startup tasks (FFmpeg config, GPU detect, HDR10+ download) running through the status bar with main window visible * Adding Terms and Agreements dialog shown on first startup requiring user acceptance before proceeding +* Fixing #719 Unable to save/load film grain setting for SVT-AV1 (thanks to gabriel101x) +* Fixing profile load/save for VVC period and threads, VP9 auto alt ref, lag in frames, AQ mode, and sharpness, rav1e photon noise, and AOM-AV1 denoise settings using integer as combo box index instead of matching by value +* Fixing VCEEncC pre-analysis lookahead setting reading from wrong widget (pa_initqpsc instead of pa_lookahead) in HEVC, AV1, and AVC encoders * Fixing #716 Maximize button not working (thanks to roxerqermik and 19Battlestar65) * Fixing #349 NVEncC audio conversion losing multichannel layout for EAC3 (thanks to Wontell) * Fixing #384 Remove HDR leaving Dolby Vision metadata traces in Rigaya encoder output (thanks to end2endzone) diff --git a/fastflix/application.py b/fastflix/application.py index 12d33aba..523fe2ca 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -250,6 +250,13 @@ def app_setup( app.setStyleSheet(data) + # On Linux/KDE, applying a custom stylesheet can disrupt the platform + # icon theme for standard dialog icons (e.g., QFileDialog toolbar). + # Re-asserting the icon theme after stylesheet application restores them. + if sys.platform == "linux": + theme_name = QtGui.QIcon.themeName() or "breeze" + QtGui.QIcon.setThemeName(theme_name) + logger.setLevel(app.fastflix.config.logging_level) # Initialize empty encoder/audio lists so Container can be created before startup tasks diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 1266c053..33d0fa0d 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -12807,3 +12807,1143 @@ Ready: ukr: Готово. kor: 준비 ron: Gata +Visual Crop: + eng: Visual Crop + deu: Visueller Ausschnitt + fra: Recadrage visuel + ita: Ritaglio visivo + spa: Recorte visual + jpn: ビジュアル・クロップ + rus: Визуальное обрезание + por: Corte visual + swe: Visuell beskärning + pol: Visual Crop + chs: 视觉裁剪 + ukr: Візуальне обрізання + kor: 시각적 자르기 + ron: Crop vizual +Preview: + eng: Preview + deu: Vorschau + fra: Avant-première + ita: Anteprima + spa: Vista previa + jpn: プレビュー + rus: Предварительный просмотр + por: Pré-visualização + swe: Förhandsgranskning + pol: Podgląd + chs: 预览 + ukr: Попередній перегляд + kor: 미리 보기 + ron: Previzualizare +Set Start Time: + eng: Set Start Time + deu: Startzeit einstellen + fra: Définir l'heure de début + ita: Impostare l'ora di inizio + spa: Hora de inicio + jpn: 開始時間の設定 + rus: Установить время начала + por: Definir hora de início + swe: Ställ in starttid + pol: Ustaw czas rozpoczęcia + chs: 设置开始时间 + ukr: Встановити час запуску + kor: 시작 시간 설정 + ron: Setați ora de începere +Set End Time: + eng: Set End Time + deu: Endzeit einstellen + fra: Définir l'heure de fin + ita: Impostare l'ora di fine + spa: Fijar hora de finalización + jpn: 終了時間の設定 + rus: Установить время окончания + por: Definir hora de fim + swe: Ställ in sluttid + pol: Ustaw czas zakończenia + chs: 设置结束时间 + ukr: Встановити час завершення + kor: 종료 시간 설정 + ron: Setați ora de sfârșit +Close: + eng: Close + deu: Schließen Sie + fra: Fermer + ita: Chiudere + spa: Cerrar + jpn: 閉じる + rus: Закрыть + por: Fechar + swe: Nära + pol: Zamknij + chs: 关闭 + ukr: Закрити + kor: 닫기 + ron: Închidere +Data & Attachments: + eng: Data & Attachments + deu: Daten & Anhänge + fra: Données et pièces jointes + ita: Dati e allegati + spa: Datos y anexos + jpn: データと添付ファイル + rus: Данные и вложения + por: Dados e anexos + swe: Data och bilagor + pol: Dane i załączniki + chs: 数据和附件 + ukr: Дані та додатки + kor: 데이터 및 첨부 파일 + ron: Date și anexe +Data: + eng: Data + deu: Daten + fra: Données + ita: Dati + spa: Datos + jpn: データ + rus: Данные + por: Dados + swe: Uppgifter + pol: Dane + chs: 数据 + ukr: Дані + kor: 데이터 + ron: Date +Attachment: + eng: Attachment + deu: Anlage + fra: Pièce jointe + ita: Allegato + spa: Adjunto + jpn: アタッチメント + rus: Вложение + por: Anexo + swe: Bilaga + pol: Załącznik + chs: 附件 + ukr: Вкладення + kor: 첨부 파일 + ron: Atașament +Attachment streams are not supported in this output format: + eng: Attachment streams are not supported in this output format + deu: Attachment Streams werden in diesem Ausgabeformat nicht unterstützt + fra: Les flux de pièces jointes ne sont pas pris en charge dans ce format de sortie. + ita: I flussi di allegati non sono supportati in questo formato di output. + spa: Este formato de salida no admite flujos adjuntos. + jpn: この出力形式では、添付ストリームはサポートされていません。 + rus: Потоки вложений не поддерживаются в этом формате вывода + por: Os fluxos de anexos não são suportados neste formato de saída + swe: Attachment-strömmar stöds inte i detta utdataformat + pol: Strumienie załączników nie są obsługiwane w tym formacie wyjściowym + chs: 此输出格式不支持附件流 + ukr: Потоки вкладених файлів не підтримуються у цьому форматі виводу + kor: 첨부 파일 스트림은 이 출력 형식에서 지원되지 않습니다. + ron: Fluxurile atașate nu sunt acceptate în acest format de ieșire +Auto-detect external subtitle files: + eng: Auto-detect external subtitle files + deu: Automatische Erkennung von externen Untertiteldateien + fra: Détection automatique des fichiers de sous-titres externes + ita: Rilevamento automatico dei file di sottotitoli esterni + spa: Detección automática de archivos de subtítulos externos + jpn: 外部字幕ファイルの自動検出 + rus: Автоматическое определение внешних файлов субтитров + por: Deteção automática de ficheiros de legendas externos + swe: Automatisk detektering av externa undertextfiler + pol: Automatyczne wykrywanie zewnętrznych plików napisów + chs: 自动检测外部字幕文件 + ukr: Автоматичне визначення зовнішніх файлів субтитрів + kor: 외부 자막 파일 자동 감지 + ron: Detectarea automată a fișierelor externe de subtitrare +Autocrop tried to crop too much: + eng: Autocrop tried to crop too much + deu: Autocrop hat versucht, zu viel zu beschneiden + fra: Autocrop a essayé de trop recadrer + ita: L'autocrop ha cercato di ritagliare troppo + spa: Autocrop intentó recortar demasiado + jpn: オートクロップが過剰にトリミングしようとした + rus: Автокроп пытался обрезать слишком много + por: O corte automático tentou cortar demasiado + swe: Autocrop försökte skörda för mycket + pol: Autocrop próbował przyciąć za dużo + chs: 自动裁剪试图裁剪太多 + ukr: Автопосів намагався засіяти занадто багато + kor: 자동 자르기가 너무 많이 자르려고 했습니다. + ron: Autocrop a încercat să recolteze prea mult +Bad image: + eng: Bad image + deu: Schlechtes Image + fra: Mauvaise image + ita: Immagine negativa + spa: Mala imagen + jpn: 悪いイメージ + rus: Плохое изображение + por: Má imagem + swe: Dålig image + pol: Zły wizerunek + chs: 形象不佳 + ukr: Погане зображення + kor: 나쁜 이미지 + ron: Imagine proastă +Cannot reload encoders while encoding is in progress: + eng: Cannot reload encoders while encoding is in progress + deu: Kodierer können nicht neu geladen werden, während die Kodierung läuft + fra: Impossible de recharger les encodeurs lorsque l'encodage est en cours + ita: Impossibile ricaricare i codificatori mentre è in corso la codifica + spa: No se pueden recargar los codificadores mientras la codificación está en curso. + jpn: エンコード中にエンコーダをリロードできない + rus: Невозможно перезагрузить кодировщики во время выполнения кодирования + por: Não é possível recarregar os codificadores enquanto a codificação está a decorrer + swe: Kan inte ladda om kodare medan kodning pågår + pol: Nie można przeładować koderów, gdy kodowanie jest w toku + chs: 编码过程中无法重新加载编码器 + ukr: Неможливо перезавантажити енкодери під час кодування + kor: 인코딩이 진행되는 동안 인코더를 다시 로드할 수 없습니다. + ron: Nu se pot reîncărca codificatoarele în timp ce codificarea este în curs +Command: + eng: Command + deu: Befehl + fra: Commandement + ita: Comando + spa: Comando + jpn: コマンド + rus: Команда + por: Comando + swe: Kommando + pol: Polecenie + chs: 指挥 + ukr: Командир. + kor: 명령 + ron: Comanda +Could not connect to GitHub to check for newer versions.: + eng: Could not connect to GitHub to check for newer versions. + deu: Es konnte keine Verbindung zu GitHub hergestellt werden, um nach neueren Versionen zu suchen. + fra: Impossible de se connecter à GitHub pour vérifier les nouvelles versions. + ita: Impossibile connettersi a GitHub per verificare la presenza di nuove versioni. + spa: No se ha podido conectar a GitHub para comprobar si hay versiones más recientes. + jpn: 新しいバージョンをチェックするためにGitHubに接続できませんでした。 + rus: Не удалось подключиться к GitHub, чтобы проверить наличие новых версий. + por: Não foi possível conectar-se ao GitHub para verificar se há versões mais recentes. + swe: Det gick inte att ansluta till GitHub för att kontrollera om det finns nyare versioner. + pol: Nie można połączyć się z serwisem GitHub, aby sprawdzić dostępność nowszych wersji. + chs: 无法连接到 GitHub 以检查更新版本。 + ukr: Не вдалося підключитися до GitHub, щоб перевірити наявність нових версій. + kor: 최신 버전을 확인하기 위해 GitHub에 연결할 수 없습니다. + ron: Nu s-a putut conecta la GitHub pentru a verifica dacă există versiuni mai noi. +Could not connect to github to check for newer versions.: + eng: Could not connect to github to check for newer versions. + deu: Konnte keine Verbindung zu Github herstellen, um nach neueren Versionen zu suchen. + fra: Impossible de se connecter à github pour vérifier les nouvelles versions. + ita: Impossibile connettersi a github per verificare la presenza di nuove versioni. + spa: No se ha podido conectar a github para comprobar si hay versiones más recientes. + jpn: 新しいバージョンをチェックするためにgithubに接続できませんでした。 + rus: Не удалось подключиться к github, чтобы проверить наличие новых версий. + por: Não foi possível conectar-se ao github para verificar se há versões mais recentes. + swe: Det gick inte att ansluta till github för att kontrollera om det finns nyare versioner. + pol: Nie można połączyć się z serwisem github, aby sprawdzić dostępność nowszych wersji. + chs: 无法连接到 github 以检查更新版本。 + ukr: Не вдалося підключитися до github, щоб перевірити наявність нових версій. + kor: 최신 버전을 확인하기 위해 깃허브에 연결할 수 없습니다. + ron: Nu s-a putut conecta la github pentru a verifica dacă există versiuni mai noi. +'Could not delete previous temp extract directory: ': + eng: 'Could not delete previous temp extract directory: ' + deu: 'Das vorherige temporäre Extraktverzeichnis konnte nicht gelöscht werden:' + fra: "Impossible de supprimer le répertoire d'extraction temporaire précédent :" + ita: 'Impossibile eliminare la precedente directory di estrazione temporanea:' + spa: 'No se ha podido eliminar el directorio de extracción temporal anterior:' + jpn: 以前の一時解凍ディレクトリを削除できませんでした: + rus: 'Не удалось удалить предыдущий каталог временных распаковок:' + por: 'Não foi possível eliminar o anterior diretório de extração temporário:' + swe: 'Det gick inte att ta bort föregående temp extract-katalog:' + pol: 'Nie można usunąć poprzedniego katalogu temp extract:' + chs: 无法删除先前的临时解压缩目录: + ukr: 'Не вдалося видалити попередній каталог тимчасового розпакування:' + kor: '이전 임시 추출 디렉터리를 삭제할 수 없습니다:' + ron: 'Nu s-a putut șterge directorul anterior de extragere temporară:' +Could not download HDR10+ tool: + eng: Could not download HDR10+ tool + deu: HDR10+ Tool konnte nicht heruntergeladen werden + fra: Impossible de télécharger l'outil HDR10 + ita: Impossibile scaricare lo strumento HDR10+ + spa: No se puede descargar la herramienta HDR10 + jpn: HDR10+ツールをダウンロードできませんでした + rus: Не удалось загрузить инструмент HDR10+ + por: Não foi possível descarregar a ferramenta HDR10 + swe: Det gick inte att ladda ner HDR10+-verktyget + pol: Nie można pobrać narzędzia HDR10+ + chs: 无法下载 HDR10+ 工具 + ukr: Не вдалося завантажити інструмент HDR10+ + kor: HDR10+ 도구를 다운로드할 수 없습니다. + ron: Nu s-a putut descărca instrumentul HDR10+ +Could not download the newest FFmpeg: + eng: Could not download the newest FFmpeg + deu: Konnte das neueste FFmpeg nicht herunterladen + fra: Impossible de télécharger la dernière version de FFmpeg + ita: Impossibile scaricare il nuovo FFmpeg + spa: No se ha podido descargar la última versión de FFmpeg + jpn: 最新のFFmpegをダウンロードできませんでした。 + rus: Не удалось загрузить новейший FFmpeg + por: Não foi possível descarregar o FFmpeg mais recente + swe: Det gick inte att ladda ner den senaste FFmpeg + pol: Nie można pobrać najnowszej wersji FFmpeg + chs: 无法下载最新的 FFmpeg + ukr: Не вдалося завантажити найновіший FFmpeg + kor: 최신 FFmpeg를 다운로드할 수 없습니다. + ron: Nu s-a putut descărca cel mai nou FFmpeg +Could not extract FFmpeg files from: + eng: Could not extract FFmpeg files from + deu: FFmpeg-Dateien konnten nicht extrahiert werden von + fra: Impossible d'extraire les fichiers FFmpeg de + ita: Impossibile estrarre i file FFmpeg da + spa: No se pueden extraer archivos FFmpeg de + jpn: からFFmpegファイルを抽出できませんでした。 + rus: Не удалось извлечь файлы FFmpeg из + por: Não foi possível extrair ficheiros FFmpeg de + swe: Kunde inte extrahera FFmpeg-filer från + pol: Nie udało się wyodrębnić plików FFmpeg z + chs: 无法从以下文件中提取 FFmpeg 文件 + ukr: Не вдалося розпакувати файли FFmpeg з + kor: 다음에서 FFmpeg 파일을 추출할 수 없습니다. + ron: Nu s-a putut extrage fișierele FFmpeg din +Could not extract hdr10plus_tool files from: + eng: Could not extract hdr10plus_tool files from + deu: Die hdr10plus_tool-Dateien konnten nicht extrahiert werden aus + fra: Impossible d'extraire les fichiers hdr10plus_tool de + ita: Impossibile estrarre i file hdr10plus_tool da + spa: No se han podido extraer los archivos hdr10plus_tool de + jpn: からhdr10plus_toolファイルを展開できませんでした。 + rus: Не удалось извлечь файлы hdr10plus_tool из + por: Não foi possível extrair os ficheiros hdr10plus_tool de + swe: Kunde inte extrahera hdr10plus_tool-filer från + pol: Nie udało się wyodrębnić plików hdr10plus_tool z pliku + chs: 无法从以下文件中提取 hdr10plus_tool 文件 + ukr: Не вдалося розпакувати файли hdr10plus_tool з + kor: 다음에서 hdr10plus_tool 파일을 추출할 수 없습니다. + ron: Nu s-a putut extrage fișierele hdr10plus_tool din +Could not find any matching FFmpeg expected patterns, please check: + eng: Could not find any matching FFmpeg expected patterns, please check + deu: Es konnten keine passenden FFmpeg-Muster gefunden werden, bitte überprüfen Sie + fra: Il n'a pas été possible de trouver des modèles correspondants aux attentes de FFmpeg, veuillez vérifier. + ita: Non è stato possibile trovare alcuno schema FFmpeg corrispondente, controllare + spa: No se ha encontrado ningún patrón esperado de FFmpeg que coincida, por favor, compruébelo + jpn: 一致するFFmpeg期待パターンが見つかりませんでした。 + rus: Не удалось найти ни одного подходящего шаблона, ожидаемого FFmpeg, пожалуйста, проверьте + por: Não foi possível encontrar nenhum padrão esperado do FFmpeg, por favor verifique + swe: Kunde inte hitta några matchande FFmpeg förväntade mönster, vänligen kontrollera + pol: Nie znaleziono żadnych pasujących wzorców oczekiwanych przez FFmpeg, sprawdź + chs: 找不到任何匹配的 FFmpeg 预期模式,请检查 + ukr: Не вдалося знайти відповідні шаблони FFmpeg, будь ласка, перевірте + kor: 일치하는 FFmpeg 예상 패턴을 찾을 수 없습니다. + ron: Nu a putut fi găsit niciun model FFmpeg așteptat, vă rugăm să verificați +Could not find any matching expected patterns, please check: + eng: Could not find any matching expected patterns, please check + deu: Es konnten keine passenden erwarteten Muster gefunden werden, bitte überprüfen Sie + fra: Il n'a pas été possible de trouver les motifs attendus correspondants, veuillez vérifier. + ita: Non è stato possibile trovare alcun modello corrispondente a quello previsto, verificare + spa: No se ha encontrado ningún patrón que coincida con los esperados, por favor, compruébelo + jpn: 一致するパターンが見つかりませんでした。 + rus: Не удалось найти ни одного подходящего шаблона, пожалуйста, проверьте + por: Não foi possível encontrar nenhum padrão esperado correspondente, verifique + swe: Kunde inte hitta några matchande förväntade mönster, vänligen kontrollera + pol: Nie znaleziono pasujących oczekiwanych wzorców, sprawdź + chs: 找不到匹配的预期模式,请检查 + ukr: Не вдалося знайти відповідні шаблони, будь ласка, перевірте + kor: 예상 패턴과 일치하는 패턴을 찾을 수 없습니다. + ron: Nu a putut fi găsit niciun model așteptat corespunzător, vă rugăm să verificați +Could not find language for: + eng: Could not find language for + deu: Konnte keine Sprache finden für + fra: Impossible de trouver la langue pour + ita: Impossibile trovare la lingua per + spa: No se ha podido encontrar el idioma para + jpn: の言語が見つからなかった。 + rus: Не удалось найти язык для + por: Não foi possível encontrar a língua para + swe: Kunde inte hitta språk för + pol: Nie można znaleźć języka dla + chs: 找不到 + ukr: Не могли знайти мову для + kor: 언어를 찾을 수 없습니다. + ron: Nu s-a putut găsi limba pentru +Could not find the executable in the extracted files: + eng: Could not find the executable in the extracted files + deu: Die ausführbare Datei konnte in den extrahierten Dateien nicht gefunden werden + fra: Impossible de trouver l'exécutable dans les fichiers extraits + ita: Impossibile trovare l'eseguibile nei file estratti + spa: No se ha podido encontrar el ejecutable en los archivos extraídos + jpn: 解凍したファイルの中に実行ファイルが見つかりませんでした。 + rus: Не удалось найти исполняемый файл в распакованных файлах + por: Não foi possível encontrar o executável nos ficheiros extraídos + swe: Kunde inte hitta den körbara filen i de extraherade filerna + pol: Nie można znaleźć pliku wykonywalnego w wyodrębnionych plikach + chs: 无法在提取的文件中找到可执行文件 + ukr: Не вдалося знайти виконуваний файл у розпакованих файлах + kor: 압축을 푼 파일에서 실행 파일을 찾을 수 없습니다. + ron: Nu s-a putut găsi executabilul în fișierele extrase +Could not locate the downloaded HDR10+ tool: + eng: Could not locate the downloaded HDR10+ tool + deu: Das heruntergeladene HDR10+-Tool konnte nicht gefunden werden. + fra: Impossible de localiser l'outil HDR10+ téléchargé + ita: Impossibile individuare lo strumento HDR10+ scaricato + spa: No se ha podido localizar la herramienta HDR10+ descargada + jpn: ダウンロードしたHDR10+ツールが見つかりません。 + rus: Не удалось найти загруженный инструмент HDR10+ + por: Não foi possível localizar a ferramenta HDR10+ descarregada + swe: Det gick inte att hitta det nedladdade HDR10+-verktyget + pol: Nie można zlokalizować pobranego narzędzia HDR10+ + chs: 无法找到下载的 HDR10+ 工具 + ukr: Не вдалося знайти завантажений інструмент HDR10+ + kor: 다운로드한 HDR10+ 도구를 찾을 수 없습니다. + ron: Nu s-a putut localiza instrumentul HDR10+ descărcat +Could not locate the downloaded files at: + eng: Could not locate the downloaded files at + deu: Die heruntergeladenen Dateien konnten nicht gefunden werden unter + fra: Impossible de localiser les fichiers téléchargés à l'adresse + ita: Impossibile individuare i file scaricati in + spa: No se han podido localizar los archivos descargados en + jpn: にダウンロードしたファイルが見つかりません。 + rus: Не удалось обнаружить загруженные файлы по адресу + por: Não foi possível localizar os ficheiros descarregados em + swe: Det gick inte att hitta de nedladdade filerna på + pol: Nie można zlokalizować pobranych plików pod adresem + chs: 无法在 + ukr: Не вдалося знайти завантажені файли за адресою + kor: 다운로드한 파일을 다음 위치에서 찾을 수 없습니다. + ron: Nu am putut localiza fișierele descărcate la adresa +Could not update Rigaya's encoders: + eng: Could not update Rigaya's encoders + deu: Rigayas Geber konnten nicht aktualisiert werden + fra: Impossible de mettre à jour les encodeurs de Rigaya + ita: Impossibile aggiornare gli encoder di Rigaya + spa: No se pudieron actualizar los codificadores de Rigaya + jpn: リガヤのエンコーダーを更新できなかった + rus: Не удалось обновить кодеры Rigaya + por: Não foi possível atualizar os codificadores de Rigaya + swe: Kunde inte uppdatera Rigayas kodare + pol: Nie można zaktualizować koderów Rigaya + chs: 无法更新 Rigaya 编码器 + ukr: Не вдалося оновити енкодери Rigaya + kor: 리가야의 인코더를 업데이트할 수 없습니다. + ron: Nu s-a putut actualiza codificatoarele Rigaya +Custom Filters: + eng: Custom Filters + deu: Benutzerdefinierte Filter + fra: Filtres personnalisés + ita: Filtri personalizzati + spa: Filtros personalizados + jpn: カスタムフィルター + rus: Пользовательские фильтры + por: Filtros personalizados + swe: Anpassade filter + pol: Filtry niestandardowe + chs: 自定义过滤器 + ukr: Користувацькі фільтри + kor: 사용자 지정 필터 + ron: Filtre personalizate +Data streams are not supported in this output format: + eng: Data streams are not supported in this output format + deu: Datenströme werden in diesem Ausgabeformat nicht unterstützt + fra: Les flux de données ne sont pas pris en charge dans ce format de sortie + ita: I flussi di dati non sono supportati in questo formato di uscita. + spa: No se admiten flujos de datos en este formato de salida + jpn: この出力フォーマットでは、データストリームはサポートされていません。 + rus: Потоки данных не поддерживаются в этом формате вывода + por: Os fluxos de dados não são suportados neste formato de saída + swe: Dataströmmar stöds inte i detta utdataformat + pol: Strumienie danych nie są obsługiwane w tym formacie wyjściowym + chs: 此输出格式不支持数据流 + ukr: Потоки даних не підтримуються у цьому форматі виводу + kor: 이 출력 형식에서는 데이터 스트림이 지원되지 않습니다. + ron: Fluxurile de date nu sunt acceptate în acest format de ieșire +Downloading HDR10+ Tool: + eng: Downloading HDR10+ Tool + deu: Herunterladen des HDR10+ Tools + fra: Téléchargement de l'outil HDR10 + ita: Scaricare lo strumento HDR10+ + spa: Descarga de la herramienta HDR10 + jpn: HDR10+ツールのダウンロード + rus: Загрузка инструмента HDR10+ + por: Descarregar a ferramenta HDR10 + swe: Ladda ner HDR10+-verktyget + pol: Pobieranie narzędzia HDR10+ + chs: 下载 HDR10+ 工具 + ukr: Завантаження HDR10+ Tool + kor: HDR10+ 도구 다운로드 + ron: Descărcarea instrumentului HDR10+ +Encoding cancelled: + eng: Encoding cancelled + deu: Kodierung abgebrochen + fra: Encodage annulé + ita: Codifica annullata + spa: Codificación anulada + jpn: エンコード中止 + rus: Кодирование отменено + por: Codificação anulada + swe: Kodning avbruten + pol: Kodowanie anulowane + chs: 编码取消 + ukr: Кодування скасовано + kor: 인코딩 취소됨 + ron: Codare anulată +Encoding error: + eng: Encoding error + deu: Kodierungsfehler + fra: Erreur d'encodage + ita: Errore di codifica + spa: Error de codificación + jpn: エンコードエラー + rus: Ошибка кодирования + por: Erro de codificação + swe: Kodningsfel + pol: Błąd kodowania + chs: 编码错误 + ukr: Помилка кодування + kor: 인코딩 오류 + ron: Eroare de codare +Error while moving files in: + eng: Error while moving files in + deu: Fehler beim Verschieben von Dateien in + fra: Erreur lors du déplacement de fichiers dans + ita: Errore durante lo spostamento dei file in + spa: Error al mover archivos en + jpn: のファイル移動中にエラーが発生しました。 + rus: Ошибка при перемещении файлов в + por: Erro ao mover ficheiros em + swe: Fel vid flyttning av filer i + pol: Błąd podczas przenoszenia plików w + chs: 将文件移入 + ukr: Помилка під час переміщення файлів + kor: 파일 이동 중 오류 발생 + ron: Eroare în timpul mutării fișierelor în +FFmpeg downloaded successfully: + eng: FFmpeg downloaded successfully + deu: FFmpeg erfolgreich heruntergeladen + fra: FFmpeg téléchargé avec succès + ita: FFmpeg è stato scaricato con successo + spa: FFmpeg se ha descargado correctamente + jpn: FFmpegのダウンロードに成功 + rus: FFmpeg успешно загружен + por: FFmpeg descarregado com sucesso + swe: FFmpeg nedladdad framgångsrikt + pol: FFmpeg pobrany pomyślnie + chs: FFmpeg 下载成功 + ukr: FFmpeg успішно завантажено + kor: FFmpeg 다운로드 성공 + ron: FFmpeg descărcat cu succes +FFmpeg not found: + eng: FFmpeg not found + deu: FFmpeg nicht gefunden + fra: FFmpeg introuvable + ita: FFmpeg non trovato + spa: FFmpeg no encontrado + jpn: FFmpegが見つからない + rus: FFmpeg не найден + por: FFmpeg não encontrado + swe: FFmpeg hittades inte + pol: Nie znaleziono FFmpeg + chs: 未找到 FFmpeg + ukr: FFmpeg не знайдено + kor: FFmpeg를 찾을 수 없음 + ron: FFmpeg nu a fost găsit +FFmpeg was not properly downloaded as the file size is too small: + eng: FFmpeg was not properly downloaded as the file size is too small + deu: FFmpeg wurde nicht richtig heruntergeladen, da die Datei zu klein ist + fra: FFmpeg n'a pas été correctement téléchargé car la taille du fichier est trop petite. + ita: FFmpeg non è stato scaricato correttamente perché le dimensioni del file sono troppo ridotte. + spa: FFmpeg no se ha descargado correctamente porque el tamaño del archivo es demasiado pequeño. + jpn: ファイルサイズが小さすぎるため、FFmpegが正しくダウンロードされませんでした。 + rus: FFmpeg не удалось загрузить должным образом, так как размер файла слишком мал + por: O FFmpeg não foi descarregado corretamente porque o tamanho do ficheiro é demasiado pequeno + swe: FFmpeg laddades inte ner ordentligt eftersom filstorleken är för liten + pol: FFmpeg nie został poprawnie pobrany, ponieważ rozmiar pliku jest zbyt mały + chs: FFmpeg 无法正确下载,因为文件太小 + ukr: FFmpeg не вдалося завантажити належним чином, оскільки розмір файлу замалий + kor: 파일 크기가 너무 작아서 FFmpeg가 제대로 다운로드되지 않았습니다. + ron: FFmpeg nu a fost descărcat corect deoarece dimensiunea fișierului este prea mică +Failed to reload encoders: + eng: Failed to reload encoders + deu: Nachladen der Encoder fehlgeschlagen + fra: Échec du rechargement des codeurs + ita: Impossibile ricaricare gli encoder + spa: Fallo al recargar codificadores + jpn: エンコーダのリロードに失敗 + rus: Не удалось перезагрузить энкодеры + por: Falha ao recarregar os codificadores + swe: Misslyckades med att ladda om pulsgivarna + pol: Nie udało się przeładować enkoderów + chs: 重新加载编码器失败 + ukr: Не вдалося перезавантажити енкодери + kor: 인코더를 다시 로드하지 못함 + ron: A eșuat reîncărcarea codificatoarelor +First Pass: + eng: First Pass + deu: Erster Durchgang + fra: Premier passage + ita: Primo passaggio + spa: Primer pase + jpn: ファーストパス + rus: Первый проход + por: Primeira passagem + swe: Första passet + pol: Pierwsze przejście + chs: 第一次通过 + ukr: Перший прохід + kor: 첫 번째 패스 + ron: Prima trecere +Font: + eng: Font + deu: Schriftart + fra: Police + ita: Carattere + spa: Fuente + jpn: フォント + rus: Шрифт + por: Tipo de letra + swe: Typsnitt + pol: Czcionka + chs: 字体 + ukr: Шрифт + kor: 글꼴 + ron: Font +HDR10+ detected but requires FFmpeg 8.0+ for AV1 passthrough: + eng: HDR10+ detected but requires FFmpeg 8.0+ for AV1 passthrough + deu: HDR10+ wird erkannt, erfordert aber FFmpeg 8.0+ für AV1-Passthrough + fra: HDR10+ détecté mais nécessite FFmpeg 8.0+ pour le passthrough AV1 + ita: HDR10+ rilevato ma richiede FFmpeg 8.0+ per il passthrough AV1 + spa: Se detecta HDR10+ pero requiere FFmpeg 8.0+ para el paso a AV1 + jpn: HDR10+は検出されたが、AV1パススルーにはFFmpeg 8.0+が必要。 + rus: HDR10+ обнаружен, но требуется FFmpeg 8.0+ для AV1 passthrough + por: HDR10+ detectado mas requer FFmpeg 8.0+ para passagem AV1 + swe: HDR10+ detekteras men kräver FFmpeg 8.0+ för AV1-passthrough + pol: HDR10+ wykryty, ale wymaga FFmpeg 8.0+ dla AV1 passthrough + chs: 检测到 HDR10+,但需要 FFmpeg 8.0+ 实现 AV1 直通 + ukr: Визначено HDR10+, але для проходження AV1 потрібен FFmpeg 8.0+ + kor: HDR10+가 감지되었지만 AV1 패스스루를 위해서는 FFmpeg 8.0+가 필요합니다. + ron: HDR10+ detectat, dar necesită FFmpeg 8.0+ pentru AV1 passthrough +HDR10+ tool has been downloaded to: + eng: HDR10+ tool has been downloaded to + deu: Das Tool HDR10+ wurde heruntergeladen auf + fra: L'outil HDR10+ a été téléchargé sur + ita: Lo strumento HDR10+ è stato scaricato su + spa: La herramienta HDR10+ se ha descargado en + jpn: HDR10+ツールがダウンロードされました。 + rus: Инструмент HDR10+ был загружен в + por: A ferramenta HDR10+ foi descarregada para + swe: HDR10+-verktyget har laddats ner till + pol: Narzędzie HDR10+ zostało pobrane do + chs: HDR10+ 工具已下载到 + ukr: Інструмент HDR10+ було завантажено на + kor: HDR10+ 도구가 다운로드되었습니다. + ron: Instrumentul HDR10+ a fost descărcat la +? "HDR10+ tool not found. Do you want FastFlix to automatically download it?\n\nThis tool is used for extracting and injecting HDR10+ dynamic metadata during encoding." +: eng: "HDR10+ tool not found. Do you want FastFlix to automatically download it?\n\nThis tool is used for extracting and injecting HDR10+ dynamic metadata during encoding." + deu: "HDR10+ Tool nicht gefunden. Möchten Sie, dass FastFlix es automatisch herunterlädt?\n\nDieses Tool wird zum Extrahieren und Einfügen von dynamischen HDR10+-Metadaten während der Codierung verwendet." + fra: "L'outil HDR10+ n'a pas été trouvé. Voulez-vous que FastFlix le télécharge automatiquement ?\n\nCet outil est utilisé pour extraire et injecter des métadonnées dynamiques HDR10+ pendant l'encodage." + ita: "Strumento HDR10+ non trovato. Volete che FastFlix lo scarichi automaticamente?\n\nQuesto strumento viene utilizzato per estrarre e inserire i metadati dinamici HDR10+ durante la codifica." + spa: "No se ha encontrado la herramienta HDR10+. ¿Desea que FastFlix la descargue automáticamente?\n\nEsta herramienta se utiliza para extraer e inyectar metadatos dinámicos HDR10+ durante la codificación." + jpn: "HDR10+ツールが見つかりません。FastFlixに自動的にダウンロードさせますか?\n\nこのツールは、エンコード中にHDR10+ダイナミックメタデータを抽出・注入するために使用します。" + rus: "Инструмент HDR10+ не найден. Хотите, чтобы FastFlix автоматически загрузил его?\n\nЭтот инструмент используется для извлечения и вставки динамических метаданных HDR10+ во время кодирования." + por: "Ferramenta HDR10+ não encontrada. Quer que o FastFlix a descarregue automaticamente?\n\nEsta ferramenta é usada para extrair e injetar metadados dinâmicos HDR10+ durante a codificação." + swe: "HDR10+ verktyg hittades inte. Vill du att FastFlix automatiskt ska ladda ner det?\n\nDetta verktyg används för att extrahera och injicera HDR10+ dynamiska metadata under kodning." + pol: "Nie znaleziono narzędzia HDR10+. Czy chcesz, aby FastFlix automatycznie je pobrał?\n\nNarzędzie to służy do wyodrębniania i wstawiania dynamicznych metadanych HDR10+ podczas kodowania." + chs: "未找到 HDR10+ 工具。您想让 FastFlix 自动下载吗?\n\n该工具用于在编码过程中提取和注入 HDR10+ 动态元数据。" + ukr: "Інструмент HDR10+ не знайдено. Ви хочете, щоб FastFlix автоматично завантажив його?\n\nЦей інструмент використовується для вилучення та введення динамічних метаданих HDR10+ під час кодування." + kor: "HDR10+ 도구를 찾을 수 없습니다. FastFlix가 자동으로 다운로드하도록 하시겠습니까?\n\n이 도구는 인코딩 중에 HDR10+ 동적 메타데이터를 추출하고 삽입하는 데 사용됩니다." + ron: "Ferramenta HDR10+ não encontrada. Quer que o FastFlix a descarregue automaticamente?\n\nEsta ferramenta é usada para extrair e injetar metadados dinâmicos HDR10+ durante a codificação." +Height must be smaller than video height: + eng: Height must be smaller than video height + deu: Höhe muss kleiner als die Videohöhe sein + fra: La hauteur doit être inférieure à celle de la vidéo + ita: L'altezza deve essere inferiore a quella del video + spa: La altura debe ser inferior a la altura del vídeo + jpn: 高さはビデオの高さより小さくなければならない + rus: Высота должна быть меньше высоты видео + por: A altura deve ser inferior à altura do vídeo + swe: Höjden måste vara mindre än videohöjden + pol: Wysokość musi być mniejsza niż wysokość wideo + chs: 高度必须小于视频高度 + ukr: Висота повинна бути меншою за висоту відео + kor: 높이는 동영상 높이보다 작아야 합니다. + ron: Înălțimea trebuie să fie mai mică decât înălțimea videoclipului +Left must be positive number: + eng: Left must be positive number + deu: Links muss eine positive Zahl sein + fra: La gauche doit être un nombre positif + ita: La sinistra deve essere un numero positivo + spa: Izquierda debe ser un número positivo + jpn: 左は正の数でなければならない + rus: Слева должно быть положительное число + por: A esquerda deve ser um número positivo + swe: Vänster måste vara ett positivt tal + pol: Left musi być liczbą dodatnią + chs: 左数必须是正数 + ukr: Зліва повинно бути додатнім числом + kor: 왼쪽은 양수여야 합니다. + ron: Stânga trebuie să fie un număr pozitiv +Missing dependencies for PGS OCR: + eng: Missing dependencies for PGS OCR + deu: Fehlende Abhängigkeiten für PGS OCR + fra: Dépendances manquantes pour PGS OCR + ita: Dipendenze mancanti per PGS OCR + spa: Faltan dependencias para PGS OCR + jpn: PGS OCRの欠落している依存関係 + rus: Отсутствующие зависимости для PGS OCR + por: Dependências em falta para o PGS OCR + swe: Saknade beroenden för PGS OCR + pol: Brakujące zależności dla PGS OCR + chs: PGS OCR 缺少依赖项 + ukr: Відсутні залежності для PGS OCR + kor: PGS OCR에 대한 누락된 종속성 + ron: Dependențe lipsă pentru PGS OCR +'Missing dependencies: tesseract or pgsrip': + eng: 'Missing dependencies: tesseract or pgsrip' + deu: 'Fehlende Abhängigkeiten: tesseract oder pgsrip' + fra: 'Dépendances manquantes : tesseract ou pgsrip' + ita: 'Dipendenze mancanti: tesseract o pgsrip' + spa: 'Dependencias que faltan: tesseract o pgsrip' + jpn: '欠けている依存関係: tesseract または pgsrip' + rus: 'Отсутствующие зависимости: tesseract или pgsrip' + por: 'Dependências em falta: tesseract ou pgsrip' + swe: 'Beroenden som saknas: tesseract eller pgsrip' + pol: 'Brakujące zależności: tesseract lub pgsrip' + chs: 缺少依赖项:tesseract 或 pgsrip + ukr: 'Відсутні залежності: tesseract або pgsrip' + kor: '누락된 종속성: 테서랙트 또는 pgsrip' + ron: 'Dependențe lipsă: tesseract sau pgsrip' +NVEnc was not properly downloaded as the file size is too small: + eng: NVEnc was not properly downloaded as the file size is too small + deu: NVEnc wurde nicht richtig heruntergeladen, da die Datei zu klein ist + fra: NVEnc n'a pas été correctement téléchargé car la taille du fichier est trop petite. + ita: NVEnc non è stato scaricato correttamente perché le dimensioni del file sono troppo piccole + spa: NVEnc no se ha descargado correctamente porque el tamaño del archivo es demasiado pequeño. + jpn: ファイルサイズが小さすぎるため、NVEncが正しくダウンロードされませんでした。 + rus: NVEnc не удалось загрузить должным образом, так как размер файла слишком мал + por: O NVEnc não foi descarregado corretamente porque o tamanho do ficheiro é demasiado pequeno + swe: NVEnc laddades inte ner ordentligt eftersom filstorleken är för liten + pol: Plik NVEnc nie został pobrany poprawnie, ponieważ był zbyt mały + chs: 由于文件太小,无法正确下载 NVEnc + ukr: NVEnc не вдалося завантажити належним чином, оскільки розмір файлу занадто малий + kor: 파일 크기가 너무 작아서 NVEnc가 제대로 다운로드되지 않았습니다. + ron: NVEnc nu a fost descărcat corect deoarece dimensiunea fișierului este prea mică +Not deinterlacing will result in banding after encoding.: + eng: Not deinterlacing will result in banding after encoding. + deu: Ohne Deinterlacing kommt es nach der Kodierung zu Banding. + fra: L'absence de désentrelacement entraînera un effet de bande après l'encodage. + ita: Se non si esegue il deinterlacciamento, dopo la codifica si verifica il banding. + spa: Si no se desentrelaza, se producirán bandas después de la codificación. + jpn: デインターレースをしないと、エンコード後にバンディングが発生する。 + rus: Отсутствие деинтерлейсинга приведет к появлению полос после кодирования. + por: A ausência de desentrelaçamento resultará em bandas após a codificação. + swe: Om deinterlacing inte används uppstår bandning efter kodning. + pol: Brak usuwania przeplotu spowoduje powstawanie pasm po zakodowaniu. + chs: 不去掉隔行扫描会导致编码后出现带状。 + ukr: Якщо не зняти чересстрочність, це призведе до появи смуг після кодування. + kor: 디인터레이싱을 하지 않으면 인코딩 후 밴딩이 발생합니다. + ron: Neinterlațarea va duce la banding după codare. +OCR conversion failed, kept .sup file: + eng: OCR conversion failed, kept .sup file + deu: OCR-Konvertierung fehlgeschlagen, .sup-Datei beibehalten + fra: La conversion OCR a échoué, le fichier .sup a été conservé + ita: Conversione OCR fallita, mantenuto il file .sup + spa: Error de conversión OCR, archivo .sup conservado + jpn: OCR変換に失敗し、.supファイルを保持 + rus: Преобразование OCR не удалось, сохранился файл .sup + por: A conversão de OCR falhou, manteve o ficheiro .sup + swe: OCR-konvertering misslyckades, behöll .sup-fil + pol: Konwersja OCR nie powiodła się, zachowano plik .sup + chs: OCR 转换失败,保留 .sup 文件 + ukr: OCR-перетворення не вдалося, збережено файл .sup + kor: OCR 변환 실패, .sup 파일 유지 + ron: Conversia OCR a eșuat, a păstrat fișierul .sup +Output video path is same as source!: + eng: Output video path is same as source! + deu: Der Videoausgangspfad ist derselbe wie die Quelle! + fra: Le chemin vidéo de sortie est le même que celui de la source ! + ita: Il percorso video di uscita è lo stesso della sorgente! + spa: La ruta de vídeo de salida es la misma que la de la fuente. + jpn: 出力ビデオパスはソースと同じです! + rus: Выходной видеотракт совпадает с исходным! + por: O caminho do vídeo de saída é o mesmo da fonte! + swe: Utgående videosökväg är samma som källan! + pol: Wyjściowa ścieżka wideo jest taka sama jak źródłowa! + chs: 输出视频路径与信号源相同! + ukr: Вихідний відеошлях такий самий, як і вихідний! + kor: 출력 비디오 경로가 소스와 동일합니다! + ron: Calea video de ieșire este aceeași ca și sursa! +PGS OCR setup instructions: + eng: PGS OCR setup instructions + deu: PGS OCR-Einrichtungsanweisungen + fra: Instructions de configuration de l'OCR PGS + ita: Istruzioni per l'impostazione di PGS OCR + spa: Instrucciones de configuración de PGS OCR + jpn: PGS OCRセットアップ手順 + rus: Инструкции по настройке PGS OCR + por: Instruções de configuração do PGS OCR + swe: Instruktioner för installation av PGS OCR + pol: Instrukcje konfiguracji PGS OCR + chs: PGS OCR 设置说明 + ukr: Інструкція з налаштування PGS OCR + kor: PGS OCR 설정 지침 + ron: Instrucțiuni de configurare PGS OCR +Please: + eng: Please + deu: Bitte + fra: S'il vous plaît + ita: Per favore + spa: Por favor, + jpn: お願い + rus: Пожалуйста, + por: Por favor + swe: Vänligen + pol: Proszę + chs: 请 + ukr: Будь ласка. + kor: 제발 + ron: Vă rog +Please provide bitrates for the audio streams: + eng: Please provide bitrates for the audio streams + deu: Bitte geben Sie die Bitraten für die Audiostreams an + fra: Veuillez fournir les débits binaires pour les flux audio + ita: Fornire il bitrate dei flussi audio + spa: Indique la velocidad de bits de los flujos de audio + jpn: オーディオストリームのビットレートを教えてください。 + rus: Пожалуйста, укажите битрейт аудиопотоков + por: Forneça as taxas de bits para os fluxos de áudio + swe: Vänligen ange bitrate för ljudströmmarna + pol: Podaj bitrate dla strumieni audio + chs: 请提供音频流的比特率 + ukr: Будь ласка, вкажіть бітрейт для аудіопотоків + kor: 오디오 스트림의 비트레이트를 입력하세요. + ron: Vă rugăm să furnizați bitrate pentru fluxurile audio +Please specify output video: + eng: Please specify output video + deu: Bitte geben Sie das Ausgangsvideo an + fra: Veuillez spécifier la vidéo de sortie + ita: Specificare il video in uscita + spa: Especifique el vídeo de salida + jpn: 出力ビデオを指定してください + rus: Пожалуйста, укажите выходное видео + por: Especificar o vídeo de saída + swe: Vänligen ange utmatningsvideo + pol: Należy określić wyjściowy sygnał wideo + chs: 请指定输出视频 + ukr: Будь ласка, вкажіть вихідне відео + kor: 출력 비디오를 지정하세요. + ron: Vă rugăm să specificați ieșirea video +Remove: + eng: Remove + deu: entfernen + fra: Retirer + ita: Rimuovere + spa: Eliminar + jpn: 削除 + rus: Удалить + por: Remover + swe: Ta bort + pol: Usunąć + chs: 移除 + ukr: Видалити + kor: 제거 + ron: Eliminați +Rigaya's encoders updated: + eng: Rigaya's encoders updated + deu: Rigayas Geber aktualisiert + fra: Mise à jour des encodeurs de Rigaya + ita: Aggiornamento dei codificatori di Rigaya + spa: Actualización de los codificadores de Rigaya + jpn: リガヤのエンコーダーが更新 + rus: Обновление кодировщиков Rigaya + por: Atualização dos codificadores de Rigaya + swe: Rigayas pulsgivare uppdaterade + pol: Zaktualizowano kodery Rigaya + chs: 更新里加亚编码器 + ukr: Оновлені енкодери Rigaya + kor: 리가야의 인코더 업데이트 + ron: Codificatoarele Rigaya actualizate +Second Pass: + eng: Second Pass + deu: Zweiter Durchgang + fra: Deuxième passage + ita: Secondo passaggio + spa: Segundo pase + jpn: セカンドパス + rus: Второе прохождение + por: Segunda passagem + swe: Andra passet + pol: Drugi przejazd + chs: 第二次通过 + ukr: Другий прохід + kor: 두 번째 패스 + ron: A doua trecere +Select Subtitle File: + eng: Select Subtitle File + deu: Untertiteldatei auswählen + fra: Sélectionner le fichier de sous-titres + ita: Selezionare il file dei sottotitoli + spa: Seleccionar archivo de subtítulos + jpn: 字幕ファイルを選択 + rus: Выберите файл субтитров + por: Selecionar ficheiro de legendas + swe: Välj undertextfil + pol: Wybierz plik napisów + chs: 选择字幕文件 + ukr: Виберіть файл субтитрів + kor: 자막 파일 선택 + ron: Selectați fișierul de subtitrare +There is an existing burn-in track, only one can be enabled at a time: + eng: There is an existing burn-in track, only one can be enabled at a time + deu: Es gibt eine vorhandene Einbrennspur, es kann jeweils nur eine aktiviert werden + fra: Il y a une piste de déverminage existante, une seule peut être activée à la fois. + ita: Esiste una traccia di burn-in, ma è possibile attivarne solo una alla volta. + spa: Existe una pista de quemado, sólo se puede activar una a la vez + jpn: 既存のバーンイントラックがあり、一度に1つしか有効にできない。 + rus: Имеется существующая дорожка прожига, одновременно может быть включена только одна. + por: Existe uma pista de burn-in, mas só pode ser activada uma de cada vez + swe: Det finns ett befintligt inbränningsspår, men endast ett kan aktiveras åt gången + pol: Istnieje ścieżka wypalania, tylko jedna może być włączona w danym momencie. + chs: 现有一条预烧轨道,每次只能启用一条 + ukr: Існує існуюча доріжка вигоряння, одночасно може бути ввімкнена лише одна + kor: 기존 번인 트랙이 있으며, 한 번에 하나만 활성화할 수 있습니다. + ron: Există o cale de ardere existentă, numai una poate fi activată simultan +This video has been detected to have an interlaced video.: + eng: This video has been detected to have an interlaced video. + deu: Dieses Video wurde als Zeilensprungvideo erkannt. + fra: Cette vidéo a été détectée comme étant une vidéo entrelacée. + ita: È stato rilevato che questo video è interlacciato. + spa: Se ha detectado que este vídeo está entrelazado. + jpn: このビデオはインターレースビデオとして検出されました。 + rus: Было обнаружено, что это видео имеет чересстрочную развертку. + por: Foi detectado que este vídeo tem um vídeo entrelaçado. + swe: Den här videon har upptäckts ha en interlaced video. + pol: Wykryto, że to wideo ma przeplot. + chs: 检测到此视频为隔行扫描视频。 + ukr: Виявлено, що це відео має чересстрочний формат. + kor: 이 동영상에 인터레이스된 동영상이 있는 것으로 감지되었습니다. + ron: Acest videoclip a fost detectat ca având un videoclip întrepătruns. +Top must be positive number: + eng: Top must be positive number + deu: Oben muss eine positive Zahl sein + fra: Le sommet doit être un nombre positif + ita: Top deve essere un numero positivo + spa: Top debe ser un número positivo + jpn: トップは正の数でなければならない + rus: Top должно быть положительным числом + por: O topo deve ser um número positivo + swe: Top måste vara ett positivt tal + pol: Top musi być liczbą dodatnią + chs: 顶部必须是正数 + ukr: Верхня частина має бути додатнім числом + kor: 상단은 양수여야 합니다. + ron: Topul trebuie să fie un număr pozitiv +Total video width must be greater than 0: + eng: Total video width must be greater than 0 + deu: Die Gesamtbreite des Videos muss größer als 0 sein. + fra: La largeur totale de la vidéo doit être supérieure à 0 + ita: La larghezza totale del video deve essere maggiore di 0 + spa: La anchura total del vídeo debe ser superior a 0 + jpn: ビデオ幅の合計は0より大きくなければならない + rus: Общая ширина видео должна быть больше 0 + por: A largura total do vídeo deve ser superior a 0 + swe: Total videobredd måste vara större än 0 + pol: Całkowita szerokość wideo musi być większa niż 0 + chs: 视频总宽度必须大于 0 + ukr: Загальна ширина відео повинна бути більшою за 0 + kor: 총 동영상 너비는 0보다 커야 합니다. + ron: Lățimea video totală trebuie să fie mai mare decât 0 +Updating Rigaya's encoders: + eng: Updating Rigaya's encoders + deu: Aktualisierung der Rigaya-Geber + fra: Mise à jour des encodeurs de Rigaya + ita: Aggiornamento degli encoder di Rigaya + spa: Actualización de los codificadores de Rigaya + jpn: リガヤのエンコーダーをアップデート + rus: Обновление кодеров Rigaya + por: Atualização dos codificadores Rigaya + swe: Uppdatering av Rigayas kodare + pol: Aktualizacja enkoderów Rigaya + chs: 更新 Rigaya 编码器 + ukr: Оновлення енкодерів Rigaya + kor: 리가야의 인코더 업데이트 + ron: Actualizarea codificatoarelor Rigaya +Waiting for current encode to finish before shutdown: + eng: Waiting for current encode to finish before shutdown + deu: Warten auf das Ende der aktuellen Kodierung vor dem Herunterfahren + fra: Attente de la fin de l'encodage en cours avant l'arrêt + ita: Attendere che la codifica in corso sia terminata prima di spegnersi + spa: Esperar a que finalice la codificación actual antes del apagado + jpn: シャットダウン前に現在のエンコードが終了するのを待つ + rus: Ожидание завершения текущего кодирования перед выключением + por: Aguardar que a codificação atual termine antes de encerrar + swe: Väntar på att aktuell kodning ska avslutas innan avstängning + pol: Oczekiwanie na zakończenie bieżącego kodowania przed wyłączeniem + chs: 关闭前等待当前编码完成 + ukr: Очікування завершення поточного кодування перед вимкненням + kor: 종료하기 전에 현재 인코딩이 완료되기를 기다리는 중입니다. + ron: Așteptarea finalizării codării curente înainte de închidere +Width must be smaller than video width: + eng: Width must be smaller than video width + deu: Die Breite muss kleiner sein als die Videobreite + fra: La largeur doit être inférieure à la largeur de la vidéo + ita: La larghezza deve essere inferiore alla larghezza del video + spa: La anchura debe ser inferior a la anchura del vídeo + jpn: 幅はビデオの幅より小さくなければならない + rus: Ширина должна быть меньше ширины видео + por: A largura deve ser inferior à largura do vídeo + swe: Bredden måste vara mindre än videobredden + pol: Szerokość musi być mniejsza niż szerokość wideo + chs: 宽度必须小于视频宽度 + ukr: Ширина повинна бути меншою за ширину відео + kor: 너비는 동영상 너비보다 작아야 합니다. + ron: Lățimea trebuie să fie mai mică decât lățimea videoclipului +and add it to PATH: + eng: and add it to PATH + deu: und fügen Sie es zu PATH hinzu + fra: et l'ajouter au PATH + ita: e aggiungerlo al PATH + spa: y añádelo a PATH + jpn: に追加する。 + rus: и добавьте его в PATH + por: e adicioná-lo ao PATH + swe: och lägg till den i PATH + pol: i dodać go do PATH + chs: 并将其添加到 PATH + ukr: і додайте його до PATH + kor: 를 클릭하고 PATH에 추가합니다. + ron: și adăugați-l la PATH +bottom: + eng: bottom + deu: unten + fra: fond + ita: fondo + spa: fondo + jpn: 下 + rus: дно + por: fundo + swe: botten + pol: dno + chs: 底层 + ukr: дно + kor: 하단 + ron: fund +configure in Settings: + eng: configure in Settings + deu: in den Einstellungen konfigurieren + fra: configurer dans Paramètres + ita: configurare in Impostazioni + spa: configurar en Ajustes + jpn: 設定 + rus: настроить в разделе Настройки + por: configurar em Definições + swe: konfigurera i Inställningar + pol: skonfigurować w Ustawieniach + chs: 在设置中配置 + ukr: налаштувати в Налаштуваннях + kor: 설정에서 구성 + ron: configurați în Setări +download a static FFmpeg: + eng: download a static FFmpeg + deu: ein statisches FFmpeg herunterladen + fra: télécharger un FFmpeg statique + ita: scaricare un FFmpeg statico + spa: descargar un FFmpeg estático + jpn: 静的なFFmpegをダウンロードする + rus: загрузить статический FFmpeg + por: descarregar um FFmpeg estático + swe: ladda ner en statisk FFmpeg + pol: pobierz statyczny FFmpeg + chs: 下载静态 FFmpeg + ukr: завантажити статичний FFmpeg + kor: 정적 FFmpeg 다운로드 + ron: descărcați un FFmpeg static +hdr10plus_tool was not properly downloaded as the file size is too small: + eng: hdr10plus_tool was not properly downloaded as the file size is too small + deu: hdr10plus_tool wurde nicht ordnungsgemäß heruntergeladen, da die Dateigröße zu klein ist + fra: hdr10plus_tool n'a pas été correctement téléchargé car la taille du fichier est trop petite + ita: hdr10plus_tool non è stato scaricato correttamente perché la dimensione del file è troppo piccola. + spa: hdr10plus_tool no se ha descargado correctamente porque el tamaño del archivo es demasiado pequeño. + jpn: ファイルサイズが小さすぎるため、hdr10plus_toolが正しくダウンロードされませんでした。 + rus: hdr10plus_tool не был загружен должным образом, так как размер файла слишком мал + por: hdr10plus_tool não foi descarregado corretamente porque o tamanho do ficheiro é demasiado pequeno + swe: hdr10plus_tool laddades inte ner ordentligt eftersom filstorleken är för liten + pol: hdr10plus_tool nie został poprawnie pobrany, ponieważ rozmiar pliku jest zbyt mały + chs: 由于文件太小,无法正确下载 hdr10plus_tool + ukr: hdr10plus_tool не вдалося завантажити належним чином, оскільки розмір файлу занадто малий + kor: 파일 크기가 너무 작아서 hdr10plus_tool이 제대로 다운로드되지 않았습니다. + ron: hdr10plus_tool nu nu a fost descărcat corect deoarece dimensiunea fișierului este prea mică +ignoring: + eng: ignoring + deu: Ignorieren von + fra: ignorant + ita: ignorare + spa: ignorando + jpn: 無視 + rus: игнорирование + por: ignorando + swe: ignorerar + pol: ignorowanie + chs: 视而不见 + ukr: ігнорування + kor: 무시 + ron: ignorare +latest release from: + eng: latest release from + deu: neueste Veröffentlichung von + fra: dernière publication de + ita: l'ultima uscita di + spa: último lanzamiento de + jpn: からの最新リリース + rus: последний релиз от + por: último lançamento da + swe: senaste utgåvan från + pol: najnowsze wydanie od + chs: 最新发布的 + ukr: останній реліз від + kor: 최신 릴리스 + ron: cea mai recentă versiune de la +left: + eng: left + deu: links + fra: gauche + ita: sinistra + spa: izquierda + jpn: 左 + rus: слева + por: esquerda + swe: vänster + pol: lewy + chs: 左侧 + ukr: ліворуч + kor: 왼쪽 + ron: stânga +right: + eng: right + deu: rechts + fra: droit + ita: diritto + spa: derecha + jpn: 右 + rus: справа + por: correto + swe: rätt + pol: prawo + chs: 对 + ukr: Так. + kor: 오른쪽 + ron: corect +top: + eng: top + deu: top + fra: sommet + ita: top + spa: top + jpn: トップ + rus: топ + por: topo + swe: topp + pol: top + chs: 顶级 + ukr: верхній + kor: top + ron: top diff --git a/fastflix/encoders/av1_aom/settings_panel.py b/fastflix/encoders/av1_aom/settings_panel.py index eb48434b..ba81dac9 100644 --- a/fastflix/encoders/av1_aom/settings_panel.py +++ b/fastflix/encoders/av1_aom/settings_panel.py @@ -221,21 +221,31 @@ def denoise_update(self): self.widgets.custom_denoise.setDisabled(not custom) self.main.page_update() + def _set_denoise_from_value(self, saved): + """Set denoise combo box and custom field from an integer value.""" + if not saved or str(saved) == "0": + self.widgets.denoise.setCurrentIndex(0) + return + matched = False + for i, opt in enumerate(denoise_options): + if opt.startswith(str(saved) + " "): + self.widgets.denoise.setCurrentIndex(i) + matched = True + break + if not matched: + self.widgets.denoise.setCurrentIndex(len(denoise_options) - 1) + self.widgets.custom_denoise.setText(str(saved)) + self.denoise_update() + + def update_profile(self): + saved = self.app.fastflix.config.encoder_opt(self.profile_name, "denoise_noise_level") + self._set_denoise_from_value(saved) + super().update_profile() + def reload(self): super().reload() saved = self.app.fastflix.current_video.video_settings.video_encoder_settings.denoise_noise_level - if saved and str(saved) != "0": - matched = False - for i, opt in enumerate(denoise_options): - if opt.startswith(str(saved)): - self.widgets.denoise.setCurrentIndex(i) - matched = True - break - if not matched: - self.widgets.denoise.setCurrentIndex(len(denoise_options) - 1) - self.widgets.custom_denoise.setText(str(saved)) - else: - self.widgets.denoise.setCurrentIndex(0) + self._set_denoise_from_value(saved) def init_aom_params(self): layout = QtWidgets.QHBoxLayout() diff --git a/fastflix/encoders/common/setting_panel.py b/fastflix/encoders/common/setting_panel.py index af6e86e5..da923669 100644 --- a/fastflix/encoders/common/setting_panel.py +++ b/fastflix/encoders/common/setting_panel.py @@ -95,6 +95,7 @@ def translate_tip(tooltip): return "
".join([t(x) for x in tooltip.split("\n") if x.strip()]) def determine_default(self, widget_name, opt, items: List, raise_error: bool = False): + original_opt = opt if widget_name == "pix_fmt": items = [x.split(":")[1].strip() for x in items] elif widget_name in ("crf", "qp", "qscale"): @@ -106,6 +107,24 @@ def determine_default(self, widget_name, opt, items: List, raise_error: bool = F if not opt: return 5 items = [x.split("(")[0].split("-")[0].strip() for x in items] + elif widget_name in ("film_grain", "photon_noise"): + if opt is None or opt == 0: + return 0 + opt = str(opt) + items = [x.split(" - ")[0].split(" ")[0].strip() for x in items] + elif widget_name == "period": + if opt is None: + return 0 + opt = str(opt) + elif widget_name == "threads": + if opt == 0: + return 0 + opt = str(opt) + elif widget_name in ("auto_alt_ref", "lag_in_frames", "aq_mode", "sharpness"): + if opt == -1: + return 0 + opt = str(opt) + items = [x.split("(")[0].split()[0] for x in items] elif widget_name == "gpu": if opt == -1: return 0 @@ -117,6 +136,10 @@ def determine_default(self, widget_name, opt, items: List, raise_error: bool = F for i, item in enumerate(items): if item.split(" - ")[0].strip() == opt: return i + # If original opt was an integer, use it directly as a combo box index + # (e.g. x265 aq_mode stores index 0-4, not a string label) + if isinstance(original_opt, int) and 0 <= original_opt < len(items): + return original_opt if raise_error: raise FastFlixInternalException else: diff --git a/fastflix/encoders/rav1e/settings_panel.py b/fastflix/encoders/rav1e/settings_panel.py index 2934d87d..05418f9f 100644 --- a/fastflix/encoders/rav1e/settings_panel.py +++ b/fastflix/encoders/rav1e/settings_panel.py @@ -232,7 +232,10 @@ def update_video_encoder_settings(self): except (ValueError, TypeError): photon_noise = 0 else: - photon_noise = int(photon_noise_text.split(" ")[0]) + try: + photon_noise = int(photon_noise_text.split(" ")[0]) + except (ValueError, TypeError): + photon_noise = 0 rav1e_params_text = self.widgets.rav1e_params.text().strip() @@ -256,6 +259,32 @@ def update_video_encoder_settings(self): settings.bitrate = q_value if encode_type == "bitrate" else None self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + def _set_photon_noise_from_value(self, saved): + """Set photon noise combo box and custom field from an integer value.""" + if not saved or str(saved) == "0": + self.widgets.photon_noise.setCurrentIndex(0) + return + matched = False + for i, opt in enumerate(photon_noise_options): + if opt.startswith(str(saved) + " "): + self.widgets.photon_noise.setCurrentIndex(i) + matched = True + break + if not matched: + self.widgets.photon_noise.setCurrentIndex(len(photon_noise_options) - 1) + self.widgets.custom_photon_noise.setText(str(saved)) + self.photon_noise_update() + + def update_profile(self): + saved = self.app.fastflix.config.encoder_opt(self.profile_name, "photon_noise") + self._set_photon_noise_from_value(saved) + super().update_profile() + + def reload(self): + super().reload() + photon_noise = self.app.fastflix.current_video.video_settings.video_encoder_settings.photon_noise + self._set_photon_noise_from_value(photon_noise) + def set_mode(self, x): self.mode = x.text() self.main.build_commands() diff --git a/fastflix/encoders/svt_av1/settings_panel.py b/fastflix/encoders/svt_av1/settings_panel.py index 5fc3f212..8012fada 100644 --- a/fastflix/encoders/svt_av1/settings_panel.py +++ b/fastflix/encoders/svt_av1/settings_panel.py @@ -260,6 +260,32 @@ def init_svtav1_params(self): layout.addWidget(self.widgets.svtav1_params) return layout + def _set_film_grain_from_value(self, saved): + """Set film grain combo box and custom field from an integer value.""" + if not saved or str(saved) == "0": + self.widgets.film_grain.setCurrentIndex(0) + return + matched = False + for i, opt in enumerate(film_grain_options): + if opt.startswith(str(saved) + " "): + self.widgets.film_grain.setCurrentIndex(i) + matched = True + break + if not matched: + self.widgets.film_grain.setCurrentIndex(len(film_grain_options) - 1) + self.widgets.custom_film_grain.setText(str(saved)) + self.film_grain_update() + + def update_profile(self): + saved = self.app.fastflix.config.encoder_opt(self.profile_name, "film_grain") + self._set_film_grain_from_value(saved) + super().update_profile() + + def reload(self): + super().reload() + film_grain = self.app.fastflix.current_video.video_settings.video_encoder_settings.film_grain + self._set_film_grain_from_value(film_grain) + def init_modes(self): return self._add_modes(recommended_bitrates, recommended_qp, qp_name="qp", qp_display_name="CRF/QP") @@ -279,7 +305,10 @@ def update_video_encoder_settings(self): except (ValueError, TypeError): film_grain = 0 else: - film_grain = int(film_grain_text.split(" ")[0]) + try: + film_grain = int(film_grain_text.split(" ")[0]) + except (ValueError, TypeError): + film_grain = 0 settings = SVTAV1Settings( speed=self.widgets.speed.currentText(), diff --git a/fastflix/encoders/vceencc_av1/settings_panel.py b/fastflix/encoders/vceencc_av1/settings_panel.py index ec9ebb5a..776d117e 100644 --- a/fastflix/encoders/vceencc_av1/settings_panel.py +++ b/fastflix/encoders/vceencc_av1/settings_panel.py @@ -295,7 +295,7 @@ def update_video_encoder_settings(self): pa_activity_type=self.widgets.pa_activity_type.currentText(), pa_caq_strength=self.widgets.pa_caq_strength.currentText(), pa_initqpsc=self.widgets.pa_initqpsc.currentIndex() or None, - pa_lookahead=self.widgets.pa_initqpsc.currentIndex() or None, + pa_lookahead=self.widgets.pa_lookahead.currentIndex() or None, pa_fskip_maxqp=int(self.widgets.pa_fskip_maxqp.text() or 0) or None, pa_ltr=self.widgets.pa_ltr.isChecked(), pa_paq=self.widgets.pa_paq.currentText(), diff --git a/fastflix/encoders/vceencc_avc/settings_panel.py b/fastflix/encoders/vceencc_avc/settings_panel.py index c731b57d..de142e1e 100644 --- a/fastflix/encoders/vceencc_avc/settings_panel.py +++ b/fastflix/encoders/vceencc_avc/settings_panel.py @@ -291,7 +291,7 @@ def update_video_encoder_settings(self): pa_activity_type=self.widgets.pa_activity_type.currentText(), pa_caq_strength=self.widgets.pa_caq_strength.currentText(), pa_initqpsc=self.widgets.pa_initqpsc.currentIndex() or None, - pa_lookahead=self.widgets.pa_initqpsc.currentIndex() or None, + pa_lookahead=self.widgets.pa_lookahead.currentIndex() or None, pa_fskip_maxqp=int(self.widgets.pa_fskip_maxqp.text() or 0) or None, pa_ltr=self.widgets.pa_ltr.isChecked(), pa_paq=self.widgets.pa_paq.currentText(), diff --git a/fastflix/encoders/vceencc_hevc/settings_panel.py b/fastflix/encoders/vceencc_hevc/settings_panel.py index 6d7c8b18..eb287ccb 100644 --- a/fastflix/encoders/vceencc_hevc/settings_panel.py +++ b/fastflix/encoders/vceencc_hevc/settings_panel.py @@ -289,7 +289,7 @@ def update_video_encoder_settings(self): pa_activity_type=self.widgets.pa_activity_type.currentText(), pa_caq_strength=self.widgets.pa_caq_strength.currentText(), pa_initqpsc=self.widgets.pa_initqpsc.currentIndex() or None, - pa_lookahead=self.widgets.pa_initqpsc.currentIndex() or None, + pa_lookahead=self.widgets.pa_lookahead.currentIndex() or None, pa_fskip_maxqp=int(self.widgets.pa_fskip_maxqp.text() or 0) or None, pa_ltr=self.widgets.pa_ltr.isChecked(), pa_paq=self.widgets.pa_paq.currentText(), diff --git a/fastflix/encoders/vp9/settings_panel.py b/fastflix/encoders/vp9/settings_panel.py index 86248355..2a5321ad 100644 --- a/fastflix/encoders/vp9/settings_panel.py +++ b/fastflix/encoders/vp9/settings_panel.py @@ -299,6 +299,51 @@ def update_video_encoder_settings(self): settings.bitrate = q_value if encode_type == "bitrate" else None self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + def _set_default_combo_from_value(self, widget_name, value, options): + """Set a combo box from model value where -1 means 'Default' and others match by first token.""" + if value == -1: + self.widgets[widget_name].setCurrentIndex(0) # "Default" + return + text = str(value) + for i, opt in enumerate(options): + if opt.split()[0] == text: + self.widgets[widget_name].setCurrentIndex(i) + return + self.widgets[widget_name].setCurrentIndex(0) + + def update_profile(self): + auto_alt_ref_opts = ["Default", "0 (disabled)", "1", "2", "3", "4", "5", "6"] + lag_opts = ["Default", "0", "10", "16", "20", "25", "30", "40", "50"] + aq_opts = ["Default", "0 (none)", "1 (variance)", "2 (complexity)", "3 (cyclic)", "4 (equator360)"] + sharpness_opts = ["Default", "0", "1", "2", "3", "4", "5", "6", "7"] + + self._set_default_combo_from_value( + "auto_alt_ref", self.app.fastflix.config.encoder_opt(self.profile_name, "auto_alt_ref"), auto_alt_ref_opts + ) + self._set_default_combo_from_value( + "lag_in_frames", self.app.fastflix.config.encoder_opt(self.profile_name, "lag_in_frames"), lag_opts + ) + self._set_default_combo_from_value( + "aq_mode", self.app.fastflix.config.encoder_opt(self.profile_name, "aq_mode"), aq_opts + ) + self._set_default_combo_from_value( + "sharpness", self.app.fastflix.config.encoder_opt(self.profile_name, "sharpness"), sharpness_opts + ) + super().update_profile() + + def reload(self): + super().reload() + settings = self.app.fastflix.current_video.video_settings.video_encoder_settings + auto_alt_ref_opts = ["Default", "0 (disabled)", "1", "2", "3", "4", "5", "6"] + lag_opts = ["Default", "0", "10", "16", "20", "25", "30", "40", "50"] + aq_opts = ["Default", "0 (none)", "1 (variance)", "2 (complexity)", "3 (cyclic)", "4 (equator360)"] + sharpness_opts = ["Default", "0", "1", "2", "3", "4", "5", "6", "7"] + + self._set_default_combo_from_value("auto_alt_ref", settings.auto_alt_ref, auto_alt_ref_opts) + self._set_default_combo_from_value("lag_in_frames", settings.lag_in_frames, lag_opts) + self._set_default_combo_from_value("aq_mode", settings.aq_mode, aq_opts) + self._set_default_combo_from_value("sharpness", settings.sharpness, sharpness_opts) + def set_mode(self, x): self.mode = x.text() self.main.build_commands() diff --git a/fastflix/encoders/vvc/settings_panel.py b/fastflix/encoders/vvc/settings_panel.py index 120f71e2..7a18b9f9 100644 --- a/fastflix/encoders/vvc/settings_panel.py +++ b/fastflix/encoders/vvc/settings_panel.py @@ -297,6 +297,43 @@ def update_video_encoder_settings(self): settings.bitrate = q_value if encode_type == "bitrate" else None self.app.fastflix.current_video.video_settings.video_encoder_settings = settings + def _set_period_from_value(self, value): + """Set period combo box from model value (None=Auto, else int).""" + if value is None: + self.widgets.period.setCurrentIndex(0) # "Auto" + return + text = str(value) + for i in range(self.widgets.period.count()): + if self.widgets.period.itemText(i) == text: + self.widgets.period.setCurrentIndex(i) + return + self.widgets.period.setCurrentIndex(0) + + def _set_threads_from_value(self, value): + """Set threads combo box from model value (0=Auto, else int).""" + if value == 0: + self.widgets.threads.setCurrentIndex(0) # "Auto" + return + text = str(value) + for i in range(self.widgets.threads.count()): + if self.widgets.threads.itemText(i) == text: + self.widgets.threads.setCurrentIndex(i) + return + self.widgets.threads.setCurrentIndex(0) + + def update_profile(self): + period = self.app.fastflix.config.encoder_opt(self.profile_name, "period") + self._set_period_from_value(period) + threads = self.app.fastflix.config.encoder_opt(self.profile_name, "threads") + self._set_threads_from_value(threads) + super().update_profile() + + def reload(self): + super().reload() + settings = self.app.fastflix.current_video.video_settings.video_encoder_settings + self._set_period_from_value(settings.period) + self._set_threads_from_value(settings.threads) + def set_mode(self, x): self.mode = x.text() self.main.build_commands() diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index 32ca50c2..724f7195 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -117,7 +117,7 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): f"QTabBar{{ font-size: {tab_font_pt}pt; }} " "QTabBar::tab{ border-top: 2px solid transparent; } " f"QTabBar::tab:selected{{ border-top: 2px solid {ONYX_COLORS['primary']}; }} " - "QLineEdit{ color: white; border-radius: 5px; min-height: 0px; } " + "QLineEdit{ color: white; border-radius: 5px; min-height: 1.1em; } " "QTextEdit{ color: white; } " "QPlainTextEdit{ color: white; } " f"QComboBox{{ min-height: 1.1em; border-radius: 5px; {get_onyx_combobox_style()} }}" diff --git a/pyproject.toml b/pyproject.toml index 5485a730..7648d092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.13" dynamic = ["version"] dependencies = [ - "platformdirs~=4.3", + "platformdirs~=4.7", "chardet>=5.1.0,<5.2.0", "colorama>=0.4,<1.0", "coloredlogs>=15.0,<16.0", @@ -52,12 +52,12 @@ build-backend = "setuptools.build_meta" dev = [ "pre-commit>=4.2.0", "pyinstaller>=6.13.0", - "pytest>=8.4.1", - "ruff>=0.12.1", - "types-requests>=2.32.4.20250611", - "types-setuptools>=80.9.0.20250529", - "typing-extensions>=4.14.0", - "wheel>=0.45.1", + "pytest>=9.0", + "ruff>=0.14", + "types-requests>=2.32", + "types-setuptools>=80.9", + "typing-extensions>=4.14", + "wheel>=0.45", ] [tool.setuptools.dynamic] diff --git a/tests/conftest.py b/tests/conftest.py index f242426a..fef30262 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,10 @@ from fastflix.models.video import Video, VideoSettings +def pytest_configure(config): + config.addinivalue_line("markers", "local_only: mark test to run only locally (not on CI)") + + def create_fastflix_instance( encoder_settings: EncoderSettings, video_settings: Optional[VideoSettings] = None, hdr10_metadata: bool = False ): diff --git a/uv.lock b/uv.lock index d88db8f8..1598e0fb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,7 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" - [[package]] name = "altgraph" version = "0.17.5" @@ -136,14 +135,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -244,7 +243,7 @@ requires-dist = [ { name = "packaging", specifier = ">=23.2" }, { name = "pathvalidate", specifier = ">=2.4,<3.0" }, { name = "pgsrip", specifier = ">=0.1.0" }, - { name = "platformdirs", specifier = "~=4.3" }, + { name = "platformdirs", specifier = "~=4.7" }, { name = "psutil", specifier = ">=5.9,<6.0" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, { name = "pyside6", specifier = "==6.10.1" }, @@ -262,17 +261,17 @@ requires-dist = [ dev = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pyinstaller", specifier = ">=6.13.0" }, - { name = "pytest", specifier = ">=8.4.1" }, - { name = "ruff", specifier = ">=0.12.1" }, - { name = "types-requests", specifier = ">=2.32.4.20250611" }, - { name = "types-setuptools", specifier = ">=80.9.0.20250529" }, - { name = "typing-extensions", specifier = ">=4.14.0" }, - { name = "wheel", specifier = ">=0.45.1" }, + { name = "pytest", specifier = ">=9.0" }, + { name = "ruff", specifier = ">=0.14" }, + { name = "types-requests", specifier = ">=2.32" }, + { name = "types-setuptools", specifier = ">=80.9" }, + { name = "typing-extensions", specifier = ">=4.14" }, + { name = "wheel", specifier = ">=0.45" }, ] [[package]] name = "ffmpeg-normalize" -version = "1.36.1" +version = "1.37.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -281,30 +280,30 @@ dependencies = [ { name = "mutagen" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/c3/a662f9f8cc8dd23d59e3895ae5cfc757be929662eac0f834f7cd7862f2d3/ffmpeg_normalize-1.36.1.tar.gz", hash = "sha256:1dc19d3ff5ef2c7c4040c0bd8a77e355331777efc31cd05de66e570f305764a9", size = 32815, upload-time = "2026-01-07T15:36:49.621Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/77/4fe5b7f721d5cc18173515f37f9f33d9742654564d62f0710fb31deb9296/ffmpeg_normalize-1.37.3.tar.gz", hash = "sha256:98de2f9a2493f8b57906ffd067d3efa37ab385369e0f73290daf2c2cdaa3796d", size = 33987, upload-time = "2026-02-08T19:23:29.695Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/df/203efa0d81a87624aa8af968dde04a37d9e5a89fdfd072bb5133787e5136/ffmpeg_normalize-1.36.1-py3-none-any.whl", hash = "sha256:b974dd9f5cf351b23378bd1e5df9755251ed7d0ee8d218f4d9fff0c2763c5c92", size = 39207, upload-time = "2026-01-07T15:36:48.281Z" }, + { url = "https://files.pythonhosted.org/packages/6e/be/beb66d03c216024841d6370d2bb3ad28efd9d88defaae05b02c5d9089383/ffmpeg_normalize-1.37.3-py3-none-any.whl", hash = "sha256:699711ed5ae37f387cec74c5d14e6948a073f44bc0a2d3b381fd549a19f132f7", size = 40338, upload-time = "2026-02-08T19:23:28.458Z" }, ] [[package]] name = "ffmpeg-progress-yield" -version = "1.1.1" +version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/9b/90952b4133bb4a7a8864ae79b2efd9f5a95051b6d1170e14afd554d02fd4/ffmpeg_progress_yield-1.1.1.tar.gz", hash = "sha256:1161a6a506576779abda7efe41e8dcf52674a99d455650584c84a2befd49b7bc", size = 9923, upload-time = "2026-01-13T13:07:41.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/52/ef3dbb7509ec04288908ae5c1ea6cdea880aa8fa1236f37742d9f6639399/ffmpeg_progress_yield-1.1.2.tar.gz", hash = "sha256:bebdfb26f1e47dd3a1334692496ed35fb43fb0bbdc7e1b5263b3dde9e02e955e", size = 9917, upload-time = "2026-02-07T19:00:51.2Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/49/ee532a839d68414744441245891710d0e199373d6437d9cbc3e70f4ca6f4/ffmpeg_progress_yield-1.1.1-py3-none-any.whl", hash = "sha256:25b7f804e0d8920b50b407e8f90ed1a7a9bcf90067c1b94c82895450788cf193", size = 12676, upload-time = "2026-01-13T13:07:40.014Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/c2264ddd52d0427313fe7be0e9811f1814036b96ef5665dbfa371b02455e/ffmpeg_progress_yield-1.1.2-py3-none-any.whl", hash = "sha256:9a95d02c7530739e86d69dbb92c7ca98d035756b37b562b05af128ee2378682b", size = 12678, upload-time = "2026-02-07T19:00:50.087Z" }, ] [[package]] name = "filelock" -version = "3.20.3" +version = "3.21.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/71/74364ff065ca78914d8bd90b312fe78ddc5e11372d38bc9cb7104f887ce1/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708", size = 31486, upload-time = "2026-02-13T01:27:15.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/98/73/3a18f1e1276810e81477c431009b55eeccebbd7301d28a350b77aacf3c33/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560", size = 21479, upload-time = "2026-02-13T01:27:13.611Z" }, ] [[package]] @@ -357,7 +356,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -365,9 +364,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -458,66 +457,79 @@ wheels = [ [[package]] name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, ] [[package]] name = "opencv-python" -version = "4.12.0.88" +version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/71/25c98e634b6bdeca4727c7f6d6927b056080668c5008ad3c8fc9e7f8f6ec/opencv-python-4.12.0.88.tar.gz", hash = "sha256:8b738389cede219405f6f3880b851efa3415ccd674752219377353f017d2994d", size = 95373294, upload-time = "2025-07-07T09:20:52.389Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/68/3da40142e7c21e9b1d4e7ddd6c58738feb013203e6e4b803d62cdd9eb96b/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:f9a1f08883257b95a5764bf517a32d75aec325319c8ed0f89739a57fae9e92a5", size = 37877727, upload-time = "2025-07-07T09:13:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/33/7c/042abe49f58d6ee7e1028eefc3334d98ca69b030e3b567fe245a2b28ea6f/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:812eb116ad2b4de43ee116fcd8991c3a687f099ada0b04e68f64899c09448e81", size = 57326471, upload-time = "2025-07-07T09:13:41.26Z" }, - { url = "https://files.pythonhosted.org/packages/62/3a/440bd64736cf8116f01f3b7f9f2e111afb2e02beb2ccc08a6458114a6b5d/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51fd981c7df6af3e8f70b1556696b05224c4e6b6777bdd2a46b3d4fb09de1a92", size = 45887139, upload-time = "2025-07-07T09:13:50.761Z" }, - { url = "https://files.pythonhosted.org/packages/68/1f/795e7f4aa2eacc59afa4fb61a2e35e510d06414dd5a802b51a012d691b37/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:092c16da4c5a163a818f120c22c5e4a2f96e0db4f24e659c701f1fe629a690f9", size = 67041680, upload-time = "2025-07-07T09:14:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131, upload-time = "2025-07-07T09:14:08.819Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -555,80 +567,69 @@ sdist = { url = "https://files.pythonhosted.org/packages/45/c3/4d8da691f5324e84a [[package]] name = "pillow" -version = "12.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/25/ccd8e88fcd16a4eb6343a8b4b9635e6f3928a7ebcd82822a14d20e3ca29f/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36", size = 23118, upload-time = "2026-02-12T22:21:53.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e3/1eddccb2c39ecfbe09b3add42a04abcc3fa5b468aa4224998ffb8a7e9c8f/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6", size = 18983, upload-time = "2026-02-12T22:21:52.237Z" }, ] [[package]] @@ -777,15 +778,15 @@ wheels = [ [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.11" +version = "2026.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/2f/2c68b6722d233dae3e5243751aafc932940b836919cfaca22dd0c60d417c/pyinstaller_hooks_contrib-2025.11.tar.gz", hash = "sha256:dfe18632e06655fa88d218e0d768fd753e1886465c12a6d4bce04f1aaeec917d", size = 169183, upload-time = "2025-12-23T12:59:37.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/8f/8052ff65067697ee80fde45b9731842e160751c41ac5690ba232c22030e8/pyinstaller_hooks_contrib-2026.0.tar.gz", hash = "sha256:0120893de491a000845470ca9c0b39284731ac6bace26f6849dea9627aaed48e", size = 170311, upload-time = "2026-01-20T00:15:23.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c4/3a096c6e701832443b957b9dac18a163103360d0c7f5842ca41695371148/pyinstaller_hooks_contrib-2025.11-py3-none-any.whl", hash = "sha256:777e163e2942474aa41a8e6d31ac1635292d63422c3646c176d584d04d971c34", size = 449478, upload-time = "2025-12-23T12:59:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b1/9da6ec3e88696018ee7bb9dc4a7310c2cfaebf32923a19598cd342767c10/pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5", size = 452318, upload-time = "2026-01-20T00:15:21.88Z" }, ] [[package]] @@ -973,7 +974,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -1006,83 +1006,68 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" }, - { url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" }, - { url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" }, - { url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" }, - { url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" }, - { url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" }, - { url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" }, - { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, - { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, - { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, - { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, - { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, - { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, - { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, - { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, - { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, - { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, - { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, - { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, - { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, - { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, - { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, - { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, - { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, - { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, - { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, - { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, - { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, - { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, - { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, - { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, - { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, - { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, - { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, - { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, - { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, - { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, - { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, - { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, - { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] @@ -1096,37 +1081,36 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, - { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, - { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, - { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, - { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, - { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, - { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" }, - { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, ] [[package]] name = "setuptools" -version = "80.9.0" +version = "82.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] [[package]] @@ -1152,14 +1136,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.1" +version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] @@ -1189,11 +1173,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "80.9.0.20251223" +version = "82.0.0.20260210" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/07/d1b605230730990de20477150191d6dccf6aecc037da94c9960a5d563bc8/types_setuptools-80.9.0.20251223.tar.gz", hash = "sha256:d3411059ae2f5f03985217d86ac6084efea2c9e9cacd5f0869ef950f308169b2", size = 42420, upload-time = "2025-12-23T03:18:26.752Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/90/796ac8c774a7f535084aacbaa6b7053d16fff5c630eff87c3ecff7896c37/types_setuptools-82.0.0.20260210.tar.gz", hash = "sha256:d9719fbbeb185254480ade1f25327c4654f8c00efda3fec36823379cebcdee58", size = 44768, upload-time = "2026-02-10T04:22:02.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/5c/b8877da94012dbc6643e4eeca22bca9b99b295be05d161f8a403ae9387c0/types_setuptools-80.9.0.20251223-py3-none-any.whl", hash = "sha256:1b36db79d724c2287d83dc052cf887b47c0da6a2fff044378be0b019545f56e6", size = 64318, upload-time = "2025-12-23T03:18:25.868Z" }, + { url = "https://files.pythonhosted.org/packages/3e/54/3489432b1d9bc713c9d8aa810296b8f5b0088403662959fb63a8acdbd4fc/types_setuptools-82.0.0.20260210-py3-none-any.whl", hash = "sha256:5124a7daf67f195c6054e0f00f1d97c69caad12fdcf9113eba33eff0bce8cd2b", size = 68433, upload-time = "2026-02-10T04:22:00.876Z" }, ] [[package]] @@ -1242,11 +1226,14 @@ wheels = [ [[package]] name = "wheel" -version = "0.45.1" +version = "0.46.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, ] [[package]] From 130187439fe72bd575959ab874a17d2a42aa23a5 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 13 Feb 2026 14:25:15 -0600 Subject: [PATCH 05/10] * Adding Output Naming settings tab with template editor, clickable variable chips, live preview, and validation for customizing output filenames with 23 pre-encode variables --- CHANGES | 2 + fastflix/models/config.py | 1 + fastflix/models/video.py | 4 + fastflix/widgets/main.py | 154 +++++++++++++++++++++- fastflix/widgets/panels/audio_panel.py | 1 + fastflix/widgets/panels/queue_panel.py | 22 ++++ fastflix/widgets/settings.py | 172 +++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 352 insertions(+), 8 deletions(-) diff --git a/CHANGES b/CHANGES index bc8a36a1..d5f9a096 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,8 @@ ## Version 6.1.0 +* Adding Output Naming settings tab with template editor, clickable variable chips, live preview, and validation for customizing output filenames with 23 pre-encode variables +* Adding always-on ffprobe validation of output files after every encode to catch silent failures * Adding Visual Crop window for dragging crop edges directly on a video frame preview, with live overlay and divisible-by-8 snapping on save * Adding Data & Attachments tab for per-track control of data streams (timecodes, navigation) and non-image attachments (fonts) * Fixing UI scaling for Source/Folder/Filename text boxes, file extension dropdown, Resolution label, Start/End Time controls, and Crop input fields being too small diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 6c25f2e3..d8a2ac3a 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -265,6 +265,7 @@ class Config(BaseModel): sane_audio_selection: list = Field( default_factory=lambda: [ "aac", + "aac_mf", "ac3", "alac", "dca", diff --git a/fastflix/models/video.py b/fastflix/models/video.py index 67d36caf..10d3c88c 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import uuid +from datetime import datetime from pathlib import Path from typing import List, Optional, Union, Tuple @@ -113,6 +114,7 @@ class VideoSettings(BaseModel): contrast: Optional[str] = None saturation: Optional[str] = None copy_data: bool = False + template_generated_name: str = "" video_encoder_settings: Optional[ Union[ x265Settings, @@ -181,6 +183,7 @@ class Status(BaseModel): cancelled: bool = False subtitle_fixed: bool = False current_command: int = 0 + encode_started_at: Optional[datetime] = None @property def ready(self) -> bool: @@ -194,6 +197,7 @@ def clear(self): self.cancelled = False self.subtitle_fixed = False self.current_command = 0 + self.encode_started_at = None class Video(BaseModel): diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 82f09d49..ea050984 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -244,7 +244,12 @@ def __init__(self, parent, app: FastFlixApp): self.output_video_path_widget.setStyleSheet( f"padding: 0 0 -1px 5px; color: rgb({get_text_color(self.app.fastflix.config.theme)})" ) - self.output_video_path_widget.setMaxLength(220) + self.output_video_path_widget.setMaxLength(250) + + self._filename_was_truncated = False + self.filename_truncation_warning = QtWidgets.QLabel() + self.filename_truncation_warning.setStyleSheet("color: #cc8800; font-size: 11px; padding-left: 5px;") + self.filename_truncation_warning.hide() # self.output_video_path_widget.textChanged.connect(lambda x: self.page_update(build_thumbnail=False)) self.video_path_widget.setEnabled(False) @@ -665,6 +670,7 @@ def init_video_area(self): file_group_layout.addLayout(source_layout) file_group_layout.addLayout(out_dir_layout) file_group_layout.addLayout(output_layout) + file_group_layout.addWidget(self.filename_truncation_warning) # Video info bar (codec, bit depth, color space, chroma subsampling, HDR10, HDR10+) self.video_codec_label = QtWidgets.QLabel() @@ -1629,6 +1635,7 @@ def open_file(self): self.output_video_path_widget.setDisabled(True) self.widgets.output_directory.setText("") self.output_path_button.setDisabled(True) + self.filename_truncation_warning.hide() self.page_update() def open_many(self, paths: list): @@ -1673,10 +1680,8 @@ def stop_me(): @property def generate_output_filename(self): - source = self.input_video.stem - iso_datetime = datetime.datetime.now().isoformat().replace(":", "-").split(".")[0] - rand_4 = secrets.token_hex(2) - rand_8 = secrets.token_hex(4) + from fastflix.naming import resolve_pre_encode_variables, truncate_filename + out_loc = f"{Path('~').expanduser()}{os.sep}" if tx := self.widgets.output_directory.text(): out_loc = f"{tx}{os.sep}" @@ -1687,7 +1692,41 @@ def generate_output_filename(self): gen_string = self.app.fastflix.config.output_name_format or "{source}-fastflix-{rand_4}" - return out_loc, gen_string.format(source=source, datetime=iso_datetime, rand_4=rand_4, rand_8=rand_8, ext="") + video = getattr(self.app.fastflix, "current_video", None) + encoder_settings = None + video_settings = None + if video: + video_settings = video.video_settings + encoder_settings = video.video_settings.video_encoder_settings + + name = resolve_pre_encode_variables( + gen_string, + self.input_video, + video=video, + encoder_settings=encoder_settings, + video_settings=video_settings, + ) + + extension = "" + if self.current_encoder: + try: + extension = self.widgets.output_type_combo.currentText() + except Exception: + extension = self.current_encoder.video_extensions[0] if self.current_encoder.video_extensions else "" + name, was_truncated = truncate_filename(name, out_loc, extension) + self._filename_was_truncated = was_truncated + + return out_loc, name + + def _update_truncation_warning(self): + """Show or hide the filename truncation warning based on the last generate call.""" + if self._filename_was_truncated: + self.filename_truncation_warning.setText( + t("Filename was truncated to fit the 250 character path limit. Some template variables may be missing.") + ) + self.filename_truncation_warning.show() + else: + self.filename_truncation_warning.hide() @property def output_video(self): @@ -1879,6 +1918,7 @@ def clear_current_video(self): self.widgets.output_directory.setText("") self.output_path_button.setDisabled(True) self.output_video_path_widget.setDisabled(True) + self.filename_truncation_warning.hide() for i in range(self.widgets.video_track.count()): self.widgets.video_track.removeItem(0) self.widgets.preview.setText(t("No Video File")) @@ -2003,9 +2043,11 @@ def update_video_info(self, hide_progress=False): folder, name = self.generate_output_filename self.output_video_path_widget.setText(name) self.widgets.output_directory.setText(folder.rstrip("/").rstrip("\\")) + self._update_truncation_warning() self.output_video_path_widget.setDisabled(False) self.output_path_button.setDisabled(False) self.app.fastflix.current_video = Video(source=self.input_video, work_path=self.get_temp_work_path()) + self.app.fastflix.current_video.video_settings.template_generated_name = name tasks = [ Task(t("Parse Video details"), parse), Task(t("Extract covers"), extract_attachments), @@ -2651,6 +2693,7 @@ def _load_dropped_video(self): self.output_video_path_widget.setDisabled(True) self.widgets.output_directory.setText("") self.output_path_button.setDisabled(True) + self.filename_truncation_warning.hide() self.page_update() def dragEnterEvent(self, event): @@ -2688,6 +2731,7 @@ def status_update(self, status_response): break else: video.status.complete = True + self._post_encode_process(video) if response.status == "error": video.status.error = True @@ -2740,10 +2784,108 @@ def send_next_video(self) -> bool: self.set_convert_button() return False + def _post_encode_process(self, video: Video): + """Run ffprobe validation and post-encode rename on completed video.""" + try: + from fastflix.naming import has_post_encode_placeholders + + output_path = video.video_settings.output_path + if not output_path or not output_path.exists(): + logger.warning(f"Post-encode: output file not found at {output_path}") + return + + # Always run ffprobe for validation + try: + from fastflix.flix import probe + + probe_data = probe(self.app, output_path) + except Exception: + logger.exception(f"Post-encode: ffprobe failed on {output_path}") + probe_data = None + + self._validate_output(output_path, probe_data) + + # Rename if post-encode placeholders exist in filename + if has_post_encode_placeholders(output_path.stem): + self._rename_with_post_encode_vars(video, probe_data) + + except Exception: + logger.exception("Post-encode processing failed (encode itself succeeded)") + + def _validate_output(self, output_path: Path, probe_data): + """Quick sanity check on the output file.""" + if not output_path.exists(): + logger.warning(f"Output validation: file does not exist: {output_path}") + return + + file_size = output_path.stat().st_size + if file_size < 1024: + logger.warning(f"Output validation: file is suspiciously small ({file_size} bytes): {output_path}") + + if not probe_data: + logger.warning(f"Output validation: no probe data available for {output_path}") + return + + # Check for video stream + has_video = False + if hasattr(probe_data, "streams"): + for stream in probe_data.streams: + if stream.get("codec_type") == "video": + has_video = True + break + if not has_video: + logger.warning(f"Output validation: no video stream found in {output_path}") + + # Check duration + if hasattr(probe_data, "format") and probe_data.format: + duration = probe_data.format.get("duration") + if duration: + try: + if float(duration) <= 0: + logger.warning(f"Output validation: duration is 0 or negative for {output_path}") + except (ValueError, TypeError): + pass + + def _rename_with_post_encode_vars(self, video: Video, probe_data): + """Resolve post-encode placeholders and rename the output file.""" + from fastflix.naming import resolve_post_encode_variables + + output_path = video.video_settings.output_path + encode_end = datetime.datetime.now(datetime.timezone.utc) + encode_start = video.status.encode_started_at + + old_stem = output_path.stem + new_stem = resolve_post_encode_variables( + old_stem, + output_path, + probe_data, + encode_start=encode_start, + encode_end=encode_end, + ) + + if new_stem == old_stem: + return + + new_path = output_path.with_stem(new_stem) + + # Handle collision + if new_path.exists(): + rand_suffix = secrets.token_hex(2) + new_path = output_path.with_stem(f"{new_stem}-{rand_suffix}") + + try: + output_path.rename(new_path) + video.video_settings.output_path = new_path + logger.info(f"Post-encode rename: {output_path.name} -> {new_path.name}") + except OSError: + logger.exception(f"Post-encode rename failed: {output_path} -> {new_path}") + def send_video_request_to_worker_queue(self, video: Video): command = video.video_settings.conversion_commands[video.status.current_command] self.app.fastflix.currently_encoding = True prevent_sleep_mode() + if video.status.current_command == 0: + video.status.encode_started_at = datetime.datetime.now(datetime.timezone.utc) # logger.info(f"Sending video {video.uuid} command {command.uuid} called from {inspect.stack()}") diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index 5aedbe85..e867c487 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -39,6 +39,7 @@ # Mapping of codec names to friendly display names codec_display_names = { "aac": "AAC", + "aac_mf": "AAC (MF)", "ac3": "AC3", "eac3": "E-AC3", "truehd": "TrueHD", diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 62cb7c8c..f5228079 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -551,6 +551,28 @@ def add_to_queue(self): if not self.main.build_commands(): return False + # Re-resolve output filename with full encoder variables if user hasn't manually edited it + current_video = self.app.fastflix.current_video + if current_video.video_settings.template_generated_name and current_video.video_settings.output_path: + current_stem = current_video.video_settings.output_path.stem + if current_stem == current_video.video_settings.template_generated_name: + from fastflix.naming import resolve_pre_encode_variables, truncate_filename + + gen_string = self.app.fastflix.config.output_name_format or "{source}-fastflix-{rand_4}" + new_name = resolve_pre_encode_variables( + gen_string, + current_video.source, + video=current_video, + encoder_settings=current_video.video_settings.video_encoder_settings, + video_settings=current_video.video_settings, + ) + directory = str(current_video.video_settings.output_path.parent) + extension = current_video.video_settings.output_path.suffix + new_name, _ = truncate_filename(new_name, directory, extension) + new_path = current_video.video_settings.output_path.with_stem(new_name) + current_video.video_settings.output_path = new_path + current_video.video_settings.template_generated_name = new_name + for video in self.app.fastflix.conversion_list: if video.status.complete: continue diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index bb939d0e..60d0ead0 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -11,7 +11,15 @@ from fastflix.exceptions import FastFlixInternalException from fastflix.language import t, Language from fastflix.models.fastflix_app import FastFlixApp +from fastflix.naming import ( + PRE_ENCODE_VARIABLES, + POST_ENCODE_VARIABLES, + ALL_VARIABLES, + generate_preview, + validate_template, +) from fastflix.shared import error_message, link +from fastflix.widgets.flow_layout import FlowLayout logger = logging.getLogger("fastflix") language_list = [v.name for v in iter_langs() if v.pt2b and v.pt1] @@ -53,6 +61,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): tab_widget = QtWidgets.QTabWidget() tab_widget.addTab(self._build_settings_tab(), t("Settings")) + tab_widget.addTab(self._build_output_naming_tab(), t("Output Naming")) tab_widget.addTab(self._build_locations_tab(), t("Application Locations")) main_layout.addWidget(tab_widget) @@ -269,6 +278,158 @@ def in_dir(): tab.setLayout(layout) return tab + def _build_output_naming_tab(self): + tab = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout() + + # Template editor + template_label = QtWidgets.QLabel(t("Output Filename Template")) + template_label.setStyleSheet("font-weight: bold;") + layout.addWidget(template_label) + + template_row = QtWidgets.QHBoxLayout() + self.template_edit = QtWidgets.QLineEdit() + self.template_edit.setText(self.app.fastflix.config.output_name_format) + self.template_edit.setPlaceholderText("{source}-fastflix-{rand_4}") + self.template_edit.textChanged.connect(self._update_template_preview) + template_row.addWidget(self.template_edit) + + reset_button = QtWidgets.QPushButton(t("Reset")) + reset_button.setFixedWidth(60) + reset_button.setToolTip(t("Reset to default template")) + reset_button.clicked.connect(lambda: self.template_edit.setText("{source}-fastflix-{rand_4}")) + template_row.addWidget(reset_button) + + layout.addLayout(template_row) + + # Live preview + preview_header = QtWidgets.QLabel(t("Preview:")) + preview_header.setStyleSheet("font-weight: bold; margin-top: 6px;") + layout.addWidget(preview_header) + + self.template_preview = QtWidgets.QLabel() + self.template_preview.setWordWrap(True) + self.template_preview.setStyleSheet("color: #888; padding: 4px;") + layout.addWidget(self.template_preview) + + # Validation status + self.template_status = QtWidgets.QLabel() + layout.addWidget(self.template_status) + + # Pre-encode variable chips + pre_header = QtWidgets.QLabel(t("Pre-Encode Variables")) + pre_header.setStyleSheet("font-weight: bold; margin-top: 10px;") + layout.addWidget(pre_header) + + self._variable_chips = {} + pre_flow = FlowLayout(h_spacing=6, v_spacing=6) + pre_container = QtWidgets.QWidget() + for var in PRE_ENCODE_VARIABLES: + chip = self._make_chip(f"{{{var.name}}}", var.description, var.example, is_post=False) + self._variable_chips[var.name] = chip + pre_flow.addWidget(chip) + pre_container.setLayout(pre_flow) + layout.addWidget(pre_container) + + # Post-encode variable chips + post_header = QtWidgets.QLabel(t("Post-Encode Variables")) + post_header.setStyleSheet("font-weight: bold; margin-top: 10px;") + layout.addWidget(post_header) + post_subtitle = QtWidgets.QLabel(t("Resolved after encoding completes; file will be renamed automatically")) + post_subtitle.setStyleSheet("color: #888; font-size: 11px;") + layout.addWidget(post_subtitle) + + post_flow = FlowLayout(h_spacing=6, v_spacing=6) + post_container = QtWidgets.QWidget() + for var in POST_ENCODE_VARIABLES: + chip = self._make_chip(f"{{{var.name}}}", var.description, var.example, is_post=True) + self._variable_chips[var.name] = chip + post_flow.addWidget(chip) + post_container.setLayout(post_flow) + layout.addWidget(post_container) + + # Variable reference table (always visible) + self.ref_table = QtWidgets.QTableWidget() + self.ref_table.setColumnCount(4) + self.ref_table.setHorizontalHeaderLabels([t("Variable"), t("Description"), t("Phase"), t("Example")]) + self.ref_table.setRowCount(len(ALL_VARIABLES)) + self.ref_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.ref_table.horizontalHeader().setStretchLastSection(True) + self.ref_table.verticalHeader().setVisible(False) + for row, var in enumerate(ALL_VARIABLES): + self.ref_table.setItem(row, 0, QtWidgets.QTableWidgetItem(f"{{{var.name}}}")) + self.ref_table.setItem(row, 1, QtWidgets.QTableWidgetItem(var.description)) + phase_text = t("Pre-Encode") if var.phase.value == "pre_encode" else t("Post-Encode") + self.ref_table.setItem(row, 2, QtWidgets.QTableWidgetItem(phase_text)) + self.ref_table.setItem(row, 3, QtWidgets.QTableWidgetItem(var.example)) + self.ref_table.resizeColumnsToContents() + layout.addWidget(self.ref_table) + + # Initialize preview + self._update_template_preview() + + tab.setLayout(layout) + return tab + + _CHIP_STYLE_PRE = ( + "QPushButton { background-color: #2a6e9e; color: white; border-radius: 10px; " + "padding: 4px 10px; font-size: 12px; border: 1px solid #3a8ebe; }" + "QPushButton:hover { background-color: #3a7eae; }" + ) + _CHIP_STYLE_POST = ( + "QPushButton { background-color: #5b4a8a; color: white; border-radius: 10px; " + "padding: 4px 10px; font-size: 12px; border: 1px solid #7a6aaa; }" + "QPushButton:hover { background-color: #6b5a9a; }" + ) + _CHIP_STYLE_DISABLED = ( + "QPushButton { background-color: #555; color: #888; border-radius: 10px; " + "padding: 4px 10px; font-size: 12px; border: 1px solid #666; }" + ) + + def _make_chip(self, text, description, example, is_post=False): + """Create a clickable variable chip button.""" + chip = QtWidgets.QPushButton(text) + chip.setToolTip(f"{description}\n{t('Example')}: {example}") + chip.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) + chip.setProperty("is_post", is_post) + chip.setStyleSheet(self._CHIP_STYLE_POST if is_post else self._CHIP_STYLE_PRE) + chip.clicked.connect(lambda: self._insert_variable(text)) + return chip + + def _insert_variable(self, text): + """Insert variable text at cursor position in template editor.""" + cursor_pos = self.template_edit.cursorPosition() + current = self.template_edit.text() + new_text = current[:cursor_pos] + text + current[cursor_pos:] + self.template_edit.setText(new_text) + self.template_edit.setCursorPosition(cursor_pos + len(text)) + self.template_edit.setFocus() + + def _update_template_preview(self): + """Update the live preview, validation status, and chip enabled states.""" + template = self.template_edit.text() + is_valid, message = validate_template(template) + + if is_valid: + self.template_status.setText(f'\u2714 {message}') + else: + self.template_status.setText(f'\u2718 {message}') + + preview = generate_preview(template) + self.template_preview.setText(preview) + + # Gray out chips for variables already present in the template + for var_name, chip in self._variable_chips.items(): + already_used = f"{{{var_name}}}" in template + chip.setEnabled(not already_used) + if already_used: + chip.setStyleSheet(self._CHIP_STYLE_DISABLED) + chip.setCursor(QtGui.QCursor(QtCore.Qt.ForbiddenCursor)) + else: + is_post = chip.property("is_post") + chip.setStyleSheet(self._CHIP_STYLE_POST if is_post else self._CHIP_STYLE_PRE) + chip.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) + def _build_locations_tab(self): tab = QtWidgets.QWidget() layout = QtWidgets.QGridLayout() @@ -508,6 +669,17 @@ def save(self): if self.app.fastflix.config.ui_scale != old_scale: restart_needed = True + # Output naming template + new_template = self.template_edit.text().strip() + if new_template: + is_valid, msg = validate_template(new_template) + if not is_valid: + error_message(f"{t('Invalid output naming template')}: {msg}", parent=self) + return + self.app.fastflix.config.output_name_format = new_template + else: + self.app.fastflix.config.output_name_format = "{source}-fastflix-{rand_4}" + self.app.fastflix.config.clean_old_logs = self.clean_old_logs_button.isChecked() self.app.fastflix.config.sticky_tabs = self.sticky_tabs.isChecked() self.app.fastflix.config.disable_complete_message = self.disable_end_message.isChecked() diff --git a/pyproject.toml b/pyproject.toml index 7648d092..a3225524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dev = [ "types-requests>=2.32", "types-setuptools>=80.9", "typing-extensions>=4.14", - "wheel>=0.45", + "wheel>=0.46", ] [tool.setuptools.dynamic] diff --git a/uv.lock b/uv.lock index 1598e0fb..75528d3f 100644 --- a/uv.lock +++ b/uv.lock @@ -266,7 +266,7 @@ dev = [ { name = "types-requests", specifier = ">=2.32" }, { name = "types-setuptools", specifier = ">=80.9" }, { name = "typing-extensions", specifier = ">=4.14" }, - { name = "wheel", specifier = ">=0.45" }, + { name = "wheel", specifier = ">=0.46" }, ] [[package]] From 53cdb350aa72fc20449afcf71f951aba3d3ac20a Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 13 Feb 2026 21:34:42 -0600 Subject: [PATCH 06/10] Changelog update --- CHANGES | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index d5f9a096..64a18032 100644 --- a/CHANGES +++ b/CHANGES @@ -2,14 +2,14 @@ ## Version 6.1.0 -* Adding Output Naming settings tab with template editor, clickable variable chips, live preview, and validation for customizing output filenames with 23 pre-encode variables +* Adding #717 Output Naming settings tab with template editor, clickable variable chips, live preview, and validation for customizing output filenames with 23 pre-encode variables (thanks to roxerqermik) * Adding always-on ffprobe validation of output files after every encode to catch silent failures * Adding Visual Crop window for dragging crop edges directly on a video frame preview, with live overlay and divisible-by-8 snapping on save -* Adding Data & Attachments tab for per-track control of data streams (timecodes, navigation) and non-image attachments (fonts) +* Adding #660 Data & Attachments tab for per-track control of data streams (timecodes, navigation) and non-image attachments (fonts) (thanks to techguru0) * Fixing UI scaling for Source/Folder/Filename text boxes, file extension dropdown, Resolution label, Start/End Time controls, and Crop input fields being too small -* Adding language and disposition metadata parsing from auto-detected external subtitle filenames (e.g., video.forced.deu.srt) -* Adding auto-detection of external subtitle files (.srt, .ass, .ssa, .vtt, .sup, .sub, .idx) when loading a video, configurable in Settings -* Adding external subtitle support for Rigaya encoders (NVEncC, QSVEncC, VCEEncC) via --sub-source +* Adding #706 language and disposition metadata parsing from auto-detected external subtitle filenames (e.g., video.forced.deu.srt) (thanks to mpek) +* Adding #706 auto-detection of external subtitle files (.srt, .ass, .ssa, .vtt, .sup, .sub, .idx) when loading a video, configurable in Settings (thanks to mpek) +* Adding #698 external subtitle support for Rigaya encoders (NVEncC, QSVEncC, VCEEncC) via --sub-source (thanks to Augusto7743) * Adding bottom status bar with animated icon showing encoding state, progress bar, and status messages * Adding startup tasks (FFmpeg config, GPU detect, HDR10+ download) running through the status bar with main window visible * Adding Terms and Agreements dialog shown on first startup requiring user acceptance before proceeding From bca15e9fead647f7716c44de49abcf33806a2233 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 18 Feb 2026 17:18:30 -0600 Subject: [PATCH 07/10] Changelog update --- CHANGES | 11 ++++++----- fastflix/widgets/main.py | 7 +++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGES b/CHANGES index 64a18032..c6ed6b31 100644 --- a/CHANGES +++ b/CHANGES @@ -3,19 +3,16 @@ ## Version 6.1.0 * Adding #717 Output Naming settings tab with template editor, clickable variable chips, live preview, and validation for customizing output filenames with 23 pre-encode variables (thanks to roxerqermik) -* Adding always-on ffprobe validation of output files after every encode to catch silent failures -* Adding Visual Crop window for dragging crop edges directly on a video frame preview, with live overlay and divisible-by-8 snapping on save * Adding #660 Data & Attachments tab for per-track control of data streams (timecodes, navigation) and non-image attachments (fonts) (thanks to techguru0) -* Fixing UI scaling for Source/Folder/Filename text boxes, file extension dropdown, Resolution label, Start/End Time controls, and Crop input fields being too small * Adding #706 language and disposition metadata parsing from auto-detected external subtitle filenames (e.g., video.forced.deu.srt) (thanks to mpek) * Adding #706 auto-detection of external subtitle files (.srt, .ass, .ssa, .vtt, .sup, .sub, .idx) when loading a video, configurable in Settings (thanks to mpek) * Adding #698 external subtitle support for Rigaya encoders (NVEncC, QSVEncC, VCEEncC) via --sub-source (thanks to Augusto7743) +* Adding always-on ffprobe validation of output files after every encode to catch silent failures +* Adding Visual Crop window for dragging crop edges directly on a video frame preview, with live overlay and divisible-by-8 snapping on save * Adding bottom status bar with animated icon showing encoding state, progress bar, and status messages * Adding startup tasks (FFmpeg config, GPU detect, HDR10+ download) running through the status bar with main window visible * Adding Terms and Agreements dialog shown on first startup requiring user acceptance before proceeding * Fixing #719 Unable to save/load film grain setting for SVT-AV1 (thanks to gabriel101x) -* Fixing profile load/save for VVC period and threads, VP9 auto alt ref, lag in frames, AQ mode, and sharpness, rav1e photon noise, and AOM-AV1 denoise settings using integer as combo box index instead of matching by value -* Fixing VCEEncC pre-analysis lookahead setting reading from wrong widget (pa_initqpsc instead of pa_lookahead) in HEVC, AV1, and AVC encoders * Fixing #716 Maximize button not working (thanks to roxerqermik and 19Battlestar65) * Fixing #349 NVEncC audio conversion losing multichannel layout for EAC3 (thanks to Wontell) * Fixing #384 Remove HDR leaving Dolby Vision metadata traces in Rigaya encoder output (thanks to end2endzone) @@ -25,6 +22,10 @@ * Fixing #600 anime subtitle size increasing during burn-in encoding (thanks to TinderboxUK) * Fixing #693 subtitle tracks losing title metadata during encoding (thanks to mpissarello) * Fixing #715 WINDOWS_BUILD.md needed updated to show Python 3.13 (thanks to Jack L) +* Fixing #720 custom profile resolution settings (Height, Width, Long Edge, Custom, explicit like 640x480) being ignored when loading a video, defaulting to source resolution instead of profile resolution (thanks to Xoanon88) +* Fixing UI scaling for Source/Folder/Filename text boxes, file extension dropdown, Resolution label, Start/End Time controls, and Crop input fields being too small +* Fixing profile load/save for VVC period and threads, VP9 auto alt ref, lag in frames, AQ mode, and sharpness, rav1e photon noise, and AOM-AV1 denoise settings using integer as combo box index instead of matching by value +* Fixing VCEEncC pre-analysis lookahead setting reading from wrong widget (pa_initqpsc instead of pa_lookahead) in HEVC, AV1, and AVC encoders * Fixing Settings window staying on top and freezing when notification dialogs appeared behind it * Fixing tab bar scroll arrows being too small and both stuck on the right side - now larger with left arrow on far left diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index ea050984..13a29094 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -1079,7 +1079,10 @@ def set_profile(self): if not res_pix: matcher = {"method": res_method} else: - matcher = {"method": res_method, "pixels": res_pix} + try: + matcher = {"method": res_method, "pixels": int(res_pix)} + except (ValueError, TypeError): + matcher = {"method": res_method, "pixels": res_pix} if matcher in resolutions.values(): for k, v in resolutions.items(): @@ -1287,7 +1290,7 @@ def update_resolution(self): elif self.widgets.resolution_drop_down.currentIndex() in {1, 2, 3, 4}: self.widgets.resolution_custom.setDisabled(False) self.widgets.resolution_custom.setPlaceholderText(self.widgets.resolution_drop_down.currentText()) - if self.app.fastflix.current_video: + if self.app.fastflix.current_video and not self.loading_video: match resolutions[self.widgets.resolution_drop_down.currentText()]["method"]: case "long edge": self.widgets.resolution_custom.setText( From a2fcae6c8017857eb06d4ab2e8b8593e880f5d53 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 18 Feb 2026 18:02:26 -0600 Subject: [PATCH 08/10] Add missing files --- .pre-commit-config.yaml | 7 +- fastflix/data/icons/white/crop.svg | 15 + fastflix/naming.py | 408 +++++++++++++ fastflix/widgets/flow_layout.py | 109 ++++ fastflix/widgets/panels/data_panel.py | 308 ++++++++++ fastflix/widgets/status_bar.py | 289 +++++++++ fastflix/widgets/windows/crop_window.py | 739 ++++++++++++++++++++++++ tests/test_data_panel.py | 194 +++++++ tests/test_local_encode.py | 492 ++++++++++++++++ tests/test_naming.py | 364 ++++++++++++ tests/test_profile_round_trip.py | 291 ++++++++++ tests/test_subtitle_filename_parsing.py | 118 ++++ 12 files changed, 3330 insertions(+), 4 deletions(-) create mode 100644 fastflix/data/icons/white/crop.svg create mode 100644 fastflix/naming.py create mode 100644 fastflix/widgets/flow_layout.py create mode 100644 fastflix/widgets/panels/data_panel.py create mode 100644 fastflix/widgets/status_bar.py create mode 100644 fastflix/widgets/windows/crop_window.py create mode 100644 tests/test_data_panel.py create mode 100644 tests/test_local_encode.py create mode 100644 tests/test_naming.py create mode 100644 tests/test_profile_round_trip.py create mode 100644 tests/test_subtitle_filename_parsing.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7eb719f5..fe4a61ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: mixed-line-ending - id: trailing-whitespace @@ -8,8 +8,7 @@ repos: - id: requirements-txt-fixer - id: check-yaml - id: check-json - - id: fix-encoding-pragma - - id: check-byte-order-marker + - id: fix-byte-order-marker - id: debug-statements - id: check-added-large-files exclude: | @@ -24,7 +23,7 @@ repos: - id: end-of-file-fixer - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.1 + rev: v0.15.1 hooks: # Run the linter. - id: ruff-check diff --git a/fastflix/data/icons/white/crop.svg b/fastflix/data/icons/white/crop.svg new file mode 100644 index 00000000..df0f177c --- /dev/null +++ b/fastflix/data/icons/white/crop.svg @@ -0,0 +1,15 @@ + + + + + + + diff --git a/fastflix/naming.py b/fastflix/naming.py new file mode 100644 index 00000000..f4d58506 --- /dev/null +++ b/fastflix/naming.py @@ -0,0 +1,408 @@ +# -*- coding: utf-8 -*- +import logging +import re +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional + +from pathvalidate import sanitize_filename + +logger = logging.getLogger("fastflix") + +MAX_FILENAME_PATH_LENGTH = 250 + + +class VariablePhase(Enum): + PRE_ENCODE = "pre_encode" + POST_ENCODE = "post_encode" + + +@dataclass +class TemplateVariable: + name: str + description: str + phase: VariablePhase + example: str + placeholder: str = "" # For post-encode vars, filesystem-safe placeholder + + +PRE_ENCODE_VARIABLES = [ + TemplateVariable("source", "Source filename stem", VariablePhase.PRE_ENCODE, "MyMovie"), + TemplateVariable("datetime", "ISO date+time (queue time)", VariablePhase.PRE_ENCODE, "2026-02-13T14-30-00"), + TemplateVariable("date", "Date only (queue time)", VariablePhase.PRE_ENCODE, "2026-02-13"), + TemplateVariable("time", "Time only (queue time)", VariablePhase.PRE_ENCODE, "14-30-00"), + TemplateVariable("rand_4", "4-char random hex", VariablePhase.PRE_ENCODE, "a3f1"), + TemplateVariable("rand_8", "8-char random hex", VariablePhase.PRE_ENCODE, "a3f1b2c4"), + TemplateVariable("encoder", "Short encoder name", VariablePhase.PRE_ENCODE, "x265"), + TemplateVariable("codec", "Full codec name", VariablePhase.PRE_ENCODE, "HEVC (x265)"), + TemplateVariable("preset", "Encoder preset", VariablePhase.PRE_ENCODE, "medium"), + TemplateVariable("crf", "CRF/QP value", VariablePhase.PRE_ENCODE, "22"), + TemplateVariable("bitrate", "Target bitrate", VariablePhase.PRE_ENCODE, "5000k"), + TemplateVariable("pix_fmt", "Pixel format", VariablePhase.PRE_ENCODE, "yuv420p10le"), + TemplateVariable("resolution", "Output resolution", VariablePhase.PRE_ENCODE, "1920x1080"), + TemplateVariable("source_resolution", "Source resolution", VariablePhase.PRE_ENCODE, "3840x2160"), + TemplateVariable("source_duration", "Source duration", VariablePhase.PRE_ENCODE, "01-23-45"), + TemplateVariable("source_fps", "Source frame rate", VariablePhase.PRE_ENCODE, "23.976"), + TemplateVariable("source_codec", "Source video codec", VariablePhase.PRE_ENCODE, "hevc"), + TemplateVariable("source_bitrate", "Source overall bitrate", VariablePhase.PRE_ENCODE, "45000k"), + TemplateVariable("audio_codec", "First audio track codec", VariablePhase.PRE_ENCODE, "aac"), + TemplateVariable("audio_channels", "First audio channels", VariablePhase.PRE_ENCODE, "5.1"), + TemplateVariable("color_space", "Color space", VariablePhase.PRE_ENCODE, "bt2020nc"), + TemplateVariable("color_primaries", "Color primaries", VariablePhase.PRE_ENCODE, "bt2020"), + TemplateVariable("hdr", "HDR type or SDR", VariablePhase.PRE_ENCODE, "HDR10"), +] + +POST_ENCODE_VARIABLES = [ + TemplateVariable("encode_time", "Encode duration", VariablePhase.POST_ENCODE, "00-15-32", "FFETIME"), + TemplateVariable("encode_end", "Finish timestamp", VariablePhase.POST_ENCODE, "2026-02-13T14-45-32", "FFEEND"), + TemplateVariable("filesize", "Human-readable size", VariablePhase.POST_ENCODE, "1.5GB", "FFFSIZE"), + TemplateVariable("filesize_mb", "Size in MB", VariablePhase.POST_ENCODE, "1536", "FFFSMB"), + TemplateVariable("video_bitrate", "Actual video bitrate", VariablePhase.POST_ENCODE, "4523k", "FFVIDBIT"), + TemplateVariable("audio_bitrate", "Actual audio bitrate", VariablePhase.POST_ENCODE, "320k", "FFAUDBIT"), + TemplateVariable("overall_bitrate", "Overall bitrate", VariablePhase.POST_ENCODE, "4843k", "FFALLBIT"), +] + +ALL_VARIABLES = PRE_ENCODE_VARIABLES + POST_ENCODE_VARIABLES + +_VARIABLE_MAP = {v.name: v for v in ALL_VARIABLES} +_POST_ENCODE_PLACEHOLDERS = {v.placeholder: v for v in POST_ENCODE_VARIABLES} + + +def safe_format(template: str, variables: dict) -> str: + """Replace {var} patterns in template with values from variables dict. + Unknown variables are left as-is.""" + + def replacer(match): + key = match.group(1) + if key in variables: + return str(variables[key]) + return match.group(0) + + return re.sub(r"\{(\w+)\}", replacer, template) + + +def _extract_short_encoder_name(full_name: str) -> str: + """Extract short encoder name from full codec name like 'HEVC (x265)' -> 'x265'.""" + m = re.search(r"\((.+)\)", full_name) + if m: + return m.group(1) + return full_name.replace(" ", "-") + + +def _format_duration_hms(seconds: float) -> str: + """Format seconds as HH-MM-SS (filesystem-safe).""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + return f"{hours:02d}-{minutes:02d}-{secs:02d}" + + +def _format_bitrate(bps: float) -> str: + """Format bits per second as human-readable like '4523k'.""" + kbps = int(bps / 1000) + if kbps >= 1000: + return f"{kbps}k" + return f"{kbps}k" + + +def _format_filesize(size_bytes: int) -> str: + """Format file size as human-readable.""" + if size_bytes >= 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024 * 1024):.1f}GB" + if size_bytes >= 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f}MB" + return f"{size_bytes / 1024:.1f}KB" + + +def _get_channel_layout(channels: int) -> str: + """Convert channel count to common layout name.""" + layouts = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"} + return layouts.get(channels, str(channels)) + + +def _get_hdr_type(video) -> str: + """Determine HDR type from video metadata.""" + if hasattr(video, "hdr10_plus") and video.hdr10_plus: + return "HDR10+" + if hasattr(video, "hdr10_streams") and video.hdr10_streams: + return "HDR10" + color_transfer = "" + if hasattr(video, "color_transfer"): + color_transfer = video.color_transfer + if color_transfer == "smpte2084": + return "HDR10" + if color_transfer == "arib-std-b67": + return "HLG" + return "SDR" + + +def _safe_value(value: str) -> str: + """Sanitize a single variable value for use in filenames.""" + return str(sanitize_filename(str(value), replacement_text="-")) + + +def _eval_frame_rate(fr_string: str) -> str: + """Evaluate frame rate fraction like '24000/1001' to '23.976'.""" + if not fr_string: + return "N-A" + if "/" in fr_string: + try: + num, den = fr_string.split("/") + val = float(num) / float(den) + return f"{val:.3f}" + except (ValueError, ZeroDivisionError): + return fr_string + return fr_string + + +def resolve_pre_encode_variables( + template: str, + source_path: Path, + video=None, + encoder_settings=None, + video_settings=None, +) -> str: + """Resolve all pre-encode variables in a template string. + + Inserts placeholders for any post-encode variables present in the template. + """ + import secrets + + now = datetime.now() + iso_datetime = now.strftime("%Y-%m-%dT%H-%M-%S") + date_only = now.strftime("%Y-%m-%d") + time_only = now.strftime("%H-%M-%S") + + variables = { + "source": source_path.stem, + "datetime": iso_datetime, + "date": date_only, + "time": time_only, + "rand_4": secrets.token_hex(2), + "rand_8": secrets.token_hex(4), + } + + # Encoder-specific variables + if encoder_settings is not None: + full_name = getattr(encoder_settings, "name", "N-A") + variables["codec"] = full_name + variables["encoder"] = _extract_short_encoder_name(full_name) + variables["preset"] = str(getattr(encoder_settings, "preset", "N-A")) + + crf_val = getattr(encoder_settings, "crf", None) + if crf_val is None: + crf_val = getattr(encoder_settings, "qp", None) + variables["crf"] = str(crf_val) if crf_val is not None else "N-A" + + bitrate_val = getattr(encoder_settings, "bitrate", None) + variables["bitrate"] = str(bitrate_val) if bitrate_val else "N-A" + + variables["pix_fmt"] = str(getattr(encoder_settings, "pix_fmt", "N-A")) + else: + for key in ("encoder", "codec", "preset", "crf", "bitrate", "pix_fmt"): + variables.setdefault(key, "N-A") + + # Source and video variables + if video is not None: + variables["source_resolution"] = f"{video.width}x{video.height}" + variables["source_duration"] = _format_duration_hms(video.duration) if video.duration else "N-A" + variables["source_fps"] = _eval_frame_rate(video.frame_rate) + variables["color_space"] = video.color_space or "N-A" + variables["color_primaries"] = video.color_primaries or "N-A" + variables["hdr"] = _get_hdr_type(video) + + # Source codec from streams + if video.streams and video.streams.video: + stream = video.current_video_stream + if stream: + variables["source_codec"] = stream.get("codec_name", "N-A") + else: + variables["source_codec"] = "N-A" + else: + variables["source_codec"] = "N-A" + + # Source bitrate from format + if video.format and video.format.get("bit_rate"): + try: + bps = int(video.format.bit_rate) + variables["source_bitrate"] = _format_bitrate(bps) + except (ValueError, TypeError): + variables["source_bitrate"] = "N-A" + else: + variables["source_bitrate"] = "N-A" + + # Audio info from first audio track + if video.streams and video.streams.get("audio"): + audio = video.streams.audio[0] + variables["audio_codec"] = audio.get("codec_name", "N-A") + channels = audio.get("channels", 0) + variables["audio_channels"] = _get_channel_layout(channels) + else: + variables["audio_codec"] = "N-A" + variables["audio_channels"] = "N-A" + else: + for key in ( + "source_resolution", + "source_duration", + "source_fps", + "source_codec", + "source_bitrate", + "audio_codec", + "audio_channels", + "color_space", + "color_primaries", + "hdr", + ): + variables.setdefault(key, "N-A") + + # Output resolution + if video_settings is not None and video is not None: + scale = video.scale + if scale: + variables["resolution"] = scale.replace(":", "x").replace("-8", "auto") + else: + variables["resolution"] = f"{video.width}x{video.height}" + else: + variables["resolution"] = variables.get("source_resolution", "N-A") + + # Sanitize all values for filesystem safety + variables = {k: _safe_value(v) for k, v in variables.items()} + + # Insert placeholders for post-encode variables + for var in POST_ENCODE_VARIABLES: + variables[var.name] = var.placeholder + + return safe_format(template, variables) + + +def resolve_post_encode_variables( + filename: str, + output_path: Path, + probe_data, + encode_start: Optional[datetime] = None, + encode_end: Optional[datetime] = None, +) -> str: + """Replace post-encode placeholders in a filename with actual values from probe data.""" + replacements = {} + + # Encode time + if encode_start and encode_end: + elapsed = (encode_end - encode_start).total_seconds() + replacements["FFETIME"] = _safe_value(_format_duration_hms(elapsed)) + else: + replacements["FFETIME"] = "N-A" + + # Encode end timestamp + if encode_end: + replacements["FFEEND"] = _safe_value(encode_end.strftime("%Y-%m-%dT%H-%M-%S")) + else: + replacements["FFEEND"] = _safe_value(datetime.now().strftime("%Y-%m-%dT%H-%M-%S")) + + # File size + try: + size_bytes = output_path.stat().st_size + replacements["FFFSIZE"] = _safe_value(_format_filesize(size_bytes)) + replacements["FFFSMB"] = str(int(size_bytes / (1024 * 1024))) + except OSError: + replacements["FFFSIZE"] = "N-A" + replacements["FFFSMB"] = "N-A" + + # Bitrate info from probe data + if probe_data: + # Overall bitrate + overall_br = None + if hasattr(probe_data, "format") and probe_data.format: + overall_br = probe_data.format.get("bit_rate") + if overall_br: + try: + replacements["FFALLBIT"] = _format_bitrate(int(overall_br)) + except (ValueError, TypeError): + replacements["FFALLBIT"] = "N-A" + else: + replacements["FFALLBIT"] = "N-A" + + # Per-stream bitrates + video_br = "N-A" + audio_br = "N-A" + if hasattr(probe_data, "streams"): + for stream in probe_data.streams: + codec_type = stream.get("codec_type", "") + br = stream.get("bit_rate") + if codec_type == "video" and br and video_br == "N-A": + try: + video_br = _format_bitrate(int(br)) + except (ValueError, TypeError): + pass + elif codec_type == "audio" and br and audio_br == "N-A": + try: + audio_br = _format_bitrate(int(br)) + except (ValueError, TypeError): + pass + replacements["FFVIDBIT"] = video_br + replacements["FFAUDBIT"] = audio_br + else: + for key in ("FFALLBIT", "FFVIDBIT", "FFAUDBIT"): + replacements[key] = "N-A" + + # Do the replacements + result = filename + for placeholder, value in replacements.items(): + result = result.replace(placeholder, value) + + return result + + +def has_post_encode_placeholders(filename: str) -> bool: + """Check if any post-encode placeholders exist in the filename.""" + return any(var.placeholder in filename for var in POST_ENCODE_VARIABLES) + + +def generate_preview(template: str) -> str: + """Generate a preview of the template using example values.""" + variables = {} + for var in ALL_VARIABLES: + variables[var.name] = var.example + return safe_format(template, variables) + + +def validate_template(template: str) -> tuple[bool, str]: + """Validate a template string. Returns (is_valid, message).""" + if not template.strip(): + return False, "Template cannot be empty" + + unknown = [] + for match in re.finditer(r"\{(\w+)\}", template): + name = match.group(1) + if name not in _VARIABLE_MAP: + unknown.append(name) + + if unknown: + return False, f"Unknown variable(s): {', '.join('{' + n + '}' for n in unknown)}" + + return True, "Valid template" + + +def truncate_filename(name: str, directory: str, extension: str) -> tuple[str, bool]: + """Truncate the filename stem so the full path stays within MAX_FILENAME_PATH_LENGTH. + + Returns (possibly_truncated_name, was_truncated). + The directory and extension lengths are accounted for. + """ + # +1 for the path separator between directory and filename + overhead = len(directory) + 1 + len(extension) + max_name_len = MAX_FILENAME_PATH_LENGTH - overhead + + if max_name_len < 10: + # Directory path is extremely long, allow at least 10 chars for the name + max_name_len = 10 + + if len(name) <= max_name_len: + return name, False + + truncated = name[:max_name_len] + logger.info( + f"Output filename truncated from {len(name)} to {max_name_len} chars (path limit {MAX_FILENAME_PATH_LENGTH})" + ) + return truncated, True diff --git a/fastflix/widgets/flow_layout.py b/fastflix/widgets/flow_layout.py new file mode 100644 index 00000000..3acc8112 --- /dev/null +++ b/fastflix/widgets/flow_layout.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# FlowLayout implementation based on Qt documentation examples. +# Arranges child widgets in a wrapping horizontal flow. + +from PySide6 import QtCore, QtWidgets + + +class FlowLayout(QtWidgets.QLayout): + def __init__(self, parent=None, margin=-1, h_spacing=-1, v_spacing=-1): + super().__init__(parent) + self._h_space = h_spacing + self._v_space = v_spacing + self._item_list = [] + if margin >= 0: + self.setContentsMargins(margin, margin, margin, margin) + + def addItem(self, item): + self._item_list.append(item) + + def horizontalSpacing(self): + if self._h_space >= 0: + return self._h_space + return self._smart_spacing(QtWidgets.QStyle.PM_LayoutHorizontalSpacing) + + def verticalSpacing(self): + if self._v_space >= 0: + return self._v_space + return self._smart_spacing(QtWidgets.QStyle.PM_LayoutVerticalSpacing) + + def count(self): + return len(self._item_list) + + def itemAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list[index] + return None + + def takeAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list.pop(index) + return None + + def expandingDirections(self): + return QtCore.Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._do_layout(QtCore.QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QtCore.QSize() + for item in self._item_list: + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + size += QtCore.QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) + return size + + def _do_layout(self, rect, test_only): + left, top, right, bottom = self.getContentsMargins() + effective_rect = rect.adjusted(left, top, -right, -bottom) + x = effective_rect.x() + y = effective_rect.y() + line_height = 0 + + for item in self._item_list: + widget = item.widget() + h_space = self.horizontalSpacing() + v_space = self.verticalSpacing() + + if h_space == -1: + h_space = widget.style().layoutSpacing( + QtWidgets.QSizePolicy.PushButton, QtWidgets.QSizePolicy.PushButton, QtCore.Qt.Horizontal + ) + if v_space == -1: + v_space = widget.style().layoutSpacing( + QtWidgets.QSizePolicy.PushButton, QtWidgets.QSizePolicy.PushButton, QtCore.Qt.Vertical + ) + + next_x = x + item.sizeHint().width() + h_space + if next_x - h_space > effective_rect.right() and line_height > 0: + x = effective_rect.x() + y = y + line_height + v_space + next_x = x + item.sizeHint().width() + h_space + line_height = 0 + + if not test_only: + item.setGeometry(QtCore.QRect(QtCore.QPoint(x, y), item.sizeHint())) + + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + return y + line_height - rect.y() + bottom + + def _smart_spacing(self, pm): + parent = self.parent() + if parent is None: + return -1 + if parent.isWidgetType(): + return parent.style().pixelMetric(pm, None, parent) + return parent.spacing() diff --git a/fastflix/widgets/panels/data_panel.py b/fastflix/widgets/panels/data_panel.py new file mode 100644 index 00000000..4d304460 --- /dev/null +++ b/fastflix/widgets/panels/data_panel.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import logging + +from box import Box +from PySide6 import QtGui, QtWidgets + +from fastflix.language import t +from fastflix.models.encode import DataTrack +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.resources import get_icon +from fastflix.shared import no_border, shrink_text_to_fit +from fastflix.ui_scale import scaler +from fastflix.widgets.panels.abstract_list import FlixList + +logger = logging.getLogger("fastflix") + +COVER_NAMES = {"cover", "small_cover", "cover_land", "small_cover_land"} + +# Container support for data/attachment streams +# MKV supports everything; MP4/M4V support timecodes but not font attachments +NO_DATA_EXTENSIONS = {".gif", ".webm", ".webp", ".avif"} +NO_ATTACHMENT_EXTENSIONS = {".gif", ".webm", ".webp", ".avif", ".mp4", ".m4v", ".mov", ".ts", ".mts", ".m2ts"} + + +class DataTrackWidget(QtWidgets.QTabWidget): + def __init__(self, app, parent, index, enabled=True, first=False): + self.loading = True + super(DataTrackWidget, self).__init__(parent) + self.app = app + self.parent = parent + self.setObjectName("DataTrack") + self.index = index + self.outdex = None + self.first = first + self.last = False + + track: DataTrack = self.app.fastflix.current_video.data_tracks[index] + + # Determine type badge text + if track.codec_type == "attachment": + type_badge = t("Font") if track.mimetype and "font" in track.mimetype.lower() else t("Attachment") + else: + type_badge = t("Data") + + self.widgets = Box( + track_number=QtWidgets.QLabel(f"{track.index}:{track.outdex}" if enabled else "❌"), + info_label=QtWidgets.QLabel(f" {track.friendly_info}"), + type_badge=QtWidgets.QLabel(type_badge), + up_button=QtWidgets.QPushButton( + QtGui.QIcon(get_icon("up-arrow", self.parent.app.fastflix.config.theme)), "" + ), + down_button=QtWidgets.QPushButton( + QtGui.QIcon(get_icon("down-arrow", self.parent.app.fastflix.config.theme)), "" + ), + enable_check=QtWidgets.QCheckBox(t("Preserve")), + ) + + self.widgets.up_button.setStyleSheet(no_border) + self.widgets.down_button.setStyleSheet(no_border) + + self.widgets.enable_check.setChecked(enabled) + self.widgets.enable_check.toggled.connect(self.update_enable) + + self.setFixedHeight(60) + + # Tooltip with raw info + if track.raw_info: + try: + self.widgets.info_label.setToolTip(Box(track.raw_info).to_yaml()) + except Exception: + self.widgets.info_label.setToolTip(str(track.raw_info)) + + # Check container compatibility + self._check_compatibility(track) + + self.grid = QtWidgets.QGridLayout() + self.grid.addLayout(self.init_move_buttons(), 0, 0) + self.grid.addWidget(self.widgets.track_number, 0, 1) + self.grid.addWidget(self.widgets.info_label, 0, 2) + self.grid.setColumnStretch(2, True) + self.grid.addWidget(self.widgets.type_badge, 0, 3) + self.grid.addWidget(self.widgets.enable_check, 0, 4) + + self.setLayout(self.grid) + self.loading = False + + def _check_compatibility(self, track: DataTrack): + output_path = self.app.fastflix.current_video.video_settings.output_path + if not output_path: + return + ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "" + ext_with_dot = f".{ext}" + + incompatible = False + reason = "" + + if track.codec_type == "data" and ext_with_dot in NO_DATA_EXTENSIONS: + incompatible = True + reason = t("Data streams are not supported in this output format") + elif track.codec_type == "attachment" and ext_with_dot in NO_ATTACHMENT_EXTENSIONS: + incompatible = True + reason = t("Attachment streams are not supported in this output format") + + if incompatible: + self.widgets.enable_check.setChecked(False) + self.widgets.enable_check.setEnabled(False) + self.widgets.enable_check.setToolTip(reason) + track.enabled = False + + def init_move_buttons(self): + layout = QtWidgets.QVBoxLayout() + layout.setSpacing(0) + self.widgets.up_button.setDisabled(self.first) + self.widgets.up_button.setFixedWidth(scaler.scale(17)) + self.widgets.up_button.setFixedHeight(scaler.scale(20)) + self.widgets.up_button.setIconSize(scaler.scale_size(12, 12)) + self.widgets.up_button.clicked.connect(lambda: self.parent.move_up(self)) + self.widgets.down_button.setDisabled(self.last) + self.widgets.down_button.setFixedWidth(scaler.scale(17)) + self.widgets.down_button.setFixedHeight(scaler.scale(20)) + self.widgets.down_button.setIconSize(scaler.scale_size(12, 12)) + self.widgets.down_button.clicked.connect(lambda: self.parent.move_down(self)) + layout.addWidget(self.widgets.up_button) + layout.addWidget(self.widgets.down_button) + return layout + + def set_first(self, first=True): + self.first = first + self.widgets.up_button.setDisabled(self.first) + + def set_last(self, last=True): + self.last = last + self.widgets.down_button.setDisabled(self.last) + + def set_outdex(self, outdex): + self.app.fastflix.current_video.data_tracks[self.index].outdex = outdex + track: DataTrack = self.app.fastflix.current_video.data_tracks[self.index] + self.outdex = outdex + if not self.enabled: + self.widgets.track_number.setText("❌") + else: + self.widgets.track_number.setText(f"{track.index}:{track.outdex}") + + @property + def enabled(self): + try: + return self.app.fastflix.current_video.data_tracks[self.index].enabled + except IndexError: + return False + + def update_enable(self): + enabled = self.widgets.enable_check.isChecked() + track = self.app.fastflix.current_video.data_tracks[self.index] + track.enabled = enabled + self.widgets.track_number.setText(f"{track.index}:{track.outdex}" if enabled else "❌") + self.parent.reorder(update=True) + + def page_update(self): + if not self.loading: + return self.parent.main.page_update(build_thumbnail=False) + + +class DataList(FlixList): + def __init__(self, parent, app: FastFlixApp): + top_layout = QtWidgets.QHBoxLayout() + + label = QtWidgets.QLabel(t("Data & Attachments")) + label.setFixedHeight(30) + top_layout.addWidget(label) + top_layout.addStretch(1) + + self.remove_all_button = QtWidgets.QPushButton(t("Unselect All")) + self.remove_all_button.setFixedWidth(150) + self.remove_all_button.clicked.connect(lambda: self.select_all(False)) + self.save_all_button = QtWidgets.QPushButton(t("Preserve All")) + self.save_all_button.setFixedWidth(150) + self.save_all_button.clicked.connect(lambda: self.select_all(True)) + + for w in (self.remove_all_button, self.save_all_button): + shrink_text_to_fit(w) + + top_layout.addWidget(self.remove_all_button) + top_layout.addWidget(self.save_all_button) + + super().__init__(app, parent, "Data & Attachments", "data", top_row_layout=top_layout) + self.main = parent.main + self.app = app + + def select_all(self, select=True): + for track in self.tracks: + if track.widgets.enable_check.isEnabled(): + track.widgets.enable_check.setChecked(select) + + def _is_cover_attachment(self, stream): + """Check if an attachment stream is a cover image (handled by cover panel).""" + filename = stream.get("tags", {}).get("filename", "") + mimetype = stream.get("tags", {}).get("mimetype", "") + base_name = filename.rsplit(".", 1)[0].lower() if filename else "" + if base_name in COVER_NAMES: + return True + if mimetype.startswith("image"): + return True + return False + + def new_source(self): + self.tracks = [] + video = self.app.fastflix.current_video + + # Add data streams + for stream in getattr(video.streams, "data", []): + codec_name = stream.get("codec_name", "") + codec_long_name = stream.get("codec_long_name", codec_name) + title = stream.get("tags", {}).get("title", "") + friendly = codec_long_name or codec_name + if title: + friendly = f"{title} ({friendly})" + + video.data_tracks.append( + DataTrack( + index=stream.index, + outdex=0, + enabled=True, + codec_name=codec_name, + codec_type="data", + title=title, + friendly_info=friendly, + raw_info=stream, + ) + ) + + # Add non-image attachment streams (fonts, etc.) + for stream in getattr(video.streams, "attachment", []): + if self._is_cover_attachment(stream): + continue + codec_name = stream.get("codec_name", "") + filename = stream.get("tags", {}).get("filename", "") + mimetype = stream.get("tags", {}).get("mimetype", "") + friendly = filename if filename else codec_name + if mimetype: + friendly = f"{friendly} ({mimetype})" + + video.data_tracks.append( + DataTrack( + index=stream.index, + outdex=0, + enabled=True, + codec_name=codec_name, + codec_type="attachment", + filename=filename, + mimetype=mimetype, + friendly_info=friendly, + raw_info=stream, + ) + ) + + for i, track in enumerate(video.data_tracks): + new_item = DataTrackWidget( + app=self.app, + parent=self, + index=i, + first=True if i == 0 else False, + enabled=track.enabled, + ) + self.tracks.append(new_item) + + if self.tracks: + self.tracks[0].set_first() + self.tracks[-1].set_last() + + super()._new_source(self.tracks) + + def get_settings(self): + # Widget state is already written to data_tracks via set_outdex / update_enable + pass + + def reload(self, data_tracks): + from fastflix.shared import clear_list + + clear_list(self.tracks) + + for i, track in enumerate(self.app.fastflix.current_video.data_tracks): + self.tracks.append( + DataTrackWidget( + app=self.app, + parent=self, + index=i, + first=True if i == 0 else False, + enabled=track.enabled, + ) + ) + super()._new_source(self.tracks) + + def move_up(self, widget): + self.app.fastflix.current_video.data_tracks.insert( + widget.index - 1, self.app.fastflix.current_video.data_tracks.pop(widget.index) + ) + index = self.tracks.index(widget) + self.tracks.insert(index - 1, self.tracks.pop(index)) + self.reorder() + + def move_down(self, widget): + self.app.fastflix.current_video.data_tracks.insert( + widget.index + 1, self.app.fastflix.current_video.data_tracks.pop(widget.index) + ) + index = self.tracks.index(widget) + self.tracks.insert(index + 1, self.tracks.pop(index)) + self.reorder() diff --git a/fastflix/widgets/status_bar.py b/fastflix/widgets/status_bar.py new file mode 100644 index 00000000..4e92ffa2 --- /dev/null +++ b/fastflix/widgets/status_bar.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +import logging +from dataclasses import dataclass, field +from typing import Callable + +from PySide6 import QtCore, QtGui, QtWidgets + +from fastflix.language import t +from fastflix.models.fastflix_app import FastFlixApp +from fastflix.resources import main_icon +from fastflix.ui_scale import scaler + +logger = logging.getLogger("fastflix") + + +@dataclass +class Task: + name: str + command: Callable + kwargs: dict = field(default_factory=dict) + + +# State constants +STATE_IDLE = "idle" +STATE_ENCODING = "encoding" +STATE_PROCESSING = "processing" +STATE_ERROR = "error" +STATE_COMPLETE = "complete" + +STATE_COLORS = { + STATE_IDLE: QtGui.QColor("#888888"), + STATE_ENCODING: QtGui.QColor("#3daee9"), + STATE_PROCESSING: QtGui.QColor("#f0a030"), + STATE_ERROR: QtGui.QColor("#da4453"), + STATE_COMPLETE: QtGui.QColor("#27ae60"), +} + + +class StatusBarWidget(QtWidgets.QFrame): + progress_signal = QtCore.Signal(int) + stop_signal = QtCore.Signal() + + def __init__(self, app: FastFlixApp, parent=None): + super().__init__(parent) + self.app = app + self.cancelled = False + self._state = STATE_IDLE + self._pulse_bright = True + + self.setObjectName("StatusBarWidget") + self.setFixedHeight(scaler.scale(28)) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(6, 2, 6, 2) + layout.setSpacing(8) + + # Icon label + self.icon_label = QtWidgets.QLabel() + self.icon_label.setFixedSize(scaler.scale(20), scaler.scale(20)) + self.icon_label.setStyleSheet("background: transparent;") + self._base_icon = self._load_base_icon() + self._update_icon(STATE_COLORS[STATE_IDLE]) + layout.addWidget(self.icon_label) + + # Status message + self.status_label = QtWidgets.QLabel(t("Ready")) + self.status_label.setMinimumWidth(100) + layout.addWidget(self.status_label, stretch=1) + + # Progress bar — hidden by default, shown only when actively used + self.progress_bar = QtWidgets.QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(True) + self.progress_bar.setFixedHeight(scaler.scale(16)) + self.progress_bar.hide() + layout.addWidget(self.progress_bar, stretch=1) + + self.setLayout(layout) + + # Pulse animation timer + self._pulse_timer = QtCore.QTimer(self) + self._pulse_timer.setInterval(500) + self._pulse_timer.timeout.connect(self._pulse_tick) + + self._apply_theme_style() + + def _load_base_icon(self) -> QtGui.QPixmap: + pixmap = QtGui.QPixmap(main_icon) + if pixmap.isNull(): + pixmap = QtGui.QPixmap(20, 20) + pixmap.fill(QtCore.Qt.transparent) + return pixmap.scaled( + scaler.scale(20), + scaler.scale(20), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation, + ) + + def _create_tinted_icon(self, color: QtGui.QColor) -> QtGui.QPixmap: + tinted = QtGui.QPixmap(self._base_icon.size()) + tinted.fill(QtCore.Qt.transparent) + painter = QtGui.QPainter(tinted) + painter.drawPixmap(0, 0, self._base_icon) + painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceIn) + painter.fillRect(tinted.rect(), color) + painter.end() + return tinted + + def _update_icon(self, color: QtGui.QColor, opacity: float = 1.0): + tinted = self._create_tinted_icon(color) + if opacity < 1.0: + faded = QtGui.QPixmap(tinted.size()) + faded.fill(QtCore.Qt.transparent) + painter = QtGui.QPainter(faded) + painter.setOpacity(opacity) + painter.drawPixmap(0, 0, tinted) + painter.end() + tinted = faded + self.icon_label.setPixmap(tinted) + + def _pulse_tick(self): + self._pulse_bright = not self._pulse_bright + opacity = 1.0 if self._pulse_bright else 0.4 + color = STATE_COLORS.get(self._state, STATE_COLORS[STATE_IDLE]) + self._update_icon(color, opacity) + + def _show_progress(self): + if not self.progress_bar.isVisible(): + self.progress_bar.show() + + def _hide_progress(self): + if self.progress_bar.isVisible(): + self.progress_bar.setValue(0) + self.progress_bar.hide() + + def set_state(self, state: str, message: str = ""): + self._state = state + color = STATE_COLORS.get(state, STATE_COLORS[STATE_IDLE]) + + if message: + self.status_label.setText(message) + elif state == STATE_IDLE: + self.status_label.setText(t("Ready")) + + if state == STATE_IDLE: + self._hide_progress() + + # Start/stop pulsing based on state + if state in (STATE_ENCODING, STATE_PROCESSING): + self._pulse_bright = True + self._update_icon(color, 1.0) + if not self._pulse_timer.isActive(): + self._pulse_timer.start() + else: + self._pulse_timer.stop() + self._pulse_bright = True + self._update_icon(color, 1.0) + + def set_progress(self, percent: int): + percent = max(0, min(100, percent)) + if percent > 0: + self._show_progress() + self.progress_bar.setValue(percent) + else: + self._hide_progress() + + def run_tasks( + self, tasks: list, signal_task: bool = False, can_cancel: bool = False, persist_complete: bool = False + ): + """Blocking task runner that replaces ProgressBar popup for post-startup tasks. + + Runs tasks synchronously while updating the status bar, calling processEvents() + to keep the UI responsive. + + If persist_complete is True and tasks succeed without error or cancellation, + the status bar stays green with a completion message instead of restoring + the previous state. + """ + if not tasks: + logger.error("Status bar run_tasks called without any tasks") + return + + self.cancelled = False + errored = False + previous_state = self._state + previous_message = self.status_label.text() + previous_progress = self.progress_bar.value() + previous_visible = self.progress_bar.isVisible() + + self.set_state(STATE_PROCESSING, tasks[0].name) + self.progress_bar.setValue(0) + self._show_progress() + self.app.processEvents() + + try: + if signal_task: + self.progress_signal.connect(self._update_task_progress) + tasks[0].kwargs["signal"] = self.progress_signal + tasks[0].kwargs["stop_signal"] = self.stop_signal + try: + tasks[0].command(config=self.app.fastflix.config, app=self.app, **tasks[0].kwargs) + finally: + try: + self.progress_signal.disconnect(self._update_task_progress) + except RuntimeError: + pass + else: + ratio = 100 / len(tasks) + for i, task in enumerate(tasks, start=1): + logger.info(f"Running task {task.name}") + self.set_state(STATE_PROCESSING, task.name) + self.app.processEvents() + if self.app.fastflix.shutting_down or self.cancelled: + break + try: + task.command(config=self.app.fastflix.config, app=self.app, **task.kwargs) + except Exception: + logger.exception(f"Could not run task {task.name}") + raise + self.set_progress(int(i * ratio)) + self.app.processEvents() + except Exception: + errored = True + raise + finally: + if persist_complete and not self.cancelled and not errored: + self.set_state(STATE_COMPLETE, f"{tasks[-1].name} - {t('Complete')}") + self._hide_progress() + else: + self.set_state(previous_state, previous_message) + if previous_visible and previous_progress > 0: + self.set_progress(previous_progress) + else: + self._hide_progress() + + def _update_task_progress(self, value: int): + self.set_progress(value) + self.app.processEvents() + if self.app.fastflix.shutting_down: + self.stop_signal.emit() + + def cancel(self): + self.stop_signal.emit() + self.cancelled = True + + def _apply_theme_style(self): + theme = self.app.fastflix.config.theme + if theme == "onyx": + self.setStyleSheet("#StatusBarWidget { background-color: #3a4149; border-top: 1px solid #567781;}") + self.status_label.setStyleSheet("color: #b5b5b5; background: transparent;") + self.progress_bar.setStyleSheet( + "QProgressBar {" + " background-color: #4a555e;" + " border: 1px solid #567781;" + " border-radius: 3px;" + " color: white;" + " text-align: center;" + "}" + "QProgressBar::chunk {" + " background-color: #3daee9;" + " border-radius: 2px;" + "}" + ) + elif theme == "dark": + self.setStyleSheet("#StatusBarWidget { background-color: #2d2d2d; border-top: 1px solid #555555;}") + self.status_label.setStyleSheet("color: #cccccc; background: transparent;") + self.progress_bar.setStyleSheet( + "QProgressBar {" + " background-color: #3d3d3d;" + " border: 1px solid #555555;" + " border-radius: 3px;" + " color: white;" + " text-align: center;" + "}" + "QProgressBar::chunk {" + " background-color: #3daee9;" + " border-radius: 2px;" + "}" + ) + else: + self.setStyleSheet("#StatusBarWidget { background-color: #f0f0f0; border-top: 1px solid #cccccc;}") + self.status_label.setStyleSheet("color: #333333; background: transparent;") + + def update_theme(self): + self._apply_theme_style() + + def cleanup(self): + self._pulse_timer.stop() diff --git a/fastflix/widgets/windows/crop_window.py b/fastflix/widgets/windows/crop_window.py new file mode 100644 index 00000000..07b1cfd8 --- /dev/null +++ b/fastflix/widgets/windows/crop_window.py @@ -0,0 +1,739 @@ +# -*- coding: utf-8 -*- +import logging +import secrets +from pathlib import Path +from subprocess import run, PIPE +from typing import Optional, TYPE_CHECKING + +from PySide6 import QtWidgets, QtCore, QtGui + +from fastflix.encoders.common import helpers +from fastflix.flix import generate_thumbnail_command +from fastflix.language import t +from fastflix.shared import time_to_number +from fastflix.ui_scale import scaler + +if TYPE_CHECKING: + from fastflix.widgets.main import Main + +__all__ = ["CropPreviewWindow"] + +logger = logging.getLogger("fastflix") + + +class CropImageWidget(QtWidgets.QWidget): + """Interactive widget that displays a video frame and allows dragging crop edges.""" + + EDGE_TOLERANCE = 8 # pixels for edge hit detection + MIN_CROP_SIZE = 64 # minimum crop area in video pixels + + def __init__(self, parent: "CropPreviewWindow"): + super().__init__(parent) + self.crop_window = parent + self.setMouseTracking(True) + self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + + # Display state + self.display_rect = QtCore.QRect() # where the image is drawn in widget coords + self.frame_pixmap: Optional[QtGui.QPixmap] = None + self.preview_pixmap: Optional[QtGui.QPixmap] = None + + # Drag state - list of active edges (e.g. ["top", "left"] for corner drag) + self._dragging_edges: list[str] = [] + + def set_frame(self, pixmap: QtGui.QPixmap): + self.frame_pixmap = pixmap + self.update() + + def set_preview(self, pixmap: QtGui.QPixmap): + self.preview_pixmap = pixmap + self.update() + + def _compute_display_rect(self, pixmap: QtGui.QPixmap) -> QtCore.QRect: + """Compute the rectangle where the pixmap should be drawn, centered and aspect-ratio preserved.""" + if pixmap.isNull(): + return QtCore.QRect() + pw, ph = pixmap.width(), pixmap.height() + ww, wh = self.width(), self.height() + scale = min(ww / pw, wh / ph) + dw = int(pw * scale) + dh = int(ph * scale) + dx = (ww - dw) // 2 + dy = (wh - dh) // 2 + return QtCore.QRect(dx, dy, dw, dh) + + def _video_to_widget(self, vx: float, vy: float) -> QtCore.QPoint: + """Convert video pixel coordinates to widget coordinates.""" + if not self.frame_pixmap or self.frame_pixmap.isNull() or self.display_rect.isEmpty(): + return QtCore.QPoint(0, 0) + sx = self.display_rect.width() / self.frame_pixmap.width() + sy = self.display_rect.height() / self.frame_pixmap.height() + return QtCore.QPoint( + int(self.display_rect.x() + vx * sx), + int(self.display_rect.y() + vy * sy), + ) + + def _widget_to_video(self, wx: int, wy: int) -> tuple[int, int]: + """Convert widget coordinates to video pixel coordinates, clamped to bounds.""" + if not self.frame_pixmap or self.frame_pixmap.isNull() or self.display_rect.isEmpty(): + return 0, 0 + sx = self.frame_pixmap.width() / self.display_rect.width() + sy = self.frame_pixmap.height() / self.display_rect.height() + vx = int((wx - self.display_rect.x()) * sx) + vy = int((wy - self.display_rect.y()) * sy) + vx = max(0, min(vx, self.frame_pixmap.width())) + vy = max(0, min(vy, self.frame_pixmap.height())) + return vx, vy + + def _edges_at_pos(self, pos: QtCore.QPoint) -> list[str]: + """Return which crop edges are near the given widget position (can be 2 for corners).""" + if not self.frame_pixmap or self.frame_pixmap.isNull(): + return [] + crop = self.crop_window.crop_values + video_w = self.crop_window.video_width + video_h = self.crop_window.video_height + + # Crop edges in video coordinates + left_v = crop["left"] + right_v = video_w - crop["right"] + top_v = crop["top"] + bottom_v = video_h - crop["bottom"] + + # Convert to widget coordinates + left_w = self._video_to_widget(left_v, 0).x() + right_w = self._video_to_widget(right_v, 0).x() + top_w = self._video_to_widget(0, top_v).y() + bottom_w = self._video_to_widget(0, bottom_v).y() + + tol = self.EDGE_TOLERANCE + px, py = pos.x(), pos.y() + + edges = [] + near_left = abs(px - left_w) <= tol + near_right = abs(px - right_w) <= tol + near_top = abs(py - top_w) <= tol + near_bottom = abs(py - bottom_w) <= tol + + # Vertical edges need cursor within vertical range (with tolerance) + in_v_range = top_w - tol <= py <= bottom_w + tol + # Horizontal edges need cursor within horizontal range (with tolerance) + in_h_range = left_w - tol <= px <= right_w + tol + + if near_top and in_h_range: + edges.append("top") + if near_bottom and in_h_range: + edges.append("bottom") + if near_left and in_v_range: + edges.append("left") + if near_right and in_v_range: + edges.append("right") + return edges + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform) + + if self.crop_window.mode == "preview" and self.preview_pixmap and not self.preview_pixmap.isNull(): + rect = self._compute_display_rect(self.preview_pixmap) + painter.drawPixmap(rect, self.preview_pixmap) + self.display_rect = self._compute_display_rect(self.frame_pixmap) if self.frame_pixmap else rect + painter.end() + return + + if not self.frame_pixmap or self.frame_pixmap.isNull(): + painter.end() + return + + self.display_rect = self._compute_display_rect(self.frame_pixmap) + painter.drawPixmap(self.display_rect, self.frame_pixmap) + + # Draw crop overlay + crop = self.crop_window.crop_values + video_w = self.crop_window.video_width + video_h = self.crop_window.video_height + + tl = self._video_to_widget(crop["left"], crop["top"]) + br = self._video_to_widget(video_w - crop["right"], video_h - crop["bottom"]) + crop_rect = QtCore.QRect(tl, br) + + # Semi-transparent overlay outside crop area + overlay_color = QtGui.QColor(0, 0, 0, 140) + dr = self.display_rect + + # Top strip + painter.fillRect(QtCore.QRect(dr.left(), dr.top(), dr.width(), tl.y() - dr.top()), overlay_color) + # Bottom strip + painter.fillRect(QtCore.QRect(dr.left(), br.y(), dr.width(), dr.bottom() - br.y() + 1), overlay_color) + # Left strip (between top and bottom) + painter.fillRect(QtCore.QRect(dr.left(), tl.y(), tl.x() - dr.left(), br.y() - tl.y()), overlay_color) + # Right strip (between top and bottom) + painter.fillRect(QtCore.QRect(br.x(), tl.y(), dr.right() - br.x() + 1, br.y() - tl.y()), overlay_color) + + # Crop border + pen = QtGui.QPen(QtGui.QColor(0, 180, 255), 2) + painter.setPen(pen) + painter.drawRect(crop_rect) + + # Drag handles at edge midpoints + handle_size = 8 + handle_color = QtGui.QColor(0, 180, 255) + painter.setBrush(handle_color) + painter.setPen(QtCore.Qt.NoPen) + mid_y = (tl.y() + br.y()) // 2 + mid_x = (tl.x() + br.x()) // 2 + + # Edge midpoint handles + painter.drawRect(tl.x() - handle_size // 2, mid_y - handle_size // 2, handle_size, handle_size) + painter.drawRect(br.x() - handle_size // 2, mid_y - handle_size // 2, handle_size, handle_size) + painter.drawRect(mid_x - handle_size // 2, tl.y() - handle_size // 2, handle_size, handle_size) + painter.drawRect(mid_x - handle_size // 2, br.y() - handle_size // 2, handle_size, handle_size) + # Corner handles + painter.drawRect(tl.x() - handle_size // 2, tl.y() - handle_size // 2, handle_size, handle_size) + painter.drawRect(br.x() - handle_size // 2, tl.y() - handle_size // 2, handle_size, handle_size) + painter.drawRect(tl.x() - handle_size // 2, br.y() - handle_size // 2, handle_size, handle_size) + painter.drawRect(br.x() - handle_size // 2, br.y() - handle_size // 2, handle_size, handle_size) + + painter.end() + + @staticmethod + def _cursor_for_edges(edges: list[str]) -> QtCore.Qt.CursorShape: + s = set(edges) + if s in ({"top", "left"}, {"bottom", "right"}): + return QtCore.Qt.SizeFDiagCursor + if s in ({"top", "right"}, {"bottom", "left"}): + return QtCore.Qt.SizeBDiagCursor + if s & {"left", "right"}: + return QtCore.Qt.SizeHorCursor + if s & {"top", "bottom"}: + return QtCore.Qt.SizeVerCursor + return QtCore.Qt.ArrowCursor + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton and self.crop_window.mode == "crop": + edges = self._edges_at_pos(event.pos()) + if edges: + self._dragging_edges = edges + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self.crop_window.mode != "crop": + return super().mouseMoveEvent(event) + + if self._dragging_edges: + vx, vy = self._widget_to_video(event.pos().x(), event.pos().y()) + crop = self.crop_window.crop_values + video_w = self.crop_window.video_width + video_h = self.crop_window.video_height + + if "left" in self._dragging_edges: + max_val = video_w - crop["right"] - self.MIN_CROP_SIZE + crop["left"] = max(0, min(vx, max_val)) + if "right" in self._dragging_edges: + max_val = video_w - crop["left"] - self.MIN_CROP_SIZE + crop["right"] = max(0, min(video_w - vx, max_val)) + if "top" in self._dragging_edges: + max_val = video_h - crop["bottom"] - self.MIN_CROP_SIZE + crop["top"] = max(0, min(vy, max_val)) + if "bottom" in self._dragging_edges: + max_val = video_h - crop["top"] - self.MIN_CROP_SIZE + crop["bottom"] = max(0, min(video_h - vy, max_val)) + + self.crop_window.update_size_label() + self.update() + else: + # Update cursor based on edge proximity + edges = self._edges_at_pos(event.pos()) + self.setCursor(self._cursor_for_edges(edges)) + + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._dragging_edges = [] + super().mouseReleaseEvent(event) + + +class TimelineWidget(QtWidgets.QWidget): + """Custom widget that overlays start/end time markers on top of the slider area.""" + + def __init__(self, qt_parent: QtWidgets.QWidget, crop_window: "CropPreviewWindow"): + super().__init__(qt_parent) + self.crop_window = crop_window + self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + slider = self.crop_window.slider + video = self.crop_window.main.app.fastflix.current_video + if not video or video.duration <= 0: + painter.end() + return + + # Get slider geometry relative to this widget's parent (they share the same parent) + slider_geo = slider.geometry() + # The groove is inset from the slider widget edges by handle half-width + handle_w = 12 # matches stylesheet + groove_left = slider_geo.x() + handle_w // 2 + groove_right = slider_geo.x() + slider_geo.width() - handle_w // 2 + groove_width = groove_right - groove_left + + duration = video.duration + marker_h = self.height() + + # Draw start time marker + if self.crop_window.start_time is not None and self.crop_window.start_time > 0: + frac = self.crop_window.start_time / duration + x = int(groove_left + frac * groove_width) + pen = QtGui.QPen(QtGui.QColor(0, 200, 80), 2) + painter.setPen(pen) + painter.drawLine(x, 0, x, marker_h) + # Small label + painter.setFont(QtGui.QFont("sans-serif", 7)) + painter.drawText(x + 3, 10, "S") + + # Draw end time marker + if self.crop_window.end_time is not None and self.crop_window.end_time < duration: + frac = self.crop_window.end_time / duration + x = int(groove_left + frac * groove_width) + pen = QtGui.QPen(QtGui.QColor(200, 60, 60), 2) + painter.setPen(pen) + painter.drawLine(x, 0, x, marker_h) + painter.setFont(QtGui.QFont("sans-serif", 7)) + painter.drawText(x + 3, 10, "E") + + painter.end() + + +class CropPreviewWindow(QtWidgets.QWidget): + """Window for visually setting crop values by dragging edges on a video frame.""" + + def __init__(self, parent: "Main"): + super().__init__() + self.main = parent + self.mode = "crop" # "crop" or "preview" + self.crop_values = {"top": 0, "bottom": 0, "left": 0, "right": 0} + self.video_width = 0 + self.video_height = 0 + self.start_time: Optional[float] = None # None means not set by user in this window + self.end_time: Optional[float] = None + self.last_path: Optional[Path] = None + + self.setWindowTitle(t("Visual Crop")) + self.setMinimumSize(600, 400) + # Default size: ~90% of the 1200x680 reference resolution + self.resize(scaler.scale(1080), scaler.scale(612)) + screen = self.main.app.primaryScreen() + if screen: + size = screen.size() + self.setMaximumWidth(size.width()) + self.setMaximumHeight(size.height()) + + self._build_ui() + + def _build_ui(self): + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Top bar + top_bar = QtWidgets.QWidget() + top_bar.setFixedHeight(scaler.scale(40)) + top_bar.setStyleSheet("background-color: #2d3136;") + top_layout = QtWidgets.QHBoxLayout(top_bar) + top_layout.setContentsMargins(scaler.scale(10), 0, scaler.scale(10), 0) + + self.reset_btn = QtWidgets.QPushButton() + self.reset_btn.setFixedSize(scaler.scale(28), scaler.scale(28)) + self.reset_btn.setIcon(self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_BrowserReload)) + self.reset_btn.setToolTip(t("Reset")) + self.reset_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.reset_btn.clicked.connect(self.reset_crop) + + self.crop_btn = QtWidgets.QPushButton(t("Crop")) + self.preview_btn = QtWidgets.QPushButton(t("Preview")) + for btn in (self.crop_btn, self.preview_btn): + btn.setFixedHeight(scaler.scale(28)) + btn.setCursor(QtCore.Qt.PointingHandCursor) + self.crop_btn.clicked.connect(lambda: self.switch_mode("crop")) + self.preview_btn.clicked.connect(lambda: self.switch_mode("preview")) + + self.set_start_btn = QtWidgets.QPushButton(t("Set Start Time")) + self.set_start_btn.setFixedHeight(scaler.scale(28)) + self.set_start_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.set_start_btn.clicked.connect(self.set_start_time) + + self.set_end_btn = QtWidgets.QPushButton(t("Set End Time")) + self.set_end_btn.setFixedHeight(scaler.scale(28)) + self.set_end_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.set_end_btn.clicked.connect(self.set_end_time) + + self.size_label = QtWidgets.QLabel("") + self.size_label.setStyleSheet("color: #b5b5b5;") + self.size_label.setAlignment(QtCore.Qt.AlignCenter) + + self.save_btn = QtWidgets.QPushButton(t("Save")) + self.save_btn.setFixedHeight(scaler.scale(28)) + self.save_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.save_btn.clicked.connect(self.save_crop) + + self.close_btn = QtWidgets.QPushButton(t("Close")) + self.close_btn.setFixedHeight(scaler.scale(28)) + self.close_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.close_btn.clicked.connect(self.hide) + + top_layout.addWidget(self.reset_btn) + top_layout.addWidget(self.crop_btn) + top_layout.addWidget(self.preview_btn) + top_layout.addWidget(self.set_start_btn) + top_layout.addWidget(self.set_end_btn) + top_layout.addStretch(1) + top_layout.addWidget(self.size_label) + top_layout.addStretch(1) + top_layout.addWidget(self.save_btn) + top_layout.addWidget(self.close_btn) + + self._update_button_styles() + + layout.addWidget(top_bar) + + # Image widget + self.image_widget = CropImageWidget(self) + self.image_widget.setStyleSheet("background-color: #1a1d21;") + layout.addWidget(self.image_widget, 1) + + # Bottom bar with slider + bottom_bar = QtWidgets.QWidget() + bottom_bar.setFixedHeight(scaler.scale(36)) + bottom_bar.setStyleSheet("background-color: #2d3136;") + bottom_layout = QtWidgets.QHBoxLayout(bottom_bar) + bottom_layout.setContentsMargins(scaler.scale(10), scaler.scale(4), scaler.scale(10), scaler.scale(4)) + + self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.slider.setMinimum(1) + self.slider.setMaximum(100) + self.slider.sliderReleased.connect(self.slider_changed) + self.slider.valueChanged.connect(self._update_time_label) + self.slider.installEventFilter(self) + self.slider.setStyleSheet(""" + QSlider { background: transparent; } + QSlider::groove:horizontal { + background: rgba(255, 255, 255, 40); + height: 6px; + border-radius: 3px; + } + QSlider::handle:horizontal { + background: rgba(255, 255, 255, 255); + width: 12px; + height: 16px; + margin: -5px 0; + border-radius: 3px; + } + QSlider::sub-page:horizontal { background: transparent; } + """) + + self.time_label = QtWidgets.QLabel("0:00:00") + self.time_label.setStyleSheet("color: white; font-weight: bold;") + self.time_label.setFixedWidth(scaler.scale(70)) + self.time_label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + + bottom_layout.addWidget(self.slider) + bottom_layout.addWidget(self.time_label) + + layout.addWidget(bottom_bar) + + # Timeline markers overlay (painted on top of the bottom bar) + self.timeline_overlay = TimelineWidget(bottom_bar, self) + self.timeline_overlay.setGeometry(bottom_bar.rect()) + self.timeline_overlay.raise_() + + def _update_button_styles(self): + active = ( + "QPushButton { background: #567781; color: white; border: none; border-radius: 4px; padding: 4px 12px; }" + ) + inactive = ( + "QPushButton { background: #4a555e; color: #b5b5b5; border: none; border-radius: 4px; padding: 4px 12px; }" + "QPushButton:hover { background: #566068; }" + ) + self.crop_btn.setStyleSheet(active if self.mode == "crop" else inactive) + self.preview_btn.setStyleSheet(active if self.mode == "preview" else inactive) + + reset_style = ( + "QPushButton { background: #4a555e; border: none; border-radius: 4px; }" + "QPushButton:hover { background: #566068; }" + ) + save_style = ( + "QPushButton { background: #567781; color: white; border: none; border-radius: 4px; padding: 4px 12px; }" + "QPushButton:hover { background: #678a94; }" + ) + close_style = ( + "QPushButton { background: #4a555e; color: #b5b5b5; border: none; border-radius: 4px; padding: 4px 12px; }" + "QPushButton:hover { background: #566068; }" + ) + start_active = self.start_time is not None and self.start_time > 0 + end_active = ( + self.end_time is not None + and self.main.app.fastflix.current_video + and self.end_time < self.main.app.fastflix.current_video.duration + ) + start_style = ( + "QPushButton { background: #2a6640; color: #80ff80; border: none; border-radius: 4px; padding: 4px 12px; }" + if start_active + else inactive + ) + end_style = ( + "QPushButton { background: #662a2a; color: #ff8080; border: none; border-radius: 4px; padding: 4px 12px; }" + if end_active + else inactive + ) + self.reset_btn.setStyleSheet(reset_style) + self.set_start_btn.setStyleSheet(start_style) + self.set_end_btn.setStyleSheet(end_style) + self.save_btn.setStyleSheet(save_style) + self.close_btn.setStyleSheet(close_style) + + def resizeEvent(self, event): + super().resizeEvent(event) + if hasattr(self, "timeline_overlay"): + self.timeline_overlay.setGeometry(self.timeline_overlay.parentWidget().rect()) + self.timeline_overlay.update() + + def keyPressEvent(self, event: QtGui.QKeyEvent): + if event.key() == QtCore.Qt.Key_Q: + self.hide() + elif event.key() == QtCore.Qt.Key_Escape: + self.hide() + super().keyPressEvent(event) + + def eventFilter(self, obj, event): + if obj == self.slider: + if event.type() == QtCore.QEvent.KeyRelease and event.key() in ( + QtCore.Qt.Key_Left, + QtCore.Qt.Key_Right, + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_PageDown, + ): + if not event.isAutoRepeat(): + self.slider_changed() + return super().eventFilter(obj, event) + + def open_window(self): + """Initialize from current main window values and generate frame.""" + video = self.main.app.fastflix.current_video + if not video: + return + + self.video_width = video.width + self.video_height = video.height + + # Read current crop values from main UI + try: + self.crop_values = { + "top": int(self.main.widgets.crop.top.text() or 0), + "bottom": int(self.main.widgets.crop.bottom.text() or 0), + "left": int(self.main.widgets.crop.left.text() or 0), + "right": int(self.main.widgets.crop.right.text() or 0), + } + except (ValueError, AttributeError): + self.crop_values = {"top": 0, "bottom": 0, "left": 0, "right": 0} + + # Read current start/end times from main UI + self.start_time = time_to_number(self.main.widgets.start_time.text()) + self.end_time = time_to_number(self.main.widgets.end_time.text()) + + # Sync slider range and position with main window slider + main_slider = self.main.widgets.thumb_time + self.slider.setMaximum(main_slider.maximum()) + self.slider.setPageStep(main_slider.pageStep()) + self.slider.setValue(main_slider.value()) + + self.mode = "crop" + self._update_button_styles() + self.update_size_label() + self.generate_image() + + def update_size_label(self): + """Update the label showing original -> cropped dimensions.""" + crop = self.crop_values + cw = self.video_width - crop["left"] - crop["right"] + ch = self.video_height - crop["top"] - crop["bottom"] + self.size_label.setText(f"{self.video_width}x{self.video_height} → {cw}x{ch}") + + def _get_preview_time(self) -> float: + """Calculate time position from slider, same formula as Main.preview_place.""" + video = self.main.app.fastflix.current_video + if not video: + return 0 + ticks = video.duration / self.slider.maximum() + return (self.slider.value() - 1) * ticks + + def _update_time_label(self): + seconds = self._get_preview_time() + self.time_label.setText(self.main.format_preview_time(seconds)) + if hasattr(self, "timeline_overlay"): + self.timeline_overlay.update() + + def set_start_time(self): + """Set start time from current slider position.""" + time = self._get_preview_time() + if self.end_time is not None and time >= self.end_time: + return + self.start_time = time + self._update_button_styles() + self.timeline_overlay.update() + + def set_end_time(self): + """Set end time from current slider position.""" + time = self._get_preview_time() + if self.start_time is not None and time <= self.start_time: + return + self.end_time = time + self._update_button_styles() + self.timeline_overlay.update() + + def generate_image(self, with_crop=False): + """Generate a video frame, optionally with crop applied.""" + video = self.main.app.fastflix.current_video + if not video or not video.video_settings.video_encoder_settings: + return + + settings = video.video_settings.model_dump() + + if video.video_settings.video_encoder_settings.pix_fmt == "yuv420p10le" and video.color_space.startswith( + "bt2020" + ): + settings["remove_hdr"] = True + if not settings.get("color_transfer"): + settings["color_transfer"] = video.color_transfer + + if with_crop: + # Apply current visual crop values for preview + crop = self.crop_values + cw = self.video_width - crop["left"] - crop["right"] + ch = self.video_height - crop["top"] - crop["bottom"] + settings["crop"] = {"width": cw, "height": ch, "left": crop["left"], "top": crop["top"]} + else: + # No crop for the base frame + settings["crop"] = None + + # Don't apply scale for the crop window - we want full resolution frame + settings["scale"] = None + + filters = helpers.generate_filters( + enable_opencl=False, + start_filters="select=eq(pict_type\\,I)" + if self.main.app.fastflix.config.use_keyframes_for_preview + else None, + **settings, + ) + + output = self.main.app.fastflix.config.work_path / f"crop_preview_{secrets.token_hex(16)}.tiff" + + thumb_command = generate_thumbnail_command( + config=self.main.app.fastflix.config, + source=self.main.source_material, + output=output, + filters=filters, + start_time=self._get_preview_time(), + input_track=video.video_settings.selected_track, + ) + + logger.info(f"Generating crop preview: {thumb_command}") + + thumb_run = run(thumb_command, shell=True, stderr=PIPE, stdout=PIPE) + if thumb_run.returncode > 0: + logger.warning(f"Could not generate crop preview: {thumb_run.stdout} |----| {thumb_run.stderr}") + return + + pixmap = QtGui.QPixmap(str(output)) + + if with_crop: + self.image_widget.set_preview(pixmap) + else: + self.image_widget.set_frame(pixmap) + + # Clean up previous temp file + if self.last_path: + try: + self.last_path.unlink(missing_ok=True) + except OSError: + logger.warning(f"Could not delete crop preview temp file {self.last_path}") + self.last_path = output + + def switch_mode(self, mode: str): + self.mode = mode + self._update_button_styles() + if mode == "preview": + self.generate_image(with_crop=True) + self.image_widget.update() + + def slider_changed(self): + self.generate_image(with_crop=False) + if self.mode == "preview": + self.generate_image(with_crop=True) + + def reset_crop(self): + """Reset all crop values to zero and clear start/end times.""" + self.crop_values = {"top": 0, "bottom": 0, "left": 0, "right": 0} + self.start_time = 0 + video = self.main.app.fastflix.current_video + if video: + self.end_time = video.duration + self.update_size_label() + self._update_button_styles() + self.timeline_overlay.update() + if self.mode == "preview": + self.generate_image(with_crop=True) + self.image_widget.update() + + def save_crop(self): + """Snap crop to divisible-by-8 dimensions and write back to main UI.""" + crop = self.crop_values + + w = self.video_width - crop["left"] - crop["right"] + h = self.video_height - crop["top"] - crop["bottom"] + + target_w = (w // 8) * 8 + target_h = (h // 8) * 8 + + w_diff = w - target_w + h_diff = h - target_h + + # Distribute remainder evenly across both edges + crop["left"] += w_diff // 2 + crop["right"] += w_diff - w_diff // 2 + crop["top"] += h_diff // 2 + crop["bottom"] += h_diff - h_diff // 2 + + # Write to main UI crop fields + self.main.widgets.crop.top.setText(str(crop["top"])) + self.main.widgets.crop.bottom.setText(str(crop["bottom"])) + self.main.widgets.crop.left.setText(str(crop["left"])) + self.main.widgets.crop.right.setText(str(crop["right"])) + + # Write start/end times back to main UI + if self.start_time is not None: + self.main.widgets.start_time.setText(self.main.number_to_time(self.start_time)) + if self.end_time is not None: + self.main.widgets.end_time.setText(self.main.number_to_time(self.end_time)) + + logger.info( + f"Crop saved: top={crop['top']} bottom={crop['bottom']} " + f"left={crop['left']} right={crop['right']} " + f"({self.video_width - crop['left'] - crop['right']}x" + f"{self.video_height - crop['top'] - crop['bottom']})" + ) + self.hide() + + def hideEvent(self, event): + # Clean up temp file on close + if self.last_path: + try: + self.last_path.unlink(missing_ok=True) + except OSError: + pass + self.last_path = None + super().hideEvent(event) diff --git a/tests/test_data_panel.py b/tests/test_data_panel.py new file mode 100644 index 00000000..d771fc6a --- /dev/null +++ b/tests/test_data_panel.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +from box import Box + +from fastflix.models.encode import DataTrack +from fastflix.encoders.common.encc_helpers import build_data +from fastflix.encoders.common.helpers import generate_ending + + +class TestDataTrackModel: + def test_create_data_track(self): + track = DataTrack(index=5, outdex=3, codec_name="bin_data", codec_type="data") + assert track.index == 5 + assert track.outdex == 3 + assert track.enabled is True + assert track.codec_name == "bin_data" + assert track.codec_type == "data" + + def test_create_attachment_track(self): + track = DataTrack( + index=10, + outdex=5, + codec_name="ttf", + codec_type="attachment", + mimetype="application/x-truetype-font", + filename="Arial.ttf", + ) + assert track.codec_type == "attachment" + assert track.mimetype == "application/x-truetype-font" + assert track.filename == "Arial.ttf" + + def test_defaults(self): + track = DataTrack(index=0, outdex=0) + assert track.enabled is True + assert track.codec_name == "" + assert track.codec_type == "" + assert track.title == "" + assert track.mimetype == "" + assert track.filename == "" + assert track.friendly_info == "" + assert track.raw_info is None + + def test_serialization(self): + track = DataTrack(index=5, outdex=3, codec_name="tmcd", codec_type="data", title="Timecode") + data = track.model_dump() + assert data["index"] == 5 + assert data["codec_name"] == "tmcd" + restored = DataTrack(**data) + assert restored.index == track.index + assert restored.codec_name == track.codec_name + + +class TestBuildDataRigaya: + def test_empty_tracks(self): + result = build_data([], [], []) + assert result == [] + + def test_data_only(self): + tracks = [DataTrack(index=5, outdex=3, enabled=True, codec_type="data")] + data_streams = [Box({"index": 5})] + result = build_data(tracks, data_streams, []) + assert "--data-copy" in result + assert "1" in result + + def test_attachment_only(self): + tracks = [DataTrack(index=10, outdex=5, enabled=True, codec_type="attachment")] + attachment_streams = [Box({"index": 10})] + result = build_data(tracks, [], attachment_streams) + assert "--attachment-copy" in result + assert "1" in result + + def test_mixed_data_and_attachment(self): + tracks = [ + DataTrack(index=5, outdex=3, enabled=True, codec_type="data"), + DataTrack(index=10, outdex=4, enabled=True, codec_type="attachment"), + ] + data_streams = [Box({"index": 5})] + attachment_streams = [Box({"index": 10})] + result = build_data(tracks, data_streams, attachment_streams) + assert "--data-copy" in result + assert "--attachment-copy" in result + + def test_disabled_tracks(self): + tracks = [ + DataTrack(index=5, outdex=3, enabled=False, codec_type="data"), + DataTrack(index=10, outdex=4, enabled=False, codec_type="attachment"), + ] + data_streams = [Box({"index": 5})] + attachment_streams = [Box({"index": 10})] + result = build_data(tracks, data_streams, attachment_streams) + assert result == [] + + def test_partial_disabled(self): + tracks = [ + DataTrack(index=5, outdex=3, enabled=True, codec_type="data"), + DataTrack(index=10, outdex=4, enabled=False, codec_type="attachment"), + ] + data_streams = [Box({"index": 5})] + attachment_streams = [Box({"index": 10})] + result = build_data(tracks, data_streams, attachment_streams) + assert "--data-copy" in result + assert "--attachment-copy" not in result + + def test_multiple_data_streams(self): + tracks = [ + DataTrack(index=5, outdex=3, enabled=True, codec_type="data"), + DataTrack(index=6, outdex=4, enabled=True, codec_type="data"), + ] + data_streams = [Box({"index": 5}), Box({"index": 6})] + result = build_data(tracks, data_streams, []) + assert "--data-copy" in result + idx = result.index("--data-copy") + assert result[idx + 1] == "1,2" + + +class TestGenerateEndingWithDataTracks: + def test_with_data_tracks(self): + tracks = [ + DataTrack(index=5, outdex=3, enabled=True, codec_type="data"), + DataTrack(index=10, outdex=4, enabled=True, codec_type="attachment"), + ] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + data_tracks=tracks, + ) + assert "-map" in result + assert "0:5" in result + assert "0:10" in result + assert "-c:d" in result + assert "copy" in result + assert "-c:t" in result + + def test_with_disabled_data_tracks(self): + tracks = [ + DataTrack(index=5, outdex=3, enabled=False, codec_type="data"), + ] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + data_tracks=tracks, + ) + assert "0:5" not in result + assert "-c:d" not in result + + def test_data_tracks_override_copy_data(self): + """When data_tracks is provided, copy_data should be ignored.""" + tracks = [DataTrack(index=5, outdex=3, enabled=True, codec_type="data")] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + data_tracks=tracks, + copy_data=True, + ) + # Should use per-track mapping, not bulk copy + assert "0:5" in result + assert "0:d" not in result + + def test_fallback_copy_data(self): + """When no data_tracks, copy_data=True should still work.""" + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + copy_data=True, + ) + assert "0:d" in result + assert "-c:d" in result + + def test_no_data(self): + """When no data_tracks and copy_data=False, no data mapping.""" + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + ) + assert "-c:d" not in result + assert "0:d" not in result + + def test_attachment_only_codec(self): + """When only attachment tracks, should set -c:t copy but not -c:d.""" + tracks = [DataTrack(index=10, outdex=4, enabled=True, codec_type="attachment")] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + data_tracks=tracks, + ) + assert "-c:t" in result + assert "-c:d" not in result diff --git a/tests/test_local_encode.py b/tests/test_local_encode.py new file mode 100644 index 00000000..17289f95 --- /dev/null +++ b/tests/test_local_encode.py @@ -0,0 +1,492 @@ +# -*- coding: utf-8 -*- +""" +Local-only integration tests that build real encoder commands and execute them. + +These tests: +1. Build actual encoder commands (no mocking) +2. Execute them against a real test video +3. Verify output with ffprobe +4. Skip on CI (GitHub Actions sets CI=true) +5. Skip encoders whose tools aren't installed + +Run: + uv run pytest tests/test_local_encode.py -v + uv run pytest tests/test_local_encode.py -v -k "x265" + CI=true uv run pytest tests/test_local_encode.py -v # all skipped +""" + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Optional + +import pytest +from box import Box +from platformdirs import user_data_dir + +from fastflix.models.config import Config +from fastflix.models.encode import ( + AOMAV1Settings, + CopySettings, + FFmpegNVENCSettings, + GIFSettings, + GifskiSettings, + NVEncCAV1Settings, + NVEncCAVCSettings, + NVEncCSettings, + QSVEncCAV1Settings, + QSVEncCH264Settings, + QSVEncCSettings, + SVTAV1Settings, + SVTAVIFSettings, + VCEEncCAV1Settings, + VCEEncCAVCSettings, + VCEEncCSettings, + VP9Settings, + VVCSettings, + WebPSettings, + rav1eSettings, + x264Settings, + x265Settings, +) +from fastflix.flix import guess_bit_depth +from fastflix.models.fastflix import FastFlix +from fastflix.models.video import Video, VideoSettings + +# --------------------------------------------------------------------------- +# Skip everything on CI +# --------------------------------------------------------------------------- +pytestmark = pytest.mark.local_only + +ON_CI = os.environ.get("CI", "").lower() in ("true", "1", "yes") +if ON_CI: + pytestmark = [pytestmark, pytest.mark.skip(reason="Local-only tests skipped on CI")] + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +TEST_SOURCE = Path(__file__).parent / "media" / "Beverly Hills Duck Pond - HDR10plus - Jessica Payne.mp4" +FFMPEG = shutil.which("ffmpeg") +FFPROBE = shutil.which("ffprobe") + +if not FFMPEG or not FFPROBE: + pytestmark = [pytestmark, pytest.mark.skip(reason="ffmpeg/ffprobe not found")] + + +# --------------------------------------------------------------------------- +# Detect available FFmpeg encoders +# --------------------------------------------------------------------------- +def _get_ffmpeg_encoders() -> set[str]: + if not FFMPEG: + return set() + try: + result = subprocess.run( + [FFMPEG, "-encoders", "-hide_banner"], + capture_output=True, + text=True, + timeout=15, + ) + encoders = set() + for line in result.stdout.splitlines(): + parts = line.strip().split() + if len(parts) >= 2 and len(parts[0]) == 6: + encoders.add(parts[1]) + return encoders + except Exception: + return set() + + +_ffmpeg_encoders = _get_ffmpeg_encoders() + + +def _has_ffmpeg_encoder(name: str) -> bool: + return name in _ffmpeg_encoders + + +# Rigaya tools - check PATH first, then FastFlix's download locations in AppData +def _find_rigaya(app_name: str, binary_base: str) -> Optional[str]: + """Find a Rigaya encoder binary, checking PATH then FastFlix's AppData download folder.""" + for name in (f"{binary_base}64", binary_base): + found = shutil.which(name) + if found: + return found + # Check FastFlix's download location + asset_folder = Path(user_data_dir(app_name, appauthor=False, roaming=True)) + if asset_folder.exists(): + for exe in asset_folder.glob(f"{binary_base}*64.exe"): + if exe.is_file(): + return str(exe) + for exe in asset_folder.glob(f"{binary_base}*.exe"): + if exe.is_file(): + return str(exe) + return None + + +NVENCC = _find_rigaya("NVEnc", "NVEncC") +QSVENCC = _find_rigaya("QSVEnc", "QSVEncC") +VCEENCC = _find_rigaya("VCEEnc", "VCEEncC") +GIFSKI = shutil.which("gifski") + + +# --------------------------------------------------------------------------- +# Probe the source video once at module level +# --------------------------------------------------------------------------- +def _probe_source() -> Optional[dict]: + if not FFPROBE or not TEST_SOURCE.exists(): + return None + try: + result = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(TEST_SOURCE)], + capture_output=True, + text=True, + timeout=30, + ) + return json.loads(result.stdout) + except Exception: + return None + + +_source_probe = _probe_source() + + +# --------------------------------------------------------------------------- +# Helper: create a real FastFlix instance from probed data +# --------------------------------------------------------------------------- +def create_real_fastflix(encoder_settings, output_path: Path, work_path: Path, is_rigaya: bool = False) -> FastFlix: + probe = _source_probe + assert probe is not None, "Could not probe source video" + + video_streams = [s for s in probe["streams"] if s["codec_type"] == "video"] + audio_streams = [s for s in probe["streams"] if s["codec_type"] == "audio"] + subtitle_streams = [s for s in probe["streams"] if s["codec_type"] == "subtitle"] + data_streams = [s for s in probe["streams"] if s["codec_type"] == "data"] + attachment_streams = [s for s in probe["streams"] if s.get("codec_type") == "attachment"] + + # Add bit_depth like FastFlix's flix.py does during probe parsing + for stream in video_streams: + if "bits_per_raw_sample" in stream: + stream["bit_depth"] = int(stream["bits_per_raw_sample"]) + else: + stream["bit_depth"] = guess_bit_depth(stream.get("pix_fmt", ""), stream.get("color_primaries")) + + streams = Box( + { + "video": [Box(v) for v in video_streams], + "audio": [Box(a) for a in audio_streams], + "subtitle": [Box(s) for s in subtitle_streams], + "data": [Box(d) for d in data_streams], + "attachment": [Box(a) for a in attachment_streams], + } + ) + + video_settings = VideoSettings( + remove_hdr=False, + maxrate=None, + bufsize=None, + output_path=output_path, + end_time=1, + ) + video_settings.video_encoder_settings = encoder_settings + + video = Video( + source=TEST_SOURCE, + duration=float(probe.get("format", {}).get("duration", 10)), + streams=streams, + format=Box(probe.get("format", {})), + video_settings=video_settings, + work_path=work_path, + ) + + config = Config( + version="4.0.0", + ffmpeg=Path(FFMPEG), + ffprobe=Path(FFPROBE), + work_path=work_path, + ) + + if is_rigaya: + if NVENCC: + config.nvencc = Path(NVENCC) + if QSVENCC: + config.qsvencc = Path(QSVENCC) + if VCEENCC: + config.vceencc = Path(VCEENCC) + if GIFSKI: + config.gifski = Path(GIFSKI) + + fastflix = FastFlix( + config=config, + encoders={}, + audio_encoders=[], + current_video=video, + ffmpeg_version="n5.0", + ) + + return fastflix + + +# --------------------------------------------------------------------------- +# Helper: verify output with ffprobe +# --------------------------------------------------------------------------- +def verify_output(output_path: Path, expected_codec: str): + assert output_path.exists(), f"Output file does not exist: {output_path}" + assert output_path.stat().st_size > 0, f"Output file is empty: {output_path}" + + result = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", str(output_path)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, f"ffprobe failed: {result.stderr}" + + data = json.loads(result.stdout) + video_streams = [s for s in data["streams"] if s["codec_type"] == "video"] + assert len(video_streams) >= 1, "No video stream in output" + actual_codec = video_streams[0]["codec_name"] + assert actual_codec == expected_codec, f"Expected codec {expected_codec}, got {actual_codec}" + + +# --------------------------------------------------------------------------- +# Helper: run a Command list +# --------------------------------------------------------------------------- +def run_commands(commands, work_path: Path): + for cmd in commands: + if cmd.shell: + cmd_to_run = cmd.to_string() + else: + cmd_to_run = cmd.to_list() + result = subprocess.run( + cmd_to_run, + capture_output=True, + text=True, + timeout=120, + shell=cmd.shell, + cwd=str(work_path), + ) + assert result.returncode == 0, ( + f"Command '{cmd.name}' failed (exit {result.returncode}):\n" + f"CMD: {cmd.to_string()}\n" + f"STDERR: {result.stderr[-2000:]}" + ) + + +# =========================================================================== +# Encoder test definitions +# =========================================================================== + +ENCODER_TESTS = [] + + +def _add(encoder_id, settings, output_ext, expected_codec, skip_condition, is_rigaya=False): + ENCODER_TESTS.append( + pytest.param( + encoder_id, + settings, + output_ext, + expected_codec, + is_rigaya, + id=encoder_id, + marks=pytest.mark.skipif(skip_condition(), reason=f"{encoder_id}: required tool not available"), + ) + ) + + +# --- FFmpeg-based encoders --- +_add( + "hevc_x265", + x265Settings(preset="ultrafast", crf=51), + ".mkv", + "hevc", + lambda: not _has_ffmpeg_encoder("libx265"), +) +_add( + "avc_x264", + x264Settings(preset="ultrafast", crf=51, pix_fmt="yuv420p"), + ".mkv", + "h264", + lambda: not _has_ffmpeg_encoder("libx264"), +) +_add( + "svt_av1", + SVTAV1Settings(speed="13", qp=63), + ".mkv", + "av1", + lambda: not _has_ffmpeg_encoder("libsvtav1"), +) +_add( + "av1_aom", + AOMAV1Settings(cpu_used="8", usage="realtime", crf=63), + ".mkv", + "av1", + lambda: not _has_ffmpeg_encoder("libaom-av1"), +) +_add( + "rav1e", + rav1eSettings(speed="10", qp=255), + ".mkv", + "av1", + lambda: not _has_ffmpeg_encoder("librav1e"), +) +_add( + "vp9", + VP9Settings(speed="5", quality="realtime", crf=63, single_pass=True), + ".mkv", + "vp9", + lambda: not _has_ffmpeg_encoder("libvpx-vp9"), +) +_add( + "vvc", + VVCSettings(preset="faster", qp=51), + ".mkv", + "vvc", + lambda: not _has_ffmpeg_encoder("libvvenc"), +) +_add( + "webp", + WebPSettings(compression="0", qscale=1), + ".webp", + "webp", + lambda: not _has_ffmpeg_encoder("libwebp"), +) +_add( + "gif", + GIFSettings(fps="5"), + ".gif", + "gif", + lambda: False, # GIF uses built-in FFmpeg filter, always available +) +_add( + "gifski", + GifskiSettings(fps="5", fast=True, quality="1"), + ".gif", + "gif", + lambda: not GIFSKI, +) +_add( + "svt_av1_avif", + SVTAVIFSettings(speed="13", qp=63), + ".avif", + "av1", + lambda: not _has_ffmpeg_encoder("libsvtav1"), +) +_add( + "ffmpeg_hevc_nvenc", + FFmpegNVENCSettings(preset="p1", bitrate="1000k"), + ".mkv", + "hevc", + lambda: not _has_ffmpeg_encoder("hevc_nvenc"), +) +_add( + "copy", + CopySettings(), + ".mkv", + "hevc", # Source is HEVC, copy preserves codec + lambda: False, +) + +# --- NVEncC --- +_add( + "nvencc_hevc", + NVEncCSettings(preset="performance", bitrate="1000k"), + ".mkv", + "hevc", + lambda: not NVENCC, + is_rigaya=True, +) +_add( + "nvencc_avc", + NVEncCAVCSettings(preset="performance", bitrate="1000k"), + ".mkv", + "h264", + lambda: not NVENCC, + is_rigaya=True, +) +_add( + "nvencc_av1", + NVEncCAV1Settings(preset="performance", bitrate="1000k"), + ".mkv", + "av1", + lambda: not NVENCC, + is_rigaya=True, +) + +# --- QSVEncC --- +_add( + "qsvencc_hevc", + QSVEncCSettings(preset="fastest", bitrate="1000k"), + ".mkv", + "hevc", + lambda: not QSVENCC, + is_rigaya=True, +) +_add( + "qsvencc_avc", + QSVEncCH264Settings(preset="fastest", bitrate="1000k"), + ".mkv", + "h264", + lambda: not QSVENCC, + is_rigaya=True, +) +_add( + "qsvencc_av1", + QSVEncCAV1Settings(preset="fastest", bitrate="1000k"), + ".mkv", + "av1", + lambda: not QSVENCC, + is_rigaya=True, +) + +# --- VCEEncC --- +_add( + "vceencc_hevc", + VCEEncCSettings(preset="fast", bitrate="1000k"), + ".mkv", + "hevc", + lambda: not VCEENCC, + is_rigaya=True, +) +_add( + "vceencc_avc", + VCEEncCAVCSettings(preset="fast", bitrate="1000k"), + ".mkv", + "h264", + lambda: not VCEENCC, + is_rigaya=True, +) +_add( + "vceencc_av1", + VCEEncCAV1Settings(preset="fast", bitrate="1000k"), + ".mkv", + "av1", + lambda: not VCEENCC, + is_rigaya=True, +) + + +# =========================================================================== +# Parameterized test +# =========================================================================== +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", ENCODER_TESTS) +def test_encode(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + if ON_CI: + pytest.skip("Skipped on CI") + if _source_probe is None: + pytest.skip("Could not probe test source video") + + output_path = tmp_path / f"output_{encoder_id}{output_ext}" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_real_fastflix(settings, output_path, work_path, is_rigaya=is_rigaya) + + # Import and call the encoder's build() + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + + assert commands, f"Encoder {encoder_id} returned no commands" + + run_commands(commands, work_path) + verify_output(output_path, expected_codec) diff --git a/tests/test_naming.py b/tests/test_naming.py new file mode 100644 index 00000000..45686520 --- /dev/null +++ b/tests/test_naming.py @@ -0,0 +1,364 @@ +# -*- coding: utf-8 -*- +from datetime import datetime, timezone +from pathlib import Path + +from box import Box + +from fastflix.naming import ( + MAX_FILENAME_PATH_LENGTH, + safe_format, + resolve_pre_encode_variables, + resolve_post_encode_variables, + has_post_encode_placeholders, + generate_preview, + validate_template, + truncate_filename, + PRE_ENCODE_VARIABLES, + POST_ENCODE_VARIABLES, + ALL_VARIABLES, +) + + +class TestSafeFormat: + def test_known_variables(self): + result = safe_format("{source}-{encoder}", {"source": "MyMovie", "encoder": "x265"}) + assert result == "MyMovie-x265" + + def test_unknown_variables_left_as_is(self): + result = safe_format("{source}-{unknown}", {"source": "MyMovie"}) + assert result == "MyMovie-{unknown}" + + def test_mixed_variables(self): + result = safe_format("{source}-{foo}-{encoder}", {"source": "Test", "encoder": "x264"}) + assert result == "Test-{foo}-x264" + + def test_empty_template(self): + result = safe_format("", {"source": "Test"}) + assert result == "" + + def test_no_variables(self): + result = safe_format("plain-name", {"source": "Test"}) + assert result == "plain-name" + + def test_repeated_variable(self): + result = safe_format("{source}-{source}", {"source": "Test"}) + assert result == "Test-Test" + + +class TestValidateTemplate: + def test_valid_template(self): + is_valid, msg = validate_template("{source}-{encoder}-{crf}") + assert is_valid + assert "Valid" in msg + + def test_empty_template(self): + is_valid, msg = validate_template("") + assert not is_valid + assert "empty" in msg.lower() + + def test_unknown_variable(self): + is_valid, msg = validate_template("{source}-{nonexistent}") + assert not is_valid + assert "nonexistent" in msg + + def test_multiple_unknown_variables(self): + is_valid, msg = validate_template("{source}-{foo}-{bar}") + assert not is_valid + assert "foo" in msg + assert "bar" in msg + + def test_all_variables_valid(self): + template = "-".join(f"{{{v.name}}}" for v in ALL_VARIABLES) + is_valid, msg = validate_template(template) + assert is_valid + + def test_no_variables(self): + is_valid, msg = validate_template("plain-name") + assert is_valid + + def test_whitespace_only(self): + is_valid, msg = validate_template(" ") + assert not is_valid + + +class TestGeneratePreview: + def test_basic_preview(self): + result = generate_preview("{source}-{encoder}") + assert "MyMovie" in result + assert "x265" in result + + def test_all_variables_have_examples(self): + template = "-".join(f"{{{v.name}}}" for v in ALL_VARIABLES) + result = generate_preview(template) + for var in ALL_VARIABLES: + assert var.example in result + + +class TestResolvePreEncodeVariables: + def test_basic_resolution(self): + result = resolve_pre_encode_variables("{source}", Path("MyMovie.mkv")) + assert result == "MyMovie" + + def test_source_with_special_chars(self): + result = resolve_pre_encode_variables("{source}", Path("My Movie (2024).mkv")) + assert "My Movie (2024)" in result + + def test_datetime_variables(self): + result = resolve_pre_encode_variables("{date}-{time}", Path("test.mkv")) + # Should contain date-like pattern + assert "-" in result + assert len(result) > 10 + + def test_random_variables_different(self): + result1 = resolve_pre_encode_variables("{rand_4}", Path("test.mkv")) + result2 = resolve_pre_encode_variables("{rand_4}", Path("test.mkv")) + # Random values should differ (extremely unlikely to match) + assert result1 != result2 + + def test_encoder_settings(self): + class MockEncoder: + name = "HEVC (x265)" + preset = "slow" + crf = 18 + bitrate = None + pix_fmt = "yuv420p10le" + + result = resolve_pre_encode_variables( + "{encoder}-{preset}-{crf}", + Path("test.mkv"), + encoder_settings=MockEncoder(), + ) + assert "x265" in result + assert "slow" in result + assert "18" in result + + def test_no_encoder_settings_fallback(self): + result = resolve_pre_encode_variables("{encoder}-{crf}", Path("test.mkv")) + assert "N-A" in result + + def test_video_metadata(self): + video = Box( + { + "width": 3840, + "height": 2160, + "duration": 5025, + "frame_rate": "24000/1001", + "color_space": "bt2020nc", + "color_primaries": "bt2020", + "color_transfer": "smpte2084", + "hdr10_plus": [], + "hdr10_streams": [{"index": 0}], + "current_video_stream": {"codec_name": "hevc"}, + "format": {"bit_rate": "45000000"}, + "streams": { + "video": [{"index": 0, "codec_name": "hevc"}], + "audio": [{"codec_name": "aac", "channels": 6}], + }, + "scale": None, + "video_settings": {"selected_track": 0}, + } + ) + result = resolve_pre_encode_variables( + "{source_resolution}-{source_codec}-{hdr}", + Path("test.mkv"), + video=video, + ) + assert "3840x2160" in result + assert "hevc" in result + assert "HDR10" in result + + def test_post_encode_placeholders_inserted(self): + result = resolve_pre_encode_variables("{source}-{filesize}", Path("test.mkv")) + assert "FFFSIZE" in result + assert "test" in result + + def test_with_encoder_name_extraction(self): + class MockEncoder: + name = "AVC (x264)" + preset = "medium" + crf = 23 + bitrate = None + pix_fmt = "yuv420p" + + result = resolve_pre_encode_variables("{encoder}", Path("test.mkv"), encoder_settings=MockEncoder()) + assert result == "x264" + + def test_encoder_name_no_parens(self): + class MockEncoder: + name = "gifski" + preset = "default" + + result = resolve_pre_encode_variables("{encoder}", Path("test.mkv"), encoder_settings=MockEncoder()) + assert result == "gifski" + + +class TestResolvePostEncodeVariables: + def test_basic_resolution(self): + probe_data = Box( + { + "format": {"bit_rate": "5000000", "duration": "3600"}, + "streams": [ + {"codec_type": "video", "bit_rate": "4500000"}, + {"codec_type": "audio", "bit_rate": "320000"}, + ], + } + ) + start = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 1, 12, 15, 32, tzinfo=timezone.utc) + + result = resolve_post_encode_variables( + "movie-FFETIME-FFVIDBIT", + Path("dummy.mkv"), + probe_data, + encode_start=start, + encode_end=end, + ) + assert "00-15-32" in result + assert "4500k" in result + assert "FFETIME" not in result + assert "FFVIDBIT" not in result + + def test_missing_probe_data(self): + result = resolve_post_encode_variables( + "movie-FFVIDBIT", + Path("dummy.mkv"), + None, + ) + assert "N-A" in result + + def test_no_placeholders(self): + result = resolve_post_encode_variables( + "movie-output", + Path("dummy.mkv"), + None, + ) + assert result == "movie-output" + + def test_filesize_placeholder(self, tmp_path): + # Create a real file to test size + test_file = tmp_path / "test.mkv" + test_file.write_bytes(b"\x00" * (5 * 1024 * 1024)) # 5MB + + probe_data = Box({"format": {}, "streams": []}) + result = resolve_post_encode_variables( + "movie-FFFSIZE-FFFSMB", + test_file, + probe_data, + ) + assert "5.0MB" in result + assert "FFFSIZE" not in result + + def test_all_placeholders_resolved(self): + probe_data = Box( + { + "format": {"bit_rate": "5000000", "duration": "3600"}, + "streams": [ + {"codec_type": "video", "bit_rate": "4500000"}, + {"codec_type": "audio", "bit_rate": "320000"}, + ], + } + ) + start = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 1, 12, 15, 32, tzinfo=timezone.utc) + + # Build a filename with all placeholders + filename = "-".join(var.placeholder for var in POST_ENCODE_VARIABLES) + result = resolve_post_encode_variables( + filename, + Path("dummy.mkv"), + probe_data, + encode_start=start, + encode_end=end, + ) + # None of the placeholders should remain + for var in POST_ENCODE_VARIABLES: + assert var.placeholder not in result + + +class TestHasPostEncodePlaceholders: + def test_with_placeholder(self): + assert has_post_encode_placeholders("movie-FFETIME-output") + + def test_without_placeholder(self): + assert not has_post_encode_placeholders("movie-output") + + def test_all_placeholders(self): + for var in POST_ENCODE_VARIABLES: + assert has_post_encode_placeholders(f"test-{var.placeholder}") + + def test_partial_match_no_false_positive(self): + assert not has_post_encode_placeholders("movie-FF-output") + + +class TestTruncateFilename: + def test_no_truncation_needed(self): + name, was_truncated = truncate_filename("short-name", "C:\\Videos", ".mkv") + assert name == "short-name" + assert not was_truncated + + def test_truncation_applied(self): + # Create a name that would exceed 250 with directory + extension + long_name = "A" * 300 + name, was_truncated = truncate_filename(long_name, "C:\\Videos", ".mkv") + assert was_truncated + assert len(name) < 300 + # Full path should be within limit + full_len = len("C:\\Videos") + 1 + len(name) + len(".mkv") + assert full_len <= MAX_FILENAME_PATH_LENGTH + + def test_exact_limit(self): + directory = "C:\\Videos" + extension = ".mkv" + # overhead = len(directory) + 1 + len(extension) = 9 + 1 + 4 = 14 + # max_name_len = 250 - 14 = 236 + exact_name = "A" * 236 + name, was_truncated = truncate_filename(exact_name, directory, extension) + assert not was_truncated + assert name == exact_name + + def test_one_over_limit(self): + directory = "C:\\Videos" + extension = ".mkv" + over_name = "A" * 237 + name, was_truncated = truncate_filename(over_name, directory, extension) + assert was_truncated + assert len(name) == 236 + + def test_long_directory_minimum_name(self): + # Very long directory path + long_dir = "C:\\" + "A" * 245 + name, was_truncated = truncate_filename("some-video-name", long_dir, ".mkv") + # Should allow at least 10 chars for name + assert len(name) >= 10 + + def test_max_filename_path_length_constant(self): + assert MAX_FILENAME_PATH_LENGTH == 250 + + +class TestVariableRegistries: + def test_all_variables_has_both(self): + assert len(ALL_VARIABLES) == len(PRE_ENCODE_VARIABLES) + len(POST_ENCODE_VARIABLES) + + def test_unique_names(self): + names = [v.name for v in ALL_VARIABLES] + assert len(names) == len(set(names)) + + def test_post_encode_have_placeholders(self): + for var in POST_ENCODE_VARIABLES: + assert var.placeholder + assert var.placeholder.startswith("FF") + assert var.placeholder.isalpha() + + def test_post_encode_placeholders_are_short(self): + for var in POST_ENCODE_VARIABLES: + assert len(var.placeholder) <= 8, ( + f"Placeholder {var.placeholder} is too long ({len(var.placeholder)} chars)" + ) + + def test_unique_placeholders(self): + placeholders = [v.placeholder for v in POST_ENCODE_VARIABLES] + assert len(placeholders) == len(set(placeholders)) + + def test_pre_encode_no_placeholders(self): + for var in PRE_ENCODE_VARIABLES: + assert not var.placeholder diff --git a/tests/test_profile_round_trip.py b/tests/test_profile_round_trip.py new file mode 100644 index 00000000..e3e1f5f1 --- /dev/null +++ b/tests/test_profile_round_trip.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +""" +Tests for determine_default() ensuring profile values map to correct combo box indices. + +These tests catch the "integer-as-index" class of bugs where an integer model value +(e.g. film_grain=8) was used as a raw combo box index instead of being matched +by text against the option list. +""" + +import pytest + +from fastflix.encoders.common.setting_panel import SettingPanel + + +def determine_default(widget_name, opt, items, raise_error=False): + """Helper: call determine_default without needing a real SettingPanel instance.""" + return SettingPanel.determine_default(None, widget_name, opt, items, raise_error) + + +# ============================================================ +# Option lists (must match the actual UI definitions) +# ============================================================ + +film_grain_options = [ + "0 - Disabled", + "4 - Animation", + "6 - Light grain", + "8 - Normal", + "10 - Heavy grain", + "15 - Very heavy", + "Custom", +] + +photon_noise_options = [ + "0 - Disabled", + "4 - Light", + "8 - Normal", + "16 - Heavy", + "32 - Very heavy", + "Custom", +] + +denoise_options = [ + "0 - Disabled", + "5 - Light", + "10 - Medium", + "25 - Heavy", + "50 - Maximum", + "Custom", +] + +vvc_period_options = ["Auto", "0", "1", "2", "3", "5", "10"] +vvc_threads_options = ["Auto", "1", "2", "4", "6", "8", "12", "16", "24", "32"] + +vp9_auto_alt_ref_options = ["Default", "0 (disabled)", "1", "2", "3", "4", "5", "6"] +vp9_lag_in_frames_options = ["Default", "0", "10", "16", "20", "25", "30", "40", "50"] +vp9_aq_mode_options = ["Default", "0 (none)", "1 (variance)", "2 (complexity)", "3 (cyclic)", "4 (equator360)"] +vp9_sharpness_options = ["Default", "0", "1", "2", "3", "4", "5", "6", "7"] + + +# ============================================================ +# SVT-AV1 film_grain tests +# ============================================================ +class TestFilmGrain: + def test_default_zero(self): + assert determine_default("film_grain", 0, film_grain_options) == 0 + + def test_none(self): + assert determine_default("film_grain", None, film_grain_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (4, 1), # "4 - Animation" + (6, 2), # "6 - Light grain" + (8, 3), # "8 - Normal" + (10, 4), # "10 - Heavy grain" + (15, 5), # "15 - Very heavy" + ], + ) + def test_preset_values(self, value, expected_index): + assert determine_default("film_grain", value, film_grain_options) == expected_index + + +# ============================================================ +# rav1e photon_noise tests +# ============================================================ +class TestPhotonNoise: + def test_default_zero(self): + assert determine_default("photon_noise", 0, photon_noise_options) == 0 + + def test_none(self): + assert determine_default("photon_noise", None, photon_noise_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (4, 1), # "4 - Light" + (8, 2), # "8 - Normal" + (16, 3), # "16 - Heavy" + (32, 4), # "32 - Very heavy" + ], + ) + def test_preset_values(self, value, expected_index): + assert determine_default("photon_noise", value, photon_noise_options) == expected_index + + +# ============================================================ +# AOM-AV1 denoise tests (denoise uses film_grain/photon_noise pattern) +# ============================================================ +class TestDenoise: + """denoise_noise_level is NOT in self.opts so determine_default isn't called + for it by the base class, but the same pattern applies via _set_denoise_from_value.""" + + def test_default_zero(self): + # denoise_noise_level=0 means disabled + # The widget name in the UI is just "denoise" but it's not in self.opts, + # so determine_default is not called for it directly. This test validates + # the pattern would work if it were. + pass + + +# ============================================================ +# VVC period tests +# ============================================================ +class TestVVCPeriod: + def test_auto_none(self): + """period=None in model means Auto → index 0.""" + assert determine_default("period", None, vvc_period_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (0, 1), # "0" + (1, 2), # "1" + (2, 3), # "2" + (3, 4), # "3" + (5, 5), # "5" + (10, 6), # "10" + ], + ) + def test_integer_values(self, value, expected_index): + assert determine_default("period", value, vvc_period_options) == expected_index + + +# ============================================================ +# VVC threads tests +# ============================================================ +class TestVVCThreads: + def test_auto_zero(self): + """threads=0 in model means Auto → index 0.""" + assert determine_default("threads", 0, vvc_threads_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (1, 1), # "1" + (2, 2), # "2" + (4, 3), # "4" + (6, 4), # "6" + (8, 5), # "8" + (12, 6), # "12" + (16, 7), # "16" + (24, 8), # "24" + (32, 9), # "32" + ], + ) + def test_integer_values(self, value, expected_index): + assert determine_default("threads", value, vvc_threads_options) == expected_index + + +# ============================================================ +# VP9 auto_alt_ref tests +# ============================================================ +class TestVP9AutoAltRef: + def test_default_neg1(self): + """auto_alt_ref=-1 means Default → index 0.""" + assert determine_default("auto_alt_ref", -1, vp9_auto_alt_ref_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (0, 1), # "0 (disabled)" + (1, 2), # "1" + (2, 3), # "2" + (3, 4), # "3" + (4, 5), # "4" + (5, 6), # "5" + (6, 7), # "6" + ], + ) + def test_integer_values(self, value, expected_index): + assert determine_default("auto_alt_ref", value, vp9_auto_alt_ref_options) == expected_index + + +# ============================================================ +# VP9 lag_in_frames tests +# ============================================================ +class TestVP9LagInFrames: + def test_default_neg1(self): + """lag_in_frames=-1 means Default → index 0.""" + assert determine_default("lag_in_frames", -1, vp9_lag_in_frames_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (0, 1), # "0" + (10, 2), # "10" + (16, 3), # "16" + (20, 4), # "20" + (25, 5), # "25" + (30, 6), # "30" + (40, 7), # "40" + (50, 8), # "50" + ], + ) + def test_integer_values(self, value, expected_index): + assert determine_default("lag_in_frames", value, vp9_lag_in_frames_options) == expected_index + + +# ============================================================ +# VP9 aq_mode tests +# ============================================================ +class TestVP9AqMode: + def test_default_neg1(self): + """aq_mode=-1 means Default → index 0.""" + assert determine_default("aq_mode", -1, vp9_aq_mode_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (0, 1), # "0 (none)" + (1, 2), # "1 (variance)" + (2, 3), # "2 (complexity)" + (3, 4), # "3 (cyclic)" + (4, 5), # "4 (equator360)" + ], + ) + def test_integer_values(self, value, expected_index): + assert determine_default("aq_mode", value, vp9_aq_mode_options) == expected_index + + +# ============================================================ +# VP9 sharpness tests +# ============================================================ +class TestVP9Sharpness: + def test_default_neg1(self): + """sharpness=-1 means Default → index 0.""" + assert determine_default("sharpness", -1, vp9_sharpness_options) == 0 + + @pytest.mark.parametrize( + "value,expected_index", + [ + (0, 1), # "0" + (1, 2), # "1" + (2, 3), # "2" + (3, 4), # "3" + (4, 5), # "4" + (5, 6), # "5" + (6, 7), # "6" + (7, 8), # "7" + ], + ) + def test_integer_values(self, value, expected_index): + assert determine_default("sharpness", value, vp9_sharpness_options) == expected_index + + +# ============================================================ +# Pix fmt tests (sanity check for existing behavior) +# ============================================================ +class TestPixFmt: + def test_yuv420p(self): + items = ["8-bit: yuv420p", "10-bit: yuv420p10le", "12-bit: yuv420p12le"] + assert determine_default("pix_fmt", "yuv420p", items) == 0 + + def test_yuv420p10le(self): + items = ["8-bit: yuv420p", "10-bit: yuv420p10le", "12-bit: yuv420p12le"] + assert determine_default("pix_fmt", "yuv420p10le", items) == 1 + + +# ============================================================ +# GPU tests (sanity check for existing behavior) +# ============================================================ +class TestGpu: + def test_gpu_default(self): + items = ["Auto", "0", "1", "2"] + assert determine_default("gpu", -1, items) == 0 + + def test_gpu_specific(self): + items = ["Auto", "0", "1", "2"] + assert determine_default("gpu", 1, items) == 1 diff --git a/tests/test_subtitle_filename_parsing.py b/tests/test_subtitle_filename_parsing.py new file mode 100644 index 00000000..ec321af6 --- /dev/null +++ b/tests/test_subtitle_filename_parsing.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +from fastflix.widgets.panels.subtitle_panel import parse_subtitle_filename_metadata + + +class TestParseSubtitleFilenameMetadata: + def test_forced_german(self): + result = parse_subtitle_filename_metadata("video", "video.forced.deu.srt", ".srt") + assert result["language"] == "ger" + assert result["dispositions"] == {"forced": True} + assert result["title"] == "Forced" + + def test_sdh_german(self): + result = parse_subtitle_filename_metadata("video", "video.sdh.deu.srt", ".srt") + assert result["language"] == "ger" + assert result["dispositions"] == {"hearing_impaired": True} + assert result["title"] == "SDH" + + def test_normal_english(self): + result = parse_subtitle_filename_metadata("video", "video.normal.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {} + assert result["title"] == "Normal" + + def test_language_only(self): + result = parse_subtitle_filename_metadata("video", "video.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {} + assert result["title"] == "" + + def test_reversed_order(self): + result = parse_subtitle_filename_metadata("video", "video.deu.forced.srt", ".srt") + assert result["language"] == "ger" + assert result["dispositions"] == {"forced": True} + assert result["title"] == "Forced" + + def test_hi_treated_as_hearing_impaired(self): + """'hi' should be treated as hearing_impaired disposition, not Hindi language.""" + result = parse_subtitle_filename_metadata("video", "video.hi.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {"hearing_impaired": True} + assert result["title"] == "SDH" + + def test_two_letter_language_code(self): + result = parse_subtitle_filename_metadata("video", "video.de.srt", ".srt") + assert result["language"] == "ger" + assert result["dispositions"] == {} + assert result["title"] == "" + + def test_no_segments(self): + result = parse_subtitle_filename_metadata("video", "video.srt", ".srt") + assert result["language"] == "" + assert result["dispositions"] == {} + assert result["title"] == "" + + def test_non_matching_stem(self): + result = parse_subtitle_filename_metadata("video", "other_file.eng.srt", ".srt") + assert result["language"] == "" + assert result["dispositions"] == {} + assert result["title"] == "" + + def test_multiple_dispositions(self): + result = parse_subtitle_filename_metadata("video", "video.forced.sdh.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {"forced": True, "hearing_impaired": True} + # First disposition's title wins + assert result["title"] == "Forced" + + def test_cc_tag(self): + result = parse_subtitle_filename_metadata("video", "video.cc.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {"hearing_impaired": True} + assert result["title"] == "CC" + + def test_commentary_tag(self): + result = parse_subtitle_filename_metadata("video", "video.commentary.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {"comment": True} + assert result["title"] == "Commentary" + + def test_ass_extension(self): + result = parse_subtitle_filename_metadata("video", "video.forced.jpn.ass", ".ass") + assert result["language"] == "jpn" + assert result["dispositions"] == {"forced": True} + assert result["title"] == "Forced" + + def test_unknown_segments_ignored(self): + result = parse_subtitle_filename_metadata("video", "video.foobar.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {} + assert result["title"] == "" + + def test_full_descriptor(self): + result = parse_subtitle_filename_metadata("video", "video.full.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {} + assert result["title"] == "Full" + + def test_video_stem_with_spaces(self): + result = parse_subtitle_filename_metadata( + "Beverly Hills Duck Pond - HDR10plus", + "Beverly Hills Duck Pond - HDR10plus.forced.deu.srt", + ".srt", + ) + assert result["language"] == "ger" + assert result["dispositions"] == {"forced": True} + assert result["title"] == "Forced" + + def test_comment_tag(self): + result = parse_subtitle_filename_metadata("video", "video.comment.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {"comment": True} + assert result["title"] == "Commentary" + + def test_default_disposition(self): + result = parse_subtitle_filename_metadata("video", "video.default.eng.srt", ".srt") + assert result["language"] == "eng" + assert result["dispositions"] == {"default": True} + assert result["title"] == "" From c2c2e42f704800dc0bb7d602222c3f8fdfbd8a8c Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 18 Feb 2026 18:12:54 -0600 Subject: [PATCH 09/10] Fix CI tests --- tests/test_local_encode.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_local_encode.py b/tests/test_local_encode.py index 17289f95..118a71a6 100644 --- a/tests/test_local_encode.py +++ b/tests/test_local_encode.py @@ -58,11 +58,11 @@ # --------------------------------------------------------------------------- # Skip everything on CI # --------------------------------------------------------------------------- -pytestmark = pytest.mark.local_only +pytestmark = [pytest.mark.local_only] ON_CI = os.environ.get("CI", "").lower() in ("true", "1", "yes") if ON_CI: - pytestmark = [pytestmark, pytest.mark.skip(reason="Local-only tests skipped on CI")] + pytestmark.append(pytest.mark.skip(reason="Local-only tests skipped on CI")) # --------------------------------------------------------------------------- # Paths @@ -72,7 +72,7 @@ FFPROBE = shutil.which("ffprobe") if not FFMPEG or not FFPROBE: - pytestmark = [pytestmark, pytest.mark.skip(reason="ffmpeg/ffprobe not found")] + pytestmark.append(pytest.mark.skip(reason="ffmpeg/ffprobe not found")) # --------------------------------------------------------------------------- From 4ef41314005626678e8fa47844da6f358728de1e Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 18 Feb 2026 18:40:22 -0600 Subject: [PATCH 10/10] Fix monitor jumping --- fastflix/application.py | 4 +++- fastflix/widgets/container.py | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/fastflix/application.py b/fastflix/application.py index 523fe2ca..bacd25b1 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -269,7 +269,9 @@ def app_setup( container = Container(app) container.show() - screen_geometry = QtGui.QGuiApplication.primaryScreen().availableGeometry() + cursor_pos = QtGui.QCursor.pos() + screen = QtGui.QGuiApplication.screenAt(cursor_pos) or QtGui.QGuiApplication.primaryScreen() + screen_geometry = screen.availableGeometry() container.move(screen_geometry.center() - container.rect().center()) # Disable entire window during startup tasks diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index ceb1c423..e1b74ec2 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -121,13 +121,20 @@ def __init__(self, app: FastFlixApp, **kwargs): # self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) self.moveFlag = False + def _current_screen(self) -> QtGui.QScreen: + """Return the screen the window center is on, falling back to primary.""" + screen = QtGui.QGuiApplication.screenAt(self.geometry().center()) + if screen is None: + screen = QtGui.QGuiApplication.primaryScreen() + return screen + def _update_scaled_styles(self) -> None: """Update all stylesheets based on current scale factors.""" self.setStyleSheet(get_scaled_stylesheet(self.app.fastflix.config.theme)) def _constrain_to_screen(self): """Ensure the window fits within the available screen geometry.""" - screen = QtGui.QGuiApplication.primaryScreen() + screen = self._current_screen() if screen is None: return available = screen.availableGeometry() @@ -141,7 +148,7 @@ def ensure_window_in_bounds(self): if self.isMaximized() or self.isFullScreen(): return self._constrain_to_screen() - screen = QtGui.QGuiApplication.primaryScreen() + screen = self._current_screen() if screen is None: return available = screen.availableGeometry() @@ -180,7 +187,7 @@ def resizeEvent(self, event: QtGui.QResizeEvent) -> None: if self.isMaximized() or self.isFullScreen(): return - screen = QtGui.QGuiApplication.primaryScreen() + screen = self._current_screen() if screen is None: return available = screen.availableGeometry()