From f4390ebab821d385b4d41286aca78c015265c3ab Mon Sep 17 00:00:00 2001 From: luzpaz Date: Sat, 6 Sep 2025 00:10:12 -0400 Subject: [PATCH 01/25] CHANGES: fix typo (#688) Fix simple spelling mistake --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index a448c6c3..ce74a413 100644 --- a/CHANGES +++ b/CHANGES @@ -10,7 +10,7 @@ ## Version 5.12.3 -* Fixing #673 changing subtitle langauge in the UI did not take effect in the command (thanks to danielly2020) +* Fixing #673 changing subtitle language in the UI did not take effect in the command (thanks to danielly2020) * Fixing #673 extract subtitle command was looking for subtitle index, not absolute index (thanks to danielly2020) ## Version 5.12.2 From f451d21d2848ca70fe1cbf11c1f6abc24a36a03a Mon Sep 17 00:00:00 2001 From: JacobDev1 <65915437+JacobDev1@users.noreply.github.com> Date: Wed, 26 Nov 2025 00:27:51 +0100 Subject: [PATCH 02/25] Fix process priority (#708) --- fastflix/command_runner.py | 11 ++++++----- fastflix/widgets/panels/queue_panel.py | 7 +++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/fastflix/command_runner.py b/fastflix/command_runner.py index 7a7273f5..8443b5f2 100644 --- a/fastflix/command_runner.py +++ b/fastflix/command_runner.py @@ -9,6 +9,7 @@ from subprocess import PIPE from threading import Thread from typing import Literal +import sys from psutil import Popen @@ -23,12 +24,12 @@ ) except ImportError: priority_levels = { - "Realtime": 20, - "High": 10, - "Above Normal": 5, + "Realtime": -20, + "High": -10, + "Above Normal": -5, "Normal": 0, - "Below Normal": -10, - "Idle": -20, + "Below Normal": 10, + "Idle": 19 if sys.platform == "linux" else 20, } else: priority_levels = { diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 9a4676b1..1212f718 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -230,8 +230,11 @@ def __init__(self, parent, app: FastFlixApp): self.load_queue_button.setFixedWidth(110) self.priority_widget = QtWidgets.QComboBox() - self.priority_widget.addItems(["Realtime", "High", "Above Normal", "Normal", "Below Normal", "Idle"]) - self.priority_widget.setCurrentIndex(3) + self.priority_widget.addItems( + ([] if reusables.win_based else ["Realtime"]) + + ["High", "Above Normal", "Normal", "Below Normal", "Idle"] + ) + self.priority_widget.setCurrentText("Normal") self.priority_widget.currentIndexChanged.connect(self.set_priority) self.clear_queue = QtWidgets.QPushButton( From e8b28a30ec1e9e6cd1a6663a9d47dd7462f7efad Mon Sep 17 00:00:00 2001 From: anne-o-pixel <81879599+anne-o-pixel@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:28:17 +0000 Subject: [PATCH 03/25] Add function to find Rigaya encoder binaries (#707) --- fastflix/models/config.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/fastflix/models/config.py b/fastflix/models/config.py index d4f5ee0e..189f95dd 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -98,6 +98,20 @@ def where(filename: str, portable_mode=False) -> Path | None: return location return None +def find_rigaya_encoder(base_name: str) -> Path | None: + """Find Rigaya encoder binaries with case-insensitive search.""" + # Try common binary names in order of preference + candidates = [ + f"{base_name}64", # Windows 64-bit + f"{base_name}", # Windows/Linux + f"{base_name.lower()}", # Linux lowercase + ] + + for candidate in candidates: + if location := where(candidate): + return location + return None + class Config(BaseModel): version: str = __version__ @@ -105,9 +119,9 @@ class Config(BaseModel): ffmpeg: Path = Field(default_factory=lambda: find_ffmpeg_file("ffmpeg")) ffprobe: Path = Field(default_factory=lambda: find_ffmpeg_file("ffprobe")) hdr10plus_parser: Path | None = Field(default_factory=find_hdr10plus_tool) - nvencc: Path | None = Field(default_factory=lambda: where("NVEncC64") or where("NVEncC")) - vceencc: Path | None = Field(default_factory=lambda: where("VCEEncC64") or where("VCEEncC")) - qsvencc: Path | None = Field(default_factory=lambda: where("QSVEncC64") or where("QSVEncC")) + nvencc: Path | None = Field(default_factory=lambda: find_rigaya_encoder("NVEncC")) + vceencc: Path | None = Field(default_factory=lambda: find_rigaya_encoder("VCEEncC")) + qsvencc: Path | None = Field(default_factory=lambda: find_rigaya_encoder("QSVEncC")) output_directory: Path | None = None source_directory: Path | None = None output_name_format: str = "{source}-fastflix-{rand_4}" From 7edb2625aaa5c350f161808b00c416e0bfb144e4 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sun, 18 Jan 2026 16:35:00 -0500 Subject: [PATCH 04/25] Package updates --- .github/workflows/build.yaml | 6 +- .github/workflows/pythonpublish.yml | 2 +- .github/workflows/test.yaml | 4 +- pyproject.toml | 6 +- uv.lock | 539 ++++++++++++++-------------- 5 files changed, 284 insertions(+), 273 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4ebe0027..ec21ab99 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,7 +9,7 @@ jobs: build-nix: strategy: matrix: - os: [ ubuntu-22.04, ubuntu-24.04, macos-13, macos-14 ] + os: [ ubuntu-22.04, ubuntu-24.04, macos-14 ] runs-on: ${{ matrix.os }} steps: @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install the latest version of uv and activate the environment uses: astral-sh/setup-uv@v6 @@ -84,7 +84,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install the latest version of uv and activate the environment uses: astral-sh/setup-uv@v6 diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 77f4c8a3..0e5f596e 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Install Dependencies run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index aa5e196d..f45a8437 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install the latest version of uv and activate the environment uses: astral-sh/setup-uv@v6 @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install the latest version of uv and activate the environment uses: astral-sh/setup-uv@v6 diff --git a/pyproject.toml b/pyproject.toml index add07dce..5ba51e68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ authors = [{ name = "Chris Griffith", email = "chris@cdgriffith.com" }] readme = "README.md" #url = "https://fastflix.org" #download_url = "https://github.com/cdgriffith/FastFlix/releases" -requires-python = ">=3.12" +requires-python = ">=3.13" dynamic = ["version"] dependencies = [ "platformdirs~=4.3", @@ -91,8 +91,8 @@ target-version = "py312" #ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. -fixable = ["F401", "F541"] -unfixable = [] +fixable = [ "F541"] +unfixable = ["F401"] [tool.ruff.format] # Like Black, use double quotes for strings. diff --git a/uv.lock b/uv.lock index 7241bcdf..90ae0404 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,14 @@ version = 1 revision = 2 -requires-python = ">=3.12" +requires-python = ">=3.13" [[package]] name = "altgraph" -version = "0.17.4" +version = "0.17.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418, upload-time = "2023-09-25T09:04:52.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, ] [[package]] @@ -22,20 +22,20 @@ wheels = [ [[package]] name = "certifi" -version = "2025.6.15" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] @@ -49,37 +49,43 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, - { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, - { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, - { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, - { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, - { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, - { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, - { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, - { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, - { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, - { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -105,23 +111,23 @@ wheels = [ [[package]] name = "colorlog" -version = "6.9.0" +version = "6.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/6b/4e5481ddcdb9c255b2715f54c863629f1543e97bc8c309d1c5c131ad14f2/colorlog-6.7.0.tar.gz", hash = "sha256:bd94bd21c1e13fac7bd3153f4bc3a7dc0eb0974b8bc2fdf1a989e474f6e582e5", size = 29920, upload-time = "2022-08-29T14:51:27.945Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, + { url = "https://files.pythonhosted.org/packages/58/43/a363c213224448f9e194d626221123ce00e3fb3d87c0c22aed52b620bdd1/colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662", size = 11286, upload-time = "2022-08-29T14:51:26.426Z" }, ] [[package]] name = "distlib" -version = "0.3.9" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] @@ -194,7 +200,7 @@ dev = [ [[package]] name = "ffmpeg-normalize" -version = "1.32.5" +version = "1.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -203,25 +209,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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/11/1b1adca14e40084198632d8eab31fdb91cb26bc74b0b76ac7366b8eeab8d/ffmpeg_normalize-1.32.5-py3-none-any.whl", hash = "sha256:c22ab5421726a1736134992efd6b52da570d9f808d2ba9500a21b7ef20de4d6c", size = 36240, upload-time = "2025-06-22T18:22:52.39Z" }, + { 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" }, ] [[package]] name = "ffmpeg-progress-yield" -version = "1.0.1" +version = "1.1.1" 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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b1/1f88ee6006f212e36e2d1867d20bdaffd0f5a065c17d34c7083a3b03b4f3/ffmpeg_progress_yield-1.0.1-py3-none-any.whl", hash = "sha256:3c24844110accc84d48bde8c7c4d5a8c163cc652f1cf0e2f62c803565ae42dae", size = 13704, upload-time = "2025-06-22T18:20:13.827Z" }, + { 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" }, ] [[package]] name = "filelock" -version = "3.18.0" +version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, + { 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" }, ] [[package]] @@ -238,50 +249,50 @@ wheels = [ [[package]] name = "identify" -version = "2.6.12" +version = "2.6.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "iso639-lang" -version = "2.6.1" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/2f/e40adecac3e38d0308f9507b7ad7dc72be4b20b2a196f0b0992113dead8c/iso639_lang-2.6.1.tar.gz", hash = "sha256:5e960467cd95b7e4417d48792745a51457be0346417a3cde40a75cfc142e1a37", size = 319461, upload-time = "2025-06-23T08:26:11.712Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5a/49bbf16d155192255e7bb37e403b2ac360144992d0d112a865afc62e457f/iso639_lang-2.6.3.tar.gz", hash = "sha256:078ddb7cd0182dcc04367691acc8022ddf7158b6cb09f08f798af823fa864265", size = 319391, upload-time = "2025-07-23T09:04:53.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/c4/8e09251b52b6b5a772c3d098ad44e50d0ecaaf8ff11a5c2351a89e04254c/iso639_lang-2.6.1-py3-none-any.whl", hash = "sha256:6f41183aafc84716c3d559f57c036b04c3262899b89f7eadd68c397cce1ab572", size = 324943, upload-time = "2025-06-23T08:26:10.578Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c7/f6fd3db6c33a164631c39dce2ca26a3794e3abf91b875cc99a43a5565d88/iso639_lang-2.6.3-py3-none-any.whl", hash = "sha256:a6c2fb9f739dca180dc7f48b098880f303bcce2cdf93a4ca3152ed8bbbb94fbb", size = 324990, upload-time = "2025-07-23T09:04:52.221Z" }, ] [[package]] name = "macholib" -version = "1.16.3" +version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altgraph" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309, upload-time = "2023-09-25T09:10:16.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094, upload-time = "2023-09-25T09:10:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, ] [[package]] @@ -295,30 +306,37 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, - { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, - { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, - { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, - { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, - { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, - { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, - { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, - { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, - { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, - { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, - { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, - { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, - { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] @@ -332,11 +350,11 @@ wheels = [ [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] @@ -359,20 +377,20 @@ wheels = [ [[package]] name = "pefile" -version = "2023.2.7" +version = "2024.8.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854, upload-time = "2023-02-07T12:23:55.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] [[package]] name = "platformdirs" -version = "4.3.8" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, + { 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" }, ] [[package]] @@ -386,7 +404,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.2.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -395,9 +413,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] @@ -416,7 +434,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.7" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -424,51 +442,62 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] @@ -482,7 +511,7 @@ wheels = [ [[package]] name = "pyinstaller" -version = "6.14.1" +version = "6.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altgraph" }, @@ -493,32 +522,32 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/d66d3a9c34349d73eb099401060e2591da8ccc5ed427e54fff3961302513/pyinstaller-6.14.1.tar.gz", hash = "sha256:35d5c06a668e21f0122178dbf20e40fd21012dc8f6170042af6050c4e7b3edca", size = 4284317, upload-time = "2025-06-08T18:45:46.367Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/b8/0fe3359920b0a4e7008e0e93ff383003763e3eee3eb31a07c52868722960/pyinstaller-6.18.0.tar.gz", hash = "sha256:cdc507542783511cad4856fce582fdc37e9f29665ca596889c663c83ec8c6ec9", size = 4034976, upload-time = "2026-01-13T03:13:23.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/f6/fa56e547fe849db4b8da0acaad6101a6382c18370c7e0f378a1cf0ea89f0/pyinstaller-6.14.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:da559cfe4f7a20a7ebdafdf12ea2a03ea94d3caa49736ef53ee2c155d78422c9", size = 999937, upload-time = "2025-06-08T18:44:26.429Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/a2814978f47ae038b1ce112717adbdcfd8dfb9504e5c52437902331cde1a/pyinstaller-6.14.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f040d1e3d42af3730104078d10d4a8ca3350bd1c78de48f12e1b26f761e0cbc3", size = 719569, upload-time = "2025-06-08T18:44:30.948Z" }, - { url = "https://files.pythonhosted.org/packages/35/f0/86391a4c0f558aef43a7dac8f678d46f4e5b84bd133308e3ea81f7384ab9/pyinstaller-6.14.1-py3-none-manylinux2014_i686.whl", hash = "sha256:7b8813fb2d5a82ef4ceffc342ed9a11a6fc1ef21e68e833dbd8fedb8a188d3f5", size = 729824, upload-time = "2025-06-08T18:44:34.983Z" }, - { url = "https://files.pythonhosted.org/packages/e5/88/446814e335d937406e6e1ae4a77ed922b8eea8b90f3aaf69427a16b58ed2/pyinstaller-6.14.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e2cfdbc6dd41d19872054fc233da18856ec422a7fdea899b6985ae04f980376a", size = 727937, upload-time = "2025-06-08T18:44:38.954Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/5aa891c61d303ad4a794b7e2f864aacf64fe0f6f5559e2aec0f742595fad/pyinstaller-6.14.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:a4d53b3ecb5786b097b79bda88c4089186fc1498ef7eaa6cee57599ae459241e", size = 724762, upload-time = "2025-06-08T18:44:42.768Z" }, - { url = "https://files.pythonhosted.org/packages/c5/92/e32ec0a1754852a8ed5a60f6746c6483e3da68aee97d314f3a3a99e0ed9e/pyinstaller-6.14.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c48dd257f77f61ebea2d1fdbaf11243730f2271873c88d3b5ecb7869525d3bcb", size = 724957, upload-time = "2025-06-08T18:44:46.829Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/1260f384e47bf939f6238f791d4cda7edb94771d2fa0a451e0edb21ac9c7/pyinstaller-6.14.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5b05cbb2ffc033b4681268159b82bac94b875475c339603c7e605f00a73c8746", size = 724132, upload-time = "2025-06-08T18:44:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8b/8570ab94ec07e0b2b1203f45840353ee76aa067a2540c97da43d43477b26/pyinstaller-6.14.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d5fd73757c8ea9adb2f9c1f81656335ba9890029ede3031835d768fde36e89f0", size = 723847, upload-time = "2025-06-08T18:44:54.896Z" }, - { url = "https://files.pythonhosted.org/packages/d5/43/6c68dc9e53b09ff948d6e46477932b387832bbb920c48061d734ef089368/pyinstaller-6.14.1-py3-none-win32.whl", hash = "sha256:547f7a93592e408cbfd093ce9fd9631215387dab0dbf3130351d3b0b1186a534", size = 1299744, upload-time = "2025-06-08T18:45:00.781Z" }, - { url = "https://files.pythonhosted.org/packages/7c/dd/bb8d5bcb0592f7f5d454ad308051d00ed34f8b08d5003400b825cfe35513/pyinstaller-6.14.1-py3-none-win_amd64.whl", hash = "sha256:0794290b4b56ef9d35858334deb29f36ec1e1f193b0f825212a0aa5a1bec5a2f", size = 1357625, upload-time = "2025-06-08T18:45:06.826Z" }, - { url = "https://files.pythonhosted.org/packages/89/57/8a8979737980e50aa5031b77318ce783759bf25be2956317f2e1d7a65a09/pyinstaller-6.14.1-py3-none-win_arm64.whl", hash = "sha256:d9d99695827f892cb19644106da30681363e8ff27b8326ac8416d62890ab9c74", size = 1298607, upload-time = "2025-06-08T18:45:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/73/e6/51b0146a1a3eec619e58f5d69fb4e3d0f65a31cbddbeef557c9bb83eeed9/pyinstaller-6.18.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:cb7aa5a71bfa7c0af17a4a4e21855663c89e4bd7c40f1d337c8370636d8847c3", size = 1040056, upload-time = "2026-01-13T03:12:15.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9c/a3634c0ec8e1ed31b373b548848b5c0b39b56edc191cf737e697d484ec23/pyinstaller-6.18.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:07785459b3bf8a48889eac0b4d0667ade84aef8930ce030bc7cbb32f41283b33", size = 734971, upload-time = "2026-01-13T03:12:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/2c/04/6756442078ccfcd552ccce636be1574035e62f827ffa1f5d8a0382682546/pyinstaller-6.18.0-py3-none-manylinux2014_i686.whl", hash = "sha256:f998675b7ccb2dabbb1dc2d6f18af61d55428ad6d38e6c4d700417411b697d37", size = 746637, upload-time = "2026-01-13T03:12:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/54/39/fbc56519000cdbf450f472692a7b9b55d42077ce8529f1be631db7b75a36/pyinstaller-6.18.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:779817a0cf69604cddcdb5be1fd4959dc2ce048d6355c73e5da97884df2f3387", size = 744343, upload-time = "2026-01-13T03:12:33.369Z" }, + { url = "https://files.pythonhosted.org/packages/36/f2/50887badf282fee776e83d1e4feab74c026f50a1ea16e109ed939e32aa28/pyinstaller-6.18.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:31b5d109f8405be0b7cddcede43e7b074792bc9a5bbd54ec000a3e779183c2af", size = 741084, upload-time = "2026-01-13T03:12:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/1c/08/3a1419183e4713ef77d912ecbdd6ef858689ed9deb34d547133f724ca745/pyinstaller-6.18.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4328c9837f1aef4fe1a127d4ff1b09a12ce53c827ce87c94117628b0e1fd098b", size = 740943, upload-time = "2026-01-13T03:12:41.589Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/309305e36d116f1434b42d91c420ff951fa79b2c398bbd59930c830450be/pyinstaller-6.18.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:3638fc81eb948e5e5eab1d4ad8f216e3fec6d4a350648304f0adb227b746ee5e", size = 740107, upload-time = "2026-01-13T03:12:45.694Z" }, + { url = "https://files.pythonhosted.org/packages/83/0f/a59a95cd1df59ddbc9e74d5a663387551333bcf19a5dd3086f5c81a2e83c/pyinstaller-6.18.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe59da34269e637f97fd3c43024f764586fc319141d245ff1a2e9af1036aa3", size = 739843, upload-time = "2026-01-13T03:12:49.728Z" }, + { url = "https://files.pythonhosted.org/packages/9a/09/e7a870e7205cdbd2f8785010a5d3fe48a9df2591156ee34a8b29b774fa14/pyinstaller-6.18.0-py3-none-win32.whl", hash = "sha256:496205e4fa92ec944f9696eb597962a83aef4d4c3479abfab83d730e1edf016b", size = 1323811, upload-time = "2026-01-13T03:12:55.717Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d5/48eef2002b6d3937ceac2717fe17e9ca3a43a4c9826bafee367dfc75ba85/pyinstaller-6.18.0-py3-none-win_amd64.whl", hash = "sha256:976fabd90ecfbda47571c87055ad73413ec615ff7dea35e12a4304174de78de9", size = 1384389, upload-time = "2026-01-13T03:13:01.993Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/1a88e6e94107de3ea1c842fd59c3aa132d344ad8e52ea458ffa9a748726e/pyinstaller-6.18.0-py3-none-win_arm64.whl", hash = "sha256:dba4b70e3c9ba09aab51152c72a08e58a751851548f77ad35944d32a300c8381", size = 1324869, upload-time = "2026-01-13T03:13:08.192Z" }, ] [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.5" +version = "2025.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/ff/e3376595935d5f8135964d2177cd3e3e0c1b5a6237497d9775237c247a5d/pyinstaller_hooks_contrib-2025.5.tar.gz", hash = "sha256:707386770b8fe066c04aad18a71bc483c7b25e18b4750a756999f7da2ab31982", size = 163124, upload-time = "2025-06-08T18:47:53.26Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/2c/b4d317534e17dd1df95c394d4b37febb15ead006a1c07c2bb006481fb5e7/pyinstaller_hooks_contrib-2025.5-py3-none-any.whl", hash = "sha256:ebfae1ba341cb0002fb2770fad0edf2b3e913c2728d92df7ad562260988ca373", size = 437246, upload-time = "2025-06-08T18:47:51.516Z" }, + { 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" }, ] [[package]] @@ -580,7 +609,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.1" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -589,9 +618,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] @@ -612,15 +641,15 @@ all = [ [[package]] name = "pywin32" -version = "310" +version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239, upload-time = "2025-03-17T00:55:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839, upload-time = "2025-03-17T00:56:00.8Z" }, - { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470, upload-time = "2025-03-17T00:56:02.601Z" }, - { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384, upload-time = "2025-03-17T00:56:04.383Z" }, - { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039, upload-time = "2025-03-17T00:56:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152, upload-time = "2025-03-17T00:56:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] @@ -634,33 +663,43 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "requests" -version = "2.32.4" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -668,9 +707,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -684,65 +723,37 @@ wheels = [ [[package]] name = "ruamel-yaml" -version = "0.18.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload-time = "2025-06-09T08:51:09.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload-time = "2025-06-09T08:51:06.348Z" }, -] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.12" +version = "0.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload-time = "2024-10-20T10:12:55.657Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload-time = "2024-10-20T10:12:57.155Z" }, - { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload-time = "2024-10-20T10:12:58.501Z" }, - { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload-time = "2024-10-20T10:13:00.211Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload-time = "2024-10-21T11:26:46.038Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload-time = "2024-10-21T11:26:47.487Z" }, - { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload-time = "2024-12-11T19:58:17.252Z" }, - { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload-time = "2024-10-20T10:13:01.395Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload-time = "2024-10-20T10:13:02.768Z" }, - { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload-time = "2024-10-20T10:13:04.377Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload-time = "2024-10-20T10:13:05.906Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload-time = "2024-10-20T10:13:07.26Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload-time = "2024-10-20T10:13:08.504Z" }, - { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload-time = "2024-10-21T11:26:48.866Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload-time = "2024-10-21T11:26:50.213Z" }, - { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload-time = "2024-12-11T19:58:18.846Z" }, - { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload-time = "2024-10-20T10:13:09.658Z" }, - { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] [[package]] name = "ruff" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/97/38/796a101608a90494440856ccfb52b1edae90de0b817e76bfade66b12d320/ruff-0.12.1.tar.gz", hash = "sha256:806bbc17f1104fd57451a98a58df35388ee3ab422e029e8f5cf30aa4af2c138c", size = 4413426, upload-time = "2025-06-26T20:34:14.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/bf/3dba52c1d12ab5e78d75bd78ad52fb85a6a1f29cc447c2423037b82bed0d/ruff-0.12.1-py3-none-linux_armv6l.whl", hash = "sha256:6013a46d865111e2edb71ad692fbb8262e6c172587a57c0669332a449384a36b", size = 10305649, upload-time = "2025-06-26T20:33:39.242Z" }, - { url = "https://files.pythonhosted.org/packages/8c/65/dab1ba90269bc8c81ce1d499a6517e28fe6f87b2119ec449257d0983cceb/ruff-0.12.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3f75a19e03a4b0757d1412edb7f27cffb0c700365e9d6b60bc1b68d35bc89e0", size = 11120201, upload-time = "2025-06-26T20:33:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3e/2d819ffda01defe857fa2dd4cba4d19109713df4034cc36f06bbf582d62a/ruff-0.12.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a256522893cb7e92bb1e1153283927f842dea2e48619c803243dccc8437b8be", size = 10466769, upload-time = "2025-06-26T20:33:44.102Z" }, - { url = "https://files.pythonhosted.org/packages/63/37/bde4cf84dbd7821c8de56ec4ccc2816bce8125684f7b9e22fe4ad92364de/ruff-0.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:069052605fe74c765a5b4272eb89880e0ff7a31e6c0dbf8767203c1fbd31c7ff", size = 10660902, upload-time = "2025-06-26T20:33:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/0e/3a/390782a9ed1358c95e78ccc745eed1a9d657a537e5c4c4812fce06c8d1a0/ruff-0.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a684f125a4fec2d5a6501a466be3841113ba6847827be4573fddf8308b83477d", size = 10167002, upload-time = "2025-06-26T20:33:47.81Z" }, - { url = "https://files.pythonhosted.org/packages/6d/05/f2d4c965009634830e97ffe733201ec59e4addc5b1c0efa035645baa9e5f/ruff-0.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdecdef753bf1e95797593007569d8e1697a54fca843d78f6862f7dc279e23bd", size = 11751522, upload-time = "2025-06-26T20:33:49.857Z" }, - { url = "https://files.pythonhosted.org/packages/35/4e/4bfc519b5fcd462233f82fc20ef8b1e5ecce476c283b355af92c0935d5d9/ruff-0.12.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70d52a058c0e7b88b602f575d23596e89bd7d8196437a4148381a3f73fcd5010", size = 12520264, upload-time = "2025-06-26T20:33:52.199Z" }, - { url = "https://files.pythonhosted.org/packages/85/b2/7756a6925da236b3a31f234b4167397c3e5f91edb861028a631546bad719/ruff-0.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84d0a69d1e8d716dfeab22d8d5e7c786b73f2106429a933cee51d7b09f861d4e", size = 12133882, upload-time = "2025-06-26T20:33:54.231Z" }, - { url = "https://files.pythonhosted.org/packages/dd/00/40da9c66d4a4d51291e619be6757fa65c91b92456ff4f01101593f3a1170/ruff-0.12.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cc32e863adcf9e71690248607ccdf25252eeeab5193768e6873b901fd441fed", size = 11608941, upload-time = "2025-06-26T20:33:56.202Z" }, - { url = "https://files.pythonhosted.org/packages/91/e7/f898391cc026a77fbe68dfea5940f8213622474cb848eb30215538a2dadf/ruff-0.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fd49a4619f90d5afc65cf42e07b6ae98bb454fd5029d03b306bd9e2273d44cc", size = 11602887, upload-time = "2025-06-26T20:33:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/f6/02/0891872fc6aab8678084f4cf8826f85c5d2d24aa9114092139a38123f94b/ruff-0.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ed5af6aaaea20710e77698e2055b9ff9b3494891e1b24d26c07055459bb717e9", size = 10521742, upload-time = "2025-06-26T20:34:00.465Z" }, - { url = "https://files.pythonhosted.org/packages/2a/98/d6534322c74a7d47b0f33b036b2498ccac99d8d8c40edadb552c038cecf1/ruff-0.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:801d626de15e6bf988fbe7ce59b303a914ff9c616d5866f8c79eb5012720ae13", size = 10149909, upload-time = "2025-06-26T20:34:02.603Z" }, - { url = "https://files.pythonhosted.org/packages/34/5c/9b7ba8c19a31e2b6bd5e31aa1e65b533208a30512f118805371dbbbdf6a9/ruff-0.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2be9d32a147f98a1972c1e4df9a6956d612ca5f5578536814372113d09a27a6c", size = 11136005, upload-time = "2025-06-26T20:34:04.723Z" }, - { url = "https://files.pythonhosted.org/packages/dc/34/9bbefa4d0ff2c000e4e533f591499f6b834346025e11da97f4ded21cb23e/ruff-0.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49b7ce354eed2a322fbaea80168c902de9504e6e174fd501e9447cad0232f9e6", size = 11648579, upload-time = "2025-06-26T20:34:06.766Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/20cdb593783f8f411839ce749ec9ae9e4298c2b2079b40295c3e6e2089e1/ruff-0.12.1-py3-none-win32.whl", hash = "sha256:d973fa626d4c8267848755bd0414211a456e99e125dcab147f24daa9e991a245", size = 10519495, upload-time = "2025-06-26T20:34:08.718Z" }, - { url = "https://files.pythonhosted.org/packages/cf/56/7158bd8d3cf16394928f47c637d39a7d532268cd45220bdb6cd622985760/ruff-0.12.1-py3-none-win_amd64.whl", hash = "sha256:9e1123b1c033f77bd2590e4c1fe7e8ea72ef990a85d2484351d408224d603013", size = 11547485, upload-time = "2025-06-26T20:34:11.008Z" }, - { url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209, upload-time = "2025-06-26T20:34:12.928Z" }, +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" }, ] [[package]] @@ -789,67 +800,67 @@ wheels = [ [[package]] name = "types-requests" -version = "2.32.4.20250611" +version = "2.32.4.20260107" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/7f/73b3a04a53b0fd2a911d4ec517940ecd6600630b559e4505cc7b68beb5a0/types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826", size = 23118, upload-time = "2025-06-11T03:11:41.272Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ea/0be9258c5a4fa1ba2300111aa5a0767ee6d18eb3fd20e91616c12082284d/types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072", size = 20643, upload-time = "2025-06-11T03:11:40.186Z" }, + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, ] [[package]] name = "types-setuptools" -version = "80.9.0.20250529" +version = "80.9.0.20251223" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/66/1b276526aad4696a9519919e637801f2c103419d2c248a6feb2729e034d1/types_setuptools-80.9.0.20250529.tar.gz", hash = "sha256:79e088ba0cba2186c8d6499cbd3e143abb142d28a44b042c28d3148b1e353c91", size = 41337, upload-time = "2025-05-29T03:07:34.487Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d8/83790d67ec771bf029a45ff1bd1aedbb738d8aa58c09dd0cc3033eea0e69/types_setuptools-80.9.0.20250529-py3-none-any.whl", hash = "sha256:00dfcedd73e333a430e10db096e4d46af93faf9314f832f13b6bbe3d6757e95f", size = 63263, upload-time = "2025-05-29T03:07:33.064Z" }, + { 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" }, ] [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "virtualenv" -version = "20.31.2" +version = "20.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]] From dd0df90f2d938563ad3f9a2ba8b15be31dc96b6c Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 19 Jan 2026 13:54:43 -0500 Subject: [PATCH 05/25] Audio and GIF fixes --- .gitignore | 2 + CHANGES | 10 ++ CLAUDE.md | 107 ++++++++++++++++ fastflix/encoders/common/audio.py | 2 +- fastflix/encoders/common/encc_helpers.py | 2 +- fastflix/encoders/gif/command_builder.py | 65 +++++++--- fastflix/models/config.py | 7 +- fastflix/models/profiles.py | 8 +- fastflix/version.py | 2 +- fastflix/widgets/panels/audio_panel.py | 20 +-- fastflix/widgets/panels/queue_panel.py | 3 +- fastflix/widgets/panels/subtitle_panel.py | 39 ++++++ fastflix/widgets/video_options.py | 2 +- tests/test_audio.py | 146 +++++++++++++++++++++- tests/test_audio_bugs.py | 2 + 15 files changed, 380 insertions(+), 37 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/test_audio_bugs.py diff --git a/.gitignore b/.gitignore index b3e29e06..07eb55e2 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,5 @@ test.html iso-639-3.* build-dir/ benchmarking/ + +./.claude/settings.local.json diff --git a/CHANGES b/CHANGES index ce74a413..9ac85d8b 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,15 @@ # Changelog +## Version 5.12.5 + +* Fixing GIF encoder not applying resolution scaling +* Fixing audio profile loading - validator returned wrong enum type (MatchType instead of MatchItem) +* Fixing "monoo" typo in downmix mapping that caused mono downmix to fail +* Fixing undefined variable crash when creating duplicate audio tracks via profile filters +* Fixing IndexError when applying profile audio filters to videos with non-sequential track indices +* Fixing TypeError crash with Rigaya encoders when audio quality not explicitly set +* Fixing AttributeError crash when audio track metadata is incomplete + ## Version 5.12.4 * Fixing #675 "Default Source Folder" not used when adding Complete Folders (thanks to Krawk) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..35b88399 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +FastFlix is a Python GUI application for video encoding/transcoding using PySide6 (Qt6). It wraps FFmpeg and supports 25+ encoder backends including x264, x265, AV1 variants, VP9, VVC, and hardware encoders (NVIDIA NVEncC, Intel QSVEncC, AMD VCEEncC). + +**Requirements:** Python 3.13+, FFmpeg 4.3+ (5.0+ recommended) + +## Build & Development Commands + +```bash +# Install dependencies +uv sync --frozen + +# Lint and format +uv run ruff check # Check for violations +uv run ruff check --fix # Auto-fix issues +uv run ruff format # Format code + +# Run tests +uv run pytest tests -v +PYTEST_QT_API=pyside6 uv run pytest tests -v # Linux with Qt + +# Run specific test file +uv run pytest tests/encoders/test_hevc_x265_command_builder.py -v + +# Run the application +python -m fastflix + +# Build executables +uv run pyinstaller FastFlix_Windows_OneFile.spec +uv run pyinstaller FastFlix_Nix_OneFile.spec +``` + +## Code Style + +- Line length: 120 characters +- Double quotes for strings +- Ruff for linting and formatting (Black-compatible) +- Type hints via Pydantic models + +## Architecture + +### Multi-Process Design +- **Main process** (`entry.py`): Sets up queues and spawns worker subprocess +- **GUI process** (`application.py`): Qt application (prevents UI blocking) +- **Worker process** (`conversion_worker.py`): Processes conversion queue +- Queue communication between processes for status/logging + +### Encoder Plugin System +Each encoder lives in `fastflix/encoders/{encoder_name}/` with: +- `__init__.py`: Encoder metadata/registration +- `command_builder.py`: Implements `build(fastflix) -> List[Command]` +- Settings model in `models/encode.py` (Pydantic) +- UI panel in `widgets/panels/{encoder_name}/` + +### Key Modules +| Module | Purpose | +|--------|---------| +| `flix.py` | Core FFmpeg/FFprobe interaction | +| `widgets/main.py` | Main GUI window | +| `models/config.py` | Configuration management | +| `models/encode.py` | Encoder settings models | +| `encoders/common/helpers.py` | Shared command building utilities | + +### Data Flow +1. User loads video → `parse()` via FFprobe +2. Encoding options set → Pydantic model validation +3. Video queued → Added to `conversion_list` +4. Worker processes → Encoder's `build()` generates FFmpeg commands +5. `command_runner.py` executes → Progress streamed to GUI + +## Adding a New Encoder + +1. Create directory: `fastflix/encoders/{encoder_name}/` +2. Add settings class to `models/encode.py` +3. Implement `command_builder.py` with `build(fastflix) -> List[Command]` +4. Create UI panel in `widgets/panels/{encoder_name}/` +5. Add tests in `tests/encoders/test_{encoder_name}_command_builder.py` + +## Configuration + +- Config file: `~/.config/FastFlix/fastflix.yaml` +- Portable mode: Place `fastflix.yaml` in app directory +- Environment overrides: `FF_FFMPEG`, `FF_FFPROBE`, `FF_HDR10PLUS`, `FF_CONFIG` + +## FFmpeg Command Research + +**IMPORTANT:** Always research FFmpeg commands online before implementing or modifying encoder command builders. FFmpeg options and filter syntax can change between versions. + +Resources to consult: +- Official FFmpeg documentation: https://ffmpeg.org/ffmpeg.html +- FFmpeg filters documentation: https://ffmpeg.org/ffmpeg-filters.html +- FFmpeg wiki: https://trac.ffmpeg.org/wiki + +Key considerations: +- Filter order matters (e.g., scale before palettegen for GIFs) +- Use appropriate scale flags (lanczos/bicubic over bilinear) +- Verify filter_complex syntax for multi-input/output chains +- Check encoder-specific options in official docs + +## Branching + +- `master`: Release branch +- `develop`: Development branch (PRs merge here) diff --git a/fastflix/encoders/common/audio.py b/fastflix/encoders/common/audio.py index 97ff1eb7..79d4219c 100644 --- a/fastflix/encoders/common/audio.py +++ b/fastflix/encoders/common/audio.py @@ -68,7 +68,7 @@ def build_audio(audio_tracks, audio_file_index=0): elif track.conversion_codec: try: cl = track.downmix if track.downmix and track.downmix != "No Downmix" else track.raw_info.channel_layout - except (AssertionError, KeyError): + except (AssertionError, KeyError, AttributeError): cl = "stereo" logger.warning("Could not determine channel layout, defaulting to stereo, please manually specify") diff --git a/fastflix/encoders/common/encc_helpers.py b/fastflix/encoders/common/encc_helpers.py index 706e3394..b4082c5a 100644 --- a/fastflix/encoders/common/encc_helpers.py +++ b/fastflix/encoders/common/encc_helpers.py @@ -133,7 +133,7 @@ def build_audio(audio_tracks: list[AudioTrack], audio_streams): bitrate = f"--audio-bitrate {audio_id}?{conversion_bitrate} " else: bitrate = audio_quality_converter( - track.conversion_aq, track.conversion_codec, track.raw_info.get("channels"), audio_id + track.conversion_aq or 0, track.conversion_codec, track.raw_info.get("channels"), audio_id ) command_list.append( f"{downmix} --audio-codec {audio_id}?{track.conversion_codec} {bitrate} " diff --git a/fastflix/encoders/gif/command_builder.py b/fastflix/encoders/gif/command_builder.py index 60c50023..4ad264e2 100644 --- a/fastflix/encoders/gif/command_builder.py +++ b/fastflix/encoders/gif/command_builder.py @@ -9,26 +9,65 @@ def build(fastflix: FastFlix): settings: GIFSettings = fastflix.current_video.video_settings.video_encoder_settings + video_settings = fastflix.current_video.video_settings + + # Get scale from Video property (computed based on resolution_method) + scale = fastflix.current_video.scale + + # Convert crop to dict if it exists (generate_filters expects dict, not Pydantic model) + crop = video_settings.crop.model_dump() if video_settings.crop else None args = f"=stats_mode={settings.stats_mode}" if settings.max_colors != "256": args += f":max_colors={settings.max_colors}" - palletgen_filters = generate_filters( - custom_filters=f"palettegen{args}", **fastflix.current_video.video_settings.model_dump() + # Build base filters for fps and scale (applied before palette operations) + # Scale must use lanczos for better GIF quality + base_filters = generate_filters( + selected_track=video_settings.selected_track, + source=fastflix.current_video.source, + crop=crop, + scale=scale, + scale_filter="lanczos", + rotate=video_settings.rotate, + vertical_flip=video_settings.vertical_flip, + horizontal_flip=video_settings.horizontal_flip, + video_speed=video_settings.video_speed, + deblock=video_settings.deblock, + deblock_size=video_settings.deblock_size, + brightness=video_settings.brightness, + saturation=video_settings.saturation, + contrast=video_settings.contrast, + custom_filters=f"fps={settings.fps}", + raw_filters=True, ) - filters = generate_filters( - custom_filters=f"fps={settings.fps}", raw_filters=True, **fastflix.current_video.video_settings.model_dump() + # Palette generation filters include the base filters + palettegen + palettegen_filters = generate_filters( + selected_track=video_settings.selected_track, + source=fastflix.current_video.source, + crop=crop, + scale=scale, + scale_filter="lanczos", + rotate=video_settings.rotate, + vertical_flip=video_settings.vertical_flip, + horizontal_flip=video_settings.horizontal_flip, + video_speed=video_settings.video_speed, + deblock=video_settings.deblock, + deblock_size=video_settings.deblock_size, + brightness=video_settings.brightness, + saturation=video_settings.saturation, + contrast=video_settings.contrast, + custom_filters=f"fps={settings.fps},palettegen{args}", ) output_video = clean_file_string(fastflix.current_video.video_settings.output_path) beginning = ( f'"{fastflix.config.ffmpeg}" -y ' - f"{f'-ss {fastflix.current_video.video_settings.start_time}' if fastflix.current_video.video_settings.start_time else ''} " - f"{f'-to {fastflix.current_video.video_settings.end_time}' if fastflix.current_video.video_settings.end_time else ''} " - f"{f'-r {fastflix.current_video.video_settings.source_fps} ' if fastflix.current_video.video_settings.source_fps else ''}" + f"{f'-ss {video_settings.start_time}' if video_settings.start_time else ''} " + f"{f'-to {video_settings.end_time}' if video_settings.end_time else ''} " + f"{f'-r {video_settings.source_fps} ' if video_settings.source_fps else ''}" f' -i "{fastflix.current_video.source}" ' ) if settings.extra: @@ -37,19 +76,17 @@ def build(fastflix: FastFlix): temp_palette = fastflix.current_video.work_path / f"temp_palette_{secrets.token_hex(10)}.png" command_1 = ( - f'{beginning} {palletgen_filters} {settings.extra if settings.extra_both_passes else ""} -y "{temp_palette}"' + f'{beginning} {palettegen_filters} {settings.extra if settings.extra_both_passes else ""} -y "{temp_palette}"' ) - gif_filters = f"fps={settings.fps}" - if filters: - gif_filters += f",{filters}" - + # For GIF creation, apply same base filters then use palette + # Format: [base_filters];[v][1:v]paletteuse=dither={dither}[o] command_2 = ( f'{beginning} -i "{temp_palette}" ' - f'-filter_complex "{filters};[v][1:v]paletteuse=dither={settings.dither}[o]" -map "[o]" {settings.extra} -y "{output_video}" ' + f'-filter_complex "{base_filters};[v][1:v]paletteuse=dither={settings.dither}[o]" -map "[o]" {settings.extra} -y "{output_video}" ' ) return [ - Command(command=command_1, name="Pallet generation", exe="ffmpeg"), + Command(command=command_1, name="Palette generation", exe="ffmpeg"), Command(command=command_2, name="GIF creation", exe="ffmpeg"), ] diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 189f95dd..45c633ea 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -98,13 +98,14 @@ def where(filename: str, portable_mode=False) -> Path | None: return location return None + def find_rigaya_encoder(base_name: str) -> Path | None: """Find Rigaya encoder binaries with case-insensitive search.""" # Try common binary names in order of preference candidates = [ - f"{base_name}64", # Windows 64-bit - f"{base_name}", # Windows/Linux - f"{base_name.lower()}", # Linux lowercase + f"{base_name}64", # Windows 64-bit + f"{base_name}", # Windows/Linux + f"{base_name.lower()}", # Linux lowercase ] for candidate in candidates: diff --git a/fastflix/models/profiles.py b/fastflix/models/profiles.py index c92fa3d4..406afc5f 100644 --- a/fastflix/models/profiles.py +++ b/fastflix/models/profiles.py @@ -56,7 +56,7 @@ class MatchType(Enum): class AudioMatch(BaseModel): match_type: Union[MatchType, list[MatchType]] # TODO figure out why when saved becomes list in yaml - match_item: Union[MatchItem, list[MatchType]] + match_item: Union[MatchItem, list[MatchItem]] match_input: str = "*" conversion: Optional[str] = None bitrate: Optional[str] = None @@ -73,13 +73,13 @@ def match_type_must_be_enum(cls, v): @classmethod def match_item_must_be_enum(cls, v): if isinstance(v, list): - return MatchType(v[0]) + return MatchItem(v[0]) return MatchItem(v) @field_validator("downmix", mode="before") @classmethod def downmix_as_string(cls, v): - fixed = {1: "monoo", 2: "stereo", 3: "2.1", 4: "3.1", 5: "5.0", 6: "5.1", 7: "6.1", 8: "7.1"} + fixed = {1: "mono", 2: "stereo", 3: "2.1", 4: "3.1", 5: "5.0", 6: "5.1", 7: "6.1", 8: "7.1"} if isinstance(v, str) and v.isnumeric(): v = int(v) if isinstance(v, int): @@ -98,7 +98,7 @@ def bitrate_k_end(cls, v): class SubtitleMatch(BaseModel): match_type: Union[MatchType, list[MatchType]] - match_item: Union[MatchItem, list[MatchType]] + match_item: Union[MatchItem, list[MatchItem]] match_input: str diff --git a/fastflix/version.py b/fastflix/version.py index 59da427c..8918b64f 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "5.12.4" +__version__ = "5.12.5" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index c70c7474..a814a54f 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -431,7 +431,7 @@ def gen_track( new_item = Audio( parent=parent, app=self.app, - index=i, + index=len(self.app.fastflix.current_video.audio_tracks) - 1, disabled_dup=( "nvencc" in self.main.convert_to.lower() or "vcenc" in self.main.convert_to.lower() @@ -462,18 +462,24 @@ def gen_track( return # Apply first set of conversions to the original audio tracks + # Build a mapping from stream index to self.tracks position + stream_index_to_track = { + self.app.fastflix.current_video.audio_tracks[i].index: i for i in range(len(self.tracks)) + } current_id = -1 skip_tracks = [] for idx, track in enumerate(tracks): # track[0] is the Box() track object, track[1] is the AudioMatch it matched against if track[0].index > current_id: current_id = track[0].index - self.tracks[track[0].index - 1].widgets.enable_check.setChecked(True) - self.tracks[track[0].index - 1].update_track( - downmix=track[1].downmix, - conversion=track[1].conversion, - bitrate=track[1].bitrate, - ) + track_pos = stream_index_to_track.get(track[0].index) + if track_pos is not None: + self.tracks[track_pos].widgets.enable_check.setChecked(True) + self.tracks[track_pos].update_track( + downmix=track[1].downmix, + conversion=track[1].conversion, + bitrate=track[1].bitrate, + ) skip_tracks.append(idx) if not og_only: diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 1212f718..ad53c3a8 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -231,8 +231,7 @@ def __init__(self, parent, app: FastFlixApp): self.priority_widget = QtWidgets.QComboBox() self.priority_widget.addItems( - ([] if reusables.win_based else ["Realtime"]) - + ["High", "Above Normal", "Normal", "Below Normal", "Idle"] + ([] if reusables.win_based else ["Realtime"]) + ["High", "Above Normal", "Normal", "Below Normal", "Idle"] ) self.priority_widget.setCurrentText("Normal") self.priority_widget.currentIndexChanged.connect(self.set_priority) diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index ff814210..7cfbbef4 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -377,6 +377,45 @@ def new_source(self): super()._new_source(self.tracks) + def apply_profile_settings(self): + """Re-apply subtitle filtering based on current profile settings.""" + self._first_selected = False + + for track in self.tracks: + sub_track = self.app.fastflix.current_video.subtitle_tracks[track.index] + enabled = self.lang_match(sub_track) + sub_track.enabled = enabled + track.widgets.enable_check.setChecked(enabled) + + if self.app.fastflix.config.opt("subtitle_automatic_burn_in"): + # Reset any existing burn-in + for track in self.tracks: + track.widgets.burn_in.setChecked(False) + + first_default, first_forced = None, None + for track in self.tracks: + if ( + not first_default + and self.app.fastflix.current_video.subtitle_tracks[track.index].dispositions.get("default", False) + and self.lang_match(track, ignore_first=True) + ): + first_default = track + break + if ( + not first_forced + and self.app.fastflix.current_video.subtitle_tracks[track.index].dispositions.get("forced", False) + and self.lang_match(track, ignore_first=True) + ): + first_forced = track + break + if not self.app.fastflix.config.disable_automatic_subtitle_burn_in: + if first_forced is not None: + first_forced.widgets.burn_in.setChecked(True) + elif first_default is not None: + first_default.widgets.burn_in.setChecked(True) + + self.reorder(update=True) + def reload(self, original_tracks): clear_list(self.tracks) diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index 691981a0..58526c4c 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -228,7 +228,7 @@ def update_profile(self): ) self.audio.update_audio_settings() if getattr(self.main.current_encoder, "enable_subtitles", False): - self.subtitles.get_settings() + self.subtitles.apply_profile_settings() if getattr(self.main.current_encoder, "enable_attachments", False): self.attachments.update_cover_settings() self.advanced.update_settings() diff --git a/tests/test_audio.py b/tests/test_audio.py index d7ede509..0d3418da 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- + from box import Box -from fastflix.models.profiles import AudioMatch, MatchType, MatchItem +from .general import test_audio_tracks from fastflix.audio_processing import apply_audio_filters - -from .general import test_audio_tracks +from fastflix.models.profiles import AudioMatch, MatchType, MatchItem +from fastflix.models.encode import AudioTrack +from fastflix.encoders.common.audio import build_audio +from fastflix.encoders.common.encc_helpers import audio_quality_converter as encc_audio_quality_converter def test_audio_filters(): @@ -286,3 +289,140 @@ def test_audio_filters(): ] assert result == expected_result, result + + +class TestAudioMatchValidator: + """Tests for AudioMatch validator returning correct enum type.""" + + def test_match_item_validator_returns_match_item_from_list(self): + """Test that match_item_must_be_enum validator returns MatchItem, not MatchType.""" + # When loaded from YAML, match_item may come as a list [int_value] + audio_match = AudioMatch( + match_type=MatchType.ALL, + match_item=[2], # Simulates YAML loading - should become MatchItem.TITLE + match_input="*", + ) + assert isinstance(audio_match.match_item, MatchItem) + assert audio_match.match_item == MatchItem.TITLE + + def test_match_item_validator_returns_match_item_from_int(self): + """Test that match_item_must_be_enum validator returns MatchItem from int.""" + audio_match = AudioMatch( + match_type=MatchType.ALL, + match_item=3, # Should become MatchItem.TRACK + match_input="*", + ) + assert isinstance(audio_match.match_item, MatchItem) + assert audio_match.match_item == MatchItem.TRACK + + def test_match_item_validator_with_all_enum_values(self): + """Test validator with all MatchItem enum values.""" + for item in MatchItem: + audio_match = AudioMatch( + match_type=MatchType.ALL, + match_item=[item.value], + match_input="*", + ) + assert audio_match.match_item == item + + +class TestDownmixMapping: + """Tests for downmix string mapping.""" + + def test_downmix_mono_is_correct(self): + """Test that mono downmix produces 'mono', not 'monoo'.""" + audio_match = AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.ALL, + match_input="*", + downmix=1, # Should become "mono" + ) + assert audio_match.downmix == "mono" + + def test_downmix_stereo_mapping(self): + """Test that stereo downmix maps correctly.""" + audio_match = AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.ALL, + match_input="*", + downmix=2, + ) + assert audio_match.downmix == "stereo" + + def test_downmix_51_mapping(self): + """Test that 5.1 downmix maps correctly.""" + audio_match = AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.ALL, + match_input="*", + downmix=6, + ) + assert audio_match.downmix == "5.1" + + def test_downmix_string_passthrough(self): + """Test that string downmix values pass through unchanged.""" + audio_match = AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.ALL, + match_input="*", + downmix="stereo", + ) + assert audio_match.downmix == "stereo" + + +class TestEnccAudioQualityConverter: + """Tests for encc_helpers audio_quality_converter handling None.""" + + def test_audio_quality_converter_handles_zero(self): + """Test that audio_quality_converter handles quality=0 correctly.""" + result = encc_audio_quality_converter(0, "libopus", channels=2, track_number=1) + assert "240k" in result + + def test_audio_quality_converter_handles_valid_quality(self): + """Test that audio_quality_converter handles valid quality values.""" + result = encc_audio_quality_converter(5, "aac", channels=2, track_number=1) + assert "audio-quality" in result or "audio-bitrate" in result + + +class TestBuildAudioAttributeError: + """Tests for build_audio handling AttributeError when raw_info is None.""" + + def test_build_audio_with_none_raw_info(self): + """Test that build_audio handles None raw_info gracefully.""" + track = AudioTrack( + index=1, + outdex=0, + codec="aac", + title="Test", + language="eng", + channels=2, + enabled=True, + raw_info=None, # This should not cause AttributeError + conversion_codec="aac", + conversion_bitrate="128k", + downmix="stereo", + dispositions={"default": False}, + ) + # Should not raise AttributeError + result = build_audio([track]) + assert "-c:0 aac" in result + + def test_build_audio_with_raw_info_missing_channel_layout(self): + """Test that build_audio handles raw_info without channel_layout.""" + track = AudioTrack( + index=1, + outdex=0, + codec="aac", + title="Test", + language="eng", + channels=2, + enabled=True, + raw_info=Box({"channels": 2}), # Missing channel_layout + conversion_codec="aac", + conversion_bitrate="128k", + downmix=None, # Will try to access raw_info.channel_layout + dispositions={"default": False}, + ) + # Should fall back to stereo without crashing + result = build_audio([track]) + assert "-c:0 aac" in result diff --git a/tests/test_audio_bugs.py b/tests/test_audio_bugs.py new file mode 100644 index 00000000..1ea3b263 --- /dev/null +++ b/tests/test_audio_bugs.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Tests for audio-related bug fixes.""" From 3768b75bd10f93aad57e9c554c37ef5ab7cd05e3 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 19 Jan 2026 15:13:55 -0500 Subject: [PATCH 06/25] * Fixing crash when dragging non-video content over the main window * Fixing rotation setting not being applied correctly when loading profiles * Fixing flip settings not restoring correctly when returning video from queue --- CHANGES | 3 +++ fastflix/widgets/main.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CHANGES b/CHANGES index 9ac85d8b..ac01c481 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,9 @@ ## Version 5.12.5 +* Fixing crash when dragging non-video content over the main window +* Fixing rotation setting not being applied correctly when loading profiles +* Fixing flip settings not restoring correctly when returning video from queue * Fixing GIF encoder not applying resolution scaling * Fixing audio profile loading - validator returned wrong enum type (MatchType instead of MatchItem) * Fixing "monoo" typo in downmix mapping that caused mono downmix to fail diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 8d7d45a8..c5c9c368 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -626,7 +626,7 @@ def set_profile(self): self.loading_video = True try: # self.widgets.scale.keep_aspect.setChecked(self.app.fastflix.config.opt("keep_aspect_ratio")) - self.widgets.rotate.setCurrentIndex(self.app.fastflix.config.opt("rotate") or 0 // 90) + self.widgets.rotate.setCurrentIndex((self.app.fastflix.config.opt("rotate") or 0) // 90) v_flip = self.app.fastflix.config.opt("vertical_flip") h_flip = self.app.fastflix.config.opt("horizontal_flip") @@ -1381,7 +1381,7 @@ def enable_all(self): self.output_path_button.setEnabled(True) self.output_video_path_widget.setEnabled(True) self.add_profile.setEnabled(True) - self.resolution_custom() + self.update_resolution() def clear_current_video(self): self.loading_video = True @@ -1484,12 +1484,12 @@ def reload_video_from_queue(self, video: Video): self.widgets.remove_hdr.setChecked(self.app.fastflix.current_video.video_settings.remove_hdr) self.widgets.rotate.setCurrentIndex(video.video_settings.rotate) self.widgets.fast_time.setCurrentIndex(0 if video.video_settings.fast_seek else 1) - if video.video_settings.vertical_flip: - self.widgets.flip.setCurrentIndex(1) - if video.video_settings.horizontal_flip: - self.widgets.flip.setCurrentIndex(2) if video.video_settings.vertical_flip and video.video_settings.horizontal_flip: self.widgets.flip.setCurrentIndex(3) + elif video.video_settings.vertical_flip: + self.widgets.flip.setCurrentIndex(1) + elif video.video_settings.horizontal_flip: + self.widgets.flip.setCurrentIndex(2) self.video_options.advanced.video_title.setText(video.video_settings.video_title) self.video_options.advanced.video_track_title.setText(video.video_settings.video_track_title) @@ -1915,7 +1915,7 @@ def encode_video(self): if self.app.fastflix.conversion_paused: return error_message("Queue is currently paused") - if not self.app.fastflix.conversion_list or self.app.fastflix.current_video: + if self.app.fastflix.current_video: add_current = True if self.app.fastflix.conversion_list and self.app.fastflix.current_video: add_current = yes_no_message("Add current video to queue?", yes_text="Yes", no_text="No") @@ -2038,7 +2038,7 @@ def dragEnterEvent(self, event): event.accept() if event.mimeData().hasUrls else event.ignore() def dragMoveEvent(self, event): - event.accept() if event.mimeData().hasUrls else event.ignoreAF() + event.accept() if event.mimeData().hasUrls else event.ignore() def status_update(self, status_response): response = Response(*status_response) From 72a6786b04b71e6188fd3d85a297659d4432862e Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 19 Jan 2026 23:35:19 -0500 Subject: [PATCH 07/25] - Adding async queue saving to prevent GUI blocking during queue operations - Adding atomic file writes for queue to prevent corruption from interrupted saves - Adding file-based locking for queue operations to prevent race conditions between instances - Adding graceful shutdown handling for worker process and background threads - Fixing potential GUI freeze when log queue fills up during encoding - Fixing file handle leaks in command runner when process startup fails --- CHANGES | 6 + CLAUDE.md | 6 + fastflix/application.py | 8 +- fastflix/command_runner.py | 35 ++- fastflix/conversion_worker.py | 21 +- fastflix/entry.py | 58 +++-- fastflix/ff_queue.py | 309 +++++++++++++++++++++++- fastflix/widgets/main.py | 38 ++- fastflix/widgets/panels/queue_panel.py | 12 +- fastflix/widgets/panels/status_panel.py | 19 +- 10 files changed, 438 insertions(+), 74 deletions(-) diff --git a/CHANGES b/CHANGES index ac01c481..1aaeb25c 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,12 @@ ## Version 5.12.5 +* Adding async queue saving to prevent GUI blocking during queue operations +* Adding atomic file writes for queue to prevent corruption from interrupted saves +* Adding file-based locking for queue operations to prevent race conditions between instances +* Adding graceful shutdown handling for worker process and background threads +* Fixing potential GUI freeze when log queue fills up during encoding +* Fixing file handle leaks in command runner when process startup fails * Fixing crash when dragging non-video content over the main window * Fixing rotation setting not being applied correctly when loading profiles * Fixing flip settings not restoring correctly when returning video from queue diff --git a/CLAUDE.md b/CLAUDE.md index 35b88399..693e83a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,12 @@ Key considerations: - Verify filter_complex syntax for multi-input/output chains - Check encoder-specific options in official docs +## Changelog + +**IMPORTANT:** Always update the `CHANGES` file when making significant additions or bug fixes during a session. Add entries under the current version section at the top of the file using the format: +- `* Adding {feature description}` for new features +- `* Fixing {bug description}` for bug fixes + ## Branching - `master`: Release branch diff --git a/fastflix/application.py b/fastflix/application.py index 7acd95c1..2a700b16 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -164,14 +164,12 @@ def init_fastflix_directories(app: FastFlixApp): def app_setup( enable_scaling: bool = True, portable_mode: bool = False, - queue_list: list = None, - queue_lock=None, status_queue=None, log_queue=None, worker_queue=None, ): app = create_app(enable_scaling=enable_scaling) - app.fastflix = FastFlix(queue=queue_list, queue_lock=queue_lock) + app.fastflix = FastFlix() app.fastflix.log_queue = log_queue app.fastflix.status_queue = status_queue app.fastflix.worker_queue = worker_queue @@ -268,15 +266,13 @@ def app_setup( return app -def start_app(worker_queue, status_queue, log_queue, queue_list, queue_lock, portable_mode=False, enable_scaling=True): +def start_app(worker_queue, status_queue, log_queue, portable_mode=False, enable_scaling=True): # import tracemalloc # # tracemalloc.start() app = app_setup( enable_scaling=enable_scaling, portable_mode=portable_mode, - queue_list=queue_list, - queue_lock=queue_lock, status_queue=status_queue, log_queue=log_queue, worker_queue=worker_queue, diff --git a/fastflix/command_runner.py b/fastflix/command_runner.py index 8443b5f2..2811a252 100644 --- a/fastflix/command_runner.py +++ b/fastflix/command_runner.py @@ -6,6 +6,7 @@ import shlex import time from pathlib import Path +from queue import Full from subprocess import PIPE from threading import Thread from typing import Literal @@ -73,13 +74,17 @@ def start_exec(self, command, work_dir: str = None, shell: bool = False, errors= self.error_message = errors self.success_message = successes logger.info(f"Running command: {command}") + stdout_handle = None + stderr_handle = None try: + stdout_handle = open(self.output_file, "w") + stderr_handle = open(self.error_output_file, "w") self.process = Popen( shlex.split(command.replace("\\", "\\\\")) if not shell and isinstance(command, str) else command, shell=shell, cwd=work_dir, - stdout=open(self.output_file, "w"), - stderr=open(self.error_output_file, "w"), + stdout=stdout_handle, + stderr=stderr_handle, stdin=PIPE, # FFmpeg can try to read stdin and wrecks havoc on linux encoding="utf-8", ) @@ -89,16 +94,25 @@ def start_exec(self, command, work_dir: str = None, shell: bool = False, errors= "Please make sure encoder is executable and you have permissions to run it." "Otherwise try running FastFlix as an administrator." ) + if stdout_handle: + stdout_handle.close() + if stderr_handle: + stderr_handle.close() self.error_detected = True return except Exception: logger.exception("Could not start worker process") + if stdout_handle: + stdout_handle.close() + if stderr_handle: + stderr_handle.close() self.error_detected = True return self.started_at = datetime.datetime.now(datetime.timezone.utc) - Thread(target=self.read_output).start() + thread = Thread(target=self.read_output, daemon=True) + thread.start() def change_priority( self, new_priority: Literal["Realtime", "High", "Above Normal", "Normal", "Below Normal", "Idle"] @@ -110,6 +124,13 @@ def change_priority( except Exception: logger.exception(f"Could not set process priority to {new_priority}") + def _safe_log_put(self, msg): + """Put message to log queue with timeout to prevent blocking if GUI is dead.""" + try: + self.log_queue.put(msg, timeout=1.0) + except Full: + pass # GUI likely dead, ignore + def read_output(self): with ( open(self.output_file, "r", encoding="utf-8", errors="ignore") as out_file, @@ -120,18 +141,18 @@ def read_output(self): if not self.is_alive(): excess = out_file.read() logger.info(excess) - self.log_queue.put(excess) + self._safe_log_put(excess) err_excess = err_file.read() logger.info(err_excess) - self.log_queue.put(err_excess) + self._safe_log_put(err_excess) if self.process.returncode is not None and self.process.returncode > 0: self.error_detected = True break line = out_file.readline().rstrip() if line: logger.info(line) - self.log_queue.put(line) + self._safe_log_put(line) if not self.success_detected: for success in self.success_message: if success in line: @@ -140,7 +161,7 @@ def read_output(self): err_line = err_file.readline().rstrip() if err_line: logger.info(err_line) - self.log_queue.put(err_line) + self._safe_log_put(err_line) if "Conversion failed!" in err_line or "Error during output" in err_line: self.error_detected = True if not self.error_detected: diff --git a/fastflix/conversion_worker.py b/fastflix/conversion_worker.py index fbd92c1a..0aec4f94 100644 --- a/fastflix/conversion_worker.py +++ b/fastflix/conversion_worker.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import logging from pathlib import Path -from queue import Empty +from queue import Empty, Full from typing import Literal from datetime import datetime @@ -55,7 +55,10 @@ def start_command(): while True: if currently_encoding and not runner.is_alive(): reusables.remove_file_handlers(logger) - log_queue.put("STOP_TIMER") + try: + log_queue.put("STOP_TIMER", timeout=1.0) + except Full: + pass # GUI likely dead, ignore currently_encoding = False if runner.error_detected: @@ -95,7 +98,10 @@ def start_command(): runner.kill() currently_encoding = False status_queue.put(("cancelled", video_uuid, command_uuid)) - log_queue.put("STOP_TIMER") + try: + log_queue.put("STOP_TIMER", timeout=1.0) + except Full: + pass # GUI likely dead, ignore if request[0] == "pause encode": logger.debug(t("Command worker received request to pause current encode")) @@ -115,3 +121,12 @@ def start_command(): priority = request[1] if runner.is_alive(): runner.change_priority(priority) + + if request[0] == "shutdown": + logger.debug(t("Shutdown signal received from GUI")) + if runner.is_alive(): + logger.info(t("Waiting for current encode to finish before shutdown")) + # Don't kill current encode, let it finish + continue + logger.debug(t("Worker shutting down gracefully")) + return diff --git a/fastflix/entry.py b/fastflix/entry.py index 2cd6f29a..b10348fb 100644 --- a/fastflix/entry.py +++ b/fastflix/entry.py @@ -2,7 +2,7 @@ import logging import sys import traceback -from multiprocessing import Process, Queue, freeze_support, Manager, Lock +from multiprocessing import Process, Queue, freeze_support try: import coloredlogs @@ -21,7 +21,7 @@ sys.exit(1) -def separate_app_process(worker_queue, status_queue, log_queue, queue_list, queue_lock, portable_mode=False): +def separate_app_process(worker_queue, status_queue, log_queue, portable_mode=False): """This prevents any QT components being imported in the main process""" from fastflix.models.config import Config @@ -35,8 +35,6 @@ def separate_app_process(worker_queue, status_queue, log_queue, queue_list, queu worker_queue, status_queue, log_queue, - queue_list, - queue_lock, portable_mode, enable_scaling=settings.get("enable_scaling", True), ) @@ -101,27 +99,35 @@ def main(portable_mode=False): status_queue = Queue() log_queue = Queue() - queue_lock = Lock() - with Manager() as manager: - queue_list = manager.list() - exit_status = 1 + exit_status = 1 - try: - logger.info("Preparing separate process for GUI - this may take a moment") - gui_proc = Process( - target=separate_app_process, - args=(worker_queue, status_queue, log_queue, queue_list, queue_lock, portable_mode), - ) - gui_proc.start() - except Exception: - logger.exception("Could not create GUI Process, please report this error!") - return exit_status + try: + logger.info("Preparing separate process for GUI - this may take a moment") + gui_proc = Process( + target=separate_app_process, + args=(worker_queue, status_queue, log_queue, portable_mode), + ) + gui_proc.start() + except Exception: + logger.exception("Could not create GUI Process, please report this error!") + return exit_status - try: - queue_worker(gui_proc, worker_queue, status_queue, log_queue) - exit_status = 0 - except Exception: - logger.exception("Exception occurred while running FastFlix core") - finally: - gui_proc.kill() - return exit_status + try: + queue_worker(gui_proc, worker_queue, status_queue, log_queue) + exit_status = 0 + except Exception: + logger.exception("Exception occurred while running FastFlix core") + finally: + # Try graceful shutdown first - wait for GUI to exit cleanly + if gui_proc.is_alive(): + logger.debug("Waiting for GUI process to exit gracefully...") + gui_proc.join(timeout=5.0) + + # If still alive after timeout, force kill + if gui_proc.is_alive(): + logger.warning("GUI process did not exit gracefully, forcing termination") + gui_proc.terminate() + gui_proc.join(timeout=2.0) + if gui_proc.is_alive(): + gui_proc.kill() + return exit_status diff --git a/fastflix/ff_queue.py b/fastflix/ff_queue.py index 88be2cbf..047c944f 100644 --- a/fastflix/ff_queue.py +++ b/fastflix/ff_queue.py @@ -1,11 +1,16 @@ # -*- coding: utf-8 -*- -from typing import Optional -import os -from pathlib import Path +import gc import logging +import os import shutil +import tempfile +import threading +import time import uuid -import gc +from contextlib import contextmanager +from pathlib import Path +from queue import Queue, Empty +from typing import Optional from box import Box, BoxError from ruamel.yaml import YAMLError @@ -17,6 +22,220 @@ logger = logging.getLogger("fastflix") +# Global lock for queue file operations within this process +_queue_file_lock = threading.Lock() + +# Track the last known generation ID for each queue file +_generation_tracker: dict[str, str] = {} + + +@contextmanager +def queue_file_lock(queue_file: Path, timeout: float = 30.0): + """ + Context manager that acquires both an in-process lock and a file-based lock. + + Uses a .lock file to prevent concurrent writes from multiple FastFlix instances. + The lock file is automatically cleaned up when the context exits. + + Args: + queue_file: Path to the queue file being protected + timeout: Maximum time to wait for lock acquisition + """ + lock_file = queue_file.with_suffix(".lock") + start_time = time.time() + lock_acquired = False + + # First acquire the in-process lock + with _queue_file_lock: + # Then try to acquire file-based lock + while time.time() - start_time < timeout: + try: + # Try to create lock file exclusively + # os.O_CREAT | os.O_EXCL fails if file exists + fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + lock_acquired = True + break + except FileExistsError: + # Lock file exists - check if it's stale (older than 60 seconds) + try: + if lock_file.exists(): + age = time.time() - lock_file.stat().st_mtime + if age > 60: + # Stale lock, remove it + logger.warning(f"Removing stale queue lock file (age: {age:.1f}s)") + lock_file.unlink(missing_ok=True) + continue + except OSError: + pass + time.sleep(0.1) + except OSError as e: + logger.warning(f"Error acquiring queue lock: {e}") + time.sleep(0.1) + + if not lock_acquired: + logger.error(f"Timeout waiting for queue lock after {timeout}s") + # Proceed anyway but warn - don't block the user forever + yield False + return + + try: + yield True + finally: + # Release the lock + try: + lock_file.unlink(missing_ok=True) + except OSError: + pass + + +class AsyncQueueSaver: + """ + Background thread for saving queue to disk without blocking the GUI. + + Uses a dedicated thread to handle YAML serialization and file I/O, + ensuring the GUI remains responsive even with large queues. + """ + + def __init__(self): + self._queue = Queue() + self._shutdown = False + self._thread = None + self._lock = threading.Lock() + + def start(self): + """Start the background saver thread.""" + if self._thread is None or not self._thread.is_alive(): + self._shutdown = False + self._thread = threading.Thread(target=self._worker, daemon=True) + self._thread.start() + + def _worker(self): + """Background worker that processes save requests.""" + while not self._shutdown: + try: + request = self._queue.get(timeout=0.5) + except Empty: + continue + + if request is None: # Shutdown signal + break + + queue_data, queue_file, config, expected_generation = request + try: + save_queue(queue_data, queue_file, config, expected_generation=expected_generation) + except Exception: + logger.exception("Async queue save failed") + + def save(self, queue: list, queue_file: Path, config: Optional["Config"] = None): + """ + Queue a save operation to be performed asynchronously. + + Args: + queue: List of Video objects to save + queue_file: Path to save the queue YAML file + config: Optional Config object for work paths + """ + # Capture the expected generation at the time of queueing + # This allows us to detect if another save completed between queueing and execution + expected_generation = get_current_generation(queue_file) + + # Make a deep copy of the queue data to avoid race conditions + import copy + + try: + queue_copy = copy.deepcopy(queue) + except Exception: + logger.warning("Could not deep copy queue for async save, falling back to sync save") + save_queue(queue, queue_file, config, expected_generation=expected_generation) + return + + self._queue.put((queue_copy, queue_file, config, expected_generation)) + + def shutdown(self, timeout: float = 5.0): + """ + Shutdown the background saver thread gracefully. + + Args: + timeout: Maximum time to wait for pending saves to complete + """ + self._shutdown = True + self._queue.put(None) # Signal worker to exit + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=timeout) + + def wait_for_pending(self, timeout: float = 10.0): + """ + Wait for all pending save operations to complete. + + Args: + timeout: Maximum time to wait + """ + # Shutdown and restart the thread to ensure all pending saves complete + self._queue.put(None) # Flush marker + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=timeout) + # Restart the thread for future saves + self._shutdown = False + self.start() + + +# Global async saver instance +_async_saver: Optional[AsyncQueueSaver] = None + + +def get_async_saver() -> AsyncQueueSaver: + """Get or create the global async queue saver instance.""" + global _async_saver + if _async_saver is None: + _async_saver = AsyncQueueSaver() + _async_saver.start() + return _async_saver + + +def save_queue_async(queue: list[Video], queue_file: Path, config: Optional[Config] = None): + """ + Save the queue asynchronously in a background thread. + + This prevents GUI blocking during YAML serialization and file I/O. + """ + saver = get_async_saver() + saver.save(queue, queue_file, config) + + +def shutdown_async_saver(timeout: float = 5.0): + """Shutdown the async saver, waiting for pending saves to complete.""" + global _async_saver + if _async_saver is not None: + _async_saver.shutdown(timeout) + _async_saver = None + + +def get_queue_generation(queue_file: Path) -> Optional[str]: + """ + Read the generation ID from a queue file without loading the full queue. + + Returns None if file doesn't exist or has no generation marker. + """ + if not queue_file.exists(): + return None + try: + loaded = Box.from_yaml(filename=queue_file) + return loaded.get("_generation") + except (BoxError, YAMLError): + return None + + +def get_current_generation(queue_file: Path) -> Optional[str]: + """Get the last known generation for a queue file from the tracker.""" + return _generation_tracker.get(str(queue_file)) + + +def set_current_generation(queue_file: Path, generation: str): + """Update the tracked generation for a queue file.""" + _generation_tracker[str(queue_file)] = generation + def get_queue(queue_file: Path) -> list[Video]: if not queue_file.exists(): @@ -28,6 +247,10 @@ def get_queue(queue_file: Path) -> list[Video]: logger.exception("Could not open queue") return [] + # Update generation tracker with the loaded file's generation + if "_generation" in loaded: + set_current_generation(queue_file, loaded["_generation"]) + queue = [] for video in loaded["queue"]: video["source"] = Path(video["source"]) @@ -64,8 +287,23 @@ def get_queue(queue_file: Path) -> list[Video]: return queue -def save_queue(queue: list[Video], queue_file: Path, config: Optional[Config] = None): +def save_queue( + queue: list[Video], + queue_file: Path, + config: Optional[Config] = None, + expected_generation: Optional[str] = None, +): + """ + Save the queue to a YAML file with generation tracking. + + Args: + queue: List of Video objects to save + queue_file: Path to save the queue YAML file + config: Optional Config object for work paths + expected_generation: If provided, verifies the file hasn't changed unexpectedly + """ items = [] + queue_file = Path(queue_file) if config is not None: queue_covers = config.work_path / "covers" @@ -117,12 +355,57 @@ def update_conversion_command(vid, old_path: str, new_path: str): track["file_path"] = str(new_file) items.append(video) - try: - tmp = Box(queue=items) - tmp.to_yaml(filename=queue_file) - del tmp - except Exception as err: - logger.warning(items) - logger.exception(f"Could not save queue! {err.__class__.__name__}: {err}") - raise err from None + + # Use file lock and atomic write to prevent corruption + with queue_file_lock(queue_file) as lock_acquired: + if not lock_acquired: + logger.warning("Proceeding with queue save without lock - potential race condition") + + # Verify generation if expected_generation was provided + if expected_generation is not None: + current_file_generation = get_queue_generation(queue_file) + if current_file_generation is not None and current_file_generation != expected_generation: + logger.error( + f"Queue file generation mismatch! Expected '{expected_generation}', " + f"but file has '{current_file_generation}'. " + "Another save completed between queue and execution. " + "Skipping this save to avoid overwriting newer data." + ) + return # Abort save - a newer save already completed + + # Generate new generation ID for this save + new_generation = uuid.uuid4().hex + + try: + tmp = Box(queue=items, _generation=new_generation) + + # Atomic write: write to temp file in same directory, then rename + # This ensures we never have a partially written queue file + temp_fd, temp_path = tempfile.mkstemp( + suffix=".yaml.tmp", + prefix="queue_", + dir=queue_file.parent, + ) + try: + os.close(temp_fd) # Close the fd, Box.to_yaml will open it + tmp.to_yaml(filename=temp_path) + del tmp + + # Atomic rename (on POSIX this is atomic, on Windows it replaces) + # Use shutil.move for cross-platform compatibility + shutil.move(temp_path, queue_file) + + # Update the generation tracker after successful save + set_current_generation(queue_file, new_generation) + except Exception: + # Clean up temp file on error + try: + os.unlink(temp_path) + except OSError: + pass + raise + except Exception as err: + logger.warning(items) + logger.exception(f"Could not save queue! {err.__class__.__name__}: {err}") + raise err from None gc.collect(2) diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index c5c9c368..f8dfa728 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -8,10 +8,11 @@ import secrets import shutil import time +from collections import namedtuple from datetime import timedelta from pathlib import Path +from queue import Empty from typing import Tuple, Union, Optional -from collections import namedtuple import importlib.resources import reusables @@ -1844,13 +1845,28 @@ def page_update(self, build_thumbnail=True, force_build_thumbnail=False): def close(self, no_cleanup=False, from_container=False): self.app.fastflix.shutting_down = True + + # Signal worker process to shutdown gracefully + try: + self.app.fastflix.worker_queue.put(["shutdown"]) + except Exception: + logger.debug("Could not send shutdown signal to worker") + + # Shutdown async queue saver and wait for pending saves + from fastflix.ff_queue import shutdown_async_saver + + shutdown_async_saver(timeout=5.0) + if not no_cleanup: try: shutil.rmtree(self.temp_dir, ignore_errors=True) except Exception: pass self.video_options.cleanup() - self.notifier.terminate() + self.notifier.request_shutdown() + self.notifier.wait(1000) # Wait up to 1 second for graceful shutdown + if self.notifier.isRunning(): + self.notifier.terminate() super().close() if not from_container: self.container.close() @@ -2157,22 +2173,26 @@ def __init__(self, parent, app, status_queue): self.app = app self.main: Main = parent self.status_queue = status_queue + self._shutdown = False def __del__(self): self.wait() + def request_shutdown(self): + """Request graceful shutdown of the thread.""" + self._shutdown = True + def run(self): - while True: + while not self._shutdown: # Message looks like (command, video_uuid, command_uuid) - # time.sleep(0.01) - status = self.status_queue.get() + try: + status = self.status_queue.get(timeout=0.5) + except Empty: + continue self.app.processEvents() if status[0] == "exit": logger.debug("GUI received ask to exit") - try: - self.terminate() - finally: - self.main.close_event.emit() + self.main.close_event.emit() return self.main.status_update_signal.emit(status) self.app.processEvents() diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index ad53c3a8..2474e695 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -16,7 +16,7 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.video import Video -from fastflix.ff_queue import get_queue, save_queue +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.widgets.panels.abstract_list import FlixList @@ -304,8 +304,6 @@ def __init__(self, parent, app: FastFlixApp): self.queue_startup_check() except Exception: logger.exception("Could not load queue as it is outdated or malformed. Deleting for safety.") - # with self.app.fastflix.queue_lock: - # save_queue([], queue_file=self.app.fastflix.queue_path, config=self.app.fastflix.config) def queue_startup_check(self, queue_file=None): new_queue = get_queue(queue_file or self.app.fastflix.queue_path) @@ -339,7 +337,7 @@ def queue_startup_check(self, queue_file=None): # metadata_file.unlink(missing_ok=True) self.new_source() - save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) + save_queue_async(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) def manually_save_queue(self): filename = QtWidgets.QFileDialog.getSaveFileName( @@ -395,7 +393,7 @@ def reorder(self, update=True): if self.tracks: self.tracks[0].widgets.up_button.setDisabled(True) self.tracks[-1].widgets.down_button.setDisabled(True) - save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) + save_queue_async(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) def new_source(self): for i in range(len(self.tracks) - 1, -1, -1): @@ -440,7 +438,7 @@ def remove_item(self, video, part_of_clear=False): if not part_of_clear: self.new_source() - save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) + save_queue_async(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) def reload_from_queue(self, video): try: @@ -551,7 +549,7 @@ def add_to_queue(self): self.app.fastflix.conversion_list.append(copy.deepcopy(self.app.fastflix.current_video)) self.new_source() - save_queue(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) + save_queue_async(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) def run_after_done(self): if not self.after_done_action: diff --git a/fastflix/widgets/panels/status_panel.py b/fastflix/widgets/panels/status_panel.py index e6f0f3d5..946e80dd 100644 --- a/fastflix/widgets/panels/status_panel.py +++ b/fastflix/widgets/panels/status_panel.py @@ -6,6 +6,8 @@ from datetime import timedelta from typing import Optional +from queue import Empty + from PySide6 import QtCore, QtWidgets from fastflix.exceptions import FlixError @@ -68,7 +70,10 @@ def __init__(self, parent, app: FastFlixApp): self.tick_signal.connect(self.update_time_elapsed) def cleanup(self): - self.inner_widget.log_updater.terminate() + self.inner_widget.log_updater.request_shutdown() + self.inner_widget.log_updater.wait(1000) # Wait up to 1 second for graceful shutdown + if self.inner_widget.log_updater.isRunning(): + self.inner_widget.log_updater.terminate() self.ticker_thread.stop_signal.emit() self.ticker_thread.terminate() @@ -262,13 +267,21 @@ def __init__(self, parent, log_queue): super().__init__(parent) self.parent = parent self.log_queue = log_queue + self._shutdown = False def __del__(self): self.wait() + def request_shutdown(self): + """Request graceful shutdown of the thread.""" + self._shutdown = True + def run(self): - while True: - msg = self.log_queue.get() + while not self._shutdown: + try: + msg = self.log_queue.get(timeout=0.5) + except Empty: + continue if msg.startswith("CLEAR_WINDOW"): self.parent.clear_window.emit(msg) self.parent.timer_signal.emit("START") From c3bf659bc21bd257ee0df7f55952e1bb39d58e46 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Tue, 20 Jan 2026 20:15:05 -0500 Subject: [PATCH 08/25] * Fixing window resizing beyond screen boundaries when switching profiles on macOS --- CHANGES | 1 + fastflix/widgets/container.py | 79 +++++++++++++++++++++++++++++++ fastflix/widgets/main.py | 2 + fastflix/widgets/video_options.py | 2 + 4 files changed, 84 insertions(+) diff --git a/CHANGES b/CHANGES index 1aaeb25c..0d5f1ecc 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,7 @@ ## Version 5.12.5 +* Fixing window resizing beyond screen boundaries when switching profiles on macOS * Adding async queue saving to prevent GUI blocking during queue operations * Adding atomic file writes for queue to prevent corruption from interrupted saves * Adding file-based locking for queue operations to prevent race conditions between instances diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 6cd70375..0e028bbb 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -87,6 +87,7 @@ def __init__(self, app: FastFlixApp, **kwargs): self.setBaseSize(QtCore.QSize(1350, 750)) self.icon = QtGui.QIcon(main_icon) self.setWindowIcon(self.icon) + self._constrain_to_screen() self.main.set_profile() if self.app.fastflix.config.theme == "onyx": @@ -111,6 +112,84 @@ def __init__(self, app: FastFlixApp, **kwargs): # self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) self.moveFlag = False + def _constrain_to_screen(self): + """Ensure the window fits within the available screen geometry.""" + screen = QtGui.QGuiApplication.primaryScreen() + 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) + + def ensure_window_in_bounds(self): + """Public method to ensure window stays within screen bounds after content changes.""" + self._constrain_to_screen() + screen = QtGui.QGuiApplication.primaryScreen() + if screen is None: + return + available = screen.availableGeometry() + geometry = self.geometry() + + # Calculate new position if window is out of bounds + new_x = geometry.x() + new_y = geometry.y() + new_width = min(geometry.width(), available.width() - 20) + new_height = min(geometry.height(), available.height() - 20) + + # Ensure window doesn't go off the right edge + if new_x + new_width > available.right(): + new_x = max(available.left(), available.right() - new_width) + + # Ensure window doesn't go off the bottom edge + if new_y + new_height > available.bottom(): + new_y = max(available.top(), available.bottom() - new_height) + + # Ensure window doesn't go off the left or top edges + new_x = max(available.left(), new_x) + new_y = max(available.top(), new_y) + + # Apply the constrained geometry + self.setGeometry(new_x, new_y, new_width, new_height) + + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: + """Handle resize events to ensure window stays within screen bounds.""" + super().resizeEvent(event) + screen = QtGui.QGuiApplication.primaryScreen() + if screen is None: + return + available = screen.availableGeometry() + geometry = self.geometry() + + # Check if window exceeds screen boundaries and adjust + needs_move = False + new_x = geometry.x() + new_y = geometry.y() + + # Ensure window doesn't go off the right edge + if geometry.right() > available.right(): + new_x = max(available.left(), available.right() - geometry.width()) + needs_move = True + + # Ensure window doesn't go off the bottom edge + if geometry.bottom() > available.bottom(): + new_y = max(available.top(), available.bottom() - geometry.height()) + needs_move = True + + # Ensure window doesn't go off the left edge + if geometry.left() < available.left(): + new_x = available.left() + needs_move = True + + # Ensure window doesn't go off the top edge + if geometry.top() < available.top(): + new_y = available.top() + needs_move = True + + if needs_move: + self.move(new_x, new_y) + # def mousePressEvent(self, event): # if event.button() == QtCore.Qt.LeftButton: # self.moveFlag = True diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index f8dfa728..e428fe86 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -678,6 +678,8 @@ def set_profile(self): # Hack to prevent a lot of thumbnail generation self.loading_video = False self.page_update() + # Ensure window stays within screen bounds after profile change + self.container.ensure_window_in_bounds() def save_profile(self): self.video_options.get_settings() diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index 58526c4c..dfa37f43 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -160,6 +160,8 @@ def change_conversion(self, conversion, previous_encoder_no_audio=False): if not self.reloading: self.audio.allowed_formats(self._get_audio_formats(encoder)) # self.update_profile() + # Ensure window stays within screen bounds after encoder change + self.main.container.ensure_window_in_bounds() def get_settings(self): if not self.app.fastflix.current_video: From 428eb088d87df2f7b31d98d6ba6734cee67bf1cb Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 2 Feb 2026 19:08:01 -0600 Subject: [PATCH 09/25] Add audio profile title options and fix dropdown styling --- CHANGES | 5 +- fastflix/data/languages.yaml | 105 ++++++++++++++++++ .../styles/breeze_styles/dark/stylesheet.qss | 27 +++-- .../styles/breeze_styles/light/stylesheet.qss | 29 +++-- .../styles/breeze_styles/onyx/stylesheet.qss | 27 +++-- fastflix/encoders/common/audio.py | 18 ++- fastflix/models/profiles.py | 20 +++- fastflix/version.py | 2 +- fastflix/widgets/container.py | 1 + fastflix/widgets/panels/audio_panel.py | 102 ++++++++++++++++- fastflix/widgets/video_options.py | 1 + fastflix/widgets/windows/profile_window.py | 66 ++++++++++- 12 files changed, 352 insertions(+), 51 deletions(-) diff --git a/CHANGES b/CHANGES index 0d5f1ecc..174ee607 100644 --- a/CHANGES +++ b/CHANGES @@ -1,12 +1,13 @@ # Changelog -## Version 5.12.5 +## Version 5.13.0 -* Fixing window resizing beyond screen boundaries when switching profiles on macOS +* Adding #712 audio profile title options: No Title, Generate Title, and Custom Title * Adding async queue saving to prevent GUI blocking during queue operations * Adding atomic file writes for queue to prevent corruption from interrupted saves * Adding file-based locking for queue operations to prevent race conditions between instances * Adding graceful shutdown handling for worker process and background threads +* Fixing window resizing beyond screen boundaries when switching profiles on macOS * Fixing potential GUI freeze when log queue fills up during encoding * Fixing file handle leaks in command runner when process startup fails * Fixing crash when dragging non-video content over the main window diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 11e4a6db..cbcb7870 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -11272,3 +11272,108 @@ Error checking subtitle format for track: ukr: 'Примітка: час початку та закінчення ігнорується' kor: '참고: 시작 및 종료 시간은 무시됩니다.' ron: 'Notă: ora de început și de sfârșit vor fi ignorate' +Shutdown signal received from GUI: + eng: Shutdown signal received from GUI + deu: Von der GUI empfangenes Abschaltsignal + fra: Signal d'arrêt reçu de l'interface graphique + ita: Segnale di spegnimento ricevuto dalla GUI + spa: Señal de apagado recibida de GUI + jpn: GUIからシャットダウン信号を受信 + rus: Сигнал выключения, полученный от графического интерфейса пользователя + por: Sinal de encerramento recebido da GUI + swe: Avstängningssignal mottagen från GUI + pol: Sygnał wyłączenia odebrany z GUI + chs: 从图形用户界面接收到关机信号 + ukr: Отримано сигнал вимкнення від графічного інтерфейсу + kor: GUI에서 수신한 종료 신호 + ron: Semnal de închidere primit de la GUI +Worker shutting down gracefully: + eng: Worker shutting down gracefully + deu: Worker wird ordnungsgemäß heruntergefahren + fra: Le travailleur s'arrête en douceur + ita: L'operaio si spegne con grazia + spa: El trabajador se apaga correctamente + jpn: 優雅にシャットダウンするワーカー + rus: Грациозное завершение работы + por: Encerramento gracioso do trabalhador + swe: Arbetstagaren stängs av på ett elegant sätt + pol: Pracownik wyłącza się z wdziękiem + chs: 工人优雅地关闭 + ukr: Працівник граціозно вимикається + kor: 우아하게 종료되는 작업자 + ron: Închiderea grațioasă a lucrătorului +Original Title: + eng: Original Title + deu: Originaltitel + fra: Titre original + ita: Titolo originale + spa: Título original + jpn: 原題 + rus: Оригинальное название + por: Título original + swe: Originaltitel + pol: Tytuł oryginału + chs: 原标题 + ukr: Оригінальна назва + kor: 원본 제목 + ron: Titlu original +No Title: + eng: No Title + deu: Kein Titel + fra: Sans titre + ita: Nessun titolo + spa: Sin título + jpn: タイトルなし + rus: Без названия + por: Sem título + swe: Ingen titel + pol: Bez tytułu + chs: 无标题 + ukr: Без назви + kor: 제목 없음 + ron: Fără titlu +Generate Title: + eng: Generate Title + deu: Titel generieren + fra: Générer un titre + ita: Generare il titolo + spa: Generar título + jpn: タイトルの生成 + rus: Создать название + por: Gerar título + swe: Generera titel + pol: Wygeneruj tytuł + chs: 生成标题 + ukr: Згенерувати заголовок + kor: 제목 생성 + ron: Generarea titlului +Enter custom title: + eng: Enter custom title + deu: Benutzerdefinierten Titel eingeben + fra: Saisir un titre personnalisé + ita: Inserire un titolo personalizzato + spa: Introducir título personalizado + jpn: カスタムタイトルを入力 + rus: Введите пользовательское название + por: Introduzir título personalizado + swe: Ange egen titel + pol: Wprowadź niestandardowy tytuł + chs: 输入自定义标题 + ukr: Введіть власну назву + kor: 사용자 지정 제목 입력 + ron: Introduceți titlul personalizat +Custom Title: + eng: Custom Title + deu: Benutzerdefinierter Titel + fra: Titre personnalisé + ita: Titolo personalizzato + spa: Título personalizado + jpn: カスタムタイトル + rus: Пользовательское название + por: Título personalizado + swe: Anpassad titel + pol: Tytuł niestandardowy + chs: 自定义标题 + ukr: Користувацька назва + kor: 사용자 지정 제목 + ron: Titlu personalizat diff --git a/fastflix/data/styles/breeze_styles/dark/stylesheet.qss b/fastflix/data/styles/breeze_styles/dark/stylesheet.qss index eaf178f8..e75d15fd 100644 --- a/fastflix/data/styles/breeze_styles/dark/stylesheet.qss +++ b/fastflix/data/styles/breeze_styles/dark/stylesheet.qss @@ -276,7 +276,7 @@ QMenuBar::item QMenuBar::item:selected { - background: transparent; + background-color: #3daee9; } QMenuBar::item:disabled @@ -295,7 +295,9 @@ QMenuBar::item:pressed QMenu { color: #eff0f1; + background-color: #1d2023; margin: 0.09em; + border: 1px solid #76797c; } QMenu::icon @@ -394,10 +396,6 @@ QAbstractItemView border-radius: 0.09em; } -QMenuBar::item:focus:!disabled -{ - border: 0.05em solid #3daee9; -} QTabWidget:focus, QCheckBox:focus, @@ -667,11 +665,6 @@ QFrame[frameShape="6"]:hover border: 0.05em solid #3daee9; } -/* Don't provide an outline if we have a widget that takes up the space. */ -QFrame[frameShape] QAbstractItemView:hover -{ - border: 0em solid black; -} /** * Note: I can't really change the background of the toolbars @@ -865,7 +858,13 @@ QComboBox QAbstractItemView background-color: #1d2023; selection-background-color: #2a79a3; outline-color: 0em; - border-radius: 0.09em; + border-radius: 4px; + border: 2px solid #76797c; +} + +QComboBox QAbstractItemView::item +{ + border: none; } QComboBox::drop-down @@ -1475,6 +1474,12 @@ QListView padding: 0.2em; } +QComboBox QListView +{ + background-color: #1d2023; + border: 2px solid #76797c; +} + QTableView::item, QListView::item, QTreeView::item diff --git a/fastflix/data/styles/breeze_styles/light/stylesheet.qss b/fastflix/data/styles/breeze_styles/light/stylesheet.qss index bfa22bcb..4ff8ac38 100644 --- a/fastflix/data/styles/breeze_styles/light/stylesheet.qss +++ b/fastflix/data/styles/breeze_styles/light/stylesheet.qss @@ -276,7 +276,7 @@ QMenuBar::item QMenuBar::item:selected { - background: transparent; + background-color: rgba(51, 164, 223, 0.5); } QMenuBar::item:disabled @@ -295,7 +295,9 @@ QMenuBar::item:pressed QMenu { color: #31363b; + background-color: #e0e1e2; margin: 0.09em; + border: 1px solid #76797c; } QMenu::icon @@ -394,10 +396,6 @@ QAbstractItemView border-radius: 0.09em; } -QMenuBar::item:focus:!disabled -{ - border: 0.05em solid rgba(51, 164, 223, 0.5); -} QTabWidget:focus, QCheckBox:focus, @@ -667,11 +665,6 @@ QFrame[frameShape="6"]:hover border: 0.05em solid rgba(51, 164, 223, 0.5); } -/* Don't provide an outline if we have a widget that takes up the space. */ -QFrame[frameShape] QAbstractItemView:hover -{ - border: 0em solid black; -} /** * Note: I can't really change the background of the toolbars @@ -862,10 +855,16 @@ QComboBox:hover:pressed:editable QComboBox QAbstractItemView { /* This happens for the drop-down menu always, whether editable or not.*/ - background-color: #eff0f1; + background-color: #e0e1e2; selection-background-color: rgba(45, 147, 200, 0.5); outline-color: 0em; - border-radius: 0.09em; + border-radius: 4px; + border: 2px solid #76797c; +} + +QComboBox QAbstractItemView::item +{ + border: none; } QComboBox::drop-down @@ -1475,6 +1474,12 @@ QListView padding: 0.2em; } +QComboBox QListView +{ + background-color: #e0e1e2; + border: 2px solid #76797c; +} + QTableView::item, QListView::item, QTreeView::item diff --git a/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss b/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss index e4cc8000..c8d4575a 100644 --- a/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss +++ b/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss @@ -277,7 +277,7 @@ QMenuBar::item QMenuBar::item:selected { - background: transparent; + background-color: #3daee9; } QMenuBar::item:disabled @@ -296,7 +296,9 @@ QMenuBar::item:pressed QMenu { color: #eff0f1; + background-color: #1d2023; margin: 0.09em; + border: 1px solid #76797c; } QMenu::icon @@ -395,10 +397,6 @@ QAbstractItemView border-radius: 0.09em; } -QMenuBar::item:focus:!disabled -{ - border: 0.05em solid #3daee9; -} QTabWidget:focus, QCheckBox:focus, @@ -668,11 +666,6 @@ QFrame[frameShape="6"]:hover border: 0.05em solid #3daee9; } -/* Don't provide an outline if we have a widget that takes up the space. */ -QFrame[frameShape] QAbstractItemView:hover -{ - border: 0em solid black; -} /** * Note: I can't really change the background of the toolbars @@ -866,7 +859,13 @@ QComboBox QAbstractItemView background-color: #1d2023; selection-background-color: #2a79a3; outline-color: 0em; - border-radius: 0.09em; + border-radius: 4px; + border: 2px solid #76797c; +} + +QComboBox QAbstractItemView::item +{ + border: none; } QComboBox::drop-down @@ -1476,6 +1475,12 @@ QListView padding: 0.2em; } +QComboBox QListView +{ + background-color: #1d2023; + border: 2px solid #76797c; +} + QTableView::item, QListView::item, QTreeView::item diff --git a/fastflix/encoders/common/audio.py b/fastflix/encoders/common/audio.py index 79d4219c..a9e45c2b 100644 --- a/fastflix/encoders/common/audio.py +++ b/fastflix/encoders/common/audio.py @@ -56,11 +56,19 @@ def build_audio(audio_tracks, audio_file_index=0): for track in audio_tracks: if not track.enabled: continue - command_list.append( - f"-map {audio_file_index}:{track.index} " - f'-metadata:s:{track.outdex} title="{track.title}" ' - f'-metadata:s:{track.outdex} handler="{track.title}"' - ) + if track.title: + command_list.append( + f"-map {audio_file_index}:{track.index} " + f'-metadata:s:{track.outdex} title="{track.title}" ' + f'-metadata:s:{track.outdex} handler="{track.title}"' + ) + else: + # No title - clear any existing title metadata + command_list.append( + f"-map {audio_file_index}:{track.index} " + f'-metadata:s:{track.outdex} title="" ' + f'-metadata:s:{track.outdex} handler=""' + ) if track.language: command_list.append(f"-metadata:s:{track.outdex} language={track.language}") if not track.conversion_codec or track.conversion_codec == "none": diff --git a/fastflix/models/profiles.py b/fastflix/models/profiles.py index 406afc5f..1b05e27f 100644 --- a/fastflix/models/profiles.py +++ b/fastflix/models/profiles.py @@ -37,7 +37,7 @@ ) -__all__ = ["MatchItem", "MatchType", "AudioMatch", "Profile", "SubtitleMatch", "AdvancedOptions"] +__all__ = ["MatchItem", "MatchType", "TitleMode", "AudioMatch", "Profile", "SubtitleMatch", "AdvancedOptions"] class MatchItem(Enum): @@ -54,6 +54,13 @@ class MatchType(Enum): LAST = 3 +class TitleMode(Enum): + ORIGINAL = 1 + NO_TITLE = 2 + GENERATE = 3 + CUSTOM = 4 + + class AudioMatch(BaseModel): match_type: Union[MatchType, list[MatchType]] # TODO figure out why when saved becomes list in yaml match_item: Union[MatchItem, list[MatchItem]] @@ -61,6 +68,8 @@ class AudioMatch(BaseModel): conversion: Optional[str] = None bitrate: Optional[str] = None downmix: Optional[Union[str, int]] = None + title_mode: Union[TitleMode, list[TitleMode]] = TitleMode.ORIGINAL + custom_title: Optional[str] = None @field_validator("match_type", mode="before") @classmethod @@ -76,6 +85,15 @@ def match_item_must_be_enum(cls, v): return MatchItem(v[0]) return MatchItem(v) + @field_validator("title_mode", mode="before") + @classmethod + def title_mode_must_be_enum(cls, v): + if v is None: + return TitleMode.ORIGINAL + if isinstance(v, list): + return TitleMode(v[0]) + return TitleMode(v) + @field_validator("downmix", mode="before") @classmethod def downmix_as_string(cls, v): diff --git a/fastflix/version.py b/fastflix/version.py index 8918b64f..e8760103 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "5.12.5" +__version__ = "5.13.0" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 0e028bbb..82e4545e 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -94,6 +94,7 @@ def __init__(self, app: FastFlixApp, **kwargs): self.setStyleSheet( """ QAbstractItemView{ background-color: #4b5054; } + QComboBox QAbstractItemView{ background-color: #1d2023; border: 2px solid #76797c; } QPushButton{ border-radius:10px; } QLineEdit{ background-color: #707070; color: black; border-radius: 10px; } QTextEdit{ background-color: #707070; color: black; } diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index a814a54f..8ace9f6b 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -9,7 +9,7 @@ from fastflix.language import t, Language from fastflix.models.encode import AudioTrack -from fastflix.models.profiles import Profile +from fastflix.models.profiles import Profile, TitleMode from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import get_icon from fastflix.shared import no_border, error_message, yes_no_message, clear_list @@ -21,6 +21,57 @@ language_list = [v.name for v in iter_langs() if v.pt2b and v.pt1] + ["Undefined"] logger = logging.getLogger("fastflix") +# Mapping of channel counts to friendly names +channels_to_layout = { + 1: "Mono", + 2: "Stereo", + 3: "2.1", + 4: "4.0", + 5: "5.0", + 6: "5.1", + 7: "6.1", + 8: "7.1", +} + +# Mapping of codec names to friendly display names +codec_display_names = { + "aac": "AAC", + "ac3": "AC3", + "eac3": "E-AC3", + "truehd": "TrueHD", + "dts": "DTS", + "dca": "DTS", + "flac": "FLAC", + "alac": "ALAC", + "opus": "Opus", + "libopus": "Opus", + "vorbis": "Vorbis", + "libvorbis": "Vorbis", + "mp3": "MP3", + "libmp3lame": "MP3", + "pcm_s16le": "PCM", + "pcm_s24le": "PCM", + "pcm_s32le": "PCM", +} + + +def generate_audio_title(codec: str, channels: int, downmix: str | None = None) -> str: + """Generate a friendly audio title like 'TrueHD 5.1' from codec and channel info.""" + # Get friendly codec name + codec_lower = codec.lower() if codec else "" + friendly_codec = codec_display_names.get(codec_lower, codec.upper() if codec else "Audio") + + # Determine channel layout + if downmix and downmix != "No Downmix": + # Use downmix layout directly (e.g., "stereo", "5.1") + channel_layout = downmix + else: + # Use channel count to determine layout + channel_layout = channels_to_layout.get(channels, f"{channels}ch") + + return f"{friendly_codec} {channel_layout}" + + disposition_options = [ "default", "dub", @@ -268,7 +319,7 @@ def close(self) -> bool: del self.widgets return super().close() - def update_track(self, conversion=None, bitrate=None, downmix=None): + def update_track(self, conversion=None, bitrate=None, downmix=None, title=None): audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[self.index] if conversion: audio_track.conversion_codec = conversion @@ -276,6 +327,9 @@ def update_track(self, conversion=None, bitrate=None, downmix=None): audio_track.conversion_bitrate = bitrate if downmix: audio_track.downmix = downmix + if title is not None: + audio_track.title = title + self.widgets.title.setText(title) self.page_update() def check_conversion_button(self): @@ -407,14 +461,38 @@ def apply_profile_settings( clear_list(self.tracks) def gen_track( - parent, audio_track, outdex, og=False, enabled=True, downmix=None, conversion=None, bitrate=None + parent, + audio_track, + outdex, + og=False, + enabled=True, + downmix=None, + conversion=None, + bitrate=None, + title_mode=None, + custom_title=None, ) -> Audio: track_info, tags = self._get_track_info(audio_track) + + # Determine title based on title_mode + if title_mode == TitleMode.NO_TITLE: + title = "" + elif title_mode == TitleMode.GENERATE: + # Generate title from codec and channel info + codec = conversion if conversion else audio_track.codec_name + title = generate_audio_title(codec, audio_track.channels, downmix) + elif title_mode == TitleMode.CUSTOM: + # Use custom title from the audio match + title = custom_title if custom_title else "" + else: + # Original title (default) + title = tags.get("title", "") + self.app.fastflix.current_video.audio_tracks.append( AudioTrack( index=audio_track.index, outdex=outdex, - title=tags.get("title", ""), + title=title, language=tags.get("language", ""), profile=audio_track.get("profile"), channels=audio_track.channels, @@ -475,10 +553,24 @@ def gen_track( track_pos = stream_index_to_track.get(track[0].index) if track_pos is not None: self.tracks[track_pos].widgets.enable_check.setChecked(True) + + # Determine title based on title_mode + title_mode = track[1].title_mode + if title_mode == TitleMode.NO_TITLE: + title = "" + elif title_mode == TitleMode.GENERATE: + codec = track[1].conversion if track[1].conversion else track[0].codec_name + title = generate_audio_title(codec, track[0].channels, track[1].downmix) + elif title_mode == TitleMode.CUSTOM: + title = track[1].custom_title if track[1].custom_title else "" + else: + title = None # Keep original + self.tracks[track_pos].update_track( downmix=track[1].downmix, conversion=track[1].conversion, bitrate=track[1].bitrate, + title=title, ) skip_tracks.append(idx) @@ -496,6 +588,8 @@ def gen_track( conversion=track[1].conversion, bitrate=track[1].bitrate, downmix=track[1].downmix, + title_mode=track[1].title_mode, + custom_title=track[1].custom_title, ) ) diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index dfa37f43..c1d512c7 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -64,6 +64,7 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): "*{ background-color: #4b5054; color: white} QTabWidget{margin-top: 34px; background-color: #4b5054;} " "QTabBar{font-size: 13px; background-color: #4f5962}" "QComboBox{min-height: 1.1em;}" + "QComboBox QAbstractItemView{ background-color: #1d2023; border: 2px solid #76797c; }" ) self.setIconSize(QtCore.QSize(24, 24)) diff --git a/fastflix/widgets/windows/profile_window.py b/fastflix/widgets/windows/profile_window.py index 611cc3d7..b6802b5d 100644 --- a/fastflix/widgets/windows/profile_window.py +++ b/fastflix/widgets/windows/profile_window.py @@ -12,7 +12,7 @@ from fastflix.widgets.panels.abstract_list import FlixList from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.encode import x265Settings, setting_types -from fastflix.models.profiles import AudioMatch, Profile, MatchItem, MatchType, AdvancedOptions +from fastflix.models.profiles import AudioMatch, Profile, MatchItem, MatchType, TitleMode, AdvancedOptions from fastflix.shared import error_message from fastflix.encoders.common.audio import channel_list @@ -29,6 +29,9 @@ sub_match_item_enums = [MatchItem.ALL, MatchItem.TRACK, MatchItem.LANGUAGE] sub_match_item_locale = [t("All"), t("Track Number"), t("Language")] +title_mode_enums = [TitleMode.ORIGINAL, TitleMode.NO_TITLE, TitleMode.GENERATE, TitleMode.CUSTOM] +title_mode_locale = [t("Original Title"), t("No Title"), t("Generate Title"), t("Custom Title")] + class AudioProfile(QtWidgets.QTabWidget): def __init__(self, parent_list, app, main, parent, index): @@ -41,7 +44,7 @@ def __init__(self, parent_list, app, main, parent, index): self.match_type.addItems(match_type_locale) self.match_type.view().setFixedWidth(self.match_type.minimumSizeHint().width() + 50) - self.setFixedHeight(120) + self.setFixedHeight(150) self.match_item = QtWidgets.QComboBox() self.match_item.addItems(match_item_locale) @@ -85,6 +88,16 @@ def __init__(self, parent_list, app, main, parent, index): self.downmix.setCurrentIndex(0) self.downmix.view().setFixedWidth(self.downmix.minimumSizeHint().width() + 50) + self.title_mode = QtWidgets.QComboBox() + self.title_mode.addItems(title_mode_locale) + self.title_mode.setCurrentIndex(0) + self.title_mode.view().setFixedWidth(self.title_mode.minimumSizeHint().width() + 50) + + self.custom_title = QtWidgets.QLineEdit() + self.custom_title.setPlaceholderText(t("Enter custom title")) + self.custom_title.setDisabled(True) + self.custom_title.setFixedWidth(150) + self.convert_to = QtWidgets.QComboBox() self.convert_to.addItems(["None | Passthrough"] + main.video_options.audio_formats) @@ -101,6 +114,10 @@ def __init__(self, parent_list, app, main, parent, index): self.grid.addWidget(QtWidgets.QLabel(t("Bitrate")), 1, 2) self.grid.addWidget(self.bitrate, 1, 3) self.grid.addWidget(self.downmix, 1, 4) + + self.grid.addWidget(QtWidgets.QLabel(t("Title")), 2, 0) + self.grid.addWidget(self.title_mode, 2, 1) + self.grid.addWidget(self.custom_title, 2, 2, 1, 2) self.grid.setColumnStretch(3, 0) self.grid.setColumnStretch(4, 0) self.grid.setColumnStretch(5, 0) @@ -111,6 +128,11 @@ def __init__(self, parent_list, app, main, parent, index): self.convert_to.currentIndexChanged.connect(self.update_conversion) self.match_item.currentIndexChanged.connect(self.update_combos) self.match_type.currentIndexChanged.connect(self.update_combos) + self.match_type.currentIndexChanged.connect(self.update_title_mode_availability) + self.title_mode.currentIndexChanged.connect(self.update_custom_title_field) + + # Initial state: disable Custom Title option since default is Match All + self.update_title_mode_availability() def update_combos(self): self.match_input.hide() @@ -127,6 +149,35 @@ def update_conversion(self): self.bitrate.setEnabled(True) self.downmix.setEnabled(True) + def update_title_mode_availability(self): + """Add/remove the Custom Title option based on match type.""" + match_type = match_type_eng[self.match_type.currentIndex()] + custom_title_text = title_mode_locale[title_mode_enums.index(TitleMode.CUSTOM)] + has_custom_option = self.title_mode.findText(custom_title_text) != -1 + + if match_type == MatchType.ALL: + # Remove Custom Title option when Match All is selected + if has_custom_option: + custom_index = self.title_mode.findText(custom_title_text) + # If Custom Title was selected, switch to Original Title first + if self.title_mode.currentIndex() == custom_index: + self.title_mode.setCurrentIndex(0) + self.custom_title.setDisabled(True) + self.title_mode.removeItem(custom_index) + else: + # Add Custom Title option for First or Last + if not has_custom_option: + self.title_mode.addItem(custom_title_text) + + def update_custom_title_field(self): + """Enable/disable the custom title text field based on title mode selection.""" + current_text = self.title_mode.currentText() + custom_title_text = title_mode_locale[title_mode_enums.index(TitleMode.CUSTOM)] + if current_text == custom_title_text: + self.custom_title.setEnabled(True) + else: + self.custom_title.setDisabled(True) + def set_outdex(self, pos): pass @@ -152,6 +203,11 @@ def get_settings(self): if self.convert_to.currentIndex() > 0 and not self.bitrate.text().strip(): raise FastFlixError("No Bitrate") + # Get title mode by matching the current text to the locale list + current_title_mode_text = self.title_mode.currentText() + title_mode_index = title_mode_locale.index(current_title_mode_text) + selected_title_mode = title_mode_enums[title_mode_index] + return AudioMatch( match_type=match_type_eng[self.match_type.currentIndex()], match_item=match_item_enum, @@ -159,6 +215,8 @@ def get_settings(self): conversion=self.convert_to.currentText() if self.convert_to.currentIndex() > 0 else None, bitrate=self.bitrate.text(), downmix=self.downmix.currentText() if self.downmix.currentIndex() > 0 else None, + title_mode=selected_title_mode, + custom_title=self.custom_title.text() if selected_title_mode == TitleMode.CUSTOM else None, ) @@ -227,13 +285,13 @@ def update_settings(self): def add_track(self): new_track = AudioProfile(self, self.app, self.main, self.inner_widget, len(self.tracks)) self.tracks.append(new_track) - self.reorder(height=126) + self.reorder(height=156) def remove_track(self, index): self.tracks.pop(index).close() for i, track in enumerate(self.tracks): track.index = i - self.reorder(height=126) + self.reorder(height=156) def set_audio_mode(self, button): if button.text() == self.passthrough_name: From ecd37f049114f8f280f7224750fc7a2f2e6f3798 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 2 Feb 2026 21:38:21 -0500 Subject: [PATCH 10/25] * Adding Start/End Time tab to right-side options panel between Size and Crop tabs with compact 3-column layout * Fixing video track selector showing unnecessarily when source video has only one video track * Fixing visual border between filename area and video track selector * Fixing test suite hanging due to missing QApplication in PySide6 widget tests --- CHANGES | 5 + fastflix/data/languages.yaml | 24 + fastflix/encoders/common/setting_panel.py | 15 +- fastflix/ui_constants.py | 81 +++ fastflix/ui_scale.py | 100 ++++ fastflix/ui_styles.py | 56 ++ fastflix/widgets/container.py | 58 +- fastflix/widgets/logs.py | 2 +- fastflix/widgets/main.py | 623 ++++++++++++---------- fastflix/widgets/panels/abstract_list.py | 6 +- fastflix/widgets/panels/audio_panel.py | 22 +- fastflix/widgets/panels/command_panel.py | 4 +- fastflix/widgets/panels/queue_panel.py | 4 +- fastflix/widgets/panels/status_panel.py | 7 +- fastflix/widgets/panels/subtitle_panel.py | 2 +- fastflix/widgets/progress_bar.py | 5 +- fastflix/widgets/video_options.py | 5 +- pyproject.toml | 2 +- tests/test_pyside6_fixes.py | 147 +++++ tests/test_ui_scaling.py | 237 ++++++++ uv.lock | 50 +- 21 files changed, 1097 insertions(+), 358 deletions(-) create mode 100644 fastflix/ui_constants.py create mode 100644 fastflix/ui_scale.py create mode 100644 fastflix/ui_styles.py create mode 100644 tests/test_pyside6_fixes.py create mode 100644 tests/test_ui_scaling.py diff --git a/CHANGES b/CHANGES index 174ee607..2036681f 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,10 @@ ## Version 5.13.0 * Adding #712 audio profile title options: No Title, Generate Title, and Custom Title +* Adding Start/End Time tab to right-side options panel between Size and Crop tabs with compact 3-column layout +* Fixing video track selector showing unnecessarily when source video has only one video track +* Fixing visual border between filename area and video track selector +* Fixing test suite hanging due to missing QApplication in PySide6 widget tests * Adding async queue saving to prevent GUI blocking during queue operations * Adding atomic file writes for queue to prevent corruption from interrupted saves * Adding file-based locking for queue operations to prevent race conditions between instances @@ -20,6 +24,7 @@ * Fixing IndexError when applying profile audio filters to videos with non-sequential track indices * Fixing TypeError crash with Rigaya encoders when audio quality not explicitly set * Fixing AttributeError crash when audio track metadata is incomplete +* Fixing queue file generation mismatch errors due to redundant save calls on startup and when adding to queue ## Version 5.12.4 diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index cbcb7870..8a7c3780 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -11377,3 +11377,27 @@ Custom Title: ukr: Користувацька назва kor: 사용자 지정 제목 ron: Titlu personalizat +Flip: + eng: Flip +V Flip: + eng: V Flip +H Flip: + eng: H Flip +V+H Flip: + eng: V+H Flip +Size: + eng: Size +Reset: + eng: Reset +Reset start and end times: + eng: Reset start and end times +Fast: + eng: Fast +Exact: + eng: Exact +Start/End Time: + eng: Start/End Time +Reset crop: + eng: Reset crop +Options: + eng: Options diff --git a/fastflix/encoders/common/setting_panel.py b/fastflix/encoders/common/setting_panel.py index 898cdbfd..6bc86453 100644 --- a/fastflix/encoders/common/setting_panel.py +++ b/fastflix/encoders/common/setting_panel.py @@ -9,6 +9,7 @@ from fastflix.exceptions import FastFlixInternalException from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp +from fastflix.ui_scale import scaler from fastflix.widgets.background_tasks import ExtractHDR10 from fastflix.resources import group_box_style, get_icon @@ -143,9 +144,9 @@ def _add_combo_box( self.widgets[widget_name] = QtWidgets.QComboBox() self.widgets[widget_name].addItems(options) if min_width: - self.widgets[widget_name].setMinimumWidth(min_width) + self.widgets[widget_name].setMinimumWidth(scaler.scale(min_width)) if width: - self.widgets[widget_name].setFixedWidth(width) + self.widgets[widget_name].setFixedWidth(scaler.scale(width)) if opt: default = self.determine_default( @@ -227,7 +228,7 @@ def _add_text_box( self.widgets[widget_name].setValidator(self.only_int) if width: - self.widgets[widget_name].setFixedWidth(width) + self.widgets[widget_name].setFixedWidth(scaler.scale(width)) layout.addWidget(self.labels[widget_name]) layout.addWidget(self.widgets[widget_name]) @@ -369,7 +370,7 @@ def _add_modes( config_opt = None if not disable_bitrate: self.bitrate_radio = QtWidgets.QRadioButton("Bitrate") - self.bitrate_radio.setFixedWidth(80) + self.bitrate_radio.setFixedWidth(scaler.scale(67)) self.widgets.mode.addButton(self.bitrate_radio) self.widgets.bitrate = QtWidgets.QComboBox() self.widgets.bitrate.addItems(recommended_bitrates) @@ -389,7 +390,7 @@ def _add_modes( self.widgets.bitrate.setCurrentIndex(default_bitrate_index) self.widgets.custom_bitrate = QtWidgets.QLineEdit("3000" if not custom_bitrate else config_opt) self.widgets.custom_bitrate.setValidator(QtGui.QDoubleValidator()) - self.widgets.custom_bitrate.setFixedWidth(100) + self.widgets.custom_bitrate.setMinimumWidth(scaler.scale(83)) self.widgets.custom_bitrate.setEnabled(custom_bitrate) self.widgets.custom_bitrate.textChanged.connect(lambda: self.main.build_commands()) self.widgets.custom_bitrate.setValidator(self.only_int) @@ -409,7 +410,7 @@ def _add_modes( self.qp_radio = QtWidgets.QRadioButton(qp_display_name) self.qp_radio.setChecked(True) - self.qp_radio.setFixedWidth(80) + self.qp_radio.setFixedWidth(scaler.scale(67)) self.qp_radio.setToolTip(qp_help) self.widgets.mode.addButton(self.qp_radio) @@ -430,7 +431,7 @@ def _add_modes( if not disable_custom_qp: self.widgets[f"custom_{qp_name}"] = QtWidgets.QLineEdit("30" if not custom_qp else str(qp_value)) - self.widgets[f"custom_{qp_name}"].setFixedWidth(100) + self.widgets[f"custom_{qp_name}"].setMinimumWidth(scaler.scale(83)) self.widgets[f"custom_{qp_name}"].setValidator(QtGui.QDoubleValidator()) self.widgets[f"custom_{qp_name}"].setEnabled(custom_qp) self.widgets[f"custom_{qp_name}"].textChanged.connect(lambda: self.main.build_commands()) diff --git a/fastflix/ui_constants.py b/fastflix/ui_constants.py new file mode 100644 index 00000000..c8cf5377 --- /dev/null +++ b/fastflix/ui_constants.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +UI Constants for FastFlix. + +Defines base dimensions for UI elements at the reference resolution of 1200x680. +These values are used with the UIScaler to compute actual pixel sizes at runtime. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class BaseWidths: + """Base width values (~25% smaller than original for better default scaling).""" + + MENUBAR: int = 270 + PROFILE_BOX: int = 190 + ENCODER_MIN: int = 165 + CROP_BOX_MIN: int = 280 + SOURCE_LABEL: int = 65 + RESOLUTION_CUSTOM: int = 115 + FLIP_DROPDOWN: int = 120 + ROTATE_DROPDOWN: int = 130 + PREVIEW_MIN: int = 330 + OUTPUT_TYPE: int = 60 + VIDEO_TRACK_LABEL: int = 75 + ENCODER_LABEL: int = 50 + RESOLUTION_LABEL: int = 70 + FAST_TIME: int = 50 + AUTO_CROP: int = 40 + RESET_BUTTON: int = 12 + SMALL_BUTTON: int = 15 + AUDIO_TITLE: int = 115 + AUDIO_INFO: int = 265 + SPACER_SMALL: int = 3 + CUSTOM_INPUT: int = 75 + + +@dataclass(frozen=True, slots=True) +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 + PANEL_ITEM: int = 45 + SCROLL_MIN: int = 150 + PREVIEW_MIN: int = 195 + OUTPUT_DIR: int = 18 + HEADER: int = 23 + SPACER_TINY: int = 2 + SPACER_SMALL: int = 4 + BUTTON_SIZE: int = 22 + + +@dataclass(frozen=True, slots=True) +class BaseIconSizes: + """Base icon sizes (square) - ~25% smaller than original.""" + + TINY: int = 8 + SMALL: int = 12 + MEDIUM: int = 17 + LARGE: int = 20 + XLARGE: int = 26 + + +@dataclass(frozen=True, slots=True) +class BaseFontSizes: + """Base font sizes.""" + + SMALL: int = 9 + NORMAL: int = 10 + MEDIUM: int = 11 + LARGE: int = 12 + XLARGE: int = 14 + + +WIDTHS = BaseWidths() +HEIGHTS = BaseHeights() +ICONS = BaseIconSizes() +FONTS = BaseFontSizes() diff --git a/fastflix/ui_scale.py b/fastflix/ui_scale.py new file mode 100644 index 00000000..4e222b45 --- /dev/null +++ b/fastflix/ui_scale.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +UI Scaling module for FastFlix. + +Provides a singleton UIScaler that manages scale factors for the entire application. +Scale factors are computed based on the current window size relative to the base +reference size of 1200x680. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Callable + +from PySide6 import QtCore + +BASE_WIDTH = 1200 +BASE_HEIGHT = 680 + + +@dataclass +class ScaleFactors: + """Scale factors for UI elements - immutable, use copy.replace() to modify.""" + + width: float = 1.0 + height: float = 1.0 + uniform: float = 1.0 + font: float = 1.0 + icon: float = 1.0 + + +class UIScaler: + """Singleton for managing UI scaling throughout the application.""" + + _instance: UIScaler | None = None + + def __new__(cls) -> UIScaler: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self) -> None: + if self._initialized: + return + self._initialized = True + self.factors = ScaleFactors() + self._listeners: list[Callable[[ScaleFactors], None]] = [] + + def calculate_factors(self, width: int, height: int) -> None: + """Calculate and update scale factors based on current window dimensions.""" + width_factor = width / BASE_WIDTH + height_factor = height / BASE_HEIGHT + uniform = min(width_factor, height_factor) + + # Use Python 3.13 copy.replace() for immutable update + self.factors = copy.replace( + self.factors, + width=width_factor, + height=height_factor, + uniform=uniform, + font=uniform, + icon=uniform, + ) + self._notify_listeners() + + def scale(self, base_value: int) -> int: + """Scale a base value by the uniform scale factor.""" + return max(1, int(base_value * self.factors.uniform)) + + def scale_font(self, base_size: int) -> int: + """Scale a font size, with minimum of 8px for readability.""" + return max(8, int(base_size * self.factors.font)) + + def scale_icon(self, base_size: int) -> int: + """Scale an icon size, with minimum of 10px for visibility.""" + return max(10, int(base_size * self.factors.icon)) + + def scale_size(self, width: int, height: int) -> QtCore.QSize: + """Scale a width/height pair and return as QSize.""" + return QtCore.QSize(self.scale(width), self.scale(height)) + + def add_listener(self, callback: Callable[[ScaleFactors], None]) -> None: + """Register a callback to be notified when scale factors change.""" + self._listeners.append(callback) + + def remove_listener(self, callback: Callable[[ScaleFactors], None]) -> None: + """Unregister a previously registered callback.""" + if callback in self._listeners: + self._listeners.remove(callback) + + def _notify_listeners(self) -> None: + """Notify all registered listeners of scale factor changes.""" + for callback in self._listeners: + callback(self.factors) + + +# Global singleton instance +scaler = UIScaler() diff --git a/fastflix/ui_styles.py b/fastflix/ui_styles.py new file mode 100644 index 00000000..efbf0fd6 --- /dev/null +++ b/fastflix/ui_styles.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +""" +UI Styles module for FastFlix. + +Provides scaled stylesheets that adapt to the current UI scale factors. +""" + +from fastflix.ui_scale import scaler +from fastflix.ui_constants import FONTS + + +def get_scaled_stylesheet(theme: str) -> str: + """Generate a scaled stylesheet based on the current theme and scale factors.""" + font_size = scaler.scale_font(FONTS.LARGE) + border_radius = scaler.scale(10) + + base = f"QWidget {{ font-size: {font_size}px; }}" + + if theme == "onyx": + base += f""" + QAbstractItemView {{ background-color: #4b5054; }} + QComboBox QAbstractItemView {{ background-color: #1d2023; border: 2px solid #76797c; }} + QPushButton {{ border-radius: {border_radius}px; }} + QLineEdit {{ + background-color: #707070; + color: black; + border-radius: {border_radius}px; + }} + QTextEdit {{ background-color: #707070; color: black; }} + QTabBar::tab {{ background-color: #4b5054; }} + QComboBox {{ border-radius: {border_radius}px; }} + QScrollArea {{ border: 1px solid #919191; }} + """ + + return base + + +def get_video_options_stylesheet(theme: str) -> str: + """Generate scaled stylesheet for the video options tab widget.""" + tab_font_size = scaler.scale_font(FONTS.MEDIUM) + combo_min_height = scaler.scale(22) + + if theme == "onyx": + return f""" + * {{ background-color: #4b5054; color: white; }} + QTabWidget {{ margin-top: {scaler.scale(34)}px; background-color: #4b5054; }} + QTabBar {{ font-size: {tab_font_size}px; background-color: #4f5962; }} + QComboBox {{ min-height: {combo_min_height}px; }} + """ + return "" + + +def get_menubar_stylesheet() -> str: + """Generate scaled stylesheet for the menu bar.""" + font_size = scaler.scale_font(FONTS.LARGE) + return f"font-size: {font_size}px" diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 82e4545e..dfb82b03 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -28,6 +28,8 @@ parse_filesafe_datetime, is_date_older_than_7days, ) +from fastflix.ui_scale import scaler +from fastflix.ui_styles import get_scaled_stylesheet, get_menubar_stylesheet from fastflix.widgets.about import About from fastflix.widgets.changes import Changes @@ -45,12 +47,21 @@ class Container(QtWidgets.QMainWindow): + MIN_WIDTH = 900 + MIN_HEIGHT = 500 + BASE_WIDTH = 1200 + BASE_HEIGHT = 680 + 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) + # Initialize scaler with base size + scaler.calculate_factors(self.BASE_WIDTH, self.BASE_HEIGHT) + self.app.setApplicationName("FastFlix") self.app.setWindowIcon(QtGui.QIcon(main_icon)) @@ -84,35 +95,22 @@ def __init__(self, app: FastFlixApp, **kwargs): self.main = Main(self, app) self.setCentralWidget(self.main) - self.setBaseSize(QtCore.QSize(1350, 750)) + 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) self.icon = QtGui.QIcon(main_icon) self.setWindowIcon(self.icon) self._constrain_to_screen() self.main.set_profile() - if self.app.fastflix.config.theme == "onyx": - self.setStyleSheet( - """ - QAbstractItemView{ background-color: #4b5054; } - QComboBox QAbstractItemView{ background-color: #1d2023; border: 2px solid #76797c; } - QPushButton{ border-radius:10px; } - QLineEdit{ background-color: #707070; color: black; border-radius: 10px; } - QTextEdit{ background-color: #707070; color: black; } - QTabBar::tab{ background-color: #4b5054; } - QComboBox{ border-radius:10px; } - QScrollArea{ border: 1px solid #919191; } - QWidget{font-size: 14px;} - """ - ) - else: - self.setStyleSheet( - """ - QWidget{font-size: 14px;} - """ - ) + self._update_scaled_styles() # self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) self.moveFlag = False + 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() @@ -155,8 +153,12 @@ def ensure_window_in_bounds(self): self.setGeometry(new_x, new_y, new_width, new_height) def resizeEvent(self, event: QtGui.QResizeEvent) -> None: - """Handle resize events to ensure window stays within screen bounds.""" + """Handle resize events to ensure window stays within screen bounds and update scaling.""" super().resizeEvent(event) + # Update scale factors based on new size + scaler.calculate_factors(event.size().width(), event.size().height()) + self._update_scaled_styles() + screen = QtGui.QGuiApplication.primaryScreen() if screen is None: return @@ -220,7 +222,7 @@ def closeEvent(self, a0: QtGui.QCloseEvent) -> None: sm.addButton(t("Cancel Conversion"), QtWidgets.QMessageBox.RejectRole) sm.addButton(t("Close GUI Only"), QtWidgets.QMessageBox.DestructiveRole) sm.addButton(t("Keep FastFlix Open"), QtWidgets.QMessageBox.AcceptRole) - sm.exec_() + sm.exec() if sm.clickedButton().text() == "Cancel Conversion": self.app.fastflix.worker_queue.put(["cancel"]) time.sleep(0.5) @@ -258,8 +260,8 @@ def si(self, widget): def init_menu(self): menubar = self.menuBar() menubar.setNativeMenuBar(False) - menubar.setFixedWidth(360) - menubar.setStyleSheet("font-size: 14px") + menubar.setMinimumWidth(scaler.scale(300)) + menubar.setStyleSheet(get_menubar_stylesheet()) file_menu = menubar.addMenu(t("File")) @@ -517,12 +519,6 @@ def __init__(self, parent, path): self.app = parent self.path = str(path) - def __del__(self): - try: - self.wait() - except BaseException: - pass - def run(self): try: if reusables.win_based: diff --git a/fastflix/widgets/logs.py b/fastflix/widgets/logs.py index 1016aa84..a005155e 100644 --- a/fastflix/widgets/logs.py +++ b/fastflix/widgets/logs.py @@ -41,4 +41,4 @@ def __init__(self, parent=None): def closeEvent(self, event): self.hide() - # event.accept() + event.ignore() diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index e428fe86..17577f5c 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -22,6 +22,8 @@ from fastflix.encoders.common import helpers from fastflix.exceptions import FastFlixInternalException, FlixError +from fastflix.ui_scale import scaler +from fastflix.ui_constants import WIDTHS, HEIGHTS, ICONS from fastflix.flix import ( detect_hdr10_plus, detect_interlaced, @@ -38,7 +40,6 @@ from fastflix.resources import ( get_icon, group_box_style, - reset_button_style, onyx_convert_icon, onyx_queue_add_icon, get_text_color, @@ -120,6 +121,7 @@ class MainWidgets(BaseModel): start_time: QtWidgets.QLineEdit = None end_time: QtWidgets.QLineEdit = None video_track: QtWidgets.QComboBox = None + video_track_widget: QtWidgets.QWidget = None rotate: QtWidgets.QComboBox = None flip: QtWidgets.QComboBox = None crop: CropWidgets = Field(default_factory=CropWidgets) @@ -224,7 +226,7 @@ def __init__(self, parent, app: FastFlixApp): ] ) self.source_video_path_widget = QtWidgets.QLineEdit(motto) - self.source_video_path_widget.setFixedHeight(20) + 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( @@ -233,7 +235,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(20) + 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("padding: 0 0 -1px 5px") self.output_video_path_widget.setMaxLength(220) @@ -268,6 +270,16 @@ 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) + 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) + # row: int, column: int, rowSpan: int, columnSpan: int self.grid.addLayout(self.init_top_bar(), 0, 0, 1, 6) @@ -279,12 +291,12 @@ def __init__(self, parent, app: FastFlixApp): # pi.addWidget(self.init_preview_image()) # pi.addLayout(self.()) - self.grid.addWidget(self.init_preview_image(), 0, 6, 6, 5, (QtCore.Qt.AlignTop | QtCore.Qt.AlignCenter)) + self.grid.addWidget(self.init_preview_image(), 0, 6, 6, 5) self.grid.addLayout(self.init_thumb_time_selector(), 6, 6, 1, 5, (QtCore.Qt.AlignTop | QtCore.Qt.AlignCenter)) # self.grid.addLayout(pi, 0, 6, 7, 5) spacer = QtWidgets.QLabel() - spacer.setFixedHeight(5) + spacer.setFixedHeight(scaler.scale(HEIGHTS.SPACER_SMALL)) self.grid.addWidget(spacer, 8, 0, 1, 14) self.grid.addWidget(self.video_options, 9, 0, 10, 14) @@ -322,19 +334,21 @@ def init_top_bar(self): top_bar = QtWidgets.QHBoxLayout() source = QtWidgets.QPushButton(QtGui.QIcon(self.get_icon("onyx-source")), f" {t('Source')}") - source.setIconSize(QtCore.QSize(22, 22)) - source.setFixedHeight(50) + source.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) + source.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) source.setDefault(True) source.clicked.connect(lambda: self.open_file()) self.widgets.profile_box = QtWidgets.QComboBox() 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() + 50) + 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(250) - self.widgets.profile_box.setFixedHeight(50) + self.widgets.profile_box.setFixedWidth(scaler.scale(WIDTHS.PROFILE_BOX)) + self.widgets.profile_box.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) top_bar.addWidget(source) top_bar.addWidget(QtWidgets.QSplitter(QtCore.Qt.Horizontal)) @@ -344,9 +358,8 @@ 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")), "") - # add_profile.setFixedSize(QtCore.QSize(40, 40)) - self.add_profile.setFixedHeight(50) - self.add_profile.setIconSize(QtCore.QSize(20, 20)) + self.add_profile.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) + self.add_profile.setIconSize(scaler.scale_size(ICONS.SMALL + 4, ICONS.SMALL + 4)) self.add_profile.setToolTip(t("New Profile")) # add_profile.setLayoutDirection(QtCore.Qt.RightToLeft) self.add_profile.clicked.connect(lambda: self.container.new_profile()) @@ -378,15 +391,15 @@ def init_top_bar_right(self): background-color: #6b6b6b; }""" queue = QtWidgets.QPushButton(QtGui.QIcon(onyx_queue_add_icon), f"{t('Add to Queue')} ") - queue.setIconSize(QtCore.QSize(26, 26)) - queue.setFixedHeight(50) + queue.setIconSize(scaler.scale_size(ICONS.LARGE, ICONS.LARGE)) + queue.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) queue.setStyleSheet(theme) queue.setLayoutDirection(QtCore.Qt.RightToLeft) queue.clicked.connect(lambda: self.add_to_queue()) self.widgets.convert_button = QtWidgets.QPushButton(QtGui.QIcon(onyx_convert_icon), f"{t('Convert')} ") - self.widgets.convert_button.setIconSize(QtCore.QSize(26, 26)) - self.widgets.convert_button.setFixedHeight(50) + 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.setStyleSheet(theme) self.widgets.convert_button.setLayoutDirection(QtCore.Qt.RightToLeft) self.widgets.convert_button.clicked.connect(lambda: self.encode_video()) @@ -412,7 +425,7 @@ def init_thumb_time_selector(self): self.widgets.thumb_time.sliderReleased.connect(self.thumb_time_change) spacer = QtWidgets.QLabel() - spacer.setFixedWidth(4) + spacer.setFixedWidth(scaler.scale(WIDTHS.SPACER_SMALL)) layout.addWidget(spacer) layout.addWidget(self.widgets.thumb_key) layout.addWidget(spacer) @@ -452,118 +465,290 @@ def config_update(self): def init_video_area(self): layout = QtWidgets.QVBoxLayout() spacer = QtWidgets.QLabel() - spacer.setFixedHeight(2) + spacer.setFixedHeight(scaler.scale(2)) layout.addWidget(spacer) + # Group box for Source/Folder/Filename + file_group = QtWidgets.QGroupBox() + file_group.setStyleSheet(group_box_style(bb="none")) + file_group_layout = QtWidgets.QVBoxLayout(file_group) + file_group_layout.setContentsMargins(0, 0, 0, scaler.scale(5)) + file_group_layout.setSpacing(scaler.scale(12)) + source_layout = QtWidgets.QHBoxLayout() source_label = QtWidgets.QLabel(t("Source")) - source_label.setFixedWidth(85) - self.source_video_path_widget.setFixedHeight(23) + source_label.setFixedWidth(scaler.scale(WIDTHS.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) output_layout = QtWidgets.QHBoxLayout() output_label = QtWidgets.QLabel(t("Filename")) - output_label.setFixedWidth(85) - self.output_video_path_widget.setFixedHeight(23) + output_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_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) - self.widgets.output_type_combo.setFixedWidth(80) + self.widgets.output_type_combo.setFixedWidth(scaler.scale(WIDTHS.OUTPUT_TYPE)) self.widgets.output_type_combo.addItems(self.current_encoder.video_extensions) - self.widgets.output_type_combo.setFixedHeight(23) + self.widgets.output_type_combo.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) self.widgets.output_type_combo.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) output_layout.addWidget(self.widgets.output_type_combo) - layout.addLayout(source_layout) out_dir_layout = QtWidgets.QHBoxLayout() out_dir_label = QtWidgets.QLabel(t("Folder")) - out_dir_label.setFixedHeight(23) - out_dir_label.setFixedWidth(85) + out_dir_label.setFixedHeight(scaler.scale(HEIGHTS.COMBO_BOX)) + out_dir_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) self.widgets.output_directory = QtWidgets.QPushButton() - self.widgets.output_directory.setFixedHeight(19) + self.widgets.output_directory.setFixedHeight(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"))) self.output_path_button.clicked.connect(lambda: self.save_file()) self.output_path_button.setDisabled(True) - self.output_path_button.setFixedHeight(23) - # self.output_path_button.setFixedHeight(12) - self.output_path_button.setIconSize(QtCore.QSize(16, 16)) - self.output_path_button.setFixedSize(QtCore.QSize(16, 16)) + self.output_path_button.setIconSize(scaler.scale_size(ICONS.SMALL + 3, ICONS.SMALL + 3)) + self.output_path_button.setFixedSize(scaler.scale_size(ICONS.SMALL + 3, ICONS.SMALL + 3)) self.output_path_button.setStyleSheet("border: none; padding: 0; margin: 0") out_dir_layout.addWidget(out_dir_label) out_dir_layout.addWidget(self.widgets.output_directory, alignment=QtCore.Qt.AlignTop) out_dir_layout.addWidget(self.output_path_button) - layout.addLayout(out_dir_layout) - layout.addLayout(output_layout) - - # title_layout = QtWidgets.QHBoxLayout() - # - # title_label = QtWidgets.QLabel(t("Title")) - # title_label.setFixedWidth(85) - # title_label.setToolTip(t('Set the "title" tag, sometimes shown as "Movie Name"')) - # self.widgets.video_title = QtWidgets.QLineEdit() - # self.widgets.video_title.setFixedHeight(23) - # self.widgets.video_title.setToolTip(t('Set the "title" tag, sometimes shown as "Movie Name"')) - # self.widgets.video_title.textChanged.connect(lambda: self.page_update(build_thumbnail=False)) - # - # title_layout.addWidget(title_label) - # title_layout.addWidget(self.widgets.video_title) - # - # layout.addLayout(title_layout) - layout.addLayout(self.init_video_track_select()) - layout.addWidget(self.init_start_time()) - layout.addWidget(self.init_scale()) + + file_group_layout.addLayout(source_layout) + file_group_layout.addLayout(out_dir_layout) + file_group_layout.addLayout(output_layout) + + layout.addWidget(file_group) + + layout.addWidget(self.init_video_track_select()) layout.addStretch(1) return layout def init_right_col(self): layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.init_crop()) - layout.addWidget(self.init_transforms()) - - layout.addLayout(self.init_checkboxes()) + # Add padding above the tabs + layout.addSpacing(scaler.scale(8)) + layout.addWidget(self.init_options_tabs()) layout.addStretch(1) - # custom_options = QtWidgets.QTextEdit() - # custom_options.setPlaceholderText(t("Custom Encoder Options")) - # custom_options.setMaximumHeight(90) - # layout.addWidget(custom_options) return layout - def init_transforms(self): - group_box = QtWidgets.QGroupBox() - group_box.setStyleSheet(group_box_style(pt="0", mt="0")) - transform_layout = QtWidgets.QHBoxLayout() - transform_layout.addWidget(self.init_rotate()) - transform_layout.addStretch(1) - transform_layout.addWidget(self.init_flip()) - group_box.setLayout(transform_layout) - return group_box - - def init_checkboxes(self): - transform_layout = QtWidgets.QHBoxLayout() - metadata_layout = QtWidgets.QVBoxLayout() + def init_options_tabs(self): + """Create a tabbed widget with Size, Start/End Time, Crop, and Options tabs.""" + tabs = QtWidgets.QTabWidget() + tabs.setIconSize(QtCore.QSize(scaler.scale(20), scaler.scale(20))) + + # Tab 1: Size (Resolution + Transforms) + size_tab = QtWidgets.QWidget() + size_layout = QtWidgets.QVBoxLayout(size_tab) + size_layout.setSpacing(scaler.scale(8)) + size_layout.setContentsMargins(scaler.scale(8), scaler.scale(8), scaler.scale(8), scaler.scale(8)) + + # Resolution row + res_row = QtWidgets.QHBoxLayout() + res_row.setSpacing(scaler.scale(4)) + res_label = QtWidgets.QLabel(t("Resolution")) + res_label.setFixedWidth(scaler.scale(68)) + res_row.addWidget(res_label) + + self.widgets.resolution_drop_down = QtWidgets.QComboBox() + self.widgets.resolution_drop_down.addItems(list(resolutions.keys())) + self.widgets.resolution_drop_down.currentIndexChanged.connect(self.update_resolution) + res_row.addWidget(self.widgets.resolution_drop_down) + + self.widgets.resolution_custom = QtWidgets.QLineEdit() + self.widgets.resolution_custom.setFixedWidth(scaler.scale(WIDTHS.RESOLUTION_CUSTOM)) + self.widgets.resolution_custom.textChanged.connect(self.custom_res_update) + res_row.addWidget(self.widgets.resolution_custom) + + size_layout.addLayout(res_row) + + # Transform row (rotate + flip) + transform_row = QtWidgets.QHBoxLayout() + transform_row.setSpacing(scaler.scale(4)) + + rot_label = QtWidgets.QLabel(t("Rotate")) + rot_label.setFixedWidth(scaler.scale(68)) + transform_row.addWidget(rot_label) + transform_row.addWidget(self.init_rotate()) + + flip_label = QtWidgets.QLabel(t("Flip")) + flip_label.setFixedWidth(scaler.scale(30)) + transform_row.addWidget(flip_label) + transform_row.addWidget(self.init_flip()) + transform_row.addStretch(1) + + size_layout.addLayout(transform_row) + size_layout.addStretch(1) + + tabs.addTab(size_tab, t("Size")) + + # Tab 2: Start/End Time (compact 2-column layout) + time_tab = QtWidgets.QWidget() + time_layout = QtWidgets.QHBoxLayout(time_tab) + time_layout.setSpacing(scaler.scale(12)) + time_layout.setContentsMargins(scaler.scale(8), scaler.scale(8), scaler.scale(8), scaler.scale(8)) + + # Column 1: Reset button and Seek mode + time_col1 = QtWidgets.QVBoxLayout() + time_col1.setSpacing(scaler.scale(4)) + + time_reset = QtWidgets.QPushButton(t("Reset")) + time_reset.setFixedHeight(scaler.scale(22)) + time_reset.setToolTip(t("Reset start and end times")) + time_reset.clicked.connect(self.reset_time) + self.buttons.append(time_reset) + + 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.setToolTip( + t( + "uses [fast] seek to a rough position ahead of timestamp, " + "vs a specific [exact] frame lookup. (GIF encodings use [fast])" + ) + ) + self.widgets.fast_time.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) + + time_col1.addWidget(time_reset) + time_col1.addWidget(self.widgets.fast_time) + time_col1.addStretch(1) + + # Column 2: Start and End times stacked vertically + time_col2 = QtWidgets.QVBoxLayout() + time_col2.setSpacing(scaler.scale(4)) + + self.widgets.start_time, start_row = self.build_hoz_int_field( + t("Start"), + right_stretch=False, + left_stretch=False, + time_field=True, + ) + self.widgets.start_time.textChanged.connect(lambda: self.page_update()) + + self.widgets.end_time, end_row = self.build_hoz_int_field( + t("End"), + left_stretch=False, + right_stretch=False, + 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) + + time_layout.addLayout(time_col1) + time_layout.addLayout(time_col2) + time_layout.addStretch(1) + + tabs.addTab(time_tab, t("Start/End Time")) + + # Tab 3: Crop (3-column layout) + crop_tab = QtWidgets.QWidget() + crop_layout = QtWidgets.QHBoxLayout(crop_tab) + crop_layout.setSpacing(scaler.scale(12)) + crop_layout.setContentsMargins(scaler.scale(8), scaler.scale(8), scaler.scale(8), scaler.scale(8)) + + # Column 1: Auto and Reset buttons + col1 = QtWidgets.QVBoxLayout() + col1.setSpacing(scaler.scale(4)) + auto_crop = QtWidgets.QPushButton(t("Auto")) + auto_crop.setFixedHeight(scaler.scale(22)) + auto_crop.setToolTip(t("Automatically detect black borders")) + auto_crop.clicked.connect(self.get_auto_crop) + self.buttons.append(auto_crop) + reset = QtWidgets.QPushButton(t("Reset")) + reset.setFixedHeight(scaler.scale(22)) + reset.setToolTip(t("Reset crop")) + reset.clicked.connect(self.reset_crop) + self.buttons.append(reset) + col1.addWidget(auto_crop) + col1.addWidget(reset) + col1.addStretch(1) + + # Crop input fields + field_width = scaler.scale(50) + field_height = scaler.scale(22) + + 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.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.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.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.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)) + top_row = QtWidgets.QHBoxLayout() + top_row.addWidget(QtWidgets.QLabel(t("Top"))) + top_row.addWidget(self.widgets.crop.top) + bottom_row = QtWidgets.QHBoxLayout() + bottom_row.addWidget(QtWidgets.QLabel(t("Bottom"))) + bottom_row.addWidget(self.widgets.crop.bottom) + col2.addLayout(top_row) + col2.addLayout(bottom_row) + col2.addStretch(1) + + # Column 3: Left and Right + col3 = QtWidgets.QVBoxLayout() + col3.setSpacing(scaler.scale(4)) + left_row = QtWidgets.QHBoxLayout() + left_row.addWidget(QtWidgets.QLabel(t("Left"))) + left_row.addWidget(self.widgets.crop.left) + right_row = QtWidgets.QHBoxLayout() + right_row.addWidget(QtWidgets.QLabel(t("Right"))) + right_row.addWidget(self.widgets.crop.right) + col3.addLayout(left_row) + col3.addLayout(right_row) + col3.addStretch(1) + + crop_layout.addLayout(col1) + crop_layout.addLayout(col2) + crop_layout.addLayout(col3) + crop_layout.addStretch(1) + + tabs.addTab(crop_tab, t("Crop")) + + # Tab 4: Options (checkboxes) + opts_tab = QtWidgets.QWidget() + opts_layout = QtWidgets.QVBoxLayout(opts_tab) + opts_layout.setSpacing(scaler.scale(4)) + opts_layout.setContentsMargins(scaler.scale(8), scaler.scale(8), scaler.scale(8), scaler.scale(8)) + self.widgets.remove_metadata = QtWidgets.QCheckBox(t("Remove Metadata")) self.widgets.remove_metadata.setChecked(True) self.widgets.remove_metadata.toggled.connect(self.page_update) self.widgets.remove_metadata.setToolTip( t("Scrub away all incoming metadata, like video titles, unique markings and so on.") ) + self.widgets.chapters = QtWidgets.QCheckBox(t("Copy Chapters")) self.widgets.chapters.setChecked(True) self.widgets.chapters.toggled.connect(self.page_update) self.widgets.chapters.setToolTip(t("Copy the chapter markers as is from incoming source.")) - metadata_layout.addWidget(self.widgets.remove_metadata) - metadata_layout.addWidget(self.widgets.chapters) - - transform_layout.addLayout(metadata_layout) - self.widgets.deinterlace = QtWidgets.QCheckBox(t("Deinterlace")) self.widgets.deinterlace.setChecked(False) self.widgets.deinterlace.toggled.connect(self.interlace_update) @@ -579,42 +764,36 @@ def init_checkboxes(self): f"{t('WARNING: This will take much longer and result in a larger file')}" ) - extra_details_layout = QtWidgets.QVBoxLayout() - extra_details_layout.addWidget(self.widgets.deinterlace) - extra_details_layout.addWidget(self.widgets.remove_hdr) - transform_layout.addLayout(extra_details_layout) - - # another_layout = QtWidgets.QVBoxLayout() - # - # self.widgets.copy_data = QtWidgets.QCheckBox(t("Copy Data")) - # self.widgets.copy_data.setChecked(False) - # self.widgets.copy_data.toggled.connect(self.page_update) - # self.widgets.copy_data.setToolTip( - # f'{t("Copy all data streams from the source file.")}' - # ) - # - # another_layout.addWidget(self.widgets.copy_data) - # another_layout.addWidget(QtWidgets.QWidget()) - # transform_layout.addLayout(another_layout) - - return transform_layout + opts_layout.addWidget(self.widgets.remove_metadata) + opts_layout.addWidget(self.widgets.chapters) + opts_layout.addWidget(self.widgets.deinterlace) + opts_layout.addWidget(self.widgets.remove_hdr) + opts_layout.addStretch(1) + + tabs.addTab(opts_tab, t("Options")) + + return tabs def init_video_track_select(self): - layout = QtWidgets.QHBoxLayout() + self.widgets.video_track_widget = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(self.widgets.video_track_widget) + layout.setContentsMargins(0, 0, 0, 0) self.widgets.video_track = QtWidgets.QComboBox() self.widgets.video_track.addItems([]) - self.widgets.video_track.setFixedHeight(23) + self.widgets.video_track.setFixedHeight(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": - self.widgets.video_track.setStyleSheet("border-radius: 10px; color: white") + self.widgets.video_track.setStyleSheet(f"border-radius: {scaler.scale(10)}px; color: white") track_label = QtWidgets.QLabel(t("Video Track")) - track_label.setFixedWidth(80) + track_label.setFixedWidth(scaler.scale(WIDTHS.VIDEO_TRACK_LABEL)) layout.addWidget(track_label) layout.addWidget(self.widgets.video_track, stretch=1) layout.setSpacing(10) - return layout + # Hidden by default, shown only when there's more than one video track + self.widgets.video_track_widget.hide() + return self.widgets.video_track_widget def set_profile(self): if self.loading_video: @@ -704,14 +883,13 @@ def init_flip(self): with importlib.resources.as_file(ref) as f: rot_180_file = str(f.resolve()) - self.widgets.flip.addItems([t("No Flip"), t("Vertical Flip"), t("Horizontal Flip"), t("Vert + Hoz Flip")]) + self.widgets.flip.addItems([t("No Flip"), t("V Flip"), t("H Flip"), t("V+H Flip")]) self.widgets.flip.setItemIcon(0, QtGui.QIcon(no_rot_file)) self.widgets.flip.setItemIcon(1, QtGui.QIcon(vert_flip_file)) self.widgets.flip.setItemIcon(2, QtGui.QIcon(hoz_flip_file)) self.widgets.flip.setItemIcon(3, QtGui.QIcon(rot_180_file)) - self.widgets.flip.setIconSize(QtCore.QSize(35, 35)) + self.widgets.flip.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) self.widgets.flip.currentIndexChanged.connect(lambda: self.page_update()) - self.widgets.flip.setFixedWidth(160) return self.widgets.flip def get_flips(self) -> Tuple[bool, bool]: @@ -742,15 +920,13 @@ def init_rotate(self): with importlib.resources.as_file(ref) as f: rot_180_file = str(f.resolve()) - self.widgets.rotate.addItems([t("No Rotation") + " ", "90°", "180°", "270°"]) + self.widgets.rotate.addItems(["0°", "90°", "180°", "270°"]) self.widgets.rotate.setItemIcon(0, QtGui.QIcon(no_rot_file)) self.widgets.rotate.setItemIcon(1, QtGui.QIcon(rot_90_file)) self.widgets.rotate.setItemIcon(2, QtGui.QIcon(rot_180_file)) self.widgets.rotate.setItemIcon(3, QtGui.QIcon(rot_270_file)) - self.widgets.rotate.setIconSize(QtCore.QSize(35, 35)) + self.widgets.rotate.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) self.widgets.rotate.currentIndexChanged.connect(lambda: self.page_update()) - self.widgets.rotate.setFixedWidth(170) - return self.widgets.rotate def change_output_types(self): @@ -759,21 +935,22 @@ def change_output_types(self): for i, plugin in enumerate(self.app.fastflix.encoders.values()): if getattr(plugin, "icon", False): self.widgets.convert_to.setItemIcon(i, QtGui.QIcon(plugin.icon)) - self.widgets.convert_to.setIconSize( - QtCore.QSize(40, 40) if self.app.fastflix.config.flat_ui else QtCore.QSize(35, 35) - ) + 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)) def init_encoder_drop_down(self): layout = QtWidgets.QHBoxLayout() self.widgets.convert_to = QtWidgets.QComboBox() - self.widgets.convert_to.setMinimumWidth(180) - self.widgets.convert_to.setFixedHeight(50) + self.widgets.convert_to.setFixedWidth(scaler.scale(WIDTHS.ENCODER_MIN)) + self.widgets.convert_to.setFixedHeight(scaler.scale(HEIGHTS.TOP_BAR_BUTTON)) self.change_output_types() - self.widgets.convert_to.view().setFixedWidth(self.widgets.convert_to.minimumSizeHint().width() + 50) + 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(65) + encoder_label.setFixedWidth(scaler.scale(54)) layout.addWidget(self.widgets.convert_to, stretch=0) layout.setSpacing(10) @@ -800,85 +977,10 @@ def current_encoder(self): except (AttributeError, KeyError): return self.app.fastflix.encoders[self.convert_to] - def init_start_time(self): - group_box = QtWidgets.QGroupBox() - group_box.setStyleSheet(group_box_style()) - - layout = QtWidgets.QHBoxLayout() - - reset = QtWidgets.QPushButton(QtGui.QIcon(self.get_icon("undo")), "") - reset.setIconSize(QtCore.QSize(10, 10)) - reset.clicked.connect(self.reset_time) - reset.setFixedWidth(15) - reset.setStyleSheet(reset_button_style) - self.buttons.append(reset) - - self.widgets.start_time, start_layout = self.build_hoz_int_field( - f"{t('Start')} ", - right_stretch=False, - left_stretch=True, - time_field=True, - ) - self.widgets.end_time, end_layout = self.build_hoz_int_field( - f" {t('End')} ", left_stretch=True, right_stretch=True, time_field=True - ) - - self.widgets.start_time.textChanged.connect(lambda: self.page_update()) - self.widgets.end_time.textChanged.connect(lambda: self.page_update()) - self.widgets.fast_time = QtWidgets.QComboBox() - self.widgets.fast_time.addItems(["fast", "exact"]) - self.widgets.fast_time.setCurrentIndex(0) - self.widgets.fast_time.setToolTip( - t( - "uses [fast] seek to a rough position ahead of timestamp, " - "vs a specific [exact] frame lookup. (GIF encodings use [fast])" - ) - ) - self.widgets.fast_time.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) - self.widgets.fast_time.setFixedWidth(65) - - # label = QtWidgets.QLabel(t("Trim")) - # label.setMaximumHeight(40) - # layout.addWidget(label, alignment=QtCore.Qt.AlignLeft) - layout.addWidget(reset, alignment=QtCore.Qt.AlignTop) - layout.addStretch(1) - layout.addLayout(start_layout) - layout.addLayout(end_layout) - layout.addWidget(QtWidgets.QLabel(" ")) - layout.addWidget(self.widgets.fast_time, QtCore.Qt.AlignRight) - - group_box.setLayout(layout) - return group_box - def reset_time(self): self.widgets.start_time.setText(self.number_to_time(0)) self.widgets.end_time.setText(self.number_to_time(self.app.fastflix.current_video.duration)) - def init_scale(self): - scale_area = QtWidgets.QGroupBox() - scale_area.setFont(self.app.font()) - scale_area.setStyleSheet(group_box_style(bb="none")) - - main_row = QtWidgets.QHBoxLayout() - - label = QtWidgets.QLabel(t("Resolution")) - label.setFixedWidth(90) - main_row.addWidget(label, alignment=QtCore.Qt.AlignLeft) - - self.widgets.resolution_drop_down = QtWidgets.QComboBox() - self.widgets.resolution_drop_down.addItems(list(resolutions.keys())) - self.widgets.resolution_drop_down.currentIndexChanged.connect(self.update_resolution) - - self.widgets.resolution_custom = QtWidgets.QLineEdit() - self.widgets.resolution_custom.setFixedWidth(150) - self.widgets.resolution_custom.textChanged.connect(self.custom_res_update) - - main_row.addWidget(self.widgets.resolution_drop_down, alignment=QtCore.Qt.AlignLeft) - main_row.addWidget(self.widgets.resolution_custom) - - scale_area.setLayout(main_row) - return scale_area - def custom_res_update(self): self.page_update(build_thumbnail=True) @@ -917,63 +1019,6 @@ def update_resolution(self): self.page_update(build_thumbnail=False) - def init_crop(self): - crop_box = QtWidgets.QGroupBox() - crop_box.setMinimumWidth(400) - crop_box.setStyleSheet(group_box_style(pt="0", pb="12px")) - crop_layout = QtWidgets.QVBoxLayout() - self.widgets.crop.top, crop_top_layout = self.build_hoz_int_field(f" {t('Top')} ") - self.widgets.crop.left, crop_hz_layout = self.build_hoz_int_field(f"{t('Left')} ", right_stretch=False) - self.widgets.crop.right, crop_hz_layout = self.build_hoz_int_field( - f" {t('Right')} ", left_stretch=True, layout=crop_hz_layout - ) - self.widgets.crop.bottom, crop_bottom_layout = self.build_hoz_int_field(f"{t('Bottom')} ", right_stretch=True) - - self.widgets.crop.top.textChanged.connect(lambda: self.page_update()) - self.widgets.crop.left.textChanged.connect(lambda: self.page_update()) - self.widgets.crop.right.textChanged.connect(lambda: self.page_update()) - self.widgets.crop.bottom.textChanged.connect(lambda: self.page_update()) - - label = QtWidgets.QLabel(t("Crop"), alignment=(QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight)) - - auto_crop = QtWidgets.QPushButton(t("Auto")) - auto_crop.setMaximumHeight(40) - auto_crop.setFixedWidth(50) - auto_crop.setToolTip(t("Automatically detect black borders")) - auto_crop.clicked.connect(self.get_auto_crop) - self.buttons.append(auto_crop) - - reset = QtWidgets.QPushButton(QtGui.QIcon(self.get_icon("undo")), "") - reset.setIconSize(QtCore.QSize(10, 10)) - reset.setStyleSheet(reset_button_style) - reset.setFixedWidth(15) - reset.clicked.connect(self.reset_crop) - self.buttons.append(reset) - - l1 = QtWidgets.QVBoxLayout() - l1.addWidget(label, alignment=(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)) - - l2 = QtWidgets.QVBoxLayout() - l2.addWidget(auto_crop, alignment=(QtCore.Qt.AlignTop | QtCore.Qt.AlignRight)) - - reset_layout = QtWidgets.QHBoxLayout() - reset_layout.addWidget(QtWidgets.QLabel("Reset")) - reset_layout.addWidget(reset) - - l2.addLayout(reset_layout) - l2.addStretch(1) - - crop_layout.addLayout(crop_top_layout) - crop_layout.addLayout(crop_hz_layout) - crop_layout.addLayout(crop_bottom_layout) - outer = QtWidgets.QHBoxLayout() - outer.addLayout(l1) - outer.addLayout(crop_layout) - outer.addLayout(l2) - crop_box.setLayout(outer) - - return crop_box - def reset_crop(self): self.loading_video = True self.widgets.crop.top.setText("0") @@ -1002,11 +1047,12 @@ def build_hoz_int_field( time_field=False, right_side_label=False, ): + scaled_button_size = scaler.scale(button_size) widget = QtWidgets.QLineEdit(self.number_to_time(0) if time_field else "0") widget.setObjectName(name) if not time_field: widget.setValidator(only_int) - widget.setFixedHeight(button_size) + widget.setFixedHeight(scaled_button_size) if not layout: layout = QtWidgets.QHBoxLayout() layout.setSpacing(0) @@ -1015,7 +1061,7 @@ def build_hoz_int_field( layout.addWidget(QtWidgets.QLabel(name)) minus_button = QtWidgets.QPushButton("-") minus_button.setAutoRepeat(True) - minus_button.setFixedSize(QtCore.QSize(button_size - 5, button_size)) + minus_button.setFixedSize(QtCore.QSize(scaled_button_size - scaler.scale(4), scaled_button_size)) minus_button.setStyleSheet("padding: 0; border: none;") minus_button.clicked.connect( lambda: [ @@ -1025,7 +1071,7 @@ def build_hoz_int_field( ) plus_button = QtWidgets.QPushButton("+") plus_button.setAutoRepeat(True) - plus_button.setFixedSize(button_size, button_size) + plus_button.setFixedSize(scaled_button_size, scaled_button_size) plus_button.setStyleSheet("padding: 0; border: none;") plus_button.clicked.connect( lambda: [ @@ -1036,9 +1082,9 @@ def build_hoz_int_field( self.buttons.append(minus_button) self.buttons.append(plus_button) if not time_field: - widget.setFixedWidth(45) + widget.setFixedWidth(scaler.scale(38)) else: - widget.setFixedWidth(95) + widget.setFixedWidth(scaler.scale(79)) widget.setStyleSheet("text-align: center") layout.addWidget(minus_button) layout.addWidget(widget) @@ -1054,15 +1100,52 @@ class PreviewImage(QtWidgets.QLabel): def __init__(self, parent): super().__init__() self.main = parent + self._original_pixmap = None self.setBackgroundRole(QtGui.QPalette.Base) - self.setMinimumSize(440, 260) self.setAlignment(QtCore.Qt.AlignCenter) + self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) + self._update_scaled_sizes() + # Register for scale factor changes + scaler.add_listener(self._on_scale_changed) + + def _update_scaled_sizes(self): + """Update minimum size, cursor, and stylesheet based on current scale factors.""" + self.setMinimumSize(scaler.scale(WIDTHS.PREVIEW_MIN), scaler.scale(HEIGHTS.PREVIEW_MIN)) self.setCursor( QtGui.QCursor( - QtGui.QPixmap(get_icon("onyx-magnifier", self.main.app.fastflix.config.theme)).scaledToWidth(32) + QtGui.QPixmap(get_icon("onyx-magnifier", self.main.app.fastflix.config.theme)).scaledToWidth( + scaler.scale(27) + ) ) ) - self.setStyleSheet("border: 2px solid #567781; margin: 8px;") + border_width = scaler.scale(2) + margin = scaler.scale(7) + self.setStyleSheet(f"border: {border_width}px solid #567781; margin: {margin}px;") + + def _on_scale_changed(self, factors): + """Called when scale factors change.""" + self._update_scaled_sizes() + self._update_scaled_pixmap() + + def setPixmap(self, pixmap): + self._original_pixmap = pixmap + self._update_scaled_pixmap() + + def _update_scaled_pixmap(self): + if self._original_pixmap is None or self._original_pixmap.isNull(): + super(PreviewImage, self).setPixmap(QtGui.QPixmap()) + return + # Scale pixmap to fit widget while maintaining aspect ratio + scaled = self._original_pixmap.scaled( + self.size(), + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation, + ) + super(PreviewImage, self).setPixmap(scaled) + + def resizeEvent(self, event): + self._update_scaled_pixmap() + super(PreviewImage, self).resizeEvent(event) def mousePressEvent(self, QMouseEvent): if ( @@ -1451,6 +1534,11 @@ def reload_video_from_queue(self, video: Video): ] self.widgets.video_track.clear() self.widgets.video_track.addItems(text_video_tracks) + # Show video track selector only when there's more than one video track + if len(self.app.fastflix.current_video.streams.video) > 1: + self.widgets.video_track_widget.show() + else: + self.widgets.video_track_widget.hide() for i, track in enumerate(text_video_tracks): if int(track.split(":")[0]) == self.app.fastflix.current_video.video_settings.selected_track: self.widgets.video_track.setCurrentIndex(i) @@ -1553,7 +1641,11 @@ def update_video_info(self, hide_progress=False): self.widgets.video_track.addItems(text_video_tracks) - self.widgets.video_track.setDisabled(bool(len(self.app.fastflix.current_video.streams.video) == 1)) + # Show video track selector only when there's more than one video track + if len(self.app.fastflix.current_video.streams.video) > 1: + self.widgets.video_track_widget.show() + else: + self.widgets.video_track_widget.hide() logger.debug(f"{len(self.app.fastflix.current_video.streams['video'])} {t('video tracks found')}") logger.debug(f"{len(self.app.fastflix.current_video.streams['audio'])} {t('audio tracks found')}") @@ -1632,7 +1724,7 @@ def end_time(self) -> float: @property def fast_time(self) -> bool: - return self.widgets.fast_time.currentText() == "fast" + return self.widgets.fast_time.currentIndex() == 0 @property def remove_metadata(self) -> bool: @@ -1900,7 +1992,7 @@ def encoding_checks(self): sm.setText("That output file already exists and is not empty!") sm.addButton("Cancel", QtWidgets.QMessageBox.DestructiveRole) sm.addButton("Overwrite", QtWidgets.QMessageBox.RejectRole) - sm.exec_() + sm.exec() if sm.clickedButton().text() == "Cancel": return False return True @@ -1909,12 +2001,12 @@ def set_convert_button(self): if not self.app.fastflix.currently_encoding: self.widgets.convert_button.setText(f"{t('Convert')} ") self.widgets.convert_button.setIcon(QtGui.QIcon(self.get_icon("play-round"))) - self.widgets.convert_button.setIconSize(QtCore.QSize(22, 20)) + self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) else: self.widgets.convert_button.setText(f"{t('Cancel')} ") self.widgets.convert_button.setIcon(QtGui.QIcon(self.get_icon("black-x"))) - self.widgets.convert_button.setIconSize(QtCore.QSize(22, 20)) + self.widgets.convert_button.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) def get_icon(self, name): return get_icon(name, self.app.fastflix.config.theme) @@ -1967,7 +2059,7 @@ def add_to_queue(self): else: if code is not None: return code - self.video_options.update_queue() + # No update_queue() needed - add_to_queue() already called new_source() self.video_options.show_queue() # if self.converting: @@ -2006,7 +2098,7 @@ def conversion_cancelled(self, video: Video): sm.setText(f"{t('Conversion cancelled, delete incomplete file')}\n{video.video_settings.output_path}?") sm.addButton(t("Delete"), QtWidgets.QMessageBox.YesRole) sm.addButton(t("Keep"), QtWidgets.QMessageBox.NoRole) - sm.exec_() + sm.exec() if sm.clickedButton().text() == t("Delete"): try: video.video_settings.output_path.unlink(missing_ok=True) @@ -2177,9 +2269,6 @@ def __init__(self, parent, app, status_queue): self.status_queue = status_queue self._shutdown = False - def __del__(self): - self.wait() - def request_shutdown(self): """Request graceful shutdown of the thread.""" self._shutdown = True diff --git a/fastflix/widgets/panels/abstract_list.py b/fastflix/widgets/panels/abstract_list.py index a2490b89..d825129c 100644 --- a/fastflix/widgets/panels/abstract_list.py +++ b/fastflix/widgets/panels/abstract_list.py @@ -5,6 +5,8 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp +from fastflix.ui_scale import scaler +from fastflix.ui_constants import HEIGHTS class FlixList(QtWidgets.QWidget): @@ -25,13 +27,13 @@ def __init__(self, app: FastFlixApp, parent, list_name, list_type, top_row_layou layout.addLayout(top_row_layout) else: header_text = QtWidgets.QLabel(t(list_name)) - header_text.setFixedHeight(30) + header_text.setFixedHeight(scaler.scale(25)) layout.addWidget(header_text) self.inner_widget = QtWidgets.QWidget() self.scroll_area = QtWidgets.QScrollArea(self) - self.scroll_area.setMinimumHeight(200) + self.scroll_area.setMinimumHeight(scaler.scale(HEIGHTS.SCROLL_MIN)) layout.addWidget(self.scroll_area) self.tracks = [] diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index 8ace9f6b..ed480479 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -12,6 +12,8 @@ from fastflix.models.profiles import Profile, TitleMode from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import get_icon +from fastflix.ui_scale import scaler +from fastflix.ui_constants import HEIGHTS, WIDTHS from fastflix.shared import no_border, error_message, yes_no_message, clear_list from fastflix.widgets.panels.abstract_list import FlixList from fastflix.audio_processing import apply_audio_filters @@ -105,7 +107,7 @@ def __init__( self.index = index self.first = False self.last = False - self.setFixedHeight(60) + self.setFixedHeight(scaler.scale(HEIGHTS.PANEL_ITEM)) audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[index] self.widgets = Box( @@ -143,24 +145,24 @@ def __init__( self.widgets.language.setCurrentText(lang) self.widgets.language.currentIndexChanged.connect(self.page_update) - self.widgets.title.setFixedWidth(150) + self.widgets.title.setFixedWidth(scaler.scale(WIDTHS.AUDIO_TITLE)) self.widgets.title.textChanged.connect(self.page_update) # self.widgets.audio_info.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - self.widgets.audio_info.setFixedWidth(350) + self.widgets.audio_info.setFixedWidth(scaler.scale(WIDTHS.AUDIO_INFO)) self.widgets.enable_check.setChecked(audio_track.enabled) self.widgets.enable_check.toggled.connect(self.update_enable) self.widgets.dup_button.clicked.connect(lambda: self.dup_me()) - self.widgets.dup_button.setFixedWidth(20) + self.widgets.dup_button.setFixedWidth(scaler.scale(17)) if disabled_dup: self.widgets.dup_button.hide() self.widgets.dup_button.setDisabled(True) self.widgets.delete_button.clicked.connect(lambda: self.del_me()) - self.widgets.delete_button.setFixedWidth(20) + self.widgets.delete_button.setFixedWidth(scaler.scale(17)) - self.widgets.track_number.setFixedWidth(20) + self.widgets.track_number.setFixedWidth(scaler.scale(17)) self.disposition_widget = Disposition( app=app, parent=self, track_name=f"Audio Track {index}", track_index=index, audio=True @@ -174,7 +176,7 @@ def __init__( self.widgets.disposition.setText(t("Dispositions")) label = QtWidgets.QLabel(f"{t('Title')}: ") - self.widgets.title.setFixedWidth(150) + self.widgets.title.setFixedWidth(scaler.scale(WIDTHS.AUDIO_TITLE)) title_layout = QtWidgets.QHBoxLayout() title_layout.addStretch(False) title_layout.addWidget(label, stretch=False) @@ -194,7 +196,7 @@ def __init__( if not audio_track.original: spacer = QtWidgets.QLabel() - spacer.setFixedWidth(63) + spacer.setFixedWidth(scaler.scale(53)) grid.addWidget(spacer, 0, right_button_start_index) grid.addWidget(self.widgets.delete_button, 0, right_button_start_index + 1) else: @@ -229,11 +231,11 @@ def init_move_buttons(self): # layout.setMargin(0) # self.widgets.up_button = QtWidgets.QPushButton("^") self.widgets.up_button.setDisabled(self.first) - self.widgets.up_button.setFixedWidth(20) + self.widgets.up_button.setFixedWidth(scaler.scale(17)) self.widgets.up_button.clicked.connect(lambda: self.parent.move_up(self)) # self.widgets.down_button = QtWidgets.QPushButton("v") self.widgets.down_button.setDisabled(self.last) - self.widgets.down_button.setFixedWidth(20) + self.widgets.down_button.setFixedWidth(scaler.scale(17)) self.widgets.down_button.clicked.connect(lambda: self.parent.move_down(self)) layout.addWidget(self.widgets.up_button) layout.addWidget(self.widgets.down_button) diff --git a/fastflix/widgets/panels/command_panel.py b/fastflix/widgets/panels/command_panel.py index d6602a5f..5b42dfee 100644 --- a/fastflix/widgets/panels/command_panel.py +++ b/fastflix/widgets/panels/command_panel.py @@ -9,6 +9,8 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import get_icon +from fastflix.ui_scale import scaler +from fastflix.ui_constants import HEIGHTS class Loop(QtWidgets.QGroupBox): @@ -87,7 +89,7 @@ def __init__(self, parent, app: FastFlixApp): self.inner_widget = QtWidgets.QWidget() self.scroll_area = QtWidgets.QScrollArea(self) - self.scroll_area.setMinimumHeight(200) + self.scroll_area.setMinimumHeight(scaler.scale(HEIGHTS.SCROLL_MIN)) layout.addWidget(self.scroll_area) self.commands = [] diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 2474e695..e88a6386 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -337,7 +337,7 @@ def queue_startup_check(self, queue_file=None): # metadata_file.unlink(missing_ok=True) self.new_source() - save_queue_async(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) + # No explicit save needed - new_source() triggers reorder() which saves the queue def manually_save_queue(self): filename = QtWidgets.QFileDialog.getSaveFileName( @@ -549,7 +549,7 @@ def add_to_queue(self): self.app.fastflix.conversion_list.append(copy.deepcopy(self.app.fastflix.current_video)) self.new_source() - save_queue_async(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) + # No explicit save needed - new_source() triggers reorder() which saves the queue def run_after_done(self): if not self.after_done_action: diff --git a/fastflix/widgets/panels/status_panel.py b/fastflix/widgets/panels/status_panel.py index 946e80dd..c986b86b 100644 --- a/fastflix/widgets/panels/status_panel.py +++ b/fastflix/widgets/panels/status_panel.py @@ -224,6 +224,7 @@ def timer_update(self, cmd): def closeEvent(self, event): self.hide() + event.ignore() class ElapsedTimeTicker(QtCore.QThread): @@ -241,9 +242,6 @@ def __init__(self, parent, tick_signal): self.state_signal.connect(self.set_state) self.stop_signal.connect(self.on_stop) - def __del__(self): - self.wait() - def run(self): while not self.stop_received: time.sleep(0.5) @@ -269,9 +267,6 @@ def __init__(self, parent, log_queue): self.log_queue = log_queue self._shutdown = False - def __del__(self): - self.wait() - def request_shutdown(self): """Request graceful shutdown of the thread.""" self._shutdown = True diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index 7cfbbef4..e1687688 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -237,7 +237,7 @@ def update_burn_in(self): self.widgets.burn_in.setChecked(False) error_message(t("There is an existing burn-in track, only one can be enabled at a time")) if enable and self.parent.main.fast_time: - self.parent.main.widgets.fast_time.setCurrentText("exact") + self.parent.main.widgets.fast_time.setCurrentIndex(1) # Set to "Exact" sub_track = self.app.fastflix.current_video.subtitle_tracks[self.index] sub_track.burn_in = enable self.updating_burn = False diff --git a/fastflix/widgets/progress_bar.py b/fastflix/widgets/progress_bar.py index edf55633..261193d2 100644 --- a/fastflix/widgets/progress_bar.py +++ b/fastflix/widgets/progress_bar.py @@ -8,6 +8,7 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp +from fastflix.ui_scale import scaler logger = logging.getLogger("fastflix") @@ -42,12 +43,12 @@ def __init__( self.setObjectName("ProgressBar") self.setStyleSheet("#ProgressBar{border: 1px solid #aaa}") - self.setMinimumWidth(400) + 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(30, 40, 500, 75) + 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) diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index c1d512c7..baf78d8c 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -4,11 +4,12 @@ import logging from typing import TYPE_CHECKING -from PySide6 import QtGui, QtWidgets, QtCore +from PySide6 import QtGui, QtWidgets from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import get_icon +from fastflix.ui_scale import scaler from fastflix.shared import DEVMODE, error_message from fastflix.widgets.panels.advanced_panel import AdvancedPanel from fastflix.widgets.panels.audio_panel import AudioList @@ -67,7 +68,7 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): "QComboBox QAbstractItemView{ background-color: #1d2023; border: 2px solid #76797c; }" ) - self.setIconSize(QtCore.QSize(24, 24)) + self.setIconSize(scaler.scale_size(20, 20)) self.addTab( self.current_settings, QtGui.QIcon(get_icon("onyx-quality", app.fastflix.config.theme)), t("Quality") ) diff --git a/pyproject.toml b/pyproject.toml index 5ba51e68..8039bafa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "pathvalidate>=2.4,<3.0", "psutil>=5.9,<6.0", "pydantic>=2.0,<3.0", - "pyside6==6.9.0", + "pyside6==6.10.1", "python-box[all]>=6.0,<7.0", "requests>=2.28,<3.0", "setuptools>=75.8", diff --git a/tests/test_pyside6_fixes.py b/tests/test_pyside6_fixes.py new file mode 100644 index 00000000..ea2125ce --- /dev/null +++ b/tests/test_pyside6_fixes.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" +Tests to verify PySide6 bug fixes are in place. + +These tests verify that deprecated methods are not used and that proper +thread cleanup patterns are followed. +""" + +import ast +import sys +from pathlib import Path + +import pytest +from PySide6 import QtWidgets + + +@pytest.fixture(scope="module") +def qapp(): + """Create a QApplication instance for tests that need Qt widgets.""" + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication(sys.argv) + yield app + + +def get_python_files(directory: Path) -> list[Path]: + """Get all Python files in a directory recursively.""" + return list(directory.rglob("*.py")) + + +class TestExecMethodUsage: + """Verify exec() is used instead of deprecated exec_().""" + + def test_no_exec_underscore_in_widgets(self): + """Verify no files use deprecated exec_() method.""" + widgets_dir = Path(__file__).parent.parent / "fastflix" / "widgets" + for py_file in get_python_files(widgets_dir): + content = py_file.read_text(encoding="utf-8") + # Check for .exec_() pattern - the deprecated method + assert ".exec_()" not in content, f"Found deprecated exec_() in {py_file}" + + def test_exec_method_exists_on_qdialog(self, qapp): + """Verify QMessageBox.exec() method exists (not exec_()).""" + box = QtWidgets.QMessageBox() + assert hasattr(box, "exec") + assert callable(box.exec) + + +class TestThreadCleanup: + """Verify threads are properly cleaned up without deadlock-prone patterns.""" + + def test_no_wait_in_del_methods(self): + """Verify __del__ methods don't call wait() which can cause deadlocks.""" + widgets_dir = Path(__file__).parent.parent / "fastflix" / "widgets" + + problematic_files = [] + + for py_file in get_python_files(widgets_dir): + content = py_file.read_text(encoding="utf-8") + + # Parse the AST to find __del__ methods + try: + tree = ast.parse(content) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "__del__": + # Check if the __del__ method contains a call to wait() + for child in ast.walk(node): + if isinstance(child, ast.Call): + if isinstance(child.func, ast.Attribute): + if child.func.attr == "wait": + problematic_files.append(str(py_file)) + break + + assert len(problematic_files) == 0, ( + f"Found __del__ methods calling wait() in: {problematic_files}. " + "This can cause deadlocks during garbage collection." + ) + + +class TestCloseEventHandling: + """Verify closeEvent methods handle events properly.""" + + def test_close_events_handle_event_parameter(self): + """Verify closeEvent methods either accept or ignore the event.""" + widgets_dir = Path(__file__).parent.parent / "fastflix" / "widgets" + + for py_file in get_python_files(widgets_dir): + content = py_file.read_text(encoding="utf-8") + + try: + tree = ast.parse(content) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "closeEvent": + # Check that the event parameter is used + has_event_handling = False + for child in ast.walk(node): + if isinstance(child, ast.Call): + if isinstance(child.func, ast.Attribute): + if child.func.attr in ("accept", "ignore"): + has_event_handling = True + break + # Also check for super().closeEvent() calls + if isinstance(child, ast.Call): + if isinstance(child.func, ast.Attribute): + if child.func.attr == "closeEvent": + has_event_handling = True + break + + # If the method just hides the widget, it should ignore the event + # This is a softer check - we allow hiding without explicit event handling + # as long as the method doesn't do nothing + _ = has_event_handling # Mark as used for now (soft check) + + +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 + + # Just verify the class can be imported without errors + assert ProgressBar is not None + + +class TestSignalDisconnection: + """Verify signals are properly disconnected when needed.""" + + def test_qthread_subclasses_have_shutdown_methods(self): + """Verify QThread subclasses have proper shutdown methods.""" + # Check that our thread classes have request_shutdown or similar + from fastflix.widgets.panels.status_panel import LogUpdater, ElapsedTimeTicker + + # LogUpdater should have request_shutdown + log_updater = LogUpdater.__dict__ + assert "request_shutdown" in log_updater or "_shutdown" in str(LogUpdater.__init__) + + # ElapsedTimeTicker should have stop_signal + ticker = ElapsedTimeTicker.__dict__ + assert "stop_signal" in str(ticker) or "on_stop" in ticker diff --git a/tests/test_ui_scaling.py b/tests/test_ui_scaling.py new file mode 100644 index 00000000..4e35a8a9 --- /dev/null +++ b/tests/test_ui_scaling.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +""" +Tests for the UI scaling system. + +These tests verify that the UIScaler singleton correctly calculates scale factors +and that the scaling functions return expected values. +""" + +import copy +import pytest +from unittest.mock import MagicMock + +from PySide6 import QtCore + + +class TestUIScaler: + """Tests for the UIScaler singleton class.""" + + def test_singleton(self): + """Verify UIScaler is a singleton.""" + from fastflix.ui_scale import UIScaler + + scaler1 = UIScaler() + scaler2 = UIScaler() + assert scaler1 is scaler2 + + def test_calculate_factors_at_base_size(self): + """Scale factors should be 1.0 at base size (1200x680).""" + from fastflix.ui_scale import UIScaler, BASE_WIDTH, BASE_HEIGHT + + scaler = UIScaler() + scaler.calculate_factors(BASE_WIDTH, BASE_HEIGHT) + assert scaler.factors.uniform == 1.0 + assert scaler.factors.width == 1.0 + assert scaler.factors.height == 1.0 + assert scaler.factors.font == 1.0 + assert scaler.factors.icon == 1.0 + + def test_calculate_factors_scaled_up(self): + """Scale factors should increase when window is larger than base.""" + from fastflix.ui_scale import UIScaler + + scaler = UIScaler() + scaler.calculate_factors(2400, 1360) # 2x base + assert scaler.factors.uniform == 2.0 + assert scaler.factors.width == 2.0 + assert scaler.factors.height == 2.0 + + def test_calculate_factors_scaled_down(self): + """Scale factors should decrease when window is smaller than base.""" + from fastflix.ui_scale import UIScaler + + scaler = UIScaler() + scaler.calculate_factors(600, 340) # 0.5x base + assert scaler.factors.uniform == 0.5 + assert scaler.factors.width == 0.5 + assert scaler.factors.height == 0.5 + + def test_calculate_factors_non_uniform(self): + """Uniform factor should be the minimum of width and height factors.""" + from fastflix.ui_scale import UIScaler, BASE_WIDTH, BASE_HEIGHT + + scaler = UIScaler() + # 2x width, 1x height - uniform should be 1.0 + scaler.calculate_factors(BASE_WIDTH * 2, BASE_HEIGHT) + assert scaler.factors.width == 2.0 + assert scaler.factors.height == 1.0 + assert scaler.factors.uniform == 1.0 + + def test_scale_returns_int(self): + """scale() should always return an integer.""" + from fastflix.ui_scale import UIScaler, BASE_WIDTH, BASE_HEIGHT + + scaler = UIScaler() + scaler.calculate_factors(BASE_WIDTH, BASE_HEIGHT) + result = scaler.scale(100) + assert isinstance(result, int) + + def test_scale_minimum_value(self): + """scale() should never return less than 1.""" + from fastflix.ui_scale import UIScaler + + scaler = UIScaler() + scaler.calculate_factors(1, 1) # Very small + result = scaler.scale(10) + assert result >= 1 + + def test_scale_font_minimum(self): + """scale_font() should never return less than 8 for readability.""" + from fastflix.ui_scale import UIScaler + + scaler = UIScaler() + scaler.calculate_factors(1, 1) # Very small + result = scaler.scale_font(12) + assert result >= 8 + + def test_scale_icon_minimum(self): + """scale_icon() should never return less than 10 for visibility.""" + from fastflix.ui_scale import UIScaler + + scaler = UIScaler() + scaler.calculate_factors(1, 1) # Very small + result = scaler.scale_icon(20) + assert result >= 10 + + def test_listener_notification(self): + """Listeners should be notified when scale factors change.""" + from fastflix.ui_scale import UIScaler, BASE_WIDTH, BASE_HEIGHT + + scaler = UIScaler() + callback = MagicMock() + scaler.add_listener(callback) + scaler.calculate_factors(BASE_WIDTH, BASE_HEIGHT) + callback.assert_called_once() + scaler.remove_listener(callback) + + def test_listener_removal(self): + """Removed listeners should not be notified.""" + from fastflix.ui_scale import UIScaler, BASE_WIDTH, BASE_HEIGHT + + scaler = UIScaler() + callback = MagicMock() + scaler.add_listener(callback) + scaler.remove_listener(callback) + scaler.calculate_factors(BASE_WIDTH, BASE_HEIGHT) + callback.assert_not_called() + + def test_scale_size_returns_qsize(self): + """scale_size() should return a QSize object.""" + from fastflix.ui_scale import UIScaler, BASE_WIDTH, BASE_HEIGHT + + scaler = UIScaler() + scaler.calculate_factors(BASE_WIDTH, BASE_HEIGHT) + result = scaler.scale_size(100, 50) + assert isinstance(result, QtCore.QSize) + assert result.width() == 100 + assert result.height() == 50 + + +class TestScaleFactors: + """Tests for the ScaleFactors dataclass.""" + + def test_default_values(self): + """ScaleFactors should have default values of 1.0.""" + from fastflix.ui_scale import ScaleFactors + + factors = ScaleFactors() + assert factors.width == 1.0 + assert factors.height == 1.0 + assert factors.uniform == 1.0 + assert factors.font == 1.0 + assert factors.icon == 1.0 + + def test_immutable_with_copy_replace(self): + """ScaleFactors should support copy.replace() for immutable updates.""" + from fastflix.ui_scale import ScaleFactors + + factors = ScaleFactors() + new_factors = copy.replace(factors, width=2.0) + assert factors.width == 1.0 # Original unchanged + assert new_factors.width == 2.0 + + +class TestUIConstants: + """Tests for UI constants.""" + + def test_base_widths_frozen(self): + """BaseWidths should be frozen (immutable).""" + from fastflix.ui_constants import WIDTHS + + with pytest.raises(Exception): # dataclass frozen raises FrozenInstanceError + WIDTHS.MENUBAR = 500 + + def test_base_heights_frozen(self): + """BaseHeights should be frozen (immutable).""" + from fastflix.ui_constants import HEIGHTS + + with pytest.raises(Exception): + HEIGHTS.TOP_BAR_BUTTON = 100 + + def test_base_icon_sizes_frozen(self): + """BaseIconSizes should be frozen (immutable).""" + from fastflix.ui_constants import ICONS + + with pytest.raises(Exception): + ICONS.SMALL = 50 + + def test_constants_have_positive_values(self): + """All constants should have positive values.""" + from fastflix.ui_constants import WIDTHS, HEIGHTS, ICONS + + for attr in dir(WIDTHS): + if not attr.startswith("_"): + assert getattr(WIDTHS, attr) > 0 + + for attr in dir(HEIGHTS): + if not attr.startswith("_"): + assert getattr(HEIGHTS, attr) > 0 + + for attr in dir(ICONS): + if not attr.startswith("_"): + assert getattr(ICONS, attr) > 0 + + +class TestUIStyles: + """Tests for UI style generation.""" + + def test_get_scaled_stylesheet_returns_string(self): + """get_scaled_stylesheet should return a string.""" + from fastflix.ui_styles import get_scaled_stylesheet + + result = get_scaled_stylesheet("onyx") + assert isinstance(result, str) + assert len(result) > 0 + + def test_get_scaled_stylesheet_contains_font_size(self): + """Stylesheet should contain font-size specification.""" + from fastflix.ui_styles import get_scaled_stylesheet + + result = get_scaled_stylesheet("onyx") + assert "font-size" in result + + def test_get_scaled_stylesheet_onyx_theme(self): + """Onyx theme should have specific styling.""" + from fastflix.ui_styles import get_scaled_stylesheet + + result = get_scaled_stylesheet("onyx") + assert "QAbstractItemView" in result + assert "#4b5054" in result # Onyx background color + + def test_get_menubar_stylesheet_returns_string(self): + """get_menubar_stylesheet should return a string.""" + from fastflix.ui_styles import get_menubar_stylesheet + + result = get_menubar_stylesheet() + assert isinstance(result, str) + assert "font-size" in result diff --git a/uv.lock b/uv.lock index 90ae0404..943194bd 100644 --- a/uv.lock +++ b/uv.lock @@ -178,7 +178,7 @@ requires-dist = [ { name = "platformdirs", specifier = "~=4.3" }, { name = "psutil", specifier = ">=5.9,<6.0" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, - { name = "pyside6", specifier = "==6.9.0" }, + { name = "pyside6", specifier = "==6.10.1" }, { name = "python-box", extras = ["all"], specifier = ">=6.0,<7.0" }, { name = "requests", specifier = ">=2.28,<3.0" }, { name = "reusables", specifier = ">=1.0.0" }, @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "pyside6" -version = "6.9.0" +version = "6.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyside6-addons" }, @@ -569,42 +569,42 @@ dependencies = [ { name = "shiboken6" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/46/74/0b465aa77644cfc3bfde912bb999b5a441d92c699272cab722335e92df3e/PySide6-6.9.0-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:b8f286a1bd143f3b2bdf08367b9362b13f469d26986c25700af9c4c68f79213e", size = 558001, upload-time = "2025-04-02T10:56:35.197Z" }, - { url = "https://files.pythonhosted.org/packages/91/53/ce78d2c279a4ed7d4baf5089a5ebff45d675670a42daa5e0f8dbb9ced6ed/PySide6-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:09239d1b808f18efccd3803db874d683917efcdebfdf0e8dec449cf50e74e7aa", size = 558139, upload-time = "2025-04-02T10:56:37.029Z" }, - { url = "https://files.pythonhosted.org/packages/4b/54/41d6ab0847c043f1fd96433a87ffd09a7cf17e11f5587e91e152777ec010/PySide6-6.9.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1a176409dd0dd12b72d2c78b776e5051f569071ec52b7aaadd0a5b3333493c24", size = 558139, upload-time = "2025-04-02T10:56:38.519Z" }, - { url = "https://files.pythonhosted.org/packages/63/03/55a632191beadd6bc59b04055961e2c3224a3475a906a63d1899a5ab493d/PySide6-6.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:0103e5d161696db40d75bfbf4e4b7d4f3372903c1b400c4e3379377b62c50290", size = 564479, upload-time = "2025-04-02T10:56:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/e8/80/340523ecb17d2a168d7e37dfd8a7a0eebb81dcbec4870447f132f2a1a28e/PySide6-6.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:846fbccf0b3501eb31cf0791a46e137615efba6ce540da2b426d79fa3e7762c4", size = 401752, upload-time = "2025-04-02T10:56:42.175Z" }, + { url = "https://files.pythonhosted.org/packages/56/22/f82cfcd1158be502c5741fe67c3fa853f3c1edbd3ac2c2250769dd9722d1/pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516", size = 558169, upload-time = "2025-11-20T10:09:08.79Z" }, + { url = "https://files.pythonhosted.org/packages/66/eb/54afe242a25d1c33b04ecd8321a549d9efb7b89eef7690eed92e98ba1dc9/pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa", size = 557818, upload-time = "2025-11-20T10:09:10.132Z" }, + { url = "https://files.pythonhosted.org/packages/4d/af/5706b1b33587dc2f3dfa3a5000424befba35e4f2d5889284eebbde37138b/pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c", size = 558358, upload-time = "2025-11-20T10:09:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/26/41/3f48d724ecc8e42cea8a8442aa9b5a86d394b85093275990038fd1020039/pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e", size = 564424, upload-time = "2025-11-20T10:09:12.677Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/395411473b433875a82f6b5fdd0cb28f19a0e345bcaac9fbc039400d7072/pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437", size = 548866, upload-time = "2025-11-20T10:09:14.174Z" }, ] [[package]] name = "pyside6-addons" -version = "6.9.0" +version = "6.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyside6-essentials" }, { name = "shiboken6" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/a4/211077b3f30342827b2c543f80a5f6bc483ff3af6be99766984618e68fb6/PySide6_Addons-6.9.0-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:98f9ad4b65820736e12d49c18db2e570eac63727407fbb59a62ac753e89dc201", size = 315606763, upload-time = "2025-04-02T10:56:56.271Z" }, - { url = "https://files.pythonhosted.org/packages/58/c1/21224090a7ee7e9ce5699e5bf16b84d576b7587f0712ccb6862a8b28476c/PySide6_Addons-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fc9dcd63a0ce7565f238cb11c44494435a50eb6cb72b8dbce3b709618989c3dc", size = 166252767, upload-time = "2025-04-02T10:57:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/85/c3/add4948cf15648db542531a5c292f9de946ee288243730be7607499936ec/PySide6_Addons-6.9.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d8a650644e0b9d1e7a092f6bcd11f25a63706d12f77d442b6ace75d346ab5d30", size = 161938789, upload-time = "2025-04-02T10:57:22.898Z" }, - { url = "https://files.pythonhosted.org/packages/77/c0/b1718f62d1fcc9bac4c410d4150d7e1214235e73cc18f39dc36ad49f093f/PySide6_Addons-6.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:8cf54065b3d1b4698448fad825378a25c10ef52017d9dff48cead03200636d8d", size = 142994491, upload-time = "2025-04-02T10:57:34.865Z" }, - { url = "https://files.pythonhosted.org/packages/29/aa/810ceb3d111fa6a0cc865520e05198dd0cad4855558c8c8309d4d3852854/PySide6_Addons-6.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:260a56da59539f476c1635a3ff13591e10f1b04d92155c0617129bc53ca8b5f8", size = 26840861, upload-time = "2025-04-02T10:57:41.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f9/b72a2578d7dbef7741bb90b5756b4ef9c99a5b40148ea53ce7f048573fe9/pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82", size = 322639446, upload-time = "2025-11-20T09:54:50.75Z" }, + { url = "https://files.pythonhosted.org/packages/94/3b/3ed951c570a15570706a89d39bfd4eaaffdf16d5c2dca17e82fc3ec8aaa6/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4", size = 170678293, upload-time = "2025-11-20T09:56:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/22/77/4c780b204d0bf3323a75c184e349d063e208db44c993f1214aa4745d6f47/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c", size = 166365011, upload-time = "2025-11-20T09:57:20.261Z" }, + { url = "https://files.pythonhosted.org/packages/04/14/58239776499e6b279fa6ca2e0d47209531454b99f6bd2ad7c96f11109416/pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341", size = 164864664, upload-time = "2025-11-20T09:57:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cd/1b74108671ba4b1ebb2661330665c4898b089e9c87f7ba69fe2438f3d1b6/pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db", size = 34191225, upload-time = "2025-11-20T09:58:04.184Z" }, ] [[package]] name = "pyside6-essentials" -version = "6.9.0" +version = "6.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "shiboken6" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/98/ac/a3c8097d6fdcf414d961bdc0d532381d0ee141e4c699f5e2b881a7c3613f/PySide6_Essentials-6.9.0-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:b18e3e01b507e8a57481fe19792eb373d5f10a23a50702ce540da1435e722f39", size = 131981893, upload-time = "2025-04-02T10:57:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/9e/fd/46b713827007162de9108b22d01702868e75f31585da7eca5a79e3435590/PySide6_Essentials-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:45eaf7f17688d1991f39680dbfd3c41674f3cbb78f278aa10fe0b5f2f31c1989", size = 94232483, upload-time = "2025-04-02T10:57:58.879Z" }, - { url = "https://files.pythonhosted.org/packages/ff/f1/72e1d400017a658e271594c8bd9c447c623dfd4fb936f4e043a4f9a8c93b/PySide6_Essentials-6.9.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:69aedfad77119c5bec0005ca31d5620e9bac8ba5ae66c7389160530cfd698ed8", size = 92102516, upload-time = "2025-04-02T10:58:06.598Z" }, - { url = "https://files.pythonhosted.org/packages/96/8a/bc710350c4cf6894968e39970eaa613b85a82eb1f230052de597e44a00ac/PySide6_Essentials-6.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:94a0096d6bb1d3e5cef29ca4a5366d0f229d42480fbb17aa25ad85d72b1b7947", size = 72336994, upload-time = "2025-04-02T10:58:14.491Z" }, - { url = "https://files.pythonhosted.org/packages/49/a4/703e379a0979985f681cf04b9af4129f5dde20141b3cc64fc2a39d006614/PySide6_Essentials-6.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:d2dc45536f2269ad111991042e81257124f1cd1c9ed5ea778d7224fd65dc9e2b", size = 49449220, upload-time = "2025-04-02T10:58:21.192Z" }, + { url = "https://files.pythonhosted.org/packages/04/b0/c43209fecef79912e9b1c70a1b5172b1edf76caebcc885c58c60a09613b0/pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe", size = 105461499, upload-time = "2025-11-20T09:59:23.733Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8e/b69ba7fa0c701f3f4136b50460441697ec49ee6ea35c229eb2a5ee4b5952/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856", size = 76764617, upload-time = "2025-11-20T09:59:38.831Z" }, + { url = "https://files.pythonhosted.org/packages/bd/83/569d27f4b6c6b9377150fe1a3745d64d02614021bea233636bc936a23423/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2", size = 75850373, upload-time = "2025-11-20T09:59:56.082Z" }, + { url = "https://files.pythonhosted.org/packages/1e/64/a8df6333de8ccbf3a320e1346ca30d0f314840aff5e3db9b4b66bf38e26c/pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674", size = 74491180, upload-time = "2025-11-20T10:00:11.215Z" }, + { url = "https://files.pythonhosted.org/packages/67/da/65cc6c6a870d4ea908c59b2f0f9e2cf3bfc6c0710ebf278ed72f69865e4e/pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c", size = 55190458, upload-time = "2025-11-20T10:00:26.226Z" }, ] [[package]] @@ -767,14 +767,14 @@ wheels = [ [[package]] name = "shiboken6" -version = "6.9.0" +version = "6.10.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/85/97b36b045a233bcea9580e8c99d5c76d65cf9727dad8cb173527f6717471/shiboken6-6.9.0-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:c4d8e3a5907154ac4789e52c77957db95bcf584238c244d7743cb39e9b66dd26", size = 407067, upload-time = "2025-04-02T10:58:43.491Z" }, - { url = "https://files.pythonhosted.org/packages/45/d3/f6ddef22d4f2ac11c079157ad3714d9b1fb9324d9cd3b200f824923fe2ba/shiboken6-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3f585caae5b814a7e23308db0a077355a7dc20c34d58ca4c339ff7625e9a1936", size = 206509, upload-time = "2025-04-02T10:58:44.905Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/6a91aad272fe89bf2293b7864fb6e926822c93a2f6192611528c6945196d/shiboken6-6.9.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b61579b90bf9c53ecc174085a69429166dfe57a0b8b894f933d1281af9df6568", size = 202809, upload-time = "2025-04-02T10:58:46.667Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6e/cf00d723ab141132fb6d35ba8faf109cbc0ee83412016343600abb423149/shiboken6-6.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:121ea290ed1afa5ad6abf690b377612693436292b69c61b0f8e10b1f0850f935", size = 1153132, upload-time = "2025-04-02T10:58:50.973Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/d59babab05786c99ebabdd152864ea3d4c500160979952c620eec68b1ff2/shiboken6-6.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:24f53857458881b54798d7e35704611d07f6b6885bcdf80f13a4c8bb485b8df2", size = 1831261, upload-time = "2025-04-02T10:58:52.789Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/e5db743d505ceea3efc4cd9634a3bee22a3e2bf6e07cefd28c9b9edabcc6/shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71", size = 478483, upload-time = "2025-11-20T10:08:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/b50c1a44b3c4643f482afbf1a0ea58f393827307100389ce29404f9ad3b0/shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4", size = 271993, upload-time = "2025-11-20T10:08:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/16/b8/939c24ebd662b0aa5c945443d0973145b3fb7079f0196274ef7bb4b98f73/shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0", size = 268691, upload-time = "2025-11-20T10:08:55.639Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/8c65ee0fa5e172ebcca03246b1bc3bd96cdaf1d60537316648536b7072a5/shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e", size = 1234704, upload-time = "2025-11-20T10:08:57.417Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6a/c0fea2f2ac7d9d96618c98156500683a4d1f93fea0e8c5a2bc39913d7ef1/shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9", size = 1795567, upload-time = "2025-11-20T10:08:59.184Z" }, ] [[package]] From 5131e267b494cb70bb7fde952b1dd382fd3954ab Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 2 Feb 2026 22:13:11 -0600 Subject: [PATCH 11/25] * Adding Keyframe checkbox to Settings menu as "Use keyframes for preview images" option (default enabled) * Adding preview time slider to overlay at bottom of preview image * Adding time display next to preview slider in H:MM:SS format --- CHANGES | 11 +- fastflix/application.py | 5 +- fastflix/data/languages.yaml | 171 +++++++++++++++++++ fastflix/models/config.py | 2 + fastflix/resources.py | 2 +- fastflix/ui_constants.py | 2 +- fastflix/ui_styles.py | 12 +- fastflix/widgets/main.py | 187 ++++++++++++++++++--- fastflix/widgets/panels/audio_panel.py | 8 +- fastflix/widgets/panels/subtitle_panel.py | 11 +- fastflix/widgets/settings.py | 7 +- fastflix/widgets/video_options.py | 13 +- fastflix/widgets/windows/large_preview.py | 4 +- fastflix/widgets/windows/profile_window.py | 2 +- tests/test_ui_scaling.py | 2 +- 15 files changed, 390 insertions(+), 49 deletions(-) diff --git a/CHANGES b/CHANGES index 2036681f..a6453a93 100644 --- a/CHANGES +++ b/CHANGES @@ -2,15 +2,18 @@ ## Version 5.13.0 +* Adding Keyframe checkbox to Settings menu as "Use keyframes for preview images" option (default enabled) +* Adding preview time slider to overlay at bottom of preview image +* Adding time display next to preview slider in H:MM:SS format * Adding #712 audio profile title options: No Title, Generate Title, and Custom Title -* Adding Start/End Time tab to right-side options panel between Size and Crop tabs with compact 3-column layout -* Fixing video track selector showing unnecessarily when source video has only one video track -* Fixing visual border between filename area and video track selector -* Fixing test suite hanging due to missing QApplication in PySide6 widget tests * Adding async queue saving to prevent GUI blocking during queue operations * Adding atomic file writes for queue to prevent corruption from interrupted saves * Adding file-based locking for queue operations to prevent race conditions between instances * Adding graceful shutdown handling for worker process and background threads +* Adding Start/End Time tab to right-side options panel between Size and Crop tabs with compact 3-column layout +* Fixing video track selector showing unnecessarily when source video has only one video track +* Fixing visual border between filename area and video track selector +* Fixing test suite hanging due to missing QApplication in PySide6 widget tests * Fixing window resizing beyond screen boundaries when switching profiles on macOS * Fixing potential GUI freeze when log queue fills up during encoding * Fixing file handle leaks in command runner when process startup fails diff --git a/fastflix/application.py b/fastflix/application.py index 2a700b16..75b26126 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -36,7 +36,10 @@ def create_app(enable_scaling): main_app = FastFlixApp(sys.argv) main_app.allWindows() main_app.setApplicationDisplayName("FastFlix") - my_font = QtGui.QFont("Arial" if "Arial" in QtGui.QFontDatabase().families() else "Sans Serif", 9) + 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) main_app.setFont(my_font) main_app.setWindowIcon(QtGui.QIcon(main_icon)) return main_app diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 8a7c3780..7745bf45 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -5421,6 +5421,21 @@ Use Sane Audio Selection (customizable in config file): ukr: Використовуйте Sane Audio Selection (налаштовується у файлі конфігурації) kor: 정상 오디오 선택 사용(구성 파일에서 사용자 지정 가능) ron: Utilizați selecția audio Sane (personalizabilă în fișierul de configurare) +Use keyframes for preview images: + deu: Keyframes für Vorschaubilder verwenden + eng: Use keyframes for preview images + fra: Utiliser les images clés pour les images de prévisualisation + ita: Usa i fotogrammi chiave per le immagini di anteprima + spa: Usar fotogramas clave para las imágenes de vista previa + chs: 使用关键帧作为预览图像 + jpn: プレビュー画像にキーフレームを使用する + rus: Использовать ключевые кадры для предварительного просмотра + por: Usar quadros-chave para imagens de pré-visualização + swe: Använd nyckelbilder för förhandsgranskningsbilder + pol: Użyj klatek kluczowych dla obrazów podglądu + ukr: Використовувати ключові кадри для зображень попереднього перегляду + kor: 미리보기 이미지에 키프레임 사용 + ron: Utilizați cadrele cheie pentru imaginile de previzualizare Useful when there is a desire to signal 0 values for max-cll and max-fall.: deu: Nützlich, wenn der Wunsch besteht, 0-Werte für max-cll und max-fall zu signalisieren. eng: Useful when there is a desire to signal 0 values for max-cll and max-fall. @@ -11379,25 +11394,181 @@ Custom Title: ron: Titlu personalizat Flip: eng: Flip + deu: Flip + fra: Retournement + ita: Capovolgere + spa: Flip + jpn: フリップ + rus: Флип + por: Flip + swe: Vändning + pol: Klapka + chs: 翻转 + ukr: Переверни. + kor: 플립 + ron: Flip V Flip: eng: V Flip + deu: V Flip + fra: V Flip + ita: V Flip + spa: V Voltear + jpn: Vフリップ + rus: V Флип + por: V Flip + swe: V Vändning + pol: V Flip + chs: V 翻转 + ukr: V-образне сальто + kor: V 플립 + ron: V Flip H Flip: eng: H Flip + deu: H Flip + fra: H Flip + ita: H Flip + spa: H Flip + jpn: Hフリップ + rus: H Flip + por: H Flip + swe: H Flip + pol: H Flip + chs: 翻转 + ukr: Переворот H. + kor: H 플립 + ron: H Flip V+H Flip: eng: V+H Flip + deu: V+H Flip + fra: V+H Flip + ita: V+H Flip + spa: V+H Flip + jpn: V+Hフリップ + rus: V+H Flip + por: V+H Flip + swe: V+H Vändning + pol: V+H Flip + chs: V+H 翻转 + ukr: V+H Flip + kor: V+H 플립 + ron: V+H Flip Size: eng: Size + deu: Größe + fra: Taille + ita: Dimensione + spa: Talla + jpn: サイズ + rus: Размер + por: Tamanho + swe: Storlek + pol: Rozmiar + chs: 尺寸 + ukr: Розмір + kor: 크기 + ron: Mărime Reset: eng: Reset + deu: Zurücksetzen + fra: Remise à zéro + ita: Reset + spa: Restablecer + jpn: リセット + rus: Сброс + por: Reiniciar + swe: Återställning + pol: Reset + chs: 重置 + ukr: Перезавантаження + kor: 초기화 + ron: Resetare Reset start and end times: eng: Reset start and end times + deu: Start- und Endzeiten zurücksetzen + fra: Réinitialisation des heures de début et de fin + ita: Azzeramento degli orari di inizio e fine + spa: Restablecer las horas de inicio y fin + jpn: 開始時間と終了時間をリセット + rus: Сброс времени начала и окончания + por: Repor as horas de início e de fim + swe: Återställ start- och sluttider + pol: Resetowanie czasu rozpoczęcia i zakończenia + chs: 重置开始和结束时间 + ukr: Скинути час початку та закінчення + kor: 시작 및 종료 시간 재설정 + ron: Resetați orele de început și de sfârșit Fast: eng: Fast + deu: Schnell + fra: Rapide + ita: Veloce + spa: Rápido + jpn: 速い + rus: Быстрый + por: Rápido + swe: Snabb + pol: Szybko + chs: 快速 + ukr: Швидко + kor: 빠른 + ron: Rapid Exact: eng: Exact + deu: Genau + fra: Exactement + ita: Esattamente + spa: Exacto + jpn: 正確 + rus: Точный адрес + por: Exato + swe: Exakt + pol: Dokładny + chs: 精确 + ukr: Точно. + kor: 정확한 + ron: Exact Start/End Time: eng: Start/End Time + deu: Beginn/Ende Uhrzeit + fra: Heure de début/fin + ita: Ora di inizio/fine + spa: Hora de inicio/fin + jpn: 開始/終了時間 + rus: Время начала/окончания + por: Hora de início/fim + swe: Start- och sluttid + pol: Czas rozpoczęcia/zakończenia + chs: 开始/结束时间 + ukr: Час початку/закінчення + kor: 시작/종료 시간 + ron: Ora de începere/finalizare Reset crop: eng: Reset crop + deu: Ernte zurücksetzen + fra: Réinitialiser la culture + ita: Azzeramento del raccolto + spa: Restablecer cultivo + jpn: 作物をリセットする + rus: Сброс урожая + por: Redefinir a cultura + swe: Återställ grödan + pol: Resetowanie zbiorów + chs: 重置作物 + ukr: Скинути врожай + kor: 자르기 초기화 + ron: Resetarea culturii Options: eng: Options + deu: Optionen + fra: Options + ita: Opzioni + spa: Opciones + jpn: オプション + rus: Опции + por: Opções + swe: Alternativ + pol: Opcje + chs: 选项 + ukr: Параметри + kor: 옵션 + ron: Opțiuni diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 45c633ea..86ed67f1 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -183,6 +183,8 @@ class Config(BaseModel): disable_cover_extraction: bool = False + use_keyframes_for_preview: bool = True + def encoder_opt(self, profile_name, profile_option_name): encoder_settings = getattr(self.profiles[self.selected_profile], profile_name) if encoder_settings: diff --git a/fastflix/resources.py b/fastflix/resources.py index c9c0b709..39ae9c2d 100644 --- a/fastflix/resources.py +++ b/fastflix/resources.py @@ -62,7 +62,7 @@ def get_icon(name: str, theme: str): def get_text_color(theme: str): - if theme.lower() == "dark": + if theme.lower() in ("dark", "onyx"): return "255, 255, 255" return "0, 0, 0" diff --git a/fastflix/ui_constants.py b/fastflix/ui_constants.py index c8cf5377..689526e9 100644 --- a/fastflix/ui_constants.py +++ b/fastflix/ui_constants.py @@ -43,7 +43,7 @@ class BaseHeights: TOP_BAR_BUTTON: int = 38 PATH_WIDGET: int = 20 COMBO_BOX: int = 22 - PANEL_ITEM: int = 45 + PANEL_ITEM: int = 62 SCROLL_MIN: int = 150 PREVIEW_MIN: int = 195 OUTPUT_DIR: int = 18 diff --git a/fastflix/ui_styles.py b/fastflix/ui_styles.py index efbf0fd6..2323bf43 100644 --- a/fastflix/ui_styles.py +++ b/fastflix/ui_styles.py @@ -18,16 +18,16 @@ def get_scaled_stylesheet(theme: str) -> str: if theme == "onyx": base += f""" - QAbstractItemView {{ background-color: #4b5054; }} + QAbstractItemView {{ background-color: #4f5962; }} QComboBox QAbstractItemView {{ background-color: #1d2023; border: 2px solid #76797c; }} QPushButton {{ border-radius: {border_radius}px; }} QLineEdit {{ - background-color: #707070; + background-color: #4a555e; color: black; border-radius: {border_radius}px; }} - QTextEdit {{ background-color: #707070; color: black; }} - QTabBar::tab {{ background-color: #4b5054; }} + QTextEdit {{ background-color: #4a555e; color: black; }} + QTabBar::tab {{ background-color: #4f5962; }} QComboBox {{ border-radius: {border_radius}px; }} QScrollArea {{ border: 1px solid #919191; }} """ @@ -42,8 +42,8 @@ def get_video_options_stylesheet(theme: str) -> str: if theme == "onyx": return f""" - * {{ background-color: #4b5054; color: white; }} - QTabWidget {{ margin-top: {scaler.scale(34)}px; background-color: #4b5054; }} + * {{ background-color: #4f5962; color: white; }} + QTabWidget {{ margin-top: {scaler.scale(34)}px; background-color: #4f5962; }} QTabBar {{ font-size: {tab_font_size}px; background-color: #4f5962; }} QComboBox {{ min-height: {combo_min_height}px; }} """ diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 17577f5c..4e95a0fb 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -136,7 +136,7 @@ class MainWidgets(BaseModel): remove_hdr: QtWidgets.QCheckBox = None profile_box: QtWidgets.QComboBox = None thumb_time: QtWidgets.QSlider = None - thumb_key: QtWidgets.QCheckBox = None + preview_time_label: QtWidgets.QLabel = None resolution_drop_down: QtWidgets.QComboBox = None resolution_custom: QtWidgets.QLineEdit = None output_directory: QtWidgets.QPushButton = None @@ -237,7 +237,9 @@ def __init__(self, parent, app: FastFlixApp): 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("padding: 0 0 -1px 5px") + 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.textChanged.connect(lambda x: self.page_update(build_thumbnail=False)) @@ -291,20 +293,38 @@ def __init__(self, parent, app: FastFlixApp): # pi.addWidget(self.init_preview_image()) # pi.addLayout(self.()) - self.grid.addWidget(self.init_preview_image(), 0, 6, 6, 5) - self.grid.addLayout(self.init_thumb_time_selector(), 6, 6, 1, 5, (QtCore.Qt.AlignTop | QtCore.Qt.AlignCenter)) + self.grid.addWidget(self.init_preview_image(), 0, 6, 7, 5) # self.grid.addLayout(pi, 0, 6, 7, 5) spacer = QtWidgets.QLabel() spacer.setFixedHeight(scaler.scale(HEIGHTS.SPACER_SMALL)) self.grid.addWidget(spacer, 8, 0, 1, 14) - self.grid.addWidget(self.video_options, 9, 0, 10, 14) + + # Add separator line above tabs for onyx theme + if self.app.fastflix.config.theme == "onyx": + tab_separator = QtWidgets.QFrame() + tab_separator.setFrameShape(QtWidgets.QFrame.HLine) + tab_separator.setFixedHeight(1) + tab_separator.setStyleSheet("background-color: #567781;") + self.grid.addWidget(tab_separator, 9, 0, 1, 14) + self.grid.addWidget(self.video_options, 10, 0, 10, 14) + else: + self.grid.addWidget(self.video_options, 9, 0, 10, 14) self.grid.setSpacing(5) self.paused = False self.disable_all() self.setLayout(self.grid) + + if self.app.fastflix.config.theme == "onyx": + self.setStyleSheet( + "QLabel{ color: white; } " + "QLineEdit{ color: white; } " + "QCheckBox{ color: white; } " + "QGroupBox{ color: white; } " + ) + self.show() self.initialized = True self.loading_video = False @@ -336,11 +356,12 @@ def init_top_bar(self): 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.setDefault(True) source.clicked.connect(lambda: self.open_file()) self.widgets.profile_box = QtWidgets.QComboBox() - self.widgets.profile_box.setStyleSheet("text-align: center;") + self.widgets.profile_box.setStyleSheet("text-align: center; font-size: 14px;") 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) @@ -409,29 +430,70 @@ def init_top_bar_right(self): return top_bar_right def init_thumb_time_selector(self): - layout = QtWidgets.QHBoxLayout() + """Create the preview time slider overlay widget with time display.""" + container = QtWidgets.QWidget() + container.setStyleSheet("background-color: rgba(0, 0, 0, 50); border-radius: 5px;") + container.setFixedHeight(scaler.scale(32)) - self.widgets.thumb_key = QtWidgets.QCheckBox("Keyframe") - self.widgets.thumb_key.setChecked(False) - self.widgets.thumb_key.clicked.connect(self.thumb_time_change) + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(scaler.scale(10), scaler.scale(4), scaler.scale(10), scaler.scale(4)) self.widgets.thumb_time = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.widgets.thumb_time.setMinimum(1) self.widgets.thumb_time.setMaximum(100) self.widgets.thumb_time.setValue(25) - self.widgets.thumb_time.setTickPosition(QtWidgets.QSlider.TicksBelow) - self.widgets.thumb_time.setTickInterval(1) self.widgets.thumb_time.setAutoFillBackground(False) self.widgets.thumb_time.sliderReleased.connect(self.thumb_time_change) + self.widgets.thumb_time.valueChanged.connect(self.update_preview_time_label) + self.widgets.thumb_time.setStyleSheet(""" + QSlider { + background: rgba(255, 255, 255, 0); + } + + 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.widgets.preview_time_label = QtWidgets.QLabel("0:00:00") + self.widgets.preview_time_label.setStyleSheet("color: white; font-weight: bold; background: transparent;") + self.widgets.preview_time_label.setFixedWidth(scaler.scale(70)) + self.widgets.preview_time_label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - spacer = QtWidgets.QLabel() - spacer.setFixedWidth(scaler.scale(WIDTHS.SPACER_SMALL)) - layout.addWidget(spacer) - layout.addWidget(self.widgets.thumb_key) - layout.addWidget(spacer) layout.addWidget(self.widgets.thumb_time) - layout.addWidget(spacer) - return layout + layout.addWidget(self.widgets.preview_time_label) + + return container + + def update_preview_time_label(self): + """Update the time label when slider value changes.""" + if not self.app.fastflix.current_video: + self.widgets.preview_time_label.setText("0:00:00") + return + time_seconds = self.preview_place + self.widgets.preview_time_label.setText(self.format_preview_time(time_seconds)) + + @staticmethod + def format_preview_time(seconds: float) -> str: + """Convert seconds to H:MM:SS format.""" + if seconds < 0: + seconds = 0 + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + return f"{hours}:{minutes:02d}:{secs:02d}" def thumb_time_change(self): self.generate_thumbnail() @@ -478,6 +540,8 @@ def init_video_area(self): source_layout = QtWidgets.QHBoxLayout() source_label = QtWidgets.QLabel(t("Source")) source_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) + if self.app.fastflix.config.theme == "onyx": + source_label.setStyleSheet("color: white;") 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) @@ -485,14 +549,19 @@ def init_video_area(self): output_layout = QtWidgets.QHBoxLayout() output_label = QtWidgets.QLabel(t("Filename")) output_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL)) + if self.app.fastflix.config.theme == "onyx": + output_label.setStyleSheet("color: white;") 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) self.widgets.output_type_combo.setFixedWidth(scaler.scale(WIDTHS.OUTPUT_TYPE)) 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( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) self.widgets.output_type_combo.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) output_layout.addWidget(self.widgets.output_type_combo) @@ -539,6 +608,8 @@ def init_options_tabs(self): """Create a tabbed widget with Size, Start/End Time, Crop, and Options tabs.""" 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; }") # Tab 1: Size (Resolution + Transforms) size_tab = QtWidgets.QWidget() @@ -556,6 +627,10 @@ def init_options_tabs(self): self.widgets.resolution_drop_down = QtWidgets.QComboBox() self.widgets.resolution_drop_down.addItems(list(resolutions.keys())) self.widgets.resolution_drop_down.currentIndexChanged.connect(self.update_resolution) + if self.app.fastflix.config.theme == "onyx": + self.widgets.resolution_drop_down.setStyleSheet( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) res_row.addWidget(self.widgets.resolution_drop_down) self.widgets.resolution_custom = QtWidgets.QLineEdit() @@ -599,12 +674,20 @@ def init_options_tabs(self): time_reset.setFixedHeight(scaler.scale(22)) time_reset.setToolTip(t("Reset start and end times")) time_reset.clicked.connect(self.reset_time) + if self.app.fastflix.config.theme == "onyx": + time_reset.setStyleSheet( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) self.buttons.append(time_reset) 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)) + if self.app.fastflix.config.theme == "onyx": + self.widgets.fast_time.setStyleSheet( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) self.widgets.fast_time.setToolTip( t( "uses [fast] seek to a rough position ahead of timestamp, " @@ -660,11 +743,19 @@ def init_options_tabs(self): auto_crop.setFixedHeight(scaler.scale(22)) 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( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) self.buttons.append(auto_crop) reset = QtWidgets.QPushButton(t("Reset")) reset.setFixedHeight(scaler.scale(22)) reset.setToolTip(t("Reset crop")) reset.clicked.connect(self.reset_crop) + if self.app.fastflix.config.theme == "onyx": + reset.setStyleSheet( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) self.buttons.append(reset) col1.addWidget(auto_crop) col1.addWidget(reset) @@ -890,6 +981,10 @@ def init_flip(self): self.widgets.flip.setItemIcon(3, QtGui.QIcon(rot_180_file)) self.widgets.flip.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) self.widgets.flip.currentIndexChanged.connect(lambda: self.page_update()) + if self.app.fastflix.config.theme == "onyx": + self.widgets.flip.setStyleSheet( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) return self.widgets.flip def get_flips(self) -> Tuple[bool, bool]: @@ -927,6 +1022,10 @@ def init_rotate(self): self.widgets.rotate.setItemIcon(3, QtGui.QIcon(rot_270_file)) self.widgets.rotate.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) self.widgets.rotate.currentIndexChanged.connect(lambda: self.page_update()) + if self.app.fastflix.config.theme == "onyx": + self.widgets.rotate.setStyleSheet( + "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" + ) return self.widgets.rotate def change_output_types(self): @@ -943,6 +1042,7 @@ def init_encoder_drop_down(self): 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.change_output_types() self.widgets.convert_to.view().setMinimumWidth( self.widgets.convert_to.minimumSizeHint().width() + scaler.scale(50) @@ -1158,9 +1258,50 @@ def mousePressEvent(self, QMouseEvent): self.main.large_preview.show() super(PreviewImage, self).mousePressEvent(QMouseEvent) - self.widgets.preview = PreviewImage(self) + # Create container widget to hold preview image and overlay slider + class PreviewContainer(QtWidgets.QWidget): + def __init__(self, main_widget): + super().__init__() + self.main_widget = main_widget + self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.main_widget.reposition_thumb_overlay() + + def showEvent(self, event): + super().showEvent(event) + self.main_widget.reposition_thumb_overlay() + + self.preview_container = PreviewContainer(self) - return self.widgets.preview + # Use a stacked layout approach with a QVBoxLayout and overlay + container_layout = QtWidgets.QVBoxLayout(self.preview_container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(0) + + self.widgets.preview = PreviewImage(self) + container_layout.addWidget(self.widgets.preview) + + # Create the slider overlay and position it at the bottom + self.thumb_time_overlay = self.init_thumb_time_selector() + self.thumb_time_overlay.setParent(self.preview_container) + self.thumb_time_overlay.raise_() + + return self.preview_container + + def reposition_thumb_overlay(self): + """Reposition the thumb time overlay at the bottom of the preview container.""" + if hasattr(self, "thumb_time_overlay") and hasattr(self, "preview_container"): + container_rect = self.preview_container.rect() + overlay_height = self.thumb_time_overlay.height() + margin = scaler.scale(15) + self.thumb_time_overlay.setGeometry( + margin, + container_rect.height() - overlay_height - margin, + container_rect.width() - (2 * margin), + overlay_height, + ) def modify_int(self, widget, method="add", time_field=False): modifier = 1 @@ -1764,7 +1905,7 @@ def generate_thumbnail(self): # custom_filters += ",select=eq(pict_type\\,I)" filters = helpers.generate_filters( - start_filters="select=eq(pict_type\\,I)" if self.widgets.thumb_key.isChecked() else None, + start_filters="select=eq(pict_type\\,I)" if self.app.fastflix.config.use_keyframes_for_preview else None, custom_filters=custom_filters, enable_opencl=False, **settings, diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index ed480479..5a33a291 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -232,10 +232,14 @@ def init_move_buttons(self): # self.widgets.up_button = QtWidgets.QPushButton("^") 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 = QtWidgets.QPushButton("v") 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) @@ -337,7 +341,7 @@ def update_track(self, conversion=None, bitrate=None, downmix=None, title=None): def check_conversion_button(self): audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[self.index] if audio_track.conversion_codec: - self.widgets.conversion.setStyleSheet("border-color: #0055ff") + self.widgets.conversion.setStyleSheet("border-color: #4a555e; background-color: #4a555e") self.widgets.conversion.setText(t("Conversion") + f": {audio_track.conversion_codec}") else: self.widgets.conversion.setStyleSheet("") @@ -346,7 +350,7 @@ def check_conversion_button(self): def check_dis_button(self): audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[self.index] if any(audio_track.dispositions.values()): - self.widgets.disposition.setStyleSheet("border-color: #0055ff") + self.widgets.disposition.setStyleSheet("border-color: #4a555e; background-color: #4a555e") else: self.widgets.disposition.setStyleSheet("") diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index e1687688..2ec8a0ee 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -12,6 +12,7 @@ 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.ui_scale import scaler from fastflix.widgets.background_tasks import ExtractSubtitleSRT from fastflix.widgets.panels.abstract_list import FlixList from fastflix.widgets.windows.disposition import Disposition @@ -158,10 +159,14 @@ def init_move_buttons(self): layout = QtWidgets.QVBoxLayout() layout.setSpacing(0) self.widgets.up_button.setDisabled(self.first) - self.widgets.up_button.setFixedWidth(20) + 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(20) + 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) @@ -257,7 +262,7 @@ def page_update(self): def check_dis_button(self): track: SubtitleTrack = self.app.fastflix.current_video.subtitle_tracks[self.index] if any(track.dispositions.values()): - self.widgets.disposition.setStyleSheet("border-color: #0055ff") + self.widgets.disposition.setStyleSheet("border-color: #4a555e; background-color: #4a555e") else: self.widgets.disposition.setStyleSheet("") diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 4dfd5a47..ddd1b592 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -269,6 +269,9 @@ def in_dir(): self.disable_deinterlace_button = QtWidgets.QCheckBox(t("Disable interlace check")) self.disable_deinterlace_button.setChecked(self.app.fastflix.config.disable_deinterlace_check) + self.use_keyframes_for_preview = QtWidgets.QCheckBox(t("Use keyframes for preview images")) + self.use_keyframes_for_preview.setChecked(self.app.fastflix.config.use_keyframes_for_preview) + # Layouts layout.addWidget(self.use_sane_audio, 7, 0, 1, 2) @@ -286,13 +289,14 @@ def in_dir(): layout.addWidget(self.clean_old_logs_button, 21, 0, 1, 3) layout.addWidget(self.disable_end_message, 22, 0, 1, 3) layout.addWidget(self.disable_deinterlace_button, 23, 0, 1, 3) + layout.addWidget(self.use_keyframes_for_preview, 24, 0, 1, 3) button_layout = QtWidgets.QHBoxLayout() button_layout.addStretch() button_layout.addWidget(cancel) button_layout.addWidget(save) - layout.addLayout(button_layout, 25, 0, 1, 3) + layout.addLayout(button_layout, 26, 0, 1, 3) self.setLayout(layout) @@ -379,6 +383,7 @@ def save(self): self.app.fastflix.config.sticky_tabs = self.sticky_tabs.isChecked() self.app.fastflix.config.disable_complete_message = self.disable_end_message.isChecked() self.app.fastflix.config.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.app.fastflix.config.save() diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index baf78d8c..26d1718f 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -62,10 +62,15 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): self.debug = DebugPanel(self, self.app) if self.app.fastflix.config.theme == "onyx": self.setStyleSheet( - "*{ background-color: #4b5054; color: white} QTabWidget{margin-top: 34px; background-color: #4b5054;} " - "QTabBar{font-size: 13px; background-color: #4f5962}" - "QComboBox{min-height: 1.1em;}" - "QComboBox QAbstractItemView{ background-color: #1d2023; border: 2px solid #76797c; }" + "QTabBar{ font-size: 13px; } " + "QTabBar::tab{ border-top: 2px solid transparent; } " + "QTabBar::tab:selected{ border-top: 2px solid #567781; } " + "QLineEdit{ color: white; } " + "QTextEdit{ color: white; } " + "QPlainTextEdit{ color: white; } " + "QComboBox{ min-height: 1.1em; background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px; }" + "QComboBox:hover{ background-color: #6a8a96; } " + "QComboBox QAbstractItemView{ background-color: #1d2023; border: 2px solid #4a555e; } " ) self.setIconSize(scaler.scale_size(20, 20)) diff --git a/fastflix/widgets/windows/large_preview.py b/fastflix/widgets/windows/large_preview.py index c24d3a62..6de01530 100644 --- a/fastflix/widgets/windows/large_preview.py +++ b/fastflix/widgets/windows/large_preview.py @@ -69,7 +69,9 @@ def generate_image(self): filters = helpers.generate_filters( enable_opencl=False, - start_filters="select=eq(pict_type\\,I)" if self.main.widgets.thumb_key.isChecked() else None, + start_filters="select=eq(pict_type\\,I)" + if self.main.app.fastflix.config.use_keyframes_for_preview + else None, scale=self.main.app.fastflix.current_video.scale, **settings, ) diff --git a/fastflix/widgets/windows/profile_window.py b/fastflix/widgets/windows/profile_window.py index b6802b5d..8f531870 100644 --- a/fastflix/widgets/windows/profile_window.py +++ b/fastflix/widgets/windows/profile_window.py @@ -452,7 +452,7 @@ def __init__(self, app: FastFlixApp, main, container, *args, **kwargs): profile_name_label.setFixedHeight(40) self.profile_name = QtWidgets.QLineEdit() if self.app.fastflix.config.theme == "onyx": - self.profile_name.setStyleSheet("background-color: #707070; border-radius: 10px; color: black") + self.profile_name.setStyleSheet("background-color: #4a555e; border-radius: 10px; color: black") self.profile_name.setFixedWidth(300) self.advanced_options: AdvancedOptions = self.main.video_options.advanced.get_settings() diff --git a/tests/test_ui_scaling.py b/tests/test_ui_scaling.py index 4e35a8a9..dd7629fa 100644 --- a/tests/test_ui_scaling.py +++ b/tests/test_ui_scaling.py @@ -226,7 +226,7 @@ def test_get_scaled_stylesheet_onyx_theme(self): result = get_scaled_stylesheet("onyx") assert "QAbstractItemView" in result - assert "#4b5054" in result # Onyx background color + assert "#4f5962" in result # Onyx background color def test_get_menubar_stylesheet_returns_string(self): """get_menubar_stylesheet should return a string.""" From 568e35aead383254830b1bd5957d59a3fc5d1a4d Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 2 Feb 2026 22:59:46 -0600 Subject: [PATCH 12/25] * Adding Keyframe checkbox to Settings menu as "Use keyframes for preview images" option (default enabled) * Adding preview time slider to overlay at bottom of preview image * Adding time display next to preview slider in H:MM:SS format --- fastflix/ui_styles.py | 58 ++++++++++++++++++++- fastflix/widgets/main.py | 63 ++++++----------------- fastflix/widgets/panels/advanced_panel.py | 5 +- fastflix/widgets/panels/audio_panel.py | 9 ++-- fastflix/widgets/panels/subtitle_panel.py | 5 +- fastflix/widgets/video_options.py | 26 ++++++++-- 6 files changed, 105 insertions(+), 61 deletions(-) diff --git a/fastflix/ui_styles.py b/fastflix/ui_styles.py index 2323bf43..953a57f6 100644 --- a/fastflix/ui_styles.py +++ b/fastflix/ui_styles.py @@ -9,6 +9,19 @@ from fastflix.ui_constants import FONTS +# Onyx theme color constants +ONYX_COLORS = { + "primary": "#567781", # Blue accent (borders, selected tabs) + "input_bg": "#4a555e", # Input field backgrounds + "dropdown_bg": "#4e6172", # Dropdown backgrounds + "text": "#ffffff", # White text + "text_muted": "#b5b5b5", # Muted/disabled text + "background": "#4f5962", # Main background + "overlay": "rgba(0, 0, 0, 50)", # Overlay backgrounds + "dark_bg": "#1d2023", # Dark background (dropdown menus) +} + + def get_scaled_stylesheet(theme: str) -> str: """Generate a scaled stylesheet based on the current theme and scale factors.""" font_size = scaler.scale_font(FONTS.LARGE) @@ -23,10 +36,10 @@ def get_scaled_stylesheet(theme: str) -> str: QPushButton {{ border-radius: {border_radius}px; }} QLineEdit {{ background-color: #4a555e; - color: black; + color: white; border-radius: {border_radius}px; }} - QTextEdit {{ background-color: #4a555e; color: black; }} + QTextEdit {{ background-color: #4a555e; color: white; }} QTabBar::tab {{ background-color: #4f5962; }} QComboBox {{ border-radius: {border_radius}px; }} QScrollArea {{ border: 1px solid #919191; }} @@ -54,3 +67,44 @@ def get_menubar_stylesheet() -> str: """Generate scaled stylesheet for the menu bar.""" font_size = scaler.scale_font(FONTS.LARGE) return f"font-size: {font_size}px" + + +def get_onyx_combobox_style() -> str: + """Standard combobox/dropdown style for onyx theme.""" + return ( + f"background-color: {ONYX_COLORS['input_bg']}; " + f"color: {ONYX_COLORS['text']}; " + f"border: 1px solid {ONYX_COLORS['input_bg']}; " + "border-radius: 0px;" + ) + + +def get_onyx_button_style() -> str: + """Standard button style for onyx theme.""" + return ( + f"background-color: {ONYX_COLORS['input_bg']}; " + f"color: {ONYX_COLORS['text']}; " + f"border: 1px solid {ONYX_COLORS['input_bg']}; " + "border-radius: 0px;" + ) + + +def get_onyx_disposition_style(enabled: bool = True) -> str: + """Style for disposition dropdowns in audio/subtitle panels. + + Args: + enabled: Whether the disposition is enabled (colored) or disabled (default) + """ + if enabled: + return f"border-color: {ONYX_COLORS['input_bg']}; background-color: {ONYX_COLORS['input_bg']}" + return "" + + +def get_onyx_label_style(muted: bool = False) -> str: + """Style for labels in onyx theme. + + Args: + muted: Whether to use muted text color + """ + color = ONYX_COLORS["text_muted"] if muted else ONYX_COLORS["text"] + return f"color: {color}" diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index 4e95a0fb..4b7af8a7 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -24,6 +24,7 @@ from fastflix.exceptions import FastFlixInternalException, FlixError from fastflix.ui_scale import scaler from fastflix.ui_constants import WIDTHS, HEIGHTS, ICONS +from fastflix.ui_styles import ONYX_COLORS, get_onyx_combobox_style, get_onyx_button_style from fastflix.flix import ( detect_hdr10_plus, detect_interlaced, @@ -300,16 +301,7 @@ def __init__(self, parent, app: FastFlixApp): spacer.setFixedHeight(scaler.scale(HEIGHTS.SPACER_SMALL)) self.grid.addWidget(spacer, 8, 0, 1, 14) - # Add separator line above tabs for onyx theme - if self.app.fastflix.config.theme == "onyx": - tab_separator = QtWidgets.QFrame() - tab_separator.setFrameShape(QtWidgets.QFrame.HLine) - tab_separator.setFixedHeight(1) - tab_separator.setStyleSheet("background-color: #567781;") - self.grid.addWidget(tab_separator, 9, 0, 1, 14) - self.grid.addWidget(self.video_options, 10, 0, 10, 14) - else: - self.grid.addWidget(self.video_options, 9, 0, 10, 14) + self.grid.addWidget(self.video_options, 9, 0, 10, 14) self.grid.setSpacing(5) self.paused = False @@ -559,9 +551,7 @@ def init_video_area(self): 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( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + self.widgets.output_type_combo.setStyleSheet(get_onyx_combobox_style()) self.widgets.output_type_combo.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) output_layout.addWidget(self.widgets.output_type_combo) @@ -628,9 +618,7 @@ def init_options_tabs(self): self.widgets.resolution_drop_down.addItems(list(resolutions.keys())) self.widgets.resolution_drop_down.currentIndexChanged.connect(self.update_resolution) if self.app.fastflix.config.theme == "onyx": - self.widgets.resolution_drop_down.setStyleSheet( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + self.widgets.resolution_drop_down.setStyleSheet(get_onyx_combobox_style()) res_row.addWidget(self.widgets.resolution_drop_down) self.widgets.resolution_custom = QtWidgets.QLineEdit() @@ -675,9 +663,7 @@ def init_options_tabs(self): time_reset.setToolTip(t("Reset start and end times")) time_reset.clicked.connect(self.reset_time) if self.app.fastflix.config.theme == "onyx": - time_reset.setStyleSheet( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + time_reset.setStyleSheet(get_onyx_button_style()) self.buttons.append(time_reset) self.widgets.fast_time = QtWidgets.QComboBox() @@ -685,9 +671,7 @@ def init_options_tabs(self): self.widgets.fast_time.setCurrentIndex(0) self.widgets.fast_time.setFixedHeight(scaler.scale(22)) if self.app.fastflix.config.theme == "onyx": - self.widgets.fast_time.setStyleSheet( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + self.widgets.fast_time.setStyleSheet(get_onyx_combobox_style()) self.widgets.fast_time.setToolTip( t( "uses [fast] seek to a rough position ahead of timestamp, " @@ -744,18 +728,14 @@ def init_options_tabs(self): 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( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + auto_crop.setStyleSheet(get_onyx_button_style()) self.buttons.append(auto_crop) reset = QtWidgets.QPushButton(t("Reset")) reset.setFixedHeight(scaler.scale(22)) reset.setToolTip(t("Reset crop")) reset.clicked.connect(self.reset_crop) if self.app.fastflix.config.theme == "onyx": - reset.setStyleSheet( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + reset.setStyleSheet(get_onyx_button_style()) self.buttons.append(reset) col1.addWidget(auto_crop) col1.addWidget(reset) @@ -982,9 +962,7 @@ def init_flip(self): self.widgets.flip.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) self.widgets.flip.currentIndexChanged.connect(lambda: self.page_update()) if self.app.fastflix.config.theme == "onyx": - self.widgets.flip.setStyleSheet( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + self.widgets.flip.setStyleSheet(get_onyx_combobox_style()) return self.widgets.flip def get_flips(self) -> Tuple[bool, bool]: @@ -1023,9 +1001,7 @@ def init_rotate(self): self.widgets.rotate.setIconSize(scaler.scale_size(ICONS.MEDIUM, ICONS.MEDIUM)) self.widgets.rotate.currentIndexChanged.connect(lambda: self.page_update()) if self.app.fastflix.config.theme == "onyx": - self.widgets.rotate.setStyleSheet( - "background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px;" - ) + self.widgets.rotate.setStyleSheet(get_onyx_combobox_style()) return self.widgets.rotate def change_output_types(self): @@ -1220,7 +1196,7 @@ def _update_scaled_sizes(self): ) border_width = scaler.scale(2) margin = scaler.scale(7) - self.setStyleSheet(f"border: {border_width}px solid #567781; margin: {margin}px;") + self.setStyleSheet(f"border: {border_width}px solid {ONYX_COLORS['primary']}; margin: {margin}px;") def _on_scale_changed(self, factors): """Called when scale factors change.""" @@ -1563,18 +1539,11 @@ def build_crop(self) -> Union[Crop, None]: self.widgets.crop.right.setStyleSheet("color: red") # error_message(f"{t('Invalid Crop')}: {err}") return None - self.widgets.crop.left.setStyleSheet( - "color: black" if self.app.fastflix.config.theme != "dark" else "color: white" - ) - self.widgets.crop.right.setStyleSheet( - "color: black" if self.app.fastflix.config.theme != "dark" else "color: white" - ) - self.widgets.crop.top.setStyleSheet( - "color: black" if self.app.fastflix.config.theme != "dark" else "color: white" - ) - self.widgets.crop.bottom.setStyleSheet( - "color: black" if self.app.fastflix.config.theme != "dark" else "color: white" - ) + crop_text_color = "color: white" if self.app.fastflix.config.theme in ("dark", "onyx") else "color: black" + self.widgets.crop.left.setStyleSheet(crop_text_color) + self.widgets.crop.right.setStyleSheet(crop_text_color) + self.widgets.crop.top.setStyleSheet(crop_text_color) + self.widgets.crop.bottom.setStyleSheet(crop_text_color) return crop def disable_all(self): diff --git a/fastflix/widgets/panels/advanced_panel.py b/fastflix/widgets/panels/advanced_panel.py index 8caa92d0..b998357f 100644 --- a/fastflix/widgets/panels/advanced_panel.py +++ b/fastflix/widgets/panels/advanced_panel.py @@ -11,6 +11,7 @@ from fastflix.models.video import VideoSettings from fastflix.resources import get_icon from fastflix.models.profiles import AdvancedOptions +from fastflix.ui_styles import get_onyx_label_style from fastflix.flix import ffmpeg_valid_color_primaries, ffmpeg_valid_color_transfers, ffmpeg_valid_color_space logger = logging.getLogger("fastflix") @@ -138,7 +139,7 @@ def add_row_label(self, label, row_number): label = QtWidgets.QLabel(label) label.setFixedWidth(100) if self.app.fastflix.config.theme == "onyx": - label.setStyleSheet("color: #b5b5b5") + label.setStyleSheet(get_onyx_label_style(muted=True)) self.layout.addWidget(label, row_number, 0, alignment=QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) def init_fps(self): @@ -356,7 +357,7 @@ def init_hw_message(self): self.last_row += 1 label = QtWidgets.QLabel("ʘ " + t("Not supported by rigaya's hardware encoders")) if self.app.fastflix.config.theme == "onyx": - label.setStyleSheet("color: #b5b5b5") + label.setStyleSheet(get_onyx_label_style(muted=True)) self.layout.addWidget(label, self.last_row, 0, 1, 2) diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index 5a33a291..5aedbe85 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -14,6 +14,7 @@ from fastflix.resources import get_icon from fastflix.ui_scale import scaler from fastflix.ui_constants import HEIGHTS, WIDTHS +from fastflix.ui_styles import get_onyx_disposition_style from fastflix.shared import no_border, error_message, yes_no_message, clear_list from fastflix.widgets.panels.abstract_list import FlixList from fastflix.audio_processing import apply_audio_filters @@ -341,18 +342,18 @@ def update_track(self, conversion=None, bitrate=None, downmix=None, title=None): def check_conversion_button(self): audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[self.index] if audio_track.conversion_codec: - self.widgets.conversion.setStyleSheet("border-color: #4a555e; background-color: #4a555e") + self.widgets.conversion.setStyleSheet(get_onyx_disposition_style(enabled=True)) self.widgets.conversion.setText(t("Conversion") + f": {audio_track.conversion_codec}") else: - self.widgets.conversion.setStyleSheet("") + self.widgets.conversion.setStyleSheet(get_onyx_disposition_style(enabled=False)) self.widgets.conversion.setText(t("Conversion")) def check_dis_button(self): audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[self.index] if any(audio_track.dispositions.values()): - self.widgets.disposition.setStyleSheet("border-color: #4a555e; background-color: #4a555e") + self.widgets.disposition.setStyleSheet(get_onyx_disposition_style(enabled=True)) else: - self.widgets.disposition.setStyleSheet("") + self.widgets.disposition.setStyleSheet(get_onyx_disposition_style(enabled=False)) class AudioList(FlixList): diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index 2ec8a0ee..53bb02cb 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -13,6 +13,7 @@ from fastflix.resources import loading_movie, get_icon from fastflix.shared import error_message, no_border, clear_list from fastflix.ui_scale import scaler +from fastflix.ui_styles import get_onyx_disposition_style from fastflix.widgets.background_tasks import ExtractSubtitleSRT from fastflix.widgets.panels.abstract_list import FlixList from fastflix.widgets.windows.disposition import Disposition @@ -262,9 +263,9 @@ def page_update(self): def check_dis_button(self): track: SubtitleTrack = self.app.fastflix.current_video.subtitle_tracks[self.index] if any(track.dispositions.values()): - self.widgets.disposition.setStyleSheet("border-color: #4a555e; background-color: #4a555e") + self.widgets.disposition.setStyleSheet(get_onyx_disposition_style(enabled=True)) else: - self.widgets.disposition.setStyleSheet("") + self.widgets.disposition.setStyleSheet(get_onyx_disposition_style(enabled=False)) class SubtitleList(FlixList): diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py index 26d1718f..d35d1555 100644 --- a/fastflix/widgets/video_options.py +++ b/fastflix/widgets/video_options.py @@ -4,12 +4,13 @@ import logging from typing import TYPE_CHECKING -from PySide6 import QtGui, QtWidgets +from PySide6 import QtCore, QtGui, QtWidgets from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp from fastflix.resources import get_icon from fastflix.ui_scale import scaler +from fastflix.ui_styles import ONYX_COLORS, get_onyx_combobox_style from fastflix.shared import DEVMODE, error_message from fastflix.widgets.panels.advanced_panel import AdvancedPanel from fastflix.widgets.panels.audio_panel import AudioList @@ -64,13 +65,13 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): self.setStyleSheet( "QTabBar{ font-size: 13px; } " "QTabBar::tab{ border-top: 2px solid transparent; } " - "QTabBar::tab:selected{ border-top: 2px solid #567781; } " + f"QTabBar::tab:selected{{ border-top: 2px solid {ONYX_COLORS['primary']}; }} " "QLineEdit{ color: white; } " "QTextEdit{ color: white; } " "QPlainTextEdit{ color: white; } " - "QComboBox{ min-height: 1.1em; background-color: #4a555e; color: white; border: 1px solid #4a555e; border-radius: 0px; }" + f"QComboBox{{ min-height: 1.1em; {get_onyx_combobox_style()} }}" "QComboBox:hover{ background-color: #6a8a96; } " - "QComboBox QAbstractItemView{ background-color: #1d2023; border: 2px solid #4a555e; } " + f"QComboBox QAbstractItemView{{ background-color: {ONYX_COLORS['dark_bg']}; border: 2px solid {ONYX_COLORS['input_bg']}; }} " ) self.setIconSize(scaler.scale_size(20, 20)) @@ -92,6 +93,23 @@ def __init__(self, parent, app: FastFlixApp, available_audio_encoders): if DEVMODE: self.addTab(self.debug, QtGui.QIcon(get_icon("info", app.fastflix.config.theme)), "Debug") + # Add separator line below tabs for onyx theme + self.tab_separator = None + if self.app.fastflix.config.theme == "onyx": + self.tab_separator = QtWidgets.QFrame(self) + self.tab_separator.setFrameShape(QtWidgets.QFrame.HLine) + self.tab_separator.setFixedHeight(3) + self.tab_separator.setStyleSheet(f"background-color: {ONYX_COLORS['primary']};") + self.tab_separator.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) + self.tab_separator.raise_() + + def resizeEvent(self, event): + super().resizeEvent(event) + if self.tab_separator: + # Position the separator right below the tab bar + tab_bar_height = self.tabBar().height() + self.tab_separator.setGeometry(0, tab_bar_height, self.width(), 3) + def resetTabIcons(self): for index, icon_name in icons.items(): self.setTabIcon(index, QtGui.QIcon(get_icon(icon_name, self.app.fastflix.config.theme))) From 124e4e965df8beb9cd11991f2d298f4f2b794548 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 4 Feb 2026 22:36:05 -0600 Subject: [PATCH 13/25] Beta Version 1 --- fastflix/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastflix/version.py b/fastflix/version.py index e8760103..5ea3618e 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "5.13.0" +__version__ = "5.13.0b1" __author__ = "Chris Griffith" From f7323b05dae48e5663388650294bdab649716c7e Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 4 Feb 2026 22:45:07 -0600 Subject: [PATCH 14/25] Fix CI issues --- scripts/get_version.py | 24 +++++++++++++++++++++++- tests/test_pyside6_fixes.py | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/scripts/get_version.py b/scripts/get_version.py index d6e64425..dd242ef7 100644 --- a/scripts/get_version.py +++ b/scripts/get_version.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import os +import re import sys from datetime import datetime as dt @@ -17,12 +18,33 @@ def write_and_exit(msg): sys.exit(0) +def get_nsis_version(version: str) -> str: + """ + Convert a PEP 440 version string to NSIS-compatible X.X.X.X format. + + NSIS VIProductVersion/VIFileVersion require exactly 4 numeric components. + Examples: + 5.13.0 -> 5.13.0.0 + 5.13.0b1 -> 5.13.0.1 + 5.13.0a2 -> 5.13.0.2 + 5.13.0rc3 -> 5.13.0.3 + """ + # Match: major.minor.patch followed by optional pre-release (a/b/rc + number) + match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:(?:a|b|rc)(\d+))?", version) + if match: + major, minor, patch, prerelease = match.groups() + prerelease_num = prerelease if prerelease else "0" + return f"{major}.{minor}.{patch}.{prerelease_num}" + # Fallback: just append .0 + return f"{version}.0" + + if __name__ == "__main__": if len(sys.argv) > 1: if sys.argv[1] == "exact": write_and_exit(__version__) elif sys.argv[1] == "nsis": - write_and_exit(f"{__version__}.0") + write_and_exit(get_nsis_version(__version__)) branch = os.getenv("GITHUB_REF").rsplit("/", 1)[1] diff --git a/tests/test_pyside6_fixes.py b/tests/test_pyside6_fixes.py index ea2125ce..c9a0e4d8 100644 --- a/tests/test_pyside6_fixes.py +++ b/tests/test_pyside6_fixes.py @@ -7,6 +7,7 @@ """ import ast +import os import sys from pathlib import Path @@ -14,9 +15,27 @@ from PySide6 import QtWidgets +def _can_create_qapp() -> bool: + """Check if we can create a QApplication (requires display on Linux).""" + # On Linux, Qt requires a display server + if sys.platform == "linux" and not os.environ.get("DISPLAY"): + return False + return True + + +# Skip tests requiring display when in headless environment +requires_display = pytest.mark.skipif( + not _can_create_qapp(), + reason="Test requires display server (set DISPLAY env var or use xvfb)", +) + + @pytest.fixture(scope="module") def qapp(): """Create a QApplication instance for tests that need Qt widgets.""" + if not _can_create_qapp(): + pytest.skip("Cannot create QApplication in headless environment") + app = QtWidgets.QApplication.instance() if app is None: app = QtWidgets.QApplication(sys.argv) @@ -39,6 +58,7 @@ def test_no_exec_underscore_in_widgets(self): # Check for .exec_() pattern - the deprecated method assert ".exec_()" not in content, f"Found deprecated exec_() in {py_file}" + @requires_display def test_exec_method_exists_on_qdialog(self, qapp): """Verify QMessageBox.exec() method exists (not exec_()).""" box = QtWidgets.QMessageBox() From 57701f9158b7eff22e6ee5c72a0ea18c0c02fffa Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 5 Feb 2026 19:48:16 -0600 Subject: [PATCH 15/25] * Fixing #695 Qt6 decimal input fields enforcing locale-specific decimal separators (comma vs dot) by forcing C locale * Fixing #696 RAW command display boxes too small on macOS by using actual document size for height calculation * Fixing #687 dialogs staying on top of all windows and unable to minimize by removing WindowStaysOnTopHint flag * Fixing #337 double quotes and backslashes in titles causing command issues by properly escaping special characters * Fixing #481 MP4 subtitle copy mode using wrong codec - now uses mov_text instead of copy for MP4 containers --- CHANGES | 7 ++ fastflix/encoders/common/audio.py | 7 +- fastflix/encoders/common/helpers.py | 25 +++- fastflix/encoders/common/setting_panel.py | 6 +- fastflix/encoders/common/subtitles.py | 17 ++- fastflix/encoders/modify/command_builder.py | 6 +- fastflix/shared.py | 8 +- fastflix/widgets/panels/advanced_panel.py | 12 +- fastflix/widgets/panels/command_panel.py | 22 ++-- fastflix/widgets/windows/audio_conversion.py | 6 +- fastflix/widgets/windows/concat.py | 120 ++++++++++++++++++- 11 files changed, 204 insertions(+), 32 deletions(-) diff --git a/CHANGES b/CHANGES index a6453a93..721fa869 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,7 @@ ## Version 5.13.0 +* Adding resizable columns to Concatenation Builder window with minimum widths based on header text * Adding Keyframe checkbox to Settings menu as "Use keyframes for preview images" option (default enabled) * Adding preview time slider to overlay at bottom of preview image * Adding time display next to preview slider in H:MM:SS format @@ -11,6 +12,11 @@ * Adding file-based locking for queue operations to prevent race conditions between instances * Adding graceful shutdown handling for worker process and background threads * Adding Start/End Time tab to right-side options panel between Size and Crop tabs with compact 3-column layout +* Fixing #695 Qt6 decimal input fields enforcing locale-specific decimal separators (comma vs dot) by forcing C locale +* Fixing #696 RAW command display boxes too small on macOS by using actual document size for height calculation +* Fixing #687 dialogs staying on top of all windows and unable to minimize by removing WindowStaysOnTopHint flag +* Fixing #337 double quotes and backslashes in titles causing command issues by properly escaping special characters and using shell=True on Windows to avoid shlex parsing +* Fixing #481 MP4 subtitle copy mode using wrong codec - now uses mov_text instead of copy for MP4 containers * Fixing video track selector showing unnecessarily when source video has only one video track * Fixing visual border between filename area and video track selector * Fixing test suite hanging due to missing QApplication in PySide6 widget tests @@ -29,6 +35,7 @@ * Fixing AttributeError crash when audio track metadata is incomplete * Fixing queue file generation mismatch errors due to redundant save calls on startup and when adding to queue + ## Version 5.12.4 * Fixing #675 "Default Source Folder" not used when adding Complete Folders (thanks to Krawk) diff --git a/fastflix/encoders/common/audio.py b/fastflix/encoders/common/audio.py index a9e45c2b..4feb8dd6 100644 --- a/fastflix/encoders/common/audio.py +++ b/fastflix/encoders/common/audio.py @@ -57,10 +57,13 @@ def build_audio(audio_tracks, audio_file_index=0): if not track.enabled: continue if track.title: + from fastflix.encoders.common.helpers import escape_title + + escaped_title = escape_title(track.title) command_list.append( f"-map {audio_file_index}:{track.index} " - f'-metadata:s:{track.outdex} title="{track.title}" ' - f'-metadata:s:{track.outdex} handler="{track.title}"' + f'-metadata:s:{track.outdex} title="{escaped_title}" ' + f'-metadata:s:{track.outdex} handler="{escaped_title}"' ) else: # No title - clear any existing title metadata diff --git a/fastflix/encoders/common/helpers.py b/fastflix/encoders/common/helpers.py index 2279b369..f72468bf 100644 --- a/fastflix/encoders/common/helpers.py +++ b/fastflix/encoders/common/helpers.py @@ -13,6 +13,23 @@ from fastflix.shared import clean_file_string, sanitize, quoted_path null = "/dev/null" + + +def escape_title(title: str) -> str: + """Escape special characters in titles for FFmpeg commands. + + Args: + title: The title string to escape + + Returns: + Escaped title string safe for use in FFmpeg command + """ + if not title: + return title + # Escape double quotes and backslashes for proper command line handling + return title.replace("\\", "\\\\").replace('"', '\\"') + + if reusables.win_based: null = "NUL" @@ -65,12 +82,12 @@ def generate_ffmpeg_start( vsync_text = f"-{vsync_type} {vsync}" if vsync else "" if video_title: - video_title = video_title.replace('"', '\\"') + video_title = escape_title(video_title) title = f'-metadata title="{video_title}"' if video_title else "" source = clean_file_string(source) ffmpeg = clean_file_string(ffmpeg) if video_track_title: - video_track_title = video_track_title.replace('"', '\\"') + video_track_title = escape_title(video_track_title) track_title = f'-metadata:s:v:0 title="{video_track_title}"' if video_track_title else "" opencl_option = "-init_hw_device opencl:0.0=ocl -filter_hw_device ocl" if enable_opencl and remove_hdr else "" @@ -282,7 +299,9 @@ def generate_all( subtitles_cmd, burn_in_track, burn_in_type = "", None, None if subs: - subtitles_cmd, burn_in_track, burn_in_type = build_subtitle(fastflix.current_video.subtitle_tracks) + subtitles_cmd, burn_in_track, burn_in_type = build_subtitle( + fastflix.current_video.subtitle_tracks, output_path=fastflix.current_video.video_settings.output_path + ) if burn_in_type == "text": for i, x in enumerate(fastflix.current_video.streams["subtitle"]): if x["index"] == burn_in_track: diff --git a/fastflix/encoders/common/setting_panel.py b/fastflix/encoders/common/setting_panel.py index 6bc86453..92bfc9e3 100644 --- a/fastflix/encoders/common/setting_panel.py +++ b/fastflix/encoders/common/setting_panel.py @@ -4,7 +4,7 @@ from pathlib import Path from box import Box -from PySide6 import QtGui, QtWidgets +from PySide6 import QtGui, QtWidgets, QtCore from fastflix.exceptions import FastFlixInternalException from fastflix.language import t @@ -432,7 +432,9 @@ def _add_modes( if not disable_custom_qp: self.widgets[f"custom_{qp_name}"] = QtWidgets.QLineEdit("30" if not custom_qp else str(qp_value)) self.widgets[f"custom_{qp_name}"].setMinimumWidth(scaler.scale(83)) - self.widgets[f"custom_{qp_name}"].setValidator(QtGui.QDoubleValidator()) + qp_validator = QtGui.QDoubleValidator() + qp_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator + self.widgets[f"custom_{qp_name}"].setValidator(qp_validator) self.widgets[f"custom_{qp_name}"].setEnabled(custom_qp) self.widgets[f"custom_{qp_name}"].textChanged.connect(lambda: self.main.build_commands()) diff --git a/fastflix/encoders/common/subtitles.py b/fastflix/encoders/common/subtitles.py index ee4bc53f..40030934 100644 --- a/fastflix/encoders/common/subtitles.py +++ b/fastflix/encoders/common/subtitles.py @@ -1,18 +1,29 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from pathlib import Path from typing import Tuple, Union from fastflix.models.video import SubtitleTrack def build_subtitle( - subtitle_tracks: list[SubtitleTrack], subtitle_file_index=0 + subtitle_tracks: list[SubtitleTrack], subtitle_file_index=0, output_path=None ) -> Tuple[str, Union[int, None], Union[str, None]]: command_list = [] burn_in_track = None burn_in_type = None subs_enabled = False + + # Determine if output is MP4 format (requires mov_text codec for subtitles) + is_mp4 = False + if output_path: + try: + ext = Path(output_path).suffix.lower() + is_mp4 = ext in (".mp4", ".m4v") + except Exception: + pass + for track in subtitle_tracks: if not track.enabled: continue @@ -21,7 +32,9 @@ def build_subtitle( burn_in_type = track.subtitle_type else: outdex = track.outdex - (1 if burn_in_track else 0) - command_list.append(f"-map {subtitle_file_index}:{track.index} -c:{outdex} copy ") + # MP4 containers require mov_text codec for text subtitles instead of copy (#481) + codec = "mov_text" if is_mp4 else "copy" + command_list.append(f"-map {subtitle_file_index}:{track.index} -c:{outdex} {codec} ") added = "" for disposition, is_set in track.dispositions.items(): if is_set: diff --git a/fastflix/encoders/modify/command_builder.py b/fastflix/encoders/modify/command_builder.py index 3c8d4b19..50d71dea 100644 --- a/fastflix/encoders/modify/command_builder.py +++ b/fastflix/encoders/modify/command_builder.py @@ -11,13 +11,15 @@ def build(fastflix: FastFlix): ffmpeg = fastflix.config.ffmpeg source = fastflix.current_video.source + from fastflix.encoders.common.helpers import escape_title + if video_title: - video_title = video_title.replace('"', '\\"') + video_title = escape_title(video_title) title = f'-metadata title="{video_title}"' if video_title else "" source = clean_file_string(source) ffmpeg = clean_file_string(ffmpeg) if video_track_title: - video_track_title = video_track_title.replace('"', '\\"') + video_track_title = escape_title(video_track_title) track_title = f'-metadata:s:v:0 title="{video_track_title}"' beginning = " ".join( diff --git a/fastflix/shared.py b/fastflix/shared.py index 8e9cd753..8afaa7a9 100644 --- a/fastflix/shared.py +++ b/fastflix/shared.py @@ -25,7 +25,7 @@ base_path = os.path.abspath(".") pyinstaller = False -from PySide6 import QtCore, QtGui, QtWidgets +from PySide6 import QtGui, QtWidgets from fastflix.language import t from fastflix.resources import get_bool_env @@ -72,7 +72,7 @@ def message(msg, title=None): sm = QtWidgets.QMessageBox() sm.setStyleSheet("font-size: 14px") sm.setText(msg) - sm.setWindowFlags(sm.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) + # Removed WindowStaysOnTopHint to allow minimizing dialog (#687) if title: sm.setWindowTitle(title) sm.setStandardButtons(QtWidgets.QMessageBox.Ok) @@ -85,7 +85,7 @@ def error_message(msg, details=None, traceback=False, title=None): em.setStyleSheet("font-size: 14px") em.setText(msg) em.setWindowIcon(QtGui.QIcon(my_data)) - em.setWindowFlags(em.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) + # Removed WindowStaysOnTopHint to allow minimizing dialog (#687) if title: em.setWindowTitle(title) if details: @@ -105,7 +105,7 @@ def yes_no_message(msg, title=None, yes_text=t("Yes"), no_text=t("No"), yes_acti sm.setText(msg) sm.addButton(yes_text, QtWidgets.QMessageBox.YesRole) sm.addButton(no_text, QtWidgets.QMessageBox.NoRole) - sm.setWindowFlags(sm.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) + # Removed WindowStaysOnTopHint to allow minimizing dialog (#687) sm.exec_() if sm.clickedButton().text() == yes_text: if yes_action: diff --git a/fastflix/widgets/panels/advanced_panel.py b/fastflix/widgets/panels/advanced_panel.py index b998357f..e059976d 100644 --- a/fastflix/widgets/panels/advanced_panel.py +++ b/fastflix/widgets/panels/advanced_panel.py @@ -229,17 +229,23 @@ def init_video_speed(self): def init_eq(self): self.last_row += 1 self.brightness_widget = QtWidgets.QLineEdit() - self.brightness_widget.setValidator(QtGui.QDoubleValidator()) + brightness_validator = QtGui.QDoubleValidator() + brightness_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator + self.brightness_widget.setValidator(brightness_validator) self.brightness_widget.setToolTip("Default is: 0") self.brightness_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) self.contrast_widget = QtWidgets.QLineEdit() - self.contrast_widget.setValidator(QtGui.QDoubleValidator()) + contrast_validator = QtGui.QDoubleValidator() + contrast_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator + self.contrast_widget.setValidator(contrast_validator) self.contrast_widget.setToolTip("Default is: 1") self.contrast_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) self.saturation_widget = QtWidgets.QLineEdit() - self.saturation_widget.setValidator(QtGui.QDoubleValidator()) + saturation_validator = QtGui.QDoubleValidator() + saturation_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator + self.saturation_widget.setValidator(saturation_validator) self.saturation_widget.setToolTip("Default is: 1") self.saturation_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) diff --git a/fastflix/widgets/panels/command_panel.py b/fastflix/widgets/panels/command_panel.py index 5b42dfee..81a2cd21 100644 --- a/fastflix/widgets/panels/command_panel.py +++ b/fastflix/widgets/panels/command_panel.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import math from pathlib import Path import reusables @@ -34,12 +33,7 @@ def __init__(self, parent, command, number, name="", enabled=True, height=None): self.command = command self.widget = QtWidgets.QTextBrowser() self.widget.setReadOnly(True) - if not height: - font_height = QtGui.QFontMetrics(self.widget.document().defaultFont()).height() - lines = math.ceil(len(command) / 200) - self.setMinimumHeight(int(font_height + ((lines + 2) * (font_height * 1.25)))) - else: - self.setMinimumHeight(height) + self.custom_height = height self.number = number self.name = name self.label = QtWidgets.QLabel(f"{t('Command')} {self.number}" if not self.name else self.name) @@ -55,6 +49,20 @@ def update_grid(self): self.setLayout(grid) self.widget.setText(self.command) + # Calculate height after setting text for accurate sizing + if not self.custom_height: + # Get the document size which accounts for actual text wrapping + doc_size = self.widget.document().size() + label_height = self.label.sizeHint().height() + # Add padding (30px) for margins and scrollbar if needed + required_height = int(doc_size.height() + label_height + 30) + # Set a reasonable minimum and maximum + min_height = 100 + max_height = 500 + self.setMinimumHeight(max(min_height, min(required_height, max_height))) + else: + self.setMinimumHeight(self.custom_height) + class CommandList(QtWidgets.QWidget): def __init__(self, parent, app: FastFlixApp): diff --git a/fastflix/widgets/windows/audio_conversion.py b/fastflix/widgets/windows/audio_conversion.py index 2ab2eab3..93d0e8b2 100644 --- a/fastflix/widgets/windows/audio_conversion.py +++ b/fastflix/widgets/windows/audio_conversion.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import logging -from PySide6 import QtWidgets, QtGui +from PySide6 import QtWidgets, QtGui, QtCore from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.encode import AudioTrack @@ -130,7 +130,9 @@ def __init__(self, app: FastFlixApp, track_index, encoders, audio_track_update): self.aq.currentIndexChanged.connect(self.set_aq) self.bitrate = QtWidgets.QLineEdit() self.bitrate.setFixedWidth(50) - self.bitrate.setValidator(QtGui.QDoubleValidator()) + bitrate_validator = QtGui.QDoubleValidator() + bitrate_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator + self.bitrate.setValidator(bitrate_validator) if self.audio_track.conversion_aq: self.aq.setCurrentIndex(self.audio_track.conversion_aq) diff --git a/fastflix/widgets/windows/concat.py b/fastflix/widgets/windows/concat.py index 0762295a..73849272 100644 --- a/fastflix/widgets/windows/concat.py +++ b/fastflix/widgets/windows/concat.py @@ -40,8 +40,14 @@ class ConcatTable(QtWidgets.QTableView): def __init__(self, parent): super().__init__(parent) self.verticalHeader().hide() - # self.horizontalHeader().hide() - self.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.header_labels = ["Filename", "Resolution", "Codec", "Remove"] + self.min_column_widths = [] + + header = self.horizontalHeader() + header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setStretchLastSection(False) + header.sectionResized.connect(self._on_section_resized) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setShowGrid(False) @@ -50,14 +56,110 @@ def __init__(self, parent): # Set our custom model - this prevents row "shifting" self.model = MyModel() - self.model.setHorizontalHeaderLabels(["Filename", "Resolution", "Codec", "Remove"]) + self.model.setHorizontalHeaderLabels(self.header_labels) self.setModel(self.model) self.buttons = [] + self._resizing = False + + def _calculate_min_widths(self): + """Calculate minimum column widths based on header text.""" + font_metrics = self.horizontalHeader().fontMetrics() + padding = 20 # Extra padding for header margins + self.min_column_widths = [font_metrics.horizontalAdvance(label) + padding for label in self.header_labels] + + def _on_section_resized(self, logical_index, old_size, new_size): + """Enforce column width constraints when a section is resized.""" + if self._resizing: + return + + self._resizing = True + try: + if not self.min_column_widths: + self._calculate_min_widths() + + header = self.horizontalHeader() + num_cols = len(self.header_labels) + viewport_width = self.viewport().width() + + # Enforce minimum width for the resized column + if new_size < self.min_column_widths[logical_index]: + header.resizeSection(logical_index, self.min_column_widths[logical_index]) + return + + # Calculate total width of all columns and ensure they fit in viewport + total_width = sum(header.sectionSize(i) for i in range(num_cols)) + + if total_width > viewport_width: + # Column was made too wide, reduce it to fit + excess = total_width - viewport_width + max_allowed = new_size - excess + if max_allowed >= self.min_column_widths[logical_index]: + header.resizeSection(logical_index, max_allowed) + else: + # Can't shrink this column enough, revert to old size + header.resizeSection(logical_index, old_size) + finally: + self._resizing = False + + def set_column_widths(self, total_width): + """Set column widths with Filename taking 50% of total width.""" + self._calculate_min_widths() + + filename_width = int(total_width * 0.5) + resolution_width = int(total_width * 0.17) + codec_width = int(total_width * 0.17) + remove_width = int(total_width * 0.16) + + # Ensure widths are at least the minimum + filename_width = max(filename_width, self.min_column_widths[0]) + resolution_width = max(resolution_width, self.min_column_widths[1]) + codec_width = max(codec_width, self.min_column_widths[2]) + remove_width = max(remove_width, self.min_column_widths[3]) + + self.setColumnWidth(0, filename_width) + self.setColumnWidth(1, resolution_width) + self.setColumnWidth(2, codec_width) + self.setColumnWidth(3, remove_width) + + def resizeEvent(self, event): + """Adjust columns to fit when the table is resized.""" + super().resizeEvent(event) + if self._resizing or not self.min_column_widths: + return + + self._resizing = True + try: + header = self.horizontalHeader() + num_cols = len(self.header_labels) + viewport_width = self.viewport().width() + total_width = sum(header.sectionSize(i) for i in range(num_cols)) + + if total_width > viewport_width: + # Columns are too wide, shrink the filename column (index 0) first + excess = total_width - viewport_width + current_filename_width = header.sectionSize(0) + new_filename_width = current_filename_width - excess + + if new_filename_width >= self.min_column_widths[0]: + header.resizeSection(0, new_filename_width) + else: + # Filename at minimum, distribute reduction across other resizable columns + header.resizeSection(0, self.min_column_widths[0]) + remaining_excess = excess - (current_filename_width - self.min_column_widths[0]) + + # Shrink other columns proportionally + for i in range(1, num_cols): + current = header.sectionSize(i) + reduction = remaining_excess // (num_cols - 1) + new_width = max(current - reduction, self.min_column_widths[i]) + header.resizeSection(i, new_width) + finally: + self._resizing = False def update_items(self, items): self.model.clear() - self.model.setHorizontalHeaderLabels(["Filename", "Resolution", "Codec", "Remove"]) + self.model.setHorizontalHeaderLabels(self.header_labels) self.buttons = [] for item in items: self.add_item(*item) @@ -114,11 +216,19 @@ class ConcatScroll(QtWidgets.QScrollArea): def __init__(self, parent): super().__init__(parent) self.setWidgetResizable(True) - self.setMinimumWidth(500) + self.setMinimumWidth(750) self.setMinimumHeight(500) self.table = ConcatTable(None) self.setWidget(self.table) + def showEvent(self, event): + """Set initial column widths when the scroll area is first shown.""" + super().showEvent(event) + # Only set initial widths the first time the widget is shown + if not hasattr(self, "_initial_widths_set"): + self._initial_widths_set = True + self.table.set_column_widths(self.width()) + class ConcatWindow(QtWidgets.QWidget): def __init__(self, app, main, items=None): From 5e0cc5ffa89264a9495911fb7229a0f4758016f3 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 5 Feb 2026 22:36:45 -0600 Subject: [PATCH 16/25] * Fixing #337 #700 #597 refactoring all FFmpeg command building from string concatenation to List[str] to fix shlex.split() failures with quotes, special characters in titles, and Windows path handling issues --- CHANGES | 2 +- fastflix/command_runner.py | 8 +- fastflix/encoders/av1_aom/command_builder.py | 40 ++- fastflix/encoders/avc_x264/command_builder.py | 49 +++- fastflix/encoders/common/attachments.py | 19 +- fastflix/encoders/common/audio.py | 79 +++-- fastflix/encoders/common/encc_helpers.py | 107 +++---- fastflix/encoders/common/helpers.py | 216 ++++++++------ fastflix/encoders/common/subtitles.py | 16 +- fastflix/encoders/copy/command_builder.py | 16 +- .../ffmpeg_hevc_nvenc/command_builder.py | 69 ++++- fastflix/encoders/gif/command_builder.py | 38 +-- .../h264_videotoolbox/command_builder.py | 51 +++- .../hevc_videotoolbox/command_builder.py | 51 +++- .../encoders/hevc_x265/command_builder.py | 41 ++- fastflix/encoders/modify/command_builder.py | 63 ++-- .../encoders/nvencc_av1/command_builder.py | 267 ++++++++--------- .../encoders/nvencc_avc/command_builder.py | 217 +++++++------- .../encoders/nvencc_hevc/command_builder.py | 269 +++++++++--------- .../encoders/qsvencc_av1/command_builder.py | 249 +++++++++------- .../encoders/qsvencc_avc/command_builder.py | 224 +++++++++------ .../encoders/qsvencc_hevc/command_builder.py | 249 +++++++++------- fastflix/encoders/rav1e/command_builder.py | 44 ++- fastflix/encoders/svt_av1/command_builder.py | 37 ++- .../encoders/svt_av1_avif/command_builder.py | 12 +- .../encoders/vaapi_h264/command_builder.py | 46 ++- .../encoders/vaapi_hevc/command_builder.py | 46 ++- .../encoders/vaapi_mpeg2/command_builder.py | 39 ++- .../encoders/vaapi_vp9/command_builder.py | 39 ++- .../encoders/vceencc_av1/command_builder.py | 213 +++++++------- .../encoders/vceencc_avc/command_builder.py | 191 +++++++------ .../encoders/vceencc_hevc/command_builder.py | 216 +++++++------- fastflix/encoders/vp9/command_builder.py | 66 ++++- fastflix/encoders/vvc/command_builder.py | 39 ++- fastflix/encoders/webp/command_builder.py | 24 +- fastflix/flix.py | 9 +- fastflix/widgets/panels/command_panel.py | 18 +- tests/encoders/test_attachments.py | 82 ++++-- tests/encoders/test_audio.py | 52 ++-- .../encoders/test_avc_x264_command_builder.py | 117 ++++++-- tests/encoders/test_encc_helpers.py | 70 ++--- .../test_ffmpeg_hevc_nvenc_command_builder.py | 127 ++++++--- tests/encoders/test_helpers.py | 158 +++++++--- .../test_hevc_x265_command_builder.py | 138 ++++++--- tests/encoders/test_subtitles.py | 68 +++-- .../encoders/test_svt_av1_command_builder.py | 140 ++++++--- tests/test_audio.py | 4 +- 47 files changed, 2662 insertions(+), 1673 deletions(-) diff --git a/CHANGES b/CHANGES index 721fa869..6b98efb1 100644 --- a/CHANGES +++ b/CHANGES @@ -15,7 +15,7 @@ * Fixing #695 Qt6 decimal input fields enforcing locale-specific decimal separators (comma vs dot) by forcing C locale * Fixing #696 RAW command display boxes too small on macOS by using actual document size for height calculation * Fixing #687 dialogs staying on top of all windows and unable to minimize by removing WindowStaysOnTopHint flag -* Fixing #337 double quotes and backslashes in titles causing command issues by properly escaping special characters and using shell=True on Windows to avoid shlex parsing +* Fixing #337 #700 #597 refactoring all FFmpeg command building from string concatenation to List[str] to fix shlex.split() failures with quotes, special characters in titles, and Windows path handling issues * Fixing #481 MP4 subtitle copy mode using wrong codec - now uses mov_text instead of copy for MP4 containers * Fixing video track selector showing unnecessarily when source video has only one video track * Fixing visual border between filename area and video track selector diff --git a/fastflix/command_runner.py b/fastflix/command_runner.py index 2811a252..301eea63 100644 --- a/fastflix/command_runner.py +++ b/fastflix/command_runner.py @@ -79,8 +79,14 @@ def start_exec(self, command, work_dir: str = None, shell: bool = False, errors= try: stdout_handle = open(self.output_file, "w") stderr_handle = open(self.error_output_file, "w") + if isinstance(command, list): + popen_cmd = command + elif not shell: + popen_cmd = shlex.split(command.replace("\\", "\\\\")) + else: + popen_cmd = command self.process = Popen( - shlex.split(command.replace("\\", "\\\\")) if not shell and isinstance(command, str) else command, + popen_cmd, shell=shell, cwd=work_dir, stdout=stdout_handle, diff --git a/fastflix/encoders/av1_aom/command_builder.py b/fastflix/encoders/av1_aom/command_builder.py index 32287105..03129af4 100644 --- a/fastflix/encoders/av1_aom/command_builder.py +++ b/fastflix/encoders/av1_aom/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null from fastflix.models.encode import AOMAV1Settings @@ -10,28 +11,45 @@ def build(fastflix: FastFlix): settings: AOMAV1Settings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "libaom-av1") - beginning += ( - "-strict experimental " - f"-cpu-used {settings.cpu_used} " - f"-tile-rows {settings.tile_rows} " - f"-tile-columns {settings.tile_columns} " - f"-usage {settings.usage} " - f"{generate_color_details(fastflix)} " + beginning.extend( + [ + "-strict", + "experimental", + "-cpu-used", + str(settings.cpu_used), + "-tile-rows", + str(settings.tile_rows), + "-tile-columns", + str(settings.tile_columns), + "-usage", + settings.usage, + ] ) + beginning.extend(generate_color_details(fastflix)) if settings.row_mt.lower() == "enabled": - beginning += "-row-mt 1 " + beginning.extend(["-row-mt", "1"]) + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] if settings.bitrate: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" - command_1 = f'{beginning} -passlogfile "{pass_log_file}" -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ""} -an {output_fps} -f matroska {null}' + command_1 = ( + beginning + + ["-passlogfile", str(pass_log_file), "-b:v", settings.bitrate, "-pass", "1"] + + extra_both + + ["-an"] + + output_fps + + ["-f", "matroska", null] + ) command_2 = ( - f'{beginning} -passlogfile "{pass_log_file}" -b:v {settings.bitrate} -pass 2 {settings.extra} {ending}' + beginning + ["-passlogfile", str(pass_log_file), "-b:v", settings.bitrate, "-pass", "2"] + extra + ending ) return [ Command(command=command_1, name="First Pass bitrate"), Command(command=command_2, name="Second Pass bitrate"), ] elif settings.crf: - command_1 = f"{beginning} -b:v 0 -crf {settings.crf} {settings.extra} {ending}" + command_1 = beginning + ["-b:v", "0", "-crf", str(settings.crf)] + extra + ending return [Command(command=command_1, name="Single Pass CRF")] diff --git a/fastflix/encoders/avc_x264/command_builder.py b/fastflix/encoders/avc_x264/command_builder.py index 04b8f531..04df83fe 100644 --- a/fastflix/encoders/avc_x264/command_builder.py +++ b/fastflix/encoders/avc_x264/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null from fastflix.models.encode import x264Settings @@ -11,33 +12,63 @@ def build(fastflix: FastFlix): beginning, ending, output_fps = generate_all(fastflix, "libx264") - beginning += f"{f'-tune:v {settings.tune}' if settings.tune else ''} {generate_color_details(fastflix)} " + if settings.tune: + beginning.extend(["-tune:v", settings.tune]) + + beginning.extend(generate_color_details(fastflix)) if settings.profile and settings.profile != "default": - beginning += f"-profile:v {settings.profile} " + beginning.extend(["-profile:v", settings.profile]) pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + if settings.bitrate: if settings.bitrate_passes == 2: command_1 = ( - f"{beginning} -pass 1 " - f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra if settings.extra_both_passes else ""} -an -sn -dn {output_fps} -f mp4 {null}' + beginning + + [ + "-pass", + "1", + "-passlogfile", + str(pass_log_file), + "-b:v", + settings.bitrate, + "-preset:v", + settings.preset, + ] + + extra_both + + ["-an", "-sn", "-dn"] + + output_fps + + ["-f", "mp4", null] ) command_2 = ( - f'{beginning} -pass 2 -passlogfile "{pass_log_file}" ' - f"-b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} " - ) + ending + beginning + + [ + "-pass", + "2", + "-passlogfile", + str(pass_log_file), + "-b:v", + settings.bitrate, + "-preset:v", + settings.preset, + ] + + extra + + ending + ) return [ Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), ] else: - command = f"{beginning} -b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} {ending}" + command = beginning + ["-b:v", settings.bitrate, "-preset:v", settings.preset] + extra + ending return [Command(command=command, name="Single pass bitrate", exe="ffmpeg")] elif settings.crf: - command = f"{beginning} -crf:v {settings.crf} -preset:v {settings.preset} {settings.extra} {ending}" + command = beginning + ["-crf:v", str(settings.crf), "-preset:v", settings.preset] + extra + ending return [Command(command=command, name="Single pass CRF", exe="ffmpeg")] else: diff --git a/fastflix/encoders/common/attachments.py b/fastflix/encoders/common/attachments.py index a5b23cab..66979938 100644 --- a/fastflix/encoders/common/attachments.py +++ b/fastflix/encoders/common/attachments.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- from pathlib import Path +from typing import List from fastflix.models.encode import AttachmentTrack -from fastflix.shared import clean_file_string def image_type(file: Path | str) -> tuple[str | None, str | None]: @@ -16,14 +16,19 @@ def image_type(file: Path | str) -> tuple[str | None, str | None]: return mime_type, ext_type -def build_attachments(attachments: list[AttachmentTrack]) -> str: +def build_attachments(attachments: list[AttachmentTrack]) -> List[str]: commands = [] for attachment in attachments: if attachment.attachment_type == "cover": mime_type, ext_type = image_type(attachment.file_path) - clean_path = clean_file_string(attachment.file_path) - commands.append( - f' -attach "{clean_path}" -metadata:s:{attachment.outdex} mimetype="{mime_type}" ' - f'-metadata:s:{attachment.outdex} filename="{attachment.filename}.{ext_type}" ' + commands.extend( + [ + "-attach", + str(attachment.file_path), + f"-metadata:s:{attachment.outdex}", + f"mimetype={mime_type}", + f"-metadata:s:{attachment.outdex}", + f"filename={attachment.filename}.{ext_type}", + ] ) - return " ".join(commands) + return commands diff --git a/fastflix/encoders/common/audio.py b/fastflix/encoders/common/audio.py index 4feb8dd6..2fe8f039 100644 --- a/fastflix/encoders/common/audio.py +++ b/fastflix/encoders/common/audio.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- import logging +from typing import List logger = logging.getLogger("fastflix") @@ -51,32 +52,40 @@ def audio_quality_converter(quality, codec, channels=2, track_number=1): return f"-b:{track_number} {base * channels}k" -def build_audio(audio_tracks, audio_file_index=0): +def _split_quality(quality_str: str) -> List[str]: + """Split a quality string like '-vbr:1 on -b:1 120k' into list items.""" + return quality_str.split() + + +def build_audio(audio_tracks, audio_file_index=0) -> List[str]: command_list = [] + has_truehd = False + has_opus = False + has_dca = False + for track in audio_tracks: if not track.enabled: continue + command_list.extend(["-map", f"{audio_file_index}:{track.index}"]) if track.title: - from fastflix.encoders.common.helpers import escape_title - - escaped_title = escape_title(track.title) - command_list.append( - f"-map {audio_file_index}:{track.index} " - f'-metadata:s:{track.outdex} title="{escaped_title}" ' - f'-metadata:s:{track.outdex} handler="{escaped_title}"' - ) + command_list.extend([f"-metadata:s:{track.outdex}", f"title={track.title}"]) + command_list.extend([f"-metadata:s:{track.outdex}", f"handler={track.title}"]) else: - # No title - clear any existing title metadata - command_list.append( - f"-map {audio_file_index}:{track.index} " - f'-metadata:s:{track.outdex} title="" ' - f'-metadata:s:{track.outdex} handler=""' - ) + command_list.extend([f"-metadata:s:{track.outdex}", "title="]) + command_list.extend([f"-metadata:s:{track.outdex}", "handler="]) + if track.language: - command_list.append(f"-metadata:s:{track.outdex} language={track.language}") + command_list.extend([f"-metadata:s:{track.outdex}", f"language={track.language}"]) if not track.conversion_codec or track.conversion_codec == "none": - command_list.append(f"-c:{track.outdex} copy") + command_list.extend([f"-c:{track.outdex}", "copy"]) elif track.conversion_codec: + if track.conversion_codec == "truehd": + has_truehd = True + elif track.conversion_codec == "opus": + has_opus = True + elif track.conversion_codec == "dca": + has_dca = True + try: cl = track.downmix if track.downmix and track.downmix != "No Downmix" else track.raw_info.channel_layout except (AssertionError, KeyError, AttributeError): @@ -84,11 +93,13 @@ def build_audio(audio_tracks, audio_file_index=0): logger.warning("Could not determine channel layout, defaulting to stereo, please manually specify") downmix = ( - f"-ac:{track.outdex} {channel_list[cl]}" if track.downmix and track.downmix != "No Downmix" else "" + [f"-ac:{track.outdex}", str(channel_list[cl])] + if track.downmix and track.downmix != "No Downmix" + else [] ) - channel_layout = f'-filter:{track.outdex} "aformat=channel_layouts={cl}"' + channel_layout = [f"-filter:{track.outdex}", f"aformat=channel_layouts={cl}"] - bitrate = "" + bitrate_parts = [] if track.conversion_codec not in lossless: if track.conversion_bitrate: conversion_bitrate = ( @@ -96,14 +107,21 @@ def build_audio(audio_tracks, audio_file_index=0): if track.conversion_bitrate.lower().endswith(("k", "m", "g", "kb", "mb", "gb")) else f"{track.conversion_bitrate}k" ) - - bitrate = f"-b:{track.outdex} {conversion_bitrate}" + bitrate_parts = [f"-b:{track.outdex}", conversion_bitrate] else: - bitrate = audio_quality_converter( - track.conversion_aq or 0, track.conversion_codec, track.raw_info.get("channels"), track.outdex + bitrate_parts = _split_quality( + audio_quality_converter( + track.conversion_aq or 0, + track.conversion_codec, + track.raw_info.get("channels"), + track.outdex, + ) ) - command_list.append(f"-c:{track.outdex} {track.conversion_codec} {bitrate} {downmix} {channel_layout}") + command_list.extend([f"-c:{track.outdex}", track.conversion_codec]) + command_list.extend(bitrate_parts) + command_list.extend(downmix) + command_list.extend(channel_layout) if getattr(track, "dispositions", None): added = "" @@ -111,11 +129,10 @@ def build_audio(audio_tracks, audio_file_index=0): if is_set: added += f"{disposition}+" if added: - command_list.append(f"-disposition:{track.outdex} {added.rstrip('+')}") + command_list.extend([f"-disposition:{track.outdex}", added.rstrip("+")]) else: - command_list.append(f"-disposition:{track.outdex} 0") + command_list.extend([f"-disposition:{track.outdex}", "0"]) - end_command = " ".join(command_list) - if " truehd " in end_command or " opus " in end_command or " dca " in end_command: - end_command += " -strict -2 " - return end_command + if has_truehd or has_opus or has_dca: + command_list.extend(["-strict", "-2"]) + return command_list diff --git a/fastflix/encoders/common/encc_helpers.py b/fastflix/encoders/common/encc_helpers.py index b4082c5a..09ebec3f 100644 --- a/fastflix/encoders/common/encc_helpers.py +++ b/fastflix/encoders/common/encc_helpers.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +from typing import List from fastflix.models.video import SubtitleTrack, AudioTrack from fastflix.encoders.common.audio import lossless @@ -29,7 +30,7 @@ def audio_quality_converter(quality, codec, channels=2, track_number=1): return f" --audio-bitrate {track_number}?{base * channels}k " -def rigaya_avformat_reader(fastflix: FastFlix) -> str: +def rigaya_avformat_reader(fastflix: FastFlix) -> List[str]: # Avisynth reader avs # VapourSynth reader vpy # avi reader avi @@ -39,34 +40,36 @@ def rigaya_avformat_reader(fastflix: FastFlix) -> str: ending = fastflix.current_video.source.suffix if fastflix.current_video.video_settings.video_encoder_settings.decoder not in ("Hardware", "Software"): if ending.lower() in (".avs", ".vpy", ".avi", ".y4m", ".yuv"): - return "" - return "--avhw" if fastflix.current_video.video_settings.video_encoder_settings.decoder == "Hardware" else "--avsw" + return [] + return ( + ["--avhw"] if fastflix.current_video.video_settings.video_encoder_settings.decoder == "Hardware" else ["--avsw"] + ) -def rigaya_auto_options(fastflix: FastFlix) -> str: +def rigaya_auto_options(fastflix: FastFlix) -> List[str]: reader_format = rigaya_avformat_reader(fastflix) if not reader_format: - output = "" + output = [] if fastflix.current_video.video_settings.color_space: - output += f"--colormatrix {fastflix.current_video.video_settings.color_space} " + output.extend(["--colormatrix", fastflix.current_video.video_settings.color_space]) if fastflix.current_video.video_settings.color_transfer: - output += f"--transfer {fastflix.current_video.video_settings.color_transfer} " + output.extend(["--transfer", fastflix.current_video.video_settings.color_transfer]) if fastflix.current_video.video_settings.color_primaries: - output += f"--colorprim {fastflix.current_video.video_settings.color_primaries} " + output.extend(["--colorprim", fastflix.current_video.video_settings.color_primaries]) return output - return " ".join( - [ - "--chromaloc auto", - "--colorrange auto", - "--colormatrix", - (fastflix.current_video.video_settings.color_space or "auto"), - "--transfer", - (fastflix.current_video.video_settings.color_transfer or "auto"), - "--colorprim", - (fastflix.current_video.video_settings.color_primaries or "auto"), - ] - ) + return [ + "--chromaloc", + "auto", + "--colorrange", + "auto", + "--colormatrix", + (fastflix.current_video.video_settings.color_space or "auto"), + "--transfer", + (fastflix.current_video.video_settings.color_transfer or "auto"), + "--colorprim", + (fastflix.current_video.video_settings.color_primaries or "auto"), + ] def pa_builder(settings: VCEEncCAVCSettings | VCEEncCAV1Settings | VCEEncCSettings): @@ -99,9 +102,9 @@ def get_stream_pos(streams) -> dict: return {x.index: i for i, x in enumerate(streams, start=1)} -def build_audio(audio_tracks: list[AudioTrack], audio_streams): +def build_audio(audio_tracks: list[AudioTrack], audio_streams) -> List[str]: if not audio_tracks: - return "" + return [] command_list = [] copies = [] track_ids = set() @@ -115,14 +118,16 @@ def build_audio(audio_tracks: list[AudioTrack], audio_streams): track_ids.add(track.index) audio_id = stream_ids[track.index] if track.language: - command_list.append(f"--audio-metadata {audio_id}?language={track.language}") + command_list.extend(["--audio-metadata", f"{audio_id}?language={track.language}"]) if not track.conversion_codec or track.conversion_codec == "none": copies.append(str(audio_id)) elif track.conversion_codec: downmix = ( - f"--audio-stream {audio_id}?:{track.downmix}" if track.downmix and track.downmix != "No Downmix" else "" + ["--audio-stream", f"{audio_id}?:{track.downmix}"] + if track.downmix and track.downmix != "No Downmix" + else [] ) - bitrate = "" + bitrate_parts = [] if track.conversion_codec not in lossless: if track.conversion_bitrate: conversion_bitrate = ( @@ -130,41 +135,44 @@ def build_audio(audio_tracks: list[AudioTrack], audio_streams): if track.conversion_bitrate.lower().endswith(("k", "m", "g", "kb", "mb", "gb")) else f"{track.conversion_bitrate}k" ) - bitrate = f"--audio-bitrate {audio_id}?{conversion_bitrate} " + bitrate_parts = ["--audio-bitrate", f"{audio_id}?{conversion_bitrate}"] else: - bitrate = audio_quality_converter( + quality_str = audio_quality_converter( track.conversion_aq or 0, track.conversion_codec, track.raw_info.get("channels"), audio_id ) - command_list.append( - f"{downmix} --audio-codec {audio_id}?{track.conversion_codec} {bitrate} " - f"--audio-metadata {audio_id}?clear" - ) + bitrate_parts = quality_str.split() + command_list.extend(downmix) + command_list.extend(["--audio-codec", f"{audio_id}?{track.conversion_codec}"]) + command_list.extend(bitrate_parts) + command_list.extend(["--audio-metadata", f"{audio_id}?clear"]) if track.title: - command_list.append( - f'--audio-metadata {audio_id}?title="{track.title}" ' - f'--audio-metadata {audio_id}?handler="{track.title}" ' - ) + command_list.extend(["--audio-metadata", f"{audio_id}?title={track.title}"]) + command_list.extend(["--audio-metadata", f"{audio_id}?handler={track.title}"]) added = "" for disposition, is_set in track.dispositions.items(): if is_set: added += f"{disposition}," if added: - command_list.append(f"--audio-disposition {audio_id}?{added.rstrip(',')}") + command_list.extend(["--audio-disposition", f"{audio_id}?{added.rstrip(',')}"]) else: - command_list.append(f"--audio-disposition {audio_id}?unset") + command_list.extend(["--audio-disposition", f"{audio_id}?unset"]) if not command_list: - return "" - return f" --audio-copy {','.join(copies)} {' '.join(command_list)}" if copies else f" {' '.join(command_list)}" + return [] + result = [] + if copies: + result.extend(["--audio-copy", ",".join(copies)]) + result.extend(command_list) + return result -def build_subtitle(subtitle_tracks: list[SubtitleTrack], subtitle_streams, video_height: int) -> str: +def build_subtitle(subtitle_tracks: list[SubtitleTrack], subtitle_streams, video_height: int) -> List[str]: command_list = [] copies = [] stream_ids = get_stream_pos(subtitle_streams) if not subtitle_tracks: - return "" + return [] scale = ",scale=2.0" if video_height > 1800 else "" @@ -173,7 +181,7 @@ def build_subtitle(subtitle_tracks: list[SubtitleTrack], subtitle_streams, video continue sub_id = stream_ids[track.index] if track.burn_in: - command_list.append(f"--vpp-subburn track={sub_id}{scale}") + command_list.extend(["--vpp-subburn", f"track={sub_id}{scale}"]) else: copies.append(str(sub_id)) added = "" @@ -181,13 +189,16 @@ def build_subtitle(subtitle_tracks: list[SubtitleTrack], subtitle_streams, video if is_set: added += f"{disposition}," if added: - command_list.append(f"--sub-disposition {sub_id}?{added.rstrip(',')}") + command_list.extend(["--sub-disposition", f"{sub_id}?{added.rstrip(',')}"]) else: - command_list.append(f"--sub-disposition {sub_id}?unset") + command_list.extend(["--sub-disposition", f"{sub_id}?unset"]) - command_list.append(f"--sub-metadata {sub_id}?language='{track.language}'") + command_list.extend(["--sub-metadata", f"{sub_id}?language={track.language}"]) if not command_list: - return "" - commands = f" --sub-copy {','.join(copies)} {' '.join(command_list)}" if copies else f" {' '.join(command_list)}" - return commands + return [] + result = [] + if copies: + result.extend(["--sub-copy", ",".join(copies)]) + result.extend(command_list) + return result diff --git a/fastflix/encoders/common/helpers.py b/fastflix/encoders/common/helpers.py index f72468bf..99751cf9 100644 --- a/fastflix/encoders/common/helpers.py +++ b/fastflix/encoders/common/helpers.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- +import shlex +import subprocess +import sys import uuid from pathlib import Path -from typing import Tuple, Union, Optional +from typing import List, Tuple, Union, Optional import reusables from pydantic import BaseModel, Field @@ -10,38 +13,40 @@ from fastflix.encoders.common.audio import build_audio from fastflix.encoders.common.subtitles import build_subtitle from fastflix.models.fastflix import FastFlix -from fastflix.shared import clean_file_string, sanitize, quoted_path +from fastflix.shared import sanitize, quoted_path null = "/dev/null" -def escape_title(title: str) -> str: - """Escape special characters in titles for FFmpeg commands. - - Args: - title: The title string to escape - - Returns: - Escaped title string safe for use in FFmpeg command - """ - if not title: - return title - # Escape double quotes and backslashes for proper command line handling - return title.replace("\\", "\\\\").replace('"', '\\"') - - if reusables.win_based: null = "NUL" class Command(BaseModel): - command: str + command: Union[List[str], str] item: str = "command" name: str = "" exe: str = None shell: bool = False uuid: str = Field(default_factory=lambda: str(uuid.uuid4())) + def to_list(self) -> List[str]: + """Convert command to a list suitable for Popen.""" + if isinstance(self.command, list): + return self.command + # Legacy fallback for string commands + if sys.platform == "win32": + return shlex.split(self.command.replace("\\", "\\\\")) + return shlex.split(self.command) + + def to_string(self) -> str: + """Convert command to a display string.""" + if isinstance(self.command, str): + return self.command + if sys.platform == "win32": + return subprocess.list2cmdline(self.command) + return shlex.join(self.command) + def generate_ffmpeg_start( source, @@ -66,11 +71,47 @@ def generate_ffmpeg_start( remove_hdr: bool = True, start_extra: str = "", **_, -) -> str: - time_settings = f"{f'-ss {start_time}' if start_time else ''} {f'-to {end_time}' if end_time else ''}".strip() - time_one = time_settings if fast_seek else "" - time_two = time_settings if not fast_seek else "" - incoming_fps = f"-r {source_fps}" if source_fps else "" +) -> List[str]: + command = [str(ffmpeg)] + + if start_extra: + command.extend(shlex.split(start_extra)) + + if enable_opencl and remove_hdr: + command.extend(["-init_hw_device", "opencl:0.0=ocl", "-filter_hw_device", "ocl"]) + + command.append("-y") + + # Time settings for fast seek (before -i) + if fast_seek: + if start_time: + command.extend(["-ss", str(start_time)]) + if end_time: + command.extend(["-to", str(end_time)]) + + if source_fps: + command.extend(["-r", str(source_fps)]) + + if concat: + command.extend(["-f", "concat", "-safe", "0"]) + + command.extend(["-i", str(source)]) + + # Time settings for non-fast seek (after -i) + if not fast_seek: + if start_time: + command.extend(["-ss", str(start_time)]) + if end_time: + command.extend(["-to", str(end_time)]) + + if video_title: + command.extend(["-metadata", f"title={video_title}"]) + + if max_muxing_queue_size != "default": + command.extend(["-max_muxing_queue_size", str(max_muxing_queue_size)]) + + if not filters: + command.extend(["-map", f"0:{selected_track}"]) vsync_type = "vsync" try: @@ -79,50 +120,24 @@ def generate_ffmpeg_start( except Exception: pass - vsync_text = f"-{vsync_type} {vsync}" if vsync else "" + if vsync: + command.extend([f"-{vsync_type}", str(vsync)]) + + if filters: + command.extend(filters) + + command.extend(["-c:v", encoder]) + command.extend(["-pix_fmt", pix_fmt]) + + if maxrate: + command.extend(["-maxrate:v", f"{maxrate}k"]) + if bufsize: + command.extend(["-bufsize:v", f"{bufsize}k"]) - if video_title: - video_title = escape_title(video_title) - title = f'-metadata title="{video_title}"' if video_title else "" - source = clean_file_string(source) - ffmpeg = clean_file_string(ffmpeg) if video_track_title: - video_track_title = escape_title(video_track_title) - track_title = f'-metadata:s:v:0 title="{video_track_title}"' if video_track_title else "" - - opencl_option = "-init_hw_device opencl:0.0=ocl -filter_hw_device ocl" if enable_opencl and remove_hdr else "" - concat_option = "-f concat -safe 0" if concat else "" - muxing_option = f"-max_muxing_queue_size {max_muxing_queue_size}" if max_muxing_queue_size != "default" else "" - map_option = f"-map 0:{selected_track}" if not filters else "" - maxrate_option = f"-maxrate:v {maxrate}k" if maxrate else "" - bufsize_option = f"-bufsize:v {bufsize}k" if bufsize else "" - - # Create a list of command parts and filter out empty strings - command_parts = [ - f'"{ffmpeg}"', - start_extra, - opencl_option, - "-y", - time_one, - incoming_fps, - concat_option, - f'-i "{source}"', - time_two, - title, - muxing_option, - map_option, - vsync_text, - filters or "", - f"-c:v {encoder}", - f"-pix_fmt {pix_fmt}", - maxrate_option, - bufsize_option, - track_title, - ] - - # Filter out empty strings and join with a single space - # Add a trailing space to match expected output in tests - return " ".join(filter(None, command_parts)) + " " + command.extend(["-metadata:s:v:0", f"title={video_track_title}"]) + + return command def rigaya_data(streams, copy_data=False, **_): @@ -150,25 +165,42 @@ def generate_ending( copy_data=False, **_, ): - metadata_option = "-map_metadata -1" if remove_metadata else "-map_metadata 0" - chapters_option = "-map_chapters 0" if copy_chapters else "-map_chapters -1" - fps_option = f"-r {output_fps}" if output_fps else "" - data_option = "-map 0:d -c:d copy" if copy_data else "" - rotate_option = "-metadata:s:v rotate=0" if not disable_rotate_metadata and not remove_metadata else "" + command = [] - # Create a list of command parts - command_parts = [rotate_option, metadata_option, chapters_option, fps_option, audio, subtitles, cover, data_option] + if not disable_rotate_metadata and not remove_metadata: + command.extend(["-metadata:s:v", "rotate=0"]) - # Filter out empty strings and join with a single space - # Add a leading space to match expected output in tests - ending = " " + " ".join(filter(None, command_parts)) + if remove_metadata: + command.extend(["-map_metadata", "-1"]) + else: + command.extend(["-map_metadata", "0"]) + + if copy_chapters: + command.extend(["-map_chapters", "0"]) + else: + command.extend(["-map_chapters", "-1"]) + + fps_option = [] + if output_fps: + fps_option = ["-r", str(output_fps)] + command.extend(fps_option) + + if audio: + command.extend(audio) + if subtitles: + command.extend(subtitles) + if cover: + command.extend(cover) + + if copy_data: + command.extend(["-map", "0:d", "-c:d", "copy"]) if output_video and not null_ending: - ending += f' "{clean_file_string(sanitize(output_video))}"' + command.append(str(sanitize(output_video))) else: - ending += null + command.append(null) - return ending, fps_option + return command, fps_option def generate_filters( @@ -271,16 +303,18 @@ def generate_filters( filter_complex = f"[0:{selected_track}][0:{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(clean_file_string(source))}':si={burn_in_subtitle_track}[v]" + filter_complex = f"[0:{selected_track}]{filter_prefix}subtitles='{quoted_path(str(source))}':si={burn_in_subtitle_track}[v]" elif filters: filter_complex = f"[0:{selected_track}]{filters}[v]" else: - return "" + if raw_filters: + return "" + return [] if raw_filters: return filter_complex - return f' -filter_complex "{filter_complex}" -map "[v]" ' + return ["-filter_complex", filter_complex, "-map", "[v]"] def generate_all( @@ -292,12 +326,12 @@ def generate_all( vaapi: bool = False, start_extra: str = "", **filters_extra, -) -> Tuple[str, str, str]: +) -> Tuple[List[str], List[str], List[str]]: settings = fastflix.current_video.video_settings.video_encoder_settings - audio_cmd = build_audio(fastflix.current_video.audio_tracks) if audio else "" + audio_cmd = build_audio(fastflix.current_video.audio_tracks) if audio else [] - subtitles_cmd, burn_in_track, burn_in_type = "", None, None + subtitles_cmd, burn_in_track, burn_in_type = [], None, None if subs: subtitles_cmd, burn_in_track, burn_in_type = build_subtitle( fastflix.current_video.subtitle_tracks, output_path=fastflix.current_video.video_settings.output_path @@ -353,18 +387,16 @@ def generate_all( return beginning, ending, output_fps -def generate_color_details(fastflix: FastFlix) -> str: +def generate_color_details(fastflix: FastFlix) -> List[str]: if fastflix.current_video.video_settings.remove_hdr: - return "" + return [] details = [] if fastflix.current_video.video_settings.color_primaries: - details.append(f"-color_primaries {fastflix.current_video.video_settings.color_primaries}") + details.extend(["-color_primaries", fastflix.current_video.video_settings.color_primaries]) if fastflix.current_video.video_settings.color_transfer: - details.append(f"-color_trc {fastflix.current_video.video_settings.color_transfer}") + details.extend(["-color_trc", fastflix.current_video.video_settings.color_transfer]) if fastflix.current_video.video_settings.color_space: - details.append(f"-colorspace {fastflix.current_video.video_settings.color_space}") + details.extend(["-colorspace", fastflix.current_video.video_settings.color_space]) - # Filter out any empty strings (though there shouldn't be any in this case) - # and join with a single space - return " ".join(filter(None, details)) + return details diff --git a/fastflix/encoders/common/subtitles.py b/fastflix/encoders/common/subtitles.py index 40030934..ef2bfb69 100644 --- a/fastflix/encoders/common/subtitles.py +++ b/fastflix/encoders/common/subtitles.py @@ -2,14 +2,14 @@ # -*- coding: utf-8 -*- from pathlib import Path -from typing import Tuple, Union +from typing import List, Tuple, Union from fastflix.models.video import SubtitleTrack def build_subtitle( subtitle_tracks: list[SubtitleTrack], subtitle_file_index=0, output_path=None -) -> Tuple[str, Union[int, None], Union[str, None]]: +) -> Tuple[List[str], Union[int, None], Union[str, None]]: command_list = [] burn_in_track = None burn_in_type = None @@ -34,7 +34,7 @@ def build_subtitle( outdex = track.outdex - (1 if burn_in_track else 0) # MP4 containers require mov_text codec for text subtitles instead of copy (#481) codec = "mov_text" if is_mp4 else "copy" - command_list.append(f"-map {subtitle_file_index}:{track.index} -c:{outdex} {codec} ") + command_list.extend(["-map", f"{subtitle_file_index}:{track.index}", f"-c:{outdex}", codec]) added = "" for disposition, is_set in track.dispositions.items(): if is_set: @@ -42,10 +42,10 @@ def build_subtitle( if disposition in ("default", "forced"): subs_enabled = True if added: - command_list.append(f"-disposition:{outdex} {added.rstrip('+')}") + command_list.extend([f"-disposition:{outdex}", added.rstrip("+")]) else: - command_list.append(f"-disposition:{outdex} 0") - command_list.append(f"-metadata:s:{outdex} language='{track.language}'") + command_list.extend([f"-disposition:{outdex}", "0"]) + command_list.extend([f"-metadata:s:{outdex}", f"language={track.language}"]) if not subs_enabled: - command_list.append("-default_mode infer_no_subs") - return " ".join(command_list), burn_in_track, burn_in_type + command_list.extend(["-default_mode", "infer_no_subs"]) + return command_list, burn_in_track, burn_in_type diff --git a/fastflix/encoders/copy/command_builder.py b/fastflix/encoders/copy/command_builder.py index a95717df..ccfc24c4 100644 --- a/fastflix/encoders/copy/command_builder.py +++ b/fastflix/encoders/copy/command_builder.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import shlex + from fastflix.encoders.common.helpers import Command, generate_all from fastflix.models.fastflix import FastFlix @@ -13,15 +15,21 @@ def build(fastflix: FastFlix): elif "rotation" in fastflix.current_video.current_video_stream.get("side_data_list", [{}])[0]: rotation = abs(int(fastflix.current_video.current_video_stream.side_data_list[0].rotation)) - rot = "" + rot = [] # if fastflix.current_video.video_settings.rotate != 0: - # rot = f"-display_rotation:s:v {rotation + (fastflix.current_video.video_settings.rotate * 90)}" + # rot = ["-display_rotation:s:v", str(rotation + (fastflix.current_video.video_settings.rotate * 90))] if fastflix.current_video.video_settings.output_path.name.lower().endswith("mp4"): - rot = f"-metadata:s:v rotate={rotation + (fastflix.current_video.video_settings.rotate * 90)}" + rot = ["-metadata:s:v", f"rotate={rotation + (fastflix.current_video.video_settings.rotate * 90)}"] + + extra = ( + shlex.split(fastflix.current_video.video_settings.video_encoder_settings.extra) + if fastflix.current_video.video_settings.video_encoder_settings.extra + else [] + ) return [ Command( - command=f"{beginning} {rot} {fastflix.current_video.video_settings.video_encoder_settings.extra} {ending}", + command=beginning + rot + extra + ending, name="No Video Encoding", exe="ffmpeg", ) diff --git a/fastflix/encoders/ffmpeg_hevc_nvenc/command_builder.py b/fastflix/encoders/ffmpeg_hevc_nvenc/command_builder.py index 256dcfad..d65b0ea0 100644 --- a/fastflix/encoders/ffmpeg_hevc_nvenc/command_builder.py +++ b/fastflix/encoders/ffmpeg_hevc_nvenc/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null from fastflix.models.encode import FFmpegNVENCSettings @@ -13,32 +14,78 @@ def build(fastflix: FastFlix): fastflix, "hevc_nvenc", start_extra="-hwaccel auto" if settings.hw_accel else "" ) - beginning += f"{f'-tune:v {settings.tune}' if settings.tune else ''} {generate_color_details(fastflix)} -spatial_aq:v {settings.spatial_aq} -tier:v {settings.tier} -rc-lookahead:v {settings.rc_lookahead} -gpu {settings.gpu} -b_ref_mode {settings.b_ref_mode} " + if settings.tune: + beginning.extend(["-tune:v", settings.tune]) + beginning.extend(generate_color_details(fastflix)) + beginning.extend( + [ + "-spatial_aq:v", + str(settings.spatial_aq), + "-tier:v", + settings.tier, + "-rc-lookahead:v", + str(settings.rc_lookahead), + "-gpu", + str(settings.gpu), + "-b_ref_mode", + str(settings.b_ref_mode), + ] + ) if settings.profile: - beginning += f"-profile:v {settings.profile} " + beginning.extend(["-profile:v", settings.profile]) if settings.rc: - beginning += f"-rc:v {settings.rc} " + beginning.extend(["-rc:v", settings.rc]) if settings.level: - beginning += f"-level:v {settings.level} " + beginning.extend(["-level:v", settings.level]) + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] if not settings.bitrate: - command = (f"{beginning} -qp:v {settings.qp} -preset:v {settings.preset} {settings.extra}") + ending + command = beginning + ["-qp:v", str(settings.qp), "-preset:v", settings.preset] + extra + ending return [Command(command=command, name="Single QP encode", exe="ffmpeg")] pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" command_1 = ( - f"{beginning} -pass 1 " - f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} -preset:v {settings.preset} -2pass 1 ' - f"{settings.extra if settings.extra_both_passes else ''} -an -sn -dn {output_fps} -f mp4 {null}" + beginning + + [ + "-pass", + "1", + "-passlogfile", + str(pass_log_file), + "-b:v", + settings.bitrate, + "-preset:v", + settings.preset, + "-2pass", + "1", + ] + + extra_both + + ["-an", "-sn", "-dn"] + + output_fps + + ["-f", "mp4", null] ) command_2 = ( - f'{beginning} -pass 2 -passlogfile "{pass_log_file}" -2pass 1 ' - f"-b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} " - ) + ending + beginning + + [ + "-pass", + "2", + "-passlogfile", + str(pass_log_file), + "-2pass", + "1", + "-b:v", + settings.bitrate, + "-preset:v", + settings.preset, + ] + + extra + + ending + ) return [ Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), diff --git a/fastflix/encoders/gif/command_builder.py b/fastflix/encoders/gif/command_builder.py index 4ad264e2..947e91c1 100644 --- a/fastflix/encoders/gif/command_builder.py +++ b/fastflix/encoders/gif/command_builder.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_filters from fastflix.models.encode import GIFSettings from fastflix.models.fastflix import FastFlix -from fastflix.shared import clean_file_string +from fastflix.shared import sanitize def build(fastflix: FastFlix): @@ -43,6 +44,7 @@ def build(fastflix: FastFlix): ) # Palette generation filters include the base filters + palettegen + # This returns List[str] since raw_filters=False (default) palettegen_filters = generate_filters( selected_track=video_settings.selected_track, source=fastflix.current_video.source, @@ -61,29 +63,33 @@ def build(fastflix: FastFlix): custom_filters=f"fps={settings.fps},palettegen{args}", ) - output_video = clean_file_string(fastflix.current_video.video_settings.output_path) + output_video = str(sanitize(fastflix.current_video.video_settings.output_path)) - beginning = ( - f'"{fastflix.config.ffmpeg}" -y ' - f"{f'-ss {video_settings.start_time}' if video_settings.start_time else ''} " - f"{f'-to {video_settings.end_time}' if video_settings.end_time else ''} " - f"{f'-r {video_settings.source_fps} ' if video_settings.source_fps else ''}" - f' -i "{fastflix.current_video.source}" ' - ) - if settings.extra: - beginning += " " + beginning = [str(fastflix.config.ffmpeg), "-y"] + if video_settings.start_time: + beginning.extend(["-ss", str(video_settings.start_time)]) + if video_settings.end_time: + beginning.extend(["-to", str(video_settings.end_time)]) + if video_settings.source_fps: + beginning.extend(["-r", str(video_settings.source_fps)]) + beginning.extend(["-i", str(fastflix.current_video.source)]) temp_palette = fastflix.current_video.work_path / f"temp_palette_{secrets.token_hex(10)}.png" - command_1 = ( - f'{beginning} {palettegen_filters} {settings.extra if settings.extra_both_passes else ""} -y "{temp_palette}"' - ) + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + extra = shlex.split(settings.extra) if settings.extra else [] + + command_1 = beginning + palettegen_filters + extra_both + ["-y", str(temp_palette)] # For GIF creation, apply same base filters then use palette # Format: [base_filters];[v][1:v]paletteuse=dither={dither}[o] + filter_complex = f"{base_filters};[v][1:v]paletteuse=dither={settings.dither}[o]" command_2 = ( - f'{beginning} -i "{temp_palette}" ' - f'-filter_complex "{base_filters};[v][1:v]paletteuse=dither={settings.dither}[o]" -map "[o]" {settings.extra} -y "{output_video}" ' + beginning + + ["-i", str(temp_palette)] + + ["-filter_complex", filter_complex, "-map", "[o]"] + + extra + + ["-y", output_video] ) return [ diff --git a/fastflix/encoders/h264_videotoolbox/command_builder.py b/fastflix/encoders/h264_videotoolbox/command_builder.py index 8467ddc9..38f8428a 100644 --- a/fastflix/encoders/h264_videotoolbox/command_builder.py +++ b/fastflix/encoders/h264_videotoolbox/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null from fastflix.models.encode import HEVCVideoToolboxSettings @@ -10,31 +11,55 @@ def build(fastflix: FastFlix): settings: HEVCVideoToolboxSettings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "h264_videotoolbox") - beginning += generate_color_details(fastflix) + beginning.extend(generate_color_details(fastflix)) def clean_bool(item): return "true" if item else "false" - details = ( - f"-profile:v {settings.profile} " - f"-allow_sw {clean_bool(settings.allow_sw)} " - f"-require_sw {clean_bool(settings.require_sw)} " - f"-realtime {clean_bool(settings.realtime)} " - f"-frames_before {clean_bool(settings.frames_before)} " - f"-frames_after {clean_bool(settings.frames_after)} " - ) + details = [ + "-profile:v", + settings.profile, + "-allow_sw", + clean_bool(settings.allow_sw), + "-require_sw", + clean_bool(settings.require_sw), + "-realtime", + clean_bool(settings.realtime), + "-frames_before", + clean_bool(settings.frames_before), + "-frames_after", + clean_bool(settings.frames_after), + ] + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] if settings.bitrate: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" - beginning += " " - command_1 = f'{beginning} -b:v {settings.bitrate} {details} -pass 1 -passlogfile "{pass_log_file}" {settings.extra if settings.extra_both_passes else ""} -an {output_fps} -f mp4 {null}' - command_2 = f'{beginning} -b:v {settings.bitrate} {details} -pass 2 -passlogfile "{pass_log_file}" {settings.extra} {ending}' + command_1 = ( + beginning + + ["-b:v", settings.bitrate] + + details + + ["-pass", "1", "-passlogfile", str(pass_log_file)] + + extra_both + + ["-an"] + + output_fps + + ["-f", "mp4", null] + ) + command_2 = ( + beginning + + ["-b:v", settings.bitrate] + + details + + ["-pass", "2", "-passlogfile", str(pass_log_file)] + + extra + + ending + ) return [ Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), ] - command_1 = f"{beginning} -q:v {settings.q} {details} {settings.extra} {ending}" + command_1 = beginning + ["-q:v", str(settings.q)] + details + extra + ending return [ Command(command=command_1, name="Single pass constant quality", exe="ffmpeg"), diff --git a/fastflix/encoders/hevc_videotoolbox/command_builder.py b/fastflix/encoders/hevc_videotoolbox/command_builder.py index bcaa51d7..c1313be9 100644 --- a/fastflix/encoders/hevc_videotoolbox/command_builder.py +++ b/fastflix/encoders/hevc_videotoolbox/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null from fastflix.models.encode import HEVCVideoToolboxSettings @@ -10,31 +11,55 @@ def build(fastflix: FastFlix): settings: HEVCVideoToolboxSettings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "hevc_videotoolbox") - beginning += generate_color_details(fastflix) + beginning.extend(generate_color_details(fastflix)) def clean_bool(item): return "true" if item else "false" - details = ( - f"-profile:v {settings.profile} " - f"-allow_sw {clean_bool(settings.allow_sw)} " - f"-require_sw {clean_bool(settings.require_sw)} " - f"-realtime {clean_bool(settings.realtime)} " - f"-frames_before {clean_bool(settings.frames_before)} " - f"-frames_after {clean_bool(settings.frames_after)} " - ) + details = [ + "-profile:v", + settings.profile, + "-allow_sw", + clean_bool(settings.allow_sw), + "-require_sw", + clean_bool(settings.require_sw), + "-realtime", + clean_bool(settings.realtime), + "-frames_before", + clean_bool(settings.frames_before), + "-frames_after", + clean_bool(settings.frames_after), + ] + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] if settings.bitrate: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" - beginning += " " - command_1 = f'{beginning} -b:v {settings.bitrate} {details} -pass 1 -passlogfile "{pass_log_file}" {settings.extra if settings.extra_both_passes else ""} -an {output_fps} -f mp4 {null}' - command_2 = f'{beginning} -b:v {settings.bitrate} {details} -pass 2 -passlogfile "{pass_log_file}" {settings.extra} {ending}' + command_1 = ( + beginning + + ["-b:v", settings.bitrate] + + details + + ["-pass", "1", "-passlogfile", str(pass_log_file)] + + extra_both + + ["-an"] + + output_fps + + ["-f", "mp4", null] + ) + command_2 = ( + beginning + + ["-b:v", settings.bitrate] + + details + + ["-pass", "2", "-passlogfile", str(pass_log_file)] + + extra + + ending + ) return [ Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), ] - command_1 = f"{beginning} -q:v {settings.q} {details} {settings.extra} {ending}" + command_1 = beginning + ["-q:v", str(settings.q)] + details + extra + ending return [ Command(command=command_1, name="Single pass constant quality", exe="ffmpeg"), diff --git a/fastflix/encoders/hevc_x265/command_builder.py b/fastflix/encoders/hevc_x265/command_builder.py index faa79c45..8f2e916f 100644 --- a/fastflix/encoders/hevc_x265/command_builder.py +++ b/fastflix/encoders/hevc_x265/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, null from fastflix.models.encode import x265Settings @@ -82,13 +83,10 @@ def build(fastflix: FastFlix): beginning, ending, output_fps = generate_all(fastflix, "libx265") if settings.tune and settings.tune != "default": - beginning += f"-tune:v {settings.tune} " + beginning.extend(["-tune:v", settings.tune]) if settings.profile and settings.profile != "default": - beginning += f"-profile:v {settings.profile} " - - # if settings.gop_size: - # beginning += f"-g {settings.gop_size}" + beginning.extend(["-profile:v", settings.profile]) x265_params = settings.x265_params.copy() or [] @@ -175,31 +173,46 @@ def get_x265_params(params=()): if not isinstance(params, (list, tuple)): params = [params] all_params = x265_params + list(params) - return '-x265-params "{}" '.format(":".join(all_params)) if all_params else "" + return ["-x265-params", ":".join(all_params)] if all_params else [] + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] if settings.bitrate: if settings.bitrate_passes == 2: command_1 = ( - f"{beginning} {get_x265_params(['pass=1', 'no-slow-firstpass=1', f'stats={pass_log_file}'])} " - f" -b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra if settings.extra_both_passes else ''} " - f" -an -sn -dn {output_fps} -f mp4 {null}" + beginning + + get_x265_params(["pass=1", "no-slow-firstpass=1", f"stats={pass_log_file}"]) + + ["-b:v", settings.bitrate, "-preset:v", settings.preset] + + extra_both + + ["-an", "-sn", "-dn"] + + output_fps + + ["-f", "mp4", null] ) command_2 = ( - f"{beginning} {get_x265_params(['pass=2', f'stats={pass_log_file}'])} " - f"-b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} {ending}" + beginning + + get_x265_params(["pass=2", f"stats={pass_log_file}"]) + + ["-b:v", settings.bitrate, "-preset:v", settings.preset] + + extra + + ending ) return [ Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), Command(command=command_2, name="Second pass bitrate", exe="ffmpeg"), ] else: - command = f"{beginning} {get_x265_params()} -b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} {ending}" + command = ( + beginning + + get_x265_params() + + ["-b:v", settings.bitrate, "-preset:v", settings.preset] + + extra + + ending + ) return [Command(command=command, name="Single pass bitrate", exe="ffmpeg")] elif settings.crf: command = ( - f"{beginning} {get_x265_params()} -crf:v {settings.crf} " - f"-preset:v {settings.preset} {settings.extra} {ending}" + beginning + get_x265_params() + ["-crf:v", str(settings.crf), "-preset:v", settings.preset] + extra + ending ) return [Command(command=command, name="Single pass CRF", exe="ffmpeg")] diff --git a/fastflix/encoders/modify/command_builder.py b/fastflix/encoders/modify/command_builder.py index 50d71dea..216cced6 100644 --- a/fastflix/encoders/modify/command_builder.py +++ b/fastflix/encoders/modify/command_builder.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- +import shlex + from fastflix.encoders.common.helpers import Command, generate_all from fastflix.models.fastflix import FastFlix -from fastflix.shared import clean_file_string def build(fastflix: FastFlix): @@ -11,55 +12,69 @@ def build(fastflix: FastFlix): ffmpeg = fastflix.config.ffmpeg source = fastflix.current_video.source - from fastflix.encoders.common.helpers import escape_title + beginning = [str(ffmpeg), "-y", "-i", str(source)] - if video_title: - video_title = escape_title(video_title) - title = f'-metadata title="{video_title}"' if video_title else "" - source = clean_file_string(source) - ffmpeg = clean_file_string(ffmpeg) - if video_track_title: - video_track_title = escape_title(video_track_title) - track_title = f'-metadata:s:v:0 title="{video_track_title}"' + title = ["-metadata", f"title={video_title}"] if video_title else [] + track_title = ["-metadata:s:v:0", f"title={video_track_title}"] if video_track_title else [] - beginning = " ".join( - [ - f'"{ffmpeg}"', - "-y", - f'-i "{source}"', - " ", # Leave space after commands - ] + extra = ( + shlex.split(fastflix.current_video.video_settings.video_encoder_settings.extra) + if fastflix.current_video.video_settings.video_encoder_settings.extra + else [] ) audio = fastflix.current_video.video_settings.video_encoder_settings.add_audio_track subs = fastflix.current_video.video_settings.video_encoder_settings.add_subtitle_track if audio and subs: - audio_path_clean = clean_file_string(audio) - subs_path_clean = clean_file_string(subs) return [ Command( - command=f'{beginning} -i "{audio_path_clean}" -i "{subs_path_clean}" -map 0 -map 1:a -map 2:s {title} {track_title if video_track_title else ""} -c copy {fastflix.current_video.video_settings.video_encoder_settings.extra} {ending}', + command=( + beginning + + ["-i", str(audio), "-i", str(subs)] + + ["-map", "0", "-map", "1:a", "-map", "2:s"] + + title + + track_title + + ["-c", "copy"] + + extra + + ending + ), name="Add audio and subtitle track", exe="ffmpeg", ) ] if audio: - audio_path_clean = clean_file_string(audio) return [ Command( - command=f'{beginning} -i "{audio_path_clean}" -map 0 -map 1:a {title} {track_title if video_track_title else ""} -c copy {fastflix.current_video.video_settings.video_encoder_settings.extra} {ending}', + command=( + beginning + + ["-i", str(audio)] + + ["-map", "0", "-map", "1:a"] + + title + + track_title + + ["-c", "copy"] + + extra + + ending + ), name="Add audio track", exe="ffmpeg", ) ] if subs: - subs_path_clean = clean_file_string(subs) return [ Command( - command=f'{beginning} -i "{subs_path_clean}" -map 0 -map 1:s {title} {track_title if video_track_title else ""} -c copy {fastflix.current_video.video_settings.video_encoder_settings.extra} {ending}', + command=( + beginning + + ["-i", str(subs)] + + ["-map", "0", "-map", "1:s"] + + title + + track_title + + ["-c", "copy"] + + extra + + ending + ), name="Add subtitle track", exe="ffmpeg", ) diff --git a/fastflix/encoders/nvencc_av1/command_builder.py b/fastflix/encoders/nvencc_av1/command_builder.py index d2a0a54e..26102d01 100644 --- a/fastflix/encoders/nvencc_av1/command_builder.py +++ b/fastflix/encoders/nvencc_av1/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +from typing import List from fastflix.encoders.common.helpers import Command from fastflix.models.encode import NVEncCAV1Settings @@ -11,7 +12,6 @@ rigaya_auto_options, rigaya_avformat_reader, ) -from fastflix.flix import clean_file_string logger = logging.getLogger("fastflix") @@ -20,51 +20,24 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: NVEncCAV1Settings = fastflix.current_video.video_settings.video_encoder_settings - master_display = None - if fastflix.current_video.master_display: - master_display = ( - f'--master-display "G{fastflix.current_video.master_display.green}' - f"B{fastflix.current_video.master_display.blue}" - f"R{fastflix.current_video.master_display.red}" - f"WP{fastflix.current_video.master_display.white}" - f'L{fastflix.current_video.master_display.luminance}"' - ) - - max_cll = None - if fastflix.current_video.cll: - max_cll = f'--max-cll "{fastflix.current_video.cll}"' - - dhdr = None - if settings.copy_hdr10: - dhdr = "--dhdr10-info copy" - - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" - - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" - - remove_hdr = "" - if video.video_settings.remove_hdr: - remove_type = ( - video.video_settings.tone_map - if video.video_settings.tone_map in ("mobius", "hable", "reinhard") - else "mobius" - ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + bit_depth = "8" + if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: + bit_depth = "10" + if settings.force_ten_bit: + bit_depth = "10" - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" + if video.video_settings.vsync == "cfr": + vsync_setting = "forcecfr" + elif video.video_settings.vsync == "vfr": + vsync_setting = "vfr" init_q = settings.init_q_i if settings.init_q_i and settings.init_q_p and settings.init_q_b: @@ -78,101 +51,131 @@ def build(fastflix: FastFlix): if settings.max_q_i and settings.max_q_p and settings.max_q_b: max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" - try: - stream_id = int(video.current_video_stream["id"], 16) - except Exception: - if len(video.streams.video) > 1: - logger.warning("Could not get stream ID from source, the proper video track may not be selected!") - stream_id = None + command: List[str] = [ + str(fastflix.config.nvencc), + ] + + command.extend(rigaya_avformat_reader(fastflix)) + + command.extend(["--device", str(settings.device)]) + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + if video.video_settings.start_time: + command.extend(["--seek", video.video_settings.start_time]) + if video.video_settings.end_time: + command.extend(["--seekto", video.video_settings.end_time]) + if video.video_settings.source_fps: + command.extend(["--fps", video.video_settings.source_fps]) + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "av1"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend(["--cqp", settings.cqp]) + + if video.video_settings.maxrate: + command.extend(["--max-bitrate", str(video.video_settings.maxrate)]) + command.extend(["--vbv-bufsize", str(video.video_settings.bufsize)]) + + if settings.vbr_target is not None and settings.bitrate: + command.extend(["--vbr-quality", str(settings.vbr_target)]) + if init_q and settings.bitrate: + command.extend(["--qp-init", str(init_q)]) + if min_q and settings.bitrate: + command.extend(["--qp-min", str(min_q)]) + if max_q and settings.bitrate: + command.extend(["--qp-max", str(max_q)]) + if settings.b_frames: + command.extend(["--bframes", str(settings.b_frames)]) + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--bref-mode", settings.b_ref_mode]) + command.extend(["--preset", settings.preset]) + command.extend(["--tier", settings.tier]) + + if settings.lookahead: + command.extend(["--lookahead", str(settings.lookahead)]) - aq = "--no-aq" if settings.aq.lower() == "spatial": - aq = f"--aq --aq-strength {settings.aq_strength}" + command.extend(["--aq", "--aq-strength", str(settings.aq_strength)]) elif settings.aq.lower() == "temporal": - aq = f"--aq-temporal --aq-strength {settings.aq_strength}" + command.extend(["--aq-temporal", "--aq-strength", str(settings.aq_strength)]) + else: + command.append("--no-aq") - bit_depth = "8" - if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: - bit_depth = "10" - if settings.force_ten_bit: - bit_depth = "10" + command.extend(["--level", (settings.level or "auto")]) - vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" - if video.video_settings.vsync == "cfr": - vsync_setting = "forcecfr" - elif video.video_settings.vsync == "vfr": - vsync_setting = "vfr" + command.extend(rigaya_auto_options(fastflix)) + + if fastflix.current_video.master_display: + md = fastflix.current_video.master_display + command.extend( + [ + "--master-display", + f"G{md.green}B{md.blue}R{md.red}WP{md.white}L{md.luminance}", + ] + ) + if fastflix.current_video.cll: + command.extend(["--max-cll", str(fastflix.current_video.cll)]) + if settings.copy_hdr10: + command.extend(["--dhdr10-info", "copy"]) - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" + command.extend(["--output-depth", bit_depth]) + command.extend(["--multipass", settings.multipass]) + command.extend(["--mv-precision", settings.mv_precision]) + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", str(video.interlaced)]) + if video.video_settings.deinterlace: + command.append("--vpp-yadif") + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) - split_mode = "" if settings.split_mode == "split": - split_mode = "--split-enc auto_forced" + command.extend(["--split-enc", "auto_forced"]) elif settings.split_mode == "parallel": - split_mode = "--parallel auto" - - command = [ - f'"{clean_file_string(fastflix.config.nvencc)}"', - rigaya_avformat_reader(fastflix), - "--device", - str(settings.device), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "av1", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), - vbv, - (f"--vbr-quality {settings.vbr_target}" if settings.vbr_target is not None and settings.bitrate else ""), - (f"--qp-init {init_q}" if init_q and settings.bitrate else ""), - (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), - (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), - (f"--bframes {settings.b_frames}" if settings.b_frames else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - f"--bref-mode {settings.b_ref_mode}", - "--preset", - settings.preset, - "--tier", - settings.tier, - (f"--lookahead {settings.lookahead}" if settings.lookahead else ""), - aq, - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - (master_display if master_display else ""), - (max_cll if max_cll else ""), - (dhdr if dhdr else ""), - "--output-depth", - bit_depth, - "--multipass", - settings.multipass, - "--mv-precision", - settings.mv_precision, - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-yadif" if video.video_settings.deinterlace else ""), - remove_hdr, - split_mode, - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', - ] + command.extend(["--parallel", "auto"]) + + if settings.metrics: + command.extend(["--psnr", "--ssim"]) + + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + + if settings.extra: + command.extend(settings.extra.split()) + + command.extend(["-o", str(video.video_settings.output_path)]) - return [Command(command=" ".join(x for x in command if x), name="NVEncC Encode", exe="NVEncE")] + return [Command(command=command, name="NVEncC Encode", exe="NVEncE")] diff --git a/fastflix/encoders/nvencc_avc/command_builder.py b/fastflix/encoders/nvencc_avc/command_builder.py index ad6fc932..4c81901b 100644 --- a/fastflix/encoders/nvencc_avc/command_builder.py +++ b/fastflix/encoders/nvencc_avc/command_builder.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- import logging +from typing import List from fastflix.encoders.common.helpers import Command from fastflix.models.encode import NVEncCAVCSettings from fastflix.models.video import Video from fastflix.models.fastflix import FastFlix -from fastflix.shared import clean_file_string from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, @@ -20,33 +20,18 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: NVEncCAVCSettings = fastflix.current_video.video_settings.video_encoder_settings - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" - - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" - - remove_hdr = "" - if video.video_settings.remove_hdr: - remove_type = ( - video.video_settings.tone_map - if video.video_settings.tone_map in ("mobius", "hable", "reinhard") - else "mobius" - ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" - - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" + if video.video_settings.vsync == "cfr": + vsync_setting = "forcecfr" + elif video.video_settings.vsync == "vfr": + vsync_setting = "vfr" init_q = settings.init_q_i if settings.init_q_i and settings.init_q_p and settings.init_q_b: @@ -60,82 +45,114 @@ def build(fastflix: FastFlix): if settings.max_q_i and settings.max_q_p and settings.max_q_b: max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" - try: - stream_id = int(video.current_video_stream["id"], 16) - except Exception: - if len(video.streams.video) > 1: - logger.warning("Could not get stream ID from source, the proper video track may not be selected!") - stream_id = None + command: List[str] = [ + str(fastflix.config.nvencc), + ] + + command.extend(rigaya_avformat_reader(fastflix)) + + command.extend(["--device", str(settings.device)]) + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + if video.video_settings.start_time: + command.extend(["--seek", video.video_settings.start_time]) + if video.video_settings.end_time: + command.extend(["--seekto", video.video_settings.end_time]) + if video.video_settings.source_fps: + command.extend(["--fps", video.video_settings.source_fps]) + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "avc"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend(["--cqp", settings.cqp]) + + if video.video_settings.maxrate: + command.extend(["--max-bitrate", str(video.video_settings.maxrate)]) + command.extend(["--vbv-bufsize", str(video.video_settings.bufsize)]) + + if settings.vbr_target is not None and settings.bitrate: + command.extend(["--vbr-quality", str(settings.vbr_target)]) + if init_q and settings.bitrate: + command.extend(["--qp-init", str(init_q)]) + if min_q and settings.bitrate: + command.extend(["--qp-min", str(min_q)]) + if max_q and settings.bitrate: + command.extend(["--qp-max", str(max_q)]) + if settings.b_frames: + command.extend(["--bframes", str(settings.b_frames)]) + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--bref-mode", settings.b_ref_mode]) + command.extend(["--preset", settings.preset]) + + if settings.lookahead: + command.extend(["--lookahead", str(settings.lookahead)]) - aq = "--no-aq" if settings.aq.lower() == "spatial": - aq = f"--aq --aq-strength {settings.aq_strength}" + command.extend(["--aq", "--aq-strength", str(settings.aq_strength)]) elif settings.aq.lower() == "temporal": - aq = f"--aq-temporal --aq-strength {settings.aq_strength}" + command.extend(["--aq-temporal", "--aq-strength", str(settings.aq_strength)]) + else: + command.append("--no-aq") - vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" - if video.video_settings.vsync == "cfr": - vsync_setting = "forcecfr" - elif video.video_settings.vsync == "vfr": - vsync_setting = "vfr" + command.extend(["--level", (settings.level or "auto")]) - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - - command = [ - f'"{clean_file_string(fastflix.config.nvencc)}"', - rigaya_avformat_reader(fastflix), - "--device", - str(settings.device), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "avc", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), - vbv, - (f"--vbr-quality {settings.vbr_target}" if settings.vbr_target is not None and settings.bitrate else ""), - (f"--qp-init {init_q}" if init_q and settings.bitrate else ""), - (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), - (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), - (f"--bframes {settings.b_frames}" if settings.b_frames else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - f"--bref-mode {settings.b_ref_mode}", - "--preset", - settings.preset, - (f"--lookahead {settings.lookahead}" if settings.lookahead else ""), - aq, - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - "--multipass", - settings.multipass, - "--mv-precision", - settings.mv_precision, - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-yadif" if video.video_settings.deinterlace else ""), - remove_hdr, - "--parallel auto" if settings.split_mode == "parallel" else "", - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', - ] + command.extend(rigaya_auto_options(fastflix)) + + command.extend(["--multipass", settings.multipass]) + command.extend(["--mv-precision", settings.mv_precision]) + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", str(video.interlaced)]) + if video.video_settings.deinterlace: + command.append("--vpp-yadif") + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + + if settings.split_mode == "parallel": + command.extend(["--parallel", "auto"]) + + if settings.metrics: + command.extend(["--psnr", "--ssim"]) + + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + + if settings.extra: + command.extend(settings.extra.split()) + + command.extend(["-o", str(video.video_settings.output_path)]) - return [Command(command=" ".join(x for x in command if x), name="NVEncC Encode", exe="NVEncE")] + return [Command(command=command, name="NVEncC Encode", exe="NVEncE")] diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py index c75f4bcd..7f48a062 100644 --- a/fastflix/encoders/nvencc_hevc/command_builder.py +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +from typing import List from fastflix.encoders.common.helpers import Command from fastflix.models.encode import NVEncCSettings @@ -11,7 +12,6 @@ rigaya_auto_options, rigaya_avformat_reader, ) -from fastflix.flix import clean_file_string logger = logging.getLogger("fastflix") @@ -20,51 +20,24 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: NVEncCSettings = fastflix.current_video.video_settings.video_encoder_settings - master_display = None - if fastflix.current_video.master_display: - master_display = ( - f'--master-display "G{fastflix.current_video.master_display.green}' - f"B{fastflix.current_video.master_display.blue}" - f"R{fastflix.current_video.master_display.red}" - f"WP{fastflix.current_video.master_display.white}" - f'L{fastflix.current_video.master_display.luminance}"' - ) - - max_cll = None - if fastflix.current_video.cll: - max_cll = f'--max-cll "{fastflix.current_video.cll}"' - - dhdr = None - if settings.copy_hdr10: - dhdr = "--dhdr10-info copy" - - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" - - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" - - remove_hdr = "" - if video.video_settings.remove_hdr: - remove_type = ( - video.video_settings.tone_map - if video.video_settings.tone_map in ("mobius", "hable", "reinhard") - else "mobius" - ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + bit_depth = "8" + if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: + bit_depth = "10" + if settings.force_ten_bit: + bit_depth = "10" - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" + if video.video_settings.vsync == "cfr": + vsync_setting = "forcecfr" + elif video.video_settings.vsync == "vfr": + vsync_setting = "vfr" init_q = settings.init_q_i if settings.init_q_i and settings.init_q_p and settings.init_q_b: @@ -78,101 +51,131 @@ def build(fastflix: FastFlix): if settings.max_q_i and settings.max_q_p and settings.max_q_b: max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" - try: - stream_id = int(video.current_video_stream["id"], 16) - except Exception: - if len(video.streams.video) > 1: - logger.warning("Could not get stream ID from source, the proper video track may not be selected!") - stream_id = None + command: List[str] = [ + str(fastflix.config.nvencc), + ] + + command.extend(rigaya_avformat_reader(fastflix)) + + command.extend(["--device", str(settings.device)]) + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + if video.video_settings.start_time: + command.extend(["--seek", video.video_settings.start_time]) + if video.video_settings.end_time: + command.extend(["--seekto", video.video_settings.end_time]) + if video.video_settings.source_fps: + command.extend(["--fps", video.video_settings.source_fps]) + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "hevc"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend(["--cqp", settings.cqp]) + + if video.video_settings.maxrate: + command.extend(["--max-bitrate", str(video.video_settings.maxrate)]) + command.extend(["--vbv-bufsize", str(video.video_settings.bufsize)]) + + if settings.vbr_target is not None and settings.bitrate: + command.extend(["--vbr-quality", str(settings.vbr_target)]) + if init_q and settings.bitrate: + command.extend(["--qp-init", str(init_q)]) + if min_q and settings.bitrate: + command.extend(["--qp-min", str(min_q)]) + if max_q and settings.bitrate: + command.extend(["--qp-max", str(max_q)]) + if settings.b_frames: + command.extend(["--bframes", str(settings.b_frames)]) + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--bref-mode", settings.b_ref_mode]) + command.extend(["--preset", settings.preset]) + command.extend(["--tier", settings.tier]) + + if settings.lookahead: + command.extend(["--lookahead", str(settings.lookahead)]) - aq = "--no-aq" if settings.aq.lower() == "spatial": - aq = f"--aq --aq-strength {settings.aq_strength}" + command.extend(["--aq", "--aq-strength", str(settings.aq_strength)]) elif settings.aq.lower() == "temporal": - aq = f"--aq-temporal --aq-strength {settings.aq_strength}" + command.extend(["--aq-temporal", "--aq-strength", str(settings.aq_strength)]) + else: + command.append("--no-aq") - bit_depth = "8" - if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: - bit_depth = "10" - if settings.force_ten_bit: - bit_depth = "10" + command.extend(["--level", (settings.level or "auto")]) - vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" - if video.video_settings.vsync == "cfr": - vsync_setting = "forcecfr" - elif video.video_settings.vsync == "vfr": - vsync_setting = "vfr" + command.extend(rigaya_auto_options(fastflix)) + + if fastflix.current_video.master_display: + md = fastflix.current_video.master_display + command.extend( + [ + "--master-display", + f"G{md.green}B{md.blue}R{md.red}WP{md.white}L{md.luminance}", + ] + ) + if fastflix.current_video.cll: + command.extend(["--max-cll", str(fastflix.current_video.cll)]) + if settings.copy_hdr10: + command.extend(["--dhdr10-info", "copy"]) + + command.extend(["--output-depth", bit_depth]) + command.extend(["--multipass", settings.multipass]) + command.extend(["--mv-precision", settings.mv_precision]) + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", str(video.interlaced)]) + if video.video_settings.deinterlace: + command.append("--vpp-yadif") + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) - split_mode = "" if settings.split_mode == "split": - split_mode = "--split-enc auto_forced" + command.extend(["--split-enc", "auto_forced"]) elif settings.split_mode == "parallel": - split_mode = "--parallel auto" - - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - - command = [ - f'"{clean_file_string(fastflix.config.nvencc)}"', - rigaya_avformat_reader(fastflix), - "--device", - str(settings.device), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "hevc", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), - vbv, - (f"--vbr-quality {settings.vbr_target}" if settings.vbr_target is not None and settings.bitrate else ""), - (f"--qp-init {init_q}" if init_q and settings.bitrate else ""), - (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), - (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), - (f"--bframes {settings.b_frames}" if settings.b_frames else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - f"--bref-mode {settings.b_ref_mode}", - "--preset", - settings.preset, - "--tier", - settings.tier, - (f"--lookahead {settings.lookahead}" if settings.lookahead else ""), - aq, - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - (master_display if master_display else ""), - (max_cll if max_cll else ""), - (dhdr if dhdr else ""), - "--output-depth", - bit_depth, - "--multipass", - settings.multipass, - "--mv-precision", - settings.mv_precision, - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-yadif" if video.video_settings.deinterlace else ""), - remove_hdr, - split_mode, - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', - ] + command.extend(["--parallel", "auto"]) + + if settings.metrics: + command.extend(["--psnr", "--ssim"]) + + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + + if settings.extra: + command.extend(settings.extra.split()) + + command.extend(["-o", str(video.video_settings.output_path)]) - return [Command(command=" ".join(x for x in command if x), name="NVEncC Encode", exe="NVEncE")] + return [Command(command=command, name="NVEncC Encode", exe="NVEncE")] diff --git a/fastflix/encoders/qsvencc_av1/command_builder.py b/fastflix/encoders/qsvencc_av1/command_builder.py index 176679ac..87359b6e 100644 --- a/fastflix/encoders/qsvencc_av1/command_builder.py +++ b/fastflix/encoders/qsvencc_av1/command_builder.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import logging +import shlex +from typing import List from fastflix.encoders.common.helpers import Command from fastflix.models.encode import QSVEncCAV1Settings @@ -11,7 +13,6 @@ rigaya_auto_options, rigaya_avformat_reader, ) -from fastflix.flix import clean_file_string logger = logging.getLogger("fastflix") @@ -20,134 +21,164 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: QSVEncCAV1Settings = fastflix.current_video.video_settings.video_encoder_settings - master_display = None + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None + + bit_depth = "8" + if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: + bit_depth = "10" + if settings.force_ten_bit: + bit_depth = "10" + + vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" + if video.video_settings.vsync == "cfr": + vsync_setting = "forcecfr" + elif video.video_settings.vsync == "vfr": + vsync_setting = "vfr" + + min_q = settings.min_q_i + if settings.min_q_i and settings.min_q_p and settings.min_q_b: + min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + + max_q = settings.max_q_i + if settings.max_q_i and settings.max_q_p and settings.max_q_b: + max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + + command: List[str] = [str(fastflix.config.qsvencc)] + + command.extend(rigaya_avformat_reader(fastflix)) + + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + + if video.video_settings.start_time: + command.extend(["--seek", str(video.video_settings.start_time)]) + + if video.video_settings.end_time: + command.extend(["--seekto", str(video.video_settings.end_time)]) + + if video.video_settings.source_fps: + command.extend(["--fps", str(video.video_settings.source_fps)]) + + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "av1"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend([f"--{settings.qp_mode}", str(settings.cqp)]) + + if video.video_settings.maxrate: + command.extend( + ["--max-bitrate", str(video.video_settings.maxrate), "--vbv-bufsize", str(video.video_settings.bufsize)] + ) + + if min_q and settings.bitrate: + command.extend(["--qp-min", str(min_q)]) + + if max_q and settings.bitrate: + command.extend(["--qp-max", str(max_q)]) + + if settings.b_frames: + command.extend(["--bframes", str(settings.b_frames)]) + + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--quality", settings.preset]) + + if settings.lookahead: + command.extend(["--la-depth", str(settings.lookahead)]) + + command.extend(["--level", settings.level or "auto"]) + + command.extend(rigaya_auto_options(fastflix)) + if fastflix.current_video.master_display: - master_display = ( - f'--master-display "G{fastflix.current_video.master_display.green}' - f"B{fastflix.current_video.master_display.blue}" - f"R{fastflix.current_video.master_display.red}" - f"WP{fastflix.current_video.master_display.white}" - f'L{fastflix.current_video.master_display.luminance}"' + md = fastflix.current_video.master_display + command.extend( + [ + "--master-display", + f"G{md.green}B{md.blue}R{md.red}WP{md.white}L{md.luminance}", + ] ) - max_cll = None if fastflix.current_video.cll: - max_cll = f'--max-cll "{fastflix.current_video.cll}"' + command.extend(["--max-cll", str(fastflix.current_video.cll)]) - dhdr = None if settings.copy_hdr10: - dhdr = "--dhdr10-info copy" + command.extend(["--dhdr10-info", "copy"]) - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" + command.extend(["--output-depth", bit_depth]) - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", str(video.interlaced)]) + + if video.video_settings.deinterlace: + command.append("--vpp-yadif") - remove_hdr = "" if video.video_settings.remove_hdr: remove_type = ( video.video_settings.tone_map if video.video_settings.tone_map in ("mobius", "hable", "reinhard") else "mobius" ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + if settings.split_mode == "parallel": + command.extend(["--parallel", "auto"]) - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + if settings.adapt_ref: + command.append("--adapt-ref") - min_q = settings.min_q_i - if settings.min_q_i and settings.min_q_p and settings.min_q_b: - min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + if settings.adapt_ltr: + command.append("--adapt-ltr") - max_q = settings.max_q_i - if settings.max_q_i and settings.max_q_p and settings.max_q_b: - max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + if settings.adapt_cqm: + command.append("--adapt-cqm") - try: - stream_id = int(video.current_video_stream["id"], 16) - except Exception: - if len(video.streams.video) > 1: - logger.warning("Could not get stream ID from source, the proper video track may not be selected!") - stream_id = None + if settings.metrics: + command.extend(["--psnr", "--ssim"]) - bit_depth = "8" - if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: - bit_depth = "10" - if settings.force_ten_bit: - bit_depth = "10" + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) - vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" - if video.video_settings.vsync == "cfr": - vsync_setting = "forcecfr" - elif video.video_settings.vsync == "vfr": - vsync_setting = "vfr" + if settings.extra: + command.extend(shlex.split(settings.extra)) + + command.extend(["-o", str(video.video_settings.output_path)]) - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - - command = [ - f'"{clean_file_string(fastflix.config.qsvencc)}"', - rigaya_avformat_reader(fastflix), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "av1", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--{settings.qp_mode} {settings.cqp}"), - vbv, - (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), - (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), - (f"--bframes {settings.b_frames}" if settings.b_frames else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - "--quality", - settings.preset, - (f"--la-depth {settings.lookahead}" if settings.lookahead else ""), - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - (master_display if master_display else ""), - (max_cll if max_cll else ""), - (dhdr if dhdr else ""), - "--output-depth", - bit_depth, - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-yadif" if video.video_settings.deinterlace else ""), - remove_hdr, - "--parallel auto" if settings.split_mode == "parallel" else "", - ("--adapt-ref" if settings.adapt_ref else ""), - ("--adapt-ltr" if settings.adapt_ltr else ""), - ("--adapt-cqm" if settings.adapt_cqm else ""), - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', - ] - - return [Command(command=" ".join(x for x in command if x), name="QSVEncC Encode", exe="QSVEncC")] + return [Command(command=command, name="QSVEncC Encode", exe="QSVEncC")] diff --git a/fastflix/encoders/qsvencc_avc/command_builder.py b/fastflix/encoders/qsvencc_avc/command_builder.py index 90754443..1ed7abe4 100644 --- a/fastflix/encoders/qsvencc_avc/command_builder.py +++ b/fastflix/encoders/qsvencc_avc/command_builder.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import logging +import shlex +from typing import List from fastflix.encoders.common.helpers import Command from fastflix.models.encode import QSVEncCH264Settings @@ -11,7 +13,6 @@ rigaya_auto_options, rigaya_avformat_reader, ) -from fastflix.flix import clean_file_string logger = logging.getLogger("fastflix") @@ -20,42 +21,6 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: QSVEncCH264Settings = fastflix.current_video.video_settings.video_encoder_settings - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" - - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" - - remove_hdr = "" - if video.video_settings.remove_hdr: - remove_type = ( - video.video_settings.tone_map - if video.video_settings.tone_map in ("mobius", "hable", "reinhard") - else "mobius" - ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" - - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" - - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" - - min_q = settings.min_q_i - if settings.min_q_i and settings.min_q_p and settings.min_q_b: - min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" - - max_q = settings.max_q_i - if settings.max_q_i and settings.max_q_p and settings.max_q_b: - max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" - try: stream_id = int(video.current_video_stream["id"], 16) except Exception: @@ -75,60 +40,131 @@ def build(fastflix: FastFlix): elif video.video_settings.vsync == "vfr": vsync_setting = "vfr" - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - - command = [ - f'"{clean_file_string(fastflix.config.qsvencc)}"', - rigaya_avformat_reader(fastflix), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "h264", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--{settings.qp_mode} {settings.cqp}"), - vbv, - (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), - (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), - (f"--bframes {settings.b_frames}" if settings.b_frames else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - "--quality", - settings.preset, - "--profile", - settings.profile, - (f"--la-depth {settings.lookahead}" if settings.lookahead else ""), - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - "--output-depth", - bit_depth, - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-yadif" if video.video_settings.deinterlace else ""), - remove_hdr, - "--parallel auto" if settings.split_mode == "parallel" else "", - ("--adapt-ref" if settings.adapt_ref else ""), - ("--adapt-ltr" if settings.adapt_ltr else ""), - ("--adapt-cqm" if settings.adapt_cqm else ""), - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', - ] - - return [Command(command=" ".join(x for x in command if x), name="QSVEncC Encode", exe="QSVEncC")] + min_q = settings.min_q_i + if settings.min_q_i and settings.min_q_p and settings.min_q_b: + min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + + max_q = settings.max_q_i + if settings.max_q_i and settings.max_q_p and settings.max_q_b: + max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + + command: List[str] = [str(fastflix.config.qsvencc)] + + command.extend(rigaya_avformat_reader(fastflix)) + + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + + if video.video_settings.start_time: + command.extend(["--seek", str(video.video_settings.start_time)]) + + if video.video_settings.end_time: + command.extend(["--seekto", str(video.video_settings.end_time)]) + + if video.video_settings.source_fps: + command.extend(["--fps", str(video.video_settings.source_fps)]) + + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "h264"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend([f"--{settings.qp_mode}", str(settings.cqp)]) + + if video.video_settings.maxrate: + command.extend( + ["--max-bitrate", str(video.video_settings.maxrate), "--vbv-bufsize", str(video.video_settings.bufsize)] + ) + + if min_q and settings.bitrate: + command.extend(["--qp-min", str(min_q)]) + + if max_q and settings.bitrate: + command.extend(["--qp-max", str(max_q)]) + + if settings.b_frames: + command.extend(["--bframes", str(settings.b_frames)]) + + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--quality", settings.preset]) + command.extend(["--profile", settings.profile]) + + if settings.lookahead: + command.extend(["--la-depth", str(settings.lookahead)]) + + command.extend(["--level", settings.level or "auto"]) + + command.extend(rigaya_auto_options(fastflix)) + + command.extend(["--output-depth", bit_depth]) + + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", str(video.interlaced)]) + + if video.video_settings.deinterlace: + command.append("--vpp-yadif") + + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + + if settings.split_mode == "parallel": + command.extend(["--parallel", "auto"]) + + if settings.adapt_ref: + command.append("--adapt-ref") + + if settings.adapt_ltr: + command.append("--adapt-ltr") + + if settings.adapt_cqm: + command.append("--adapt-cqm") + + if settings.metrics: + command.extend(["--psnr", "--ssim"]) + + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + + if settings.extra: + command.extend(shlex.split(settings.extra)) + + command.extend(["-o", str(video.video_settings.output_path)]) + + return [Command(command=command, name="QSVEncC Encode", exe="QSVEncC")] diff --git a/fastflix/encoders/qsvencc_hevc/command_builder.py b/fastflix/encoders/qsvencc_hevc/command_builder.py index f2a7fdc3..0b5de7db 100644 --- a/fastflix/encoders/qsvencc_hevc/command_builder.py +++ b/fastflix/encoders/qsvencc_hevc/command_builder.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import logging +import shlex +from typing import List from fastflix.encoders.common.helpers import Command from fastflix.models.encode import QSVEncCSettings @@ -11,7 +13,6 @@ rigaya_auto_options, rigaya_avformat_reader, ) -from fastflix.flix import clean_file_string logger = logging.getLogger("fastflix") @@ -20,134 +21,164 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: QSVEncCSettings = fastflix.current_video.video_settings.video_encoder_settings - master_display = None + try: + stream_id = int(video.current_video_stream["id"], 16) + except Exception: + if len(video.streams.video) > 1: + logger.warning("Could not get stream ID from source, the proper video track may not be selected!") + stream_id = None + + bit_depth = "8" + if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: + bit_depth = "10" + if settings.force_ten_bit: + bit_depth = "10" + + vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" + if video.video_settings.vsync == "cfr": + vsync_setting = "forcecfr" + elif video.video_settings.vsync == "vfr": + vsync_setting = "vfr" + + min_q = settings.min_q_i + if settings.min_q_i and settings.min_q_p and settings.min_q_b: + min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + + max_q = settings.max_q_i + if settings.max_q_i and settings.max_q_p and settings.max_q_b: + max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + + command: List[str] = [str(fastflix.config.qsvencc)] + + command.extend(rigaya_avformat_reader(fastflix)) + + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + + if video.video_settings.start_time: + command.extend(["--seek", str(video.video_settings.start_time)]) + + if video.video_settings.end_time: + command.extend(["--seekto", str(video.video_settings.end_time)]) + + if video.video_settings.source_fps: + command.extend(["--fps", str(video.video_settings.source_fps)]) + + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "hevc"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend([f"--{settings.qp_mode}", str(settings.cqp)]) + + if video.video_settings.maxrate: + command.extend( + ["--max-bitrate", str(video.video_settings.maxrate), "--vbv-bufsize", str(video.video_settings.bufsize)] + ) + + if min_q and settings.bitrate: + command.extend(["--qp-min", str(min_q)]) + + if max_q and settings.bitrate: + command.extend(["--qp-max", str(max_q)]) + + if settings.b_frames: + command.extend(["--bframes", str(settings.b_frames)]) + + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--quality", settings.preset]) + + if settings.lookahead: + command.extend(["--la-depth", str(settings.lookahead)]) + + command.extend(["--level", settings.level or "auto"]) + + command.extend(rigaya_auto_options(fastflix)) + if fastflix.current_video.master_display: - master_display = ( - f'--master-display "G{fastflix.current_video.master_display.green}' - f"B{fastflix.current_video.master_display.blue}" - f"R{fastflix.current_video.master_display.red}" - f"WP{fastflix.current_video.master_display.white}" - f'L{fastflix.current_video.master_display.luminance}"' + md = fastflix.current_video.master_display + command.extend( + [ + "--master-display", + f"G{md.green}B{md.blue}R{md.red}WP{md.white}L{md.luminance}", + ] ) - max_cll = None if fastflix.current_video.cll: - max_cll = f'--max-cll "{fastflix.current_video.cll}"' + command.extend(["--max-cll", str(fastflix.current_video.cll)]) - dhdr = None if settings.copy_hdr10: - dhdr = "--dhdr10-info copy" + command.extend(["--dhdr10-info", "copy"]) - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" + command.extend(["--output-depth", bit_depth]) - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", str(video.interlaced)]) + + if video.video_settings.deinterlace: + command.append("--vpp-yadif") - remove_hdr = "" if video.video_settings.remove_hdr: remove_type = ( video.video_settings.tone_map if video.video_settings.tone_map in ("mobius", "hable", "reinhard") else "mobius" ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" + if settings.split_mode == "parallel": + command.extend(["--parallel", "auto"]) - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" + if settings.adapt_ref: + command.append("--adapt-ref") - min_q = settings.min_q_i - if settings.min_q_i and settings.min_q_p and settings.min_q_b: - min_q = f"{settings.min_q_i}:{settings.min_q_p}:{settings.min_q_b}" + if settings.adapt_ltr: + command.append("--adapt-ltr") - max_q = settings.max_q_i - if settings.max_q_i and settings.max_q_p and settings.max_q_b: - max_q = f"{settings.max_q_i}:{settings.max_q_p}:{settings.max_q_b}" + if settings.adapt_cqm: + command.append("--adapt-cqm") - try: - stream_id = int(video.current_video_stream["id"], 16) - except Exception: - if len(video.streams.video) > 1: - logger.warning("Could not get stream ID from source, the proper video track may not be selected!") - stream_id = None + if settings.metrics: + command.extend(["--psnr", "--ssim"]) - bit_depth = "8" - if video.current_video_stream.bit_depth > 8 and not video.video_settings.remove_hdr: - bit_depth = "10" - if settings.force_ten_bit: - bit_depth = "10" + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) - vsync_setting = "cfr" if video.frame_rate == video.average_frame_rate else "vfr" - if video.video_settings.vsync == "cfr": - vsync_setting = "forcecfr" - elif video.video_settings.vsync == "vfr": - vsync_setting = "vfr" + if settings.extra: + command.extend(shlex.split(settings.extra)) + + command.extend(["-o", str(video.video_settings.output_path)]) - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - - command = [ - f'"{clean_file_string(fastflix.config.qsvencc)}"', - rigaya_avformat_reader(fastflix), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "hevc", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--{settings.qp_mode} {settings.cqp}"), - vbv, - (f"--qp-min {min_q}" if min_q and settings.bitrate else ""), - (f"--qp-max {max_q}" if max_q and settings.bitrate else ""), - (f"--bframes {settings.b_frames}" if settings.b_frames else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - "--quality", - settings.preset, - (f"--la-depth {settings.lookahead}" if settings.lookahead else ""), - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - (master_display if master_display else ""), - (max_cll if max_cll else ""), - (dhdr if dhdr else ""), - "--output-depth", - bit_depth, - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-yadif" if video.video_settings.deinterlace else ""), - remove_hdr, - "--parallel auto" if settings.split_mode == "parallel" else "", - ("--adapt-ref" if settings.adapt_ref else ""), - ("--adapt-ltr" if settings.adapt_ltr else ""), - ("--adapt-cqm" if settings.adapt_cqm else ""), - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', - ] - - return [Command(command=" ".join(x for x in command if x), name="QSVEncC Encode", exe="QSVEncC")] + return [Command(command=command, name="QSVEncC Encode", exe="QSVEncC")] diff --git a/fastflix/encoders/rav1e/command_builder.py b/fastflix/encoders/rav1e/command_builder.py index 3c8ce349..a574e72d 100644 --- a/fastflix/encoders/rav1e/command_builder.py +++ b/fastflix/encoders/rav1e/command_builder.py @@ -3,6 +3,7 @@ import logging import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null from fastflix.models.encode import rav1eSettings @@ -15,16 +16,21 @@ def build(fastflix: FastFlix): settings: rav1eSettings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "librav1e") - beginning += ( - "-strict experimental " - f"-speed {settings.speed} " - f"-tile-columns {settings.tile_columns} " - f"-tile-rows {settings.tile_rows} " - f"-tiles {settings.tiles} " - f"{generate_color_details(fastflix)} " + beginning.extend( + [ + "-strict", + "experimental", + "-speed", + str(settings.speed), + "-tile-columns", + str(settings.tile_columns), + "-tile-rows", + str(settings.tile_rows), + "-tiles", + str(settings.tiles), + ] ) - - # if not fastflix.current_video.video_settings.remove_hdr: + beginning.extend(generate_color_details(fastflix)) # Currently unsupported https://github.com/xiph/rav1e/issues/2554 # rav1e_options = [] @@ -46,20 +52,30 @@ def build(fastflix: FastFlix): if not settings.single_pass: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" - beginning += f'-passlogfile "{pass_log_file}" ' + beginning.extend(["-passlogfile", str(pass_log_file)]) pass_type = "bitrate" if settings.bitrate else "QP" + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + if not settings.bitrate: - command_1 = f"{beginning} -qp {settings.qp} {settings.extra} {ending}" + command_1 = beginning + ["-qp", str(settings.qp)] + extra + ending return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] if settings.single_pass: - command_1 = f"{beginning} -b:v {settings.bitrate} {settings.extra} {ending}" + command_1 = beginning + ["-b:v", settings.bitrate] + extra + ending return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] else: - command_1 = f"{beginning} -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an {output_fps} -f matroska {null}" - command_2 = f"{beginning} -b:v {settings.bitrate} -pass 2 {settings.extra} {ending}" + command_1 = ( + beginning + + ["-b:v", settings.bitrate, "-pass", "1"] + + extra_both + + ["-an"] + + output_fps + + ["-f", "matroska", null] + ) + command_2 = beginning + ["-b:v", settings.bitrate, "-pass", "2"] + extra + ending return [ Command(command=command_1, name=f"First pass {pass_type}", exe="ffmpeg"), Command(command=command_2, name=f"Second pass {pass_type} ", exe="ffmpeg"), diff --git a/fastflix/encoders/svt_av1/command_builder.py b/fastflix/encoders/svt_av1/command_builder.py index fea25f04..ce4d75ee 100644 --- a/fastflix/encoders/svt_av1/command_builder.py +++ b/fastflix/encoders/svt_av1/command_builder.py @@ -3,6 +3,7 @@ import logging import secrets +import shlex import reusables @@ -18,7 +19,8 @@ def build(fastflix: FastFlix): settings: SVTAV1Settings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "libsvtav1") - beginning += f"-strict experimental -preset {settings.speed} {generate_color_details(fastflix)} " + beginning.extend(["-strict", "experimental", "-preset", str(settings.speed)]) + beginning.extend(generate_color_details(fastflix)) svtav1_params = settings.svtav1_params.copy() svtav1_params.extend( @@ -78,31 +80,48 @@ def convert_me(two_numbers, conversion_rate=50_000) -> str: svtav1_params.append("enable-hdr=1") if svtav1_params: - beginning += f' -svtav1-params "{":".join(svtav1_params)}" ' + beginning.extend(["-svtav1-params", ":".join(svtav1_params)]) if not settings.single_pass: pass_log_file = f"pass_log_file_{secrets.token_hex(10)}" - beginning += f'-passlogfile "{pass_log_file}" ' + beginning.extend(["-passlogfile", pass_log_file]) pass_type = "bitrate" if settings.bitrate else "QP" + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + if settings.single_pass: if settings.bitrate: - command_1 = f"{beginning} -b:v {settings.bitrate} {settings.extra} {ending}" + command_1 = beginning + ["-b:v", settings.bitrate] + extra + ending elif settings.qp is not None: - command_1 = f"{beginning} -{settings.qp_mode} {settings.qp} {settings.extra} {ending}" + command_1 = beginning + [f"-{settings.qp_mode}", str(settings.qp)] + extra + ending else: return [] return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] else: if settings.bitrate: - command_1 = f"{beginning} -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an {output_fps} -f matroska {null}" - command_2 = f"{beginning} -b:v {settings.bitrate} -pass 2 {settings.extra} {ending}" + command_1 = ( + beginning + + ["-b:v", settings.bitrate, "-pass", "1"] + + extra_both + + ["-an"] + + output_fps + + ["-f", "matroska", null] + ) + command_2 = beginning + ["-b:v", settings.bitrate, "-pass", "2"] + extra + ending elif settings.qp is not None: - command_1 = f"{beginning} -{settings.qp_mode} {settings.qp} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an {output_fps} -f matroska {null}" - command_2 = f"{beginning} -{settings.qp_mode} {settings.qp} -pass 2 {settings.extra} {ending}" + command_1 = ( + beginning + + [f"-{settings.qp_mode}", str(settings.qp), "-pass", "1"] + + extra_both + + ["-an"] + + output_fps + + ["-f", "matroska", null] + ) + command_2 = beginning + [f"-{settings.qp_mode}", str(settings.qp), "-pass", "2"] + extra + ending else: return [] return [ diff --git a/fastflix/encoders/svt_av1_avif/command_builder.py b/fastflix/encoders/svt_av1_avif/command_builder.py index 93e98abe..3a18402f 100644 --- a/fastflix/encoders/svt_av1_avif/command_builder.py +++ b/fastflix/encoders/svt_av1_avif/command_builder.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import logging +import shlex import reusables @@ -17,7 +18,8 @@ def build(fastflix: FastFlix): settings: SVTAVIFSettings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "libsvtav1", audio=False) - beginning += f"-strict experimental -preset {settings.speed} {generate_color_details(fastflix)} " + beginning.extend(["-strict", "experimental", "-preset", str(settings.speed)]) + beginning.extend(generate_color_details(fastflix)) svtav1_params = settings.svtav1_params.copy() @@ -66,15 +68,17 @@ def convert_me(two_numbers, conversion_rate=50_000) -> str: svtav1_params.append("enable-hdr=1") if svtav1_params: - beginning += f' -svtav1-params "{":".join(svtav1_params)}" ' + beginning.extend(["-svtav1-params", ":".join(svtav1_params)]) pass_type = "bitrate" if settings.bitrate else "QP" + extra = shlex.split(settings.extra) if settings.extra else [] + if settings.bitrate: - command_1 = f"{beginning} -b:v {settings.bitrate} {settings.extra} -f avif {ending}" + command_1 = beginning + ["-b:v", settings.bitrate] + extra + ["-f", "avif"] + ending elif settings.qp is not None: - command_1 = f"{beginning} -{settings.qp_mode} {settings.qp} {settings.extra} -f avif {ending}" + command_1 = beginning + [f"-{settings.qp_mode}", str(settings.qp)] + extra + ["-f", "avif"] + ending else: return [] return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] diff --git a/fastflix/encoders/vaapi_h264/command_builder.py b/fastflix/encoders/vaapi_h264/command_builder.py index 7480f9b7..9d3da157 100644 --- a/fastflix/encoders/vaapi_h264/command_builder.py +++ b/fastflix/encoders/vaapi_h264/command_builder.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import logging +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details from fastflix.models.encode import VAAPIH264Settings @@ -12,7 +13,16 @@ def build(fastflix: FastFlix): settings: VAAPIH264Settings = fastflix.current_video.video_settings.video_encoder_settings - start_extra = f"-init_hw_device vaapi=hwdev:{settings.vaapi_device} -hwaccel vaapi -hwaccel_device hwdev -hwaccel_output_format vaapi " + start_extra = [ + "-init_hw_device", + f"vaapi=hwdev:{settings.vaapi_device}", + "-hwaccel", + "vaapi", + "-hwaccel_device", + "hwdev", + "-hwaccel_output_format", + "vaapi", + ] beginning, ending, output_fps = generate_all( fastflix, "h264_vaapi", @@ -21,23 +31,29 @@ def build(fastflix: FastFlix): vaapi=True, ) - beginning += ( - f"-rc_mode {settings.rc_mode} " - f"-async_depth {settings.async_depth} " - f"-b_depth {settings.b_depth} " - f"-idr_interval {settings.idr_interval} " - f"{generate_color_details(fastflix)} " - "-filter_hw_device hwdev " + beginning.extend( + [ + "-rc_mode", + str(settings.rc_mode), + "-async_depth", + str(settings.async_depth), + "-b_depth", + str(settings.b_depth), + "-idr_interval", + str(settings.idr_interval), + ] ) + beginning.extend(generate_color_details(fastflix)) + beginning.extend(["-filter_hw_device", "hwdev"]) if settings.aud: - beginning += "-aud 1 " + beginning.extend(["-aud", "1"]) if settings.low_power: - beginning += "-low-power 1 " + beginning.extend(["-low-power", "1"]) if settings.level: - beginning += f"-level {settings.level} " + beginning.extend(["-level", str(settings.level)]) # ffmpeg -init_hw_device vaapi=foo:/dev/dri/renderD128 -hwaccel_device foo -i input.mp4 -filter_hw_device foo -vf 'format=nv12|vaapi,hwupload' @@ -68,11 +84,15 @@ def build(fastflix: FastFlix): pass_type = "bitrate" if settings.bitrate else "QP" if not settings.bitrate: - command_1 = f"{beginning} -qp {settings.qp} {settings.extra} {ending}" + command_1 = ( + beginning + ["-qp", str(settings.qp)] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # if settings.single_pass: - command_1 = f"{beginning} -b:v {settings.bitrate} {settings.extra} {ending}" + command_1 = ( + beginning + ["-b:v", settings.bitrate] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # else: # command_1 = f"{beginning} -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f matroska {null}" diff --git a/fastflix/encoders/vaapi_hevc/command_builder.py b/fastflix/encoders/vaapi_hevc/command_builder.py index 79ad839e..ec67a27e 100644 --- a/fastflix/encoders/vaapi_hevc/command_builder.py +++ b/fastflix/encoders/vaapi_hevc/command_builder.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import logging +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details from fastflix.models.encode import VAAPIHEVCSettings @@ -12,7 +13,16 @@ def build(fastflix: FastFlix): settings: VAAPIHEVCSettings = fastflix.current_video.video_settings.video_encoder_settings - start_extra = f"-init_hw_device vaapi=hwdev:{settings.vaapi_device} -hwaccel vaapi -hwaccel_device hwdev -hwaccel_output_format vaapi " + start_extra = [ + "-init_hw_device", + f"vaapi=hwdev:{settings.vaapi_device}", + "-hwaccel", + "vaapi", + "-hwaccel_device", + "hwdev", + "-hwaccel_output_format", + "vaapi", + ] beginning, ending, output_fps = generate_all( fastflix, "hevc_vaapi", @@ -21,23 +31,29 @@ def build(fastflix: FastFlix): vaapi=True, ) - beginning += ( - f"-rc_mode {settings.rc_mode} " - f"-async_depth {settings.async_depth} " - f"-b_depth {settings.b_depth} " - f"-idr_interval {settings.idr_interval} " - f"{generate_color_details(fastflix)} " - "-filter_hw_device hwdev " + beginning.extend( + [ + "-rc_mode", + str(settings.rc_mode), + "-async_depth", + str(settings.async_depth), + "-b_depth", + str(settings.b_depth), + "-idr_interval", + str(settings.idr_interval), + ] ) + beginning.extend(generate_color_details(fastflix)) + beginning.extend(["-filter_hw_device", "hwdev"]) if settings.aud: - beginning += "-aud 1 " + beginning.extend(["-aud", "1"]) if settings.low_power: - beginning += "-low-power 1 " + beginning.extend(["-low-power", "1"]) if settings.level: - beginning += f"-level {settings.level} " + beginning.extend(["-level", str(settings.level)]) # ffmpeg -init_hw_device vaapi=foo:/dev/dri/renderD128 -hwaccel_device foo -i input.mp4 -filter_hw_device foo -vf 'format=nv12|vaapi,hwupload' @@ -68,11 +84,15 @@ def build(fastflix: FastFlix): pass_type = "bitrate" if settings.bitrate else "QP" if not settings.bitrate: - command_1 = f"{beginning} -qp {settings.qp} {settings.extra} {ending}" + command_1 = ( + beginning + ["-qp", str(settings.qp)] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # if settings.single_pass: - command_1 = f"{beginning} -b:v {settings.bitrate} {settings.extra} {ending}" + command_1 = ( + beginning + ["-b:v", settings.bitrate] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # else: # command_1 = f"{beginning} -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f matroska {null}" diff --git a/fastflix/encoders/vaapi_mpeg2/command_builder.py b/fastflix/encoders/vaapi_mpeg2/command_builder.py index 14ca2638..77025762 100644 --- a/fastflix/encoders/vaapi_mpeg2/command_builder.py +++ b/fastflix/encoders/vaapi_mpeg2/command_builder.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import logging +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details from fastflix.models.encode import VAAPIMPEG2Settings @@ -12,7 +13,16 @@ def build(fastflix: FastFlix): settings: VAAPIMPEG2Settings = fastflix.current_video.video_settings.video_encoder_settings - start_extra = f"-init_hw_device vaapi=hwdev:{settings.vaapi_device} -hwaccel vaapi -hwaccel_device hwdev -hwaccel_output_format vaapi " + start_extra = [ + "-init_hw_device", + f"vaapi=hwdev:{settings.vaapi_device}", + "-hwaccel", + "vaapi", + "-hwaccel_device", + "hwdev", + "-hwaccel_output_format", + "vaapi", + ] beginning, ending, output_fps = generate_all( fastflix, "mpeg2_vaapi", @@ -21,16 +31,21 @@ def build(fastflix: FastFlix): vaapi=True, ) - beginning += ( - f"-rc_mode {settings.rc_mode} " - f"-b_depth {settings.b_depth} " - f"-idr_interval {settings.idr_interval} " - f"{generate_color_details(fastflix)} " - "-filter_hw_device hwdev " + beginning.extend( + [ + "-rc_mode", + str(settings.rc_mode), + "-b_depth", + str(settings.b_depth), + "-idr_interval", + str(settings.idr_interval), + ] ) + beginning.extend(generate_color_details(fastflix)) + beginning.extend(["-filter_hw_device", "hwdev"]) if settings.low_power: - beginning += "-low-power 1 " + beginning.extend(["-low-power", "1"]) # ffmpeg -init_hw_device vaapi=foo:/dev/dri/renderD128 -hwaccel_device foo -i input.mp4 -filter_hw_device foo -vf 'format=nv12|vaapi,hwupload' @@ -61,11 +76,15 @@ def build(fastflix: FastFlix): pass_type = "bitrate" if settings.bitrate else "QP" if not settings.bitrate: - command_1 = f"{beginning} -qp {settings.qp} {settings.extra} {ending}" + command_1 = ( + beginning + ["-qp", str(settings.qp)] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # if settings.single_pass: - command_1 = f"{beginning} -b:v {settings.bitrate} {settings.extra} {ending}" + command_1 = ( + beginning + ["-b:v", settings.bitrate] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # else: # command_1 = f"{beginning} -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f matroska {null}" diff --git a/fastflix/encoders/vaapi_vp9/command_builder.py b/fastflix/encoders/vaapi_vp9/command_builder.py index ad3cf667..5d84e9db 100644 --- a/fastflix/encoders/vaapi_vp9/command_builder.py +++ b/fastflix/encoders/vaapi_vp9/command_builder.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import logging +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details from fastflix.models.encode import VAAPIVP9Settings @@ -12,7 +13,16 @@ def build(fastflix: FastFlix): settings: VAAPIVP9Settings = fastflix.current_video.video_settings.video_encoder_settings - start_extra = f"-init_hw_device vaapi=hwdev:{settings.vaapi_device} -hwaccel vaapi -hwaccel_device hwdev -hwaccel_output_format vaapi " + start_extra = [ + "-init_hw_device", + f"vaapi=hwdev:{settings.vaapi_device}", + "-hwaccel", + "vaapi", + "-hwaccel_device", + "hwdev", + "-hwaccel_output_format", + "vaapi", + ] beginning, ending, output_fps = generate_all( fastflix, "vp9_vaapi", @@ -21,16 +31,21 @@ def build(fastflix: FastFlix): vaapi=True, ) - beginning += ( - f"-rc_mode {settings.rc_mode} " - f"-b_depth {settings.b_depth} " - f"-idr_interval {settings.idr_interval} " - f"{generate_color_details(fastflix)} " - "-filter_hw_device hwdev " + beginning.extend( + [ + "-rc_mode", + str(settings.rc_mode), + "-b_depth", + str(settings.b_depth), + "-idr_interval", + str(settings.idr_interval), + ] ) + beginning.extend(generate_color_details(fastflix)) + beginning.extend(["-filter_hw_device", "hwdev"]) if settings.low_power: - beginning += "-low-power 1 " + beginning.extend(["-low-power", "1"]) # ffmpeg -init_hw_device vaapi=foo:/dev/dri/renderD128 -hwaccel_device foo -i input.mp4 -filter_hw_device foo -vf 'format=nv12|vaapi,hwupload' @@ -61,11 +76,15 @@ def build(fastflix: FastFlix): pass_type = "bitrate" if settings.bitrate else "QP" if not settings.bitrate: - command_1 = f"{beginning} -qp {settings.qp} {settings.extra} {ending}" + command_1 = ( + beginning + ["-qp", str(settings.qp)] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # if settings.single_pass: - command_1 = f"{beginning} -b:v {settings.bitrate} {settings.extra} {ending}" + command_1 = ( + beginning + ["-b:v", settings.bitrate] + (shlex.split(settings.extra) if settings.extra else []) + ending + ) return [Command(command=command_1, name=f"{pass_type}", exe="ffmpeg")] # else: # command_1 = f"{beginning} -b:v {settings.bitrate} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an -f matroska {null}" diff --git a/fastflix/encoders/vceencc_av1/command_builder.py b/fastflix/encoders/vceencc_av1/command_builder.py index 2c2e14ed..138bf64e 100644 --- a/fastflix/encoders/vceencc_av1/command_builder.py +++ b/fastflix/encoders/vceencc_av1/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +import shlex from fastflix.encoders.common.helpers import Command from fastflix.models.encode import VCEEncCAV1Settings @@ -12,7 +13,6 @@ rigaya_avformat_reader, pa_builder, ) -from fastflix.flix import clean_file_string logger = logging.getLogger("fastflix") @@ -21,52 +21,6 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: VCEEncCAV1Settings = fastflix.current_video.video_settings.video_encoder_settings - master_display = None - if fastflix.current_video.master_display: - master_display = ( - f'--master-display "G{fastflix.current_video.master_display.green}' - f"B{fastflix.current_video.master_display.blue}" - f"R{fastflix.current_video.master_display.red}" - f"WP{fastflix.current_video.master_display.white}" - f'L{fastflix.current_video.master_display.luminance}"' - ) - - max_cll = None - if fastflix.current_video.cll: - max_cll = f'--max-cll "{fastflix.current_video.cll}"' - - dhdr = None - if settings.copy_hdr10: - dhdr = "--dhdr10-info copy" - - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" - - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" - - remove_hdr = "" - if video.video_settings.remove_hdr: - remove_type = ( - video.video_settings.tone_map - if video.video_settings.tone_map in ("mobius", "hable", "reinhard") - else "mobius" - ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" - - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" - - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" - try: stream_id = int(video.current_video_stream["id"], 16) except Exception: @@ -80,8 +34,6 @@ def build(fastflix: FastFlix): elif video.video_settings.vsync == "vfr": vsync_setting = "vfr" - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - output_depth = settings.output_depth if not settings.output_depth: output_depth = ( @@ -92,60 +44,113 @@ def build(fastflix: FastFlix): ) command = [ - f'"{clean_file_string(fastflix.config.vceencc)}"', - rigaya_avformat_reader(fastflix), - "--device", - str(settings.device), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "av1", - (f"--{settings.bitrate_mode} {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), - vbv, - (f"--qp-min {settings.min_q}" if settings.min_q and settings.bitrate else ""), - (f"--qp-max {settings.max_q}" if settings.max_q and settings.bitrate else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - "--preset", - settings.preset, - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - (master_display if master_display else ""), - (max_cll if max_cll else ""), - (dhdr if dhdr else ""), - "--output-depth", - output_depth, - "--motion-est", - settings.mv_precision, - ("--vbaq" if settings.vbaq else ""), - ("--pe" if settings.pre_encode else ""), - pa_builder(settings), - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-nnedi" if video.video_settings.deinterlace else ""), - remove_hdr, - "--parallel auto" if settings.split_mode == "parallel" else "", - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', + str(fastflix.config.vceencc), ] + command.extend(rigaya_avformat_reader(fastflix)) + command.extend(["--device", str(settings.device)]) + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + if video.video_settings.start_time: + command.extend(["--seek", video.video_settings.start_time]) + if video.video_settings.end_time: + command.extend(["--seekto", video.video_settings.end_time]) + if video.video_settings.source_fps: + command.extend(["--fps", video.video_settings.source_fps]) + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "av1"]) + + if settings.bitrate: + command.extend([f"--{settings.bitrate_mode}", settings.bitrate.rstrip("k")]) + else: + command.extend(["--cqp", settings.cqp]) + + if video.video_settings.maxrate: + command.extend(["--max-bitrate", str(video.video_settings.maxrate)]) + command.extend(["--vbv-bufsize", str(video.video_settings.bufsize)]) + if settings.min_q and settings.bitrate: + command.extend(["--qp-min", str(settings.min_q)]) + if settings.max_q and settings.bitrate: + command.extend(["--qp-max", str(settings.max_q)]) + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--preset", settings.preset]) + command.extend(["--level", settings.level or "auto"]) + + command.extend(rigaya_auto_options(fastflix)) + + if fastflix.current_video.master_display: + md = fastflix.current_video.master_display + command.extend( + [ + "--master-display", + f"G{md.green}B{md.blue}R{md.red}WP{md.white}L{md.luminance}", + ] + ) + if fastflix.current_video.cll: + command.extend(["--max-cll", str(fastflix.current_video.cll)]) + if settings.copy_hdr10: + command.extend(["--dhdr10-info", "copy"]) + + command.extend(["--output-depth", output_depth]) + command.extend(["--motion-est", settings.mv_precision]) + + if settings.vbaq: + command.append("--vbaq") + if settings.pre_encode: + command.append("--pe") + + pa = pa_builder(settings) + if pa: + command.append(pa) + + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", video.interlaced]) + if video.video_settings.deinterlace: + command.append("--vpp-nnedi") + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + if settings.split_mode == "parallel": + command.extend(["--parallel", "auto"]) + if settings.metrics: + command.extend(["--psnr", "--ssim"]) + + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + + if settings.extra: + command.extend(shlex.split(settings.extra)) + + command.extend(["-o", str(video.video_settings.output_path)]) - return [Command(command=" ".join(x for x in command if x), name="VCEEncC Encode", exe="VCEEncC")] + return [Command(command=command, name="VCEEncC Encode", exe="VCEEncC")] diff --git a/fastflix/encoders/vceencc_avc/command_builder.py b/fastflix/encoders/vceencc_avc/command_builder.py index 81b8cb4c..f0994a27 100644 --- a/fastflix/encoders/vceencc_avc/command_builder.py +++ b/fastflix/encoders/vceencc_avc/command_builder.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- import logging +import shlex from fastflix.encoders.common.helpers import Command from fastflix.models.encode import VCEEncCAVCSettings from fastflix.models.video import Video from fastflix.models.fastflix import FastFlix -from fastflix.shared import clean_file_string from fastflix.encoders.common.encc_helpers import ( build_subtitle, build_audio, @@ -21,34 +21,6 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: VCEEncCAVCSettings = fastflix.current_video.video_settings.video_encoder_settings - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" - - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" - - remove_hdr = "" - if video.video_settings.remove_hdr: - remove_type = ( - video.video_settings.tone_map - if video.video_settings.tone_map in ("mobius", "hable", "reinhard") - else "mobius" - ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" - - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" - - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" - try: stream_id = int(video.current_video_stream["id"], 16) except Exception: @@ -62,12 +34,6 @@ def build(fastflix: FastFlix): elif video.video_settings.vsync == "vfr": vsync_setting = "vfr" - profile_opt = "" - if settings.profile.lower() != "auto": - profile_opt = f"--profile {settings.profile}" - - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - output_depth = settings.output_depth if not settings.output_depth: output_depth = ( @@ -78,59 +44,106 @@ def build(fastflix: FastFlix): ) command = [ - f'"{clean_file_string(fastflix.config.vceencc)}"', - rigaya_avformat_reader(fastflix), - "--device", - str(settings.device), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "avc", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), - vbv, - (f"--qp-min {settings.min_q}" if settings.min_q and settings.bitrate else ""), - (f"--qp-max {settings.max_q}" if settings.max_q and settings.bitrate else ""), - (f"--bframes {settings.b_frames}" if settings.b_frames else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - "--preset", - settings.preset, - profile_opt, - "--level", - (settings.level or "auto"), - "--output-depth", - output_depth, - rigaya_auto_options(fastflix), - "--motion-est", - settings.mv_precision, - ("--vbaq" if settings.vbaq else ""), - ("--pe" if settings.pre_encode else ""), - pa_builder(settings), - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-nnedi" if video.video_settings.deinterlace else ""), - remove_hdr, - "--parallel auto" if settings.split_mode == "parallel" else "", - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', + str(fastflix.config.vceencc), ] + command.extend(rigaya_avformat_reader(fastflix)) + command.extend(["--device", str(settings.device)]) + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + if video.video_settings.start_time: + command.extend(["--seek", video.video_settings.start_time]) + if video.video_settings.end_time: + command.extend(["--seekto", video.video_settings.end_time]) + if video.video_settings.source_fps: + command.extend(["--fps", video.video_settings.source_fps]) + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "avc"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend(["--cqp", settings.cqp]) + + if video.video_settings.maxrate: + command.extend(["--max-bitrate", str(video.video_settings.maxrate)]) + command.extend(["--vbv-bufsize", str(video.video_settings.bufsize)]) + if settings.min_q and settings.bitrate: + command.extend(["--qp-min", str(settings.min_q)]) + if settings.max_q and settings.bitrate: + command.extend(["--qp-max", str(settings.max_q)]) + if settings.b_frames: + command.extend(["--bframes", str(settings.b_frames)]) + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--preset", settings.preset]) + + if settings.profile.lower() != "auto": + command.extend(["--profile", settings.profile]) + + command.extend(["--level", settings.level or "auto"]) + command.extend(["--output-depth", output_depth]) + + command.extend(rigaya_auto_options(fastflix)) + + command.extend(["--motion-est", settings.mv_precision]) + + if settings.vbaq: + command.append("--vbaq") + if settings.pre_encode: + command.append("--pe") + + pa = pa_builder(settings) + if pa: + command.append(pa) + + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", video.interlaced]) + if video.video_settings.deinterlace: + command.append("--vpp-nnedi") + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + if settings.split_mode == "parallel": + command.extend(["--parallel", "auto"]) + if settings.metrics: + command.extend(["--psnr", "--ssim"]) + + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + + if settings.extra: + command.extend(shlex.split(settings.extra)) + + command.extend(["-o", str(video.video_settings.output_path)]) - return [Command(command=" ".join(x for x in command if x), name="VCEEncC Encode", exe="VCEEncC")] + return [Command(command=command, name="VCEEncC Encode", exe="VCEEncC")] diff --git a/fastflix/encoders/vceencc_hevc/command_builder.py b/fastflix/encoders/vceencc_hevc/command_builder.py index 08f5a0b9..1ddbee18 100644 --- a/fastflix/encoders/vceencc_hevc/command_builder.py +++ b/fastflix/encoders/vceencc_hevc/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +import shlex from fastflix.encoders.common.helpers import Command from fastflix.models.encode import VCEEncCSettings @@ -12,7 +13,6 @@ rigaya_auto_options, pa_builder, ) -from fastflix.flix import clean_file_string logger = logging.getLogger("fastflix") @@ -21,52 +21,6 @@ def build(fastflix: FastFlix): video: Video = fastflix.current_video settings: VCEEncCSettings = fastflix.current_video.video_settings.video_encoder_settings - master_display = None - if fastflix.current_video.master_display: - master_display = ( - f'--master-display "G{fastflix.current_video.master_display.green}' - f"B{fastflix.current_video.master_display.blue}" - f"R{fastflix.current_video.master_display.red}" - f"WP{fastflix.current_video.master_display.white}" - f'L{fastflix.current_video.master_display.luminance}"' - ) - - max_cll = None - if fastflix.current_video.cll: - max_cll = f'--max-cll "{fastflix.current_video.cll}"' - - dhdr = None - if settings.copy_hdr10: - dhdr = "--dhdr10-info copy" - - seek = "" - seekto = "" - if video.video_settings.start_time: - seek = f"--seek {video.video_settings.start_time}" - if video.video_settings.end_time: - seekto = f"--seekto {video.video_settings.end_time}" - - transform = "" - if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: - transform = f"--vpp-transform flip_x={'true' if video.video_settings.horizontal_flip else 'false'},flip_y={'true' if video.video_settings.vertical_flip else 'false'}" - - remove_hdr = "" - if video.video_settings.remove_hdr: - remove_type = ( - video.video_settings.tone_map - if video.video_settings.tone_map in ("mobius", "hable", "reinhard") - else "mobius" - ) - remove_hdr = f"--vpp-colorspace hdr2sdr={remove_type}" if video.video_settings.remove_hdr else "" - - crop = "" - if video.video_settings.crop: - crop = f"--crop {video.video_settings.crop.left},{video.video_settings.crop.top},{video.video_settings.crop.right},{video.video_settings.crop.bottom}" - - vbv = "" - if video.video_settings.maxrate: - vbv = f"--max-bitrate {video.video_settings.maxrate} --vbv-bufsize {video.video_settings.bufsize}" - try: stream_id = int(video.current_video_stream["id"], 16) except Exception: @@ -80,8 +34,6 @@ def build(fastflix: FastFlix): elif video.video_settings.vsync == "vfr": vsync_setting = "vfr" - source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else "" - output_depth = settings.output_depth if not settings.output_depth: output_depth = ( @@ -92,62 +44,114 @@ def build(fastflix: FastFlix): ) command = [ - f'"{clean_file_string(fastflix.config.vceencc)}"', - rigaya_avformat_reader(fastflix), - "--device", - str(settings.device), - "-i", - f'"{clean_file_string(video.source)}"', - (f"--video-streamid {stream_id}" if stream_id else ""), - seek, - seekto, - source_fps, - (f"--vpp-rotate {video.video_settings.rotate * 90}" if video.video_settings.rotate else ""), - transform, - (f"--output-res {video.scale.replace(':', 'x')}" if video.scale else ""), - crop, - ( - "--video-metadata clear --metadata clear" - if video.video_settings.remove_metadata - else "--video-metadata copy --metadata copy" - ), - (f'--video-metadata title="{video.video_settings.video_title}"' if video.video_settings.video_title else ""), - ("--chapter-copy" if video.video_settings.copy_chapters else ""), - "-c", - "hevc", - (f"--vbr {settings.bitrate.rstrip('k')}" if settings.bitrate else f"--cqp {settings.cqp}"), - vbv, - (f"--qp-min {settings.min_q}" if settings.min_q and settings.bitrate else ""), - (f"--qp-max {settings.max_q}" if settings.max_q and settings.bitrate else ""), - (f"--ref {settings.ref}" if settings.ref else ""), - "--preset", - settings.preset, - "--tier", - settings.tier, - "--level", - (settings.level or "auto"), - rigaya_auto_options(fastflix), - (master_display if master_display else ""), - (max_cll if max_cll else ""), - (dhdr if dhdr else ""), - "--output-depth", - output_depth, - "--motion-est", - settings.mv_precision, - ("--vbaq" if settings.vbaq else ""), - ("--pe" if settings.pre_encode else ""), - pa_builder(settings), - f"--avsync {vsync_setting}", - (f"--interlace {video.interlaced}" if video.interlaced and video.interlaced != "False" else ""), - ("--vpp-nnedi" if video.video_settings.deinterlace else ""), - remove_hdr, - "--parallel auto" if settings.split_mode == "parallel" else "", - "--psnr --ssim" if settings.metrics else "", - build_audio(video.audio_tracks, video.streams.audio), - build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height), - settings.extra, - "-o", - f'"{clean_file_string(video.video_settings.output_path)}"', + str(fastflix.config.vceencc), ] + command.extend(rigaya_avformat_reader(fastflix)) + command.extend(["--device", str(settings.device)]) + command.extend(["-i", str(video.source)]) + + if stream_id: + command.extend(["--video-streamid", str(stream_id)]) + if video.video_settings.start_time: + command.extend(["--seek", video.video_settings.start_time]) + if video.video_settings.end_time: + command.extend(["--seekto", video.video_settings.end_time]) + if video.video_settings.source_fps: + command.extend(["--fps", video.video_settings.source_fps]) + if video.video_settings.rotate: + command.extend(["--vpp-rotate", str(video.video_settings.rotate * 90)]) + if video.video_settings.vertical_flip or video.video_settings.horizontal_flip: + flip_x = "true" if video.video_settings.horizontal_flip else "false" + flip_y = "true" if video.video_settings.vertical_flip else "false" + command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) + if video.scale: + command.extend(["--output-res", video.scale.replace(":", "x")]) + if video.video_settings.crop: + crop = video.video_settings.crop + command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + + if video.video_settings.remove_metadata: + command.extend(["--video-metadata", "clear", "--metadata", "clear"]) + else: + command.extend(["--video-metadata", "copy", "--metadata", "copy"]) + + if video.video_settings.video_title: + command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + if video.video_settings.copy_chapters: + command.append("--chapter-copy") + + command.extend(["-c", "hevc"]) + + if settings.bitrate: + command.extend(["--vbr", settings.bitrate.rstrip("k")]) + else: + command.extend(["--cqp", settings.cqp]) + + if video.video_settings.maxrate: + command.extend(["--max-bitrate", str(video.video_settings.maxrate)]) + command.extend(["--vbv-bufsize", str(video.video_settings.bufsize)]) + if settings.min_q and settings.bitrate: + command.extend(["--qp-min", str(settings.min_q)]) + if settings.max_q and settings.bitrate: + command.extend(["--qp-max", str(settings.max_q)]) + if settings.ref: + command.extend(["--ref", str(settings.ref)]) + + command.extend(["--preset", settings.preset]) + command.extend(["--tier", settings.tier]) + command.extend(["--level", settings.level or "auto"]) + + command.extend(rigaya_auto_options(fastflix)) + + if fastflix.current_video.master_display: + md = fastflix.current_video.master_display + command.extend( + [ + "--master-display", + f"G{md.green}B{md.blue}R{md.red}WP{md.white}L{md.luminance}", + ] + ) + if fastflix.current_video.cll: + command.extend(["--max-cll", str(fastflix.current_video.cll)]) + if settings.copy_hdr10: + command.extend(["--dhdr10-info", "copy"]) + + command.extend(["--output-depth", output_depth]) + command.extend(["--motion-est", settings.mv_precision]) + + if settings.vbaq: + command.append("--vbaq") + if settings.pre_encode: + command.append("--pe") + + pa = pa_builder(settings) + if pa: + command.append(pa) + + command.extend(["--avsync", vsync_setting]) + + if video.interlaced and video.interlaced != "False": + command.extend(["--interlace", video.interlaced]) + if video.video_settings.deinterlace: + command.append("--vpp-nnedi") + if video.video_settings.remove_hdr: + remove_type = ( + video.video_settings.tone_map + if video.video_settings.tone_map in ("mobius", "hable", "reinhard") + else "mobius" + ) + command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + if settings.split_mode == "parallel": + command.extend(["--parallel", "auto"]) + if settings.metrics: + command.extend(["--psnr", "--ssim"]) + + command.extend(build_audio(video.audio_tracks, video.streams.audio)) + command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) + + if settings.extra: + command.extend(shlex.split(settings.extra)) + + command.extend(["-o", str(video.video_settings.output_path)]) - return [Command(command=" ".join(x for x in command if x), name="VCEEncC Encode", exe="VCEEncC")] + return [Command(command=command, name="VCEEncC Encode", exe="VCEEncC")] diff --git a/fastflix/encoders/vp9/command_builder.py b/fastflix/encoders/vp9/command_builder.py index 9d4759b6..4d665668 100644 --- a/fastflix/encoders/vp9/command_builder.py +++ b/fastflix/encoders/vp9/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null from fastflix.models.encode import VP9Settings @@ -10,38 +11,85 @@ def build(fastflix: FastFlix): settings: VP9Settings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "libvpx-vp9") - beginning += f"{'-row-mt 1' if settings.row_mt else ''} {generate_color_details(fastflix)} " + if settings.row_mt: + beginning.extend(["-row-mt", "1"]) + beginning.extend(generate_color_details(fastflix)) if not settings.single_pass: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" - beginning += f'-passlogfile "{pass_log_file}" ' + beginning.extend(["-passlogfile", str(pass_log_file)]) # TODO color_range 1 # if not fastflix.current_video.video_settings.remove_hdr and settings.pix_fmt in ("yuv420p10le", "yuv420p12le"): # if fastflix.current_video.color_space.startswith("bt2020"): # beginning += "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc -color_range 1" - details = f"-quality:v {settings.quality} -profile:v {settings.profile} -tile-columns:v {settings.tile_columns} -tile-rows:v {settings.tile_rows} " + details = [ + "-quality:v", + settings.quality, + "-profile:v", + str(settings.profile), + "-tile-columns:v", + str(settings.tile_columns), + "-tile-rows:v", + str(settings.tile_rows), + ] + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] if settings.bitrate: if settings.quality == "realtime": return [ Command( - command=f"{beginning} -speed:v {settings.speed} -b:v {settings.bitrate} {details} {settings.extra} {ending} ", + command=( + beginning + + ["-speed:v", str(settings.speed), "-b:v", settings.bitrate] + + details + + extra + + ending + ), name="Single pass realtime bitrate", exe="ffmpeg", ) ] - command_1 = f"{beginning} -speed:v {'4' if settings.fast_first_pass else settings.speed} -b:v {settings.bitrate} {details} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an {output_fps} -f webm {null}" + command_1 = ( + beginning + + ["-speed:v", str("4" if settings.fast_first_pass else settings.speed), "-b:v", settings.bitrate] + + details + + ["-pass", "1"] + + extra_both + + ["-an"] + + output_fps + + ["-f", "webm", null] + ) command_2 = ( - f"{beginning} -speed:v {settings.speed} -b:v {settings.bitrate} {details} -pass 2 {settings.extra} {ending}" + beginning + + ["-speed:v", str(settings.speed), "-b:v", settings.bitrate] + + details + + ["-pass", "2"] + + extra + + ending ) elif settings.crf: - command_1 = f"{beginning} -b:v 0 -crf:v {settings.crf} {details} -pass 1 {settings.extra if settings.extra_both_passes else ''} -an {output_fps} -f webm {null}" + command_1 = ( + beginning + + ["-b:v", "0", "-crf:v", str(settings.crf)] + + details + + ["-pass", "1"] + + extra_both + + ["-an"] + + output_fps + + ["-f", "webm", null] + ) command_2 = ( - f"{beginning} -b:v 0 -crf:v {settings.crf} {details} " - f"{'-pass 2' if not settings.single_pass else ''} {settings.extra} {ending}" + beginning + + ["-b:v", "0", "-crf:v", str(settings.crf)] + + details + + (["-pass", "2"] if not settings.single_pass else []) + + extra + + ending ) else: diff --git a/fastflix/encoders/vvc/command_builder.py b/fastflix/encoders/vvc/command_builder.py index 428fc795..505a8783 100644 --- a/fastflix/encoders/vvc/command_builder.py +++ b/fastflix/encoders/vvc/command_builder.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import secrets +import shlex from fastflix.encoders.common.helpers import Command, generate_all, null from fastflix.models.encode import VVCSettings @@ -83,10 +84,10 @@ def build(fastflix: FastFlix): beginning, ending, output_fps = generate_all(fastflix, "libvvenc") if settings.tier: - beginning += f"-tier:v {settings.tier} " + beginning.extend(["-tier:v", settings.tier]) if settings.levelidc: - beginning += f"-level {settings.levelidc} " + beginning.extend(["-level", settings.levelidc]) vvc_params = settings.vvc_params.copy() or [] @@ -103,20 +104,29 @@ def get_vvc_params(params=()): if not isinstance(params, (list, tuple)): params = [params] all_params = vvc_params + list(params) - return '-vvenc-params "{}" '.format(":".join(all_params)) if all_params else "" + return ["-vvenc-params", ":".join(all_params)] if all_params else [] + + extra = shlex.split(settings.extra) if settings.extra else [] + extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] if settings.bitrate: - params = get_vvc_params(["pass=1", f"rcstatsfile={quoted_path(clean_file_string(pass_log_file))}"]) + params = get_vvc_params(["pass=1", f"rcstatsfile={quoted_path(clean_file_string(str(pass_log_file)))}"]) command_1 = ( - f"{beginning} {params} " - f'-passlogfile "{pass_log_file}" -b:v {settings.bitrate} ' - f"-preset:v {settings.preset} {settings.extra if settings.extra_both_passes else ''} " - f" -an -sn -dn {output_fps} -f mp4 {null}" + beginning + + params + + ["-passlogfile", str(pass_log_file), "-b:v", settings.bitrate, "-preset:v", settings.preset] + + extra_both + + ["-an", "-sn", "-dn"] + + output_fps + + ["-f", "mp4", null] ) - params2 = get_vvc_params(["pass=2", f"rcstatsfile={quoted_path(clean_file_string(pass_log_file))}"]) + params2 = get_vvc_params(["pass=2", f"rcstatsfile={quoted_path(clean_file_string(str(pass_log_file)))}"]) command_2 = ( - f'{beginning} {params2} -passlogfile "{pass_log_file}" ' - f"-b:v {settings.bitrate} -preset:v {settings.preset} {settings.extra} {ending}" + beginning + + params2 + + ["-passlogfile", str(pass_log_file), "-b:v", settings.bitrate, "-preset:v", settings.preset] + + extra + + ending ) return [ Command(command=command_1, name="First pass bitrate", exe="ffmpeg"), @@ -125,8 +135,11 @@ def get_vvc_params(params=()): elif settings.qp: command = ( - f"{beginning} {get_vvc_params()} -qp:v {settings.qp} -b:v 0 " - f"-preset:v {settings.preset} {settings.extra} {ending}" + beginning + + get_vvc_params() + + ["-qp:v", str(settings.qp), "-b:v", "0", "-preset:v", settings.preset] + + extra + + ending ) return [Command(command=command, name="Single pass CRF", exe="ffmpeg")] diff --git a/fastflix/encoders/webp/command_builder.py b/fastflix/encoders/webp/command_builder.py index feacebef..2e442e6d 100644 --- a/fastflix/encoders/webp/command_builder.py +++ b/fastflix/encoders/webp/command_builder.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import shlex + from fastflix.encoders.common.helpers import Command, generate_all from fastflix.models.encode import WebPSettings from fastflix.models.fastflix import FastFlix @@ -9,11 +11,27 @@ def build(fastflix: FastFlix): beginning, ending, output_fps = generate_all(fastflix, "libwebp", audio=False, subs=False) + extra = shlex.split(settings.extra) if settings.extra else [] + + command = ( + beginning + + [ + "-lossless", + "1" if settings.lossless.lower() in ("1", "yes") else "0", + "-compression_level", + str(settings.compression), + "-qscale", + str(settings.qscale), + "-preset", + settings.preset, + ] + + extra + + ending + ) + return [ Command( - command=f"{beginning} -lossless {'1' if settings.lossless.lower() in ('1', 'yes') else '0'} " - f"-compression_level {settings.compression} " - f"-qscale {settings.qscale} -preset {settings.preset} {settings.extra} {ending}", + command=command, name="WebP", exe="ffmpeg", ), diff --git a/fastflix/flix.py b/fastflix/flix.py index 5d96c0b7..9ce1e2f8 100644 --- a/fastflix/flix.py +++ b/fastflix/flix.py @@ -324,7 +324,7 @@ def generate_thumbnail_command( config: Config, source: Path, output: Path, - filters: str, + filters: list[str] | str, start_time: float = 0, input_track: int = 0, ) -> list[str]: @@ -338,10 +338,13 @@ def generate_thumbnail_command( # Video file input command += ["-loglevel", "warning", "-i", clean_file_string(source)] - command += shlex.split(filters) + if isinstance(filters, list): + command += filters + else: + command += shlex.split(filters) # Apply video track selection - if "-map" not in filters: + if "-map" not in (filters if isinstance(filters, list) else shlex.split(filters)): command += ["-map", f"0:{input_track}"] command += ["-an", "-y", "-map_metadata", "-1", "-frames:v", "1", clean_file_string(output)] diff --git a/fastflix/widgets/panels/command_panel.py b/fastflix/widgets/panels/command_panel.py index 81a2cd21..a7077c67 100644 --- a/fastflix/widgets/panels/command_panel.py +++ b/fastflix/widgets/panels/command_panel.py @@ -1,5 +1,8 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import shlex +import subprocess +import sys from pathlib import Path import reusables @@ -12,6 +15,15 @@ from fastflix.ui_constants import HEIGHTS +def _command_to_display_string(command): + """Convert a command (str or list) to a display string.""" + if isinstance(command, str): + return command + if sys.platform == "win32": + return subprocess.list2cmdline(command) + return shlex.join(command) + + class Loop(QtWidgets.QGroupBox): def __init__(self, parent, condition, commands, number, name=""): super(Loop, self).__init__(parent) @@ -30,7 +42,7 @@ def __init__(self, parent, condition, commands, number, name=""): class Command(QtWidgets.QTabWidget): def __init__(self, parent, command, number, name="", enabled=True, height=None): super(Command, self).__init__(parent) - self.command = command + self.command = _command_to_display_string(command) self.widget = QtWidgets.QTextBrowser() self.widget.setReadOnly(True) self.custom_height = height @@ -104,7 +116,7 @@ def __init__(self, parent, app: FastFlixApp): self.setLayout(layout) def _prep_commands(self): - commands = [x.command for x in self.commands if x.name != "hidden"] + commands = [_command_to_display_string(x.command) for x in self.commands if x.name != "hidden"] return "\r\n".join(commands) if reusables.win_based else "\n".join(commands) def copy_commands_to_clipboard(self): @@ -130,7 +142,7 @@ def update_commands(self, commands): self.commands = [] for index, item in enumerate(commands, 1): if item.item == "command": - new_item = Command(self.scroll_area, item.command, index, name=item.name) + new_item = Command(self.scroll_area, _command_to_display_string(item.command), index, name=item.name) self.commands.append(item) layout.addWidget(new_item) layout.addStretch() diff --git a/tests/encoders/test_attachments.py b/tests/encoders/test_attachments.py index 6f65b8d5..097f5d02 100644 --- a/tests/encoders/test_attachments.py +++ b/tests/encoders/test_attachments.py @@ -47,18 +47,43 @@ def test_image_type_other(): def test_build_attachments_empty(): """Test the build_attachments function with an empty list.""" result = build_attachments([]) - assert result == "" + assert result == [] def test_build_attachments_with_cover(sample_attachment_tracks): """Test the build_attachments function with cover attachments.""" result = build_attachments(sample_attachment_tracks) - # Check that each attachment is included in the command - assert '-attach "cover.jpg" -metadata:s:0 mimetype="image/jpeg" -metadata:s:0 filename="cover.jpg"' in result - assert ( - '-attach "thumbnail.png" -metadata:s:1 mimetype="image/png" -metadata:s:1 filename="thumbnail.png"' in result - ) + # Check that the result is a list + assert isinstance(result, list) + + # Check that each attachment is included in the command list + # First cover attachment: cover.jpg at outdex 0 + assert "-attach" in result + assert "cover.jpg" in result + assert "mimetype=image/jpeg" in result + assert "filename=cover.jpg" in result + + # Second cover attachment: thumbnail.png at outdex 1 + assert "thumbnail.png" in result + assert "mimetype=image/png" in result + assert "filename=thumbnail.png" in result + + # Verify the structure by checking index-based ordering for first attachment + first_attach_idx = result.index("-attach") + assert result[first_attach_idx + 1] == "cover.jpg" + assert result[first_attach_idx + 2] == "-metadata:s:0" + assert result[first_attach_idx + 3] == "mimetype=image/jpeg" + assert result[first_attach_idx + 4] == "-metadata:s:0" + assert result[first_attach_idx + 5] == "filename=cover.jpg" + + # Verify the structure for second attachment + second_attach_idx = result.index("-attach", first_attach_idx + 1) + assert result[second_attach_idx + 1] == "thumbnail.png" + assert result[second_attach_idx + 2] == "-metadata:s:1" + assert result[second_attach_idx + 3] == "mimetype=image/png" + assert result[second_attach_idx + 4] == "-metadata:s:1" + assert result[second_attach_idx + 5] == "filename=thumbnail.png" def test_build_attachments_with_custom_paths(): @@ -79,15 +104,24 @@ def test_build_attachments_with_custom_paths(): result = build_attachments(attachments) - # Check that each attachment is included in the command with correct paths and filenames - assert ( - '-attach "path/to/cover.jpg" -metadata:s:0 mimetype="image/jpeg" -metadata:s:0 filename="movie_cover.jpg"' - in result - ) - assert ( - '-attach "path/with spaces/thumbnail.png" -metadata:s:1 mimetype="image/png" -metadata:s:1 filename="movie_thumbnail.png"' - in result - ) + # Check that the result is a list + assert isinstance(result, list) + + # Verify first attachment with custom path and filename + first_attach_idx = result.index("-attach") + assert result[first_attach_idx + 1] == "path/to/cover.jpg" + assert result[first_attach_idx + 2] == "-metadata:s:0" + assert result[first_attach_idx + 3] == "mimetype=image/jpeg" + assert result[first_attach_idx + 4] == "-metadata:s:0" + assert result[first_attach_idx + 5] == "filename=movie_cover.jpg" + + # Verify second attachment with spaces in path and custom filename + second_attach_idx = result.index("-attach", first_attach_idx + 1) + assert result[second_attach_idx + 1] == "path/with spaces/thumbnail.png" + assert result[second_attach_idx + 2] == "-metadata:s:1" + assert result[second_attach_idx + 3] == "mimetype=image/png" + assert result[second_attach_idx + 4] == "-metadata:s:1" + assert result[second_attach_idx + 5] == "filename=movie_thumbnail.png" def test_build_attachments_non_cover_type(): @@ -106,6 +140,20 @@ def test_build_attachments_non_cover_type(): result = build_attachments(attachments) - # Check that only the cover attachment is included in the command - assert '-attach "cover.jpg" -metadata:s:0 mimetype="image/jpeg" -metadata:s:0 filename="cover.jpg"' in result + # Check that only the cover attachment is included in the command list + assert "-attach" in result + assert "cover.jpg" in result + assert "mimetype=image/jpeg" in result + assert "filename=cover.jpg" in result assert "font.ttf" not in result + + # Verify there is exactly one -attach entry (only the cover, not the font) + assert result.count("-attach") == 1 + + # Verify the structure of the single cover attachment + attach_idx = result.index("-attach") + assert result[attach_idx + 1] == "cover.jpg" + assert result[attach_idx + 2] == "-metadata:s:0" + assert result[attach_idx + 3] == "mimetype=image/jpeg" + assert result[attach_idx + 4] == "-metadata:s:0" + assert result[attach_idx + 5] == "filename=cover.jpg" diff --git a/tests/encoders/test_audio.py b/tests/encoders/test_audio.py index c8e411d3..3f536af2 100644 --- a/tests/encoders/test_audio.py +++ b/tests/encoders/test_audio.py @@ -121,10 +121,18 @@ def test_audio_quality_converter_default(): assert result == "-b:1 144k" +def _has_consecutive(lst, a, b): + """Check that elements a and b appear consecutively in lst.""" + for i in range(len(lst) - 1): + if lst[i] == a and lst[i + 1] == b: + return True + return False + + def test_build_audio_empty(): """Test the build_audio function with an empty list.""" result = build_audio([]) - assert result == "" + assert result == [] def test_build_audio_disabled_tracks(sample_audio_tracks): @@ -134,7 +142,7 @@ def test_build_audio_disabled_tracks(sample_audio_tracks): track.enabled = False result = build_audio(sample_audio_tracks) - assert result == "" + assert result == [] def test_build_audio_copy_tracks(sample_audio_tracks): @@ -147,16 +155,16 @@ def test_build_audio_copy_tracks(sample_audio_tracks): result = build_audio(sample_audio_tracks) # Check that each track is mapped and copied - assert "-map 0:1" in result - assert "-map 0:2" in result - assert "-map 0:3" in result - assert "-c:0 copy" in result - assert "-c:1 copy" in result - assert "-c:2 copy" in result + assert _has_consecutive(result, "-map", "0:1") + assert _has_consecutive(result, "-map", "0:2") + assert _has_consecutive(result, "-map", "0:3") + assert _has_consecutive(result, "-c:0", "copy") + assert _has_consecutive(result, "-c:1", "copy") + assert _has_consecutive(result, "-c:2", "copy") # Check that titles and languages are set - assert 'title="Surround 5.1"' in result - assert 'title="Stereo"' in result + assert "title=Surround 5.1" in result + assert "title=Stereo" in result assert "language=eng" in result assert "language=jpn" in result @@ -175,16 +183,18 @@ def test_build_audio_convert_tracks(sample_audio_tracks): result = build_audio(sample_audio_tracks) # Check that each track is mapped and converted correctly - assert "-map 0:1" in result - assert "-map 0:2" in result - assert "-c:0 aac -b:0 128k -ac:0 2" in result - assert "-c:1 libmp3lame -q:1 3" in result - assert "aformat=channel_layouts=stereo" in result + assert _has_consecutive(result, "-map", "0:1") + assert _has_consecutive(result, "-map", "0:2") + assert _has_consecutive(result, "-c:0", "aac") + assert _has_consecutive(result, "-b:0", "128k") + assert _has_consecutive(result, "-ac:0", "2") + assert _has_consecutive(result, "-c:1", "libmp3lame") + assert "-q:1" in result assert "aformat=channel_layouts=stereo" in result # Check that titles and languages are set - assert 'title="Surround 5.1"' in result - assert 'title="Stereo"' in result + assert "title=Surround 5.1" in result + assert "title=Stereo" in result assert "language=eng" in result assert "language=jpn" in result @@ -200,8 +210,8 @@ def test_build_audio_with_dispositions(sample_audio_tracks): result = build_audio(sample_audio_tracks) # Check that dispositions are set correctly - assert "-disposition:0 default" in result - assert "-disposition:1 forced" in result + assert _has_consecutive(result, "-disposition:0", "default") + assert _has_consecutive(result, "-disposition:1", "forced") assert "-disposition:2" not in result @@ -215,4 +225,6 @@ def test_build_audio_with_strict_codecs(sample_audio_tracks): result = build_audio(sample_audio_tracks) # Check that -strict -2 is added - assert "-strict -2" in result + assert "-strict" in result + assert "-2" in result + assert _has_consecutive(result, "-strict", "-2") diff --git a/tests/encoders/test_avc_x264_command_builder.py b/tests/encoders/test_avc_x264_command_builder.py index f533869e..60ba96d8 100644 --- a/tests/encoders/test_avc_x264_command_builder.py +++ b/tests/encoders/test_avc_x264_command_builder.py @@ -30,18 +30,29 @@ def test_avc_x264_basic_crf(): # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.avc_x264.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"]) # Mock the generate_color_details function to return a predictable result with mock.patch( "fastflix.encoders.avc_x264.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["--color_details"] result = build(fastflix) # The expected command should include the CRF setting and other basic parameters - expected_command = "ffmpeg -y -i input.mkv --color_details -crf:v 23 -preset:v medium output.mkv" + expected_command = [ + "ffmpeg", + "-y", + "-i", + "input.mkv", + "--color_details", + "-crf:v", + "23", + "-preset:v", + "medium", + "output.mkv", + ] assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" @@ -68,13 +79,13 @@ def test_avc_x264_two_pass_bitrate(): # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.avc_x264.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"]) # Mock the generate_color_details function to return a predictable result with mock.patch( "fastflix.encoders.avc_x264.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["--color_details"] # Mock the secrets.token_hex function to return a predictable result with mock.patch("fastflix.encoders.avc_x264.command_builder.secrets.token_hex") as mock_token_hex: @@ -84,22 +95,56 @@ def test_avc_x264_two_pass_bitrate(): # The expected command should be a list of two Command objects for two-pass encoding if reusables.win_based: - expected_commands = [ - 'ffmpeg -y -i input.mkv --color_details -pass 1 -passlogfile "work_path\\pass_log_file_abcdef1234" -b:v 5000k -preset:v medium -an -sn -dn -r 24 -f mp4 NUL', - 'ffmpeg -y -i input.mkv --color_details -pass 2 -passlogfile "work_path\\pass_log_file_abcdef1234" -b:v 5000k -preset:v medium output.mkv', - ] + pass_log = "work_path\\pass_log_file_abcdef1234" else: - expected_commands = [ - 'ffmpeg -y -i input.mkv --color_details -pass 1 -passlogfile "work_path/pass_log_file_abcdef1234" -b:v 5000k -preset:v medium -an -sn -dn -r 24 -f mp4 /dev/null', - 'ffmpeg -y -i input.mkv --color_details -pass 2 -passlogfile "work_path/pass_log_file_abcdef1234" -b:v 5000k -preset:v medium output.mkv', - ] + pass_log = "work_path/pass_log_file_abcdef1234" + + expected_command_1 = [ + "ffmpeg", + "-y", + "-i", + "input.mkv", + "--color_details", + "-pass", + "1", + "-passlogfile", + pass_log, + "-b:v", + "5000k", + "-preset:v", + "medium", + "-an", + "-sn", + "-dn", + "-r", + "24", + "-f", + "mp4", + "NUL" if reusables.win_based else "/dev/null", + ] + expected_command_2 = [ + "ffmpeg", + "-y", + "-i", + "input.mkv", + "--color_details", + "-pass", + "2", + "-passlogfile", + pass_log, + "-b:v", + "5000k", + "-preset:v", + "medium", + "output.mkv", + ] assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 2, f"Expected 2 Command objects, got {len(result)}" - assert result[0].command == expected_commands[0], ( - f"Expected: {expected_commands[0]}\nGot: {result[0].command}" + assert result[0].command == expected_command_1, ( + f"Expected: {expected_command_1}\nGot: {result[0].command}" ) - assert result[1].command == expected_commands[1], ( - f"Expected: {expected_commands[1]}\nGot: {result[1].command}" + assert result[1].command == expected_command_2, ( + f"Expected: {expected_command_2}\nGot: {result[1].command}" ) @@ -124,18 +169,29 @@ def test_avc_x264_single_pass_bitrate(): # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.avc_x264.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"]) # Mock the generate_color_details function to return a predictable result with mock.patch( "fastflix.encoders.avc_x264.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["--color_details"] result = build(fastflix) # The expected command should include the bitrate setting - expected_command = "ffmpeg -y -i input.mkv --color_details -b:v 5000k -preset:v medium output.mkv" + expected_command = [ + "ffmpeg", + "-y", + "-i", + "input.mkv", + "--color_details", + "-b:v", + "5000k", + "-preset:v", + "medium", + "output.mkv", + ] assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" @@ -161,18 +217,33 @@ def test_avc_x264_profile_tune(): # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.avc_x264.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"]) # Mock the generate_color_details function to return a predictable result with mock.patch( "fastflix.encoders.avc_x264.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["--color_details"] result = build(fastflix) # The expected command should include the profile and tune settings - expected_command = "ffmpeg -y -i input.mkv -tune:v film --color_details -profile:v high -crf:v 23 -preset:v medium output.mkv" + expected_command = [ + "ffmpeg", + "-y", + "-i", + "input.mkv", + "-tune:v", + "film", + "--color_details", + "-profile:v", + "high", + "-crf:v", + "23", + "-preset:v", + "medium", + "output.mkv", + ] assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" diff --git a/tests/encoders/test_encc_helpers.py b/tests/encoders/test_encc_helpers.py index 68f72c0b..7b6668be 100644 --- a/tests/encoders/test_encc_helpers.py +++ b/tests/encoders/test_encc_helpers.py @@ -142,7 +142,7 @@ def test_rigaya_avformat_reader_avs(encc_fastflix_instance): # Test the function result = rigaya_avformat_reader(encc_fastflix_instance) - assert result == "" + assert result == [] def test_rigaya_avformat_reader_vpy(encc_fastflix_instance): @@ -153,7 +153,7 @@ def test_rigaya_avformat_reader_vpy(encc_fastflix_instance): # Test the function result = rigaya_avformat_reader(encc_fastflix_instance) - assert result == "" + assert result == [] def test_rigaya_avformat_reader_hardware(encc_fastflix_instance): @@ -164,7 +164,7 @@ def test_rigaya_avformat_reader_hardware(encc_fastflix_instance): # Test the function result = rigaya_avformat_reader(encc_fastflix_instance) - assert result == "--avhw" + assert result == ["--avhw"] def test_rigaya_avformat_reader_software(encc_fastflix_instance): @@ -175,14 +175,14 @@ def test_rigaya_avformat_reader_software(encc_fastflix_instance): # Test the function result = rigaya_avformat_reader(encc_fastflix_instance) - assert result == "--avsw" + assert result == ["--avsw"] def test_rigaya_auto_options_with_reader(encc_fastflix_instance): """Test the rigaya_auto_options function with a reader format.""" # Set up the test with mock.patch("fastflix.encoders.common.encc_helpers.rigaya_avformat_reader") as mock_reader: - mock_reader.return_value = "--avhw" + mock_reader.return_value = ["--avhw"] # Set color settings encc_fastflix_instance.current_video.video_settings.color_space = "bt2020nc" @@ -192,19 +192,19 @@ def test_rigaya_auto_options_with_reader(encc_fastflix_instance): # Test the function result = rigaya_auto_options(encc_fastflix_instance) - # Check that auto options are included - assert "--chromaloc auto" in result - assert "--colorrange auto" in result - assert "--colormatrix bt2020nc" in result - assert "--transfer smpte2084" in result - assert "--colorprim bt2020" in result + # Check that auto options are included as consecutive list elements + assert "--chromaloc" in result and "auto" in result + assert "--colorrange" in result + assert "--colormatrix" in result and "bt2020nc" in result + assert "--transfer" in result and "smpte2084" in result + assert "--colorprim" in result and "bt2020" in result def test_rigaya_auto_options_without_reader(encc_fastflix_instance): """Test the rigaya_auto_options function without a reader format.""" # Set up the test with mock.patch("fastflix.encoders.common.encc_helpers.rigaya_avformat_reader") as mock_reader: - mock_reader.return_value = "" + mock_reader.return_value = [] # Set color settings encc_fastflix_instance.current_video.video_settings.color_space = "bt2020nc" @@ -215,11 +215,11 @@ def test_rigaya_auto_options_without_reader(encc_fastflix_instance): result = rigaya_auto_options(encc_fastflix_instance) # Check that only specific color options are included - assert "--colormatrix bt2020nc" in result - assert "--transfer smpte2084" in result - assert "--colorprim bt2020" in result - assert "--chromaloc auto" not in result - assert "--colorrange auto" not in result + assert "--colormatrix" in result and "bt2020nc" in result + assert "--transfer" in result and "smpte2084" in result + assert "--colorprim" in result and "bt2020" in result + assert "--chromaloc" not in result + assert "--colorrange" not in result def test_pa_builder_disabled(): @@ -292,7 +292,7 @@ def test_get_stream_pos(): def test_build_audio_empty(): """Test the build_audio function with an empty list.""" result = build_audio([], []) - assert result == "" + assert result == [] def test_build_audio_copy_tracks(sample_audio_tracks): @@ -308,7 +308,7 @@ def test_build_audio_copy_tracks(sample_audio_tracks): result = build_audio(sample_audio_tracks, audio_streams) # Check that audio tracks are copied - assert "--audio-copy 1,2,3" in result + assert "--audio-copy" in result and "1,2,3" in result def test_build_audio_convert_tracks(sample_audio_tracks): @@ -328,17 +328,17 @@ def test_build_audio_convert_tracks(sample_audio_tracks): result = build_audio(sample_audio_tracks, audio_streams) # Check that audio tracks are converted correctly - assert "--audio-stream 1?:stereo" in result - assert "--audio-codec 1?aac" in result - assert "--audio-bitrate 1?128k" in result - assert "--audio-codec 2?libmp3lame" in result - assert "--audio-quality 2?3" in result + assert "--audio-stream" in result and "1?:stereo" in result + assert "--audio-codec" in result and "1?aac" in result + assert "--audio-bitrate" in result and "1?128k" in result + assert "--audio-codec" in result and "2?libmp3lame" in result + assert "--audio-quality" in result and "2?3" in result def test_build_subtitle_empty(): """Test the build_subtitle function with an empty list.""" result = build_subtitle([], [], 1080) - assert result == "" + assert result == [] def test_build_subtitle_copy_tracks(sample_subtitle_tracks): @@ -354,17 +354,17 @@ def test_build_subtitle_copy_tracks(sample_subtitle_tracks): result = build_subtitle(sample_subtitle_tracks, subtitle_streams, 1080) # Check that subtitle tracks are copied - assert "--sub-copy 1,2,3" in result + assert "--sub-copy" in result and "1,2,3" in result # Check that dispositions are set correctly - assert "--sub-disposition 1?default" in result - assert "--sub-disposition 2?unset" in result - assert "--sub-disposition 3?forced" in result + assert "--sub-disposition" in result and "1?default" in result + assert "2?unset" in result + assert "3?forced" in result # Check that languages are set - assert "--sub-metadata 1?language='eng'" in result - assert "--sub-metadata 2?language='jpn'" in result - assert "--sub-metadata 3?language='eng'" in result + assert "--sub-metadata" in result and "1?language=eng" in result + assert "2?language=jpn" in result + assert "3?language=eng" in result def test_build_subtitle_with_burn_in(sample_subtitle_tracks): @@ -380,10 +380,10 @@ def test_build_subtitle_with_burn_in(sample_subtitle_tracks): result = build_subtitle(sample_subtitle_tracks, subtitle_streams, 1080) # Check that the burn-in track is included with vpp-subburn - assert "--vpp-subburn track=1" in result + assert "--vpp-subburn" in result and "track=1" in result # Check that the other tracks are copied - assert "--sub-copy 2,3" in result + assert "--sub-copy" in result and "2,3" in result def test_build_subtitle_with_4k_scaling(sample_subtitle_tracks): @@ -397,4 +397,4 @@ def test_build_subtitle_with_4k_scaling(sample_subtitle_tracks): result = build_subtitle(sample_subtitle_tracks, subtitle_streams, 2160) # Check that the burn-in track includes scale parameter - assert "--vpp-subburn track=1,scale=2.0" in result + assert "--vpp-subburn" in result and "track=1,scale=2.0" in result diff --git a/tests/encoders/test_ffmpeg_hevc_nvenc_command_builder.py b/tests/encoders/test_ffmpeg_hevc_nvenc_command_builder.py index f247a400..393d066c 100644 --- a/tests/encoders/test_ffmpeg_hevc_nvenc_command_builder.py +++ b/tests/encoders/test_ffmpeg_hevc_nvenc_command_builder.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- from unittest import mock -import reusables - +from fastflix.encoders.common.helpers import null from fastflix.encoders.ffmpeg_hevc_nvenc.command_builder import build from fastflix.models.encode import FFmpegNVENCSettings from fastflix.models.video import VideoSettings @@ -35,23 +34,40 @@ def test_ffmpeg_hevc_nvenc_qp(): ), ) - # Mock the generate_all function to return a predictable result + # Mock the generate_all function to return a predictable result (lists) with mock.patch("fastflix.encoders.ffmpeg_hevc_nvenc.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) - # Mock the generate_color_details function to return a predictable result + # Mock the generate_color_details function to return a predictable result (list) with mock.patch( "fastflix.encoders.ffmpeg_hevc_nvenc.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] result = build(fastflix) - # The expected command should include the QP setting and other basic parameters - expected_command = "ffmpeg -y -i input.mkv -tune:v hq --color_details -spatial_aq:v 0 -tier:v main -rc-lookahead:v 0 -gpu -1 -b_ref_mode disabled -profile:v main -qp:v 28 -preset:v slow output.mkv" assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + + cmd = result[0].command + assert isinstance(cmd, list), f"Expected command to be a list, got {type(cmd)}" + + # Check key elements + assert "-tune:v" in cmd + assert "hq" in cmd + assert "-qp:v" in cmd + assert "28" in cmd + assert "-preset:v" in cmd + assert "slow" in cmd + assert "-spatial_aq:v" in cmd + assert "-tier:v" in cmd + assert "main" in cmd + assert "-profile:v" in cmd + assert "output.mkv" in cmd def test_ffmpeg_hevc_nvenc_bitrate(): @@ -79,15 +95,19 @@ def test_ffmpeg_hevc_nvenc_bitrate(): ), ) - # Mock the generate_all function to return a predictable result + # Mock the generate_all function to return a predictable result (lists) with mock.patch("fastflix.encoders.ffmpeg_hevc_nvenc.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) - # Mock the generate_color_details function to return a predictable result + # Mock the generate_color_details function to return a predictable result (list) with mock.patch( "fastflix.encoders.ffmpeg_hevc_nvenc.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] # Mock the secrets.token_hex function to return a predictable result with mock.patch("fastflix.encoders.ffmpeg_hevc_nvenc.command_builder.secrets.token_hex") as mock_token_hex: @@ -95,25 +115,33 @@ def test_ffmpeg_hevc_nvenc_bitrate(): result = build(fastflix) - # The expected command should be a list of two Command objects for two-pass encoding - if reusables.win_based: - expected_commands = [ - 'ffmpeg -y -i input.mkv -tune:v hq --color_details -spatial_aq:v 0 -tier:v main -rc-lookahead:v 0 -gpu -1 -b_ref_mode disabled -profile:v main -pass 1 -passlogfile "work_path\\pass_log_file_abcdef1234" -b:v 6000k -preset:v slow -2pass 1 -an -sn -dn -r 24 -f mp4 NUL', - 'ffmpeg -y -i input.mkv -tune:v hq --color_details -spatial_aq:v 0 -tier:v main -rc-lookahead:v 0 -gpu -1 -b_ref_mode disabled -profile:v main -pass 2 -passlogfile "work_path\\pass_log_file_abcdef1234" -2pass 1 -b:v 6000k -preset:v slow output.mkv', - ] - else: - expected_commands = [ - 'ffmpeg -y -i input.mkv -tune:v hq --color_details -spatial_aq:v 0 -tier:v main -rc-lookahead:v 0 -gpu -1 -b_ref_mode disabled -profile:v main -pass 1 -passlogfile "work_path/pass_log_file_abcdef1234" -b:v 6000k -preset:v slow -2pass 1 -an -sn -dn -r 24 -f mp4 /dev/null', - 'ffmpeg -y -i input.mkv -tune:v hq --color_details -spatial_aq:v 0 -tier:v main -rc-lookahead:v 0 -gpu -1 -b_ref_mode disabled -profile:v main -pass 2 -passlogfile "work_path/pass_log_file_abcdef1234" -2pass 1 -b:v 6000k -preset:v slow output.mkv', - ] assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 2, f"Expected 2 Command objects, got {len(result)}" - assert result[0].command == expected_commands[0], ( - f"Expected: {expected_commands[0]}\nGot: {result[0].command}" - ) - assert result[1].command == expected_commands[1], ( - f"Expected: {expected_commands[1]}\nGot: {result[1].command}" - ) + + cmd1 = result[0].command + cmd2 = result[1].command + assert isinstance(cmd1, list), f"Expected command to be a list, got {type(cmd1)}" + assert isinstance(cmd2, list), f"Expected command to be a list, got {type(cmd2)}" + + # First pass + assert "-pass" in cmd1 + assert "1" in cmd1[cmd1.index("-pass") + 1 :][:1] + assert "-b:v" in cmd1 + assert "6000k" in cmd1 + assert "-2pass" in cmd1 + assert "-an" in cmd1 + assert "-sn" in cmd1 + assert "-dn" in cmd1 + assert "-f" in cmd1 + assert "mp4" in cmd1 + assert null in cmd1 + + # Second pass + assert "-pass" in cmd2 + assert "2" in cmd2[cmd2.index("-pass") + 1 :][:1] + assert "-b:v" in cmd2 + assert "6000k" in cmd2 + assert "output.mkv" in cmd2 def test_ffmpeg_hevc_nvenc_with_rc_level(): @@ -142,20 +170,45 @@ def test_ffmpeg_hevc_nvenc_with_rc_level(): ), ) - # Mock the generate_all function to return a predictable result + # Mock the generate_all function to return a predictable result (lists) with mock.patch("fastflix.encoders.ffmpeg_hevc_nvenc.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -hwaccel auto -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = ( + ["ffmpeg", "-hwaccel", "auto", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) - # Mock the generate_color_details function to return a predictable result + # Mock the generate_color_details function to return a predictable result (list) with mock.patch( "fastflix.encoders.ffmpeg_hevc_nvenc.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] result = build(fastflix) - # The expected command should include the RC and level settings - expected_command = "ffmpeg -hwaccel auto -y -i input.mkv -tune:v hq --color_details -spatial_aq:v 1 -tier:v high -rc-lookahead:v 32 -gpu 0 -b_ref_mode each -profile:v main -rc:v vbr -level:v 5.1 -qp:v 28 -preset:v slow output.mkv" assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + + cmd = result[0].command + assert isinstance(cmd, list), f"Expected command to be a list, got {type(cmd)}" + + # Check key elements + assert "-tune:v" in cmd + assert "hq" in cmd + assert "-rc:v" in cmd + assert "vbr" in cmd + assert "-level:v" in cmd + assert "5.1" in cmd + assert "-spatial_aq:v" in cmd + assert "1" in cmd + assert "-tier:v" in cmd + assert "high" in cmd + assert "-rc-lookahead:v" in cmd + assert "32" in cmd + assert "-gpu" in cmd + assert "0" in cmd + assert "-b_ref_mode" in cmd + assert "each" in cmd + assert "-qp:v" in cmd + assert "28" in cmd + assert "output.mkv" in cmd diff --git a/tests/encoders/test_helpers.py b/tests/encoders/test_helpers.py index 44e62d9c..0776d163 100644 --- a/tests/encoders/test_helpers.py +++ b/tests/encoders/test_helpers.py @@ -14,8 +14,8 @@ def test_command_class(): - """Test the Command class.""" - # Test basic command creation + """Test the Command class with string and list commands.""" + # Test string command creation cmd = Command(command='ffmpeg -i "input.mkv" output.mp4', name="Test Command", exe="ffmpeg") assert cmd.command == 'ffmpeg -i "input.mkv" output.mp4' assert cmd.name == "Test Command" @@ -24,6 +24,12 @@ def test_command_class(): assert cmd.shell is False assert cmd.uuid is not None + # Test list command creation + cmd_list = Command(command=["ffmpeg", "-i", "input.mkv", "output.mp4"], name="List Command", exe="ffmpeg") + assert cmd_list.command == ["ffmpeg", "-i", "input.mkv", "output.mp4"] + assert isinstance(cmd_list.to_list(), list) + assert isinstance(cmd_list.to_string(), str) + def test_generate_ffmpeg_start_basic(fastflix_instance): """Test the generate_ffmpeg_start function with basic parameters.""" @@ -36,8 +42,17 @@ def test_generate_ffmpeg_start_basic(fastflix_instance): pix_fmt="yuv420p10le", ) - expected = r'"ffmpeg" -y -i "C:\test_ file.mkv" -map 0:0 -c:v libx265 -pix_fmt yuv420p10le ' - assert result == expected + assert isinstance(result, list) + assert result[0] == "ffmpeg" + assert "-y" in result + assert "-i" in result + assert r"C:\test_ file.mkv" in result + assert "-map" in result + assert "0:0" in result + assert "-c:v" in result + assert "libx265" in result + assert "-pix_fmt" in result + assert "yuv420p10le" in result def test_generate_ffmpeg_start_with_options(fastflix_instance): @@ -63,29 +78,51 @@ def test_generate_ffmpeg_start_with_options(fastflix_instance): start_extra="--extra-option", ) - expected = '"ffmpeg" --extra-option -init_hw_device opencl:0.0=ocl -filter_hw_device ocl -y -ss 10 -to 60 -r 24 -i "input.mkv" -metadata title="Test Video" -map 0:0 -fps_mode cfr -c:v libx265 -pix_fmt yuv420p10le -maxrate:v 5000k -bufsize:v 10000k -metadata:s:v:0 title="Main Track" ' - assert result == expected + assert isinstance(result, list) + assert result[0] == "ffmpeg" + assert "--extra-option" in result + assert "-init_hw_device" in result + assert "-ss" in result + assert "10" in result + assert "-to" in result + assert "60" in result + assert "-r" in result + assert "24" in result + assert "-metadata" in result + assert "title=Test Video" in result + assert "-fps_mode" in result + assert "cfr" in result + assert "-maxrate:v" in result + assert "5000k" in result + assert "-bufsize:v" in result + assert "10000k" in result + assert "-metadata:s:v:0" in result + assert "title=Main Track" in result def test_generate_ending_basic(): """Test the generate_ending function with basic parameters.""" ending, output_fps = generate_ending( - audio="", - subtitles="", + audio=[], + subtitles=[], output_video=Path("output.mkv"), ) - expected = ' -map_metadata -1 -map_chapters 0 "output.mkv"' - assert ending == expected - assert output_fps == "" + assert isinstance(ending, list) + assert "-map_metadata" in ending + assert "-1" in ending + assert "-map_chapters" in ending + assert "0" in ending + assert "output.mkv" in ending + assert output_fps == [] def test_generate_ending_with_options(): """Test the generate_ending function with various options.""" ending, output_fps = generate_ending( - audio="-map 0:1 -c:a copy", - subtitles="-map 0:2 -c:s copy", - cover="-attach cover.jpg", + audio=["-map", "0:1", "-c:a", "copy"], + subtitles=["-map", "0:2", "-c:s", "copy"], + cover=["-attach", "cover.jpg"], output_video=Path("output.mkv"), copy_chapters=False, remove_metadata=False, @@ -94,9 +131,25 @@ def test_generate_ending_with_options(): copy_data=True, ) - expected = ' -metadata:s:v rotate=0 -map_metadata 0 -map_chapters -1 -r 24 -map 0:1 -c:a copy -map 0:2 -c:s copy -attach cover.jpg -map 0:d -c:d copy "output.mkv"' - assert ending == expected - assert output_fps == "-r 24" + assert isinstance(ending, list) + assert "-metadata:s:v" in ending + assert "rotate=0" in ending + assert "-map_metadata" in ending + assert "0" in ending + assert "-map_chapters" in ending + assert "-1" in ending + assert "-r" in ending + assert "24" in ending + assert "-map" in ending + assert "0:1" in ending + assert "-c:a" in ending + assert "copy" in ending + assert "-attach" in ending + assert "cover.jpg" in ending + assert "0:d" in ending + assert "-c:d" in ending + assert "output.mkv" in ending + assert output_fps == ["-r", "24"] def test_generate_filters_basic(): @@ -106,8 +159,8 @@ def test_generate_filters_basic(): source=Path("input.mkv"), ) - # With no filters specified, should return empty string - assert result == "" + # With no filters specified, should return empty list + assert result == [] def test_generate_filters_with_crop(): @@ -118,8 +171,12 @@ def test_generate_filters_with_crop(): crop={"width": 1920, "height": 1080, "left": 0, "top": 0}, ) - expected = ' -filter_complex "[0:0]crop=1920:1080:0:0[v]" -map "[v]" ' - assert result == expected + assert isinstance(result, list) + assert len(result) == 4 + assert result[0] == "-filter_complex" + assert "[0:0]crop=1920:1080:0:0[v]" in result[1] + assert result[2] == "-map" + assert result[3] == "[v]" def test_generate_filters_with_scale(): @@ -130,8 +187,11 @@ def test_generate_filters_with_scale(): scale="1920:-8", ) - expected = ' -filter_complex "[0:0]scale=1920:-8:flags=lanczos,setsar=1:1[v]" -map "[v]" ' - assert result == expected + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "scale=1920:-8:flags=lanczos,setsar=1:1" in result[1] + assert result[2] == "-map" + assert result[3] == "[v]" def test_generate_filters_with_hdr_removal(): @@ -143,8 +203,11 @@ def test_generate_filters_with_hdr_removal(): tone_map="hable", ) - expected = ' -filter_complex "[0:0]zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p[v]" -map "[v]" ' - assert result == expected + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "tonemap=tonemap=hable" in result[1] + assert result[2] == "-map" + assert result[3] == "[v]" def test_generate_filters_with_multiple_options(): @@ -162,8 +225,19 @@ def test_generate_filters_with_multiple_options(): video_speed=0.5, ) - expected = ' -filter_complex "[0:0]yadif,crop=1920:1080:0:0,scale=1920:-8:flags=lanczos,setsar=1:1,transpose=1,setpts=0.5*PTS,eq=eval=frame:brightness=0.1:saturation=1.2:contrast=1.1[v]" -map "[v]" ' - assert result == expected + assert isinstance(result, list) + assert result[0] == "-filter_complex" + filter_str = result[1] + assert "yadif" in filter_str + assert "crop=1920:1080:0:0" in filter_str + assert "scale=1920:-8:flags=lanczos,setsar=1:1" in filter_str + assert "transpose=1" in filter_str + assert "setpts=0.5*PTS" in filter_str + assert "brightness=0.1" in filter_str + assert "saturation=1.2" in filter_str + assert "contrast=1.1" in filter_str + assert result[2] == "-map" + assert result[3] == "[v]" def test_generate_all(fastflix_instance): @@ -177,13 +251,13 @@ def test_generate_all(fastflix_instance): mock.patch("fastflix.encoders.common.helpers.generate_ending") as mock_generate_ending, mock.patch("fastflix.encoders.common.helpers.generate_ffmpeg_start") as mock_generate_ffmpeg_start, ): - # Set up the mock returns - mock_build_audio.return_value = "-map 0:1 -c:a copy" - mock_build_subtitle.return_value = ("-map 0:2 -c:s copy", None, None) - mock_build_attachments.return_value = "-attach cover.jpg" - mock_generate_filters.return_value = "-filter_complex [0:0]scale=1920:-8[v] -map [v]" - mock_generate_ending.return_value = (' -map_metadata -1 "output.mkv"', "-r 24") - mock_generate_ffmpeg_start.return_value = 'ffmpeg -y -i "input.mkv"' + # Set up the mock returns as lists + mock_build_audio.return_value = ["-map", "0:1", "-c:a", "copy"] + mock_build_subtitle.return_value = (["-map", "0:2", "-c:s", "copy"], None, None) + mock_build_attachments.return_value = ["-attach", "cover.jpg"] + mock_generate_filters.return_value = ["-filter_complex", "[0:0]scale=1920:-8[v]", "-map", "[v]"] + mock_generate_ending.return_value = (["-map_metadata", "-1", "output.mkv"], ["-r", "24"]) + mock_generate_ffmpeg_start.return_value = ["ffmpeg", "-y", "-i", "input.mkv"] # Set up the video encoder settings fastflix_instance.current_video.video_settings.video_encoder_settings = x265Settings() @@ -192,13 +266,16 @@ def test_generate_all(fastflix_instance): beginning, ending, output_fps = generate_all(fastflix_instance, "libx265") # Check the results - assert beginning == 'ffmpeg -y -i "input.mkv"' - assert ending == ' -map_metadata -1 "output.mkv"' - assert output_fps == "-r 24" + assert beginning == ["ffmpeg", "-y", "-i", "input.mkv"] + assert ending == ["-map_metadata", "-1", "output.mkv"] + assert output_fps == ["-r", "24"] # Verify the mock calls mock_build_audio.assert_called_once_with(fastflix_instance.current_video.audio_tracks) - mock_build_subtitle.assert_called_once_with(fastflix_instance.current_video.subtitle_tracks) + mock_build_subtitle.assert_called_once_with( + fastflix_instance.current_video.subtitle_tracks, + output_path=fastflix_instance.current_video.video_settings.output_path, + ) mock_build_attachments.assert_called_once_with(fastflix_instance.current_video.attachment_tracks) @@ -207,7 +284,7 @@ def test_generate_color_details(fastflix_instance): # Test with HDR removal enabled fastflix_instance.current_video.video_settings.remove_hdr = True result = generate_color_details(fastflix_instance) - assert result == "" + assert result == [] # Test with HDR removal disabled and color settings fastflix_instance.current_video.video_settings.remove_hdr = False @@ -216,5 +293,4 @@ def test_generate_color_details(fastflix_instance): fastflix_instance.current_video.video_settings.color_space = "bt2020nc" result = generate_color_details(fastflix_instance) - expected = "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc" - assert result == expected + assert result == ["-color_primaries", "bt2020", "-color_trc", "smpte2084", "-colorspace", "bt2020nc"] diff --git a/tests/encoders/test_hevc_x265_command_builder.py b/tests/encoders/test_hevc_x265_command_builder.py index 7f8c7e64..48b8e388 100644 --- a/tests/encoders/test_hevc_x265_command_builder.py +++ b/tests/encoders/test_hevc_x265_command_builder.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- from unittest import mock -import reusables - from fastflix.encoders.hevc_x265.command_builder import build +from fastflix.encoders.common.helpers import null from fastflix.models.encode import x265Settings from fastflix.models.video import VideoSettings @@ -35,17 +34,31 @@ def test_hevc_x265_basic_crf(): ), ) - # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.hevc_x265.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", None) + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + [], + ) result = build(fastflix) - # The expected command should include the CRF setting and other basic parameters - expected_command = 'ffmpeg -y -i input.mkv -x265-params "aq-mode=2:repeat-headers=0:strong-intra-smoothing=1:bframes=4:b-adapt=2:frame-threads=0:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10_opt=0:hdr10=0:chromaloc=0" -crf:v 22 -preset:v medium output.mkv' - assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" - assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + assert isinstance(result, list) + assert len(result) == 1 + cmd = result[0].command + assert isinstance(cmd, list) + assert "-x265-params" in cmd + assert "-crf:v" in cmd + assert "22" in cmd + assert "-preset:v" in cmd + assert "medium" in cmd + + # Verify x265 params contain expected values + params_idx = cmd.index("-x265-params") + params_str = cmd[params_idx + 1] + assert "aq-mode=2" in params_str + assert "bframes=4" in params_str + assert "colorprim=bt2020" in params_str def test_hevc_x265_two_pass_bitrate(): @@ -74,28 +87,39 @@ def test_hevc_x265_two_pass_bitrate(): ), ) - # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.hevc_x265.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", None) - # Mock the secrets.token_hex function to return a predictable result + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + [], + ) with mock.patch("fastflix.encoders.hevc_x265.command_builder.secrets.token_hex") as mock_token_hex: mock_token_hex.return_value = "abcdef1234" result = build(fastflix) - # The expected command should be a list of two Command objects for two-pass encoding - expected_commands = [ - f'ffmpeg -y -i input.mkv -x265-params "aq-mode=2:repeat-headers=0:strong-intra-smoothing=1:bframes=4:b-adapt=2:frame-threads=0:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10_opt=0:hdr10=0:chromaloc=0:pass=1:no-slow-firstpass=1:stats=pass_log_file_abcdef1234.log" -b:v 5000k -preset:v medium -an -sn -dn None -f mp4 {"NUL" if reusables.win_based else "/dev/null"}', - 'ffmpeg -y -i input.mkv -x265-params "aq-mode=2:repeat-headers=0:strong-intra-smoothing=1:bframes=4:b-adapt=2:frame-threads=0:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10_opt=0:hdr10=0:chromaloc=0:pass=2:stats=pass_log_file_abcdef1234.log" -b:v 5000k -preset:v medium output.mkv', - ] - assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" - assert len(result) == 2, f"Expected 2 Command objects, got {len(result)}" - assert result[0].command == expected_commands[0], ( - f"Expected: {expected_commands[0]}\nGot: {result[0].command}" - ) - assert result[1].command == expected_commands[1], ( - f"Expected: {expected_commands[1]}\nGot: {result[1].command}" - ) + assert isinstance(result, list) + assert len(result) == 2 + + # First pass + cmd1 = result[0].command + assert isinstance(cmd1, list) + assert "-b:v" in cmd1 + assert "5000k" in cmd1 + assert "-an" in cmd1 + assert "-sn" in cmd1 + assert null in cmd1 + params_idx = cmd1.index("-x265-params") + assert "pass=1" in cmd1[params_idx + 1] + + # Second pass + cmd2 = result[1].command + assert isinstance(cmd2, list) + assert "-b:v" in cmd2 + assert "5000k" in cmd2 + assert "output.mkv" in cmd2 + params_idx = cmd2.index("-x265-params") + assert "pass=2" in cmd2[params_idx + 1] def test_hevc_x265_hdr10_settings(): @@ -124,17 +148,26 @@ def test_hevc_x265_hdr10_settings(): hdr10_metadata=True, ) - # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.hevc_x265.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", None) + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + [], + ) result = build(fastflix) - # The expected command should include HDR10 settings - expected_command = 'ffmpeg -y -i input.mkv -x265-params "aq-mode=2:repeat-headers=1:strong-intra-smoothing=1:bframes=4:b-adapt=2:frame-threads=0:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10_opt=1:master-display=G(0.2650,0.6900)B(0.1500,0.0600)R(0.6800,0.3200)WP(0.3127,0.3290)L(1000.0,0.0001):max-cll=1000,300:hdr10=1:chromaloc=0" -crf:v 22 -preset:v medium output.mkv' - assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" - assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + assert isinstance(result, list) + assert len(result) == 1 + cmd = result[0].command + assert isinstance(cmd, list) + + params_idx = cmd.index("-x265-params") + params_str = cmd[params_idx + 1] + assert "hdr10_opt=1" in params_str + assert "hdr10=1" in params_str + assert "master-display=" in params_str + assert "max-cll=1000,300" in params_str def test_hevc_x265_custom_params(): @@ -163,17 +196,24 @@ def test_hevc_x265_custom_params(): ), ) - # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.hevc_x265.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", None) + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + [], + ) result = build(fastflix) - # The expected command should include the custom x265 parameters - expected_command = 'ffmpeg -y -i input.mkv -x265-params "keyint=120:min-keyint=60:aq-mode=2:repeat-headers=0:strong-intra-smoothing=1:bframes=4:b-adapt=2:frame-threads=0:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10_opt=0:hdr10=0:chromaloc=0" -crf:v 22 -preset:v medium output.mkv' - assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" - assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + assert isinstance(result, list) + assert len(result) == 1 + cmd = result[0].command + assert isinstance(cmd, list) + + params_idx = cmd.index("-x265-params") + params_str = cmd[params_idx + 1] + assert "keyint=120" in params_str + assert "min-keyint=60" in params_str def test_hevc_x265_tune_profile(): @@ -201,14 +241,20 @@ def test_hevc_x265_tune_profile(): ), ) - # Mock the generate_all function to return a predictable result with mock.patch("fastflix.encoders.hevc_x265.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", None) + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + [], + ) result = build(fastflix) - # The expected command should include the tune and profile settings - expected_command = 'ffmpeg -y -i input.mkv -tune:v animation -profile:v main10 -x265-params "aq-mode=2:repeat-headers=0:strong-intra-smoothing=1:bframes=4:b-adapt=2:frame-threads=0:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:hdr10_opt=0:hdr10=0:chromaloc=0" -crf:v 22 -preset:v medium output.mkv' - assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" - assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + assert isinstance(result, list) + assert len(result) == 1 + cmd = result[0].command + assert isinstance(cmd, list) + assert "-tune:v" in cmd + assert "animation" in cmd + assert "-profile:v" in cmd + assert "main10" in cmd diff --git a/tests/encoders/test_subtitles.py b/tests/encoders/test_subtitles.py index 5b40601c..f1293180 100644 --- a/tests/encoders/test_subtitles.py +++ b/tests/encoders/test_subtitles.py @@ -7,7 +7,7 @@ def test_build_subtitle_empty(): """Test the build_subtitle function with an empty list.""" result, burn_in_track, burn_in_type = build_subtitle([]) - assert result == "-default_mode infer_no_subs" + assert result == ["-default_mode", "infer_no_subs"] assert burn_in_track is None assert burn_in_type is None @@ -19,7 +19,7 @@ def test_build_subtitle_disabled_tracks(sample_subtitle_tracks): track.enabled = False result, burn_in_track, burn_in_type = build_subtitle(sample_subtitle_tracks) - assert result == "-default_mode infer_no_subs" + assert result == ["-default_mode", "infer_no_subs"] assert burn_in_track is None assert burn_in_type is None @@ -34,18 +34,25 @@ def test_build_subtitle_copy_tracks(sample_subtitle_tracks): result, burn_in_track, burn_in_type = build_subtitle(sample_subtitle_tracks) # Check that each track is mapped and copied - assert "-map 0:0 -c:0 copy" in result - assert "-map 0:1 -c:1 copy" in result - assert "-map 0:2 -c:2 copy" in result - - # Check that languages are set - assert "language='eng'" in result - assert "language='jpn'" in result + assert "-map" in result + assert "0:0" in result + assert "-c:0" in result + assert "0:1" in result + assert "-c:1" in result + assert "0:2" in result + assert "-c:2" in result + assert "copy" in result + + # Check that languages are set (no quotes around language value in list-based API) + assert "language=eng" in result + assert "language=jpn" in result # Check that dispositions are set correctly - assert "-disposition:0 default" in result - assert "-disposition:1 0" in result - assert "-disposition:2 forced" in result + assert "-disposition:0" in result + assert "default" in result + assert "-disposition:1" in result + assert "-disposition:2" in result + assert "forced" in result # Check that burn-in track and type are None assert burn_in_track is None @@ -62,19 +69,24 @@ def test_build_subtitle_with_burn_in(sample_subtitle_tracks): result, burn_in_track, burn_in_type = build_subtitle(sample_subtitle_tracks) # Check that the burn-in track is not included in the command - assert "-map 0:0" not in result + assert "0:0" not in result # Check that the other tracks are mapped and copied with adjusted outdex - assert "-map 0:1 -c:1 copy" in result - assert "-map 0:2 -c:2 copy" in result + assert "-map" in result + assert "0:1" in result + assert "-c:1" in result + assert "0:2" in result + assert "-c:2" in result + assert "copy" in result # Check that languages are set - assert "language='jpn'" in result - assert "language='eng'" in result + assert "language=jpn" in result + assert "language=eng" in result # Check that dispositions are set correctly - assert "-disposition:1 0" in result - assert "-disposition:2 forced" in result + assert "-disposition:1" in result + assert "-disposition:2" in result + assert "forced" in result # Check that burn-in track and type are set correctly assert burn_in_track == 0 @@ -96,11 +108,15 @@ def test_build_subtitle_with_different_subtitle_types(sample_subtitle_tracks): result, burn_in_track, burn_in_type = build_subtitle(sample_subtitle_tracks) # Check that the burn-in track is not included in the command - assert "-map 0:1" not in result + assert "0:1" not in result # Check that the other tracks are mapped and copied - assert "-map 0:0 -c:0 copy" in result - assert "-map 0:2 -c:1 copy" in result + assert "-map" in result + assert "0:0" in result + assert "-c:0" in result + assert "0:2" in result + assert "-c:1" in result + assert "copy" in result # Check that burn-in track and type are set correctly assert burn_in_track == 1 @@ -117,7 +133,7 @@ def test_build_subtitle_with_default_subs_enabled(sample_subtitle_tracks): result, burn_in_track, burn_in_type = build_subtitle(sample_subtitle_tracks) # Check that default_mode is not added since there's a default track - assert "-default_mode infer_no_subs" not in result + assert "-default_mode" not in result def test_build_subtitle_with_no_default_or_forced_subs(sample_subtitle_tracks): @@ -130,7 +146,8 @@ def test_build_subtitle_with_no_default_or_forced_subs(sample_subtitle_tracks): result, burn_in_track, burn_in_type = build_subtitle(sample_subtitle_tracks) # Check that default_mode is added - assert "-default_mode infer_no_subs" in result + assert "-default_mode" in result + assert "infer_no_subs" in result def test_build_subtitle_with_custom_file_index(): @@ -150,4 +167,5 @@ def test_build_subtitle_with_custom_file_index(): result, burn_in_track, burn_in_type = build_subtitle([subtitle_track], subtitle_file_index=1) # Check that the custom file index is used - assert "-map 1:0" in result + assert "-map" in result + assert "1:0" in result diff --git a/tests/encoders/test_svt_av1_command_builder.py b/tests/encoders/test_svt_av1_command_builder.py index ae47e417..ba68e474 100644 --- a/tests/encoders/test_svt_av1_command_builder.py +++ b/tests/encoders/test_svt_av1_command_builder.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- from unittest import mock -import reusables - +from fastflix.encoders.common.helpers import null from fastflix.encoders.svt_av1.command_builder import build from fastflix.models.encode import SVTAV1Settings from fastflix.models.video import VideoSettings @@ -30,23 +29,44 @@ def test_svt_av1_single_pass_qp(): ), ) - # Mock the generate_all function to return a predictable result + # Mock the generate_all function to return a predictable result (lists) with mock.patch("fastflix.encoders.svt_av1.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) - # Mock the generate_color_details function to return a predictable result + # Mock the generate_color_details function to return a predictable result (list) with mock.patch( "fastflix.encoders.svt_av1.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] result = build(fastflix) - # The expected command should include the QP setting and other basic parameters - expected_command = 'ffmpeg -y -i input.mkv -strict experimental -preset 7 --color_details -svtav1-params "tile-columns=0:tile-rows=0:scd=0:color-primaries=9:transfer-characteristics=16:matrix-coefficients=9" -crf 24 output.mkv' assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + + cmd = result[0].command + assert isinstance(cmd, list), f"Expected command to be a list, got {type(cmd)}" + + # Check key elements are present in the command list + assert "-strict" in cmd + assert "experimental" in cmd + assert "-preset" in cmd + assert "7" in cmd + assert "-crf" in cmd + assert "24" in cmd + assert "-svtav1-params" in cmd + assert "output.mkv" in cmd + + # Verify svtav1-params contains the expected parameters + params_idx = cmd.index("-svtav1-params") + params_value = cmd[params_idx + 1] + assert "tile-columns=0" in params_value + assert "tile-rows=0" in params_value + assert "scd=0" in params_value def test_svt_av1_two_pass_qp(): @@ -69,15 +89,19 @@ def test_svt_av1_two_pass_qp(): ), ) - # Mock the generate_all function to return a predictable result + # Mock the generate_all function to return a predictable result (lists) with mock.patch("fastflix.encoders.svt_av1.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) - # Mock the generate_color_details function to return a predictable result + # Mock the generate_color_details function to return a predictable result (list) with mock.patch( "fastflix.encoders.svt_av1.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] # Mock the secrets.token_hex function to return a predictable result with mock.patch("fastflix.encoders.svt_av1.command_builder.secrets.token_hex") as mock_token_hex: @@ -85,19 +109,27 @@ def test_svt_av1_two_pass_qp(): result = build(fastflix) - # The expected command should be a list of two Command objects for two-pass encoding - expected_commands = [ - f'ffmpeg -y -i input.mkv -strict experimental -preset 7 --color_details -svtav1-params "tile-columns=0:tile-rows=0:scd=0:color-primaries=9:transfer-characteristics=16:matrix-coefficients=9" -passlogfile "pass_log_file_abcdef1234" -crf 24 -pass 1 -an -r 24 -f matroska {"NUL" if reusables.win_based else "/dev/null"}', - 'ffmpeg -y -i input.mkv -strict experimental -preset 7 --color_details -svtav1-params "tile-columns=0:tile-rows=0:scd=0:color-primaries=9:transfer-characteristics=16:matrix-coefficients=9" -passlogfile "pass_log_file_abcdef1234" -crf 24 -pass 2 output.mkv', - ] assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 2, f"Expected 2 Command objects, got {len(result)}" - assert result[0].command == expected_commands[0], ( - f"Expected: {expected_commands[0]}\nGot: {result[0].command}" - ) - assert result[1].command == expected_commands[1], ( - f"Expected: {expected_commands[1]}\nGot: {result[1].command}" - ) + + cmd1 = result[0].command + cmd2 = result[1].command + assert isinstance(cmd1, list), f"Expected command to be a list, got {type(cmd1)}" + assert isinstance(cmd2, list), f"Expected command to be a list, got {type(cmd2)}" + + # First pass should have pass 1, -an, null output + assert "-pass" in cmd1 + assert "1" in cmd1[cmd1.index("-pass") + 1 :][:1] + assert "-an" in cmd1 + assert "-f" in cmd1 + assert "matroska" in cmd1 + assert null in cmd1 + assert "-passlogfile" in cmd1 + + # Second pass should have pass 2, real output + assert "-pass" in cmd2 + assert "2" in cmd2[cmd2.index("-pass") + 1 :][:1] + assert "output.mkv" in cmd2 def test_svt_av1_single_pass_bitrate(): @@ -119,23 +151,32 @@ def test_svt_av1_single_pass_bitrate(): ), ) - # Mock the generate_all function to return a predictable result + # Mock the generate_all function to return a predictable result (lists) with mock.patch("fastflix.encoders.svt_av1.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) - # Mock the generate_color_details function to return a predictable result + # Mock the generate_color_details function to return a predictable result (list) with mock.patch( "fastflix.encoders.svt_av1.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] result = build(fastflix) - # The expected command should include the bitrate setting - expected_command = 'ffmpeg -y -i input.mkv -strict experimental -preset 7 --color_details -svtav1-params "tile-columns=0:tile-rows=0:scd=0:color-primaries=9:transfer-characteristics=16:matrix-coefficients=9" -b:v 5000k output.mkv' assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + + cmd = result[0].command + assert isinstance(cmd, list), f"Expected command to be a list, got {type(cmd)}" + + # Check key elements + assert "-b:v" in cmd + assert "5000k" in cmd + assert "output.mkv" in cmd def test_svt_av1_with_hdr(): @@ -160,24 +201,35 @@ def test_svt_av1_with_hdr(): hdr10_metadata=True, ) - # Mock the generate_all function to return a predictable result + # Mock the generate_all function to return a predictable result (lists) with mock.patch("fastflix.encoders.svt_av1.command_builder.generate_all") as mock_generate_all: - mock_generate_all.return_value = ("ffmpeg -y -i input.mkv ", " output.mkv", "-r 24") + mock_generate_all.return_value = ( + ["ffmpeg", "-y", "-i", "input.mkv"], + ["output.mkv"], + ["-r", "24"], + ) - # Mock the generate_color_details function to return a predictable result + # Mock the generate_color_details function to return a predictable result (list) with mock.patch( "fastflix.encoders.svt_av1.command_builder.generate_color_details" ) as mock_generate_color_details: - mock_generate_color_details.return_value = "--color_details" + mock_generate_color_details.return_value = ["-color_primaries", "bt2020"] - # Mock the convert_me function to return predictable results - with mock.patch("fastflix.encoders.svt_av1.command_builder.convert_me", create=True) as mock_convert_me: - mock_convert_me.side_effect = lambda x, y=50000: "0.0100,0.0200" if y == 50000 else "0.1000,0.0001" + result = build(fastflix) - result = build(fastflix) + assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" + assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - # The expected command should include HDR settings - expected_command = 'ffmpeg -y -i input.mkv -strict experimental -preset 7 --color_details -svtav1-params "tile-columns=0:tile-rows=0:scd=1:color-primaries=9:transfer-characteristics=16:matrix-coefficients=9:mastering-display=G(0.0000,0.0000)B(0.0000,0.0000)R(0.0000,0.0000)WP(0.0000,0.0000)L(0.1000,0.0000):content-light=1000,300:enable-hdr=1" -crf 24 output.mkv' - assert isinstance(result, list), f"Expected a list of Command objects, got {type(result)}" - assert len(result) == 1, f"Expected 1 Command object, got {len(result)}" - assert result[0].command == expected_command, f"Expected: {expected_command}\nGot: {result[0].command}" + cmd = result[0].command + assert isinstance(cmd, list), f"Expected command to be a list, got {type(cmd)}" + + # Verify svtav1-params contains HDR-related parameters + params_idx = cmd.index("-svtav1-params") + params_value = cmd[params_idx + 1] + assert "scd=1" in params_value + assert "color-primaries=9" in params_value + assert "transfer-characteristics=16" in params_value + assert "matrix-coefficients=9" in params_value + assert "mastering-display=" in params_value + assert "content-light=" in params_value + assert "enable-hdr=1" in params_value diff --git a/tests/test_audio.py b/tests/test_audio.py index 0d3418da..b020cb97 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -405,7 +405,7 @@ def test_build_audio_with_none_raw_info(self): ) # Should not raise AttributeError result = build_audio([track]) - assert "-c:0 aac" in result + assert "-c:0" in result and "aac" in result def test_build_audio_with_raw_info_missing_channel_layout(self): """Test that build_audio handles raw_info without channel_layout.""" @@ -425,4 +425,4 @@ def test_build_audio_with_raw_info_missing_channel_layout(self): ) # Should fall back to stereo without crashing result = build_audio([track]) - assert "-c:0 aac" in result + assert "-c:0" in result and "aac" in result From 7bcb8196b56064dad1c80c45d81763c8bfbfe779 Mon Sep 17 00:00:00 2001 From: Dude <398394+mikeSGman@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:15:10 -0500 Subject: [PATCH 17/25] Add PGS to SRT OCR subtitle extraction feature (#709) Implements OCR-based conversion of PGS (Presentation Graphic Stream) subtitles to SRT format with automatic tool detection. Features: - Auto-detect Tesseract OCR from PATH or Subtitle Edit installations - Auto-detect MKVToolNix from standard install locations - Support multiple language codes (ISO 639-2/3, language names) - GUI checkbox to enable/disable OCR for PGS subtitles - Automatic cleanup of intermediate .sup files after conversion Dependencies: - pgsrip: PGS subtitle OCR engine - pytesseract: Tesseract OCR wrapper - babelfish: Language code handling - opencv-python, cleanit, trakit: Image/metadata processing Known limitation: This feature works when running from source (python -m fastflix) but not in PyInstaller-built executables due to subprocess environment issues with pgsrip. Users needing PGS OCR should run from source. --- FastFlix_Windows_Installer.spec | 7 +- FastFlix_Windows_OneFile.spec | 7 +- README.md | 19 ++ WINDOWS_BUILD.md | 133 ++++++++ fastflix/__main__.py | 27 ++ fastflix/models/config.py | 116 ++++++- fastflix/widgets/background_tasks.py | 148 ++++++++- fastflix/widgets/panels/subtitle_panel.py | 44 ++- fastflix/widgets/settings.py | 50 ++- pyproject.toml | 7 + uv.lock | 379 +++++++++++++++++++++- 11 files changed, 923 insertions(+), 14 deletions(-) create mode 100644 WINDOWS_BUILD.md diff --git a/FastFlix_Windows_Installer.spec b/FastFlix_Windows_Installer.spec index 4f03f0c0..961a0294 100644 --- a/FastFlix_Windows_Installer.spec +++ b/FastFlix_Windows_Installer.spec @@ -1,5 +1,5 @@ # -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_submodules, copy_metadata, collect_data_files import toml block_cipher = None @@ -24,9 +24,12 @@ all_imports.remove("python-box") all_imports.append("box") all_imports.append("iso639") +# Add pgsrip for OCR support +all_imports.extend(["pgsrip", "pytesseract", "cv2", "numpy", "pysrt", "babelfish", "babelfish.converters", "babelfish.converters.alpha2", "babelfish.converters.alpha3b", "babelfish.converters.alpha3t", "babelfish.converters.name", "babelfish.converters.opensubtitles", "cleanit"]) + a = Analysis(['fastflix\\__main__.py'], binaries=[], - datas=[('CHANGES', 'fastflix\\.'), ('docs\\build-licenses.txt', 'docs')] + all_fastflix_files, + datas=[('CHANGES', 'fastflix\\.'), ('docs\\build-licenses.txt', 'docs')] + all_fastflix_files + copy_metadata('pgsrip') + copy_metadata('pytesseract') + copy_metadata('babelfish') + copy_metadata('cleanit') + copy_metadata('trakit') + collect_data_files('babelfish') + collect_data_files('cleanit'), hiddenimports=all_imports, hookspath=[], runtime_hooks=[], diff --git a/FastFlix_Windows_OneFile.spec b/FastFlix_Windows_OneFile.spec index 55d18d9e..5dba54f4 100644 --- a/FastFlix_Windows_OneFile.spec +++ b/FastFlix_Windows_OneFile.spec @@ -2,7 +2,7 @@ import os import toml -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_submodules, copy_metadata, collect_data_files block_cipher = None @@ -27,13 +27,16 @@ all_imports.remove("python-box") all_imports.append("box") all_imports.append("iso639") +# Add pgsrip for OCR support +all_imports.extend(["pgsrip", "pytesseract", "cv2", "numpy", "pysrt", "babelfish", "babelfish.converters", "babelfish.converters.alpha2", "babelfish.converters.alpha3b", "babelfish.converters.alpha3t", "babelfish.converters.name", "babelfish.converters.opensubtitles", "cleanit"]) + portable_file = "fastflix\\portable.py" with open(portable_file, "w") as portable: portable.write(" ") a = Analysis(['fastflix\\__main__.py'], binaries=[], - datas=[('CHANGES', 'fastflix\\.'), ('docs\\build-licenses.txt', 'docs')] + all_fastflix_files, + datas=[('CHANGES', 'fastflix\\.'), ('docs\\build-licenses.txt', 'docs')] + all_fastflix_files + copy_metadata('pgsrip') + copy_metadata('pytesseract') + copy_metadata('babelfish') + copy_metadata('cleanit') + copy_metadata('trakit') + collect_data_files('babelfish') + collect_data_files('cleanit'), hiddenimports=all_imports, hookspath=[], runtime_hooks=[], diff --git a/README.md b/README.md index 3521d2af..e60f0f5c 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,25 @@ Windows: Go into FastFlix's settings and select the corresponding EXE file for e Linux: Install the rpm or deb and restart FastFlix +# Subtitle Extraction + +FastFlix can extract subtitles from video files in various formats including SRT, ASS, SSA, and PGS. + +## PGS to SRT OCR Conversion + +FastFlix includes experimental support for converting PGS (Presentation Graphic Stream) subtitles to SRT format using OCR. This feature automatically detects and uses installed OCR tools. + +**Requirements (auto-detected)**: +- Tesseract OCR 4.x or higher +- MKVToolNix (mkvextract/mkvmerge) + +**Important**: This feature only works when running FastFlix from source: +```bash +python -m fastflix +``` + +The Windows/Mac executable builds do not support PGS OCR due to environment limitations with the pgsrip library. If you need this feature, install FastFlix via pip and run from source. + # HDR On any 10-bit or higher video output, FastFlix will copy the input HDR colorspace (bt2020). Which is [different than HDR10 or HDR10+](https://codecalamity.com/hdr-hdr10-hdr10-hlg-and-dolby-vision/). diff --git a/WINDOWS_BUILD.md b/WINDOWS_BUILD.md new file mode 100644 index 00000000..e34465e2 --- /dev/null +++ b/WINDOWS_BUILD.md @@ -0,0 +1,133 @@ +# Building FastFlix on Windows + +This guide explains how to build FastFlix executables on Windows. + +## Prerequisites + +1. **Python 3.12 or higher** + - Download from [python.org](https://www.python.org/downloads/) + - Make sure to check "Add Python to PATH" during installation + +2. **Git** (to clone/update the repository) + - Download from [git-scm.com](https://git-scm.com/download/win) + +## Build Steps + +### 1. Open Command Prompt or PowerShell + +Navigate to where you want to clone/have the FastFlix repository: + +```bash +cd C:\path\to\your\projects +git clone https://github.com/cdgriffith/FastFlix.git +cd FastFlix +``` + +Or if you already have it: + +```bash +cd C:\path\to\FastFlix +``` + +### 2. Create and Activate Virtual Environment + +```bash +python -m venv venv +venv\Scripts\activate +``` + +You should see `(venv)` in your command prompt. + +### 3. Install Dependencies + +```bash +pip install --upgrade pip +pip install -e ".[dev]" +``` + +This installs FastFlix in editable mode with all development dependencies including PyInstaller. + +### 4. Build the Executable + +You have two options: + +#### Option A: Single Executable (Recommended for distribution) + +```bash +pyinstaller FastFlix_Windows_OneFile.spec +``` + +The executable will be in: `dist\FastFlix.exe` + +#### Option B: Directory with Multiple Files (Faster startup) + +```bash +pyinstaller FastFlix_Windows_Installer.spec +``` + +The executable will be in: `dist\FastFlix\FastFlix.exe` + +### 5. Test the Build + +```bash +cd dist +FastFlix.exe +``` + +Or for the installer version: + +```bash +cd dist\FastFlix +FastFlix.exe +``` + +## Running Without Building (For Testing) + +If you just want to test changes without building an executable: + +```bash +python -m fastflix +``` + +## Troubleshooting + +### Missing Dependencies + +If you get import errors, try reinstalling: + +```bash +pip install --upgrade --force-reinstall -e ".[dev]" +``` + +### Build Errors + +1. Make sure you're in the FastFlix root directory +2. Ensure the virtual environment is activated (you see `(venv)`) +3. Try deleting `build` and `dist` folders and rebuilding: + +```bash +rmdir /s /q build dist +pyinstaller FastFlix_Windows_OneFile.spec +``` + +### FFmpeg Not Found + +The FastFlix executable doesn't include FFmpeg. You need to: + +1. Download FFmpeg from [ffmpeg.org](https://ffmpeg.org/download.html#build-windows) +2. Extract it somewhere +3. Add the `bin` folder to your PATH, or configure it in FastFlix settings + +## Known Limitations + +### PGS to SRT OCR (PyInstaller builds) + +Due to an upstream issue in pgsrip v0.1.12, PGS to SRT OCR conversion does not work in PyInstaller-built executables. The feature works perfectly when running from source (`python -m fastflix`). + +If you need PGS OCR functionality, please run FastFlix from source instead of using the compiled executable. + +## Notes + +- The build process creates a `portable.py` file temporarily (it's removed after) +- The `.spec` files automatically collect all dependencies from `pyproject.toml` +- The icon is located at `fastflix\data\icon.ico` diff --git a/fastflix/__main__.py b/fastflix/__main__.py index bffdf715..6692ca28 100644 --- a/fastflix/__main__.py +++ b/fastflix/__main__.py @@ -1,11 +1,35 @@ # -*- coding: utf-8 -*- +import os import sys import traceback from multiprocessing import freeze_support +from pathlib import Path from fastflix.entry import main +def setup_ocr_environment(): + """Set up environment variables for OCR tools early in app startup. + + This is necessary for PyInstaller frozen executables where os.environ + modifications later in the code don't properly propagate to subprocesses. + """ + from fastflix.models.config import find_ocr_tool + + # Find tesseract and add to PATH + tesseract_path = find_ocr_tool("tesseract") + if tesseract_path: + tesseract_dir = str(Path(tesseract_path).parent) + os.environ["PATH"] = f"{tesseract_dir}{os.pathsep}{os.environ.get('PATH', '')}" + os.environ["TESSERACT_CMD"] = str(tesseract_path) + + # Find mkvmerge and add MKVToolNix to PATH + mkvmerge_path = find_ocr_tool("mkvmerge") + if mkvmerge_path: + mkvtoolnix_dir = str(Path(mkvmerge_path).parent) + os.environ["PATH"] = f"{mkvtoolnix_dir}{os.pathsep}{os.environ.get('PATH', '')}" + + def start_fastflix(): exit_code = 2 portable_mode = True @@ -17,6 +41,9 @@ def start_fastflix(): if portable_mode: print("PORTABLE MODE DETECTED: now using local config file and workspace in same directory as the executable") + # Set up OCR environment variables early for PyInstaller compatibility + setup_ocr_environment() + try: exit_code = main(portable_mode) except Exception: diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 86ed67f1..1c0b90ec 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -99,6 +99,113 @@ def where(filename: str, portable_mode=False) -> Path | None: return None +def find_ocr_tool(name): + """Find OCR tools (tesseract, mkvmerge, pgsrip) similar to how we find FFmpeg""" + # Check environment variable + if ocr_location := os.getenv(f"FF_{name.upper()}"): + return Path(ocr_location).absolute() + + # Check system PATH + if (ocr_location := shutil.which(name)) is not None: + return Path(ocr_location).absolute() + + # Special handling for tesseract on Windows (not in PATH by default) + if name == "tesseract" and win_based: + # Check common install locations using environment variables + localappdata = os.getenv("LOCALAPPDATA") + appdata = os.getenv("APPDATA") + program_files = os.getenv("PROGRAMFILES") + program_files_x86 = os.getenv("PROGRAMFILES(X86)") + + # Check for Subtitle Edit's Tesseract installations and find the newest version + subtitle_edit_versions = [] + if appdata: + subtitle_edit_dir = Path(appdata) / "Subtitle Edit" + if subtitle_edit_dir.exists(): + # Find all Tesseract* directories + for tesseract_dir in subtitle_edit_dir.glob("Tesseract*"): + tesseract_exe = tesseract_dir / "tesseract.exe" + if tesseract_exe.exists(): + # Extract version number from directory name (e.g., Tesseract550 -> 550) + version_str = tesseract_dir.name.replace("Tesseract", "") + try: + version = int(version_str) + subtitle_edit_versions.append((version, tesseract_exe)) + except ValueError: + # If we can't parse version, still add it with version 0 + subtitle_edit_versions.append((0, tesseract_exe)) + + # If we found Subtitle Edit versions, return the newest one + if subtitle_edit_versions: + subtitle_edit_versions.sort(reverse=True) # Sort by version descending + return subtitle_edit_versions[0][1] + + common_paths = [] + # Check user-local installation first + if localappdata: + common_paths.append(Path(localappdata) / "Programs" / "Tesseract-OCR" / "tesseract.exe") + # Check system-wide installations + if program_files: + common_paths.append(Path(program_files) / "Tesseract-OCR" / "tesseract.exe") + if program_files_x86: + common_paths.append(Path(program_files_x86) / "Tesseract-OCR" / "tesseract.exe") + + for path in common_paths: + if path.exists(): + return path + + # Check Windows registry for Tesseract install location + try: + import winreg + + # Try HKEY_LOCAL_MACHINE first (system-wide install) + for root_key in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: + try: + key = winreg.OpenKey(root_key, r"SOFTWARE\Tesseract-OCR") + install_path = winreg.QueryValueEx(key, "InstallDir")[0] + winreg.CloseKey(key) + tesseract_exe = Path(install_path) / "tesseract.exe" + if tesseract_exe.exists(): + return tesseract_exe + except (FileNotFoundError, OSError): + pass + except ImportError: + pass + + # Special handling for mkvmerge on Windows + if name == "mkvmerge" and win_based: + # Check common install locations using environment variables + localappdata = os.getenv("LOCALAPPDATA") + program_files = os.getenv("PROGRAMFILES") + program_files_x86 = os.getenv("PROGRAMFILES(X86)") + + common_paths = [] + # Check user-local installation first + if localappdata: + common_paths.append(Path(localappdata) / "Programs" / "MKVToolNix" / "mkvmerge.exe") + # Check system-wide installations + if program_files: + common_paths.append(Path(program_files) / "MKVToolNix" / "mkvmerge.exe") + if program_files_x86: + common_paths.append(Path(program_files_x86) / "MKVToolNix" / "mkvmerge.exe") + + for path in common_paths: + if path.exists(): + return path + + # Check in FastFlix OCR tools folder + ocr_folder = Path(user_data_dir("FastFlix_OCR", appauthor=False, roaming=True)) + if ocr_folder.exists(): + for file in ocr_folder.iterdir(): + if file.is_file() and file.name.lower() in (name, f"{name}.exe"): + return file + # Check bin subfolder + if (ocr_folder / "bin").exists(): + for file in (ocr_folder / "bin").iterdir(): + if file.is_file() and file.name.lower() in (name, f"{name}.exe"): + return file + + def find_rigaya_encoder(base_name: str) -> Path | None: """Find Rigaya encoder binaries with case-insensitive search.""" # Try common binary names in order of preference @@ -111,7 +218,7 @@ def find_rigaya_encoder(base_name: str) -> Path | None: for candidate in candidates: if location := where(candidate): return location - return None + class Config(BaseModel): @@ -183,8 +290,15 @@ class Config(BaseModel): disable_cover_extraction: bool = False + # PGS to SRT OCR Settings + enable_pgs_ocr: bool = False + tesseract_path: Path | None = Field(default_factory=lambda: find_ocr_tool("tesseract")) + mkvmerge_path: Path | None = Field(default_factory=lambda: find_ocr_tool("mkvmerge")) + pgs_ocr_language: str = "eng" + use_keyframes_for_preview: bool = True + def encoder_opt(self, profile_name, profile_option_name): encoder_settings = getattr(self.profiles[self.selected_profile], profile_name) if encoder_settings: diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py index 75421799..ea111a29 100644 --- a/fastflix/widgets/background_tasks.py +++ b/fastflix/widgets/background_tasks.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import importlib.util import logging import os from pathlib import Path @@ -46,13 +47,14 @@ def run(self): class ExtractSubtitleSRT(QtCore.QThread): - def __init__(self, app: FastFlixApp, main, index, signal, language): + def __init__(self, app: FastFlixApp, main, index, signal, language, use_ocr=False): super().__init__(main) self.main = main self.app = app self.index = index self.signal = signal self.language = language + self.use_ocr = use_ocr def run(self): subtitle_format = self._get_subtitle_format() @@ -63,6 +65,9 @@ def run(self): self.signal.emit() return + # Flag to track if we need OCR conversion after extraction + should_convert_to_srt = False + if subtitle_format == "srt": extension = "srt" output_args = ["-c", "srt", "-f", "srt"] @@ -75,6 +80,8 @@ def run(self): elif subtitle_format == "pgs": extension = "sup" output_args = ["-c", "copy"] + # If OCR is requested, we'll extract .sup first, then convert after + should_convert_to_srt = self.use_ocr and self.app.fastflix.config.enable_pgs_ocr else: self.main.thread_logging_signal.emit( f"WARNING:{t('Subtitle Track')} {self.index} {t('is not in supported format (SRT, ASS, SSA, PGS), skipping extraction')}: {subtitle_format}" @@ -115,6 +122,13 @@ def run(self): ) else: self.main.thread_logging_signal.emit(f"INFO:{t('Extracted subtitles successfully')}") + + # If this is PGS and OCR was requested, convert the .sup to .srt + if subtitle_format == "pgs" and should_convert_to_srt: + if self._convert_sup_to_srt(filename): + self.main.thread_logging_signal.emit(f"INFO:{t('Successfully converted to SRT with OCR')}") + else: + self.main.thread_logging_signal.emit(f"WARNING:{t('OCR conversion failed, kept .sup file')}") self.signal.emit() def _get_subtitle_format(self): @@ -164,6 +178,138 @@ def _get_subtitle_format(self): ) return None + def _check_pgsrip_dependencies(self) -> bool: + """Check all required dependencies for pgsrip OCR conversion""" + missing = [] + + # Check tesseract (auto-detected from PATH or config) + if not self.app.fastflix.config.tesseract_path: + missing.append("tesseract-ocr") + + # Check mkvmerge (CRITICAL - required by pgsrip but not documented) + if not self.app.fastflix.config.mkvmerge_path: + missing.append("mkvtoolnix") + + # Check if pgsrip Python library is available + if importlib.util.find_spec("pgsrip") is None: + missing.append("pgsrip (Python library)") + + if missing: + self.main.thread_logging_signal.emit( + f"ERROR:{t('Missing dependencies for PGS OCR')}: {', '.join(missing)}\n\n" + f"Install instructions:\n" + f" pgsrip: pip install pgsrip\n" + f" Linux: sudo apt install tesseract-ocr mkvtoolnix\n" + f" macOS: brew install tesseract mkvtoolnix\n" + f" Windows:\n" + f" - Tesseract: https://github.com/UB-Mannheim/tesseract/wiki\n" + f" - MKVToolNix: https://mkvtoolnix.download/downloads.html" + ) + return False + + return True + + def _convert_sup_to_srt(self, sup_filepath: str) -> bool: + """Convert PGS subtitle to .srt using pgsrip OCR by processing the original MKV + + Args: + sup_filepath: Path to the extracted .sup file (used for naming output) + + Returns: + True if conversion successful, False otherwise + """ + # Check dependencies first + if not self._check_pgsrip_dependencies(): + return False + + try: + self.main.thread_logging_signal.emit( + f"INFO:{t('Converting .sup to .srt using OCR')} (this may take 3-5 minutes)..." + ) + + # Import pgsrip Python API + from pgsrip import pgsrip, Mkv, Options + from babelfish import Language as BabelLanguage + + # Set environment variables for pgsrip to find tesseract and mkvextract + if self.app.fastflix.config.tesseract_path: + tesseract_dir = str(Path(self.app.fastflix.config.tesseract_path).parent) + os.environ["PATH"] = f"{tesseract_dir}{os.pathsep}{os.environ.get('PATH', '')}" + os.environ["TESSERACT_CMD"] = str(self.app.fastflix.config.tesseract_path) + + if self.app.fastflix.config.mkvmerge_path: + mkvtoolnix_dir = str(Path(self.app.fastflix.config.mkvmerge_path).parent) + os.environ["PATH"] = f"{mkvtoolnix_dir}{os.pathsep}{os.environ.get('PATH', '')}" + + # pgsrip needs the original MKV file, not the extracted .sup + sup_path = Path(sup_filepath) + video_path = Path(self.main.input_video) + media = Mkv(str(video_path)) + + # Configure options for pgsrip + try: + # Detect if language code is 2-letter or 3-letter + if len(self.language) == 2: + babel_lang = BabelLanguage.fromalpha2(self.language) + elif len(self.language) == 3: + babel_lang = BabelLanguage(self.language) + else: + babel_lang = BabelLanguage.fromname(self.language) + + options = Options( + languages={babel_lang}, + overwrite=True, + one_per_lang=True, + ) + except Exception: + # Fallback to English if language code is invalid + options = Options( + languages={BabelLanguage("eng")}, + overwrite=True, + one_per_lang=True, + ) + + # Get list of existing .srt files before conversion + existing_srts = set(video_path.parent.glob("*.srt")) + + # Run pgsrip conversion using Python API + pgsrip.rip(media, options) + + # Find newly created .srt files + current_srts = set(video_path.parent.glob("*.srt")) + new_srts = current_srts - existing_srts + + if not new_srts: + raise Exception(f"pgsrip completed but no .srt file found in {video_path.parent}") + + # Get the first new .srt file + srt_files = list(new_srts) + + # Move the .srt file to the expected location (same dir as .sup was) + created_srt = srt_files[0] + expected_srt = sup_path.with_suffix(".srt") + + if created_srt != expected_srt: + # Move/rename to expected location + import shutil + + shutil.move(str(created_srt), str(expected_srt)) + + self.main.thread_logging_signal.emit(f"INFO:{t('OCR conversion successful')}: {expected_srt.name}") + + # Optionally delete the .sup file since we have .srt now + try: + sup_path.unlink() + self.main.thread_logging_signal.emit(f"INFO:{t('Removed .sup file, kept .srt')}") + except Exception: + pass + + return True + + except Exception as err: + self.main.thread_logging_signal.emit(f"ERROR:{t('OCR conversion failed')}: {err}") + return False + class AudioNoramlize(QtCore.QThread): def __init__(self, app: FastFlixApp, main, audio_type, signal): diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index 53bb02cb..19d4483c 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 importlib.util from typing import Union from box import Box @@ -108,8 +109,38 @@ def __init__(self, app, parent, index, enabled=True, first=False): {t("Cannot remove afterwards!")} """ ) - self.widgets.extract = QtWidgets.QPushButton(t("Extract")) - self.widgets.extract.clicked.connect(self.extract) + + # Setup extract button with OCR option for PGS subtitles + if sub_track.subtitle_type == "pgs": + self.widgets.extract = QtWidgets.QPushButton(t("Extract")) + extract_menu = QtWidgets.QMenu(self) + + # Always offer .sup extraction (fast, no dependencies) + extract_menu.addAction(t("Extract as .sup (image - fast)"), lambda: self.extract(use_ocr=False)) + + # Check if OCR dependencies are available + ocr_action = extract_menu.addAction( + t("Convert to .srt (OCR - 3-5 min)"), lambda: self.extract(use_ocr=True) + ) + + # Enable OCR option only if user enabled it AND dependencies are available + if not self.app.fastflix.config.enable_pgs_ocr: + ocr_action.setEnabled(False) + ocr_action.setToolTip(t("Enable in Settings > 'Enable PGS to SRT OCR conversion'")) + else: + # Check if pgsrip Python library is available + pgsrip_ok = importlib.util.find_spec("pgsrip") is not None + + if not ( + self.app.fastflix.config.tesseract_path and self.app.fastflix.config.mkvmerge_path and pgsrip_ok + ): + ocr_action.setEnabled(False) + ocr_action.setToolTip(t("Missing dependencies: tesseract, mkvtoolnix, or pgsrip")) + + self.widgets.extract.setMenu(extract_menu) + else: + self.widgets.extract = QtWidgets.QPushButton(t("Extract")) + self.widgets.extract.clicked.connect(self.extract) self.gif_label = QtWidgets.QLabel(self) self.movie = QtGui.QMovie(loading_movie) @@ -173,9 +204,14 @@ def init_move_buttons(self): layout.addWidget(self.widgets.down_button) return layout - def extract(self): + def extract(self, use_ocr=False): worker = ExtractSubtitleSRT( - self.parent.app, self.parent.main, self.index, self.extract_completed_signal, language=self.language + self.parent.app, + self.parent.main, + self.index, + self.extract_completed_signal, + language=self.language, + use_ocr=use_ocr, ) worker.start() self.gif_label.show() diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index ddd1b592..3a6dfb46 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import importlib.util import logging import shutil from pathlib import Path @@ -269,11 +270,23 @@ def in_dir(): self.disable_deinterlace_button = QtWidgets.QCheckBox(t("Disable interlace check")) self.disable_deinterlace_button.setChecked(self.app.fastflix.config.disable_deinterlace_check) + + # PGS OCR Settings + self.enable_pgs_ocr = QtWidgets.QCheckBox(t("Enable PGS to SRT OCR conversion")) + self.enable_pgs_ocr.setChecked(self.app.fastflix.config.enable_pgs_ocr) + self.enable_pgs_ocr.setToolTip( + t("Convert image-based PGS subtitles to text SRT using OCR.\nTypically takes 3-5 minutes per movie.") + ) + + # Dependency status + self.ocr_status_label = QtWidgets.QLabel() + self.update_ocr_dependency_status() + self.use_keyframes_for_preview = QtWidgets.QCheckBox(t("Use keyframes for preview images")) self.use_keyframes_for_preview.setChecked(self.app.fastflix.config.use_keyframes_for_preview) - # Layouts + # Layouts layout.addWidget(self.use_sane_audio, 7, 0, 1, 2) layout.addWidget(self.disable_version_check, 8, 0, 1, 2) layout.addWidget(QtWidgets.QLabel(t("GUI Logging Level")), 9, 0) @@ -289,17 +302,47 @@ def in_dir(): layout.addWidget(self.clean_old_logs_button, 21, 0, 1, 3) layout.addWidget(self.disable_end_message, 22, 0, 1, 3) layout.addWidget(self.disable_deinterlace_button, 23, 0, 1, 3) - layout.addWidget(self.use_keyframes_for_preview, 24, 0, 1, 3) + + layout.addWidget(self.enable_pgs_ocr, 24, 0, 1, 2) + layout.addWidget(self.ocr_status_label, 24, 2, 1, 1) + + layout.addWidget(self.use_keyframes_for_preview, 25, 0, 1, 3) button_layout = QtWidgets.QHBoxLayout() button_layout.addStretch() button_layout.addWidget(cancel) button_layout.addWidget(save) - layout.addLayout(button_layout, 26, 0, 1, 3) + layout.addLayout(button_layout, 27, 0, 1, 3) self.setLayout(layout) + def update_ocr_dependency_status(self): + """Update the OCR dependency status display""" + # Use config paths which use find_ocr_tool() - handles non-PATH locations + tesseract_ok = self.app.fastflix.config.tesseract_path is not None + mkvmerge_ok = self.app.fastflix.config.mkvmerge_path is not None + + # Check if pgsrip Python library is available + pgsrip_ok = importlib.util.find_spec("pgsrip") is not None + + status_parts = [] + status_parts.append("✓ tesseract" if tesseract_ok else "✗ tesseract") + status_parts.append("✓ mkvtoolnix" if mkvmerge_ok else "✗ mkvtoolnix") + status_parts.append("✓ pgsrip" if pgsrip_ok else "✗ pgsrip") + + status_text = " | ".join(status_parts) + + if not all([tesseract_ok, mkvmerge_ok, pgsrip_ok]): + status_text += "\n" + link( + "https://github.com/cdgriffith/FastFlix/wiki/PGS-OCR-Setup", + "Click here for installation instructions", + self.app.fastflix.config.theme, + ) + + self.ocr_status_label.setText(status_text) + self.ocr_status_label.setOpenExternalLinks(True) + def save(self): new_ffmpeg = Path(self.ffmpeg_path.text()) new_ffprobe = Path(self.ffprobe_path.text()) @@ -383,6 +426,7 @@ def save(self): self.app.fastflix.config.sticky_tabs = self.sticky_tabs.isChecked() self.app.fastflix.config.disable_complete_message = self.disable_end_message.isChecked() self.app.fastflix.config.disable_deinterlace_check = self.disable_deinterlace_button.isChecked() + self.app.fastflix.config.enable_pgs_ocr = self.enable_pgs_ocr.isChecked() self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() self.main.config_update() diff --git a/pyproject.toml b/pyproject.toml index 8039bafa..5485a730 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,13 @@ dependencies = [ "wmi>=1.5.1; sys_platform == 'win32'", "ffmpeg-normalize>=1.31.3,<2.0", "reusables>=1.0.0", + "pgsrip>=0.1.0", + "pytesseract>=0.3.0", + "babelfish>=0.6.0", + "cleanit>=0.4.0", + "trakit>=0.2.0", + "opencv-python>=4.8.0", + "pysrt>=1.1.0", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 943194bd..d88db8f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,8 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" + [[package]] name = "altgraph" version = "0.17.5" @@ -20,6 +21,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babelfish" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/8f/17ff889327f8a1c36a28418e686727dabc06c080ed49c95e3e2424a77aa6/babelfish-0.6.1.tar.gz", hash = "sha256:decb67a4660888d48480ab6998309837174158d0f1aa63bebb1c2e11aab97aab", size = 87706, upload-time = "2024-05-09T21:16:24.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/a1/bd4f759db13cd8beb9c9f68682aced5d966781b9d7380cf514a306f56762/babelfish-0.6.1-py3-none-any.whl", hash = "sha256:512f1501d4c8f7d38f0921f48660be7542de1a7b24abb6a6a65324a670150293", size = 94231, upload-time = "2024-05-09T21:16:22.633Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -88,6 +116,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "cleanit" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "babelfish" }, + { name = "chardet" }, + { name = "click" }, + { name = "jsonschema" }, + { name = "pysrt" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/e3/d08d7980c4a04f3e23c8adf33717cb92b0e009ac96f6c05e5867bca0edf1/cleanit-0.4.8.tar.gz", hash = "sha256:1b19fe2dd2712695ebbf9d429c4d3366a1b51300738bb034c13ea221c84a6ae9", size = 21625, upload-time = "2024-06-23T06:19:14.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/b9/fcf9e3b833bff99e1d2d63c31dad1d10c1d650f29971b541846295d96513/cleanit-0.4.8-py3-none-any.whl", hash = "sha256:8ae8853871a8664a8781f8f82940ac559322263058f9d94b245780c1750681f2", size = 26630, upload-time = "2024-06-23T06:19:12.426Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +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" } +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" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -134,22 +192,29 @@ wheels = [ name = "fastflix" source = { editable = "." } dependencies = [ + { name = "babelfish" }, { name = "chardet" }, + { name = "cleanit" }, { name = "colorama" }, { name = "coloredlogs" }, { name = "ffmpeg-normalize" }, { name = "iso639-lang" }, { name = "mistune" }, + { name = "opencv-python" }, { name = "packaging" }, { name = "pathvalidate" }, + { name = "pgsrip" }, { name = "platformdirs" }, { name = "psutil" }, { name = "pydantic" }, { name = "pyside6" }, + { name = "pysrt" }, + { name = "pytesseract" }, { name = "python-box", extra = ["all"] }, { name = "requests" }, { name = "reusables" }, { name = "setuptools" }, + { name = "trakit" }, { name = "wmi", marker = "sys_platform == 'win32'" }, ] @@ -167,22 +232,29 @@ dev = [ [package.metadata] requires-dist = [ + { name = "babelfish", specifier = ">=0.6.0" }, { name = "chardet", specifier = ">=5.1.0,<5.2.0" }, + { name = "cleanit", specifier = ">=0.4.0" }, { name = "colorama", specifier = ">=0.4,<1.0" }, { name = "coloredlogs", specifier = ">=15.0,<16.0" }, { name = "ffmpeg-normalize", specifier = ">=1.31.3,<2.0" }, { name = "iso639-lang", specifier = ">=2.6.0,<3.0" }, { name = "mistune", specifier = ">=2.0,<3.0" }, + { name = "opencv-python", specifier = ">=4.8.0" }, { name = "packaging", specifier = ">=23.2" }, { name = "pathvalidate", specifier = ">=2.4,<3.0" }, + { name = "pgsrip", specifier = ">=0.1.0" }, { name = "platformdirs", specifier = "~=4.3" }, { name = "psutil", specifier = ">=5.9,<6.0" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, { name = "pyside6", specifier = "==6.10.1" }, + { name = "pysrt", specifier = ">=1.1.0" }, + { name = "pytesseract", specifier = ">=0.3.0" }, { name = "python-box", extras = ["all"], specifier = ">=6.0,<7.0" }, { name = "requests", specifier = ">=2.28,<3.0" }, { name = "reusables", specifier = ">=1.0.0" }, { name = "setuptools", specifier = ">=75.8" }, + { name = "trakit", specifier = ">=0.2.0" }, { name = "wmi", marker = "sys_platform == 'win32'", specifier = ">=1.5.1" }, ] @@ -283,6 +355,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/c7/f6fd3db6c33a164631c39dce2ca26a3794e3abf91b875cc99a43a5565d88/iso639_lang-2.6.3-py3-none-any.whl", hash = "sha256:a6c2fb9f739dca180dc7f48b098880f303bcce2cdf93a4ca3152ed8bbbb94fbb", size = 324990, upload-time = "2025-07-23T09:04:52.221Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { 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" } +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" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "macholib" version = "1.16.4" @@ -357,6 +456,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[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" }, +] + +[[package]] +name = "opencv-python" +version = "4.12.0.88" +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" }, +] + [[package]] name = "packaging" version = "25.0" @@ -384,6 +538,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pgsrip" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babelfish" }, + { name = "cleanit" }, + { name = "click" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pysrt" }, + { name = "pytesseract" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/c3/4d8da691f5324e84a9c5249144b03c0151db26653f0889a0149eb0181e09/pgsrip-0.1.1.tar.gz", hash = "sha256:078c841b4db76e2db021608d18e3a7a73b1acee9bd19fd2d26b7aa322a3b3495", size = 14131, upload-time = "2021-04-08T09:34:31.46Z" } + +[[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" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -607,6 +845,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/da/65cc6c6a870d4ea908c59b2f0f9e2cf3bfc6c0710ebf278ed72f69865e4e/pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c", size = 55190458, upload-time = "2025-11-20T10:00:26.226Z" }, ] +[[package]] +name = "pysrt" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/1a/0d858da1c6622dcf16011235a2639b0a01a49cecf812f8ab03308ab4de37/pysrt-1.1.2.tar.gz", hash = "sha256:b4f844ba33e4e7743e9db746492f3a193dc0bc112b153914698e7c1cdeb9b0b9", size = 104371, upload-time = "2020-01-20T15:22:28.291Z" } + +[[package]] +name = "pytesseract" +version = "0.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -697,6 +957,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rebulk" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/06/24c69f8d707c9eefc1108a64e079da56b5f351e3f59ed76e8f04b9f3e296/rebulk-3.2.0.tar.gz", hash = "sha256:0d30bf80fca00fa9c697185ac475daac9bde5f646ce3338c9ff5d5dc1ebdfebc", size = 261685, upload-time = "2023-02-18T09:10:14.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4d/df073d593f7e7e4a5a7e19148b2e9b4ae63b4ddcbb863f1e7bb2b6f19c62/rebulk-3.2.0-py3-none-any.whl", hash = "sha256:6bc31ae4b37200623c5827d2f539f9ec3e52b50431322dad8154642a39b0a53e", size = 56298, upload-time = "2023-02-18T09:10:12.435Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +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 = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -721,6 +1004,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/68/1b2b93713e1fdd4cd06ba792b53d81574edd291eda0c7fdceca69d175843/reusables-1.0.0-py3-none-any.whl", hash = "sha256:1bd1fcb782ce4d67b60435e30e8d558be09231190ba798c2ab3488e258bcf3bf", size = 44934, upload-time = "2025-07-03T01:31:22.564Z" }, ] +[[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" }, +] + [[package]] name = "ruamel-yaml" version = "0.19.1" @@ -798,6 +1162,19 @@ 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" }, ] +[[package]] +name = "trakit" +version = "0.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babelfish" }, + { name = "rebulk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/0c/28f6a6f60cf58f383142c2daf73dd9b97cd8436e71f121a4bcb35e1b459e/trakit-0.2.5.tar.gz", hash = "sha256:d7e530ed82906eeadf7982d6a357883ae0490f34bbd18f8232b8fc5f250a4ae7", size = 34873, upload-time = "2025-07-29T17:04:55.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b0/e1ec7c99a0bfb66b179f8cf15f7f2aad213289c5502175534e742a250288/trakit-0.2.5-py3-none-any.whl", hash = "sha256:216cf57faa658f7a47c0b356a616cb23dfb14626e505d0de723efc073c2294b9", size = 19164, upload-time = "2025-07-29T17:04:53.669Z" }, +] + [[package]] name = "types-requests" version = "2.32.4.20260107" From 66934c29e8dadac497895e590c6638f24ec7985d Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sun, 8 Feb 2026 15:42:59 -0600 Subject: [PATCH 18/25] Improving subtitle extraction --- CHANGES | 24 +- FastFlix_Nix_OneFile.spec | 7 +- fastflix/__main__.py | 5 + fastflix/application.py | 14 +- fastflix/data/languages.yaml | 2004 +++++++++------------ fastflix/entry.py | 5 + fastflix/models/config.py | 8 +- fastflix/ui_styles.py | 15 +- fastflix/widgets/background_tasks.py | 189 +- fastflix/widgets/panels/subtitle_panel.py | 126 +- fastflix/widgets/settings.py | 90 +- 11 files changed, 1181 insertions(+), 1306 deletions(-) diff --git a/CHANGES b/CHANGES index 6b98efb1..b24015a7 100644 --- a/CHANGES +++ b/CHANGES @@ -2,21 +2,33 @@ ## Version 5.13.0 +* Adding #709 PGS to SRT OCR subtitle extraction feature (thanks to mikeSGman) +* Adding #712 audio profile title options: No Title, Generate Title, and Custom Title (thanks to gaalos) +* Adding "Detected External Programs" section to Settings showing status of NVEncC, QSVEncC, VCEEncC, HDR10+ Parser, and PGS OCR tools * Adding resizable columns to Concatenation Builder window with minimum widths based on header text * Adding Keyframe checkbox to Settings menu as "Use keyframes for preview images" option (default enabled) * Adding preview time slider to overlay at bottom of preview image * Adding time display next to preview slider in H:MM:SS format -* Adding #712 audio profile title options: No Title, Generate Title, and Custom Title * Adding async queue saving to prevent GUI blocking during queue operations * Adding atomic file writes for queue to prevent corruption from interrupted saves * Adding file-based locking for queue operations to prevent race conditions between instances * Adding graceful shutdown handling for worker process and background threads * Adding Start/End Time tab to right-side options panel between Size and Crop tabs with compact 3-column layout -* Fixing #695 Qt6 decimal input fields enforcing locale-specific decimal separators (comma vs dot) by forcing C locale -* Fixing #696 RAW command display boxes too small on macOS by using actual document size for height calculation -* Fixing #687 dialogs staying on top of all windows and unable to minimize by removing WindowStaysOnTopHint flag -* Fixing #337 #700 #597 refactoring all FFmpeg command building from string concatenation to List[str] to fix shlex.split() failures with quotes, special characters in titles, and Windows path handling issues -* Fixing #481 MP4 subtitle copy mode using wrong codec - now uses mov_text instead of copy for MP4 containers +* Adding save dialog for subtitle extraction allowing users to choose output location, defaulting to the output directory +* Fixing #337 #700 #597 refactoring all FFmpeg command building from string concatenation to List[str] to fix shlex.split() failures with quotes, special characters in titles, and Windows path handling issues (thanks to Xoanon88 and Buzz0016) +* Fixing #481 MP4 subtitle copy mode using wrong codec - now uses mov_text instead of copy for MP4 containers (thanks to wmonte75) +* Fixing #687 dialogs staying on top of all windows and unable to minimize by removing WindowStaysOnTopHint flag (thanks to Krawk) +* Fixing #688 typo in changelog (thanks to luzpaz) +* Fixing #695 Qt6 decimal input fields enforcing locale-specific decimal separators (comma vs dot) by forcing C locale (thanks to isben) +* Fixing #696 RAW command display boxes too small on macOS by using actual document size for height calculation (thanks to isben) +* Fixing #707 Rigaya encoder binary detection with case-insensitive search (thanks to anne-o-pixel) +* Fixing #708 process priority niceness values inverted on Linux and removed Realtime option on non-Windows (thanks to JacobDev1) +* Fixing Windows taskbar showing generic icon instead of FastFlix icon by setting AppUserModelID and deferring blocking version check until after event loop starts +* Fixing Settings browse buttons column being too wide by setting fixed width and column stretch +* Fixing PGS OCR requiring manual checkbox enable - now auto-detects availability from tesseract and pgsrip +* Fixing PGS OCR subtitle extraction using pgsrip.Sup to process already-extracted .sup file instead of re-extracting from MKV (fixes wrong file path and mkvextract failures) +* Fixing extract dropdown arrow being oversized by scaling the menu-indicator to match up/down button proportions +* Fixing QFont::setPointSize warnings in PyInstaller executables by using pt instead of px for stylesheet font sizes * Fixing video track selector showing unnecessarily when source video has only one video track * Fixing visual border between filename area and video track selector * Fixing test suite hanging due to missing QApplication in PySide6 widget tests diff --git a/FastFlix_Nix_OneFile.spec b/FastFlix_Nix_OneFile.spec index e5f6ae37..b5783c46 100644 --- a/FastFlix_Nix_OneFile.spec +++ b/FastFlix_Nix_OneFile.spec @@ -1,5 +1,5 @@ # -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_submodules, copy_metadata, collect_data_files import toml import os import platform @@ -26,9 +26,12 @@ all_imports.remove("python-box") all_imports.append("box") all_imports.append("iso639") +# Add pgsrip for OCR support +all_imports.extend(["pgsrip", "pytesseract", "cv2", "numpy", "pysrt", "babelfish", "babelfish.converters", "babelfish.converters.alpha2", "babelfish.converters.alpha3b", "babelfish.converters.alpha3t", "babelfish.converters.name", "babelfish.converters.opensubtitles", "cleanit"]) + a = Analysis(['fastflix/__main__.py'], binaries=[], - datas=[('CHANGES', 'fastflix/.'), ('docs/build-licenses.txt', 'docs')] + all_fastflix_files, + datas=[('CHANGES', 'fastflix/.'), ('docs/build-licenses.txt', 'docs')] + all_fastflix_files + copy_metadata('pgsrip') + copy_metadata('pytesseract') + copy_metadata('babelfish') + copy_metadata('cleanit') + copy_metadata('trakit') + collect_data_files('babelfish') + collect_data_files('cleanit'), hiddenimports=all_imports, hookspath=[], runtime_hooks=[], diff --git a/fastflix/__main__.py b/fastflix/__main__.py index 6692ca28..e616c929 100644 --- a/fastflix/__main__.py +++ b/fastflix/__main__.py @@ -5,6 +5,11 @@ from multiprocessing import freeze_support from pathlib import Path +if sys.platform == "win32": + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("cdgriffith.FastFlix") + from fastflix.entry import main diff --git a/fastflix/application.py b/fastflix/application.py index 75b26126..89a03630 100644 --- a/fastflix/application.py +++ b/fastflix/application.py @@ -23,6 +23,11 @@ def create_app(enable_scaling): + if sys.platform == "win32": + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("cdgriffith.FastFlix") + if enable_scaling: if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) @@ -41,7 +46,12 @@ def create_app(enable_scaling): selected_font = next((f for f in font_preference if f in available_fonts), "Sans Serif") my_font = QtGui.QFont(selected_font, 9) main_app.setFont(my_font) - main_app.setWindowIcon(QtGui.QIcon(main_icon)) + icon = QtGui.QIcon() + icon.addFile(main_icon, QtCore.QSize(16, 16)) + icon.addFile(main_icon, QtCore.QSize(32, 32)) + icon.addFile(main_icon, QtCore.QSize(48, 48)) + icon.addFile(main_icon, QtCore.QSize(256, 256)) + main_app.setWindowIcon(icon) return main_app @@ -264,7 +274,7 @@ def app_setup( container.move(screen_geometry.center() - container.rect().center()) if not app.fastflix.config.disable_version_check: - latest_fastflix(app=app, show_new_dialog=False) + QtCore.QTimer.singleShot(500, lambda: latest_fastflix(app=app, show_new_dialog=False)) return app diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index 7745bf45..532cff11 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -1,29 +1,18 @@ 4 or 5 will turn off rate distortion optimization, having even more of an impact on quality.: - deu: Mit 4 oder 5 wird die Optimierung der Ratenverzerrung ausgeschaltet, was sich - noch stärker auf die Qualität auswirkt. - eng: 4 or 5 will turn off rate distortion optimization, having even more of an impact - on quality. - fra: 4 ou 5 désactiveront l'optimisation de la distorsion du taux, ce qui aura un - impact encore plus important sur la qualité. - ita: 4 o 5 disattiveranno l'ottimizzazione della distorsione del tasso, con un impatto - ancora maggiore sulla qualità. - spa: 4 o 5 desactivarán la optimización de la tasa de distorsión, lo que tendrá - un impacto aún mayor en la calidad. + deu: Mit 4 oder 5 wird die Optimierung der Ratenverzerrung ausgeschaltet, was sich noch stärker auf die Qualität auswirkt. + eng: 4 or 5 will turn off rate distortion optimization, having even more of an impact on quality. + fra: 4 ou 5 désactiveront l'optimisation de la distorsion du taux, ce qui aura un impact encore plus important sur la qualité. + ita: 4 o 5 disattiveranno l'ottimizzazione della distorsione del tasso, con un impatto ancora maggiore sulla qualità. + spa: 4 o 5 desactivarán la optimización de la tasa de distorsión, lo que tendrá un impacto aún mayor en la calidad. chs: 4或5会关闭rate distortion optimization,对质量的影响会更大。 jpn: 4または5を選択すると、レート歪みの最適化がオフになり、画質への影響がさらに大きくなります。 - rus: 4 или 5 отключает оптимизацию искажений скорости, что еще больше влияет на - качество. - por: 4 ou 5 desligará a optimização da taxa de distorção, tendo um impacto ainda - maior na qualidade. - swe: 4 eller 5 stänger av optimeringen av hastighetsförvrängning, vilket har ännu - större inverkan på kvaliteten. - pol: 4 lub 5 spowoduje wyłączenie optymalizacji zniekształceń współczynnika, co - jeszcze bardziej wpłynie na jakość. - ukr: 4 або 5 вимикають оптимізацію спотворень швидкості, що ще більше впливає на - якість. + rus: 4 или 5 отключает оптимизацию искажений скорости, что еще больше влияет на качество. + por: 4 ou 5 desligará a optimização da taxa de distorção, tendo um impacto ainda maior na qualidade. + swe: 4 eller 5 stänger av optimeringen av hastighetsförvrängning, vilket har ännu större inverkan på kvaliteten. + pol: 4 lub 5 spowoduje wyłączenie optymalizacji zniekształceń współczynnika, co jeszcze bardziej wpłynie na jakość. + ukr: 4 або 5 вимикають оптимізацію спотворень швидкості, що ще більше впливає на якість. kor: 4 또는 5는 속도 왜곡 최적화를 해제하여 품질에 더 큰 영향을 미칩니다. - ron: 4 sau 5 va dezactiva optimizarea distorsiunii ratei, având un impact și mai - mare asupra calității. + ron: 4 sau 5 va dezactiva optimizarea distorsiunii ratei, având un impact și mai mare asupra calității. AQ Strength: deu: Strärke des AQ eng: AQ Strength @@ -272,13 +261,11 @@ Auto Burn-in first forced or default subtitle track: spa: Auto Burn-in primera pista de subtítulos forzados o por defecto chs: 自动内嵌第一条分配为forced或default的字幕 jpn: 最初の強制またはデフォルトの字幕トラックを自動的にバーンインする - rus: Автоматическое включение первой принудительной дорожки субтитров или дорожки - субтитров по умолчанию + rus: Автоматическое включение первой принудительной дорожки субтитров или дорожки субтитров по умолчанию por: Burn-in automático primeiro da legenda forçada ou padrão swe: Automatisk inbränning av första tvingade eller standard undertextspår pol: Auto Burn-in pierwsza wymuszona lub domyślna ścieżka napisów - ukr: Автоматичне вбудовування першої примусової доріжки субтитрів або доріжки за - замовчуванням + ukr: Автоматичне вбудовування першої примусової доріжки субтитрів або доріжки за замовчуванням kor: 자동 번인 첫 번째 강제 또는 기본 자막 트랙 ron: Auto Burn-in prima piesă de subtitrare forțată sau implicită Auto Crop: @@ -475,8 +462,7 @@ Break the video into columns to encode faster (lesser quality): pol: Podziel film na kolumny, aby kodować szybciej (gorsza jakość) ukr: Розбийте відео на колонки для швидшого кодування (з меншою якістю) kor: 비디오를 열로 분할하여 더 빠르게 인코딩(품질 저하) - ron: Împărțiți videoclipul în coloane pentru a codifica mai repede (calitate mai - slabă) + ron: Împărțiți videoclipul în coloane pentru a codifica mai repede (calitate mai slabă) Break the video into rows to encode faster (lesser quality): deu: Das Video in Zeilen aufteilen, um schneller zu kodieren (geringere Qualität) eng: Break the video into rows to encode faster (lesser quality) @@ -491,8 +477,7 @@ Break the video into rows to encode faster (lesser quality): pol: Podziel wideo na rzędy, aby kodować szybciej (gorsza jakość) ukr: Розбийте відео на рядки для швидшого кодування (з меншою якістю) kor: 동영상을 행 단위로 분할하여 더 빠르게 인코딩(품질 저하) - ron: Împărțiți videoclipul în rânduri pentru a codifica mai repede (calitate mai - slabă) + ron: Împărțiți videoclipul în rânduri pentru a codifica mai repede (calitate mai slabă) Bufsize: deu: Bufsize eng: Bufsize @@ -812,10 +797,8 @@ Command worker received request to pause current encode: deu: Befehlsablauf hat Anfrage erhalten, die aktuelle Kodierung zu pausieren eng: Command worker received request to pause current encode fra: Un employé du commandement a reçu une demande de pause de l'encodage en cours - ita: Operatore di comando ha ricevuto la richiesta di mettere in pausa la codifica - corrente - spa: El trabajador del comando recibió una solicitud para pausar la codificación - actual + ita: Operatore di comando ha ricevuto la richiesta di mettere in pausa la codifica corrente + spa: El trabajador del comando recibió una solicitud para pausar la codificación actual chs: 命令执行程序收到了暂停当前编码的请求 jpn: コマンドワーカーが現在のエンコードを一時停止するリクエストを受信しました rus: Оператор получил запрос на приостановку текущего кодирования @@ -826,30 +809,20 @@ Command worker received request to pause current encode: kor: 명령 작업자가 현재 인코딩 일시 중지 요청을 받았습니다. ron: Lucrătorul de comandă a primit o cerere de întrerupere a codificării curente Command worker received request to pause encoding after the current item completes: - deu: Befehlsablauf hat Anfrage erhalten, das Encoding nach Abschluss des aktuellen - Elements zu pausieren + deu: Befehlsablauf hat Anfrage erhalten, das Encoding nach Abschluss des aktuellen Elements zu pausieren eng: Command worker received request to pause encoding after the current item completes - fra: Un membre du personnel de commandement a reçu une demande de pause d'encodage - après la fin de l'élément en cours - ita: L'operatore di comando ha ricevuto la richiesta di mettere in pausa la codifica - dopo il completamento della voce corrente - spa: El trabajador del comando recibió la solicitud de pausar la codificación después - de que el elemento actual complete + fra: Un membre du personnel de commandement a reçu une demande de pause d'encodage après la fin de l'élément en cours + ita: L'operatore di comando ha ricevuto la richiesta di mettere in pausa la codifica dopo il completamento della voce corrente + spa: El trabajador del comando recibió la solicitud de pausar la codificación después de que el elemento actual complete chs: 命令执行程序收到了在当前项目完成后暂停编码的请求 jpn: コマンドワーカーが、現在のアイテムが完了した後にエンコードを一時停止するリクエストを受信しました - rus: Оператор получил запрос на приостановку кодирования после завершения текущего - элемента - por: O worker recebeu uma solicitação para pausar a codificação atual após a sua - conclusão - swe: Kommandotjänstemannen har mottagit en begäran om att pausa kodningen efter - det att det aktuella objektet har avslutats. - pol: Pracownik poleceń otrzymał żądanie wstrzymania kodowania po zakończeniu bieżącego - elementu - ukr: Командний працівник отримав запит на призупинення кодування після завершення - поточного елемента + rus: Оператор получил запрос на приостановку кодирования после завершения текущего элемента + por: O worker recebeu uma solicitação para pausar a codificação atual após a sua conclusão + swe: Kommandotjänstemannen har mottagit en begäran om att pausa kodningen efter det att det aktuella objektet har avslutats. + pol: Pracownik poleceń otrzymał żądanie wstrzymania kodowania po zakończeniu bieżącego elementu + ukr: Командний працівник отримав запит на призупинення кодування після завершення поточного елемента kor: 현재 항목이 완료된 후 인코딩 일시 중지 요청을 받은 명령 작업자 - ron: Lucrătorul de comandă a primit o cerere de întrerupere a codificării după finalizarea - elementului curent + ron: Lucrătorul de comandă a primit o cerere de întrerupere a codificării după finalizarea elementului curent Command worker received request to resume encoding: deu: Befehlsablauf hat Anfrage erhalten, die Kodierung fortzusetzen eng: Command worker received request to resume encoding @@ -868,11 +841,9 @@ Command worker received request to resume encoding: Command worker received request to resume paused encode: deu: Befehlsablauf hat Anfrage erhalten, die pausierte Kodierung fortzusetzen eng: Command worker received request to resume paused encode - fra: Un officier de commandement a reçu une demande de reprise de l'encodage en - pause + fra: Un officier de commandement a reçu une demande de reprise de l'encodage en pause ita: Operatore di comando ha ricevuto la richiesta di riprendere la pausa codificare - spa: El trabajador del comando recibió la solicitud de reanudar la codificación - en pausa + spa: El trabajador del comando recibió la solicitud de reanudar la codificación en pausa chs: 命令执行程序收到了恢复已暂停编码的请求 jpn: コマンドワーカーが一時停止したエンコードの再開要求を受信しました rus: Оператор получил запрос на возобновление приостановленного кодирования @@ -1138,12 +1109,10 @@ Could not compress old logs: kor: 이전 로그를 압축할 수 없음 ron: Nu a putut comprima jurnalele vechi Could not connect to github to check for newer versions: - deu: Konnte keine Verbindung zu Github herstellen, um nach neueren Versionen zu - suchen + deu: Konnte keine Verbindung zu Github herstellen, um nach neueren Versionen zu suchen eng: Could not connect to github to check for newer versions fra: Impossible de se connecter à github pour vérifier les nouvelles versions - ita: Impossibile connettersi a github per verificare la presenza di versioni più - recenti + ita: Impossibile connettersi a github per verificare la presenza di versioni più recenti spa: No se pudo conectar a github para comprobar las nuevas versiones chs: 无法连接到github检查更新 jpn: 新しいバージョンを確認するためにgithubに接続できませんでした。 @@ -1380,28 +1349,20 @@ Deblock: kor: 차단 해제 ron: Deblocare Default 4. This parameter has a quadratic effect on the amount of memory allocated: - deu: Voreinstellung 4. Die Menge des mit diesem Parameter zugewiesenen Speichers - wächst quadratisch. + deu: Voreinstellung 4. Die Menge des mit diesem Parameter zugewiesenen Speichers wächst quadratisch. eng: Default 4. This parameter has a quadratic effect on the amount of memory allocated fra: Défaut 4. Ce paramètre a un effet quadratique sur la quantité de mémoire allouée - ita: Predefinito 4. Questo parametro ha un effetto quadratico sulla quantità di - memoria allocata - spa: Por defecto 4. Este parámetro tiene un efecto cuadrático en la cantidad de - memoria asignada + ita: Predefinito 4. Questo parametro ha un effetto quadratico sulla quantità di memoria allocata + spa: Por defecto 4. Este parámetro tiene un efecto cuadrático en la cantidad de memoria asignada chs: 默认值为4。此参数对于所分配的内存量和--b-adapt jpn: デフォルトは4です。このパラメータは、割り当てられたメモリの量に二次的な影響を与えます。 - rus: По умолчанию 4. Этот параметр оказывает квадратичное влияние на объем выделяемой - памяти - por: Padrão 4. Este parâmetro tem um efeito quadrático sobre a quantidade de memória - alocada - swe: Standard 4. Den här parametern har en kvadratisk effekt på mängden minne som - tilldelas. + rus: По умолчанию 4. Этот параметр оказывает квадратичное влияние на объем выделяемой памяти + por: Padrão 4. Este parâmetro tem um efeito quadrático sobre a quantidade de memória alocada + swe: Standard 4. Den här parametern har en kvadratisk effekt på mängden minne som tilldelas. pol: Domyślnie 4. Ten parametr ma kwadratowy wpływ na ilość przydzielonej pamięci. - ukr: За замовчуванням 4. Цей параметр має квадратичний вплив на обсяг виділеної - пам'яті + ukr: За замовчуванням 4. Цей параметр має квадратичний вплив на обсяг виділеної пам'яті kor: 기본값 4. 이 매개 변수는 할당된 메모리 양에 2진법적인 영향을 미칩니다. - ron: Default 4. Acest parametru are un efect pătratic asupra cantității de memorie - alocată. + ron: Default 4. Acest parametru are un efect pătratic asupra cantității de memorie alocată. Default disabled.: deu: Standardmäßig deaktiviert. eng: Default disabled. @@ -1433,31 +1394,20 @@ Default enabled.: kor: 기본적으로 활성화되어 있습니다. ron: Activat în mod implicit. Default is an autodetected count based on the number of CPU cores and whether WPP is enabled or not.: - deu: Standard ist eine automatisch ermittelte Anzahl basierend auf der Anzahl der - CPU-Kerne und ob WPP aktiviert ist oder nicht. - eng: Default is an autodetected count based on the number of CPU cores and whether - WPP is enabled or not. - fra: La valeur par défaut est un comptage autodétecté basé sur le nombre de cœurs - CPU et sur l'activation ou non du WPP. - ita: Default è un conteggio automatico basato sul numero di core della CPU e sul - fatto che WPP sia abilitato o meno. - spa: Por defecto es un conteo autodetectado basado en el número de núcleos de la - CPU y si WPP está habilitado o no. + deu: Standard ist eine automatisch ermittelte Anzahl basierend auf der Anzahl der CPU-Kerne und ob WPP aktiviert ist oder nicht. + eng: Default is an autodetected count based on the number of CPU cores and whether WPP is enabled or not. + fra: La valeur par défaut est un comptage autodétecté basé sur le nombre de cœurs CPU et sur l'activation ou non du WPP. + ita: Default è un conteggio automatico basato sul numero di core della CPU e sul fatto che WPP sia abilitato o meno. + spa: Por defecto es un conteo autodetectado basado en el número de núcleos de la CPU y si WPP está habilitado o no. chs: WPP(Wavefront Parallel Processing)自动确定。 jpn: デフォルトでは、CPUコア数とWPPが有効かどうかに基づいて自動検出されたカウントです。 - rus: По умолчанию это автоопределяемый подсчет, основанный на количестве ядер ЦП - и на том, включен или нет WPP. - por: O padrão é uma contagem calculada pelo número de núcleos da CPU e se o WPP - está habilitado ou não. - swe: Standardvärdet är ett automatiskt registrerat antal baserat på antalet CPU-kärnor - och om WPP är aktiverat eller inte. - pol: Domyślnie jest to automatycznie wykryta liczba na podstawie liczby rdzeni CPU - i tego, czy WPP jest włączone czy nie. - ukr: За замовчуванням це автоматично визначений підрахунок на основі кількості ядер - процесора і того, чи ввімкнено WPP чи ні. + rus: По умолчанию это автоопределяемый подсчет, основанный на количестве ядер ЦП и на том, включен или нет WPP. + por: O padrão é uma contagem calculada pelo número de núcleos da CPU e se o WPP está habilitado ou não. + swe: Standardvärdet är ett automatiskt registrerat antal baserat på antalet CPU-kärnor och om WPP är aktiverat eller inte. + pol: Domyślnie jest to automatycznie wykryta liczba na podstawie liczby rdzeni CPU i tego, czy WPP jest włączone czy nie. + ukr: За замовчуванням це автоматично визначений підрахунок на основі кількості ядер процесора і того, чи ввімкнено WPP чи ні. kor: 기본값은 CPU 코어 수와 WPP 활성화 여부에 따라 자동 감지된 횟수입니다. - ron: Valoarea implicită este un număr autodetectat pe baza numărului de nuclee CPU - și a faptului că WPP este activat sau nu. + ron: Valoarea implicită este un număr autodetectat pe baza numărului de nuclee CPU și a faptului că WPP este activat sau nu. 'Default: AQ enabled with auto-variance': deu: 'Voreinstellung: AQ aktiviert mit Auto-Varianz' eng: 'Default: AQ enabled with auto-variance' @@ -1624,29 +1574,20 @@ Dither: kor: 디더 ron: Dither Dither is an intentionally applied form of noise used to randomize quantization error,: - deu: Dither dient dazu, sichtbare Quantisierungsfehler durch absichtliches Zufallsrauschen - abzuschwächen. - eng: Dither is an intentionally applied form of noise used to randomize quantization - error, - fra: Dither est une forme de bruit appliquée intentionnellement et utilisée pour - randomiser l'erreur de quantification, - ita: Il dither è una forma di rumore applicata intenzionalmente usata per randomizzare - l'errore di quantizzazione, - spa: El Dither es una forma de ruido aplicada intencionalmente que se utiliza para - aleatorizar el error de cuantificación, + deu: Dither dient dazu, sichtbare Quantisierungsfehler durch absichtliches Zufallsrauschen abzuschwächen. + eng: Dither is an intentionally applied form of noise used to randomize quantization error, + fra: Dither est une forme de bruit appliquée intentionnellement et utilisée pour randomiser l'erreur de quantification, + ita: Il dither è una forma di rumore applicata intenzionalmente usata per randomizzare l'errore di quantizzazione, + spa: El Dither es una forma de ruido aplicada intencionalmente que se utiliza para aleatorizar el error de cuantificación, chs: 抖动(Dither)是一种为了随机化量化误差(quantization error)而有意添加的噪声, jpn: ディザとは、量子化誤差をランダムにするために意図的にかけるノイズのことです。 - rus: Дизеринг - это намеренно применяемая форма шума, используемая для рандомизации - ошибки квантования, - por: Dither é uma forma intencionalmente aplicada de ruído usado para randomizar - o erro de quantização, + rus: Дизеринг - это намеренно применяемая форма шума, используемая для рандомизации ошибки квантования, + por: Dither é uma forma intencionalmente aplicada de ruído usado para randomizar o erro de quantização, swe: Dither är en avsiktligt tillämpad form av brus som används för att slumpa kvantiseringsfel, pol: Dither to celowo zastosowana forma szumu używana do randomizacji błędu kwantyzacji, - ukr: Дизер - це навмисно застосована форма шуму, яка використовується для рандомізації - помилки квантування, + ukr: Дизер - це навмисно застосована форма шуму, яка використовується для рандомізації помилки квантування, kor: 디더는 양자화 오류를 무작위화하는 데 사용되는 의도적으로 적용된 노이즈 형태입니다, - ron: Dither este o formă de zgomot aplicată în mod intenționat, utilizată pentru - a randomiza eroarea de cuantificare, + ron: Dither este o formă de zgomot aplicată în mod intenționat, utilizată pentru a randomiza eroarea de cuantificare, Download: deu: herunterladen eng: Download @@ -1783,8 +1724,7 @@ Enabled: kor: 활성화됨 ron: Activat Enabled automatically when --master-display or --max-cll is specified.: - deu: Wird automatisch aktiviert, wenn --master-display oder --max-cll angegeben - wird. + deu: Wird automatisch aktiviert, wenn --master-display oder --max-cll angegeben wird. eng: Enabled automatically when --master-display or --max-cll is specified. fra: Activé automatiquement lorsque --master-display ou --max-cll est spécifié. ita: Abilitato automaticamente quando viene specificato --master-display o --max-cll. @@ -1814,31 +1754,20 @@ Enables the yadif filter.: kor: 야디프 필터를 활성화합니다. ron: Activează filtrul yadif. Enables true lossless coding by bypassing scaling, transform, quantization and in-loop filtering.: - deu: Ermöglicht echte verlustfreie Kodierung unter Umgehung von Skalierung, Transformation, - Quantisierung und In-Loop-Filterung. - eng: Enables true lossless coding by bypassing scaling, transform, quantization - and in-loop filtering. - fra: Active le véritable codage sans perte en contournant la mise à l'échelle, la - transformation, la quantification et le filtrage en boucle. - ita: Abilita la vera codifica senza perdita di dati bypassando la scalatura, la - trasformazione, la quantizzazione e il filtraggio in loop. - spa: Habilita la verdadera codificación sin pérdidas evitando el escalado, la transformación, - la cuantificación y el filtrado en bucle. + deu: Ermöglicht echte verlustfreie Kodierung unter Umgehung von Skalierung, Transformation, Quantisierung und In-Loop-Filterung. + eng: Enables true lossless coding by bypassing scaling, transform, quantization and in-loop filtering. + fra: Active le véritable codage sans perte en contournant la mise à l'échelle, la transformation, la quantification et le filtrage en boucle. + ita: Abilita la vera codifica senza perdita di dati bypassando la scalatura, la trasformazione, la quantizzazione e il filtraggio in loop. + spa: Habilita la verdadera codificación sin pérdidas evitando el escalado, la transformación, la cuantificación y el filtrado en bucle. chs: 绕过缩放、变换、量化和in-loop filtering,从而实现真正的无损编码。 jpn: スケーリング、トランスフォーム、量子化、インループフィルタリングをバイパスすることで、真のロスレスコーディングを可能にします。 - rus: Обеспечивает прямое кодирование без потерь, минуя масштабирование, преобразование, - квантование и фильтрацию в контуре. - por: Habilita a codificação lossless ao ignorar dimensionamento, transformação, - quantização e filtragem em loop. - swe: Möjliggör verklig förlustfri kodning genom att kringgå skalning, omvandling, - kvantisering och filtrering i loopen. - pol: Umożliwia prawdziwie bezstratne kodowanie poprzez ominięcie skalowania, transformacji, - kwantyzacji i filtrowania w pętli. - ukr: Забезпечує справжнє кодування без втрат, оминаючи масштабування, перетворення, - квантування та циклічну фільтрацію. + rus: Обеспечивает прямое кодирование без потерь, минуя масштабирование, преобразование, квантование и фильтрацию в контуре. + por: Habilita a codificação lossless ao ignorar dimensionamento, transformação, quantização e filtragem em loop. + swe: Möjliggör verklig förlustfri kodning genom att kringgå skalning, omvandling, kvantisering och filtrering i loopen. + pol: Umożliwia prawdziwie bezstratne kodowanie poprzez ominięcie skalowania, transformacji, kwantyzacji i filtrowania w pętli. + ukr: Забезпечує справжнє кодування без втрат, оминаючи масштабування, перетворення, квантування та циклічну фільтрацію. kor: 스케일링, 변환, 양자화 및 인루프 필터링을 우회하여 진정한 무손실 코딩을 지원합니다. - ron: Permite o codificare cu adevărat fără pierderi, ocolind scalarea, transformarea, - cuantificarea și filtrarea în buclă. + ron: Permite o codificare cu adevărat fără pierderi, ocolind scalarea, transformarea, cuantificarea și filtrarea în buclă. Enabling cover thumbnails on your system: deu: Aktivieren von Cover-Miniaturansichten auf Ihrem System eng: Enabling cover thumbnails on your system @@ -2112,8 +2041,7 @@ Exit application: Extra flags or options, cannot modify existing settings: deu: Zusätzliche Flags oder Optionen, kann bestehende Einstellungen nicht ändern eng: Extra flags or options, cannot modify existing settings - fra: Drapeaux ou options supplémentaires, ne peuvent pas modifier les paramètres - existants + fra: Drapeaux ou options supplémentaires, ne peuvent pas modifier les paramètres existants ita: I flag o le opzioni extra, non possono modificare le impostazioni esistenti spa: Las banderas u opciones adicionales, no pueden modificar los ajustes existentes chs: 有额外的标志或选项,无法修改已有设置。 @@ -2379,11 +2307,9 @@ For lossless, this is a size/speed tradeoff.: pol: W przypadku plików bezstratnych jest to kompromis między rozmiarem a szybkością. ukr: Для lossless це компроміс між розміром і швидкістю. kor: 무손실의 경우, 이는 크기와 속도의 절충안입니다. - ron: În cazul celor fără pierderi, acesta este un compromis între dimensiune și - viteză. + ron: În cazul celor fără pierderi, acesta este un compromis între dimensiune și viteză. For lossy, this is a quality/speed tradeoff.: - deu: Für verlustbehafteter Kodierung ist dies ein Kompromiss zwischen Qualität und - Geschwindigkeit. + deu: Für verlustbehafteter Kodierung ist dies ein Kompromiss zwischen Qualität und Geschwindigkeit. eng: For lossy, this is a quality/speed tradeoff. fra: Pour les perdants, il s'agit d'un compromis qualité/vitesse. ita: Per le perdite, si tratta di un compromesso qualità/velocità. @@ -2788,8 +2714,7 @@ Invalid Crop: kor: 잘못된 자르기 ron: Cultură invalidă It is recommended that AQ-mode be enabled along with this feature: - deu: Es wird empfohlen, dass zusammen mit dieser Funktion auch der AQ-Mode aktiviert - wird + deu: Es wird empfohlen, dass zusammen mit dieser Funktion auch der AQ-Mode aktiviert wird eng: It is recommended that AQ-mode be enabled along with this feature fra: Il est recommandé d'activer le mode AQ en même temps que cette fonction ita: Si raccomanda di abilitare la modalità AQ insieme a questa funzione @@ -2806,23 +2731,18 @@ It is recommended that AQ-mode be enabled along with this feature: It saves a few bits and can help performance in the client's tonemapper.: deu: Er spart ein paar Bits und kann die Leistung im Tonemapper des Clients verbessern. eng: It saves a few bits and can help performance in the client's tonemapper. - fra: Il permet d'économiser quelques bits et peut aider à améliorer les performances - de la machine à café du client. - ita: Consente di risparmiare qualche bit e può aiutare le prestazioni nel tonemapper - del cliente. - spa: Ahorra unos pocos bits y puede ayudar al rendimiento en el mapa de tonos del - cliente. + fra: Il permet d'économiser quelques bits et peut aider à améliorer les performances de la machine à café du client. + ita: Consente di risparmiare qualche bit e può aiutare le prestazioni nel tonemapper del cliente. + spa: Ahorra unos pocos bits y puede ayudar al rendimiento en el mapa de tonos del cliente. chs: 能够节省一些数据量,并有益于播放端色调映射的性能。 jpn: これによりビットが少し節約され、クライアントのトーンマッパーのパフォーマンスが向上します。 - rus: Это экономит несколько битов и может повысить производительность клиентского - тонального маппера. + rus: Это экономит несколько битов и может повысить производительность клиентского тонального маппера. por: Ele economiza alguns bits e pode ajudar no desempenho do tonemapper do cliente. swe: Det sparar några bitar och kan förbättra prestandan i klientens tonemapper. pol: Oszczędza to kilka bitów i może pomóc w wydajności tonemappera klienta. ukr: Це економить кілька бітів і може підвищити продуктивність клієнтського тонамапера. kor: 몇 비트를 절약하고 클라이언트 톤매퍼의 성능을 향상시킬 수 있습니다. - ron: Aceasta economisește câțiva biți și poate contribui la performanța în tonemapper-ul - clientului. + ron: Aceasta economisește câțiva biți și poate contribui la performanța în tonemapper-ul clientului. Keep: deu: beibehalten eng: Keep @@ -2946,10 +2866,8 @@ Level: Log2 of number of tile columns to encode faster (lesser quality): deu: Log2 der Anzahl der Gitterspalten, um schneller zu kodieren (geringere Qualität) eng: Log2 of number of tile columns to encode faster (lesser quality) - fra: Log2 du nombre de colonnes de tuiles pour un encodage plus rapide (qualité - moindre) - ita: Log2 del numero di colonne di tegole da codificare più velocemente (qualità - inferiore) + fra: Log2 du nombre de colonnes de tuiles pour un encodage plus rapide (qualité moindre) + ita: Log2 del numero di colonne di tegole da codificare più velocemente (qualità inferiore) spa: Log2 del número de columnas de azulejos para codificar más rápido (menor calidad) chs: 块的列数的Log2来快速编码(画质降低) jpn: タイルの列数のLog2で、より高速にエンコードする(画質は落ちる)。 @@ -2959,8 +2877,7 @@ Log2 of number of tile columns to encode faster (lesser quality): pol: Log2 liczby kolumn kafelków do szybszego kodowania (gorsza jakość) ukr: Log2 від кількості стовпчиків плитки для швидшого кодування (з меншою якістю) kor: 더 빠르게 인코딩하기 위한 타일 열 수의 로그2(품질 저하) - ron: Log2 al numărului de coloane de țiglă pentru a codifica mai repede (calitate - mai slabă) + ron: Log2 al numărului de coloane de țiglă pentru a codifica mai repede (calitate mai slabă) Log2 of number of tile rows to encode faster (lesser quality): deu: Log2 der Anzahl der Gitterzeilen, um schneller zu kodieren (geringere Qualität) eng: Log2 of number of tile rows to encode faster (lesser quality) @@ -2975,8 +2892,7 @@ Log2 of number of tile rows to encode faster (lesser quality): pol: Log2 liczby rzędów kafelków do szybszego kodowania (gorsza jakość) ukr: Log2 від кількості рядів плиток для швидшого кодування (з меншою якістю) kor: 더 빠르게 인코딩하기 위한 타일 행 수의 로그2(품질 저하) - ron: Log2 al numărului de rânduri de plăci pentru a codifica mai repede (calitate - mai slabă) + ron: Log2 al numărului de rânduri de plăci pentru a codifica mai repede (calitate mai slabă) Lookahead: deu: Lookahead eng: Lookahead @@ -3008,31 +2924,20 @@ Lossless: kor: 무손실 ron: Fără pierderi Lossless encodes implicitly have no rate control, all rate control options are ignored.: - deu: Verlustfreie Kodierungen haben implizit keine Ratenkontrolle, alle Optionen - zur Ratenkontrolle werden ignoriert. - eng: Lossless encodes implicitly have no rate control, all rate control options - are ignored. - fra: Les codes sans perte n'ont implicitement aucun contrôle de taux, toutes les - options de contrôle de taux sont ignorées. - ita: Le codifiche lossless non hanno implicitamente alcun controllo del tasso, tutte - le opzioni di controllo del tasso sono ignorate. - spa: Los códigos sin pérdidas implícitamente no tienen control de la tasa, todas - las opciones de control de la tasa son ignoradas. + deu: Verlustfreie Kodierungen haben implizit keine Ratenkontrolle, alle Optionen zur Ratenkontrolle werden ignoriert. + eng: Lossless encodes implicitly have no rate control, all rate control options are ignored. + fra: Les codes sans perte n'ont implicitement aucun contrôle de taux, toutes les options de contrôle de taux sont ignorées. + ita: Le codifiche lossless non hanno implicitamente alcun controllo del tasso, tutte le opzioni di controllo del tasso sono ignorate. + spa: Los códigos sin pérdidas implícitamente no tienen control de la tasa, todas las opciones de control de la tasa son ignoradas. chs: 无损编码意味着没有码率控制,会忽略所有码率控制选项。 jpn: ロスレスエンコードでは、レートコントロールが行われず、すべてのレートコントロールオプションは無視されます。 - rus: Кодирование без потерь неявно не имеет контроля скорости, все опции контроля - скорости игнорируются. - por: Os códigos lossless não têm implicitamente nenhum controle de taxa, todas as - opções de controle de taxa são ignoradas. - swe: Förlustfria kodningar har implicit ingen hastighetsreglering, alla alternativ - för hastighetsreglering ignoreras. - pol: Kodowanie bezstratne domyślnie nie ma kontroli szybkości, wszystkie opcje kontroli - szybkości są ignorowane. - ukr: Кодування без втрат неявно не передбачають керування швидкістю, всі опції керування - швидкістю ігноруються. + rus: Кодирование без потерь неявно не имеет контроля скорости, все опции контроля скорости игнорируются. + por: Os códigos lossless não têm implicitamente nenhum controle de taxa, todas as opções de controle de taxa são ignoradas. + swe: Förlustfria kodningar har implicit ingen hastighetsreglering, alla alternativ för hastighetsreglering ignoreras. + pol: Kodowanie bezstratne domyślnie nie ma kontroli szybkości, wszystkie opcje kontroli szybkości są ignorowane. + ukr: Кодування без втрат неявно не передбачають керування швидкістю, всі опції керування швидкістю ігноруються. kor: 무손실 인코딩에는 암시적으로 속도 제어 기능이 없으며, 모든 속도 제어 옵션이 무시됩니다. - ron: Codificările fără pierderi nu au implicit niciun control al ratei, toate opțiunile - de control al ratei sunt ignorate. + ron: Codificările fără pierderi nu au implicit niciun control al ratei, toate opțiunile de control al ratei sunt ignorate. Max Muxing Queue Size: deu: Max. Größe der Muxing-Warteschlange eng: Max Muxing Queue Size @@ -3334,27 +3239,20 @@ No command found for: kor: 에 대한 명령을 찾을 수 없습니다. ron: Nu s-a găsit nicio comandă pentru No crop, scale, rotation, flip nor any other filters will be applied.: - deu: Es werden weder Zuschneiden, Skalieren, Drehen, Spiegeln noch andere Filter - angewendet. + deu: Es werden weder Zuschneiden, Skalieren, Drehen, Spiegeln noch andere Filter angewendet. eng: No crop, scale, rotation, flip nor any other filters will be applied. - fra: Aucun filtre ne sera appliqué sur les cultures, les écailles, la rotation, - le retournement ou autre. - ita: Non verranno applicati filtri per il raccolto, la scala, la rotazione, il capovolgimento - o altri filtri. - spa: No se aplicará ningún filtro de cultivo, de escala, de rotación, de volteo - ni ningún otro. + fra: Aucun filtre ne sera appliqué sur les cultures, les écailles, la rotation, le retournement ou autre. + ita: Non verranno applicati filtri per il raccolto, la scala, la rotazione, il capovolgimento o altri filtri. + spa: No se aplicará ningún filtro de cultivo, de escala, de rotación, de volteo ni ningún otro. chs: 不会应用裁切、缩放、旋转、翻转或任何其他滤镜。 jpn: 切り抜き、拡大縮小、回転、反転などのフィルターはかけられません。 rus: Обрезка, масштабирование, вращение, переворот и другие фильтры не применяются. por: Nenhum recorte, escala, rotação, inversão ou qualquer outro filtro será aplicado. - swe: Ingen beskärning, skalning, rotation, vändning eller andra filter kommer att - tillämpas. - pol: Nie zostaną zastosowane żadne filtry typu crop, scale, rotation, flip ani żadne - inne. + swe: Ingen beskärning, skalning, rotation, vändning eller andra filter kommer att tillämpas. + pol: Nie zostaną zastosowane żadne filtry typu crop, scale, rotation, flip ani żadne inne. ukr: Обрізання, масштабування, обертання, перевертання та інші фільтри не застосовуються. kor: 자르기, 크기 조정, 회전, 뒤집기 또는 기타 필터가 적용되지 않습니다. - ron: Nu se va aplica niciun filtru de decupare, scalare, rotație, întoarcere sau - orice alt filtru. + ron: Nu se va aplica niciun filtru de decupare, scalare, rotație, întoarcere sau orice alt filtru. No encoding is currently in process, starting encode: deu: Es wird momentan keine Kodierung durchgeführt, Kodierung wird gestartet eng: No encoding is currently in process, starting encode @@ -3446,29 +3344,20 @@ Not a video file: kor: 동영상 파일이 아닙니다. ron: Nu este un fișier video Only put the HDR10+ dynamic metadata in the IDR and frames where the values have changed.: - deu: Setzen der dynamischen HDR10+-Metadaten nur in die IDR und Frames, bei denen - sich die Werte geändert haben. - eng: Only put the HDR10+ dynamic metadata in the IDR and frames where the values - have changed. - fra: Ne placez les métadonnées dynamiques HDR10+ que dans l'IDR et les cadres dont - les valeurs ont changé. - ita: Mettete i metadati dinamici HDR10+ solo nell'IDR e nei frame in cui i valori - sono cambiati. - spa: Sólo pon los metadatos dinámicos HDR10+ en el IDR y los cuadros donde los valores - han cambiado. + deu: Setzen der dynamischen HDR10+-Metadaten nur in die IDR und Frames, bei denen sich die Werte geändert haben. + eng: Only put the HDR10+ dynamic metadata in the IDR and frames where the values have changed. + fra: Ne placez les métadonnées dynamiques HDR10+ que dans l'IDR et les cadres dont les valeurs ont changé. + ita: Mettete i metadati dinamici HDR10+ solo nell'IDR e nei frame in cui i valori sono cambiati. + spa: Sólo pon los metadatos dinámicos HDR10+ en el IDR y los cuadros donde los valores han cambiado. chs: 仅将HDR10+动态元数据置于其值发生改变的帧及IDR帧。 jpn: HDR10+のダイナミックメタデータは、IDRと値が変化したフレームにのみ入れます。 - rus: Поместите динамические метаданные HDR10+ только в IDR и кадры, где значения - изменились. - por: Coloque os metadados dinâmicos HDR10+ apenas no IDR e nos quadros onde os valores - foram alterados. + rus: Поместите динамические метаданные HDR10+ только в IDR и кадры, где значения изменились. + por: Coloque os metadados dinâmicos HDR10+ apenas no IDR e nos quadros onde os valores foram alterados. swe: Lägg endast in HDR10+ dynamiska metadata i IDR och ramar där värdena har ändrats. - pol: Umieszczaj metadane dynamiczne HDR10+ tylko w IDR i klatkach, w których wartości - uległy zmianie. + pol: Umieszczaj metadane dynamiczne HDR10+ tylko w IDR i klatkach, w których wartości uległy zmianie. ukr: Динамічні метадані HDR10+ додавайте лише в IDR і кадри, де значення змінилися. kor: HDR10+ 동적 메타데이터는 값이 변경된 IDR과 프레임에만 넣습니다. - ron: Introduceți metadatele dinamice HDR10+ doar în IDR și în cadrele în care valorile - s-au schimbat. + ron: Introduceți metadatele dinamice HDR10+ doar în IDR și în cadrele în care valorile s-au schimbat. Only select first matching Audio Track: deu: Nur die erste passende Audiospur auswählen eng: Only select first matching Audio Track @@ -3560,8 +3449,7 @@ Output FPS: kor: 출력 FPS ron: FPS de ieșire Over-allocation of frame threads will not improve performance,: - deu: Die Auswahl von mehr als der verfügbaren Frame-Threads verbessert nicht die - Leistung, + deu: Die Auswahl von mehr als der verfügbaren Frame-Threads verbessert nicht die Leistung, eng: Over-allocation of frame threads will not improve performance, fra: Une allocation excessive des fils de trame n'améliorera pas les performances, ita: La sovraallocazione dei thread fotogramma non migliorerà le prestazioni, @@ -3621,28 +3509,20 @@ Override the preset rate-control: kor: 사전 설정 속도 제어 재정의 ron: Suprascrieți controlul ratei presetate PIR can replace keyframes by inserting a column of intra blocks in non-keyframes,: - deu: PIR kann durch Einfügen einer Spalte von Intrablöcken in Nicht-Keyframes Keyframes - ersetzen, + deu: PIR kann durch Einfügen einer Spalte von Intrablöcken in Nicht-Keyframes Keyframes ersetzen, eng: PIR can replace keyframes by inserting a column of intra blocks in non-keyframes, - fra: Le PIR peut remplacer les images clés en insérant une colonne de blocs intra - dans les images non clés, - ita: Il PIR può sostituire i keyframe inserendo una colonna di blocchi interni nei - non-keyframe, - spa: PIR puede reemplazar los fotogramas clave insertando una columna de intrabloques - en los fotogramas no clave, + fra: Le PIR peut remplacer les images clés en insérant une colonne de blocs intra dans les images non clés, + ita: Il PIR può sostituire i keyframe inserendo una colonna di blocchi interni nei non-keyframe, + spa: PIR puede reemplazar los fotogramas clave insertando una columna de intrabloques en los fotogramas no clave, chs: PIR可以在非关键帧中插入一列intra blocks,从而替代关键帧。 jpn: PIRは、非キーフレームにイントラブロックの列を挿入することで、キーフレームを置き換えることができます。 - rus: PIR может заменить ключевые кадры, вставляя колонку внутренних блоков в неключевые - кадры, + rus: PIR может заменить ключевые кадры, вставляя колонку внутренних блоков в неключевые кадры, por: PIR pode substituir keyframes inserindo uma coluna de intra blocos em não-keyframes, swe: PIR kan ersätta keyframes genom att infoga en kolumn med intrablock i icke-keyframes, - pol: PIR może zastąpić klatki kluczowe wstawiając kolumnę bloków intra do klatek - nie będących klatkami kluczowymi, - ukr: PIR може замінювати ключові кадри, вставляючи стовпчик внутрішніх блоків у - неключові кадри, + pol: PIR może zastąpić klatki kluczowe wstawiając kolumnę bloków intra do klatek nie będących klatkami kluczowymi, + ukr: PIR може замінювати ключові кадри, вставляючи стовпчик внутрішніх блоків у неключові кадри, kor: PIR은 비키프레임에 인트라 블록 열을 삽입하여 키프레임을 대체할 수 있습니다, - ron: PIR poate înlocui cadrele cheie prin inserarea unei coloane de blocuri intra - în cadrele care nu sunt cadre cheie, + ron: PIR poate înlocui cadrele cheie prin inserarea unei coloane de blocuri intra în cadrele care nu sunt cadre cheie, Parse Video details: deu: Video-Details analysieren eng: Parse Video details @@ -3948,8 +3828,7 @@ Quality and compression efficiency vs speed trade-off: eng: Quality and compression efficiency vs speed trade-off fra: Le compromis entre la qualité et l'efficacité de la compression et la vitesse ita: Qualità ed efficienza di compressione rispetto al compromesso velocità - spa: La calidad y la eficiencia de la compresión frente a la compensación de la - velocidad + spa: La calidad y la eficiencia de la compresión frente a la compensación de la velocidad chs: 在质量及压缩效率与速度之间进行权衡 jpn: 画質と圧縮効率と速度のトレードオフ rus: Компромисс между качеством и эффективностью сжатия и скоростью @@ -4050,31 +3929,20 @@ RC Lookahead: kor: RC 룩어헤드 ron: RC Lookahead Raise or lower per-block quantization based on complexity analysis of the source image.: - deu: Erhöhen oder verringern der Quantisierung pro Block basierend auf der Komplexitätsanalyse - des Quellbildes. - eng: Raise or lower per-block quantization based on complexity analysis of the source - image. - fra: Augmenter ou diminuer la quantification par bloc en fonction de l'analyse de - la complexité de l'image source. - ita: Aumenta o diminuisci la quantizzazione per blocco in base all'analisi della - complessità dell'immagine sorgente. - spa: Aumentar o disminuir la cuantificación por bloque basada en el análisis de - la complejidad de la imagen de origen. + deu: Erhöhen oder verringern der Quantisierung pro Block basierend auf der Komplexitätsanalyse des Quellbildes. + eng: Raise or lower per-block quantization based on complexity analysis of the source image. + fra: Augmenter ou diminuer la quantification par bloc en fonction de l'analyse de la complexité de l'image source. + ita: Aumenta o diminuisci la quantizzazione per blocco in base all'analisi della complessità dell'immagine sorgente. + spa: Aumentar o disminuir la cuantificación por bloque basada en el análisis de la complejidad de la imagen de origen. chs: 根据对源图像的复杂度分析,提升或降低各个块的量化。 jpn: ソース画像の複雑さの分析に基づいて、ブロックごとの量子化率を上げるか下げる。 - rus: Повышение или понижение блочного квантования на основе анализа сложности исходного - изображения. - por: Aumentar ou diminuir a quantização por bloco com base na análise de complexidade - da imagem de origem. - swe: Öka eller sänka kvantiseringen per block baserat på en komplexitetsanalys av - källbilden. - pol: Zwiększ lub zmniejsz kwantyzację per-block na podstawie analizy złożoności - obrazu źródłowego. - ukr: Підвищуйте або знижуйте квантування на блок на основі аналізу складності вихідного - зображення. + rus: Повышение или понижение блочного квантования на основе анализа сложности исходного изображения. + por: Aumentar ou diminuir a quantização por bloco com base na análise de complexidade da imagem de origem. + swe: Öka eller sänka kvantiseringen per block baserat på en komplexitetsanalys av källbilden. + pol: Zwiększ lub zmniejsz kwantyzację per-block na podstawie analizy złożoności obrazu źródłowego. + ukr: Підвищуйте або знижуйте квантування на блок на основі аналізу складності вихідного зображення. kor: 소스 이미지의 복잡도 분석에 따라 블록당 양자화를 높이거나 낮춥니다. - ron: Creșteți sau reduceți cuantificarea pe blocuri pe baza analizei complexității - imaginii sursă. + ron: Creșteți sau reduceți cuantificarea pe blocuri pe baza analizei complexității imaginii sursă. Rate Control: deu: Ratensteuerung eng: Rate Control @@ -4123,23 +3991,18 @@ Ready to encode: Reconstructed output pictures are bit-exact to the input pictures.: deu: Rekonstruierte Ausgabebilder entsprechen bit-genau den Eingangsbildern. eng: Reconstructed output pictures are bit-exact to the input pictures. - fra: Les images de sortie reconstruites sont exactes au niveau des bits par rapport - aux images d'entrée. + fra: Les images de sortie reconstruites sont exactes au niveau des bits par rapport aux images d'entrée. ita: Le immagini di uscita ricostruite sono bit-esatte alle immagini di ingresso. - spa: Las imágenes de salida reconstruidas son un poco exactas a las imágenes de - entrada. + spa: Las imágenes de salida reconstruidas son un poco exactas a las imágenes de entrada. chs: 重建后的输出图像与输入图像是逐位一致(bit-exact)的。 jpn: 再構成された出力画像は、入力画像とビットが一致しています。 - rus: Реконструированные выходные изображения являются битово-точными по отношению - к входным изображениям. + rus: Реконструированные выходные изображения являются битово-точными по отношению к входным изображениям. por: As imagens de saída reconstruídas são bit-exatas para as imagens de entrada. - swe: De rekonstruerade utdatabilderna är bit-exakta i förhållande till de ingående - bilderna. + swe: De rekonstruerade utdatabilderna är bit-exakta i förhållande till de ingående bilderna. pol: Zrekonstruowane obrazy wyjściowe są bitowo dokładne w stosunku do obrazów wejściowych. ukr: Реконструйовані вихідні зображення є побітово точними до вхідних зображень. kor: 재구성된 출력 사진은 입력 사진과 비트가 일치합니다. - ron: Imaginile de ieșire reconstruite sunt exacte din punct de vedere biologic față - de imaginile de intrare. + ron: Imaginile de ieșire reconstruite sunt exacte din punct de vedere biologic față de imaginile de intrare. Ref Frames: deu: Ref-Frames eng: Ref Frames @@ -4471,56 +4334,35 @@ Scale: kor: 규모 ron: Scala Scrub away all incoming metadata, like video titles, unique markings and so on.: - deu: Entfernen aller eingehenden Metadaten, wie Videotitel, eindeutige Markierungen - usw. - eng: Scrub away all incoming metadata, like video titles, unique markings and so - on. - fra: Supprimez toutes les métadonnées entrantes, comme les titres des vidéos, les - marquages uniques, etc. - ita: Cancella tutti i metadati in arrivo, come i titoli dei video, le marcature - uniche e così via. - spa: Borra todos los metadatos entrantes, como títulos de video, marcas únicas y - demás. + deu: Entfernen aller eingehenden Metadaten, wie Videotitel, eindeutige Markierungen usw. + eng: Scrub away all incoming metadata, like video titles, unique markings and so on. + fra: Supprimez toutes les métadonnées entrantes, comme les titres des vidéos, les marquages uniques, etc. + ita: Cancella tutti i metadati in arrivo, come i titoli dei video, le marcature uniche e così via. + spa: Borra todos los metadatos entrantes, como títulos de video, marcas únicas y demás. chs: 擦除输入文件中所有的元数据,如视频标题、唯一标记等。 jpn: 動画のタイトルやユニークなマークなど、元のメタデータをすべて消去します。 - rus: Удалите все входящие метаданные, такие как названия видео, уникальные метки - и так далее. - por: Remover todos os metadados de entrada, como títulos de vídeo, marcações únicas - e assim por diante. - swe: Ta bort alla inkommande metadata, som videotitlar, unika markeringar och så - vidare. - pol: Usuń wszystkie przychodzące metadane, takie jak tytuły wideo, unikalne oznaczenia - i tak dalej. + rus: Удалите все входящие метаданные, такие как названия видео, уникальные метки и так далее. + por: Remover todos os metadados de entrada, como títulos de vídeo, marcações únicas e assim por diante. + swe: Ta bort alla inkommande metadata, som videotitlar, unika markeringar och så vidare. + pol: Usuń wszystkie przychodzące metadane, takie jak tytuły wideo, unikalne oznaczenia i tak dalej. ukr: Видаліть усі вхідні метадані, такі як назви відео, унікальні позначки тощо. kor: 동영상 제목, 고유 표시 등과 같이 들어오는 모든 메타데이터를 삭제합니다. - ron: Eliminați toate metadatele primite, cum ar fi titlurile video, marcajele unice - și așa mai departe. + ron: Eliminați toate metadatele primite, cum ar fi titlurile video, marcajele unice și așa mai departe. Selects which NVENC capable GPU to use. First GPU is 0, second is 1, and so on: - deu: Wählt aus, welche NVENC-fähige GPU genutzt werden soll. Die erste GPU ist 0, - die zweite 1, usw - eng: Selects which NVENC capable GPU to use. First GPU is 0, second is 1, and so - on - fra: Sélectionne le GPU compatible NVENC à utiliser. Le premier GPU est 0, le second - est 1, et ainsi de suite - ita: Seleziona quale GPU con capacità NVENC utilizzare. La prima GPU è 0, la seconda - è 1, e così via - spa: Selecciona qué GPU con capacidad NVENC se va a utilizar. La primera GPU es - 0, la segunda es 1, y así sucesivamente + deu: Wählt aus, welche NVENC-fähige GPU genutzt werden soll. Die erste GPU ist 0, die zweite 1, usw + eng: Selects which NVENC capable GPU to use. First GPU is 0, second is 1, and so on + fra: Sélectionne le GPU compatible NVENC à utiliser. Le premier GPU est 0, le second est 1, et ainsi de suite + ita: Seleziona quale GPU con capacità NVENC utilizzare. La prima GPU è 0, la seconda è 1, e così via + spa: Selecciona qué GPU con capacidad NVENC se va a utilizar. La primera GPU es 0, la segunda es 1, y así sucesivamente chs: 选择使用哪个有NVENC功能的GPU。第一个GPU为0,第二个为1,以此类推。 jpn: どのNVENC対応GPUを使用するか選択してください。最初のGPUは0、2番目は1。 - rus: Выбрать, какой GPU с поддержкой NVENC будет использоваться. Первый GPU - 0, - второй - 1 и так далее. - por: Seleciona qual GPU compatível com NVENC usar. A primeira GPU é 0, a segunda - é 1 e assim por diante. - swe: Väljer vilken NVENC-kompatibel GPU som ska användas. Den första GPU:n är 0, - den andra är 1 osv. - pol: Wybiera, który z układów GPU obsługujących NVENC ma zostać użyty. Pierwszy - układ GPU to 0, drugi to 1, i tak dalej. - ukr: Вибирає, який графічний процесор з підтримкою NVENC використовувати. Перший - графічний процесор - 0, другий - 1 і так далі + rus: Выбрать, какой GPU с поддержкой NVENC будет использоваться. Первый GPU - 0, второй - 1 и так далее. + por: Seleciona qual GPU compatível com NVENC usar. A primeira GPU é 0, a segunda é 1 e assim por diante. + swe: Väljer vilken NVENC-kompatibel GPU som ska användas. Den första GPU:n är 0, den andra är 1 osv. + pol: Wybiera, który z układów GPU obsługujących NVENC ma zostać użyty. Pierwszy układ GPU to 0, drugi to 1, i tak dalej. + ukr: Вибирає, який графічний процесор з підтримкою NVENC використовувати. Перший графічний процесор - 0, другий - 1 і так далі kor: 사용할 NVENC 지원 GPU를 선택합니다. 첫 번째 GPU는 0, 두 번째는 1 등입니다. - ron: Selectează ce GPU cu capacitate NVENC se va utiliza. Primul GPU este 0, al - doilea este 1, și așa mai departe. + ron: Selectează ce GPU cu capacitate NVENC se va utiliza. Primul GPU este 0, al doilea este 1, și așa mai departe. Set speed to 4 for first pass: deu: Setze Geschwindigkeit des ersten Durchlaufs auf 4 eng: Set speed to 4 for first pass @@ -4585,8 +4427,7 @@ Set the level of effort in determining B frame placement.: deu: Grad des Aufwands bei der Bestimmung der B-Frame-Platzierung festlegen. eng: Set the level of effort in determining B frame placement. fra: Définir le niveau d'effort pour déterminer le placement des images B. - ita: Impostare il livello di sforzo nel determinare il posizionamento del fotogramma - B. + ita: Impostare il livello di sforzo nel determinare il posizionamento del fotogramma B. spa: Establezca el nivel de esfuerzo para determinar la ubicación del cuadro B. chs: 对决定B帧位置时的工作量水平进行调整。 jpn: Bフレームの配置を決める際の努力の度合いを設定します。 @@ -4703,31 +4544,20 @@ presets slower than this result in much smaller gains: kor: 이보다 느린 사전 설정은 게인이 훨씬 적습니다. ron: presetări mai lente decât aceasta duc la câștiguri mult mai mici Slower presets will generally achieve better compression efficiency (and generate smaller bitstreams).: - deu: Langsamere Voreinstellungen erzielen im Allgemeinen eine bessere Komprimierungseffizienz - (und erzeugen kleinere Bitströme). - eng: Slower presets will generally achieve better compression efficiency (and generate - smaller bitstreams). - fra: Des préréglages plus lents permettent généralement d'obtenir une meilleure - efficacité de compression (et de générer des flux binaires plus petits). - ita: Le preimpostazioni più lente in genere raggiungono una migliore efficienza - di compressione (e generano flussi di bit più piccoli). - spa: Los preajustes más lentos generalmente lograrán una mejor eficiencia de compresión - (y generarán flujos de bits más pequeños). + deu: Langsamere Voreinstellungen erzielen im Allgemeinen eine bessere Komprimierungseffizienz (und erzeugen kleinere Bitströme). + eng: Slower presets will generally achieve better compression efficiency (and generate smaller bitstreams). + fra: Des préréglages plus lents permettent généralement d'obtenir une meilleure efficacité de compression (et de générer des flux binaires plus petits). + ita: Le preimpostazioni più lente in genere raggiungono una migliore efficienza di compressione (e generano flussi di bit più piccoli). + spa: Los preajustes más lentos generalmente lograrán una mejor eficiencia de compresión (y generarán flujos de bits más pequeños). chs: 较慢的预设通常会达成更好的压缩效率(并生成较小的码流)。 jpn: 一般的には、遅いプリセットの方が圧縮効率が良くなります(より小さなビットストリームを生成します)。 - rus: Более медленные пресеты обычно обеспечивают лучшую эффективность сжатия (и - генерируют меньшие битовые потоки). - por: Presets mais lentos geralmente alcançam melhor eficiência de compressão (e - geram bitstreams menores). - swe: Långsammare förinställningar ger i allmänhet bättre kompressionseffektivitet - (och genererar mindre bitströmmar). - pol: Wolniejsze presety generalnie osiągają lepszą wydajność kompresji (i generują - mniejsze strumienie bitów). - ukr: Повільніші пресети зазвичай забезпечують кращу ефективність стиснення (і генерують - менші бітові потоки). + rus: Более медленные пресеты обычно обеспечивают лучшую эффективность сжатия (и генерируют меньшие битовые потоки). + por: Presets mais lentos geralmente alcançam melhor eficiência de compressão (e geram bitstreams menores). + swe: Långsammare förinställningar ger i allmänhet bättre kompressionseffektivitet (och genererar mindre bitströmmar). + pol: Wolniejsze presety generalnie osiągają lepszą wydajność kompresji (i generują mniejsze strumienie bitów). + ukr: Повільніші пресети зазвичай забезпечують кращу ефективність стиснення (і генерують менші бітові потоки). kor: 일반적으로 느린 사전 설정은 더 나은 압축 효율을 달성하고 더 작은 비트 스트림을 생성합니다. - ron: Presetările mai lente vor obține, în general, o mai bună eficiență a compresiei - (și vor genera fluxuri de biți mai mici). + ron: Presetările mai lente vor obține, în general, o mai bună eficiență a compresiei (și vor genera fluxuri de biți mai mici). Source: deu: Quelle eng: Source @@ -4956,8 +4786,7 @@ Supported Image Files: The GUI might have died, but I'm going to keep converting!: deu: Die GUI ist eventuell abgestürzt, aber ich werde weiter konvertieren! eng: The GUI might have died, but I'm going to keep converting! - fra: L'interface graphique est peut-être morte, mais je vais continuer à me convertir - ! + fra: L'interface graphique est peut-être morte, mais je vais continuer à me convertir ! ita: L'interfaccia grafica sarà anche morta, ma continuerò a convertirmi! spa: ¡El GUI puede haber muerto, pero voy a seguir convirtiendo! chs: 图形界面可能已经崩溃,但转换将继续进行! @@ -4985,30 +4814,20 @@ The more complex the block, the more quantization is used.: kor: 블록이 복잡할수록 더 많은 양자화가 사용됩니다. ron: Cu cât blocul este mai complex, cu atât se utilizează mai multă cuantificare. The purpose is to prevent blocking or banding artifacts in regions with few/zero AC coefficients.: - deu: Der Zweck ist, Blocking- oder Banding-Artefakte in Regionen mit wenigen/keinen - AC-Koeffizienten zu verhindern. - eng: The purpose is to prevent blocking or banding artifacts in regions with few/zero - AC coefficients. - fra: L'objectif est d'éviter de bloquer ou de banderoler des artefacts dans des - régions où les coefficients AC sont faibles ou nuls. - ita: Lo scopo è quello di prevenire il blocco o il banding di artefatti in regioni - con pochi/zeri coefficienti AC. - spa: El propósito es prevenir el bloqueo o los artefactos de bandas en regiones - con coeficientes de CA bajos/cero. + deu: Der Zweck ist, Blocking- oder Banding-Artefakte in Regionen mit wenigen/keinen AC-Koeffizienten zu verhindern. + eng: The purpose is to prevent blocking or banding artifacts in regions with few/zero AC coefficients. + fra: L'objectif est d'éviter de bloquer ou de banderoler des artefacts dans des régions où les coefficients AC sont faibles ou nuls. + ita: Lo scopo è quello di prevenire il blocco o il banding di artefatti in regioni con pochi/zeri coefficienti AC. + spa: El propósito es prevenir el bloqueo o los artefactos de bandas en regiones con coeficientes de CA bajos/cero. chs: 目的是为了防止在AC coefficients较少或为零的区域出现blocking或banding artifacts。 jpn: これは、AC係数が少ない/ゼロの領域でのブロッキングやバンディングのアーチファクトを防ぐためです。 - rus: Цель - предотвратить блокирование или артефакты полосатости в областях с небольшим - количеством/нулевыми коэффициентами переменного тока. - por: O objetivo é evitar artefatos de bloqueio ou banding em regiões com poucos/nenhum - coeficiente AC. + rus: Цель - предотвратить блокирование или артефакты полосатости в областях с небольшим количеством/нулевыми коэффициентами переменного тока. + por: O objetivo é evitar artefatos de bloqueio ou banding em regiões com poucos/nenhum coeficiente AC. swe: Syftet är att förhindra blockering eller bandning i områden med få/noll AC-koefficienter. - pol: Ma to na celu zapobieganie powstawaniu artefaktów blokowania lub pasmowania - w regionach o małej lub zerowej liczbie współczynników AC. - ukr: Мета - запобігти артефактам блокування або смуги в регіонах з низькими/нульовими - коефіцієнтами змінного струму. + pol: Ma to na celu zapobieganie powstawaniu artefaktów blokowania lub pasmowania w regionach o małej lub zerowej liczbie współczynników AC. + ukr: Мета - запобігти артефактам блокування або смуги в регіонах з низькими/нульовими коефіцієнтами змінного струму. kor: 그 목적은 AC 계수가 거의 없거나 0인 영역에서 차단 또는 밴딩 아티팩트를 방지하는 것입니다. - ron: Scopul este de a preveni apariția unor artefacte de blocare sau de bandaj în - regiunile cu coeficienți AC puțini/zero. + ron: Scopul este de a preveni apariția unor artefacte de blocare sau de bandaj în regiunile cu coeficienți AC puțini/zero. There is a conversion in process!: deu: Es ist eine Konvertierung am laufen! eng: There is a conversion in process! @@ -5055,92 +4874,56 @@ There was an error during conversion and the queue has stopped: kor: 변환하는 동안 오류가 발생하여 대기열이 중지되었습니다. ron: S-a produs o eroare în timpul conversiei și coada s-a oprit This flag performs bi-linear interpolation of the corner reference samples for a strong smoothing effect.: - deu: Dieses Flag führt eine bi-lineare Interpolation der Eckreferenzsamples durch, - um einen starken Glättungseffekt zu erzielen. - eng: This flag performs bi-linear interpolation of the corner reference samples - for a strong smoothing effect. - fra: Ce drapeau effectue une interpolation bi-linéaire des échantillons de référence - des coins pour un fort effet de lissage. - ita: Questa bandierina esegue l'interpolazione bi-lineare dei campioni di riferimento - d'angolo per un forte effetto levigante. - spa: Este banderín realiza una interpolación bi-línea de las muestras de referencia - de las esquinas para un fuerte efecto de suavizado. + deu: Dieses Flag führt eine bi-lineare Interpolation der Eckreferenzsamples durch, um einen starken Glättungseffekt zu erzielen. + eng: This flag performs bi-linear interpolation of the corner reference samples for a strong smoothing effect. + fra: Ce drapeau effectue une interpolation bi-linéaire des échantillons de référence des coins pour un fort effet de lissage. + ita: Questa bandierina esegue l'interpolazione bi-lineare dei campioni di riferimento d'angolo per un forte effetto levigante. + spa: Este banderín realiza una interpolación bi-línea de las muestras de referencia de las esquinas para un fuerte efecto de suavizado. chs: 这个选项对corner reference samples进行双线性插值,以获得强平滑效果。 jpn: このフラグは、強力なスムージング効果を得るために、コーナーリファレンスサンプルのバイリニア補間を行います。 - rus: Этот флаг выполняет билинейную интерполяцию угловых опорных образцов для сильного - эффекта сглаживания. - por: Esta flag realiza interpolação bilinear das amostras de referência dos cantos - para um forte efeito de suavização. - swe: Denna flagga utför bi-lineär interpolering av hörnreferensproverna för en stark - utjämningseffekt. - pol: Flaga ta wykonuje bi-liniową interpolację próbek referencyjnych narożników - w celu uzyskania silnego efektu wygładzania. - ukr: Цей прапорець виконує білінійну інтерполяцію кутових еталонних відліків для - сильного ефекту згладжування. + rus: Этот флаг выполняет билинейную интерполяцию угловых опорных образцов для сильного эффекта сглаживания. + por: Esta flag realiza interpolação bilinear das amostras de referência dos cantos para um forte efeito de suavização. + swe: Denna flagga utför bi-lineär interpolering av hörnreferensproverna för en stark utjämningseffekt. + pol: Flaga ta wykonuje bi-liniową interpolację próbek referencyjnych narożników w celu uzyskania silnego efektu wygładzania. + ukr: Цей прапорець виконує білінійну інтерполяцію кутових еталонних відліків для сильного ефекту згладжування. kor: 이 플래그는 강력한 스무딩 효과를 위해 코너 기준 샘플의 이중 선형 보간을 수행합니다. - ron: Acest indicator efectuează o interpolare biliniară a eșantioanelor de referință - din colț pentru un efect puternic de netezire. + ron: Acest indicator efectuează o interpolare biliniară a eșantioanelor de referință din colț pentru un efect puternic de netezire. This improves encoding speed significantly on systems that are otherwise underutilised when encoding VP9.: - deu: Dies verbessert erheblich die Kodiergeschwindigkeit auf Systemen, die anonsten - bei der Kodierung von VP9 unausgelastet sind. - eng: This improves encoding speed significantly on systems that are otherwise underutilised - when encoding VP9. - fra: Cela améliore considérablement la vitesse de codage sur des systèmes qui sont - autrement sous-utilisés lors du codage VP9. - ita: Questo migliora la velocità di codifica in modo significativo su sistemi che - sono altrimenti sottoutilizzati durante la codifica VP9. - spa: Esto mejora significativamente la velocidad de codificación en los sistemas - que de otra manera son subutilizados al codificar el VP9. + deu: Dies verbessert erheblich die Kodiergeschwindigkeit auf Systemen, die anonsten bei der Kodierung von VP9 unausgelastet sind. + eng: This improves encoding speed significantly on systems that are otherwise underutilised when encoding VP9. + fra: Cela améliore considérablement la vitesse de codage sur des systèmes qui sont autrement sous-utilisés lors du codage VP9. + ita: Questo migliora la velocità di codifica in modo significativo su sistemi che sono altrimenti sottoutilizzati durante la codifica VP9. + spa: Esto mejora significativamente la velocidad de codificación en los sistemas que de otra manera son subutilizados al codificar el VP9. chs: 在编码VP9时资源利用不足的系统上,此选项能够显著提升编码速度。 jpn: これにより、VP9をエンコードする際に十分に利用されていないシステムにおいて、エンコード速度が大幅に向上します。 - rus: Это значительно повышает скорость кодирования на системах, которые по-другому - недостаточно используются при кодировании VP9. - por: Isso melhora significativamente a velocidade de codificação em sistemas que - de outra forma são subutilizados ao codificar VP9. - swe: Detta förbättrar kodningshastigheten avsevärt på system som annars är underutnyttjade - vid kodning av VP9. - pol: Poprawia to znacznie szybkość kodowania na systemach, które w przeciwnym razie - nie są w pełni wykorzystywane podczas kodowania VP9. - ukr: Це значно покращує швидкість кодування на системах, які інакше не використовуються - при кодуванні VP9. + rus: Это значительно повышает скорость кодирования на системах, которые по-другому недостаточно используются при кодировании VP9. + por: Isso melhora significativamente a velocidade de codificação em sistemas que de outra forma são subutilizados ao codificar VP9. + swe: Detta förbättrar kodningshastigheten avsevärt på system som annars är underutnyttjade vid kodning av VP9. + pol: Poprawia to znacznie szybkość kodowania na systemach, które w przeciwnym razie nie są w pełni wykorzystywane podczas kodowania VP9. + ukr: Це значно покращує швидкість кодування на системах, які інакше не використовуються при кодуванні VP9. kor: 이를 통해 VP9 인코딩 시 활용도가 낮은 시스템에서 인코딩 속도가 크게 향상됩니다. - ron: Acest lucru îmbunătățește semnificativ viteza de codificare pe sistemele care - sunt altfel subutilizate la codificarea VP9. + ron: Acest lucru îmbunătățește semnificativ viteza de codificare pe sistemele care sunt altfel subutilizate la codificarea VP9. This is intended for use when you do not have a container to keep the stream headers for you: - deu: Dies ist für die Verwendung vorgesehen, wenn kein Container vorhanden ist, - der die Stream-Header aufbewahrt - eng: This is intended for use when you do not have a container to keep the stream - headers for you - fra: Il est destiné à être utilisé lorsque vous ne disposez pas d'un conteneur pour - conserver les en-têtes de flux - ita: Questo è destinato all'uso quando non si dispone di un contenitore per mantenere - le intestazioni dello stream per voi - spa: Está pensado para ser utilizado cuando no se dispone de un contenedor para - guardar los encabezados de la corriente para usted - chs: This is intended for use when you do not have a container to keep the stream - headers for you. + deu: Dies ist für die Verwendung vorgesehen, wenn kein Container vorhanden ist, der die Stream-Header aufbewahrt + eng: This is intended for use when you do not have a container to keep the stream headers for you + fra: Il est destiné à être utilisé lorsque vous ne disposez pas d'un conteneur pour conserver les en-têtes de flux + ita: Questo è destinato all'uso quando non si dispone di un contenitore per mantenere le intestazioni dello stream per voi + spa: Está pensado para ser utilizado cuando no se dispone de un contenedor para guardar los encabezados de la corriente para usted + chs: This is intended for use when you do not have a container to keep the stream headers for you. jpn: ストリームヘッダーを保持してくれるコンテナがない場合に使用することを想定しています。 - rus: Это предназначено для использования, когда у вас нет контейнера для хранения - заголовков потока. - por: Isso é destinado para uso quando você não tem um contêiner para manter os cabeçalhos - do stream para você - swe: Detta är avsett att användas när du inte har en behållare som behåller stream - headers åt dig. - pol: To jest przeznaczone do użycia, gdy nie masz kontenera, który przechowuje nagłówki - strumienia dla Ciebie - ukr: Призначено для використання, коли у вас немає контейнера для зберігання заголовків - потоків + rus: Это предназначено для использования, когда у вас нет контейнера для хранения заголовков потока. + por: Isso é destinado para uso quando você não tem um contêiner para manter os cabeçalhos do stream para você + swe: Detta är avsett att användas när du inte har en behållare som behåller stream headers åt dig. + pol: To jest przeznaczone do użycia, gdy nie masz kontenera, który przechowuje nagłówki strumienia dla Ciebie + ukr: Призначено для використання, коли у вас немає контейнера для зберігання заголовків потоків kor: 스트림 헤더를 보관할 컨테이너가 없을 때 사용하기 위한 것입니다. - ron: Acest lucru este destinat utilizării atunci când nu aveți un container care - să păstreze anteturile fluxului pentru dvs. + ron: Acest lucru este destinat utilizării atunci când nu aveți un container care să păstreze anteturile fluxului pentru dvs. This is used for ultra-high bitrates with zero loss of quality.: deu: Dies wird für ultrahohe Bitraten ohne Qualitätsverluste verwendet. eng: This is used for ultra-high bitrates with zero loss of quality. fra: Il est utilisé pour les débits binaires ultra-élevés sans perte de qualité. - ita: Questo viene utilizzato per bitrate elevatissimi con perdita di qualità pari - a zero. - spa: Se utiliza para velocidades de transmisión ultra altas con cero pérdida de - calidad. + ita: Questo viene utilizzato per bitrate elevatissimi con perdita di qualità pari a zero. + spa: Se utiliza para velocidades de transmisión ultra altas con cero pérdida de calidad. chs: 用于实现无质量损失的超高比特率编码。 jpn: これは、超高ビットレートで品質を損なうことなく使用されます。 rus: Используется для сверхвысоких битрейтов с нулевой потерей качества. @@ -5387,8 +5170,7 @@ Use --bframes 0 to force all P/I low-latency encodes.: por: Use --bframes 0 para forçar todas as codificações P/I de baixa latência. swe: Använd --bframes 0 för att tvinga fram alla P/I-kodningar med låg latenstid. pol: Użyj --bframes 0 by wymusić wszystkie kodowania P/I o niskich opóźnieniach. - ukr: Використовуйте --bframes 0 для примусового використання усіх P/I кодувань з - низькою затримкою. + ukr: Використовуйте --bframes 0 для примусового використання усіх P/I кодувань з низькою затримкою. kor: 모든 P/I 저지연 인코딩을 강제하려면 --bframes 0을 사용합니다. ron: Utilizați --bframes 0 pentru a forța toate codificările P/I cu latență redusă. Use B frames as references: @@ -5455,55 +5237,37 @@ Useful when you have the "Too many packets buffered for output stream" error: deu: Nützlich, wenn der Fehler "Too many packets buffered for output stream" auftritt eng: Useful when you have the "Too many packets buffered for output stream" error fra: Utile lorsque vous avez l'erreur "Too many packets buffered for output stream - ita: Utile quando si ha l'errore "Troppi pacchetti bufferizzati per il flusso di - uscita". - spa: Útil cuando se tiene el error "Demasiados paquetes almacenados en la memoria - intermedia para el flujo de salida". + ita: Utile quando si ha l'errore "Troppi pacchetti bufferizzati per il flusso di uscita". + spa: Útil cuando se tiene el error "Demasiados paquetes almacenados en la memoria intermedia para el flujo de salida". chs: 当出现“输出流缓冲的数据包太多”(Too many packets buffered for output stream)错误时有用。 jpn: '"Too many packets buffered for output stream"というエラーが発生した場合で役に立つ。' - rus: Полезно, когда у вас возникает ошибка "Слишком много пакетов буферизировано - для выходного потока". + rus: Полезно, когда у вас возникает ошибка "Слишком много пакетов буферизировано для выходного потока". por: Útil quando você tem o erro "Too many packets buffered for output stream" swe: Användbart när du får felet "För många paket buffras för utdataströmmen". pol: Przydatne, gdy masz błąd "Zbyt wiele pakietów buforowanych dla strumienia wyjściowego". - ukr: Корисно, коли у вас виникає помилка "Занадто багато пакетів буферизовано для - вихідного потоку" + ukr: Корисно, коли у вас виникає помилка "Занадто багато пакетів буферизовано для вихідного потоку" kor: '"출력 스트림에 버퍼링된 패킷이 너무 많습니다" 오류가 발생할 때 유용합니다.' - ron: Util atunci când aveți eroarea "Prea multe pachete stocate în buffer pentru - fluxul de ieșire". + ron: Util atunci când aveți eroarea "Prea multe pachete stocate în buffer pentru fluxul de ieșire". Using 1 or 2 will increase encoding speed at the expense of having some impact on quality and rate control accuracy.: - deu: Die Verwendung von 1 oder 2 erhöht die Kodiergeschwindigkeit auf Kosten einer - gewissen Auswirkung auf die Qualität und die Genauigkeit der Ratenkontrolle. - eng: Using 1 or 2 will increase encoding speed at the expense of having some impact - on quality and rate control accuracy. - fra: L'utilisation de 1 ou 2 augmentera la vitesse d'encodage au détriment d'un - certain impact sur la qualité et la précision du contrôle des taux. - ita: L'uso di 1 o 2 aumenterà la velocità di codifica a scapito di un certo impatto - sulla qualità e sulla precisione del controllo del tasso. - spa: El uso de 1 o 2 aumentará la velocidad de codificación a expensas de tener - algún impacto en la calidad y la precisión del control de la tasa. + deu: Die Verwendung von 1 oder 2 erhöht die Kodiergeschwindigkeit auf Kosten einer gewissen Auswirkung auf die Qualität und die Genauigkeit der Ratenkontrolle. + eng: Using 1 or 2 will increase encoding speed at the expense of having some impact on quality and rate control accuracy. + fra: L'utilisation de 1 ou 2 augmentera la vitesse d'encodage au détriment d'un certain impact sur la qualité et la précision du contrôle des taux. + ita: L'uso di 1 o 2 aumenterà la velocità di codifica a scapito di un certo impatto sulla qualità e sulla precisione del controllo del tasso. + spa: El uso de 1 o 2 aumentará la velocidad de codificación a expensas de tener algún impacto en la calidad y la precisión del control de la tasa. chs: 使用1或2会提高编码速度,但代价是对质量和码率控制精度有一定影响。 jpn: 1または2を使用すると、画質やレートコントロールの精度に多少の影響を与えますが、エンコード速度が向上します。 - rus: Использование 1 или 2 увеличит скорость кодирования за счет некоторого влияния - на качество и точность контроля скорости. - por: Usar 1 ou 2 aumentará a velocidade de codificação às custas de ter algum impacto - na qualidade e na precisão do controle de taxa. - swe: Om du använder 1 eller 2 ökar kodningshastigheten på bekostnad av en viss inverkan - på kvaliteten och noggrannheten i hastighetsregleringen. - pol: Użycie 1 lub 2 zwiększy szybkość kodowania kosztem pewnego wpływu na jakość - i dokładność kontroli tempa. - ukr: Використання 1 або 2 збільшить швидкість кодування за рахунок певного впливу - на якість і точність контролю швидкості. + rus: Использование 1 или 2 увеличит скорость кодирования за счет некоторого влияния на качество и точность контроля скорости. + por: Usar 1 ou 2 aumentará a velocidade de codificação às custas de ter algum impacto na qualidade e na precisão do controle de taxa. + swe: Om du använder 1 eller 2 ökar kodningshastigheten på bekostnad av en viss inverkan på kvaliteten och noggrannheten i hastighetsregleringen. + pol: Użycie 1 lub 2 zwiększy szybkość kodowania kosztem pewnego wpływu na jakość i dokładność kontroli tempa. + ukr: Використання 1 або 2 збільшить швидкість кодування за рахунок певного впливу на якість і точність контролю швидкості. kor: 1 또는 2를 사용하면 인코딩 속도가 빨라지지만 품질 및 속도 제어 정확도에 약간의 영향을 줄 수 있습니다. - ron: Utilizarea a 1 sau 2 va crește viteza de codificare în detrimentul unui anumit - impact asupra calității și a preciziei controlului ratei. + ron: Utilizarea a 1 sau 2 va crește viteza de codificare în detrimentul unui anumit impact asupra calității și a preciziei controlului ratei. Using a single frame thread gives a slight improvement in compression,: - deu: Die Verwendung eines einzigen Frame-Threads führt zu einer leichten Verbesserung - der Komprimierung, + deu: Die Verwendung eines einzigen Frame-Threads führt zu einer leichten Verbesserung der Komprimierung, eng: Using a single frame thread gives a slight improvement in compression, fra: L'utilisation d'un seul fil de trame donne une légère amélioration de la compression, - ita: L'utilizzo di una filettatura a telaio singolo offre un leggero miglioramento - della compressione, + ita: L'utilizzo di una filettatura a telaio singolo offre un leggero miglioramento della compressione, spa: Usar un solo hilo de cuadro da una ligera mejora en la compresión, chs: 使用单帧线程会使压缩率略有提高, jpn: シングルフレームスレッドを使用すると、圧縮率が少し向上します。 @@ -5682,41 +5446,30 @@ View GUI Debug Logs: 'WARNING: This will take much longer and result in a larger file': deu: 'WARNUNG: Dies dauert viel länger und führt zu einer größeren Datei' eng: 'WARNING: This will take much longer and result in a larger file' - fra: 'AVERTISSEMENT : Cela prendra beaucoup plus de temps et entraînera un dossier - plus volumineux' + fra: 'AVERTISSEMENT : Cela prendra beaucoup plus de temps et entraînera un dossier plus volumineux' ita: 'ATTENZIONE: Ci vorrà molto più tempo e il risultato sarà un file più grande' spa: 'ADVERTENCIA: Esto tomará mucho más tiempo y resultará en un archivo más grande' chs: 警告:这将导致转换用时大幅延长,输出文件体积增大。 jpn: 注意喚起:結果としてこの作業はかなりの時間がかかり、ファイルも大きくなります。 - rus: 'ВНИМАНИЕ: Это займет гораздо больше времени и приведет к увеличению размера - файла' + rus: 'ВНИМАНИЕ: Это займет гораздо больше времени и приведет к увеличению размера файла' por: 'AVISO: Isso levará muito mais tempo e resultará em um arquivo maior' swe: 'VARNING: Detta tar mycket längre tid och resulterar i en större fil.' pol: 'OSTRZEŻENIE: To zajmie znacznie więcej czasu i spowoduje, że plik będzie większy' - ukr: 'ПОПЕРЕДЖЕННЯ: Це займе набагато більше часу і призведе до збільшення розміру - файлу' + ukr: 'ПОПЕРЕДЖЕННЯ: Це займе набагато більше часу і призведе до збільшення розміру файлу' kor: '경고: 이렇게 하면 시간이 훨씬 더 오래 걸리고 파일 크기가 커집니다.' - ron: 'AVERTISMENT: Acest lucru va dura mult mai mult timp și va avea ca rezultat - un fișier mai mare.' + ron: 'AVERTISMENT: Acest lucru va dura mult mai mult timp și va avea ca rezultat un fișier mai mare.' Wait for the current command to finish, and stop the next command from processing: - deu: Warten, bis der aktuelle Befehl beendet ist, und die Verarbeitung des nächsten - Befehls stoppen + deu: Warten, bis der aktuelle Befehl beendet ist, und die Verarbeitung des nächsten Befehls stoppen eng: Wait for the current command to finish, and stop the next command from processing - fra: Attendre la fin de la commande en cours et arrêter le traitement de la commande - suivante - ita: Attendere che il comando corrente finisca e interrompere l'elaborazione del - comando successivo - spa: Espere a que termine el comando actual y detenga el procesamiento del siguiente - comando... + fra: Attendre la fin de la commande en cours et arrêter le traitement de la commande suivante + ita: Attendere che il comando corrente finisca e interrompere l'elaborazione del comando successivo + spa: Espere a que termine el comando actual y detenga el procesamiento del siguiente comando... chs: 等待当前命令完成,之后暂不处理后面的命令 jpn: 現在のコマンドが終了するのを待ち、次のコマンドの処理を停止する - rus: Дождитесь завершения выполнения текущей команды и остановите обработку следующей - команды + rus: Дождитесь завершения выполнения текущей команды и остановите обработку следующей команды por: Aguardar o término do comando atual e interromper o próximo comando - swe: Vänta på att det aktuella kommandot avslutas och stoppa behandlingen av nästa - kommando. - pol: Poczekaj na zakończenie bieżącego polecenia i zatrzymaj przetwarzanie następnego - polecenia. + swe: Vänta på att det aktuella kommandot avslutas och stoppa behandlingen av nästa kommando. + pol: Poczekaj na zakończenie bieżącego polecenia i zatrzymaj przetwarzanie następnego polecenia. ukr: Дочекайтеся завершення поточної команди і зупиніть обробку наступної команди kor: 현재 명령이 완료될 때까지 기다렸다가 다음 명령의 처리를 중지합니다. ron: Așteaptă finalizarea comenzii curente și oprește procesarea următoarei comenzi @@ -5811,68 +5564,48 @@ Will fix first subtitle track to not be default: kor: 첫 번째 자막 트랙이 기본값이 아닌 것으로 수정됩니다. ron: Va repara prima piesă de subtitrare pentru a nu fi implicită With b-adapt 0, the GOP structure is fixed based on the values of --keyint and --bframes.: - deu: Bei b-adapt 0 wird die GOP-Struktur anhand der Werte von --keyint und --bframes - festgelegt. - eng: With b-adapt 0, the GOP structure is fixed based on the values of --keyint - and --bframes. - fra: Avec l'adaptateur b 0, la structure du GOP est fixée sur la base des valeurs - de --keyint et --bframes. + deu: Bei b-adapt 0 wird die GOP-Struktur anhand der Werte von --keyint und --bframes festgelegt. + eng: With b-adapt 0, the GOP structure is fixed based on the values of --keyint and --bframes. + fra: Avec l'adaptateur b 0, la structure du GOP est fixée sur la base des valeurs de --keyint et --bframes. ita: Con b-adapt 0, la struttura GOP è fissa in base ai valori di --keyint e --bframes. - spa: Con b-adaptado 0, la estructura del GOP se fija en base a los valores de --keyint - y --bframes. + spa: Con b-adaptado 0, la estructura del GOP se fija en base a los valores de --keyint y --bframes. chs: 当b-adapt为0时,图像组(Group Of Pictures, GOP)结构是根据--keyint和--bframes的值确定并固定的。 jpn: b-adapt 0では、--keyintおよび--bframesの値に基づいてGOP構造が固定されます。 - rus: При b-adapt 0 структура GOP фиксируется на основе значений параметров --keyint - и --bframes. - por: Com b-adapt 0, a estrutura GOP é definida com base nos valores de --keyint - e --bframes. - swe: Med b-adapt 0 fastställs GOP-strukturen baserat på värdena för --keyint och - --bframes. - pol: Przy b-adapt 0, struktura GOP jest ustalana na podstawie wartości --keyint - i --bframes. + rus: При b-adapt 0 структура GOP фиксируется на основе значений параметров --keyint и --bframes. + por: Com b-adapt 0, a estrutura GOP é definida com base nos valores de --keyint e --bframes. + swe: Med b-adapt 0 fastställs GOP-strukturen baserat på värdena för --keyint och --bframes. + pol: Przy b-adapt 0, struktura GOP jest ustalana na podstawie wartości --keyint i --bframes. ukr: При b-adapt 0 структура GOP фіксується на основі значень --keyint та --bframes. kor: b-adapt 0을 사용하면 --keyint 및 --bframes의 값을 기반으로 GOP 구조가 고정됩니다. ron: Cu b-adapt 0, structura GOP este fixată pe baza valorilor din --keyint și --bframes. With b-adapt 1 a light lookahead is used to choose B frame placement.: - deu: Mit b-adapt 1 wird ein wenig vorausgeschaut, um die B-Frame-Platzierung zu - wählen. + deu: Mit b-adapt 1 wird ein wenig vorausgeschaut, um die B-Frame-Platzierung zu wählen. eng: With b-adapt 1 a light lookahead is used to choose B frame placement. - fra: Avec l'adaptateur b 1, un léger regard est utilisé pour choisir le placement - des images B. - ita: Con b-adapt 1 si usa un leggero lookahead per scegliere il posizionamento del - telaio B. - spa: Con b-adapt 1 se utiliza un lookahead ligero para elegir la colocación del - marco B. + fra: Avec l'adaptateur b 1, un léger regard est utilisé pour choisir le placement des images B. + ita: Con b-adapt 1 si usa un leggero lookahead per scegliere il posizionamento del telaio B. + spa: Con b-adapt 1 se utiliza un lookahead ligero para elegir la colocación del marco B. chs: 当b-adapt为1时,通过轻量级的lookahead来选择B帧的位置。 jpn: b-adapt 1では、Bフレームの配置を選択するために軽いルックアヘッドが使用されます。 - rus: При использовании b-adapt 1 для выбора размещения B-кадра используется легкая - заставка. - por: Com b-adapt 1, um lookahead leve é usado para escolher o posicionamento do - B-frame. + rus: При использовании b-adapt 1 для выбора размещения B-кадра используется легкая заставка. + por: Com b-adapt 1, um lookahead leve é usado para escolher o posicionamento do B-frame. swe: Med b-adapt 1 används en lätt framåtblick för att välja B-ramens placering. - pol: W przypadku b-adapt 1 do wyboru położenia ramki B wykorzystywana jest lekka - perspektywa czasowa. + pol: W przypadku b-adapt 1 do wyboru położenia ramki B wykorzystywana jest lekka perspektywa czasowa. ukr: З b-adapt 1 використовується світлий орієнтир для вибору розміщення кадру B. kor: b-adapt 1을 사용하면 라이트 룩헤드를 사용하여 B 프레임 배치를 선택할 수 있습니다. - ron: Cu b-adapt 1 se utilizează o perspectivă ușoară pentru a alege plasarea cadrului - B. + ron: Cu b-adapt 1 se utilizează o perspectivă ușoară pentru a alege plasarea cadrului B. With b-adapt 2 (trellis) a viterbi B path selection is performed: deu: Bei b-adapt 2 (Trellis) wird eine Viterbi-B-Pfadauswahl durchgeführt eng: With b-adapt 2 (trellis) a viterbi B path selection is performed - fra: Avec l'adaptateur b 2 (treillis), une sélection du chemin B de Viterbi est - effectuée - ita: Con b-adapt 2 (traliccio) viene eseguita una selezione del percorso viterbi - B + fra: Avec l'adaptateur b 2 (treillis), une sélection du chemin B de Viterbi est effectuée + ita: Con b-adapt 2 (traliccio) viene eseguita una selezione del percorso viterbi B spa: Con b-adapt 2 (espaldera) se realiza una selección de trayectoria B viterbi chs: 对于b-adapt 2 (trellis),则执行viterbi B path selection。 jpn: b-adapt 2 (トレリス)では、ビタビB経路選択を行います。 rus: С помощью b-адаптации 2 (решетка) выполняется выбор пути Витерби B por: Com b-adapt 2 (trellis), é realizada uma seleção de caminho B viterbi swe: Med b-adapt 2 (trellis) utförs ett viterbi B-vägval. - pol: W przypadku b-adapt 2 (trellis) przeprowadzany jest wybór ścieżki B metodą - viterbi - ukr: За допомогою b-adapt 2 (решітка) виконується вибір шляху за критерієм вітербі - B + pol: W przypadku b-adapt 2 (trellis) przeprowadzany jest wybór ścieżki B metodą viterbi + ukr: За допомогою b-adapt 2 (решітка) виконується вибір шляху за критерієм вітербі B kor: b-adapt 2(트 렐리 스)를 사용하면 비터비 B 경로 선택이 수행됩니다. ron: Cu b-adapt 2 (trellis) se realizează o selecție a căii viterbi B. Work Directory: @@ -5953,19 +5686,14 @@ already exists: and the amount of work performed by the full trellis version of --b-adapt lookahead.: deu: und der Arbeitsaufwand, der bei --b-adapt 2 (full trellis) durchgeführt wird. eng: and the amount of work performed by the full trellis version of --b-adapt lookahead. - fra: et la quantité de travail effectuée par la version complète en treillis de - --b-adapt lookahead. - ita: e la quantità di lavoro svolto dalla versione completa della versione a traliccio - di --b-adattate lookahead. - spa: y la cantidad de trabajo realizado por la versión completa de la espaldera - de --b-adaptado lookahead. + fra: et la quantité de travail effectuée par la version complète en treillis de --b-adapt lookahead. + ita: e la quantità di lavoro svolto dalla versione completa della versione a traliccio di --b-adattate lookahead. + spa: y la cantidad de trabajo realizado por la versión completa de la espaldera de --b-adaptado lookahead. chs: lookahead在full(trellis)模式下执行的工作量有二次方的影响。 jpn: と、フルtrellis版の--b-adapt lookaheadによる作業量を示しています。 rus: и объем работы, выполняемой версией полной решетки --b-adapt lookahead. - por: e a quantidade de trabalho realizado pela versão trellis completa de --b-adapt - lookahead. - swe: och den mängd arbete som utförs av den fullständiga trellisversionen av --b-adapt - lookahead. + por: e a quantidade de trabalho realizado pela versão trellis completa de --b-adapt lookahead. + swe: och den mängd arbete som utförs av den fullständiga trellisversionen av --b-adapt lookahead. pol: oraz ilość pracy wykonanej przez wersję full trellis z --b-adapt lookahead. ukr: і обсяг роботи, який виконує повна решітчаста версія --b-adapt lookahead. kor: 의 전체 트 렐리 스 버전이 수행하는 작업량과 --b- 적응 룩어헤드가 수행하는 작업량입니다. @@ -6063,23 +5791,18 @@ b-adapt: 'b-adapt: Set the level of effort in determining B frame placement.': deu: 'b-adapt: Festlegen des Grades des Aufwands bei der Bestimmung der B-Frame-Platzierung.' eng: 'b-adapt: Set the level of effort in determining B frame placement.' - fra: "b-adapt : Fixe le niveau d'effort pour déterminer le placement de l'image - B." - ita: 'b-adatta: Impostare il livello di sforzo nel determinare il posizionamento - del telaio B.' - spa: 'b-adaptado: Establece el nivel de esfuerzo para determinar la colocación del - marco B.' + fra: "b-adapt : Fixe le niveau d'effort pour déterminer le placement de l'image B." + ita: 'b-adatta: Impostare il livello di sforzo nel determinare il posizionamento del telaio B.' + spa: 'b-adaptado: Establece el nivel de esfuerzo para determinar la colocación del marco B.' chs: b-adapt:对决定B帧位置时的工作量水平进行调整。 jpn: B-ADAPTBフレームの配置を決定する際の努力の度合いを設定します。 rus: 'b-adapt: Установите уровень усилий при определении размещения B кадра.' por: 'b-adapt: Definir o nível de esforço na determinação do posicionamento do B-frame.' - swe: 'b-anpassad: Ange nivån på ansträngningen när det gäller att bestämma placeringen - av B-ramar.' + swe: 'b-anpassad: Ange nivån på ansträngningen när det gäller att bestämma placeringen av B-ramar.' pol: 'b-adapt: Ustaw poziom wysiłku przy określaniu umiejscowienia ramki B.' ukr: 'b-adapt: Встановити рівень зусиль при визначенні розміщення B-фреймів.' kor: 'b-adapt: B 프레임 배치를 결정할 때 노력 수준을 설정합니다.' - ron: 'b-adapt: Stabilește nivelul de efort în determinarea poziționării cadrului - B.' + ron: 'b-adapt: Stabilește nivelul de efort în determinarea poziționării cadrului B.' bad micro value: deu: schlechter Micro-Wert eng: bad micro value @@ -6096,30 +5819,20 @@ bad micro value: kor: 잘못된 마이크로 값 ron: valoare micro proastă best is recommended if you have lots of time and want the best compression efficiency.: - deu: best wird empfohlen, wenn viel Zeit zur Verfügung steht und die beste Komprimierungseffizienz - gewünscht ist. - eng: best is recommended if you have lots of time and want the best compression - efficiency. - fra: Le meilleur est recommandé si vous avez beaucoup de temps et si vous voulez - obtenir la meilleure efficacité de compression. - ita: Il migliore è consigliato se si ha molto tempo a disposizione e si desidera - la migliore efficienza di compressione. - spa: Se recomienda el mejor si tienes mucho tiempo y quieres la mejor eficiencia - de compresión. + deu: best wird empfohlen, wenn viel Zeit zur Verfügung steht und die beste Komprimierungseffizienz gewünscht ist. + eng: best is recommended if you have lots of time and want the best compression efficiency. + fra: Le meilleur est recommandé si vous avez beaucoup de temps et si vous voulez obtenir la meilleure efficacité de compression. + ita: Il migliore è consigliato se si ha molto tempo a disposizione e si desidera la migliore efficienza di compressione. + spa: Se recomienda el mejor si tienes mucho tiempo y quieres la mejor eficiencia de compresión. chs: 在时间充裕且希望获得最佳压缩效率的情况下,建议使用best。 jpn: 時間に余裕があり、最高の圧縮効率を求める場合には、bestを推奨します。 - rus: рекомендуется, если у вас много времени и вы хотите получить максимальную эффективность - сжатия. - por: melhor é recomendado se você tiver muito tempo e quiser a melhor eficiência - de compressão. + rus: рекомендуется, если у вас много времени и вы хотите получить максимальную эффективность сжатия. + por: melhor é recomendado se você tiver muito tempo e quiser a melhor eficiência de compressão. swe: rekommenderas om du har mycket tid och vill ha bästa möjliga kompressionseffektivitet. - pol: najlepszy jest zalecany, jeśli masz dużo czasu i chcesz uzyskać najlepszą wydajność - kompresji. - ukr: best рекомендується, якщо у вас багато часу і ви хочете отримати найкращу ефективність - стиснення. + pol: najlepszy jest zalecany, jeśli masz dużo czasu i chcesz uzyskać najlepszą wydajność kompresji. + ukr: best рекомендується, якщо у вас багато часу і ви хочете отримати найкращу ефективність стиснення. kor: 는 시간이 많고 최상의 압축 효율을 원하는 경우 권장됩니다. - ron: cel mai bun este recomandat dacă aveți mult timp la dispoziție și doriți cea - mai bună eficiență a compresiei. + ron: cel mai bun este recomandat dacă aveți mult timp la dispoziție și doriți cea mai bună eficiență a compresiei. bframes: deu: bframes eng: bframes @@ -6301,8 +6014,7 @@ data tracks found: kor: '프레임 스레드: 동시에 인코딩되는 프레임 수입니다.' ron: 'frame-threads: Numărul de cadre codificate simultan.' good is the default and recommended for most applications: - deu: good ist die Standardeinstellung und wird für die meisten Anwendungszwecke - empfohlen + deu: good ist die Standardeinstellung und wird für die meisten Anwendungszwecke empfohlen eng: good is the default and recommended for most applications fra: good est la valeur par défaut et est recommandé pour la plupart des applications ita: buono è il valore predefinito e raccomandato per la maggior parte delle applicazioni @@ -6317,28 +6029,20 @@ good is the default and recommended for most applications: kor: 좋음이 기본값이며 대부분의 애플리케이션에 권장됩니다. ron: good este implicit și recomandat pentru majoritatea aplicațiilor 'hdr10-opt: Enable block-level luma and chroma QP optimization for HDR10 content.': - deu: 'hdr10-opt: Aktiviert die Luma- und Chroma-QP-Optimierung auf Blockebene für - HDR10-Inhalte.' + deu: 'hdr10-opt: Aktiviert die Luma- und Chroma-QP-Optimierung auf Blockebene für HDR10-Inhalte.' eng: 'hdr10-opt: Enable block-level luma and chroma QP optimization for HDR10 content.' - fra: "hdr10-opt : Activer l'optimisation QP luma et chroma au niveau des blocs pour - le contenu HDR10." - ita: "hdr10-opt: Attivare l'ottimizzazione a livello di blocco luma e chroma QP - per i contenuti HDR10." - spa: 'hdr10-opt: Habilitar la optimización de la luma a nivel de bloque y la QP - cromática para el contenido de HDR10.' + fra: "hdr10-opt : Activer l'optimisation QP luma et chroma au niveau des blocs pour le contenu HDR10." + ita: "hdr10-opt: Attivare l'ottimizzazione a livello di blocco luma e chroma QP per i contenuti HDR10." + spa: 'hdr10-opt: Habilitar la optimización de la luma a nivel de bloque y la QP cromática para el contenido de HDR10.' chs: hdr10-opt:启用HDR10内容的块级亮度和色度量化参数(Quantization Parameter, QP)优化。 jpn: HDR10-OPT:HDR10コンテンツに対して、ブロックレベルでのルーマおよびクロマのQP最適化を有効にします。 - rus: 'hdr10-opt: Включение оптимизации QP на уровне яркости и цветности блока для - контента HDR10.' - por: 'hdr10-opt: Ativar a otimização QP de luma e crominância em nível de bloco - para conteúdo HDR10.' + rus: 'hdr10-opt: Включение оптимизации QP на уровне яркости и цветности блока для контента HDR10.' + por: 'hdr10-opt: Ativar a otimização QP de luma e crominância em nível de bloco para conteúdo HDR10.' swe: 'hdr10-opt: Aktivera QP-optimering på blocknivå för luma och kroma för HDR10-innehåll.' pol: 'hdr10-opt: Włącza optymalizację QP na poziomie bloku dla zawartości HDR10.' - ukr: 'hdr10-opt: Увімкнути оптимізацію QP люмінесценції та кольоровості на рівні - блоків для контенту з роздільною здатністю HDR10.' + ukr: 'hdr10-opt: Увімкнути оптимізацію QP люмінесценції та кольоровості на рівні блоків для контенту з роздільною здатністю HDR10.' kor: 'HDR10-opt: HDR10 콘텐츠에 대해 블록 레벨 루마 및 크로마 QP 최적화를 활성화합니다.' - ron: 'hdr10-opt: Activați optimizarea QP la nivel de bloc pentru luma și croma pentru - conținutul HDR10.' + ron: 'hdr10-opt: Activați optimizarea QP la nivel de bloc pentru luma și croma pentru conținutul HDR10.' 'hdr10: Force signaling of HDR10 parameters in SEI packets.': deu: 'hdr10: Erzwingt die Signalisierung von HDR10-Parametern in SEI-Paketen.' eng: 'hdr10: Force signaling of HDR10 parameters in SEI packets.' @@ -6387,27 +6091,18 @@ installer: 'intra-refresh: Enables Periodic Intra Refresh(PIR) instead of keyframe insertion.': deu: 'intra-refresh: Aktiviert Periodic Intra Refresh(PIR) anstelle der Keyframe-Einblendung.' eng: 'intra-refresh: Enables Periodic Intra Refresh(PIR) instead of keyframe insertion.' - fra: "intra-refresh : Active le rafraîchissement périodique intra (PIR) au lieu - de l'insertion d'images clés." - ita: "intra-refresh: Abilita l'Intra Refresh(PIR) periodico invece dell'inserimento - del keyframe." - spa: 'intra-refresco: Habilita el Refresco Intra Periódico (PIR) en lugar de la - inserción de fotogramas clave.' + fra: "intra-refresh : Active le rafraîchissement périodique intra (PIR) au lieu de l'insertion d'images clés." + ita: "intra-refresh: Abilita l'Intra Refresh(PIR) periodico invece dell'inserimento del keyframe." + spa: 'intra-refresco: Habilita el Refresco Intra Periódico (PIR) en lugar de la inserción de fotogramas clave.' chs: intra-refresh:启用周期性帧内刷新(Periodic Intra Refresh, PIR)代替关键帧插入。 jpn: イントラリフレシュキーフレーム挿入の代わりにPIR(Periodic Intra Refresh)を有効にします。 - rus: 'intra-refresh: Включает периодическое внутреннее обновление (PIR) вместо вставки - ключевого кадра.' - por: 'intra-refresh: Habilita o Periodic Intra Refresh (PIR) ao invés da inserção - de keyframes.' - swe: 'intra-återuppdatering: Aktiverar Periodic Intra Refresh (PIR) i stället för - att infoga nyckelramar.' - pol: 'intra-refresh: Włącza Periodic Intra Refresh(PIR) zamiast wstawiania klatek - kluczowych.' - ukr: 'внутрішнє оновлення: Увімкнути періодичне внутрішнє оновлення (PIR) замість - вставки ключових кадрів.' + rus: 'intra-refresh: Включает периодическое внутреннее обновление (PIR) вместо вставки ключевого кадра.' + por: 'intra-refresh: Habilita o Periodic Intra Refresh (PIR) ao invés da inserção de keyframes.' + swe: 'intra-återuppdatering: Aktiverar Periodic Intra Refresh (PIR) i stället för att infoga nyckelramar.' + pol: 'intra-refresh: Włącza Periodic Intra Refresh(PIR) zamiast wstawiania klatek kluczowych.' + ukr: 'внутрішнє оновлення: Увімкнути періодичне внутрішнє оновлення (PIR) замість вставки ключових кадрів.' kor: '인트라-리프레시: 키프레임 삽입 대신 주기적 인트라 새로 고침(PIR)을 활성화합니다.' - ron: 'intra-refresh: Activează Periodic Intra Refresh (PIR) în locul inserției de - cadre cheie.' + ron: 'intra-refresh: Activează Periodic Intra Refresh (PIR) în locul inserției de cadre cheie.' is a default profile and will not be removed: deu: ist ein Standardprofil und wird nicht entfernt eng: is a default profile and will not be removed @@ -6454,32 +6149,20 @@ it will generally just increase memory use.: kor: 를 사용하면 일반적으로 메모리 사용량만 증가합니다. ron: în general, aceasta va crește doar utilizarea memoriei. 'keyint: Enable Intra-Encoding by forcing keyframes every 1 second (Blu-ray spec)': - deu: 'keyint: Aktiviert Intra-Encoding durch Erzwingen von Keyframes alle 1 Sekunde - (Blu-ray-Spezifikation)' - eng: 'keyint: Enable Intra-Encoding by forcing keyframes every 1 second (Blu-ray - spec)' - fra: "keyint : Activer l'intra-encodage en forçant les images clés toutes les 1 - seconde (spécification Blu-ray)" - ita: "keyint: Attivare l'Intra-Encoding forzando i keyframe ogni 1 secondo (Blu-ray - spec)" - spa: 'keyint: Habilitar la intracodificación forzando los fotogramas clave cada - 1 segundo (Blu-ray spec)' - chs: 'keyint: Enable Intra-Encoding by forcing keyframes every 1 second (Blu-ray - spec)' + deu: 'keyint: Aktiviert Intra-Encoding durch Erzwingen von Keyframes alle 1 Sekunde (Blu-ray-Spezifikation)' + eng: 'keyint: Enable Intra-Encoding by forcing keyframes every 1 second (Blu-ray spec)' + fra: "keyint : Activer l'intra-encodage en forçant les images clés toutes les 1 seconde (spécification Blu-ray)" + ita: "keyint: Attivare l'Intra-Encoding forzando i keyframe ogni 1 secondo (Blu-ray spec)" + spa: 'keyint: Habilitar la intracodificación forzando los fotogramas clave cada 1 segundo (Blu-ray spec)' + chs: 'keyint: Enable Intra-Encoding by forcing keyframes every 1 second (Blu-ray spec)' jpn: keyint:1秒ごとにキーフレームを強制的に生成してイントラエンコードを有効にする(Blu-ray仕様)。 - rus: 'keyint: Включить внутреннее кодирование путем принудительного воспроизведения - ключевых кадров каждые 1 секунду (спецификация Blu-ray).' - por: 'keyint: Habilitar Intra-Encoding forçando keyframes a cada 1 segundo (especificação - Blu-ray)' - swe: 'keyint: Aktivera intrakodning genom att tvinga fram keyframes var 1 sekund - (Blu-ray-specifikation)' - pol: 'keyint: Włącz Intra-Encoding przez wymuszanie klatek kluczowych co 1 sekundę - (specyfikacja Blu-ray)' - ukr: 'keyint: Увімкнути внутрішнє кодування, примусово створюючи ключові кадри кожну - 1 секунду (специфікація Blu-ray)' + rus: 'keyint: Включить внутреннее кодирование путем принудительного воспроизведения ключевых кадров каждые 1 секунду (спецификация Blu-ray).' + por: 'keyint: Habilitar Intra-Encoding forçando keyframes a cada 1 segundo (especificação Blu-ray)' + swe: 'keyint: Aktivera intrakodning genom att tvinga fram keyframes var 1 sekund (Blu-ray-specifikation)' + pol: 'keyint: Włącz Intra-Encoding przez wymuszanie klatek kluczowych co 1 sekundę (specyfikacja Blu-ray)' + ukr: 'keyint: Увімкнути внутрішнє кодування, примусово створюючи ключові кадри кожну 1 секунду (специфікація Blu-ray)' kor: 'keyint: 1초마다 키프레임을 강제로 인코딩하여 인트라 인코딩 활성화(블루레이 사양)' - ron: 'keyint: Activează Intra-Encoding prin forțarea cadrelor cheie la fiecare 1 - secundă (specificații Blu-ray)' + ron: 'keyint: Activează Intra-Encoding prin forțarea cadrelor cheie la fiecare 1 secundă (specificații Blu-ray)' lossless: deu: verlustfrei eng: lossless @@ -6496,32 +6179,20 @@ lossless: kor: 무손실 ron: fără pierderi 'max_muxing_queue_size: Raise to fix "Too many packets buffered for output stream" error': - deu: 'max_muxing_queue_size: Erhöhen, um den Fehler "Too many packets buffered for - output stream" zu beheben' - eng: 'max_muxing_queue_size: Raise to fix "Too many packets buffered for output - stream" error' - fra: "max_muxing_queue_size : Augmenter pour corriger l'erreur \"Too many packets - buffered for output stream" - ita: "max_muxing_queue_size: Alzare per correggere l'errore \"Troppi pacchetti bufferizzati - per il flusso di uscita" - spa: 'tamaño_muxing_queue_size: Subir para corregir el error "Demasiados paquetes - almacenados en la memoria intermedia para el flujo de salida".' - chs: max_muxing_queue_size:提高以解决 "输出流缓冲的数据包太多(Too many packets buffered for output - stream)"的错误。 + deu: 'max_muxing_queue_size: Erhöhen, um den Fehler "Too many packets buffered for output stream" zu beheben' + eng: 'max_muxing_queue_size: Raise to fix "Too many packets buffered for output stream" error' + fra: "max_muxing_queue_size : Augmenter pour corriger l'erreur \"Too many packets buffered for output stream" + ita: "max_muxing_queue_size: Alzare per correggere l'errore \"Troppi pacchetti bufferizzati per il flusso di uscita" + spa: 'tamaño_muxing_queue_size: Subir para corregir el error "Demasiados paquetes almacenados en la memoria intermedia para el flujo de salida".' + chs: max_muxing_queue_size:提高以解决 "输出流缓冲的数据包太多(Too many packets buffered for output stream)"的错误。 jpn: max_muxing_queue_size 「出力ストリームにバッファリングされるパケット数が多すぎる」というエラーが発生した場合は値を上げてください。 - rus: 'max_muxing_queue_size: Повышение для исправления ошибки "Слишком много пакетов - буферизировано для выходного потока"' - por: 'max_muxing_queue_size: Aumentar para corrigir o erro "Too many packets buffered - for output stream"' - swe: 'max_muxing_queue_size: Höj för att åtgärda felet "För många paket buffras - för utdataströmmen".' - pol: 'max_muxing_queue_size: Podnieś, aby naprawić błąd "Zbyt wiele pakietów buforowanych - dla strumienia wyjściowego"' - ukr: 'max_muxing_queue_size: Підвищити для виправлення помилки "Занадто багато пакетів - буферизовано для вихідного потоку"' + rus: 'max_muxing_queue_size: Повышение для исправления ошибки "Слишком много пакетов буферизировано для выходного потока"' + por: 'max_muxing_queue_size: Aumentar para corrigir o erro "Too many packets buffered for output stream"' + swe: 'max_muxing_queue_size: Höj för att åtgärda felet "För många paket buffras för utdataströmmen".' + pol: 'max_muxing_queue_size: Podnieś, aby naprawić błąd "Zbyt wiele pakietów buforowanych dla strumienia wyjściowego"' + ukr: 'max_muxing_queue_size: Підвищити для виправлення помилки "Занадто багато пакетів буферизовано для вихідного потоку"' kor: 'MAX_MUXING_QUEUE_SIZE: "출력 스트림에 버퍼링된 패킷이 너무 많습니다" 오류를 수정하기 위해 증가' - ron: 'max_muxing_queue_size: Creștere pentru a remedia eroarea "Prea multe pachete - stocate în buffer pentru fluxul de ieșire".' + ron: 'max_muxing_queue_size: Creștere pentru a remedia eroarea "Prea multe pachete stocate în buffer pentru fluxul de ieșire".' none: deu: keine eng: none @@ -6598,25 +6269,20 @@ preset: kor: 사전 설정 ron: presetat 'preset: The slower the preset, the better the compression and quality': - deu: 'Voreinstellung: Je langsamer die Voreinstellung, desto besser die Komprimierung - und Qualität' + deu: 'Voreinstellung: Je langsamer die Voreinstellung, desto besser die Komprimierung und Qualität' eng: 'preset: The slower the preset, the better the compression and quality' - fra: 'préréglé : Plus le préréglage est lent, meilleure est la compression et la - qualité' + fra: 'préréglé : Plus le préréglage est lent, meilleure est la compression et la qualité' ita: 'preimpostata: Più lento è il preset, migliore è la compressione e la qualità' - spa: 'preestablecido: Cuanto más lento el preajuste, mejor será la compresión y - la calidad' + spa: 'preestablecido: Cuanto más lento el preajuste, mejor será la compresión y la calidad' chs: preset:较慢的预设能提供更好的压缩比和质量。 jpn: プリセットの速度が遅いほど、圧縮率と品質が向上します。 rus: 'предустановка: Чем медленнее предустановка, тем лучше сжатие и качество' por: 'preset: Quanto mais lento o preset, melhor a compressão e a qualidade' - swe: 'förinställd: Ju långsammare förinställning, desto bättre komprimering och - kvalitet.' + swe: 'förinställd: Ju långsammare förinställning, desto bättre komprimering och kvalitet.' pol: 'ustawienie wstępne: Im wolniejszy preset, tym lepsza kompresja i jakość.' ukr: 'пресет: Чим повільніше попереднє налаштування, тим краще стиснення та якість' kor: '프리셋: 프리셋: 프리셋이 느릴수록 압축 및 품질이 향상됩니다.' - ron: 'presetate: Cu cât este mai lentă presetarea, cu atât este mai bună compresia - și calitatea.' + ron: 'presetate: Cu cât este mai lentă presetarea, cu atât este mai bună compresia și calitatea.' preventing large-scale patterns such as color banding in images.: deu: verhindert großflächige Muster wie z. B. Color Banding in Bildern. eng: preventing large-scale patterns such as color banding in images. @@ -6628,8 +6294,7 @@ preventing large-scale patterns such as color banding in images.: rus: предотвращение крупномасштабных деталей, таких как цветовые полосы на изображениях. por: impedindo padrões em grande escala, como color banding em imagens. swe: förhindra storskaliga mönster, t.ex. färgband i bilder. - pol: zapobieganie powstawaniu wielkoskalowych wzorów, takich jak kolorowy banding - na obrazach. + pol: zapobieganie powstawaniu wielkoskalowych wzorów, takich jak kolorowy banding na obrazach. ukr: запобігання великомасштабним патернам, таким як кольорові смуги на зображеннях. kor: 이미지의 컬러 밴딩과 같은 대규모 패턴을 방지합니다. ron: prevenirea modelelor la scară largă, cum ar fi banda de culoare în imagini. @@ -6667,10 +6332,8 @@ profile: deu: 'Profil: VP9-Kodierungsprofil - muss mit der Bittiefe übereinstimmen' eng: 'profile: VP9 coding profile - must match bit depth' fra: 'profil : Profil de codage VP9 - doit correspondre à la profondeur de bit' - ita: 'profilo: Profilo di codifica VP9 - deve corrispondere alla profondità della - punta' - spa: 'perfil: Perfil de codificación del VP9 - debe coincidir con la profundidad - del bit' + ita: 'profilo: Profilo di codifica VP9 - deve corrispondere alla profondità della punta' + spa: 'perfil: Perfil de codificación del VP9 - debe coincidir con la profundidad del bit' chs: 配置:VP9编码规格——必须与位深度相匹配。 jpn: プロファイルを使用しています。VP9コーディングプロファイル - ビット深度と一致する必要があります rus: 'профиль: Профиль кодирования VP9 - должен соответствовать битовой глубине' @@ -6679,8 +6342,7 @@ profile: pol: 'profil: Profil kodowania VP9 - musi być zgodny z głębią bitową' ukr: 'профіль: Профіль кодування VP9 - має відповідати бітовій глибині' kor: '프로파일: VP9 코딩 프로필 - 비트 심도와 일치해야 함' - ron: 'profil: Profilul de codare VP9 - trebuie să se potrivească cu adâncimea de - biți' + ron: 'profil: Profilul de codare VP9 - trebuie să se potrivească cu adâncimea de biți' python-box: deu: python-box eng: python-box @@ -6712,52 +6374,35 @@ rav1e github: kor: rav1e github ron: rav1e github 'repeat-headers: If enabled, x265 will emit VPS, SPS, and PPS headers with every keyframe.': - deu: 'Kopfzeilen wiederholen: Wenn aktiviert, gibt x265 mit jedem Keyframe VPS-, - SPS- und PPS-Header aus.' - eng: 'repeat-headers: If enabled, x265 will emit VPS, SPS, and PPS headers with - every keyframe.' - fra: 'des en-têtes répétitifs : Si elle est activée, x265 émettra des en-têtes VPS, - SPS et PPS avec chaque image clé.' - ita: 'ripeti-intestazioni: Se abilitato, x265 emetterà testate VPS, SPS e PPS con - ogni fotogramma chiave.' - spa: 'repetición de los encabezamientos: Si se activa, x265 emitirá encabezados - VPS, SPS y PPS con cada fotograma clave.' + deu: 'Kopfzeilen wiederholen: Wenn aktiviert, gibt x265 mit jedem Keyframe VPS-, SPS- und PPS-Header aus.' + eng: 'repeat-headers: If enabled, x265 will emit VPS, SPS, and PPS headers with every keyframe.' + fra: 'des en-têtes répétitifs : Si elle est activée, x265 émettra des en-têtes VPS, SPS et PPS avec chaque image clé.' + ita: 'ripeti-intestazioni: Se abilitato, x265 emetterà testate VPS, SPS e PPS con ogni fotogramma chiave.' + spa: 'repetición de los encabezamientos: Si se activa, x265 emitirá encabezados VPS, SPS y PPS con cada fotograma clave.' chs: repeat-headers:如果启用,x265将随每个关键帧加入VPS,SPS和PPS标头。 jpn: repeat-headersを有効にすると、x265はキーフレームごとにVPS、SPS、PPSの各ヘッダを出力します。 - rus: 'повторять заголовки: Если включено, x265 будет выдавать заголовки VPS, SPS - и PPS с каждым ключевым кадром.' - por: 'repeat-headers: Se ativado, x265 emitirá cabeçalhos VPS, SPS e PPS em cada - kkeyframe.' - swe: 'upprepa rubriker: Om den är aktiverad kommer x265 att skicka ut VPS-, SPS - och PPS-rubriker med varje nyckelbild.' - pol: 'repeat-headers: Jeśli włączone, x265 będzie emitować nagłówki VPS, SPS i PPS - z każdą klatką kluczową.' - ukr: 'repeat-headers: Якщо увімкнено, x265 видаватиме заголовки VPS, SPS та PPS - з кожним ключовим кадром.' + rus: 'повторять заголовки: Если включено, x265 будет выдавать заголовки VPS, SPS и PPS с каждым ключевым кадром.' + por: 'repeat-headers: Se ativado, x265 emitirá cabeçalhos VPS, SPS e PPS em cada kkeyframe.' + swe: 'upprepa rubriker: Om den är aktiverad kommer x265 att skicka ut VPS-, SPS och PPS-rubriker med varje nyckelbild.' + pol: 'repeat-headers: Jeśli włączone, x265 będzie emitować nagłówki VPS, SPS i PPS z każdą klatką kluczową.' + ukr: 'repeat-headers: Якщо увімкнено, x265 видаватиме заголовки VPS, SPS та PPS з кожним ключовим кадром.' kor: '반복 헤더: 활성화하면 x265는 모든 키프레임에 VPS, SPS, PPS 헤더를 전송합니다.' - ron: 'repeat-headers: Dacă este activat, x265 va emite antetele VPS, SPS și PPS - la fiecare cadru cheie.' + ron: 'repeat-headers: Dacă este activat, x265 va emite antetele VPS, SPS și PPS la fiecare cadru cheie.' since the entire reference frames are always available for motion compensation,: - deu: da immer die gesamten Referenzframes für die Bewegungskompensation zur Verfügung - stehen, + deu: da immer die gesamten Referenzframes für die Bewegungskompensation zur Verfügung stehen, eng: since the entire reference frames are always available for motion compensation, - fra: puisque les images de référence entières sont toujours disponibles pour la - compensation de mouvement, - ita: poiché l'intero frame di riferimento è sempre disponibile per la compensazione - del movimento, - spa: ya que todos los fotogramas de referencia están siempre disponibles para la - compensación de movimiento, + fra: puisque les images de référence entières sont toujours disponibles pour la compensation de mouvement, + ita: poiché l'intero frame di riferimento è sempre disponibile per la compensazione del movimento, + spa: ya que todos los fotogramas de referencia están siempre disponibles para la compensación de movimiento, chs: 因为总是可以获取完整的参考帧来进行运动补偿, jpn: は、リファレンスフレーム全体が常に動きの補正に利用できるからです。 rus: поскольку для компенсации движения всегда доступны все опорные кадры, - por: uma vez que todos os frames de referência estão sempre disponíveis para a compensação - de movimento, + por: uma vez que todos os frames de referência estão sempre disponíveis para a compensação de movimento, swe: eftersom hela referensramar alltid är tillgängliga för rörelsekompensation, pol: ponieważ całe ramki odniesienia są zawsze dostępne dla kompensacji ruchu, ukr: оскільки для компенсації руху завжди доступні всі системи відліку, kor: 전체 기준 프레임을 항상 모션 보정에 사용할 수 있기 때문입니다, - ron: deoarece cadrele de referință întregi sunt întotdeauna disponibile pentru compensarea - mișcării, + ron: deoarece cadrele de referință întregi sunt întotdeauna disponibile pentru compensarea mișcării, starting next command: deu: nächsten Befehl starten eng: starting next command @@ -6789,28 +6434,20 @@ subtitle tracks found: kor: 자막 트랙이 발견됨 ron: piese de subtitrare găsite that move across the video from one side to the other and thereby refresh the image: - deu: die sich von einer Seite zur anderen durch das Video bewegen und dabei das - Bild aktualisieren - eng: that move across the video from one side to the other and thereby refresh the - image + deu: die sich von einer Seite zur anderen durch das Video bewegen und dabei das Bild aktualisieren + eng: that move across the video from one side to the other and thereby refresh the image fra: qui passent d'un côté à l'autre de la vidéo et rafraîchissent ainsi l'image - ita: che si muovono attraverso il video da un lato all'altro e quindi rinfrescano - l'immagine + ita: che si muovono attraverso il video da un lato all'altro e quindi rinfrescano l'immagine spa: que se mueven a través del video de un lado a otro y así refrescan la imagen chs: 这些intra blocks的位置在若干帧的时间内从视频一侧移动到另一侧, jpn: 画像の中を左右に移動することで画像を更新する - rus: которые перемещаются по видео с одной стороны на другую и тем самым обновляют - изображение - por: que se movem através do vídeo de um lado para o outro e, assim, atualizam a - imagem - swe: som rör sig över videon från den ena sidan till den andra och på så sätt uppdaterar - bilden. - pol: które przesuwają się po obrazie z jednej strony na drugą i w ten sposób odświeżają - obraz + rus: которые перемещаются по видео с одной стороны на другую и тем самым обновляют изображение + por: que se movem através do vídeo de um lado para o outro e, assim, atualizam a imagem + swe: som rör sig över videon från den ena sidan till den andra och på så sätt uppdaterar bilden. + pol: które przesuwają się po obrazie z jednej strony na drugą i w ten sposób odświeżają obraz ukr: які рухаються по відео з одного боку в інший і таким чином оновлюють зображення kor: 비디오를 가로질러 한 쪽에서 다른 쪽으로 이동하여 이미지를 새로 고칩니다. - ron: care se deplasează de-a lungul imaginii video de la o parte la alta și, astfel, - reîmprospătează imaginea. + ron: care se deplasează de-a lungul imaginii video de la o parte la alta și, astfel, reîmprospătează imaginea. the resolution-to-: deu: die Auflösung zu eng: the resolution-to- @@ -6842,12 +6479,10 @@ to Blu-ray standards to burn to a physical disk: kor: 물리적 디스크에 레코딩하기 위해 블루레이 표준으로 변환합니다. ron: la standardele Blu-ray pentru a le inscripționa pe un disc fizic 'tune: Tune the settings for a particular type of source or situation': - deu: 'Feineinstellung: Die Einstellungen auf eine bestimmte Art von Quelle oder - Situation abstimmen' + deu: 'Feineinstellung: Die Einstellungen auf eine bestimmte Art von Quelle oder Situation abstimmen' eng: 'tune: Tune the settings for a particular type of source or situation' fra: 'afinar: Sintonizar los ajustes para un tipo de fuente o situación particular' - ita: 'sintonizzarsi: Sintonizzare le impostazioni per un particolare tipo di sorgente - o situazione' + ita: 'sintonizzarsi: Sintonizzare le impostazioni per un particolare tipo di sorgente o situazione' spa: 'afinar: Sintonizar los ajustes para un tipo de fuente o situación particular' chs: 针对特定类型的来源或情况调整设置。 jpn: 特定の種類のソースや状況に合わせて設定をチューニングする @@ -6904,23 +6539,17 @@ There are no videos to start converting: kor: 변환을 시작할 동영상이 없습니다. ron: Nu există videoclipuri pentru a începe conversia No crop, scale, rotation,flip nor any other filters will be applied.: - deu: Es werden weder Beschneiden, Skalieren, Drehen, Spiegeln noch andere Filter - angewendet. + deu: Es werden weder Beschneiden, Skalieren, Drehen, Spiegeln noch andere Filter angewendet. eng: No crop, scale, rotation,flip nor any other filters will be applied. - fra: Aucun filtre de culture, d'échelle, de rotation, de retournement ou autre ne - sera appliqué. + fra: Aucun filtre de culture, d'échelle, de rotation, de retournement ou autre ne sera appliqué. ita: Nessun ritaglio, scala, rotazione, flip o qualsiasi altro filtro sarà applicato. - spa: No se aplicará ningún filtro de recorte, escala, rotación, volteo ni ningún - otro. + spa: No se aplicará ningún filtro de recorte, escala, rotación, volteo ni ningún otro. chs: 不会应用裁切、缩放、旋转、翻转或任何其他滤镜。 jpn: クロップ、スケール、ローテーション、フリップなどのフィルターは適用されません。 rus: Обрезка, масштабирование, поворот, переворот и другие фильтры не применяются. - por: Nenhum recorte, dimensionamento, rotação, inversão ou qualquer outro filtro - será aplicado. - swe: Ingen beskärning, skalning, rotation, vändning eller andra filter kommer att - tillämpas. - pol: Nie będą stosowane żadne filtry typu crop, scale, rotation, flip ani żadne - inne. + por: Nenhum recorte, dimensionamento, rotação, inversão ou qualquer outro filtro será aplicado. + swe: Ingen beskärning, skalning, rotation, vändning eller andra filter kommer att tillämpas. + pol: Nie będą stosowane żadne filtry typu crop, scale, rotation, flip ani żadne inne. ukr: Обрізання, масштабування, обертання, перевертання та інші фільтри не застосовуються. kor: 자르기, 크기 조정, 회전, 뒤집기 또는 기타 필터가 적용되지 않습니다. ron: Nu se vor aplica filtre de tăiere, scalare, rotație, întoarcere sau alte filtre. @@ -7232,13 +6861,11 @@ Decoder: spa: 'Hardware: utilizar libavformat + decodificador de hardware para la entrada' chs: Hardware:使用libavformat+硬件解码器 jpn: ハードウェア:入力にlibavformat+ハードウェアデコーダを使用 - rus: 'Аппаратное обеспечение: использование libavformat + аппаратного декодера для - ввода' + rus: 'Аппаратное обеспечение: использование libavformat + аппаратного декодера для ввода' por: 'Hardware: usar libavformat + decodificador de hardware para entrada' swe: 'Hårdvara: Använd libavformat + hårdvaruavkodare för inmatning.' pol: 'Sprzęt: użyj libavformat + dekoder sprzętowy dla wejścia' - ukr: 'Апаратне забезпечення: використовуйте libavformat + апаратний декодер для - введення' + ukr: 'Апаратне забезпечення: використовуйте libavformat + апаратний декодер для введення' kor: '하드웨어: 입력 시 libavformat + 하드웨어 디코더 사용' ron: 'Hardware: folosiți libavformat + decodor hardware pentru intrare' 'Software: use avcodec + software decoder': @@ -7437,53 +7064,35 @@ Load: kor: 로드 ron: Încărcare Drag and Drop to reorder - All items need to be same dimensions: - deu: Ziehen und Ablegen zum Neuordnen - Alle Elemente müssen die gleichen Abmessungen - haben + deu: Ziehen und Ablegen zum Neuordnen - Alle Elemente müssen die gleichen Abmessungen haben eng: Drag and Drop to reorder - All items need to be same dimensions - fra: Glissez et déposez pour réorganiser - Tous les éléments doivent avoir les mêmes - dimensions. - ita: Per riordinare trascina e rilascia - tutti gli elementi devono avere le stesse - dimensioni - spa: Arrastrar y soltar para reordenar - Todos los elementos deben tener las mismas - dimensiones + fra: Glissez et déposez pour réorganiser - Tous les éléments doivent avoir les mêmes dimensions. + ita: Per riordinare trascina e rilascia - tutti gli elementi devono avere le stesse dimensioni + spa: Arrastrar y soltar para reordenar - Todos los elementos deben tener las mismas dimensiones chs: 拖放来重新排序 - 所有项目的尺寸都需要相同 jpn: ドラッグ&ドロップで並び替え - すべてのアイテムが同じ寸法である必要があります。 - rus: Перетаскивание для изменения порядка - все элементы должны иметь одинаковые - размеры + rus: Перетаскивание для изменения порядка - все элементы должны иметь одинаковые размеры por: Arraste e solte para reordenar - Todos os itens precisam ter as mesmas dimensões swe: Dra och släpp för att beställa om - Alla artiklar måste ha samma mått. - pol: Przeciągnij i upuść, aby zmienić kolejność - Wszystkie elementy muszą mieć - te same wymiary + pol: Przeciągnij i upuść, aby zmienić kolejność - Wszystkie elementy muszą mieć te same wymiary ukr: Перетягніть, щоб змінити порядок - всі елементи повинні мати однакові розміри kor: 드래그 앤 드롭으로 재주문 - 모든 항목의 크기가 동일해야 합니다. - ron: Trageți și plasați pentru a reordona - Toate articolele trebuie să aibă aceleași - dimensiuni + ron: Trageți și plasați pentru a reordona - Toate articolele trebuie să aibă aceleași dimensiuni The following items were excluded as they could not be identified as image or video files: - deu: Die folgenden Elemente wurden ausgeschlossen, da sie nicht als Bild- oder Videodateien - identifiziert werden konnten - eng: The following items were excluded as they could not be identified as image - or video files - fra: Les éléments suivants ont été exclus car ils n'ont pas pu être identifiés comme - des fichiers image ou vidéo. - ita: I seguenti elementi sono stati esclusi perché non potevano essere identificati - come file immagine o video - spa: Se han excluido los siguientes elementos por no poder ser identificados como - archivos de imagen o vídeo + deu: Die folgenden Elemente wurden ausgeschlossen, da sie nicht als Bild- oder Videodateien identifiziert werden konnten + eng: The following items were excluded as they could not be identified as image or video files + fra: Les éléments suivants ont été exclus car ils n'ont pas pu être identifiés comme des fichiers image ou vidéo. + ita: I seguenti elementi sono stati esclusi perché non potevano essere identificati come file immagine o video + spa: Se han excluido los siguientes elementos por no poder ser identificados como archivos de imagen o vídeo chs: 以下项目被排除在外,因为它们无法被识别为图像或视频文件 jpn: 以下のものは、画像・動画ファイルとして識別できないため、除外しました。 - rus: Следующие элементы были исключены, поскольку их нельзя было идентифицировать - как файлы изображений или видеофайлы - por: Os seguintes itens foram excluídos porque não puderam ser identificados como - arquivos de imagem ou vídeo - swe: Följande poster har uteslutits eftersom de inte kunde identifieras som bild- - eller videofiler - pol: Następujące pozycje zostały wyłączone, ponieważ nie można ich było zidentyfikować - jako pliki obrazu lub wideo - ukr: Наступні елементи були виключені, оскільки вони не могли бути ідентифіковані - як зображення або відеофайли + rus: Следующие элементы были исключены, поскольку их нельзя было идентифицировать как файлы изображений или видеофайлы + por: Os seguintes itens foram excluídos porque não puderam ser identificados como arquivos de imagem ou vídeo + swe: Följande poster har uteslutits eftersom de inte kunde identifieras som bild- eller videofiler + pol: Następujące pozycje zostały wyłączone, ponieważ nie można ich było zidentyfikować jako pliki obrazu lub wideo + ukr: Наступні елементи були виключені, оскільки вони не могли бути ідентифіковані як зображення або відеофайли kor: 다음 항목은 이미지 또는 동영상 파일로 식별할 수 없으므로 제외되었습니다. - ron: Următoarele elemente au fost excluse deoarece nu au putut fi identificate ca - fișiere de imagine sau video + ron: Următoarele elemente au fost excluse deoarece nu au putut fi identificate ca fișiere de imagine sau video There are already items in this list: deu: Es gibt bereits Objekte in dieser Liste eng: There are already items in this list @@ -7562,25 +7171,20 @@ does not support concatenating files together: 'WARNING: This feature is not provided by the encoder software directly': deu: 'WARNUNG: Diese Funktion wird nicht direkt von der Encoder-Software bereitgestellt.' eng: 'WARNING: This feature is not provided by the encoder software directly' - fra: "AVERTISSEMENT : Cette fonction n'est pas fournie directement par le logiciel - de l'encodeur." + fra: "AVERTISSEMENT : Cette fonction n'est pas fournie directement par le logiciel de l'encodeur." ita: 'ATTENZIONE: Questa funzione non è fornita direttamente dal software di codifica' - spa: 'ADVERTENCIA: Esta función no es proporcionada por el software del codificador - directamente' + spa: 'ADVERTENCIA: Esta función no es proporcionada por el software del codificador directamente' chs: 警告:编码器软件不直接提供这一功能。 jpn: 注意喚起:この機能は、エンコーダソフトウェアが直接提供するものではありません。 - rus: 'ВНИМАНИЕ: Эта функция не обеспечивается непосредственно программным обеспечением - энкодера' + rus: 'ВНИМАНИЕ: Эта функция не обеспечивается непосредственно программным обеспечением энкодера' por: 'AVISO: Este recurso não é fornecido diretamente pelo software do codificador' swe: 'VARNING: Denna funktion tillhandahålls inte direkt av kodarprogramvaran.' pol: 'OSTRZEŻENIE: Ta funkcja nie jest dostępna bezpośrednio w oprogramowaniu enkodera' - ukr: 'ПОПЕРЕДЖЕННЯ: Ця функція не надається безпосередньо програмним забезпеченням - кодера' + ukr: 'ПОПЕРЕДЖЕННЯ: Ця функція не надається безпосередньо програмним забезпеченням кодера' kor: '경고: 이 기능은 인코더 소프트웨어에서 직접 제공하지 않습니다.' ron: 'AVERTISMENT: Această funcție nu este furnizată direct de software-ul de codare' It is NOT supported by VCE or NVENC encoders, it will break the encoding: - deu: Sie wird NICHT von VCE- oder NVENC-Encodern unterstützt, da sie die Codierung - unterbrechen würde. + deu: Sie wird NICHT von VCE- oder NVENC-Encodern unterstützt, da sie die Codierung unterbrechen würde. eng: It is NOT supported by VCE or NVENC encoders, it will break the encoding fra: Elle n'est PAS supportée par les encodeurs VCE ou NVENC, elle interrompra l'encodage. ita: NON è supportata dagli encoder VCE o NVENC, interromperà la codifica @@ -8045,29 +7649,20 @@ Please load in a video to configure a new profile: kor: 10비트 ron: 10 biți This encoder does not support duplicating audio tracks, please remove copied tracks!: - eng: This encoder does not support duplicating audio tracks, please remove copied - tracks! - deu: Dieser Encoder unterstützt das Duplizieren von Audiospuren nicht, bitte entfernen - Sie die kopierten Spuren! - fra: Cet encodeur ne prend pas en charge la duplication des pistes audio, veuillez - supprimer les pistes copiées ! - ita: Questo encoder non supporta la duplicazione di tracce audio, per favore rimuovi - le tracce copiate! - spa: Este codificador no admite la duplicación de pistas de audio, por favor, elimine - las pistas copiadas. + eng: This encoder does not support duplicating audio tracks, please remove copied tracks! + deu: Dieser Encoder unterstützt das Duplizieren von Audiospuren nicht, bitte entfernen Sie die kopierten Spuren! + fra: Cet encodeur ne prend pas en charge la duplication des pistes audio, veuillez supprimer les pistes copiées ! + ita: Questo encoder non supporta la duplicazione di tracce audio, per favore rimuovi le tracce copiate! + spa: Este codificador no admite la duplicación de pistas de audio, por favor, elimine las pistas copiadas. chs: 此编码器不支持复制音轨,请删除已复制的音轨! jpn: このエンコーダーはオーディオトラックの複製をサポートしていません。コピーしたトラックを削除してください。 - rus: Этот кодер не поддерживает дублирование звуковых дорожек, пожалуйста, удалите - скопированные дорожки! - por: Este codificador não suporta a duplicação de faixas de áudio, por favor remova - as faixas copiadas! + rus: Этот кодер не поддерживает дублирование звуковых дорожек, пожалуйста, удалите скопированные дорожки! + por: Este codificador não suporta a duplicação de faixas de áudio, por favor remova as faixas copiadas! swe: Den här kodaren stöder inte duplicering av ljudspår, ta bort kopierade spår! pol: Ten koder nie obsługuje duplikowania ścieżek audio, usuń skopiowane ścieżki! - ukr: Цей кодер не підтримує дублювання аудіодоріжок, будь ласка, видаляйте скопійовані - доріжки! + ukr: Цей кодер не підтримує дублювання аудіодоріжок, будь ласка, видаляйте скопійовані доріжки! kor: 이 인코더는 오디오 트랙 복제를 지원하지 않으므로 복사된 트랙을 제거하세요! - ron: Acest encoder nu acceptă duplicarea pieselor audio, vă rugăm să eliminați piesele - copiate! + ron: Acest encoder nu acceptă duplicarea pieselor audio, vă rugăm să eliminați piesele copiate! Not supported by rigaya's hardware encoders: eng: Not supported by rigaya's hardware encoders deu: Wird von Rigayas Hardware-Encodern nicht unterstützt @@ -8148,20 +7743,16 @@ Hint that encoding should happen in real-time if not faster: deu: Hinweis, dass die Kodierung in Echtzeit oder sogar schneller erfolgen sollte fra: Indiquez que l'encodage doit se faire en temps réel, sinon plus rapidement. ita: 'Suggerimento: la codifica dovrebbe avvenire in tempo reale, se non più velocemente' - spa: Indicación de que la codificación debe realizarse en tiempo real, si no más - rápido + spa: Indicación de que la codificación debe realizarse en tiempo real, si no más rápido chs: 提示编码应该实时发生,如果不是更快的话 jpn: ヒント:エンコードはリアルタイムで実行すべきです。(それよりも速くなければ) - rus: Подсказка, что кодирование должно происходить в режиме реального времени, если - не быстрее + rus: Подсказка, что кодирование должно происходить в режиме реального времени, если не быстрее por: Infere que a codificação deveria acontecer em tempo real, se não mais rápido swe: En antydan om att kodning bör ske i realtid, om inte snabbare. - pol: Wskazówka, że kodowanie powinno odbywać się w czasie rzeczywistym, jeśli nie - szybciej + pol: Wskazówka, że kodowanie powinno odbywać się w czasie rzeczywistym, jeśli nie szybciej ukr: Підказка, що кодування має відбуватися в режимі реального часу, якщо не швидше kor: 인코딩은 빠르지는 않더라도 실시간으로 이루어져야 한다는 힌트 - ron: Indică faptul că codificarea ar trebui să aibă loc în timp real, dacă nu mai - repede + ron: Indică faptul că codificarea ar trebui să aibă loc în timp real, dacă nu mai repede Frames Before: eng: Frames Before deu: Frames Vorher @@ -8178,31 +7769,20 @@ Frames Before: kor: 프레임 이전 ron: Cadre Înainte Other frames will come before the frames in this session. This helps smooth concatenation issues.: - eng: Other frames will come before the frames in this session. This helps smooth - concatenation issues. - deu: Andere Rahmen werden vor den Rahmen in dieser Sitzung angezeigt. Dies hilft, - Verkettungsprobleme zu vermeiden. - fra: Les autres cadres viendront avant les cadres de cette session. Cela permet - d'atténuer les problèmes de concaténation. - ita: Gli altri fotogrammi verranno prima dei fotogrammi di questa sessione. Questo - aiuta a smussare i problemi di concatenazione. - spa: Los demás fotogramas vendrán antes que los fotogramas de esta sesión. Esto - ayuda a suavizar los problemas de concatenación. + eng: Other frames will come before the frames in this session. This helps smooth concatenation issues. + deu: Andere Rahmen werden vor den Rahmen in dieser Sitzung angezeigt. Dies hilft, Verkettungsprobleme zu vermeiden. + fra: Les autres cadres viendront avant les cadres de cette session. Cela permet d'atténuer les problèmes de concaténation. + ita: Gli altri fotogrammi verranno prima dei fotogrammi di questa sessione. Questo aiuta a smussare i problemi di concatenazione. + spa: Los demás fotogramas vendrán antes que los fotogramas de esta sesión. Esto ayuda a suavizar los problemas de concatenación. chs: 其他的帧会在这个会话中的帧之前出现。这有助于缓解合并中的问题。 jpn: 他のフレームは、このセッションのフレームより前に来ます。これは、連結の問題をスムーズにするのに役立ちます。 - rus: Другие кадры будут идти перед кадрами в этой сессии. Это помогает сгладить - проблемы конкатенации. - por: Outros frames virão antes dos frames nesta sessão. Isso ajuda a suavizar os - problemas de concatenação. - swe: Andra ramar kommer att komma före ramarna under denna session. Detta underlättar - problem med sammanlänkning. - pol: Inne ramki będą się pojawiać przed ramkami w tej sesji. Ułatwia to rozwiązywanie - problemów związanych z konkatenacją. - ukr: Інші кадри з'являтимуться перед кадрами у цьому сеансі. Це допомагає згладити - проблеми з конкатенацією. + rus: Другие кадры будут идти перед кадрами в этой сессии. Это помогает сгладить проблемы конкатенации. + por: Outros frames virão antes dos frames nesta sessão. Isso ajuda a suavizar os problemas de concatenação. + swe: Andra ramar kommer att komma före ramarna under denna session. Detta underlättar problem med sammanlänkning. + pol: Inne ramki będą się pojawiać przed ramkami w tej sesji. Ułatwia to rozwiązywanie problemów związanych z konkatenacją. + ukr: Інші кадри з'являтимуться перед кадрами у цьому сеансі. Це допомагає згладити проблеми з конкатенацією. kor: 이 세션에서는 다른 프레임이 프레임보다 먼저 나옵니다. 이렇게 하면 연결 문제를 원활하게 해결할 수 있습니다. - ron: Alte cadre vor veni înaintea cadrelor din această sesiune. Acest lucru ajută - la fluidizarea problemelor de concatenare. + ron: Alte cadre vor veni înaintea cadrelor din această sesiune. Acest lucru ajută la fluidizarea problemelor de concatenare. Frames After: eng: Frames After deu: Frames nach @@ -8219,31 +7799,20 @@ Frames After: kor: 프레임 후 ron: Cadre după Other frames will come after the frames in this session. This helps smooth concatenation issues.: - eng: Other frames will come after the frames in this session. This helps smooth - concatenation issues. - deu: Andere Frames kommen nach den Frames in dieser Sitzung. Dies hilft, Verkettungsprobleme - zu vermeiden. - fra: Les autres cadres viendront après les cadres de cette session. Cela permet - d'atténuer les problèmes de concaténation. - ita: Gli altri fotogrammi verranno dopo i fotogrammi di questa sessione. Questo - aiuta a smussare i problemi di concatenazione. - spa: Otros marcos vendrán después de los marcos de esta sesión. Esto ayuda a suavizar - los problemas de concatenación. + eng: Other frames will come after the frames in this session. This helps smooth concatenation issues. + deu: Andere Frames kommen nach den Frames in dieser Sitzung. Dies hilft, Verkettungsprobleme zu vermeiden. + fra: Les autres cadres viendront après les cadres de cette session. Cela permet d'atténuer les problèmes de concaténation. + ita: Gli altri fotogrammi verranno dopo i fotogrammi di questa sessione. Questo aiuta a smussare i problemi di concatenazione. + spa: Otros marcos vendrán después de los marcos de esta sesión. Esto ayuda a suavizar los problemas de concatenación. chs: 其他的帧将在这个会话中的帧之后出现。这有助于缓解合并中的问题。 jpn: 他のフレームは、このセッションのフレームの後に来ます。これは、連結の問題をスムーズにするのに役立ちます。 - rus: Другие кадры будут идти после кадров этой сессии. Это помогает сгладить проблемы - с конкатенацией. - por: Outros frames virão depois dos frames nesta sessão. Isso ajuda a suavizar os - problemas de concatenação. - swe: Andra ramar kommer att följa efter ramarna i denna session. Detta bidrar till - att underlätta problem med sammanlänkningar. - pol: Inne ramki będą pojawiać się po ramkach z tej sesji. Ułatwia to rozwiązywanie - problemów związanych z konkatenacją. - ukr: Інші кадри з'являться після кадрів цього сеансу. Це допомагає згладити проблеми - з конкатенацією. + rus: Другие кадры будут идти после кадров этой сессии. Это помогает сгладить проблемы с конкатенацией. + por: Outros frames virão depois dos frames nesta sessão. Isso ajuda a suavizar os problemas de concatenação. + swe: Andra ramar kommer att följa efter ramarna i denna session. Detta bidrar till att underlätta problem med sammanlänkningar. + pol: Inne ramki będą pojawiać się po ramkach z tej sesji. Ułatwia to rozwiązywanie problemów związanych z konkatenacją. + ukr: Інші кадри з'являться після кадрів цього сеансу. Це допомагає згладити проблеми з конкатенацією. kor: 다른 프레임은 이 세션의 프레임 다음에 올 것입니다. 이렇게 하면 연결 문제를 원활하게 해결할 수 있습니다. - ron: Alte cadre vor veni după cadrele din această sesiune. Acest lucru ajută la - fluidizarea problemelor de concatenare. + ron: Alte cadre vor veni după cadrele din această sesiune. Acest lucru ajută la fluidizarea problemelor de concatenare. HEVC coding profile - must match bit depth: eng: HEVC coding profile - must match bit depth deu: HEVC-Codierungsprofil - muss der Bittiefe entsprechen @@ -8488,10 +8057,8 @@ No audio tracks matched for this profile, enable first track?: eng: No audio tracks matched for this profile, enable first track? deu: Keine Audiospuren für dieses Profil gefunden, erste Spur aktivieren? fra: Aucune piste audio ne correspond à ce profil, activez la première piste ? - ita: Non ci sono tracce audio corrispondenti per questo profilo, attivare la prima - traccia? - spa: No hay pistas de audio que coincidan con este perfil, habilitar la primera - pista? + ita: Non ci sono tracce audio corrispondenti per questo profilo, attivare la prima traccia? + spa: No hay pistas de audio que coincidan con este perfil, habilitar la primera pista? chs: 此配置文件没有匹配的音轨,启用第一个音轨? jpn: このプロファイルに一致するオーディオトラックがありません。最初のトラックを有効にしますか? rus: Для этого профиля не подобраны звуковые дорожки, включить первую дорожку? @@ -8560,8 +8127,7 @@ Rigaya's encoders will only match one encoding per track: pol: Kodery Rigaya dopasują tylko jedno kodowanie na ścieżkę. ukr: Кодувальники Rigaya підтримують лише одне кодування для кожної доріжки kor: 리가야의 인코더는 트랙당 하나의 인코딩만 일치합니다. - ron: Codificatoarele Rigaya se vor potrivi doar cu o singură codificare pentru fiecare - piesă + ron: Codificatoarele Rigaya se vor potrivi doar cu o singură codificare pentru fiecare piesă Default Output Folder: eng: Default Output Folder deu: Standard-Ausgabeordner @@ -8819,28 +8385,19 @@ Video Track Title: ron: Titlul piesei video Remove GUI logs and compress conversion logs older than 30 days at exit: eng: Remove GUI logs and compress conversion logs older than 30 days at exit - ukr: Видалення логів графічного інтерфейсу та стиснення логів перетворень, старших - за 30 днів, при виході - deu: Entfernen von GUI-Protokollen und Komprimieren von Konvertierungsprotokollen, - die älter als 30 Tage sind, beim Beenden - fra: Suppression des journaux de l'interface graphique et compression des journaux - de conversion de plus de 30 jours à la sortie. - ita: Rimuovi registri eventi GUI e comprimeri registri eventi conversione più vecchi - di 30 giorni all'uscita. - spa: Elimina los registros GUI y comprime los registros de conversión de más de - 30 días al salir. + ukr: Видалення логів графічного інтерфейсу та стиснення логів перетворень, старших за 30 днів, при виході + deu: Entfernen von GUI-Protokollen und Komprimieren von Konvertierungsprotokollen, die älter als 30 Tage sind, beim Beenden + fra: Suppression des journaux de l'interface graphique et compression des journaux de conversion de plus de 30 jours à la sortie. + ita: Rimuovi registri eventi GUI e comprimeri registri eventi conversione più vecchi di 30 giorni all'uscita. + spa: Elimina los registros GUI y comprime los registros de conversión de más de 30 días al salir. chs: 在退出时删除GUI日志并压缩超过30天的转换日志 jpn: 終了時にGUIログを削除し、30日以上前の変換ログを圧縮します rus: Удаление журналов GUI и сжатие журналов конвертации старше 30 дней при выходе - por: Remover logs da GUI e comprimir logs de conversão mais antigos que 30 dias - na saída - swe: Ta bort GUI-loggar och komprimera konverteringsloggar som är äldre än 30 dagar - vid avslutningen. - pol: Usuń logi GUI i skompresuj logi konwersji starsze niż 30 dni przy wyjściu z - systemu + por: Remover logs da GUI e comprimir logs de conversão mais antigos que 30 dias na saída + swe: Ta bort GUI-loggar och komprimera konverteringsloggar som är äldre än 30 dagar vid avslutningen. + pol: Usuń logi GUI i skompresuj logi konwersji starsze niż 30 dni przy wyjściu z systemu kor: 종료 시 GUI 로그를 제거하고 30일이 지난 전환 로그를 압축합니다. - ron: Îndepărtați jurnalele GUI și comprimați jurnalele de conversie mai vechi de - 30 de zile la ieșire + ron: Îndepărtați jurnalele GUI și comprimați jurnalele de conversie mai vechi de 30 de zile la ieșire UI Scale: eng: UI Scale ukr: Шкала інтерфейсу @@ -8917,31 +8474,20 @@ Extra vvc params in opt=1:opt2=0 format: kor: opt=1:opt2=0 형식의 추가 vvc 매개변수 ron: Extra vvc params în format opt=1:opt2=0 That video was added with an encoder that is no longer available, unable to load from queue: - eng: That video was added with an encoder that is no longer available, unable to - load from queue - deu: Dieses Video wurde mit einem Encoder hinzugefügt, der nicht mehr verfügbar - ist und nicht aus der Warteschlange geladen werden kann - fra: Cette vidéo a été ajoutée avec un encodeur qui n'est plus disponible, impossible - de la charger à partir de la file d'attente. - ita: Il video è stato aggiunto con un encoder non più disponibile, non è possibile - caricarlo dalla coda. - spa: Ese vídeo se añadió con un codificador que ya no está disponible, no se puede - cargar desde la cola + eng: That video was added with an encoder that is no longer available, unable to load from queue + deu: Dieses Video wurde mit einem Encoder hinzugefügt, der nicht mehr verfügbar ist und nicht aus der Warteschlange geladen werden kann + fra: Cette vidéo a été ajoutée avec un encodeur qui n'est plus disponible, impossible de la charger à partir de la file d'attente. + ita: Il video è stato aggiunto con un encoder non più disponibile, non è possibile caricarlo dalla coda. + spa: Ese vídeo se añadió con un codificador que ya no está disponible, no se puede cargar desde la cola chs: 该视频是用一个不再可用的编码器添加的,无法从队列中加载。 jpn: その動画は、利用できなくなったエンコーダーで追加されたため、キューから読み込むことができません。 - rus: Это видео было добавлено с помощью кодировщика, который больше не доступен, - не удается загрузить из очереди - por: Esse vídeo foi adicionado com um codificador que não está mais disponível, - não é possível carregar da fila - swe: Videon lades till med en kodare som inte längre är tillgänglig, kan inte laddas - från kön. - pol: Ten film został dodany za pomocą kodera, który nie jest już dostępny, nie można - go załadować z kolejki - ukr: Це відео було додано за допомогою кодера, який більше не доступний і не може - бути завантажений з черги + rus: Это видео было добавлено с помощью кодировщика, который больше не доступен, не удается загрузить из очереди + por: Esse vídeo foi adicionado com um codificador que não está mais disponível, não é possível carregar da fila + swe: Videon lades till med en kodare som inte längre är tillgänglig, kan inte laddas från kön. + pol: Ten film został dodany za pomocą kodera, który nie jest już dostępny, nie można go załadować z kolejki + ukr: Це відео було додано за допомогою кодера, який більше не доступний і не може бути завантажений з черги kor: 해당 동영상은 더 이상 사용할 수 없는 인코더로 추가되어 대기열에서 로드할 수 없습니다. - ron: Videoclipul a fost adăugat cu un codificator care nu mai este disponibil, nu - poate fi încărcat din coada de așteptare + ron: Videoclipul a fost adăugat cu un codificator care nu mai este disponibil, nu poate fi încărcat din coada de așteptare 'This profile will be applied to all the selected items:': eng: 'This profile will be applied to all the selected items:' deu: 'Dieses Profil wird auf alle ausgewählten Artikel angewendet:' @@ -8973,55 +8519,35 @@ QP Mode: kor: QP 모드 ron: Modul QP Constant Quality, Intelligent Constant Quality, Intelligent + Lookahead Constant Quality: - eng: Constant Quality, Intelligent Constant Quality, Intelligent + Lookahead Constant - Quality - deu: Konstante Qualität, Intelligent Konstante Qualität, Intelligent + Lookahead - Konstante Qualität - fra: Qualité constante, intelligente Qualité constante, intelligente + Lookahead - Qualité constante - ita: Qualità costante, Intelligente Qualità costante, Intelligente + Lookahead Qualità - costante - spa: Calidad constante, Inteligente Calidad constante, Inteligente + Lookahead Calidad - constante + eng: Constant Quality, Intelligent Constant Quality, Intelligent + Lookahead Constant Quality + deu: Konstante Qualität, Intelligent Konstante Qualität, Intelligent + Lookahead Konstante Qualität + fra: Qualité constante, intelligente Qualité constante, intelligente + Lookahead Qualité constante + ita: Qualità costante, Intelligente Qualità costante, Intelligente + Lookahead Qualità costante + spa: Calidad constante, Inteligente Calidad constante, Inteligente + Lookahead Calidad constante chs: 恒定质量,智能恒定质量,智能+前瞻恒定质量 jpn: 品質固定、インテリジェント品質固定、インテリジェント+ルックアヘッド品質固定 - rus: Постоянное качество, интеллектуальное постоянное качество, интеллектуальное - качество + опережающее постоянное качество - por: Qualidade constante, Qualidade Constante Inteligente, Qualidade Constante Inteligente - + Lookahead - swe: Konstant kvalitet, intelligent Konstant kvalitet, intelligent + Lookahead Konstant - kvalitet + rus: Постоянное качество, интеллектуальное постоянное качество, интеллектуальное качество + опережающее постоянное качество + por: Qualidade constante, Qualidade Constante Inteligente, Qualidade Constante Inteligente + Lookahead + swe: Konstant kvalitet, intelligent Konstant kvalitet, intelligent + Lookahead Konstant kvalitet pol: Stała jakość, inteligentna stała jakość, inteligentna + Lookahead Stała jakość - ukr: Постійна якість, Інтелектуальна Постійна якість, Інтелектуальна + Lookahead - Постійна якість + ukr: Постійна якість, Інтелектуальна Постійна якість, Інтелектуальна + Lookahead Постійна якість kor: 일정한 품질, 지능형 일정한 품질, 지능형 + 룩어헤드 일정한 품질 - ron: Calitate constantă, inteligentă Calitate constantă, inteligentă + Lookahead - Calitate constantă + ron: Calitate constantă, inteligentă Calitate constantă, inteligentă + Lookahead Calitate constantă The following items were excluded as they could not be identified as a video files: - eng: The following items were excluded as they could not be identified as a video - files - deu: Die folgenden Elemente wurden ausgeschlossen, da sie nicht als Videodateien - identifiziert werden konnten - fra: Les éléments suivants ont été exclus car ils ne pouvaient pas être identifiés - comme des fichiers vidéo - ita: I seguenti elementi sono stati esclusi in quanto non potevano essere identificati - come file video - spa: Se excluyeron los siguientes elementos por no poder identificarse como archivos - de vídeo + eng: The following items were excluded as they could not be identified as a video files + deu: Die folgenden Elemente wurden ausgeschlossen, da sie nicht als Videodateien identifiziert werden konnten + fra: Les éléments suivants ont été exclus car ils ne pouvaient pas être identifiés comme des fichiers vidéo + ita: I seguenti elementi sono stati esclusi in quanto non potevano essere identificati come file video + spa: Se excluyeron los siguientes elementos por no poder identificarse como archivos de vídeo chs: 以下项目被排除在外,因为它们不能被确定为视频文件 jpn: 以下のものは、動画ファイルとして識別できないため、除外した。 - rus: Следующие предметы были исключены, так как их нельзя было идентифицировать - как видеофайлы - por: Os seguintes itens foram excluídos, pois não puderam ser identificados como - arquivos de vídeo + rus: Следующие предметы были исключены, так как их нельзя было идентифицировать как видеофайлы + por: Os seguintes itens foram excluídos, pois não puderam ser identificados como arquivos de vídeo swe: Följande poster uteslöts eftersom de inte kunde identifieras som videofiler - pol: Następujące pozycje zostały wyłączone, ponieważ nie mogły być zidentyfikowane - jako pliki wideo - ukr: Наступні елементи були виключені, оскільки вони не могли бути ідентифіковані - як відеофайли + pol: Następujące pozycje zostały wyłączone, ponieważ nie mogły być zidentyfikowane jako pliki wideo + ukr: Наступні елементи були виключені, оскільки вони не могли бути ідентифіковані як відеофайли kor: 다음 항목은 동영상 파일로 식별할 수 없으므로 제외되었습니다. - ron: Următoarele elemente au fost excluse deoarece nu au putut fi identificate ca - fișiere video + ron: Următoarele elemente au fost excluse deoarece nu au putut fi identificate ca fișiere video more: eng: more deu: mehr @@ -9130,23 +8656,18 @@ Adaptive Reference Frames: Adaptively select list of reference frames to improve encoding quality.: eng: Adaptively select list of reference frames to improve encoding quality. deu: Adaptive Auswahl einer Liste von Referenzbildern zur Verbesserung der Kodierungsqualität. - fra: Sélection adaptative d'une liste de cadres de référence pour améliorer la qualité - de l'encodage. - ita: Selezione adattiva dell'elenco di fotogrammi di riferimento per migliorare - la qualità della codifica. - spa: Selección adaptativa de la lista de fotogramas de referencia para mejorar la - calidad de la codificación. + fra: Sélection adaptative d'une liste de cadres de référence pour améliorer la qualité de l'encodage. + ita: Selezione adattiva dell'elenco di fotogrammi di riferimento per migliorare la qualità della codifica. + spa: Selección adaptativa de la lista de fotogramas de referencia para mejorar la calidad de la codificación. chs: 自适应地选择参考帧的列表以提高编码质量。 jpn: エンコード品質を向上させるために、参照フレームのリストを適応的に選択する。 rus: Адаптивный выбор списка опорных кадров для улучшения качества кодирования. - por: Selecionar adaptativamente a lista de quadros de referência para melhorar a - qualidade de codificação. + por: Selecionar adaptativamente a lista de quadros de referência para melhorar a qualidade de codificação. swe: Adaptivt val av lista över referensramar för att förbättra kodningskvaliteten. pol: Adaptacyjne wybieranie listy ramek odniesienia w celu poprawy jakości kodowania. ukr: Адаптивно вибирати список опорних кадрів для покращення якості кодування. kor: 인코딩 품질을 개선하기 위해 참조 프레임 목록을 적응적으로 선택합니다. - ron: Selectarea adaptivă a listei de cadre de referință pentru a îmbunătăți calitatea - codificării. + ron: Selectarea adaptivă a listei de cadre de referință pentru a îmbunătăți calitatea codificării. Adaptive Long-Term Reference Frames: eng: Adaptive Long-Term Reference Frames deu: Adaptive Langzeit-Referenzrahmen @@ -9163,30 +8684,20 @@ Adaptive Long-Term Reference Frames: kor: 적응형 장기 참조 프레임 ron: Cadre de referință adaptive pe termen lung Mark, modify, or remove LTR frames based on encoding parameters and content properties.: - eng: Mark, modify, or remove LTR frames based on encoding parameters and content - properties. - deu: Markieren, ändern oder entfernen Sie LTR-Frames auf der Grundlage von Kodierungsparametern - und Inhaltseigenschaften. - fra: Marquer, modifier ou supprimer les cadres LTR en fonction des paramètres d'encodage - et des propriétés du contenu. - ita: Contrassegnare, modificare o rimuovere le cornici LTR in base ai parametri - di codifica e alle proprietà del contenuto. - spa: Marque, modifique o elimine tramas LTR en función de los parámetros de codificación - y las propiedades del contenido. + eng: Mark, modify, or remove LTR frames based on encoding parameters and content properties. + deu: Markieren, ändern oder entfernen Sie LTR-Frames auf der Grundlage von Kodierungsparametern und Inhaltseigenschaften. + fra: Marquer, modifier ou supprimer les cadres LTR en fonction des paramètres d'encodage et des propriétés du contenu. + ita: Contrassegnare, modificare o rimuovere le cornici LTR in base ai parametri di codifica e alle proprietà del contenuto. + spa: Marque, modifique o elimine tramas LTR en función de los parámetros de codificación y las propiedades del contenido. chs: 根据编码参数和内容属性,标记、修改或删除LTR框架。 jpn: エンコーディングパラメータとコンテンツプロパティに基づいて、LTRフレームをマーク、修正、または削除する。 - rus: Пометить, изменить или удалить кадры LTR на основе параметров кодирования и - свойств содержимого. - por: Marcar, modificar ou remover quadros LTR com base em parâmetros de codificação - e propriedades de conteúdo. + rus: Пометить, изменить или удалить кадры LTR на основе параметров кодирования и свойств содержимого. + por: Marcar, modificar ou remover quadros LTR com base em parâmetros de codificação e propriedades de conteúdo. swe: Markera, ändra eller ta bort LTR-ramar baserat på kodningsparametrar och innehållsegenskaper. - pol: Zaznaczaj, modyfikuj lub usuwaj ramki LTR w oparciu o parametry kodowania i - właściwości zawartości. - ukr: Позначати, змінювати або видаляти LTR-кадри на основі параметрів кодування - та властивостей вмісту. + pol: Zaznaczaj, modyfikuj lub usuwaj ramki LTR w oparciu o parametry kodowania i właściwości zawartości. + ukr: Позначати, змінювати або видаляти LTR-кадри на основі параметрів кодування та властивостей вмісту. kor: 인코딩 매개변수 및 콘텐츠 속성을 기반으로 LTR 프레임을 표시, 수정 또는 제거할 수 있습니다. - ron: Marcați, modificați sau eliminați cadrele LTR pe baza parametrilor de codificare - și a proprietăților de conținut. + ron: Marcați, modificați sau eliminați cadrele LTR pe baza parametrilor de codificare și a proprietăților de conținut. Adaptive CQM: eng: Adaptive CQM deu: Adaptives CQM @@ -9202,40 +8713,23 @@ Adaptive CQM: ukr: Адаптивна система управління якістю kor: 적응형 CQM ron: CQM adaptiv -? Adaptively select one of implementation-defined quantization matrices for each frame, - to improve subjective visual quality under certain conditions. -: eng: Adaptively select one of implementation-defined quantization matrices for each - frame, to improve subjective visual quality under certain conditions. - deu: Adaptive Auswahl einer der implementierungsdefinierten Quantisierungsmatrizen - für jedes Bild, um die subjektive visuelle Qualität unter bestimmten Bedingungen - zu verbessern. - fra: Sélectionner de manière adaptative l'une des matrices de quantification définies - par l'implémentation pour chaque image, afin d'améliorer la qualité visuelle subjective - dans certaines conditions. - ita: Selezionare in modo adattivo una delle matrici di quantizzazione definite dall'implementazione - per ogni fotogramma, per migliorare la qualità visiva soggettiva in determinate - condizioni. - spa: Selecciona de forma adaptativa una de las matrices de cuantización definidas - por la aplicación para cada fotograma, con el fin de mejorar la calidad visual - subjetiva en determinadas condiciones. +? Adaptively select one of implementation-defined quantization matrices for each frame, to improve subjective visual quality under certain conditions. +: eng: Adaptively select one of implementation-defined quantization matrices for each frame, to improve subjective visual quality under certain conditions. + deu: Adaptive Auswahl einer der implementierungsdefinierten Quantisierungsmatrizen für jedes Bild, um die subjektive visuelle Qualität unter bestimmten Bedingungen zu verbessern. + fra: Sélectionner de manière adaptative l'une des matrices de quantification définies par l'implémentation pour chaque image, afin d'améliorer la qualité visuelle subjective dans certaines + conditions. + ita: Selezionare in modo adattivo una delle matrici di quantizzazione definite dall'implementazione per ogni fotogramma, per migliorare la qualità visiva soggettiva in determinate condizioni. + spa: Selecciona de forma adaptativa una de las matrices de cuantización definidas por la aplicación para cada fotograma, con el fin de mejorar la calidad visual subjetiva en determinadas + condiciones. chs: 为每一帧自适应地选择执行定义的量化矩阵之一,以改善某些条件下的主观视觉质量。 jpn: 特定の条件下で主観的な視覚品質を向上させるために、各フレームに対して実装定義された量子化マトリックスの1つを適応的に選択する。 - rus: Адаптивный выбор одной из определяемых реализацией матриц квантования для каждого - кадра для улучшения субъективного визуального качества при определенных условиях. - por: Selecionar de forma adaptativa uma das matrizes de quantização definidas pela - implementação para cada frame, para melhorar a qualidade visual subjetiva em determinadas - condições. - swe: Adaptivt välja en av de kvantiseringsmatriser som definieras av implementeringen - för varje bild för att förbättra den subjektiva visuella kvaliteten under vissa - förhållanden. - pol: Adaptacyjnie wybierz jedną z implementowanych matryc kwantyzacji dla każdej - klatki, aby poprawić subiektywną jakość wizualną w określonych warunkach. - ukr: Адаптивно вибирати одну з матриць квантування, визначених реалізацією, для - кожного кадру, щоб покращити суб'єктивну візуальну якість за певних умов. + rus: Адаптивный выбор одной из определяемых реализацией матриц квантования для каждого кадра для улучшения субъективного визуального качества при определенных условиях. + por: Selecionar de forma adaptativa uma das matrizes de quantização definidas pela implementação para cada frame, para melhorar a qualidade visual subjetiva em determinadas condições. + swe: Adaptivt välja en av de kvantiseringsmatriser som definieras av implementeringen för varje bild för att förbättra den subjektiva visuella kvaliteten under vissa förhållanden. + pol: Adaptacyjnie wybierz jedną z implementowanych matryc kwantyzacji dla każdej klatki, aby poprawić subiektywną jakość wizualną w określonych warunkach. + ukr: Адаптивно вибирати одну з матриць квантування, визначених реалізацією, для кожного кадру, щоб покращити суб'єктивну візуальну якість за певних умов. kor: 특정 조건에서 주관적인 화질을 개선하기 위해 각 프레임에 대해 구현에 정의된 양자화 매트릭스 중 하나를 적응적으로 선택합니다. - ron: Selectarea adaptivă a uneia dintre matricile de cuantificare definite de implementare - pentru fiecare cadru, pentru a îmbunătăți calitatea vizuală subiectivă în anumite - condiții. + ron: Selectarea adaptivă a uneia dintre matricile de cuantificare definite de implementare pentru fiecare cadru, pentru a îmbunătăți calitatea vizuală subiectivă în anumite condiții. Hardware Decoding: eng: Hardware Decoding deu: Hardware-Dekodierung @@ -9674,12 +9168,9 @@ B Depth: Maximum B-frame reference depth (from 1 to INT_MAX) (default 1): eng: Maximum B-frame reference depth (from 1 to INT_MAX) (default 1) deu: Maximale B-Frame-Referenztiefe (von 1 bis INT_MAX) (Standardwert 1) - fra: Profondeur de référence maximale de la trame B (de 1 à INT_MAX) (par défaut - 1) - ita: Profondità massima di riferimento del fotogramma B (da 1 a INT_MAX) (valore - predefinito 1) - spa: Profundidad máxima de referencia de fotogramas B (de 1 a INT_MAX) (por defecto - 1) + fra: Profondeur de référence maximale de la trame B (de 1 à INT_MAX) (par défaut 1) + ita: Profondità massima di riferimento del fotogramma B (da 1 a INT_MAX) (valore predefinito 1) + spa: Profundidad máxima de referencia de fotogramas B (de 1 a INT_MAX) (por defecto 1) chs: 最大的B-帧参考深度(从1到INT_MAX)(默认为1)。 jpn: 最大Bフレーム参照深度(1~INT_MAX)(デフォルト1) rus: Максимальная опорная глубина B-кадра (от 1 до INT_MAX) (по умолчанию 1) @@ -9794,43 +9285,24 @@ Async Depth: ukr: Глибина асинхронізації kor: 비동기 뎁스 ron: Adâncime asincronă -? Maximum processing parallelism. Increase this to improve single channel performance. - This option doesn't work if driver doesn't implement vaSyncBuffer function. -: eng: Maximum processing parallelism. Increase this to improve single channel performance. - This option doesn't work if driver doesn't implement vaSyncBuffer function. - deu: Maximale Verarbeitungsparallelität. Erhöhen Sie diesen Wert, um die Leistung - eines einzelnen Kanals zu verbessern. Diese Option funktioniert nicht, wenn der - Treiber die Funktion vaSyncBuffer nicht implementiert. - fra: Parallélisme de traitement maximal. Augmentez-le pour améliorer les performances - d'un seul canal. Cette option ne fonctionne pas si le pilote n'implémente pas - la fonction vaSyncBuffer. - ita: Massimo parallelismo di elaborazione. Aumentare questo valore per migliorare - le prestazioni di un singolo canale. Questa opzione non funziona se il driver - non implementa la funzione vaSyncBuffer. - spa: Máximo paralelismo de procesamiento. Auméntelo para mejorar el rendimiento - de un solo canal. Esta opción no funciona si el controlador no implementa la función +? Maximum processing parallelism. Increase this to improve single channel performance. This option doesn't work if driver doesn't implement vaSyncBuffer function. +: eng: Maximum processing parallelism. Increase this to improve single channel performance. This option doesn't work if driver doesn't implement vaSyncBuffer function. + deu: Maximale Verarbeitungsparallelität. Erhöhen Sie diesen Wert, um die Leistung eines einzelnen Kanals zu verbessern. Diese Option funktioniert nicht, wenn der Treiber die Funktion vaSyncBuffer + nicht implementiert. + fra: Parallélisme de traitement maximal. Augmentez-le pour améliorer les performances d'un seul canal. Cette option ne fonctionne pas si le pilote n'implémente pas la fonction vaSyncBuffer. + ita: Massimo parallelismo di elaborazione. Aumentare questo valore per migliorare le prestazioni di un singolo canale. Questa opzione non funziona se il driver non implementa la funzione vaSyncBuffer. + spa: Máximo paralelismo de procesamiento. Auméntelo para mejorar el rendimiento de un solo canal. Esta opción no funciona si el controlador no implementa la función vaSyncBuffer. chs: 最大的处理并行性。增加这个选项可以提高单通道的性能。如果驱动程序没有实现vaSyncBuffer函数,这个选项就不起作用。 - jpn: - 最大処理並列度。シングルチャンネルの性能を向上させるために、これを増やします。ドライバがvaSyncBuffer関数を実装していない場合、このオプションは機能しません。 - rus: Максимальная параллельность обработки. Увеличьте это значение для повышения - производительности одного канала. Эта опция не работает, если драйвер не реализует - функцию vaSyncBuffer. - por: Paralelismo máximo de processamento. Aumente este valor para melhorar o desempenho - de um único canal. Esta opção não funciona se o driver não implementar a função + jpn: 最大処理並列度。シングルチャンネルの性能を向上させるために、これを増やします。ドライバがvaSyncBuffer関数を実装していない場合、このオプションは機能しません。 + rus: Максимальная параллельность обработки. Увеличьте это значение для повышения производительности одного канала. Эта опция не работает, если драйвер не реализует функцию vaSyncBuffer. + por: Paralelismo máximo de processamento. Aumente este valor para melhorar o desempenho de um único canal. Esta opção não funciona se o driver não implementar a função vaSyncBuffer. + swe: Maximal parallellitet i bearbetningen. Öka detta värde för att förbättra prestandan för en enda kanal. Det här alternativet fungerar inte om drivrutinen inte implementerar funktionen vaSyncBuffer. - swe: Maximal parallellitet i bearbetningen. Öka detta värde för att förbättra prestandan - för en enda kanal. Det här alternativet fungerar inte om drivrutinen inte implementerar - funktionen vaSyncBuffer. - pol: Maksymalna równoległość przetwarzania. Zwiększ to, aby poprawić wydajność pojedynczego - kanału. Opcja nie działa, jeżeli sterownik nie implementuje funkcji vaSyncBuffer. - ukr: Максимальний паралелізм обробки. Збільште цей параметр, щоб покращити одноканальну - продуктивність. Цей параметр не працює, якщо драйвер не реалізує функцію vaSyncBuffer. - kor: 최대 처리 병렬 처리. 단일 채널 성능을 향상시키려면 이 값을 높입니다. 이 옵션은 드라이버가 vaSyncBuffer 기능을 구현하지 - 않는 경우 작동하지 않습니다. - ron: Paralelism maxim de procesare. Creșteți această valoare pentru a îmbunătăți - performanța unui singur canal. Această opțiune nu funcționează dacă driverul nu - implementează funcția vaSyncBuffer. + pol: Maksymalna równoległość przetwarzania. Zwiększ to, aby poprawić wydajność pojedynczego kanału. Opcja nie działa, jeżeli sterownik nie implementuje funkcji vaSyncBuffer. + ukr: Максимальний паралелізм обробки. Збільште цей параметр, щоб покращити одноканальну продуктивність. Цей параметр не працює, якщо драйвер не реалізує функцію vaSyncBuffer. + kor: 최대 처리 병렬 처리. 단일 채널 성능을 향상시키려면 이 값을 높입니다. 이 옵션은 드라이버가 vaSyncBuffer 기능을 구현하지 않는 경우 작동하지 않습니다. + ron: Paralelism maxim de procesare. Creșteți această valoare pentru a îmbunătăți performanța unui singur canal. Această opțiune nu funcționează dacă driverul nu implementează funcția vaSyncBuffer. AUD: eng: AUD deu: AUD @@ -10717,31 +10189,20 @@ Download Nightly FFmpeg: kor: 최신 FFmpeg 다운로드 ron: Descărcați cel mai nou FFmpeg hq - High Quality, uhq - Ultra High Quality, ll - Low Latency, ull - Ultra Low Latency: - eng: hq - High Quality, uhq - Ultra High Quality, ll - Low Latency, ull - Ultra - Low Latency - deu: hq - Hohe Qualität, uhq - Ultrahohe Qualität, ll - Niedrige Latenzzeit, ull - - Ultra-niedrige Latenzzeit - fra: hq - Haute qualité, uhq - Ultra haute qualité, ll - Faible latence, ull - Ultra - faible latence - ita: hq - Alta qualità, uhq - Ultra alta qualità, ll - Bassa latenza, ull - Ultra - bassa latenza - spa: hq - Alta calidad, uhq - Calidad ultra alta, ll - Baja latencia, ull - Latencia - ultra baja + eng: hq - High Quality, uhq - Ultra High Quality, ll - Low Latency, ull - Ultra Low Latency + deu: hq - Hohe Qualität, uhq - Ultrahohe Qualität, ll - Niedrige Latenzzeit, ull - Ultra-niedrige Latenzzeit + fra: hq - Haute qualité, uhq - Ultra haute qualité, ll - Faible latence, ull - Ultra faible latence + ita: hq - Alta qualità, uhq - Ultra alta qualità, ll - Bassa latenza, ull - Ultra bassa latenza + spa: hq - Alta calidad, uhq - Calidad ultra alta, ll - Baja latencia, ull - Latencia ultra baja jpn: hq - 高画質、uhq - 超高画質、ll - 低遅延、ull - 超低遅延 - rus: hq - высокое качество, uhq - сверхвысокое качество, ll - низкая задержка, ull - - сверхнизкая задержка - por: hq - Alta Qualidade, uhq - Ultra Alta Qualidade, ll - Baixa Latência, ull - - Ultra Baixa Latência - swe: hq - Hög kvalitet, uhq - Ultrahög kvalitet, ll - Låg latens, ull - Ultra låg - latens - pol: hq - wysoka jakość, uhq - bardzo wysoka jakość, ll - niskie opóźnienie, ull - - bardzo niskie opóźnienie + rus: hq - высокое качество, uhq - сверхвысокое качество, ll - низкая задержка, ull - сверхнизкая задержка + por: hq - Alta Qualidade, uhq - Ultra Alta Qualidade, ll - Baixa Latência, ull - Ultra Baixa Latência + swe: hq - Hög kvalitet, uhq - Ultrahög kvalitet, ll - Låg latens, ull - Ultra låg latens + pol: hq - wysoka jakość, uhq - bardzo wysoka jakość, ll - niskie opóźnienie, ull - bardzo niskie opóźnienie chs: hq - 高质量,uhq - 超高质量,ll - 低延迟,ull - 超低延迟 - ukr: hq - висока якість, uhq - надвисока якість, ll - низька затримка, ull - наднизька - затримка + ukr: hq - висока якість, uhq - надвисока якість, ll - низька затримка, ull - наднизька затримка kor: hq - 고품질, uhq - 초고화질, ll - 저지연, ull - 초저지연 - ron: hq - calitate înaltă, uhq - calitate ultra înaltă, ll - latență redusă, ull - - latență ultra redusă + ron: hq - calitate înaltă, uhq - calitate ultra înaltă, ll - latență redusă, ull - latență ultra redusă FFmpeg not found!: eng: FFmpeg not found! deu: FFmpeg nicht gefunden! @@ -10895,8 +10356,7 @@ Audio Normalization: This will run the audio normalization process on all streams of: eng: This will run the audio normalization process on all streams of deu: Dadurch wird der Audionormalisierungsprozess für alle Streams von - fra: Cette opération exécute le processus de normalisation audio sur tous les flux - de + fra: Cette opération exécute le processus de normalisation audio sur tous les flux de ita: Eseguirà il processo di normalizzazione audio su tutti i flussi di spa: Esto ejecutará el proceso de normalización de audio en todos los flujos de jpn: のすべてのストリームに対してオーディオ正規化処理を実行します。 @@ -10938,41 +10398,21 @@ Please make sure the source and output files are specified: ukr: Будь ласка, переконайтеся, що вказані вихідний та вихідний файли kor: 소스 및 출력 파일이 지정되었는지 확인하세요. ron: Vă rugăm să vă asigurați că fișierele sursă și de ieșire sunt specificate -? "Do you want FastFlix to automatically detect your GPUs and download the optional - encoders for them?\n\nThis will include downloading 7zip on Windows platform." -: eng: "Do you want FastFlix to automatically detect your GPUs and download the optional - encoders for them?\n\nThis will include downloading 7zip on Windows platform." - deu: "Möchten Sie, dass FastFlix Ihre GPUs automatisch erkennt und die optionalen - Encoder für sie herunterlädt?\n\nDazu gehört auch das Herunterladen von 7zip auf - der Windows-Plattform." - fra: "Voulez-vous que FastFlix détecte automatiquement vos GPU et télécharge les - encodeurs optionnels pour eux ?\n\nCela inclut le téléchargement de 7zip sur la - plateforme Windows." - ita: "Volete che FastFlix rilevi automaticamente le vostre GPU e scarichi gli encoder - opzionali per esse?\n\nQuesto include il download di 7zip su piattaforma Windows." - spa: "¿Quieres que FastFlix detecte automáticamente tus GPU y descargue los codificadores - opcionales para ellas?\n\nEsto incluirá la descarga de 7zip en la plataforma Windows." +? "Do you want FastFlix to automatically detect your GPUs and download the optional encoders for them?\n\nThis will include downloading 7zip on Windows platform." +: eng: "Do you want FastFlix to automatically detect your GPUs and download the optional encoders for them?\n\nThis will include downloading 7zip on Windows platform." + deu: "Möchten Sie, dass FastFlix Ihre GPUs automatisch erkennt und die optionalen Encoder für sie herunterlädt?\n\nDazu gehört auch das Herunterladen von 7zip auf der Windows-Plattform." + fra: "Voulez-vous que FastFlix détecte automatiquement vos GPU et télécharge les encodeurs optionnels pour eux ?\n\nCela inclut le téléchargement de 7zip sur la plateforme Windows." + ita: "Volete che FastFlix rilevi automaticamente le vostre GPU e scarichi gli encoder opzionali per esse?\n\nQuesto include il download di 7zip su piattaforma Windows." + spa: "¿Quieres que FastFlix detecte automáticamente tus GPU y descargue los codificadores opcionales para ellas?\n\nEsto incluirá la descarga de 7zip en la plataforma Windows." jpn: "FastFlixにGPUを自動的に検出させ、オプションのエンコーダをダウンロードさせたいですか?\n\nこれにはWindowsプラットフォームでの7zipのダウンロードも含まれます。" - rus: "Хотите, чтобы FastFlix автоматически определял ваши графические процессоры - и загружал для них дополнительные кодировщики?\n\nЭто включает загрузку 7zip на - платформе Windows." - por: "Quer que o FastFlix detecte automaticamente as suas GPUs e transfira os codificadores - opcionais para elas?\n\nIsso incluirá o download do 7zip na plataforma Windows." - swe: "Vill du att FastFlix automatiskt ska upptäcka dina GPU: er och ladda ner de - valfria kodarna för dem?\n\nDetta kommer att inkludera nedladdning av 7zip på - Windows-plattformen." - pol: "Czy chcesz, aby FastFlix automatycznie wykrywał twoje procesory graficzne - i pobierał dla nich opcjonalne kodery?\n\nObejmuje to pobranie programu 7zip na - platformę Windows." + rus: "Хотите, чтобы FastFlix автоматически определял ваши графические процессоры и загружал для них дополнительные кодировщики?\n\nЭто включает загрузку 7zip на платформе Windows." + por: "Quer que o FastFlix detecte automaticamente as suas GPUs e transfira os codificadores opcionais para elas?\n\nIsso incluirá o download do 7zip na plataforma Windows." + swe: "Vill du att FastFlix automatiskt ska upptäcka dina GPU: er och ladda ner de valfria kodarna för dem?\n\nDetta kommer att inkludera nedladdning av 7zip på Windows-plattformen." + pol: "Czy chcesz, aby FastFlix automatycznie wykrywał twoje procesory graficzne i pobierał dla nich opcjonalne kodery?\n\nObejmuje to pobranie programu 7zip na platformę Windows." chs: "您希望 FastFlix 自动检测您的 GPU 并为其下载可选编码器吗?\n\n这包括在 Windows 平台上下载 7zip。" - ukr: "Хочете, щоб FastFlix автоматично визначав ваш графічний процесор і завантажував - додаткові кодеки для нього?\n\nДля цього потрібно завантажити 7zip на платформі - Windows." - kor: "FastFlix가 자동으로 GPU를 감지하여 옵션 인코더를 다운로드하도록 하시겠습니까?\n\n여기에는 Windows 플랫폼에서 7zip - 다운로드가 포함됩니다." - ron: "Doriți ca FastFlix să detecteze automat GPU-urile dvs. și să descarce codurile - opționale pentru acestea?\n\nAceasta va include descărcarea 7zip pe platforma - Windows." + ukr: "Хочете, щоб FastFlix автоматично визначав ваш графічний процесор і завантажував додаткові кодеки для нього?\n\nДля цього потрібно завантажити 7zip на платформі Windows." + kor: "FastFlix가 자동으로 GPU를 감지하여 옵션 인코더를 다운로드하도록 하시겠습니까?\n\n여기에는 Windows 플랫폼에서 7zip 다운로드가 포함됩니다." + ron: "Doriți ca FastFlix să detecteze automat GPU-urile dvs. și să descarce codurile opționale pentru acestea?\n\nAceasta va include descărcarea 7zip pe platforma Windows." Allow Optional Downloads: eng: Allow Optional Downloads deu: Optionale Downloads zulassen @@ -10989,43 +10429,20 @@ Allow Optional Downloads: kor: 선택적 다운로드 허용 ron: Permiteți descărcări opționale "uses [fast] seek to a rough position ahead of timestamp, vs a specific [exact] frame lookup. (GIF encodings use [fast])": - eng: uses [fast] seek to a rough position ahead of timestamp, vs a specific - [exact] frame lookup. (GIF encodings use [fast]) - deu: verwendet eine [schnelle] Suche nach einer groben Position vor dem - Zeitstempel, im Gegensatz zu einer spezifischen [exakten] Frame-Suche. - (GIF-Kodierungen verwenden [fast]) - fra: utilise la recherche [rapide] d'une position approximative avant - l'horodatage, au lieu d'une recherche d'image spécifique [exacte]. (Les - encodages GIF utilisent [fast]) - ita: utilizza la ricerca [veloce] di una posizione approssimativa prima del - timestamp, rispetto a una ricerca specifica [esatta] del fotogramma. (Le - codifiche GIF usano [fast]) - spa: utiliza la búsqueda [rápida] a una posición aproximada por delante de la - marca de tiempo, frente a una búsqueda específica [exacta] de fotogramas. - (Las codificaciones GIF utilizan [rápido]) - jpn: - は、[正確な]フレーム検索に対して、タイムスタンプより先の大まかな位置への[高速]シークを使用します。(GIFエンコーディングは[fast]を使用) - rus: использует [быстрый] поиск до приблизительной позиции впереди временной - метки, а не конкретный [точный] поиск кадра. (В кодировках GIF используется - [быстрый]) - por: utiliza a pesquisa [rápida] para uma posição aproximada antes do carimbo - de data/hora, em vez de uma pesquisa de fotogramas [exacta] específica. (As - codificações GIF usam [fast]) - swe: använder [snabb] sökning till en ungefärlig position före tidsstämpeln, - jämfört med en specifik [exakt] ramuppslagning. (GIF-kodningar använder - [fast]) - pol: używa [szybkiego] wyszukiwania do przybliżonej pozycji przed znacznikiem - czasu, a nie konkretnego [dokładnego] wyszukiwania klatek. (Kodowania GIF - używają [fast]) + eng: uses [fast] seek to a rough position ahead of timestamp, vs a specific [exact] frame lookup. (GIF encodings use [fast]) + deu: verwendet eine [schnelle] Suche nach einer groben Position vor dem Zeitstempel, im Gegensatz zu einer spezifischen [exakten] Frame-Suche. (GIF-Kodierungen verwenden [fast]) + fra: utilise la recherche [rapide] d'une position approximative avant l'horodatage, au lieu d'une recherche d'image spécifique [exacte]. (Les encodages GIF utilisent [fast]) + ita: utilizza la ricerca [veloce] di una posizione approssimativa prima del timestamp, rispetto a una ricerca specifica [esatta] del fotogramma. (Le codifiche GIF usano [fast]) + spa: utiliza la búsqueda [rápida] a una posición aproximada por delante de la marca de tiempo, frente a una búsqueda específica [exacta] de fotogramas. (Las codificaciones GIF utilizan [rápido]) + jpn: は、[正確な]フレーム検索に対して、タイムスタンプより先の大まかな位置への[高速]シークを使用します。(GIFエンコーディングは[fast]を使用) + rus: использует [быстрый] поиск до приблизительной позиции впереди временной метки, а не конкретный [точный] поиск кадра. (В кодировках GIF используется [быстрый]) + por: utiliza a pesquisa [rápida] para uma posição aproximada antes do carimbo de data/hora, em vez de uma pesquisa de fotogramas [exacta] específica. (As codificações GIF usam [fast]) + swe: använder [snabb] sökning till en ungefärlig position före tidsstämpeln, jämfört med en specifik [exakt] ramuppslagning. (GIF-kodningar använder [fast]) + pol: używa [szybkiego] wyszukiwania do przybliżonej pozycji przed znacznikiem czasu, a nie konkretnego [dokładnego] wyszukiwania klatek. (Kodowania GIF używają [fast]) chs: 使用[快速]查找时间戳前的大致位置,而不是特定的[精确]帧查找。(GIF 编码使用[快速])。 - ukr: використовує [швидкий] пошук приблизної позиції перед міткою часу, а не - пошук конкретного [точного] кадру. (Для кодувань GIF використовується - [fast]) - kor: 는 타임스탬프보다 앞선 대략적인 위치로 [빠른] 탐색을 사용하고, 특정 [정확한] 프레임 조회는 [정확한] 탐색을 사용합니다. - (GIF 인코딩은 [fast] 사용) - ron: utilizează căutarea [rapidă] a unei poziții aproximative înainte de marca - temporală, față de o căutare specifică [exactă] a cadrului. (codificările - GIF utilizează [fast]) + ukr: використовує [швидкий] пошук приблизної позиції перед міткою часу, а не пошук конкретного [точного] кадру. (Для кодувань GIF використовується [fast]) + kor: 는 타임스탬프보다 앞선 대략적인 위치로 [빠른] 탐색을 사용하고, 특정 [정확한] 프레임 조회는 [정확한] 탐색을 사용합니다. (GIF 인코딩은 [fast] 사용) + ron: utilizează căutarea [rapidă] a unei poziții aproximative înainte de marca temporală, față de o căutare specifică [exactă] a cadrului. (codificările GIF utilizează [fast]) Both must have values to be enabled: eng: Both must have values to be enabled deu: Beide müssen Werte haben, um aktiviert zu werden @@ -11193,21 +10610,15 @@ Audio Files (*.mp3 *.aac *.wav *.flac);;All Files (*): ron: Fișiere audio (*.mp3 *.aac *.wav *.flac);;Toate fișierele (*) is not in supported format (SRT, ASS, SSA, PGS), skipping extraction: eng: is not in supported format (SRT, ASS, SSA, PGS), skipping extraction - deu: nicht im unterstützten Format ist (SRT, ASS, SSA, PGS), wird die - Extraktion übersprungen - fra: n'est pas dans un format supporté (SRT, ASS, SSA, PGS), l'extraction est - ignorée. - ita: non è in un formato supportato (SRT, ASS, SSA, PGS), si salta - l'estrazione. - spa: no está en un formato compatible (SRT, ASS, SSA, PGS), se omite la - extracción + deu: nicht im unterstützten Format ist (SRT, ASS, SSA, PGS), wird die Extraktion übersprungen + fra: n'est pas dans un format supporté (SRT, ASS, SSA, PGS), l'extraction est ignorée. + ita: non è in un formato supportato (SRT, ASS, SSA, PGS), si salta l'estrazione. + spa: no está en un formato compatible (SRT, ASS, SSA, PGS), se omite la extracción jpn: がサポートされているフォーマット(SRT、ASS、SSA、PGS)でない場合、抽出をスキップする。 rus: не в поддерживаемом формате (SRT, ASS, SSA, PGS), пропуск извлечения por: não está num formato suportado (SRT, ASS, SSA, PGS), saltando a extração - swe: inte är i ett format som stöds (SRT, ASS, SSA, PGS), hoppa över - extraktionen - pol: nie jest w obsługiwanym formacie (SRT, ASS, SSA, PGS), pomijanie - ekstrakcji + swe: inte är i ett format som stöds (SRT, ASS, SSA, PGS), hoppa över extraktionen + pol: nie jest w obsługiwanym formacie (SRT, ASS, SSA, PGS), pomijanie ekstrakcji chs: 不支持的格式(SRT、ASS、SSA、PGS),跳过提取 ukr: не у підтримуваному форматі (SRT, ASS, SSA, PGS), пропускається вилучення kor: 가 지원되지 않는 형식(SRT, ASS, SSA, PGS)이므로 추출 건너뛰기 @@ -11572,3 +10983,288 @@ Options: ukr: Параметри kor: 옵션 ron: Opțiuni +OCR conversion failed: + eng: OCR conversion failed + deu: OCR-Konvertierung fehlgeschlagen + fra: Échec de la conversion OCR + ita: Conversione OCR non riuscita + spa: Error de conversión OCR + jpn: OCR変換に失敗 + rus: Не удалось выполнить преобразование OCR + por: A conversão de OCR falhou + swe: OCR-omvandlingen misslyckades + pol: Konwersja OCR nie powiodła się + chs: OCR 转换失败 + ukr: OCR-перетворення не вдалося + kor: OCR 변환 실패 + ron: Conversia OCR a eșuat +Extract as .sup (image - fast): + eng: Extract as .sup (image - fast) + deu: Extrahieren als .sup (Bild - schnell) + fra: Extraire en .sup (image - rapide) + ita: Estrarre come .sup (immagine - veloce) + spa: Extraer como .sup (imagen - rápido) + jpn: .sup(画像-高速)として抽出する。 + rus: Извлечение в формате .sup (изображение - быстро) + por: Extrair como .sup (imagem - rápido) + swe: Extrahera som .sup (bild - snabbt) + pol: Wyodrębnij jako .sup (obraz - szybko) + chs: 提取为 .sup(图像 - 快速) + ukr: Розпакувати як .sup (зображення - швидко) + kor: .sup로 추출(이미지 - 빠른) + ron: Extras ca .sup (imagine - rapid) +Convert to .srt (OCR - 3-5 min): + eng: Convert to .srt (OCR - 3-5 min) + deu: Konvertieren in .srt (OCR - 3-5 Minuten) + fra: Convertir en .srt (OCR - 3-5 min) + ita: Convertire in .srt (OCR - 3-5 min) + spa: Convertir a .srt (OCR - 3-5 min) + jpn: .srtに変換する(OCR - 3-5分) + rus: Преобразование в .srt (OCR - 3-5 минут) + por: Converter para .srt (OCR - 3-5 min) + swe: Konvertera till .srt (OCR - 3-5 min) + pol: Konwersja do formatu .srt (OCR - 3-5 min) + chs: 转换为 .srt (OCR - 3-5 分钟) + ukr: Конвертувати в .srt (розпізнавання тексту - 3-5 хв) + kor: .srt로 변환(OCR - 3~5분) + ron: Conversia în .srt (OCR - 3-5 min) +Save Subtitle As: + eng: Save Subtitle As + deu: Untertitel speichern unter + fra: Enregistrer le sous-titre sous + ita: Salva sottotitoli con nome + spa: Guardar subtítulo como + jpn: 字幕の名前を付けて保存 + rus: Сохранить субтитры как + por: Guardar legenda como + swe: Spara undertext som + pol: Zapisz napisy jako + chs: 将字幕另存为 + ukr: Зберегти субтитри як + kor: 자막을 다른 이름으로 저장 + ron: Salvați subtitrarea ca +Subtitle Files: + eng: Subtitle Files + deu: Untertiteldateien + fra: Fichiers de sous-titres + ita: File dei sottotitoli + spa: Archivos de subtítulos + jpn: 字幕ファイル + rus: Файлы субтитров + por: Ficheiros de legendas + swe: Undertextfiler + pol: Pliki z napisami + chs: 字幕文件 + ukr: Файли субтитрів + kor: 자막 파일 + ron: Fișiere de subtitrare +Running command extract subtitle commands: + eng: Running command extract subtitle commands + deu: Laufende Befehle zum Extrahieren von Untertiteln + fra: Commande d'exécution extraire les commandes de sous-titres + ita: Esecuzione dei comandi di estrazione dei sottotitoli + spa: Ejecutar comandos extraer comandos de subtítulos + jpn: 字幕コマンドの実行 + rus: Выполнение команд извлечения субтитров + por: Comando de execução extrair comandos de legendas + swe: Körkommando extrahera undertextkommandon + pol: Uruchamianie poleceń wyodrębniania napisów + chs: 运行命令提取字幕命令 + ukr: Запуск команд вилучення субтитрів + kor: 자막 추출 명령 실행 + ron: Comanda de execuție extrage comenzile de subtitrare +Converting .sup to .srt using OCR: + eng: Converting .sup to .srt using OCR + deu: Konvertierung von .sup in .srt mit OCR + fra: Conversion de .sup en .srt par OCR + ita: Conversione di .sup in .srt tramite OCR + spa: Conversión de .sup a .srt mediante OCR + jpn: OCRを使用して.supを.srtに変換する + rus: Преобразование .sup в .srt с помощью OCR + por: Conversão de .sup para .srt utilizando OCR + swe: Konvertera .sup till .srt med hjälp av OCR + pol: Konwersja .sup do .srt przy użyciu OCR + chs: 使用 OCR 将 .sup 转换为 .srt + ukr: Перетворення .sup в .srt за допомогою OCR + kor: OCR을 사용하여 .sup를 .srt로 변환하기 + ron: Convertirea .sup în .srt utilizând OCR +OCR conversion successful: + eng: OCR conversion successful + deu: OCR-Konvertierung erfolgreich + fra: Conversion OCR réussie + ita: Conversione OCR riuscita + spa: Conversión OCR correcta + jpn: OCR変換に成功 + rus: Успешное преобразование OCR + por: Conversão de OCR bem sucedida + swe: OCR-konvertering framgångsrik + pol: Konwersja OCR powiodła się + chs: OCR 转换成功 + ukr: OCR-перетворення успішно виконано + kor: OCR 변환 성공 + ron: Conversie OCR reușită +Removed .sup file, kept .srt: + eng: Removed .sup file, kept .srt + deu: .sup-Datei entfernt, .srt beibehalten + fra: Suppression du fichier .sup, conservation du fichier .srt + ita: Rimosso il file .sup, mantenuto .srt + spa: Eliminado archivo .sup, mantenido .srt + jpn: .supファイルを削除し、.srtを残す。 + rus: Удален файл .sup, сохранен .srt + por: Removido o ficheiro .sup, mantido o .srt + swe: Borttagen .sup-fil, behållen .srt + pol: Usunięto plik .sup, zachowano .srt + chs: 删除 .sup 文件,保留 .srt 文件 + ukr: Видалено файл .sup, залишено .srt + kor: .sup 파일 제거, .srt 파일 유지 + ron: Eliminat fișierul .sup, păstrat .srt +Successfully converted to SRT with OCR: + eng: Successfully converted to SRT with OCR + deu: Erfolgreich in SRT mit OCR konvertiert + fra: Conversion réussie en SRT avec OCR + ita: Convertito con successo in SRT con OCR + spa: Convertido con éxito a SRT con OCR + jpn: OCRでSRTへの変換に成功 + rus: Успешное преобразование в SRT с помощью OCR + por: Convertido com êxito para SRT com OCR + swe: Framgångsrikt konverterad till SRT med OCR + pol: Pomyślnie przekonwertowano na SRT z OCR + chs: 通过 OCR 成功转换为 SRT + ukr: Успішне перетворення в SRT з розпізнаванням тексту + kor: OCR을 사용하여 SRT로 성공적으로 변환 완료 + ron: Convertit cu succes în SRT cu OCR +Detected External Programs: + eng: Detected External Programs + deu: Erkannte externe Programme + fra: Programmes externes détectés + ita: Programmi esterni rilevati + spa: Programas externos detectados + jpn: 検出された外部プログラム + rus: Обнаруженные внешние программы + por: Programas externos detectados + swe: Upptäckta externa program + pol: Wykryte programy zewnętrzne + chs: 检测到的外部程序 + ukr: Виявлені зовнішні програми + kor: 감지된 외부 프로그램 + ron: Programe externe detectate +NVIDIA hardware encoding: + eng: NVIDIA hardware encoding + deu: NVIDIA-Hardware-Kodierung + fra: Encodage matériel NVIDIA + ita: Codifica hardware NVIDIA + spa: Codificación por hardware NVIDIA + jpn: NVIDIAハードウェアエンコーディング + rus: Аппаратное кодирование NVIDIA + por: Codificação de hardware NVIDIA + swe: NVIDIA hårdvarukodning + pol: Kodowanie sprzętowe NVIDIA + chs: 英伟达硬件编码 + ukr: Апаратне кодування NVIDIA + kor: NVIDIA 하드웨어 인코딩 + ron: Codificare hardware NVIDIA +Intel hardware encoding: + eng: Intel hardware encoding + deu: Intel-Hardware-Kodierung + fra: Encodage matériel Intel + ita: Codifica hardware Intel + spa: Codificación de hardware Intel + jpn: インテル・ハードウェア・エンコーディング + rus: Аппаратное кодирование Intel + por: Codificação de hardware Intel + swe: Kodning av Intel-hårdvara + pol: Kodowanie sprzętowe Intel + chs: 英特尔硬件编码 + ukr: Апаратне кодування Intel + kor: 인텔 하드웨어 인코딩 + ron: Codificare hardware Intel +AMD hardware encoding: + eng: AMD hardware encoding + deu: AMD-Hardware-Kodierung + fra: Encodage matériel AMD + ita: Codifica hardware AMD + spa: Codificación por hardware AMD + jpn: AMDハードウェアエンコーディング + rus: Аппаратное кодирование AMD + por: Codificação de hardware AMD + swe: Kodning av AMD-hårdvara + pol: Kodowanie sprzętowe AMD + chs: AMD 硬件编码 + ukr: Апаратне кодування AMD + kor: AMD 하드웨어 인코딩 + ron: Codare hardware AMD +HDR10+ metadata extraction: + eng: HDR10+ metadata extraction + deu: Extraktion von HDR10+-Metadaten + fra: Extraction des métadonnées HDR10 + ita: Estrazione dei metadati HDR10+ + spa: Extracción de metadatos HDR10 + jpn: HDR10+メタデータの抽出 + rus: Извлечение метаданных HDR10+ + por: Extração de metadados HDR10+ + swe: Extrahering av HDR10+ metadata + pol: Ekstrakcja metadanych HDR10+ + chs: HDR10+ 元数据提取 + ukr: Вилучення метаданих HDR10+ + kor: HDR10+ 메타데이터 추출 + ron: Extragerea metadatelor HDR10+ +PGS subtitle OCR: + eng: PGS subtitle OCR + deu: PGS Untertitel OCR + fra: Sous-titres PGS OCR + ita: PGS sottotitoli OCR + spa: OCR de subtítulos PGS + jpn: PGS字幕OCR + rus: PGS субтитры OCR + por: OCR de legendas PGS + swe: PGS undertext OCR + pol: OCR napisów PGS + chs: PGS 字幕 OCR + ukr: Розпізнавання субтитрів PGS + kor: PGS 자막 OCR + ron: PGS subtitrare OCR +Open containing folder: + eng: Open containing folder + deu: Enthaltenden Ordner öffnen + fra: Ouvrir le dossier contenant + ita: Aprire la cartella contenente + spa: Abrir carpeta contenedora + jpn: フォルダを開く + rus: Откройте содержащую папку + por: Abrir a pasta que contém o ficheiro + swe: Öppna mappen med innehåll + pol: Otwórz folder zawierający + chs: 打开包含文件夹 + ukr: Відкрийте папку з вмістом + kor: 포함 폴더 열기 + ron: Deschideți folderul de conținut +Subtitle extraction cancelled: + eng: Subtitle extraction cancelled + deu: Extraktion von Untertiteln abgebrochen + fra: Extraction des sous-titres annulée + ita: Estrazione dei sottotitoli annullata + spa: Extracción de subtítulos cancelada + jpn: 字幕抽出中止 + rus: Извлечение субтитров отменено + por: Extração de legendas cancelada + swe: Extrahering av undertexter avbruten + pol: Wyodrębnianie napisów anulowane + chs: 取消字幕提取 + ukr: Вилучення субтитрів скасовано + kor: 자막 추출이 취소되었습니다. + ron: Extragerea subtitrărilor anulată +Cleaned up partial file: + eng: Cleaned up partial file + deu: Bereinigte Teildatei + fra: Nettoyage d'un fichier partiel + ita: File parziale ripulito + spa: Archivo parcial limpiado + jpn: 部分ファイルをクリーンアップ + rus: Очистка частичного файла + por: Ficheiro parcial limpo + swe: Rensade upp partiell fil + pol: Oczyszczony plik częściowy + chs: 清理部分文件 + ukr: Очищено частину файлу + kor: 일부 파일 정리 + ron: Fișier parțial curățat diff --git a/fastflix/entry.py b/fastflix/entry.py index b10348fb..164a1068 100644 --- a/fastflix/entry.py +++ b/fastflix/entry.py @@ -23,6 +23,11 @@ def separate_app_process(worker_queue, status_queue, log_queue, portable_mode=False): """This prevents any QT components being imported in the main process""" + if sys.platform == "win32": + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("cdgriffith.FastFlix") + from fastflix.models.config import Config settings = Config().pre_load(portable_mode=portable_mode) diff --git a/fastflix/models/config.py b/fastflix/models/config.py index 1c0b90ec..7507fb31 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -205,7 +205,7 @@ def find_ocr_tool(name): if file.is_file() and file.name.lower() in (name, f"{name}.exe"): return file - + def find_rigaya_encoder(base_name: str) -> Path | None: """Find Rigaya encoder binaries with case-insensitive search.""" # Try common binary names in order of preference @@ -220,7 +220,6 @@ def find_rigaya_encoder(base_name: str) -> Path | None: return location - class Config(BaseModel): version: str = __version__ config_path: Path = Field(default_factory=get_config) @@ -298,6 +297,11 @@ class Config(BaseModel): use_keyframes_for_preview: bool = True + @property + def pgs_ocr_available(self) -> bool: + import importlib.util + + return self.tesseract_path is not None and importlib.util.find_spec("pgsrip") is not None def encoder_opt(self, profile_name, profile_option_name): encoder_settings = getattr(self.profiles[self.selected_profile], profile_name) diff --git a/fastflix/ui_styles.py b/fastflix/ui_styles.py index 953a57f6..3d3c1394 100644 --- a/fastflix/ui_styles.py +++ b/fastflix/ui_styles.py @@ -24,10 +24,13 @@ def get_scaled_stylesheet(theme: str) -> str: """Generate a scaled stylesheet based on the current theme and scale factors.""" - font_size = scaler.scale_font(FONTS.LARGE) + # Use pt instead of px to prevent QFont::setPointSize warnings in frozen executables. + # Pixel-based font-size causes pointSize() to return -1, which triggers Qt warnings + # 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) - base = f"QWidget {{ font-size: {font_size}px; }}" + base = f"QWidget {{ font-size: {font_size_pt}pt; }}" if theme == "onyx": base += f""" @@ -50,14 +53,14 @@ def get_scaled_stylesheet(theme: str) -> str: def get_video_options_stylesheet(theme: str) -> str: """Generate scaled stylesheet for the video options tab widget.""" - tab_font_size = scaler.scale_font(FONTS.MEDIUM) + tab_font_size_pt = max(6, round(scaler.scale_font(FONTS.MEDIUM) * 0.75)) combo_min_height = scaler.scale(22) if theme == "onyx": return f""" * {{ background-color: #4f5962; color: white; }} QTabWidget {{ margin-top: {scaler.scale(34)}px; background-color: #4f5962; }} - QTabBar {{ font-size: {tab_font_size}px; background-color: #4f5962; }} + QTabBar {{ font-size: {tab_font_size_pt}pt; background-color: #4f5962; }} QComboBox {{ min-height: {combo_min_height}px; }} """ return "" @@ -65,8 +68,8 @@ def get_video_options_stylesheet(theme: str) -> str: def get_menubar_stylesheet() -> str: """Generate scaled stylesheet for the menu bar.""" - font_size = scaler.scale_font(FONTS.LARGE) - return f"font-size: {font_size}px" + font_size_pt = max(6, round(scaler.scale_font(FONTS.LARGE) * 0.75)) + return f"font-size: {font_size_pt}pt" def get_onyx_combobox_style() -> str: diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py index ea111a29..5a69ca59 100644 --- a/fastflix/widgets/background_tasks.py +++ b/fastflix/widgets/background_tasks.py @@ -47,7 +47,7 @@ def run(self): class ExtractSubtitleSRT(QtCore.QThread): - def __init__(self, app: FastFlixApp, main, index, signal, language, use_ocr=False): + def __init__(self, app: FastFlixApp, main, index, signal, language, use_ocr=False, output_path=None): super().__init__(main) self.main = main self.app = app @@ -55,6 +55,27 @@ def __init__(self, app: FastFlixApp, main, index, signal, language, use_ocr=Fals self.signal = signal self.language = language self.use_ocr = use_ocr + self.output_path = output_path + self._cancelled = False + self._process = None + + def cancel(self): + self._cancelled = True + if self._process is not None: + try: + self._process.kill() + except Exception: + pass + + def _cleanup_file(self, filepath): + """Remove a partial output file if it exists.""" + try: + p = Path(filepath) + if p.exists(): + p.unlink() + self.main.thread_logging_signal.emit(f"INFO:{t('Cleaned up partial file')}: {p.name}") + except Exception: + pass def run(self): subtitle_format = self._get_subtitle_format() @@ -62,7 +83,7 @@ def run(self): self.main.thread_logging_signal.emit( f"WARNING:{t('Could not determine subtitle format for track')} {self.index}, {t('skipping extraction')}" ) - self.signal.emit() + self.signal.emit("") return # Flag to track if we need OCR conversion after extraction @@ -81,55 +102,94 @@ def run(self): extension = "sup" output_args = ["-c", "copy"] # If OCR is requested, we'll extract .sup first, then convert after - should_convert_to_srt = self.use_ocr and self.app.fastflix.config.enable_pgs_ocr + should_convert_to_srt = self.use_ocr and self.app.fastflix.config.pgs_ocr_available else: self.main.thread_logging_signal.emit( f"WARNING:{t('Subtitle Track')} {self.index} {t('is not in supported format (SRT, ASS, SSA, PGS), skipping extraction')}: {subtitle_format}" ) - self.signal.emit() + self.signal.emit("") return - # filename = str( - # Path(self.main.output_video).parent / f"{self.main.output_video}.{self.index}.{self.language}.srt" - # ).replace("\\", "/") - filename = str( - Path(self.main.output_video).parent / f"{self.main.output_video}.{self.index}.{self.language}.{extension}" - ).replace("\\", "/") + if self.output_path: + if subtitle_format == "pgs" and should_convert_to_srt: + # For OCR: extract intermediate .sup, then convert to user's chosen .srt path + filename = str(Path(self.output_path).with_suffix(".sup")) + else: + filename = self.output_path + else: + filename = str( + Path(self.main.output_video).parent + / f"{self.main.output_video}.{self.index}.{self.language}.{extension}" + ).replace("\\", "/") self.main.thread_logging_signal.emit(f"INFO:{t('Extracting subtitles to')} {filename}") + command = [ + str(self.app.fastflix.config.ffmpeg), + "-y", + "-i", + str(self.main.input_video), + "-map", + f"0:s:{self.index}", + *output_args, + filename, + ] + self.main.thread_logging_signal.emit( + f"INFO:{t('Running command extract subtitle commands')} {' '.join(command)}" + ) try: - result = run( - [ - self.app.fastflix.config.ffmpeg, - "-y", - "-i", - self.main.input_video, - "-map", - f"0:s:{self.index}", - *output_args, - filename, - ], + self._process = Popen( + command, stdout=PIPE, stderr=STDOUT, ) + stdout, _ = self._process.communicate() + returncode = self._process.returncode + self._process = None except Exception as err: + self._process = None self.main.thread_logging_signal.emit(f"ERROR:{t('Could not extract subtitle track')} {self.index} - {err}") - else: - if result.returncode != 0: - self.main.thread_logging_signal.emit( - f"WARNING:{t('Could not extract subtitle track')} " - f"{self.index}: {result.stdout.decode('utf-8', errors='ignore')}" - ) + self.signal.emit("") + return + + if self._cancelled: + self.main.thread_logging_signal.emit(f"INFO:{t('Subtitle extraction cancelled')}") + self._cleanup_file(filename) + self.signal.emit("") + return + + if returncode != 0: + self.main.thread_logging_signal.emit( + f"WARNING:{t('Could not extract subtitle track')} " + f"{self.index}: {stdout.decode('utf-8', errors='ignore') if stdout else ''}" + ) + self.signal.emit("") + return + + self.main.thread_logging_signal.emit(f"INFO:{t('Extracted subtitles successfully')}") + + # Determine the final output path + final_path = filename + + # If this is PGS and OCR was requested, convert the .sup to .srt + if subtitle_format == "pgs" and should_convert_to_srt: + if self._cancelled: + self._cleanup_file(filename) + self.signal.emit("") + return + if self._convert_sup_to_srt(filename): + self.main.thread_logging_signal.emit(f"INFO:{t('Successfully converted to SRT with OCR')}") + # Final output is the .srt path + final_path = str(Path(filename).with_suffix(".srt")) else: - self.main.thread_logging_signal.emit(f"INFO:{t('Extracted subtitles successfully')}") + self.main.thread_logging_signal.emit(f"WARNING:{t('OCR conversion failed, kept .sup file')}") - # If this is PGS and OCR was requested, convert the .sup to .srt - if subtitle_format == "pgs" and should_convert_to_srt: - if self._convert_sup_to_srt(filename): - self.main.thread_logging_signal.emit(f"INFO:{t('Successfully converted to SRT with OCR')}") - else: - self.main.thread_logging_signal.emit(f"WARNING:{t('OCR conversion failed, kept .sup file')}") - self.signal.emit() + if self._cancelled: + self._cleanup_file(filename) + self._cleanup_file(str(Path(filename).with_suffix(".srt"))) + self.signal.emit("") + return + + self.signal.emit(final_path) def _get_subtitle_format(self): try: @@ -186,10 +246,6 @@ def _check_pgsrip_dependencies(self) -> bool: if not self.app.fastflix.config.tesseract_path: missing.append("tesseract-ocr") - # Check mkvmerge (CRITICAL - required by pgsrip but not documented) - if not self.app.fastflix.config.mkvmerge_path: - missing.append("mkvtoolnix") - # Check if pgsrip Python library is available if importlib.util.find_spec("pgsrip") is None: missing.append("pgsrip (Python library)") @@ -199,21 +255,19 @@ def _check_pgsrip_dependencies(self) -> bool: f"ERROR:{t('Missing dependencies for PGS OCR')}: {', '.join(missing)}\n\n" f"Install instructions:\n" f" pgsrip: pip install pgsrip\n" - f" Linux: sudo apt install tesseract-ocr mkvtoolnix\n" - f" macOS: brew install tesseract mkvtoolnix\n" - f" Windows:\n" - f" - Tesseract: https://github.com/UB-Mannheim/tesseract/wiki\n" - f" - MKVToolNix: https://mkvtoolnix.download/downloads.html" + f" Linux: sudo apt install tesseract-ocr\n" + f" macOS: brew install tesseract\n" + f" Windows: https://github.com/UB-Mannheim/tesseract/wiki" ) return False return True def _convert_sup_to_srt(self, sup_filepath: str) -> bool: - """Convert PGS subtitle to .srt using pgsrip OCR by processing the original MKV + """Convert extracted .sup PGS subtitle to .srt using pgsrip OCR Args: - sup_filepath: Path to the extracted .sup file (used for naming output) + sup_filepath: Path to the extracted .sup file Returns: True if conversion successful, False otherwise @@ -227,24 +281,33 @@ def _convert_sup_to_srt(self, sup_filepath: str) -> bool: f"INFO:{t('Converting .sup to .srt using OCR')} (this may take 3-5 minutes)..." ) - # Import pgsrip Python API - from pgsrip import pgsrip, Mkv, Options + # Import pgsrip Python API - use Sup to process already-extracted .sup file + from pgsrip import pgsrip, Sup, Options from babelfish import Language as BabelLanguage + import pytesseract - # Set environment variables for pgsrip to find tesseract and mkvextract + # Set environment variables for pgsrip to find tesseract if self.app.fastflix.config.tesseract_path: tesseract_dir = str(Path(self.app.fastflix.config.tesseract_path).parent) os.environ["PATH"] = f"{tesseract_dir}{os.pathsep}{os.environ.get('PATH', '')}" os.environ["TESSERACT_CMD"] = str(self.app.fastflix.config.tesseract_path) + # pytesseract uses its own module-level variable, not the env var + pytesseract.pytesseract.tesseract_cmd = str(self.app.fastflix.config.tesseract_path) - if self.app.fastflix.config.mkvmerge_path: - mkvtoolnix_dir = str(Path(self.app.fastflix.config.mkvmerge_path).parent) - os.environ["PATH"] = f"{mkvtoolnix_dir}{os.pathsep}{os.environ.get('PATH', '')}" + # pgsrip's MediaPath parses the filename for language codes and may transform them + # (e.g., 3-letter "eng" becomes 2-letter "en"), causing a FileNotFoundError. + # Rename the .sup file to match what pgsrip expects before processing. + from pgsrip.media_path import MediaPath as PgsMediaPath - # pgsrip needs the original MKV file, not the extracted .sup sup_path = Path(sup_filepath) - video_path = Path(self.main.input_video) - media = Mkv(str(video_path)) + desired_srt = sup_path.with_suffix(".srt") + pgs_media_path = PgsMediaPath(str(sup_path)) + pgsrip_expected_path = Path(str(pgs_media_path)) + if sup_path != pgsrip_expected_path: + sup_path.rename(pgsrip_expected_path) + sup_path = pgsrip_expected_path + + media = Sup(str(sup_path)) # Configure options for pgsrip try: @@ -270,32 +333,30 @@ def _convert_sup_to_srt(self, sup_filepath: str) -> bool: ) # Get list of existing .srt files before conversion - existing_srts = set(video_path.parent.glob("*.srt")) + existing_srts = set(sup_path.parent.glob("*.srt")) # Run pgsrip conversion using Python API pgsrip.rip(media, options) # Find newly created .srt files - current_srts = set(video_path.parent.glob("*.srt")) + current_srts = set(sup_path.parent.glob("*.srt")) new_srts = current_srts - existing_srts if not new_srts: - raise Exception(f"pgsrip completed but no .srt file found in {video_path.parent}") + raise Exception(f"pgsrip completed but no .srt file found in {sup_path.parent}") # Get the first new .srt file srt_files = list(new_srts) - # Move the .srt file to the expected location (same dir as .sup was) + # Move the .srt file to the user's originally desired location created_srt = srt_files[0] - expected_srt = sup_path.with_suffix(".srt") - if created_srt != expected_srt: - # Move/rename to expected location + if created_srt != desired_srt: import shutil - shutil.move(str(created_srt), str(expected_srt)) + shutil.move(str(created_srt), str(desired_srt)) - self.main.thread_logging_signal.emit(f"INFO:{t('OCR conversion successful')}: {expected_srt.name}") + self.main.thread_logging_signal.emit(f"INFO:{t('OCR conversion successful')}: {desired_srt.name}") # Optionally delete the .sup file since we have .srt now try: diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index 19d4483c..0a472ce8 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import importlib.util +from pathlib import Path from typing import Union from box import Box @@ -49,7 +49,7 @@ class Subtitle(QtWidgets.QTabWidget): - extract_completed_signal = QtCore.Signal() + extract_completed_signal = QtCore.Signal(str) def __init__(self, app, parent, index, enabled=True, first=False): self.loading = True @@ -123,21 +123,22 @@ def __init__(self, app, parent, index, enabled=True, first=False): t("Convert to .srt (OCR - 3-5 min)"), lambda: self.extract(use_ocr=True) ) - # Enable OCR option only if user enabled it AND dependencies are available - if not self.app.fastflix.config.enable_pgs_ocr: + # Enable OCR option only if dependencies are available + if not self.app.fastflix.config.pgs_ocr_available: ocr_action.setEnabled(False) - ocr_action.setToolTip(t("Enable in Settings > 'Enable PGS to SRT OCR conversion'")) - else: - # Check if pgsrip Python library is available - pgsrip_ok = importlib.util.find_spec("pgsrip") is not None - - if not ( - self.app.fastflix.config.tesseract_path and self.app.fastflix.config.mkvmerge_path and pgsrip_ok - ): - ocr_action.setEnabled(False) - ocr_action.setToolTip(t("Missing dependencies: tesseract, mkvtoolnix, or pgsrip")) + ocr_action.setToolTip(t("Missing dependencies: tesseract or pgsrip")) self.widgets.extract.setMenu(extract_menu) + # Scale the dropdown arrow to match the up/down button icon sizes + arrow_size = scaler.scale(12) + arrow_right = scaler.scale(6) + arrow_pad = arrow_size + arrow_right + scaler.scale(4) + self.widgets.extract.setStyleSheet( + f"QPushButton {{ padding-right: {arrow_pad}px; }}" + f" QPushButton::menu-indicator {{ width: {arrow_size}px; height: {arrow_size}px;" + f" subcontrol-position: right center; subcontrol-origin: padding;" + f" right: {arrow_right}px; }}" + ) else: self.widgets.extract = QtWidgets.QPushButton(t("Extract")) self.widgets.extract.clicked.connect(self.extract) @@ -146,7 +147,21 @@ def __init__(self, app, parent, index, enabled=True, first=False): self.movie = QtGui.QMovie(loading_movie) self.movie.setScaledSize(QtCore.QSize(25, 25)) self.gif_label.setMovie(self.movie) - # self.movie.start() + + self.cancel_button = QtWidgets.QPushButton(t("Cancel")) + self.cancel_button.clicked.connect(self.cancel_extraction) + self.cancel_button.hide() + + self.view_button = QtWidgets.QPushButton( + QtGui.QIcon(get_icon("onyx-file-search", self.parent.app.fastflix.config.theme)), "" + ) + self.view_button.setToolTip(t("Open containing folder")) + self.view_button.setFixedWidth(scaler.scale(30)) + self.view_button.clicked.connect(self.view_extracted_file) + self.view_button.hide() + + self._worker = None + self._last_extracted_path = "" self.disposition_widget = Disposition( app=self.app, parent=self, track_name=f"Subtitle Track {index}", track_index=index, audio=False @@ -155,7 +170,7 @@ def __init__(self, app, parent, index, enabled=True, first=False): self.widgets.disposition.clicked.connect(self.disposition_widget.show) disposition_layout = QtWidgets.QHBoxLayout() - disposition_layout.addWidget(QtWidgets.QLabel(t("Dispositions"))) + # disposition_layout.addWidget(QtWidgets.QLabel(t("Dispositions"))) disposition_layout.addWidget(self.widgets.disposition) self.grid = QtWidgets.QGridLayout() @@ -163,10 +178,17 @@ def __init__(self, app, parent, index, enabled=True, first=False): self.grid.addWidget(self.widgets.track_number, 0, 1) self.grid.addWidget(self.widgets.title, 0, 2) self.grid.setColumnStretch(2, True) - # if sub_track.subtitle_type == "text": if sub_track.subtitle_type in ["text", "pgs"]: - self.grid.addWidget(self.widgets.extract, 0, 3) - self.grid.addWidget(self.gif_label, 0, 3) + self.extract_container = QtWidgets.QWidget() + extract_layout = QtWidgets.QHBoxLayout() + extract_layout.setContentsMargins(0, 0, 0, 0) + extract_layout.setSpacing(2) + extract_layout.addWidget(self.widgets.extract) + extract_layout.addWidget(self.gif_label) + extract_layout.addWidget(self.cancel_button) + extract_layout.addWidget(self.view_button) + self.extract_container.setLayout(extract_layout) + self.grid.addWidget(self.extract_container, 0, 3) self.gif_label.hide() self.grid.addLayout(disposition_layout, 0, 4) @@ -181,12 +203,31 @@ def __init__(self, app, parent, index, enabled=True, first=False): self.updating_burn = False self.extract_completed_signal.connect(self.extraction_complete) - def extraction_complete(self): - self.grid.addWidget(self.widgets.extract, 0, 3) + def extraction_complete(self, path: str = ""): + self.movie.stop() + self.gif_label.hide() + self.cancel_button.hide() + self.widgets.extract.show() + self._worker = None + if path: + self._last_extracted_path = path + self.view_button.show() + else: + self.view_button.hide() + + def cancel_extraction(self): + if self._worker is not None: + self._worker.cancel() self.movie.stop() self.gif_label.hide() + self.cancel_button.hide() self.widgets.extract.show() + def view_extracted_file(self): + if self._last_extracted_path: + parent_dir = str(Path(self._last_extracted_path).parent) + QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(parent_dir)) + def init_move_buttons(self): layout = QtWidgets.QVBoxLayout() layout.setSpacing(0) @@ -204,18 +245,48 @@ def init_move_buttons(self): layout.addWidget(self.widgets.down_button) return layout + def _get_extract_extension(self, use_ocr=False): + """Determine the file extension for subtitle extraction.""" + sub_track = self.app.fastflix.current_video.subtitle_tracks[self.index] + if sub_track.subtitle_type == "pgs": + return "srt" if use_ocr else "sup" + codec_name = sub_track.raw_info.get("codec_name", "").lower() if sub_track.raw_info else "" + if codec_name == "ass": + return "ass" + elif codec_name == "ssa": + return "ssa" + return "srt" + def extract(self, use_ocr=False): - worker = ExtractSubtitleSRT( + extension = self._get_extract_extension(use_ocr=use_ocr) + output_dir = Path(self.parent.main.output_video).parent + input_name = Path(self.parent.main.input_video).stem + default_name = f"{input_name}.{self.index}.{self.language}.{extension}" + default_path = str(output_dir / default_name) + + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + caption=t("Save Subtitle As"), + dir=default_path, + filter=f"{t('Subtitle Files')} (*.{extension})", + ) + if not filename: + return + + self._worker = ExtractSubtitleSRT( self.parent.app, self.parent.main, self.index, self.extract_completed_signal, language=self.language, use_ocr=use_ocr, + output_path=filename, ) - worker.start() - self.gif_label.show() + self._worker.start() self.widgets.extract.hide() + self.view_button.hide() + self.gif_label.show() + self.cancel_button.show() self.movie.start() def init_language(self, sub_track: SubtitleTrack): @@ -332,10 +403,13 @@ def select_all(self, select=True): for track in self.tracks: track.widgets.enable_check.setChecked(select) - def lang_match(self, track: Union[Subtitle, dict], ignore_first=False): + def lang_match(self, track: Union[Subtitle, SubtitleTrack, dict], ignore_first=False): if not self.app.fastflix.config.opt("subtitle_select"): return False - language = track.language if isinstance(track, Subtitle) else track.get("tags", {}).get("language", "") + if isinstance(track, (Subtitle, SubtitleTrack)): + language = track.language + else: + language = track.get("tags", {}).get("language", "") if not self.app.fastflix.config.opt("subtitle_select_preferred_language"): if ( not ignore_first diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 3a6dfb46..53b0a3f0 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -import importlib.util import logging import shutil from pathlib import Path @@ -50,11 +49,13 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): self.setMinimumSize(600, 200) self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint) layout = QtWidgets.QGridLayout() + layout.setColumnStretch(1, 1) ffmpeg_label = QtWidgets.QLabel("FFmpeg") self.ffmpeg_path = QtWidgets.QLineEdit() self.ffmpeg_path.setText(str(self.app.fastflix.config.ffmpeg)) ffmpeg_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + ffmpeg_path_button.setFixedWidth(30) ffmpeg_path_button.clicked.connect(lambda: self.select_ffmpeg()) layout.addWidget(ffmpeg_label, 0, 0) layout.addWidget(self.ffmpeg_path, 0, 1) @@ -64,6 +65,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): self.ffprobe_path = QtWidgets.QLineEdit() self.ffprobe_path.setText(str(self.app.fastflix.config.ffprobe)) ffprobe_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + ffprobe_path_button.setFixedWidth(30) ffprobe_path_button.clicked.connect(lambda: self.select_ffprobe()) layout.addWidget(ffprobe_label, 1, 0) layout.addWidget(self.ffprobe_path, 1, 1) @@ -73,6 +75,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): self.work_dir = QtWidgets.QLineEdit() self.work_dir.setText(str(self.app.fastflix.config.work_path)) work_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + work_path_button.setFixedWidth(30) work_path_button.clicked.connect(lambda: self.select_work_path()) layout.addWidget(work_dir_label, 2, 0) layout.addWidget(self.work_dir, 2, 1) @@ -102,6 +105,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): layout.addWidget(self.language_combo, 5, 1) config_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_FileIcon)) + config_button.setFixedWidth(30) config_button.clicked.connect( lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(str(self.config_file))) ) @@ -160,6 +164,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): if self.app.fastflix.config.nvencc: self.nvencc_path.setText(str(self.app.fastflix.config.nvencc)) nvenc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + nvenc_path_button.setFixedWidth(30) nvenc_path_button.clicked.connect(lambda: self.select_nvencc()) layout.addWidget(nvencc_label, 12, 0) layout.addWidget(self.nvencc_path, 12, 1) @@ -173,6 +178,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): if self.app.fastflix.config.vceencc: self.vceenc_path.setText(str(self.app.fastflix.config.vceencc)) vceenc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + vceenc_path_button.setFixedWidth(30) vceenc_path_button.clicked.connect(lambda: self.select_vceenc()) layout.addWidget(vceenc_label, 13, 0) layout.addWidget(self.vceenc_path, 13, 1) @@ -186,6 +192,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): if self.app.fastflix.config.qsvencc: self.qsvenc_path.setText(str(self.app.fastflix.config.qsvencc)) qsvencc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + qsvencc_path_button.setFixedWidth(30) qsvencc_path_button.clicked.connect(lambda: self.select_qsvencc()) layout.addWidget(qsvencc_label, 14, 0) layout.addWidget(self.qsvenc_path, 14, 1) @@ -199,6 +206,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): if self.app.fastflix.config.hdr10plus_parser: self.hdr10_parser_path.setText(str(self.app.fastflix.config.hdr10plus_parser)) hdr10_parser_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + hdr10_parser_path_button.setFixedWidth(30) hdr10_parser_path_button.clicked.connect(lambda: self.select_hdr10_parser()) layout.addWidget(hdr10_parser_label, 15, 0) layout.addWidget(self.hdr10_parser_path, 15, 1) @@ -212,6 +220,7 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs): self.output_label_path_button = QtWidgets.QPushButton( icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon) ) + self.output_label_path_button.setFixedWidth(30) self.output_label_path_button.clicked.connect(lambda: self.select_output_directory()) layout.addWidget(output_label, 17, 0) layout.addWidget(self.output_path_line_edit, 17, 1) @@ -237,6 +246,7 @@ def out_click(): if self.app.fastflix.config.source_directory: self.source_path_line_edit.setText(str(self.app.fastflix.config.source_directory)) source_label_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)) + source_label_path_button.setFixedWidth(30) source_label_path_button.clicked.connect(lambda: self.select_source_directory()) layout.addWidget(source_label, 19, 0) layout.addWidget(self.source_path_line_edit, 19, 1) @@ -270,22 +280,9 @@ def in_dir(): self.disable_deinterlace_button = QtWidgets.QCheckBox(t("Disable interlace check")) self.disable_deinterlace_button.setChecked(self.app.fastflix.config.disable_deinterlace_check) - - # PGS OCR Settings - self.enable_pgs_ocr = QtWidgets.QCheckBox(t("Enable PGS to SRT OCR conversion")) - self.enable_pgs_ocr.setChecked(self.app.fastflix.config.enable_pgs_ocr) - self.enable_pgs_ocr.setToolTip( - t("Convert image-based PGS subtitles to text SRT using OCR.\nTypically takes 3-5 minutes per movie.") - ) - - # Dependency status - self.ocr_status_label = QtWidgets.QLabel() - self.update_ocr_dependency_status() - self.use_keyframes_for_preview = QtWidgets.QCheckBox(t("Use keyframes for preview images")) self.use_keyframes_for_preview.setChecked(self.app.fastflix.config.use_keyframes_for_preview) - # Layouts layout.addWidget(self.use_sane_audio, 7, 0, 1, 2) layout.addWidget(self.disable_version_check, 8, 0, 1, 2) @@ -303,10 +300,42 @@ def in_dir(): layout.addWidget(self.disable_end_message, 22, 0, 1, 3) layout.addWidget(self.disable_deinterlace_button, 23, 0, 1, 3) - layout.addWidget(self.enable_pgs_ocr, 24, 0, 1, 2) - layout.addWidget(self.ocr_status_label, 24, 2, 1, 1) + layout.addWidget(self.use_keyframes_for_preview, 24, 0, 1, 3) + + # Detected External Programs section + detected_group = QtWidgets.QGroupBox(t("Detected External Programs")) + detected_layout = QtWidgets.QGridLayout() + detected_layout.setColumnStretch(1, 1) + + programs = [ + (self.app.fastflix.config.nvencc is not None, "NVEncC", t("NVIDIA hardware encoding")), + (self.app.fastflix.config.qsvencc is not None, "QSVEncC", t("Intel hardware encoding")), + (self.app.fastflix.config.vceencc is not None, "VCEEncC", t("AMD hardware encoding")), + (self.app.fastflix.config.hdr10plus_parser is not None, "HDR10+ Parser", t("HDR10+ metadata extraction")), + (self.app.fastflix.config.pgs_ocr_available, "Tesseract + pgsrip", t("PGS subtitle OCR")), + ] + + for 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}') + detected_layout.addWidget(status_label, row, 0) + detected_layout.addWidget(QtWidgets.QLabel(f"{name}"), row, 1) + detected_layout.addWidget(QtWidgets.QLabel(description), row, 2) + + if not self.app.fastflix.config.pgs_ocr_available: + ocr_link = QtWidgets.QLabel( + link( + "https://github.com/cdgriffith/FastFlix/wiki/PGS-OCR-Setup", + t("PGS OCR setup instructions"), + self.app.fastflix.config.theme, + ) + ) + ocr_link.setOpenExternalLinks(True) + detected_layout.addWidget(ocr_link, len(programs), 0, 1, 3) - layout.addWidget(self.use_keyframes_for_preview, 25, 0, 1, 3) + detected_group.setLayout(detected_layout) + layout.addWidget(detected_group, 25, 0, 1, 3) button_layout = QtWidgets.QHBoxLayout() button_layout.addStretch() @@ -317,32 +346,6 @@ def in_dir(): self.setLayout(layout) - def update_ocr_dependency_status(self): - """Update the OCR dependency status display""" - # Use config paths which use find_ocr_tool() - handles non-PATH locations - tesseract_ok = self.app.fastflix.config.tesseract_path is not None - mkvmerge_ok = self.app.fastflix.config.mkvmerge_path is not None - - # Check if pgsrip Python library is available - pgsrip_ok = importlib.util.find_spec("pgsrip") is not None - - status_parts = [] - status_parts.append("✓ tesseract" if tesseract_ok else "✗ tesseract") - status_parts.append("✓ mkvtoolnix" if mkvmerge_ok else "✗ mkvtoolnix") - status_parts.append("✓ pgsrip" if pgsrip_ok else "✗ pgsrip") - - status_text = " | ".join(status_parts) - - if not all([tesseract_ok, mkvmerge_ok, pgsrip_ok]): - status_text += "\n" + link( - "https://github.com/cdgriffith/FastFlix/wiki/PGS-OCR-Setup", - "Click here for installation instructions", - self.app.fastflix.config.theme, - ) - - self.ocr_status_label.setText(status_text) - self.ocr_status_label.setOpenExternalLinks(True) - def save(self): new_ffmpeg = Path(self.ffmpeg_path.text()) new_ffprobe = Path(self.ffprobe_path.text()) @@ -426,7 +429,6 @@ def save(self): self.app.fastflix.config.sticky_tabs = self.sticky_tabs.isChecked() self.app.fastflix.config.disable_complete_message = self.disable_end_message.isChecked() self.app.fastflix.config.disable_deinterlace_check = self.disable_deinterlace_button.isChecked() - self.app.fastflix.config.enable_pgs_ocr = self.enable_pgs_ocr.isChecked() self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() self.main.config_update() From 49ba81704b1b17572a036716bc8db5b5ee0f9b43 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sun, 8 Feb 2026 19:10:34 -0600 Subject: [PATCH 19/25] Update version and picture --- README.md | 70 ++++++++++++++++++++++++++++--------------- docs/gui_preview.png | Bin 316570 -> 419860 bytes fastflix/version.py | 2 +- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e60f0f5c..d93b1f7e 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ If one of the above software encoders is not listed, it is due to your version o ## Hardware Encoders -These will require the appropriate hardware. Nvidia GPU for NVEnc, Intel GPU/CPU for QSVEnc, and AMD GPU for VCEEnc. +These will require the appropriate hardware. Nvidia GPU for NVEnc, Intel GPU/CPU for QSVEnc, AMD GPU for VCEEnc, +or compatible hardware for VAAPI (Linux) and Apple VideoToolbox (macOS). -Most of these are using [rigaya's hardware encoders](https://github.com/rigaya?tab=repositories) that must be downloaded separately, -extracted to a directory of your choice, and then linked too in FastFlix Settings panel. +Most of the Nvidia, Intel, and AMD encoders are using [rigaya's hardware encoders](https://github.com/rigaya?tab=repositories) that must be downloaded separately, +extracted to a directory of your choice, and then linked to in FastFlix Settings panel. ### AV1 @@ -67,6 +68,28 @@ AV1 is only supported on the latest generation of graphics cards specifically th | Covers | | | | | bt.2020 | ✓ | ✓ | ✓ | +### Apple VideoToolbox (macOS) + +| Encoder | H264 VideoToolbox | HEVC VideoToolbox | +|-----------|-------------------|-------------------| +| HDR10 | | | +| HDR10+ | | | +| Audio | ✓ | ✓ | +| Subtitles | ✓ | ✓ | +| Covers | ✓ | ✓ | +| bt.2020 | ✓ | ✓ | + +### VAAPI (Linux) + +| Encoder | VAAPI H264 | VAAPI HEVC | VAAPI VP9 | VAAPI MPEG2 | +|-----------|------------|------------|-----------|-------------| +| HDR10 | | | | | +| HDR10+ | | | | | +| Audio | ✓ | ✓ | ✓ | ✓ | +| Subtitles | ✓ | ✓ | ✓ | ✓ | +| Covers | | | | | +| bt.2020 | ✓ | ✓ | ✓ | ✓ | + `✓ - Full support | ✓* - Limited support` @@ -83,29 +106,10 @@ Check out the [FFmpeg download page for static builds](https://ffmpeg.org/downlo To use rigaya's [Nvidia NVENC](https://github.com/rigaya/NVEnc/releases), [AMD VCE](https://github.com/rigaya/VCEEnc/releases), and [Intel QSV](https://github.com/rigaya/QSVEnc/releases) encoders, download them and extract them to folder on your hard drive. -Windows: Go into FastFlix's settings and select the corresponding EXE file for each of the encoders you want to use. +Windows: FastFlix can automatically download rigaya's encoders for you from the Settings panel. Alternatively, you can manually select the corresponding EXE file for each encoder. Linux: Install the rpm or deb and restart FastFlix -# Subtitle Extraction - -FastFlix can extract subtitles from video files in various formats including SRT, ASS, SSA, and PGS. - -## PGS to SRT OCR Conversion - -FastFlix includes experimental support for converting PGS (Presentation Graphic Stream) subtitles to SRT format using OCR. This feature automatically detects and uses installed OCR tools. - -**Requirements (auto-detected)**: -- Tesseract OCR 4.x or higher -- MKVToolNix (mkvextract/mkvmerge) - -**Important**: This feature only works when running FastFlix from source: -```bash -python -m fastflix -``` - -The Windows/Mac executable builds do not support PGS OCR due to environment limitations with the pgsrip library. If you need this feature, install FastFlix via pip and run from source. - # HDR On any 10-bit or higher video output, FastFlix will copy the input HDR colorspace (bt2020). Which is [different than HDR10 or HDR10+](https://codecalamity.com/hdr-hdr10-hdr10-hlg-and-dolby-vision/). @@ -156,9 +160,27 @@ Special thanks to [leonardyan](https://github.com/leonardyan) for numerous Chine [Ta0ba0](https://github.com/Ta0ba0) for the Russian language updates and [bovirus](https://github.com/bovirus) for Italian language updates! +# Subtitle Extraction + +FastFlix can extract subtitle tracks from video files. Text-based subtitles (SRT, ASS, SSA) are extracted directly using FFmpeg. + +## PGS to SRT OCR Conversion + +FastFlix supports converting PGS (Presentation Graphic Stream) image-based subtitles to SRT text format using OCR. This is useful for Blu-ray rips and other sources that use picture-based subtitles. + +For PGS tracks, the subtitle panel offers two options: +- **Extract as .sup** (fast) - extracts the raw PGS image subtitle +- **Convert to .srt** (OCR, 3-5 min) - extracts and converts to searchable text using OCR + +**Requirements (auto-detected from system PATH, standard install locations, or Windows registry)**: +- [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) 4.x or higher +- [MKVToolNix](https://mkvtoolnix.download/) (mkvextract/mkvmerge) + +FastFlix will show the detection status of these tools in the Settings panel under "Detected External Programs". + # License -Copyright (C) 2019-2025 Chris Griffith +Copyright (C) 2019-2026 Chris Griffith The code itself is licensed under the MIT which you can read in the `LICENSE` file.
Read more about the release licensing in the [docs](docs/README.md) folder.
diff --git a/docs/gui_preview.png b/docs/gui_preview.png index 57c14d41ada8bda67e29d15e1110221fd2caf14f..c84fe8f42410b958e7485a3b9e44808e6433f02a 100644 GIT binary patch literal 419860 zcmeEtWmH?;)^31er9h#jP#g-yr4-jvti|266eqY7+)IJt#VPI@g1ePcJXnG|1c%_z zo1XKX@4N5)d&juH?#{^A*?X+)HRYMlTx+hiqg0jU@E?;u1^@u~A3sQ|0{}Q3001Tv z4i@T;-j?V&>V)R1F83Z#HA=aQx_D$IsU!&i)W+f6nPH-?ah*QsxdH$Ly??)GgN`Mi z0f0Y+AEhNVy^QzoeKQ%R{b=q2loxOKFBh}Y+UxLee&Fe&5u*#$*?&DhPJAwD|M_F= zn^flbNE0=52?L^&XSne>uf){o@2+Y?kPPvv1q_f7s+z*kxu5Z$49K45Vlo7o2DOjv z9~#~oH=f%~`SG4gb)~uFzIq^)ClDmfrjrZ_SX*@Z*CjELO5n zFzPWS>P+n3RN6FPHKu)bAYw#}+qyF}-``YfXTXg*8~9p%_Bgg~w|0-(Nay%tR8r49 zy@}oPL>m3qn2nlc`hKmeFPldZP58KP6212;%2HnpW%@AaI6vxM5mRJW!;V*E(Zh{j z@YW_0k4q4G!s1}mK73ahre!H&Wxp2{)pte#`0}JWfDNtnD=xdAJlLZ#+mZj@yXsXCG+f3vk6Xa&292&9}8V zBC;A)dZ#dfo6HkkJEI57E6j)%tu+w=L4G7h$_t{(*%oRVXoN7!1U~3X#cri=a|-ff zp~iRuUSlI)@*ID!D9nMs>SG=fK5$vQ&^NrTRF_~K&y}I2Y&5-x?hCw678AYo$~!PU zRuJ~b9I3MOo!vz?;7f-%_%8pEHW=Ajn#i~uHIO{b7}!c-R0~j8qCo$qH2DQcf!j%V z?Ax7dQ|uNMnQlf=N78W1?&#V2x~D39V>{NebR9Ocdx6lX)D&97BWj3?k&!UpbyKRh za?WRWfE?uFR#(o>PB9afVj#x4j7i@hL$n3XZxG|Mwr-fuXDdWSMw%@L>GY$O5hjid zcJ8MY`jskGcCv0YKu^Z%aGwb8cIO+W>ioC!nG|g*NR{iS$V7a{*N^zujchP#-J(h~ z?viBf)Gxc{^&6}Kav&lg!ye&~iw%>SghI*-1^fCTU3vKwsQx7X)LtEh#T$Kg8io@) zT?CT1Jp9M>%v89wcDs#;UI2&nv?hNY>;isOIwh6G-}2iJ<};&F4xblTD9QrbsMfj( zC9RH@R@Qti&33Nail;I zQwW)W4V^SB1jK;Pj1At2w!+#s94wrR*R~s%Tt65i^y8Qe_A@|)`p<|yf6m`Gti0i8 zBZqxS;G@;M8A4_mRCa_V98o^L)I<1uvC~4A&ago&bdMa?$?DA6!(-CRqBUx_lu5GQj zJW_S+k_Abs(v}w@^$W>bko@;~tH_E3Y~aTf1(7H$H33_^hVsQ*25-koJ|M=)d){4~#WIz{1759*KCyPZgv77Gb0! z9g=%{Fk-n|Ah!1zjzlQ6DuacDtB14vjdwm9nBqIuSiE@_*<#Uq^g8N4G6w-Ab8H(m z6FO6YN!p2C)gGR9EO&KNhwc?dN6Th~v9h4r3f_yO_NT4ITwPaJ1O-zs0q>d(d+zRp zGR0k=FsXDto^>boh1|dJ%ac)i0xNVYJDkM6|J47YDZDa*u}=GUbCn$w+L$pCb8#Sj zC7wlHJaO&r^0nb`*}79T|K0$oLS^&(yiOmf2Nc6kpO$yCpxTx8f_lVF?z3CJ1sm9+ zJYfeWA8M_f)?#b~7EN6=Hfvi+A$r@4GQ2t(40`+1U40uot-ThsMJhMX8$-i|WTYty ztVUvX=HqRi)oMmT#Qk{?keJa4hX!cv+2IV`tZBZhqZsTl=*`G;&gQGfhF-_W4~0r( zMS%|1rM{@c zD$i2B)6|B0D9m^9(B_f&%q-xXpYDJ^>gGus&`6}Y!!0*oKVv81sgYcPpYwhnGT!1UiVMT0MJzZPFHoW{sI&Z+!eMen~lczhNn{2dH&^Wkt=@3DI{qcsyQYe7)?!;Iul(u$+D%pY=gYAX>8J`dgait-XJ_`d;2Nt zmg&+6=xa+6qFs`0@6;PMJj?0bHyxX@kj)RbWt0WaL$u3@I@e*%b<>CAS8uf8kY7}j-4OKIf@WSp|}tj)UKe;mCPm>#047ht`Hpv= zr`o}m1ux6?}TZPs*UWq)3(WE0MpvSN^=+#DibH= z4;8@ktz#hg~c0VYUQVjNI{vg-j)QTxB0n*2%$yrZ#pP>Qy9ciLdg^8IZ!( z;n8gVo0f(?Q(U>$FR!AC&Gm5Ro*o0Px@X7%7{o6Esg9kawxqb`uO5!>mc4elI`Eb! zzBQVBIhgo?W!03X_pl;GxHjG`)pS~^^IB)Z2Zxt!pW0e0fPas&#E6|}>z#@9w0&;x zC;bCmYv(r0TYk7WOLIW$Z5T>_zQQ^Wi*TOfFk9_vj8zVp{qP2d;(dWyX@kM1msmi7=d*8FJ4J->}YmTe{Vw6!UnV(|%b$qy@!| zB60u0R%+&Fb8%8V?~*qHEg^_-Z=I61d#&@)E?IO`KX!KLVijWNO~Y`ZH~auqEpOF$ z$FpNWVW%aaZv!^Tw}2ic5JohJGe`2nW~%Z}F2Grkh6(Twft;vFKGa@+@Aj8@msbwRpMw} z4;Z(J*b6SycOiei1RS5ltyY<|vNf0eX!SqfybDpe^SkQ~c(~svtD4Y$K-cQ>RMgBw zCwMr={>HU&GCA+f&d!yu%bKfR+2vs`ScNg`y6VG8IV(9`m8Z^DJEVUf<;{`6FT_ z@{Z4oM>WBd;c9WI(_!%bshL{$YI}GTsY&sOa(elm{ycn>_SYBvl=HlmthOTL2XF`Yi$nMhnKU#NX2%14fu6Su zIiQNT!BhAOVVya!6dK=oWyEyg*$SV~=N(?`KEVBIqE=sSr~RjAleOWY`h=*(n!7## zaNe{KSz?#o07e4c*MY(V{GPP#m3L9rT0QmQj4dB z0r!O{6Rk;dncU2p$X6Yfkpyvd6!$tSkY&N*)LG)f!eo+OY^!FAMC>i-&AD!`aArod za$|csi+^x++8*o8)`4qojpc&{9*6@5IeIV8H{igQ4Gv=M;ElS^wpJOH z-?8BO)w$Et0tU*`;4eu3D^zsZ%}4lzt30)RttS9Q$O>>5H`C@xcF(XvnVHBr5Sjw9 zk_ILs$bu|jl5CDr3-ebF+msS)7?&UY9?hy_mBBq$E8E-!YNTDXdyYH%`F6wei^mj} zf;fCOfnU9RB{k-W4;p|&C?Fqk?IcDaD;y%<5G>ye5b1p4YesN!ttV(T)|D-U>AO`R zZ>8KL?kdkA;)`{$z+M*ZT7x&toElxC`Gvb)sL7Zld$t{Ra&QEqKygdVq=tkSYcsC6 z?~&EXX>GpRjepHZ2%%WypHq|RJ2ipjQ#P;bIW^InEJvmbhh>h&#o*L9s(#cN9dPwP znB6lx7|Xw~HT>sc*nQXr<0J3)YOv&9iT93iR?(7P5-aV1 zmWmEcpg^UA>C%d{bclTe(}OKyU3fkPHogB64j;2Czn7)&@NWQ0l~>ATz@ZBMA3qiq z@Gt+Er~-fa4AfPir~je=!8=+0QC~^TFqFWMcep0TPkXhnS1Q}hML~5QEc>d%s|s(d zan*;PvK4(6vYHa@bQbNjHP(|Y7PGgzUXtxaGHqU+>vKn}3VquJtQ-)S;n0Rlb3ZNz0GRxN1@!fk8ipeLx3=^AW)VQgc4C}Sy7Rb)6Q+|8 ztL*MUiHnoD6oaWQfm05Nrs@j?$~~Q!J&RKv(HO{lOM*_vy-T3=TQ`qXcL*O)reTaC z##Sod0!(uJ_BD_c-dGiZilGB9msXgafA}&Pg@KL^+k`eEartnKh4b1VMi%a7f|@Vf zep(_{v$Jz#Oc(nYJUe8Pe5SMI$EFvIvV920Ty7+G=iNiVJr@P_d2l!?Sn!rFRarQ@ zOH%-Jw4ngh2g{l~v$1kO1>oU1%M!X~8pda+pRif!zRBwRaEpTo=zB6oTwdsIZm^V^u({B1`WhCTaQU$u zoJ{F=kjz(%RM@gFr(E(OOrNguQU&dr1mMcWibnTFEC;`m%U&GMCFOk#LREQ&D^gBt zq7}Fm@qAumyMBJM75hc+-XaO`_4FD|pPs7mwuG`yA$K&06 zDjerrhs}+L?Y(M-FyvpRu5P~}f7q3{EpEC_G3lP|yr4-`Cxobfd-|lSK z!xv*CBO|dEf2rWGNl?s!_2oid?RT4dPkNAcVtYy0hfa6cINW$1a1>>M43BOc=1VTs z(l-?EPJYU53iJLnpwIK~_Q;B~UNLk#?m<~*_uLiu>2~Ati)`s&!gBR`HThYoU%E(*utUdHIXLrL+^CCe==ek*+ku!WvNUR5d-i6+FzkFBIg}2< zP$rq#a47Il>?U6hf*#|9COLgkD0%+k2QGd~HmI@TUy)}l?(y8*d|k~{o~LGGo#xhy zckh?Jto?3ys|3=f4S72Jui)AiKWpBwC4cT7V-Dh+q6N-x+1$dG3Y*Y!0Fe+uG~9`c z_UFr0^WyMUEg0)sq3-SFuR`{NO8W-Kg_sl~AmhlMc;(36_56O?q_uaxMG{mnRa<;S z=W(jQO{HYM#m58tsi>=KH#x8HA?Q1Vtpb%^I~6VcUe@aa5M@S5(75bJVjx=^6P6;4 zsGY7=5^Bg(W$lw62inP>0LxLlysP6m?g#Li(Mky5>2dV3al8@{5BlB5S>UPIlRk&( z3c~(ci@THmY+P>8?5k22b)1vNIGMI3Is~NkP+#BIwrwyQO1)%DGk=bWC~;xH>5t7d#YP_>e!t z1-Ll^#=h;ZSSOXx@Ri98nXEP>Vqqg*sR{I97;6*y+kiVJcq%6Io)|~hFM>I#QkE}B z0xtMlFQXvBkO0K&{I=JYS8KxZKKZs`e&LYC(5!Ri3{wAcsWs$PgNat@tE>hLY3cIa zTnj?5mu0w;rl{KTZrkrD$m=D%494qR>oc>7Ela5HNp7l>Pd?W@f(z(%tYEYf4KO7q zZgI$M6U44omp++`{$RkeG2lrnCQM-G^<3723>9BuiDB78-9HJsf8zI&Q_EP|Q%bFi zm|ju;vWop7D1ou;oQn{4>y7JNY{?Y&YdJ((I5t-tx=(gCZ5sn3(N(iSYeRi67LoJ5 z644G%A@Bt3sN?%579=HxW>Q}yjUty*2^ntJ|92_8VW3X z+$$mTbSLukGl2V5+TtyQL)7&ngOFJF5sB0L|UB4_}U+r8O5i)g&)a;)DK{qSlZk$)w? z1Brm=D&i*$;t#cP7yxR}PWMI;)O={GUTv?eB^NTGb)Ax+d?U^FiaSfT)t9)YQbd^fKg6`I%f zOqj71yPf18u);w7cXDs)eYty(m#SauvZQd*ogq{?Z$^^qN6Zs{J)$dgKnkN7`D@lQ zJi$NfR;!uf;bBK0yxsTIy)Cg7Pk-VBxqah#Pg`t5Hp54@ZT04IC+8jK$SU-*6T5S- z2dwyA`%>1|8*WhjwTROPHS#gMNEtpxR^Jb{0AG-j<{`OgM84$sj=48o1xN2et^*#9 zmIwZZ6zYBpy{e_y!*Ia8A3GPiyHh5)rp#ERrs-jx#N+G1? zpVT@1JvyR(+@DylTA>w?js~Yr?c2+pDRFn1^%oE=?^@ADf|OT4Px~=`InUGfEXRuS zN(11Z7^ruZ8w8m`YH7-&mb05G;^1W8KneeinE!oQG&0+CN5NIuYc=%FI-em5>oMZo zQn#`@E+FK|t%ks%uUxtCY73{VoZK5ev9O`ii^;w|LBm-kYTM!Fv#NkP+i?YE>N}Th zqdyGIEp~lj*qlk$swGu;&mR-V8g$1BbP{RkbUq`a?*6%2{;l<#CvsKo!;cH%4#EI4 zg6r*^_p5RefYV{YlLI+5LFdOQY2D-B0;?R~jWFnkD3zxTWf08$uK&Ra*Jo^Qs0uI1 zn4unj50|-L?@;r@ZL|i*Vv!CfmNbN$gqM`_bH*id80UK`J51Rfh#FC3ZOb13`Hp+O z+PXEnckfsBs}_Qf4kg^o`M(?*}0$)lU{ zW!O1mW}$Zzp6j?Fc9?nf>iJ~{cB5*zO#ycj#`y&TeWJ+L=k2+?kzLD@C#jJjmV567 zN7#YyCRmIQ&nI4TeaT)7NEp+}U8`(Q$ZnN4&F8>a3g_p{$#Ay|&b4(faudusbj3`hY@)t#*+0 zl)G+%G)1t|faOy=Yobq#)4Nqh;}zLIUm!|kanM)-iz+K*S3|;Y#-alCA{0?Im}PX5|6SBt3wqM4f1!X z4P@&TVpEu^C7PvN#N20RhLeUIkTD1q3Dq0`WA6Y(i-3v6(i#DYmY0#lE zd-)Rg;>$M&qDA(^0dFw+Lq$W?Cl118xy3((qwflp&`$*L9A3_D5X%p z_mt7LGV3<7#g6+_bl&Inm`?+;y`lC9@`GG#DIsl9(?xDC`{6hIO0U64>C??wSxHVs zq4C6up5Ewc!e_Qxm*JFz)~TYM8fc_X@Q7sR$E2TFe9tSe;W~0bT+*73y^h%}Gd{h` zSw5d@6kKY?vYAWU3Hwicauz}^`?Q&0%$uJpOB!KI&;atqGj_SWQAUG7ODZa;QAm7e z`E>a7%;ms92Rz*N+#0X)CQ{+>T^2=!C}l-`p(MoiEU`;v%Rp|O$G#2B!msUD-kZQ+ zi|2SfGCt}kllU*3+I9Iz>bsy3HK>5&TWm3?pGY5=hZR&7p7FO%P{*W9XisEK9*nA4 z*MAWI6WJ1cIWb^ntnt2_b;6bksBpI-Pr-66GCi#hlAsswjg0hR0l_>@p)tVLQGVg; zm$`cMpH}cDa`m<`f^WuFM8EZpihJBl%uLPuHNoU|wGq~;{R$0QxB|w57m1N2-Xh;Y zN{iE1Lr_Q9wcE6<-z9Lr75H7Y(V(efqJ;!v-JvSQ4y1=M$G|fN6bx(;da@Kie8!JZ zVg%IE8d)d_s8h2&Wdr0!dngmx)qAKp(4XPx2@S*Z9p&~( zM8QPQTS8hvLt%z5w9tEzvZ-v>-9?&*p9K2Aq+unz98&UTh7l&d*3f>**BOy>XCnpH zkV}{7)q9YZh7w+h+M290`IvmcZgS(B3&WZROq-IEnN^a1g(csK*_BJf0Xfmq(p0*` zZ#xr#1`6WEt)8D@4k)#FOBkS{-k?YJc8j+Jsdu{Ziqav)dLu@a+OyS;+v3*X4qs0u zVC?58NSv68fl9t_W~BAE<7Q|!ns+#17mGSu`?cakM*gHJ@!n+^n|g?8Gn&nXPtfIW zQyW9$nADm1BBbwlgIXQ9zm~v0yr$c*aP_OWgwvN-dn!*rbmVTtcg!_P!2RvVGjH~2 z%H1WIW*zsBPWdkuO9b?%tG9G73^0b{tz9cCF^fEEfOaM{+tcCMu<(v~{#pn>=bir) z>x6I^4cfZR(}w*}TQ!Yzt;A3tm>>RD{91Yh_ zwAYe3{+F98t{;pyo1j{3!x@a@V+ot!8Rumdh=pTla!Zc||377DB4F-G@)p-frRk zn*RSd_T)16P&4j-9$3T}CjYz>8EH-X<~Z#Cy!rnd{>LK!ugs%vg4IFUwCW)0^vBO@ z<(bYv%3-(Cl<}zSz(+6HFkl_S{VjJ8@?kasMW5xe|dq-)buNODnAd_CNhbYxbt4{8O$non^d1wGK%mOu+>a1Cn(QN{oAF;VPB=^LX?UGh+OcR z19THB;>;#867?VV{QGQ8LP2vyjOSLOLOEpqYDDz3U-}_{XgL!z@FQr&{Ae&}gTTnc z^zu1iW%p0VOgv)=Xsi5dG0%7U8&^`gD4k7p!~wrT-5A$G)Q{bNk}Md6H>H4udcivWi^Way)T;^(F?lo zO9lkmi4$!ci4CclU91%Ou3cA8GF6wsnOWh!)j5v0t6x5x`$w!Oe!-^87 z2D^Md+oa;lz+&y|VQGerQhR z1aVObJM}Jltq4^0x-CwkY$2fk=$-0d)peiW)z#|8WR1q{>sv}jao zw%oM%)pAL-Yqy_@5p;zA;?&^yq<84%6kim-#I)2HYv{l6{P)L13iu?5OJjC*i9{o_ z!d>&5SktawDkfE3R995ua!+yR4Q1fRwu4`zvJ7+CC~x=1YsB8o|DsJQ{h0Z%r*o|P z>4cA_1T!0Svnmb#{mi7)J=VnEd$yQI$f?)YS*G_=90g0Ytlf#*!;ArHfS?P5Gk)>O ziWpmUwDvPBV!ozJfary<8Lh(I1KyDi$@wW<1$q#?P8cBmI7J@_O~GiLek!3Naj$v* z<*zsaT(w`5dOO?d7AqBEWLI|`C`81BC0G9Yr@3d$|UCY$u*aL|8ZI5Zbs#hm}nL<3RsSTeB zh>=`v3#0SLFVOUEJfw94tjeY?xT&g`3ZF|s{xii|=$>R&5E#o^gXVjeGfj#4@zU6uTodh%|z5J$*wM z22fcYdIfNL>0r%o`>SL41i}KSA6tv6^01E3U^WVGtaksBfr48nelX*bhehI9@j$j< zUQxf$-BOXH6m}H|uL$k8=-JoDUj+XPCc+DI6~C*5zp?>VeiDhM7&d-EK^Py!=-kK` zhSM9FB~pth;i$ID5Yy1l9K}kn{$Uq=8A~rwTeiIY%f*o!6T8zd|tt7TW7NWb*r+px+L6QSe<6)LEoX@^ysa{zG^e3`_Kwk^dJ4XYb$< zDw{wHO|CLflW(w%u4QyyC%42K{KXcE^lm)serx7FIX>5+P*j=j z`f~GP;plCg=;Vp@8>pcGd@fC{Bgk>|b?h_nGr`lCCGfL3bb`-^RLa^u-bCf_;(Q{BmZPiQoWaV@Qd$2}&lk&gBO-QG zyMH4%Brv)O)m5ay(JJ#ZyZpm;gU&M9`%>2p3KX#qps~l@FJ;9H+yCqNx@iqgcdHV1 zs>_;bmvtRLl|_5=v6XJ$p1{)d;!Yn_VrZYqzkB)*-r}QoxY{}3Y5o?8TG|iZ{c1^l zO^=cIkECBx4(W>I`iFX`3ozCH)z~5%%^B*Mo|Ksp~+5i95@cREx zeEtjW|Ca{;FG%+|d?;q$PbcfIIw9Jqd-**)dm$8p!@_7k*uUn2X0@Hl-I)}zeLXaC zlkl`_$BCeIK<>>O{;zJof~($8e~oN;Wlxie$CzoL+jAXe(NvYXP~jN!#G=;j({mBo zi1?v`IdNhn+SsKUr_P(kq`~HEMs`y9A*GuXvz<91@!+iWd@`zEJv{h0bO{;2NJ+<> zehVRURKvU=dnNVS*)tz#OH$fOS1C8V(h=dZ0sDz@@4VhDqqt zIX>~=klP~eq!H<1qp=WRYsnnHbd6E7?d*XE3fSN7H68m!xt>LP zZEmptO15>G?Jy-1CfLvJ>nNq-4$fs~VWgaun)WaiAy6-hQZIZwM)Q@HntqS4xA_UH z7Ix8(R~BhCiqF!EU~(*sTdAjBn|-KFs4l3-`~6>}Gw?vji_V8)^cvwctT! zJ2ghBS$)zJUEFTE;b^nSZ*__V?9YnyZchyxs4_E!;u7C(-(I{OcH#`dyHqj%W4>ZrBGN6wWeL)- z+CGL2>CMF^E?8(-r*|!tW(k5lABX?wz4ZIdMdIY(kGoBpx+>`4LW-I%5xqCHXNbr$ ze>KPVCfrRPGx2w(Nmn_O<<0A-nm9qbS>{bne;f+fr^C6}H}m74mU#q#*QFn!R2CVO ztFZ$;U(fG-`W~~Z&1LP0$N~Wkp89NnjmMk?=IPkRJWuJx-icsggp{U?gLRg6Q&KoQZgB z{h0!)LDCO|*A#-YE4J2*cIVs=7knao)0Tw&tXC%! z>zP|2)&Tr%9*Z0;nbe9fvgra@&h|2H`c72Batn>JRA_p@MVfWK%~&J>z$oZ@Cg zYTPRRTOs(@bF@}~*PXdh9*5k{J6p2x^Nn<*8Or!o(LrW6g{emVDlTRs>MYM@Dg80( z(w_e!p-}mAY!Q3<-W|m9rm^X({B-x|3DMU$0&_RP=(IDG7ku|tmRJm3tZCegdWX`^ zt|o>DSDP|!b;hN^?H^fczjJ+R)*YydQu~U(&E4)wSv)rCXuZ7$jeTS2F(j#56y%Dh z;C0cjqgHx~!81WyM@9N}n-mrQrfz}}rBvq3I%Ui$0*72;yh^>(5hig_wPs!&aohf< z*Xt1iLElB|z#wA*An^S4 z&a&KxFnxd6pvNNCN@?1EPrNNDeY?GNvSwEd06fd@MKMtNLV?vJ3m&$|5jx;^wn?9g zu}Hr%9{nDLE8eizVZRPtq4aB@+1Q4(OJepLd<;^mIefZDDUW4D|0J3pw{?@n2St*f ziIUjYC2_sS35vc@>E&*@(_!F1IVMO^7?XuD+xJB4HwQbDz)P}>`TvohQFWwzRDvGX zQt+s3m=(veea>NAd#&|d!#6`%Enm!)i}f<^h4R6>Qp0`?2@o!1(sUj>Z8G#yET zr#Jtm* zjPCCCL?nociRzwqvje)TL7A1;q$VM%=8_d~JYvCwp+!lHEAVu%4c5q`qzmxVm z+Fziz9Oa<3NSmAORkoFyb#^*>UXXOPI-I!_XVV43FA~eKmt-6MLph5d_-rwVfD+0!F&fmhi@6*QRLIu+uGFXrGsUm?`MPB* zznhl`)07S3l8EI(CUiI-2bcJ)=G{*{eZSJ6VYS+17x&~9YCWhrGczcOm~=la)P`(m z+Nd_*lJk3b?3C5ED(l<8WNS7ytujGB59*>H>s{GKs0L=_SOloXM+$8&E{NEgMxy`r z?OO+uEY)@1!<_j2$3bP}$lUu@P?TOrYE}sEw(%%fTn>3xM zW}==FHAH1z0wYi!%~Yv+O}}T5To!{$kY;4UDEEA*G8XtZ4aW?8>+Q zR#um0s4U2ZA7+vrgAbanmflKOuHaP;qd>tK&?_S-(ipjIBAjB_&Ustpv63KldyML{ z&E?bU=Jd#tYKuu0!?PKMT0`d5+p*3hR4b7IS*NQ>sqaFKfizTN*@jBe2G9&n@xaoo z^}nR$>~AL2>|?a#KhKE5$MGDY@jLHml@QqQMRTkfy1ulorLmG)oc3+4ZT_!b$X z=uo}NbsQzU>HE$Cp+u@EO;s0;)+^5m{Ca=|WJ<%$?M*X>5rEQ`gklAZK>(EU>rB;I z@>Q?*BA=okwVW!U{LGc(L*22g&1XMsu zlya}TpLj#l2I|$Y?Ivip80*!)OC|g=NH5Pns);3Z#ZLtHJ85ATU!R-eGp6znpeXhk z8#dYQ#$*bN`Utdrg#%c*>rr-F*kmd70n87gF+o1NDgtk=7!61ste1 z=JF)BTZb6SZAFe5Gt`LqhB2|8sd}r~5WC&>e}=5>8^rXmSmE8_&#jX1A@V(6 zxCT`_YTs_~YhKXh&~`$34?8M^c7cII;w@if`M@W4odT5(g0V6X4?_Y)(u(@ zrLSv4+x~SxSr|g=meNXDNlKwKmmYAe&_C*r`~F`@qMDd|`EE9U7PX$lFKxrw92yBJ z-;do$7(j6@gg$Uz+Qm!(h}~g`5-91H*4;=v>AyrqB~-R$w>Oq!Tx$_ro=3xd4LURf zY)qqnX(O>5wHNd~YB;5#(?+ClO`mlf=%HiEIzrTc4r%`%yWKg}VkvP;gZ zcJKIsrXc39(i6JztTmb^wVEDk6@n$2 z<;Nia9;Mk_8}iVSF=C=SQmCMH_tWN8JNI_+MB=uAGs-oRM~KcVo5!- zeF>17GPOkyFDef}2rDW@l-jYr6jn5fs6u>rDa?}+n^e_3o*P?gH|{QWRVpdqX5H=b z1GT3waD44D@oOPDE-nirh-LMfIQyvk%r99KckpthMVxXh1o5$ndc}vDa+VNM%{ZIr zxauS&Eqb1lG@_!r6=F;>r7KAJ?kde^OHH~ zs^!p{T)+I_RN~}m^uhZ5W-Qh_!W7&OI6sW-y6^LB$la9Qk&?U7neGsWZ}m1mh4{TA|2Elm*UZTNdsD4a*vX!d8VnO-V4%b=*+YlWtqkW@_~EXW z=1iWUag|8nA4VU;M8|>=A~6@eFo>Q$e2(IX?uL8|A=tStD{`1?Cvn->Ldi)JnspD z-(33klK0yQk<^^PZI9>poY*M-n-P z?Uu8~eq>goBkO~5DhxVA6E7|Yq6tlXw!KhgfF&eEpqJO}u6i&Bc%u+rS)_B|N{L~z zhdZ6W-1Mq6OnXpyW=6kQ+c!%fhSyIbeuurxAl0i=dzK|wxx4R1e>*o2nh?ab%EcKR z6LQPzec$~7yO`NqS`o(%k*S|%ve82VO0CP_3X7>`}ck3Ct8 zJ=G}5p6T)EGgNr(Vn2k56mG!4%yZ3~_o`%_1{8u)iT)$Px#xQMI5Iw658oeZ~oNoIa^G^mM%4e1y zmBMmjYYl-(S1w3}W|*J4$*o;=i0`W>Al#HPI7~Q?(PJ5T{!ip{H%E>&rLYqG0RmL7nVWr0`#Aj6yj+KxGTLU6?gOE4kaAs{ad6_ zf1zH4+pF1ahJr8-de;*p%s9Q2QgjPNj3S$Nvs$KKtIB=*#(BwF0;{g~#6mUE-q7PH zKWn&^1&n_tyTgj4#p)wGB#Fwc2oa6%{t55EYvNq-A&%Y7-w@R0)KLW+CHa|_+MCmv z41DSc?thONh)c(wnPJqo(9D<=t)pR$l#twjWJ;glt@vg0Vd+pF!WJ@zJ9 zGFd%ixsz#%oPCKrI!2L=mTw&Fq~~1*$~@_kBeIu7FYSAc-eqM>gP*Mt$- zmV&yA+IPZLF;WzFP-9+_1h*+1#?q3p{JJcmv^}E1BJ)siTl^Fg#5IPhv6s+!m_Bpw zyY$m6*J`T5V_}pY{}?w{&kCYS9r|pVZt3D2VAJTDbAaXk^U_Z+7GqE?;Q7WJe_P*W zgm&mt?k5y5Ia96A zYcbD(enmE>%TVPe2P>p4U&Muyxfx4bKj=;Kmq=?z4emc6qZn}Fgim*^;9V%bpXF`yae3WrtALQXr7X6pf0{zlesSu}_gIp~cV76L zAvA>~OAenw#054zTB~DSe^wMRllPBWU`y!(YTn-~9GhSBTs15V958?kjO$>cdVg!N8`%CyLWHo1;llqYmG zo=z8)(`X7mp-s!beBbg@7u{Uyg@;P)+R75uoETqt*18ZG0HpG%R<-{c^s;`~f?eCb z>Ajy7<*4r5o1_8ghZiC)HeU-ktlxK?`DKo!I$PF8&TsN;$!nnqB!NlQjX}OD?hDsy zxKU~gmf6lzR@O%yuUG84nX%HbN^w8@FS6b<8m{h*8`ehe(R(kW4MFtY+sznJqKpav$($+DJe84=gm&h<6Tk$T<-y~culI;S zDkt`>E64r6sEPY45%t1X_BW7|pb0&i~Oe58h0%J#MZzn!iCq8eukSmuP^RTtT z`|7lmI$m~yi%JT154-3pFNloV430T|%Ir9#RClH?BM=c8yJVjb@RO)$peZt&P)qJm zx2=nqRH;okhT05k=id-}>U7*QH>|5}WRc}>T+1bBOoo>Q^N9nnU{QQwObk80rq~Cg zylP5}DVp6=m4@J1j6U*|n%0I1#^Yj&>G#2r`EDS$Vz4{4Gmj|sQ@UWjW@P7Y`!e@U ziddu8``%6`m4@xqtR5KlB@_Jg8`)oy2L|AY{_Jg)fLKC@O>BTBe}7E<4z?7Xp*Bsm z&&t`F)%232HW(C18$T!)N`utu8*rl1eWa(q)hd|~>c!krCJ8?Lgr0=Kvm11_N%7gVO*vzz zsjG1vnUL4Xs?mKLmU+_jCKZ2^A=*SqtrWTmiS`fLZzJat4J@pkPwn1+|6vDv1Y`x= zENlk&C8l^cKRXNq!idh>d0WSl*izQbKc#GsB5%HQmZOZcf;pOY9M)MRl;&?E^y)X_ zxX5`!_Qb@d-wAG$xVkI8S3ElIFR8#m21(QqjMQo6^bQoI^e00?yOwD{cj`E-G=mT39j&P2B`P@fj2X&ipPIA={avEEVn zGyZ9ZC3JI;3O;@eo9ThPD30HVxZ>$PJW=g!y0?)3=N%)^(&&cjPfV52e{AtKQk)m; z@z!Avuix*`z?_mP;PywE6!>^L$$zjx#kE)`yh7-0l~N?=O@IdRWzzY&KDJ#=?5yb8g}l6fygQH zn`4W3J&ayGZVD1jZc2%@9o3rlGkb6MDKMOQ=ueJss}(wQAhv?Ju_~>6uVU>?=u(Bv z0on7IwuOt3n4ZU!^?;xHDmzSW&QPvETkb-KiL&(HafOohlQ(n!k%-AU8A+EDXi>kV zW>XZ>#Z}8xAwQc~){H0DsWe4X7F6pbNJ@7xz_e`skFgIQ zY@5A*mppUn^DEiMKUauFbF>5J+Y&>H4lgu-8sd+Op;lj*c8oo-q0$l4Fm;o`tX81Y#ESvTo#N8MW%X~W9?EtQioGHH zUUxy`(1$GdKrT$hkaCdUe=xdf6BobJcZG`}6}Ff#^&kIyOaGMNDQ60l{x7F^Zj-mB zBWo7j!iT1gg0~;{yi`^U{nMIu5)TEZcD+}Dv?7HkFA=T0zXDvB40o?9G8~-tMYPft zWp>W5Lk>fxzp_$2L@stSrvbuu0^O%vo{w5=2KxH&l*WPR4t+x2{&Ry;$t4wFFA;VD;>bfv7EPtyK3iSbO`>+Rv>L>98 z7oR=uW*`eq{P*sXCSd$LJ>eEPK^=)3)x5{+ec1@6e4B({0gW;XD1F(2LrE;7kR*Xa zMH^xepCJV)9CHd5N?v}#z&xhaZmZK`i?9<@XB3Zn&r%&kxw=$LV%r!bC_dXsrkPI% z?8XMunoYY)vp-!63&&QUlp#Lt^)~yb{DICskWAT{Bo~Os=)F)Sl>5B`LOTMC&;TC% z*d5GIFX!wLSvO{bO{O8sAM1$;4Wm*A;ZUstVLaXkC*7Gy>CSr zv9_j9AGt;trT4T^lsx31vOr+ammi!K{oYv@13rfuQ_-bS(w4l|2MxvCqCmrX!dz9` zpL5kA8gdKJW04-Xbdpa9S0Z+{g+{vEWKk=>E^Q4mFsdAEM+Pu2q&tPx0}4mc-~Mvh z2qG2KsZ&Nj%^g&d&j2_(5)wPo!7A#dSZ^8^gD(Z2pG;}=rcExJ1sXFRwx508CDDNC$CX(V(iA_RFl_lNw0D?PxH-1CP2yaXrqT8E5tk6wtM9;&vZ=o`X4i8;Juh^^Eil8RhO!>i(@NoP}^So3#6UxFYyiF(THfu8X`H-QNpP>3>2dn??b@ z%U-<$zC-xw;+4R^zr};e66^znZXE-|-J4cECunQ#2x z0*$~gF2e4s@iM?^I0d-FM3I*WFl^l5gZG<61q2K6tDco)pZafa1bS=@i*u;~c){nB zES|$__E1m9=J!wE_`Im-WmL^87ySLG+WrR$c=UTXZYVl&;0C?vYSk;;b_Z~aCwcU& z9ece#wwvLzLsckUPnJq?9CiqX;Y)Mk6M@tx3bBBwVnP}=svR5%>iEN*`=iNEDZ0zN zqRnT~WcQ~`Tpc^_L}~wNR27uoZ~$!bWnJ=vmDc3J67I08=q}qg`2J@|AZ3+gfh<>* z)?%xvBgQ6A*pdXR=TZMHV6R)0AHeH8buWPZVB2;o5S;+gP$RpjuLvJ1?ROwkJc%}Q zk~U5bM=s&WJdYK|(Hx#TR!8eWs>?u85`?0f@l`uW%%U`6QUWn4zl}%TEL5vQrei!A zQ?Mq0e6LX(S;r;F2ZQU=mC}miBbF$7eeGa&9~sV8F0r9hC<3KAJVhl39o{~zTT0!~ zsK_k5aB3&MDssH1E)WBQsUnI}S?~X@^1#7-KO7~d^MBYAuCrJ~!nLtf4ziS?BsEjW z%+1Y*nsNgVYS|qKX;hc>B*T4BBfYth5qKrjVuNwwy&H0T7apG4WygV}r&&uO&g#<3 zX*@O@ibl=GD;;=XQ+{%viBe`3Y0ch7f2JIol$&x8Z#NSpnu`(G?D+~0^+EP^m-=eu zKc;>V-6M9;IHN6SK%99-@fI5Zw(ggnio%XKjnWkPS&7ZAwJ*`q2;o{-WFE>3Le6eO zjZ?vj*MZXBF~;CXQ=lX=BrF@#fu`x_kzDv6(QguTO^vn)M+!}hl$AZ$@kKjAqbbQ? zI(EOluFQ5(*B<{5c`rkxAR?4X#D^NogXpcRHNt6eluJtA(6~T#Je)DVjCZZ{n23LG zwv=o{*u7am8a^paY;5<8>5R6eEOEnp;XX=#$&3m!9v2>W+U5Bf16ixuL-DImnO`uQixeo4 za|Q7ic9ZpI2Qz?nWbIXE@&zTngS&Yn{+n9wv|o^@l1XsM z5*OvKKr!~dL)9DFS#IozDr+(?R!_2u?lP!uW!2()*UJ?L6KIa?EaI@Gi(m|;=_ zk(EL`yZ-s{py&3&H%n27jfD*`kMVe-7}@KAi@EL#8SHw`bxi~nH-XA+)gpRzF#@=a z$jt~lXaC(Kp>!}sg_XGddYfsx&}82{?n@r3?@ygr+F3Kw`Zb1KJvC;?;Xu31V#D|_ z*K4vmk+R%}l_n@6lBx1YuD84jhX1ypPBxFUXR6-AM)5f z%KXw9jFW-`cNg$qNZ4z7?_NIR81H$ka8+V!tz8xKx9;0%$5y&i4KpG7v&P%V4nGuV zgm`K@jkJISkASO;C<>1Sp~h@yTvX`=?(LnxF`t1mdv-AL5pEH!__PBsk8iM$9;+^ zz(hR>5A_=K%4!5OKd2KuvHFA)G#c7 zm+dFN!c)3q`s@&&!=@5itR0p8bgWxvdMnY4@3K6l3-wt!o-+)RP*85vM~znp!7;ML zB;>2x$@WD`*u_7^>EMR5M0${}K?QW;{AOF6+1hOh3#!Yu+k`B@c|{Jr`5*k`1TT z#Re#%t&a3!MuL7lJ#M&*SCoWjRtx04i5ezAraTo@#|PtLrA33c-Bx5^%v0OisR}0E z^A`9HPVp(fYZBKsK$F1qlLlK9dupidd?`c2g}%RoWB1pb>k!?zkWmH`X8V zpbfRQ)8=A7EiMswb+(a8I5R&S0K9abDeSXa-jALw8GfvU3InqMZ#Gi-en0s$Vt=4fW&HwY)&g=|aVSm;M1YjN{ zpVbc)_ZBah2OqIYXm^|Te);LgK0dE=Jr42TUF@pg3zAV8X8Wh*XlN1;4Wk%~FUC9b*$NgVRY zaR`@25gEdi!NMM_>dFJU$E2PB56SRpuddE}T|im4g_W^0map2vN>db9qbV{cmWV0R zOfeNOT;Qit%L31WvsSN^2Lvuw`$=TLVH(JvgaTS8vqrnMdhtUGqA_}+W{Xyg6vQP+aQM+j7 zl%#tgy(th$nXG}nw6_IQGfhs&I}g(-iR0<_HZQ5K6W!=0G)OkNQKsIm%;+fkR3ufc57plhpwz+{Mii2vJA-< zq6{KdqFGH=LXN@?z0bQ7o>!4B9%YzRWxN-av9pqMqJ=5@coR)StwE3E#f|QRFwN!v zMvkTu#90WGj7yRA8HlN7S@lb5k%%%7ng^XAr(qtY1-8<*RM(EPp)yw6R4=Z-hiD|{ zA`eL{7HnDKlNmLHRGA+aCaX5|y(f1V%F!J!ojYp!m+~Y%a#nV^XH}hHlH$Eavx7G- zwK5f}X!0}GcP7OSK)lxyXOr_(NrVz-U)Z*}U85ehFfEOBzUs!U&HRaWF!@v(eFQtZ zl+DA4gles`@RPCf_2tVr3I^K!$-dYRT_jBV0?H2v9H$UAT~$>bCtAwO{y!x3n~N;K z*{D~vpBSW=uKBYKWUiE&9zT|Cuavwii2Ij9bfiKpikWHD*i`AlGX1O|+k~sNDj|w} z9hv)a+Y@wfD@YNZf6d9k)e@Ci$`X}LM~FiyzyMAJMM(&n6c0Km|Kof7czpT0q4;1` zLnrYOSu%iuP!=yP_F${N{r@qOS62wq(DS_^nBm1}d<%>08_wn%LRMc9zMXdzdJB*Q1)@G9CLxDh%4F$jA zvpJd~%iVxa&O)}rh`KbbCp&AG(*Z2}tt9*as+~$)W1Qir`@Augc(FS$2J2s{$*+l5 zO_Z0`0R0(d7FgPj){{X0bKC$+tKFB*6Ta78My{g^M98pkg`K70j|afr)kdcvuPu1H z*nX+f6Lh6Gxd>5=O>kF5?im#3kFQSW%Zt*92+fL@l2v{e2u!7L_6#LZjQ3+Qcti~a z9!wu`hrz8XS`+bXpY9dM5qhC>wE-eF98?yTnxKy0e*T$f>nnFyk~<$US1qS{(zRk< ztstq@0XL$Qkp?t%2JOpKvE4#v+GFnrv!`j$rXiN`p>gad>eX=#RS$Dv|M`D9ZiZYD zLpLL<@o-8#(mO~I89d}kG*}smZquT5l)w_GLhEy8O(IvM4v01wt%9PSgqgJxAZcrZ zY5B)>e*#(wWjb~${vloTL3+?9A+^Wt+fH@cwJx1jt;(W5IdYI33?jf1XBbU0r~>~1 zY8IWdumk$)ImmB}A7GH0(j}bhOb4K4i1pX0p(8qVyQQ8leb`B6GO)_o4H^JnO+O4s zqeU@VqtfY8{*}h~9-6wNQyDS=YocPrPb4L23D8=?-cF)#`hvw%4{TZPX)>og76lf$ z+g*XfeleZX5clSG%hzLN`Cc6d!*X~g2~p&r`lqz%uy@)UaX3)E>$tGm@fXa~lv^{< zg`!8S>u4(7r%6!nT}~RpNdjCM9`^{Xvu6PEdoN`d!zDC06&;!D(~jzgP|)Kz=R=I~ z$ z5Pi*!J8o|7(%v1jqc4~C3b|KmTUQQy>-3^MW?+ElFcxf}O|*_oMy^(D2Lr{-VkUkM zSz zWh?7$pqHAim8?c_yD|ZFD-fVi0!>kKKqO(o)4Ha-%E%ogO`^2BPi*8n{b2Qp7-F4M zWO5c1#7356c3!{55>rO&PXVQ>u1hXv^=yY2_a=OlaO5&(ilIw6qa_@S`RQ=Fo76+e zC~fxf*$NZ!j;H7_Pw1a_P6PK zI>mtFv8()xJcZ&7bxiYbBm6rydb9HmwE}D{8TvI%UPA=-w;jolb}zx=%Ske!R@N#l z{JTgCt*W zco4PI;3aas+xa{3_Qov&ckz_@>^*K;wM2q~{hXot#%R(yIJU?h`2qZoak7Z7^x8o~ zX8V(M22086=#1z5natFm4R=AA@E#Y^XuG~%#*>-z+OhiqLNY~Z3XC#{NWxw$i2T1k zAAUdw_cs_KLYacZ7t7n$B-5c%lbeg=hF2M{#eHXD8FdU6BI4#(nvdP@Nbinsu5uJ| zcJdse{%8DZ717lR>GC4y{YfXPNmQxze`O2#fEcdek=DALrENtnNOGQC!#Ah1OoP#Ouo>_G6EYfAwUngU#b@C<<8A1q} zXU$e7bsgV@K=eE+@6$}jR(lLyqiLJOBt@{8dxX2=&38VOGg=HQWLg+QSM|&5QIi?ff}MCIjJH=a4kvOpuLH& zNniW&!7tS;3{Eh)V5?sIQ4Ukzuq->yDz0ohfX&twCN3AA6Hl_BibYoFf zK(}?ClnP9sW9wj`D~JUys=$Zn_n`wiO`a82z}rXx9nfj_tgvZuR1{YlkKO42SnPGO zPzPg=djzF170tD9W_D(b+MrZ=lfR1?zWmr{(@}CfIF#vIHKb7AQK+cTR3NqtA}3I! zA@0hPE0xhg?qcqFn^MK=yrrI=!Xhl-?XJhH0UC8TMN4WJOVXz@+`Cb13+fjr_023F z!WUA9-rQHsTwL4krpe-h9vxjJ;2wGhKi!M=*d_zPt^)j2yVcKsfvr(C3{>ZVD z{0yduLqMTk-t6@Tw3lUyaX8m7j-dycB*I~gcJYsyCj5ClS^MhZv_e1U z*6!67>B62Go12~^{2sNd%nB$ia%r2xK@G0E*EUsa?J+|M6908StYNrhb||*ZQPK&0 zBm1qv3^`Ulha24=x6k+|!@#VGXu~v!O8W(KF%-M+UNU>hpT;WQz%JP8-DZ2u+vKpK zTmWApGrPCjE8zL4hX4o<^C-}(!JL$k{HXuGlVIe=3#)G%%N_4(3{F$&3=}2$enr(c zwe1^-?za)F{}PvbYy#@~tmmkf%Mo{2181pY_H!Eo7|k3>|`K zo(32H~I}} z9k09lyAb3X5#sn@dv8AHyxYrT(v_|M$G^>*Xj0ia9RQhzikX&m-+tJ7L=fg(K;W;+ zv^Q{Kv6Q^0;in8>iJx`NsnN$g3OM&5_XXl~;?r5bac3K-&rtLd`4dVkR8f5Ofou#Ak(dV9=~3uCB8*4TT~N1#j*m*^JgHA+DS{ z9Xp5&y^-mILmkM4PF^1l^Yn=IoABB_hb01aG35PU<{0%C;&=Kf&`7{eW{tvUWk3@9 z!Y7OhwO?=YVljU8<)pimBWaCKQVjbKjri&5?{#Fl4FnYh(97_R3TIi(HZ z(Ml(0ht4eTD1r%~19+DSe@h_{wJuj)t#d%h`Tp!RNb5_65qX z7{3+vzxr={MYrbLQaqr*jvq2vJ-7UCIliHIkjgpuPvPXq-MT9J$WEs$}9r25b{*ip}G3Ln6Fz?NyzCI`1a}8$t`RG*x zIW$b~pjS*6k5XE0Vo4*KjGfY7ZUU}r9$N?dS7m@QptmgFCYAdhDPlZ}ih~aH_d#jM z#Qgw|GaU*kGflLj7Oh7mUhUno{(?R&* z1~SlGaSO%n@?dAQzvKMsMKKF#+;91e!}bgtfKyHRRjRDU$i2Eu$Dd^GV;q`W-z%*6 zoxn|@p|~JV`OWFy&Vva}s^zlI#UfiwT+r&@c#b|zs)f{+UE}7AhpcAN?!5ZKj`M*F zwLepXbqHg(#(e&jdr9_OKF`9eQg=s}tyrgwb85Hxi_bkgwIi2c@Beo16>m#$`CqxO z!Nu`_h-TcJ*XJ37Z@=!dXFHT#|F-)9*yO{CC+)zg}MR`P_vLoC1KM$!zqSzvls=@NLoV^EV|! z(Soe+k|iE_{K=45!BCY9%9ONGajO3MdCgShRUh^bNQt;hk|?rG`}SL;?n9_&m9L8O z^|ayvkiR0~B{16ACa^K>Gv5|mto7oQtvhtca-vJo{jv@CSZZP3q5JuNuU)6^EE4sC zZa-ucG`$|(zF_{{<9LaY=)3%odD&ylN;~uOAY=(Sf6(ilJ1ti5VlDaX*gSV;Zo}mb z{ohR6?ymq^7YpEb2mjCD@&DE{Jrk^+mF#L9&&y5iSG1futA+Ts%qUe zbeHp5fo*6&B5ZSiiShOu(d*6!7NXfc^Xx(BK(^4sdl6JKH^+(x$5h4rUANmGU%v^w zqY(?+or#)%nI3n9Y||}y4+fR~yZ*WPpCDrk|JCoA3$nUxT(1YSr6)~U^z$+CX*UJ! zt>AWVZ;a2QlMjEdR)!K4sGP@Gy@_NHy3dP$lwsd+}!uZwfVSQ z{6leNUMFuE=83}yOLW8KglMk;D-#Dt-{(yxXa@+N z1vx+qJ}=(}GjIf^xItRHm~w5KS&2^fK7V$n{MpQcuC;Ayf{Cu~{-QMjaOHC-vtKrt zyg$;_Iu9^o{7t0U&1V|0631W66w2}bB2MQUQWlwc1QLZQttpq{QM6KH{bitu$z0TY z;QL}s^xSL#{JZ3+u76>wza7}F9 zW#qo$SvPrg2dR!Q$sdK&P*oO-&;LT(idmSoKr(eRs11;6*laP?(9!Z4Evg}{0a3!I zo`+c_DU>$H|dE1Ydg(rCp+;n)y*J(40H8_ z)@^6+pL=RCE;ht&@qwQMC$2SE7$+_Ae$U0)Ql+i-wzAK*QV6mv>k|4ZvoyoMh(uup z$aQQH7eQC?>63iU+inwCI`_%-)iR;RtVCQ@WR(G2u4hamOnC*v%e}`0;tG(&kwnK7 zN;mi>QhdXZ9?*{lFHdL6g*Y)BX%m4Ssr{GQn<Kw;xI60*EDa6FOHw{XH_W zy;5kIy_B9;S?=JCD72jl`ZD6-z6Ar`!7HnUkn&=oul%`B3WF9?o3RL`|E4vu#v{ z6!@zyzC0_aX4w)yV_UiOq#N+5>|T3Wjqyl%^(rW+G46i`Fxd|aI8LXYy!hd_ltSaD zO>J!n&)d)4)w-XSOP>&avGo^AY8Q6zPJVJA8aLh*xqHZ*7l@mj`K0`9ja-Cm`?(sB zAN0zJ5a%v4?jQe*?0X_hI~5X~;ESFIn+a69jP4A_cmO51%!zXS91o<$sqSh_B(nsR z`^KNpe_Q~v{$8k3@>zZc&CEj5*RhVanYKUb{+H5qWcFoADm1jkuT0F*czo>t#?Ga_ znd^Awald)G`1lLQuVWSZW2vKIM&BLwpp_qre_^<~f+gtF=(BeCQAZX!Qel|>sKzuSp`sSZ0Z**Eq zs{*LMmx*&CjAS7bV$0dFqZt&MP@1Xm`zL4M6ORr;5ohjfW3=8QY^+wDZJb>Tw=!qk zM7p7M9@B_p$cE6rU6%f}&ye@R_yt*!f`F=6R4f+)i2s0baNvZSTxKrP(S5jjN?J-7 z6G^Q$?b`?guZDI5q%PA+YXgIa#&JPmL$A~BJrdXAVCJfOQk zEFj=f=u|OuhAMCZ=U47%lb|>ak!(u?h)AusHv~ob8W=9}>}IamhS)OPkaUE+Z{@?C zU+)5je{8?nZli2~r3$YQj^Hj4_fkSFaOiQfxXiL>wYMsk6o)Sag5vP19K2g9?GI{8m@)tP?5=WA^C@-p!= zj~RTnf6+c>B()mOZL+N`wL+Fc!TsX7OR!NN5!c{p($G#k4@<5>aEy8E-#JJaYmR?L zRPx(Dj>A~V0@}P`ASA`b@i|}gR+H#IBXZ}(G{@aVc?5pvpbz{x8ee*Q~o;8)JNv^(M_Gk#CpcjQ_^P|<``C7pHPxVJbD`Z#BJ1oT8Bkx6I<3#_F6`V)Myt7dvl z8zNn z+QSt1Xn#N=)TMXGO5Xo|_-R>uV|CQy4t}=1YR3sOn9AZ1jrT=jwa4(|4@}I==;2}F zxcGQUr8_F2%-uTg{m&;s(YAz8Ik5SMQbwF$qQAeUSer`ft}YkeQ1M&7eX|d!%x#Xz z$Z%h}({B88h<18P+u`QyeNvvTG)Gwl;l7sv$Is5r3?e&cD#y z{f6G=yB%D+6%PQsFcz^XFI>g){lPHPOn}+Dhh{*l<(ssBp1DmD%~EWAN1e?a{r2fJl=SZ_8Gil;_hY>b=8$!BUd1(NU~_ zQDng$|85-YF`OUw(Z-x)I_lQC6J$8M3-9oPR*KLcfB(M}$>@hs@&_4D9&--3ZKL** z&mQ-q)&9ZvrSFp4BK%y#@3KkTWaJBmMu0QXRGu^Bbx=MKsNrEYRlV)@@|}-YP0%EI z7~|vr*S>c^E0=S|;RwiZQZthG80!Kc*oY!~4rpI^p=B^_8e;vfp13J?n< zT;tvtfP;Aole7X>f7~>xTty#XMVbY0?$HW_=5-VG`@&}_{pX{ezMNp=ETEFNdW7PRFY$Y!ydH5xLqv zoGX$yo(m95pMmJ4_pqXG03+bV%=8uDd##vEtQ-9)x_8Pc4;ge9rNk89`2J#1<|0I8IeX?()5Nl*m1x);|b;8+zGS8#7E}PuWoc*?-8V?u2 z_nHKZ&;llSo__HV=4zXb7g-3rhHGveCa%@~qv>Y-;`;D$T9djpMeqnD7A;jw1?bRU zq!+Us2_PHPMGeKIcrwUq-AT>k8_C^saKB2I<_p9)sxcA5nIfO!I5tKJENSaUGm4Vs zcJdIXk-RPl)F~y6N}{VhQya(xT#W|BugW(nmXKT7hvJR97go(-qOhjpqUEMSrHzHU;V+oS z499>|{CfNP*m4*dO}|J~*nrXXbJ!}_fIi{=#s%q;LNJYh}UL1|!HW*VuK zRLL4h%iuOjqi_xhCFm5?X?o^FeknNUgHD5_t{~WX+u%+FM1ygS&qSlcDwucKN8qRF zu_J|1Mb`NDtl3wY*;hT-SA{8@86dO-V{7Dzg@1YL&xhTm3_Of^stm0ktnj6(`69vgIL^$;xVKs(i$<+YH_3vTI@vFeuIrTyAOwn zgno%<`Z=S9w;=93qfaH}JjS#MM9NQc(@U|>7Iy0cRS#cT5-W>e$FslZG0-Glue(*8UJ=BQvzXW3E1nm^{SF=UOceMx5;bSn9#fPVRhu zF?Frj?j448IAj9<=gCVh+~Q^LyD-Aa`_uk`VbkQ#mHo26U5)Omm(9X&iAGMzppS0( z5B;jLhbRW#=OkQwE$fM`KgA2a@>Qb5*$>`OBXa(9$=lO-_-apfqU=q?0|xAK(g^>J z)V6~`t&aTz__dE7hMnmyjz*vn9QzdR~U6!L%8jtoU7+!yF z1~F17da9x@_we7}&K*2^lk3SMk)5;D2P@xH6|!eDG~%D$3^3qo?wGgqt6dH%-B0eD zVl`t@#QAy(Y4&H!YzQUNbaROOLx6i9%G2pji%~tlpHdWz_weS4^dTJlASRlKYNe*3 zgH1JPAwbUOVCPYIpVu^JH;pVGcKtZt`sy4|3k9D&y7kir^hpxv*1a|HjEV;KAwIT=qtOV^>O|omS^M!rR^DnLrh7RH*&f+I`K6anHYdin*reX#VWCMo> z-WU>dV;AmMlSBxN>xxJED4(q{=wOuUi4-C%|m~gxV zjjOVU3wb_WzOrTuTb3t(mA1U{Kzd(KoSw{Q7hHY%cjyu7Q;4>L&ocjr9pPsWrGu31 z(xUFGLknGjwm(T+uhQ7+dLkM+?2Ogv$b>vX_wip{+lykW?bLg}e&K!fwr4@E0`K9& z!v1>>i3kUl4Q-~-d$!I}R-n7s-UY82AdAy2n57pO0J?9?TrPS??jwt@zsOjwu;=#3 zw|97V3I?t0Uqys7Y(`Vz?F?zbUAs2!Y0 zq;lI{5;fk;r)$zrr*kEP+VSEEIW-vJt2?Uj)cz9VU3JzCoPL#-O7EfVR>NYWj&onwU#`KQuUsU({gzXVq{IH?M z0$C8?0IjEta4@f+R(tf*gFI$N$YLVdKl&R~qTrX{&y&jP1T$X}&jMbmxK)jYIrZnI ziX8sJr5=Q(JLw&SWjhnb_z%IU{$TtuBiwE^-}V?FJ}?_f^8JJ3wo05IL*O1;-|-r8 zbx@+#L_K!|ece zu7l}}_%0c67NAxHjO=*$Lo1iLK~y=%@Cz04nfT>eFAN(|Un{O6dAJ8P&T8jGsDyzd zB!#kfia^vjzKT`vjet~jY)ZClx#NX)1-YpGT0El(w&TL9IsduriHmGL#sS&WW zGZDbv@mLK*KE2E-Q1+VICXD2VwiLetLw`ugrcVkU9AU<#v`B^rw+&7+2vCWR+L`dQFl zNm&L;T0U>IYBfVD_^?~tU~Tn=;@sxdmkjB%pM%z+%mYp|0~$23p}6OO`)Ul>QtUqi z2BtrM{xmj1UiBSHjSlb>8qf!}CwaCfJ#S9}NXzNKr4EiI!%ZNP58!B+F}u8D9Mzg7IM5l1_nGE&`)5OH!GgXo6Jn)t zy`n(6c`p*>HPQcRI>5aHw`^zo=RG4qx@InP1cgqaXF*?Nf{$p?MI^OqP9;O75VZgU zwK*e~`RykZnTd6V6Po$JnP7jNXb7q=d&QQ16`FqaYr?&R-rFZrEq5)!AwZbJosJ+c zRvi*h@?t6#KPI{Fdd1fe?e$KU%W{_u(XX{Zk0kT0yM3k|J_ucC^4zC!{f|e-s)&r{ zBvUhD$8#6tv%Pleb4Z8t34fn$Z8-O<|K{VXISB&C2d@_6S)V<^_ zTq1l~CGG0{WM$T*kVJofKQIWTLwQQZojp8+FH$&fnZ<$77(j6GGVSI$aGR%0vnOX^ zz$V6eP){KukKLY$2%x|ePKLB3HKpV1P7yQI{3dsBht!YF`u-u(6JsL_A2M~$`IAwk zNct63BIOYtNGQ<*G|GnbpAJrPm$3BDG(CfxpT24HmRw0jdCf{nrqzwuiD9r4rgqK0 zuoKjxnBF?feLZW2?{!my+L*mJaV#Q^>j~6ax4R|RAKbg80fqc&`?zCpFcXf?j9t+3 z5<1$4ji}^es;2xLCzEYx@Mv6#L?nB#^Y;<%ZYOZiCe$M@C$>)c5{ugT{VHS9_tU?_ z|4gmy4gPW**fNeQ9a!Q-jx8RbYBBX0RRb?@db`QQ`^pS|xA7SN{rzQj^F;n;>VvmQ z5Zz8?!yAsfBO6-hvRFj$41S2QEv$%k-%ee;x}NPHiOkLuOsk!gVKEWAml6mBC3}4u zv@TT0xBW3{9=w_H0Y|)l{$p>g#D7XhN;iiGZa6s4KCNk7?VSSIQJEH9TPKXm7bRhW zI7^?mn6EbCIrN`OS)G0QHP`o#AafSuLM?1l(r|+=(Ym$w!j8S`Ir~^4vEXnWt?I$N zzPSH8e8BMv13-(nSm%#R4&i?=dNotne9rT8R_v?&vzKS_oSjwg=oWJ?bm`7${pY4m ze@GiPrIn-_)LtuwgGE}scw^ahCVf{|#s~@=f^2tN5)|@b@%N~^OA71>O(UwrxytlO z7LKO%gj(^x2A2L5kJ-~`126I37*dthZKaZ9Oc%|_$$(r83##HhjWZQ!L7eT}=QBN) zOho|A@T9v}!R&$of{wl>+xsf5jmiGWHO!NMr3Wcxg>h2y#R#NJ$^g5?tmJD=Sy#OD z+AI;PZ;Ylw*~@a`Ky+(U1`x_r*n6`@!j@Ou8zn4b@#TiS0plS9og@oSGm-Wpb}w{*EzLwFJ@0+FbC~|U0U#Z zY@!^5`x~-g$cJ12n&rn;NkpOIPti!u?lGn%p4n$A8@+7H(61?<4cJB3p9ASI8DlOP z#%>w37=D?|Rn4)97b;5DNA>IPJecr^q9vu1%RR~P-3%*&Y?k*)Z$WEEfvd+tBb^m^MsV^N^2NpO}TT*;?#LS zjPH4jsSQqKP*xL35tORq(583C04zc=C;#}-vVu4`8=O={3z*^P3|(OW1#dyMj*O38 zmoh1N)aT{)2m3wI`ctqIp+vD#UTsouW4qxB}@aq0fvW}wQtIYuru!aY`&p0kF%4sD}465q>JY;GoCg3 z@c&`yDuCK(yERt4K#}6^?ry~$+Td=%3GPnu;_mR_P^`hBxLZkaNrB?-?tgmc&g^C; zJDJ&;-OZl&k@KAWPX>qplDU0aSSFKf5*#aP4e!^!U|5T>Qcn=D%xpUus7c5kO`@ia zQiiEOhHKA4VXW&ZVoMpp@n1wAH!SMqJ6|s$57C9(yf`*O9nO!4oVrmgd06NHW2Gbo zoW>jgG)UANVU%otMmsM6i09Z+goolWL2)07V|SzpR3gyFwNXsCNHTFg!BQLZR4hH1 zVv`+7GLTUr@TC|7a6I}L*-h{=cr3wX%6|CXAA&#AQRsaENH%fx6s;M(IPt6I9}HJa zJ|@L*C;=5#FH7a2H5rd%$OW1WH7PsklMK^T^0qP}u<$#?vtflGbIuUhYTERTqx&`L zFwtrKB33XgEXm^#$LY1%tSMVFI9ycsyv;w&F^T%11SJo2J7#h1)8~yV&hp`- zzhh%(FMIH3auoRK8aSWJqBBC-lPJX=I1ajr zn$Lmv_QBmbR{y6E1R*asa>|#qHYz^SBtePh3 zLUB$pANZixu(N^sfa~(|gU6#FScf&s`A+X^*M0BJ;>K>`vslo>Ku<`|Q>FYUU zE5q~=XU+da!_sYW>U~^gZvR?F{(kN8{}0&FDD_`kH^WEX$V_@~rQ=B?;Q|4r27{a$cAC z0}B>i`svLnaZ{xM5BEA7{HWn`ImT$|KQHS4NOx@ij<9Egr<8Yj+n%UAMrG|;NAY0m zkK+|w1f}F1-=#`y+y0Gn|9t{1Bhxb@tT!_m9JBqs&->EQ=N?t3@O1zCKpTSnx56lk z{A&~^ALpj$^*UxMAi+i&xxyqs!YqMdSUy62B_G7U(A z^Zy1Qy!|r-)uU)8sBI$leDQCoso9G!EMslO2*1-3;@7wiYKBz+y$Y{&xvJHwAd*hg zt0&QiKZ&LeVSyxMKkXzuei7$6@xAEN6b?YB)F4$GHVF(?ADl?lM7@b6C{8p<#qDn( zs_cBD6D<>MD4TsO|1b?#+^$rOQtRvHdP2A3NSH0MHizMpYMQeCkFR#Q^72|Cu0J=Q z0B5!P0kQNS;_Qn>SQk@u$Vl`qbk}1(r4o$jvCk)vzB`aDnR|X)e!AjPs(YLwe9gl% zJk|Ht_V#S;FvP{R_<));XaW2f_5)ZAGRG9)LGT|Q{ilZ!AE+t0|LMo>RgHq#4Vu*?;H-AbLQBtM%w;&DW4fzO9 z%;|-KQmy1;_1zL8(hwvu?OFm^;sqv}ksabcs!0xNuh==}eEn+4lQO~eWi+{&9hKn5 zIt;3ypHJkwBx#Wh`L&MFY>IDFQ~iJ$|^LZ+I`88DC1s$3h|afS_ML1N2g?&kxU^siJZe?MZ z5#+@~R}`(tf`CAcdsX635hbSFPr_Wr4seNy;RuD3WKp4vRb_s*#e$llcj9z@`iuPA zLx$yjW^?pCz}X+koJjnN*>QrZ(4C2}F)dc#5%IHxv@a14T`un!kgX>A-eTy(5DpO< zn0p56z)p*Lvm|Gb0B<*Xaoa~N_7?Q1&^U0K)Bnt?Q^S3X&$Za5cBL)o$T4iD&>#9+ zSOXOJI~SM)8l62e(Jz!`W{7=qTJU6`_mOQ3(5M+0akvWTWGa5K6= zCwJOf^KZlWTj$UB!SYtwq?w*#n8K-TN5JVPyH%@WK40Trq+!QZf$LIXbsIAa?Z=Wf zi{S5navzVXj65|pR-J#wL!CrrZqkK-`0jrm_b4lRkmWf~H&ed|Wi21AuXwJtft-k! zsPbDVU3`Dvk)SlY8m;}emAo>-46StM#1)N~xBz}qUY66Vnzx^`Z&pj^v0y55F%}#{ zG=q9wJKnI3XgkYckgQIGGOGF=%xXB1is?{efzy;zuSXID57}pmAZ`GGa2%-0=;H$OvRc=t7V>C%in&Fozl+m)B)%ewTZtgVLE;ncMps zHBs`GsR(@;49Yp-6>*c76r1M+N-!TMw;VNeo0x+@`OP=VrlJDgU;Rq@(m8*|nZE3p zZfDkY<*0B3VP#ltc5o~^MrL;IM?B9o>s}o>Kfpr7rz5h;m;JvtMp>?JKrv*o+Gnw+ zi^xBLHS1Ak>-S!44l9>wOkPD%?oSlwEof%QwUKJW*3x1jgVO(Lr$APvo+!a0e4Yg`E1NxdR_ z9+SbJC^5SJN_a=H7Baqqo?(^BitmRl&Uzk?Qj8<1Ybx$CIXsy7a2CK=mer6yVM-Pu zbhr|H`{Rh76If2UvEALXp>ZZuH8SfAqKl*p&zt>Xt28Q(;h~@%H}6ys!ZQ-d(;DI< zaZ>T=3Ug>?oTRY(#T1?kGtB#WytzwB;P0=+&H{1Kn}7$1X?xU}__d;QiVZ`-U%z%@ z0Yep=y4L|sJOYkj-EU&keO6~=on8m--NuPUd>nzIl>Y1dxz`)fO*4A|7k=#z9X6VGHXM*`Vqy!CqM+|pF)W*$Ha$T z1w&JBPG6^nXPwYPv0`aUb$UK!R$#KXQ(;D=8F)%PhU$3R`A27=GPp(+!`v3k?VCpR+CLQ~AyQYhie)T1u+{5c1SZDO6GKED zNyoqr#iOTVJpSeKOT4uw{Rba73P?8Ed1=L*{j>K@n694lX*(95)3?d~Qfgx*`qhH~5dj zPZ-7>s1XQ>4+UC*(I9l5gfbbH9XFu6enUH3wj%>ilrW$&A}a? z+o2+qvB$Bksh{bh6JRHJP!b}ek_)i$(S_RK=plDoZ9*ABLAWan;{v@i)ajcp%Axz= zA22?L)5sE`&*Cn%gn}H>X*BH{F=H%Agvw(q4Ofb}bh6{rI4m_sh0`#Ehn3}qCrZ+; z=y}w!B-3gHjMTMA$V_V8npQS#SGPX@+WG63JEFTH)-kuq7^@c4@Oh7s@%yu_Q#>Vw z{%)U4Y{1)Jgmp^!z*p%X9wC=HDK>^tVSJh;O-?@4KHsBdx*@8va zTKV$r#S2amA9Fu0F03vZw&;8tvnGb@WV3zD<{(&>09cGHq3)?AG(pjvu7y-Dx@+m4ja9MbKIJLZ2aaV-9$iH#<;r2*8i;pMW^tIGy~ z)$pjxPW`6INK;L4rB!OM(sX>4=FA^if7%{Zyv{Hypu9{2IXO{n{Uv_2DAX=ncj?&p z)=HPH{I`7R+7&|e+nHRnWk|7obWkrlU}_kjTIw$X{DWhp2()A8VP?u~zOedLCM+C+St;;RE>^m)nijeb*Azt3n zEG^20bn}6RdOV++Z>&^9628siZFjqxNOyG*ghF$ZjbFF4KHGZs2*d|Crs6pHJN;$P zfgi2t=ogR!{)i7##M4oi(`r8~@Aif83mhdXliatQKJ`0q@MZd^I->nL6uDwl|6IjS z#F$s^++W)bk@2@^5Wo`;p%vbD>Cmhf0O z(f{;C;O*$lc`LQ>l`Z%#J9zu;<=W<FWWk@ta;#9Qc;uVk7~ zyJ`O!JyzE#pJ2DYq+pO19t!SpTcl)czD(C5Zdrc&BeurIq`#Mir)N5Lz+c2MuPUw0 z6^D8`=Apv3heG$`rQHiNj?V3Oov!PK%o+UcWt1eIzdDa9P`CUk*1OJ*;5zp&buEZ; z@%qvLS_?X3yH|581nOj>lVcCUE48nsIW}}DNn~ODhl7KOYc4Wi%U18V<$>cC=D&0! zPx!&xjltD>5eJKnoW^J5i|0ReHQ=!?ld+w(i|Dbc=As7iqDo^%S?wYs)#eEmcjBY{ z55&Syk;uvRy3fS?`^kqY7Y+k9c}+w1;UCC*E-|{4aG!^B>&x_hZ{4?ucK2UIl&#T0 z%<^{qe2=|e+Z@D?O=2HEbnc$ro2}nOsjN%1L8dxGCH?m>{4Tu~{&pj^PptF%yu9}2 zPipiOtWzkSM}u8KFQa$Y$rEesXjPjH)$Lx7QW7cKKZU{N^H+H zhJ^M@u7fFLQ18tq+;Mngxb>MvdjB&GG0LarVxR3GA97ozAN?m>H zzPddS-|$bUF*vUGB(-1vv*1iC!U0f6%wTO({YK}kjlSQs-^7rLrtzbP;yikS5LByN z*rXP?$Apl-hH$x1yKtkzzDqcK;jqdgWBn26msSPFr5zG$kAo?|ZQaEy=XwV9jZG#c zGmcx2@Cc!nIrwC;$pIN-ZlSQxyi_UX%GMPv5_bFAkEo-`pgQH%<0)_%cthz~&5?d{v=hBxJlR%xm=`Tr0_$$Ku`x%*H5JCeLMZ z?UPN$7)C!P{?%y12RTh9+e>deX0cxSGWmn{j4RiBDZ@K!d?`-&V@ZPCG7ob$vK;d$ z#^`q(M${i+iu}VcL@B@nh)*^lJ!s{A7LfmI5l&Hl$$bGv{ek&Iwdxm-rnQ+NLxR<& zUZmG;E|@27ddw1Cl~y1-V3CCz*_y>8g%4>4hdfBhUX1QJeN7_su{HU=c#N~yn@Zs$ zT1DQ_0%ER)=0(rGxmm zD$0%ny$LISTGaAJd6K17r@&0sylGdy@+$O~?P8HTzcKMuni;O_!QWtEYl*bg98noiG9&x*dy9AlLKZPMi~OiP9pC#U9&Ad(O-6SWF`m}Jl95cG z<9nXOLcNXO{VWRVb?zQ^`RzL_j5_vgHS_v)d{uLXbEKdZcI?F)v}YWIh$6ZSzR@fO z--+UVp)?-i;Y;CiWjX6{`j&JR5#WqowlN-Uo*;LBT@24&e5SB-=cupcyD8F%MhCu; zsbtK?9ZYB}78G4z);IT{b4Y6q$m7gY&`LBx90zpQ%vR(2{zOSSv09E^r(5zL6V|)QLKd( zVc9uaa}`(FX)(JZDY|KdPbfoAlUSmBwOZK%(5h?LpjoQxVCEL#>*qw~2_9ww_qh0v z%-bL~Qmy^3m#$=L9p7R&0U^{#4CJ##WyJ@g#Izk~9fU)bH?pPWX%vlL3OJ5_6|nqt zFm>Ln^R#dDY}^Y#yt~UQCVUxJOz=FM&M${NJZ?{CNO*ng$b)}WP0f`+yrDQ#Uaar( zt8c7`PpOCxx?>(s%1WLkGSex^F; zFCcBB_u8LY9e)jcTFP94KixsU#rC*A-H0@_sJJ6*J=NG-9SQZ{`1XRBw~6RVC|Gfc z_{X10+sPoG<|Jtdp;WPrIvzTK+LE=T6jW>vM|4za1 z?vMX}M~}C1ggzaM-Ym!M8yVrFbMv=Kxl?X0p_(6xr`XeGig>Rc!~n{$68y`WSGZte z$7l7(WaRAa_3Y}X=k=)b)U$qTMul^Szuxz~bMO6-q zb*<+Izgw39c5d*Q3D$M>-ti3?kx;bP`9kaU+{Lcp!@d9Y-OCrNp9wuS)yiLTWjw^2 zZl!H+w3>3N#0G+@`$N2{<)BxiHjbC=2+9UsV+9|50MVT0M1Xq1$_0C^>eZHxmZ{5e z7SR^Sd3mA#`yq=^$jfZcId)-Bm~+q(e1`6#ZHob!xL4lK9#_iNpwq(pir@#HpWdft z>&?!rcsSk19y?4`56F zX+9fGrhRUGyIt?Ny$Q2g1~%l5Aizd84Ja~a=dCHS-A~`0Rp=PoKq4SRt~`9Cxf7bc zPPx`4>Yjn|^?zJR?*dOuABHpEvN?lJ&+bnHP1nV+{v6Oyu;#9qS%vX&_}e&lFFgt; zo$VQd!`d|pkFVd(+)X>qMoJUhBTpu$;MlkpMRz|zFifL|0h>hYQ@Oz^i9IM19i9qj zFe$PK+H5*nelnXgD1e>WbN0zJB64EQ_q(tYtu+k0!rzsI(K24kb_7D(K?jVa1$&HG zHq7q?yRB#+cK(%jqDT{`w^Mghpl8=i?zVmiuRdq%*R!kSAYj(hD+6^Z=CS6irppM5 z|D{)|M@Nhv^Nd~(|Jl;Xv#azVQOH?9aqJJ=EmFz^h_DdZoKDp}&BA9m%bTa6wg-DQx~El2o`2&+s$H4s;c zm+nezjh>_lh^MhY--z>zG>^t}Kcowm;MTEj#gvKOdD9^%O=tDc8vOQ9w*^7=DbOfc z{&PMoUAZ7l>I)^(WyY1>Yc5KTS^Y7`phLQ>K_q}$|-G*YlaM8erDMw5xHlHgiZ_h zLG~D-Y9LBc(}E}mE@@0}yAdnF(t-#FnIm*jz7kF|Qpj4d%8ZBi3(&e@@Y*NFLIP%B zS@8=s>sqsLtsMrGjTy|Akh7%ywkPq0S~uvKeb)kh*P?RPkM6Bl^{Q%J@liI{6Nr#b zP6otC3q;Y=n?NhRIDDy^)34{N)M`%cnfJ15=Kw(l?!=`DsKWbG5Jr-KYN=mg?eNw_ ztlL>^1X;xF+@b>GQ!(6LFkEHhmxp0f(eAS+z_G^tYY1A==HtZ!=powM1Z^30f>fcC zOoVFbzEeIi7$bc8)@ORZ0 z;)p=gte9o;FA-oPR(p*MM@5ef53J1(hRJEBS{!Q&%stb99*Si`S^yyFtDMud;ui+; zAZ9cOaBw3>#5}hYmRSAb4Q|PT!;55T_*{(_L5+0aqPkidMm1eC{g#IF$j&z)t0=Q5 zhwdno;?9QN5@D%`iGSZAmV|09Tp=^8cK5N$ zmr*$q>;5rb!kFRXb4rIzF5ZWokoOlQD&gvHk#KkLIgzB0(1;95;?-8zi0}4qR|?UF z-agA%vI5hMrA?Bn34)KhF#bbOuXdP@I8z5&aq_NBN|6X12Y2?zs1WgZ1kDz$Rk^&v zl7K}2@gS3E#judGU)<`f<#7SAC1jGw-)4A;SA6)iN(gfqHu*_;GGRNL*(!@eM0F3u z;NJ0;D4N2pz*X=e&W6sCIE;Iw)KLi?_Y$`OVE(~k(7dgGR4c3P>7AKks2vfEvl({% zp^71sQ?;&NEJ2_j9gV>3S>1Ep7%!V0bin_C0Z#gzRAtklO656%OQ2lI$(Jet*aN9o z&u7~r_O&vRR5}UF{_O#C$07qTHJn0CbDhNUd!x|efZ-Z@ULMsN)w<^Uw45wXG>3t7 zz%?1I#3~NN0JC!_zv?Gm!}p(>SffJ!3NXR`*|A!L=!z`$#`o58MFP^iW^Y&b&EC^6 zkG8bHvmzBU__m{6tcUGmOKd^g7D!0P(bb6h0qdF@x?B_zIiMG>FQJj{0e8Ky-SEyk zH3;ssLzub;lLjGAqHeQx5yBu_9$yi6cJNo?2KWgz6a)qG5PzlG2RJa}qZMnZya8=n*3E+p@NM@bBtJ`b6?KtZklI%+?Z_K%`c<6g#k3%uBAs2tO5dgTk_ zHsmuXbX9ntyAujBB4x1pOi(RnF6*~>25Y>A1w4yibk43qaJ9==O{Sw%8>4g&h^$t_ z3w#Q4ex|=XIh}rKQ~Ko^IDuqcZ#E+6e{?@5Qweuq&wuj!$3;HxmKM_|668qp%}4mU z>R?(wkxixhBb3{`*4S2)x7gSga=q`o8dFX)^;YeP#dL(lz6r)^iPV&`3yE@N_pm;HOu6tKmIpTV@pEB@5&(%_?|%Q0Up ziA!2IELelm>GN~NLnh=!+&k!XmQ3{7M|XuN{YJYqP1Sk5Z-}!kZqL-~oqwS_T85ST zJvi!N*w~cr58cN&(1=JNqgK6+n z-9?OM-y_-Dt1~N8y-`U*97}@qgOk9fP~od1=Q6*1z!Y-}w*9xWKo~(>Ucl-^%18Le zTOrEBBXUWR=Qs&(@9h3B!_{vp`mDJ;(P%!I4z;In1F?x{nWp?v8(1F(2XYP`RoD}vrF?L04y`!kF|Cj-|g*5`^ng+cj(g+FTrjU|tM@s2*0 zorMb=IvacLzX<-|An#XEB~irs5Fvz)2*jc4g4^&#T^u>bpF#PMl12%OJ)&;`*?*N? z>*INcgM&e!M);Ve`tgryaVfso{I)ABO-J)mU+_2zL4KwX=UFBzvq-im`ZU}^oyJE>;V^GqN~3Ax z+BN0Y+^1YS2HWgw`|P{M47%iMm&=#E?0!mg6XN*5BcT@|mGk{jOU<*?oU~@L;*HyO zk0ZQPO-FZRC0v#;0FEdo=arQO&~{0$B00N;E3Qz zV0M#5oL%%hilF9(OI?l%wn#-+DJB47{$Tr%9ZC=t3AQL!QS}159w*RmXL>M|;oFrN ziZe8XUl;%4LIT*oDmhA{gNog2s<9QWWas#s<2yaXv9R7K!XYEFZh>qVvS>{m0l!lpx7 z(`3YAq9~4P7x_)6()QhN%>O<}i--GFR=)d|LkYN$VKEu)ApCLYSJPyik;B1Q4ce_{ zPtYYLGpu(7Huo3ppra5j`UJS9kEqvOF7=})PH|rCUnGbJ1w@1~>IivQL=0T9l9}l3 zjE?aqaMba(woP5^cz)EWX459A(aNpSxml_bFe0f-Bn`o3DBnqPC37Q=0!*3HopBkA z@|a9=f8pQ~1_1q<1}AM}K$i3^6`-cU+hiK3!lfgp&9ccw__}s>al$BRoN}`3qJ4(GYx=gz zQGp~A4-vJ}7&N_P5zN0uE;4cx7?lD>3l8i$tnUxiuq8h;l_eqkQ%jqPnRj7=sZhoz z7Rmr@SPU4^OqevzU2te}gRC8Gv-+Tea=^&}e1?g$NEm4AvB~@-=IS~vGmY6JrmBph zxMg8%=1AT9p`u|;3=y)PTMxPFtzt8NShKVlNbzHY2#J5B{^A*f--b-Blv%p&^CtV) zks@-vR-?uLxSe>kwyLptG56H9r*9KY9qB_t<#jvddc^2IPYTmHxi3mMRW?{W22+E4 zEj`Bf<;Xa!P8T$=Ff-j28J)?We?H*#73dZP{<(AeFEc;6zb`SE1>n(^qA3?3k}Kr3 z?I<+1^)yS`LLWgp;_-C4)GTKKVRo5kZIXs`6|Q^&eskT{!~cE2mh3-;4|DB@=v1dC zX|qjiHcW7Ay?6FOM8T%mu75qOZ+p3gwjb5~Y&+|IxUZ0*F@kuF$ zr>Q^U?5WA1wh&=HlQ9x&`Lq|=O$!C(;l6@qHIvnp|G!|YL#RS@hrYL@idOm~PW`5^ z`Kiv#tUfOCsV3=|E6GJWas*TER&E8})iIkzF3=l)TZXuJfNefjXZXi0kx~>EHt)EHkEaTiGP|BsV)J+seu{ zR3Z2|Y|YUoat((%ki85Gj5f2u+W`up<#&a)S%&nFc` z=0-(>ZwP(&>JPc-F2GboWDXb05w*3M74Z}6^Ubf~d|w z1TfY4MTstqD|l1(j6+de7t;u8e$FrcMb_Q-%xF3y^n@TPN%SVq-yU~%ITTBnuv+WJ zw>)NV+5|AC8SdZxB7WL^_7l$NGOz0&iM^p#A4m@uwWogmwss2<7cL-gdoJVL%naI4 z>%8#Py*OR(3=L3^ih+8c%s)SqnoN+IdTU!mB_6KXKn2d8wqA^`A{TF8EHzO3COCu9 zf^WNXiO8AnC@3tVIYw-cqe-GoY`&P1epVk}UJ0C1Pg$zs@Xxn=k!BiG{3UI_85D1o;{2dFWQLCyE0BXE8QPx$*EQmt;)GnT}^SU|$D7#mCSZ2Y+)Efxt?F!tIg5?u4k zcDNX*;uc9mD@{gV5e2WaZ-E3VU6+1jKZhna_c|9ne;jvqz%W7$#4O^X(*s1^fH7l0 zSxTcsBMr^V0@~NoY$~~S*;(Zb!-J)S$=46j?Zp;533^?$hbw4>)g>QO zD~A!?&n>`=I-?W`)f(rIXmY>DHnc;ZBe-6CTmbff_Am7nQ0Y0N88 z!;-i#X)w^V6)UN&o4O9& zPGJE+tyX3mN$#e~9&V3154V6=i`%#xQPW1P0xB%afLO4ab?>@))b=H(BS&SBh;{4j zfFnF9vQ2jwM>2hVoMNI9^0%WDb|3mS@Se|qqXl^YLkr0ax2N0bDMY&mw-_^ognA~E zQ~mzr)$r+6{j3obKLjOa>}@OWb*)o3kKGH5@TO?5z)A=M3~!_-5|R9a*^(*!}aC$MWlTRoFm8cE`- z7Zt1*V%{$|{f4C3k8}|O4H3r{L4R0)neZwu1AUwKV>#vp^gY7*5Ep$p^G#YkJ`W~$ zTEAAQG=(QB<4Q+s2mhd7nNSDRHoAgv#(jDAoojx7v{;6Y-xnS^7L{cj8O#g5PsP^f zYL5@rqUdPO=3)E&1;)073)})*c4T5Rkb}7|^2=%4uIH#PZj$E5E7PVs)`%7rw>ksZ zEdI)1EV7=kVe=@}S(V+`F`-rJagnPzf+xXSD-i5-QtpGhS6l7j zf5Jy;WU#9{qPKL=%9N_PWc)q6of|YQ?4e#>C(B`$*)?@FKN>n`a_K_BN`VEKV$3#$ zqTG-Z(`Q|Y8|=3wP~n3@0$IK#^cuemkgPxepZ23y~KZId||0TYXAVe|TB9RfksH-cg*b zo;KID{zp3-p7~xW)z7OH{K>Pb8x^w`RU3be>331Q5#_-cQJP@JD{z4nlu^yTa0vPi zDr`ZZr|(Y_N>FM~GUqEar^}^w$YL_xXqTwXh}}a$WggpWNQ-^fkj)0jX){r*Jpdax zz-mXMk#nP-bK_?R_u>VMx|wagvLaAdAGjIW-B@Su-XI>&fnU95UKng**$uEP0_*|H zrp9hW7#AHrFqiikbV)TYm!7uR`f-^2-HIr}Qo~)vf+hjk!#Y8LA8N|PvB|LFz%d{z zF#%+TcvSVaG_)*F&9>@GYx zuS9|+{$c{l7D8S}R-wslP`Mieo^a|#+*l<$v*FhidM1OURw^4NH~C5>9-3aA0)c>9 zhOqvRwy6k)=rtz1bQ$K3KjIHL4KCX(xi92s-cN(|&=^)uwF3`$WTJ3a&7>8AxTDqX zwtNaz@ky@llfT?^sxUPGzBFLUlgGl!TC`=dAW{p!;DR2#2tN|O`)muA1F*N@M**XG zZvu@ZOHWuyx{EhibVw*LV2a}cDVb?~KZN@;>Y5wbN8{!FPR|N)p3}t=NHnqYtWl-(E|N|Z2-^@ z1LrSxEmf`JIZmITnn=bFu6EYUI5H#7`pCJu2q^Q^_WXPk3G=NoQi5~f8yDTp!ej*H;>vSldorO>;3Yuo@NQAFZ*sSN`+;-2ozO(F5xMyv>ZvpB9=NGQ-Q899`wo*?U*CA(DiFp*h6#4P z^8YItUq%Vx+O{__+IQpYx8B!xeGA}-J%SC&;#VHp)EuBt?oaU$U2ILbSKGIN`*^yo z;h%3#>WG2$XhXgB_Ik%FARu2oziGIBC{$!}^IaG0yxv{cWGK~Tu#xNif?hl^~PlYLyKoQV%v}EnVqc1^`rpK~9jQB#pNSUfvB-vG(i9hAh^~d#na0boKYh2!x-$ojE#~r8T z_{%#|5;M*6PHG#66$CNgf2D@6baHgF{xdD49lBa5yHT`$N z_q0LR=d^ixLSy-+49CF?nuZ20D}*>Q=X$eT zk<%pl`qQKAV5SxkAx#+(egY_YVuy=87f0!D)P1*(=jaM@$q*awmYmmA9JjbhXa>s^eKb!M*LXp^{zg?@dFb_WG4E1OZE|#)dUIkI>Oxs zTlaqPct`x|vcT-2Z(fg!D`0f_Pf+X&>CdR)d@wCzq-AVHByUDhV#4Q36d;F#a9IEV z^iqxbvcfCezgG2yBa39GIQt97vcS|;OX5W*$WqMNI!(nI<{|Y9kF6Lt;?v*lkHIBW zO=~mj99^46_^IG_%x06#F5<{3Md3?L{&YQ8*FJ96C4JhO7luGEGAzp^~|6YGlF-XxQ39uUzLVm|Ydx-!RGa zC#5Q#&EjjEYJ@}zOA3QBaWWuohK&x^#ju$qC|;BaBks^u?t#EhYg%#Em<(P|;Jsc%h9Y(uRkYe5QVbSad2R7;Me|a)l4#Z6(!XVYYpsyL z&mmU6`)bcriTqW$6tnS=HA8%;fz%$LIxNQ(mMlTM0GM$Dj zd2}>-vZbja7iK6Z8|a8J!UHaqjpoRavYAoE!`bpr&61E&4N?j|Q~4S*Z;DG3*GrhQ zw9R50^&6d_IRj#^)(Y_}3qA5vf^g9*wUtZ*3M{6E>3UO=CWK*1WedoHI0p`h{sU<# zS_&-HXUkzNc1V#9WPh>3Roqb>e8iZ+;ds9Qf-( z3$gaVr)>H5^K|+fZlJcCfE4blRmkHMvE`}G?HP2WTkT{Qew^4Q5~f()2`ePNPjGct zrw&&%p~3h{<*Pr>r{;CGrUrX2zCN9;pW`}UzVliBqQiNwpHXfa?LcU|%OKQ#q^#rJ z8Q05&$MdTZ52f931jFL?v9)}Zb`LJbetLP(c%RzuopTI|ZA}3GXDRCh*qIIn#BcFE z4?=(B|0>zcIw2X_!cK9h#my0j@8^^9YD)bC%TTNE-Iy@xyHDsks3G!--0Qh7>)idW z%gb$G;S>}~DRGVCz^Y<=FvBHCyD5g|11J!0g{ZpU9GMQ}*8hw&UG@f9NWMib0$4tt z!yMqfE$pQ@bEIm!8XP=5E(eO(i;QDSiu)X>n2w9CKd8ewweNfkI%*BV zSe!5YK#lZvAo;?0EP>7%_S;_I9wru*t~y=J=ypef$6gb9Z<-0vhj@0T!D}*bj_d8D z%=GgVLz$X=eC%m}@K6d=(hnfP_f5lJPM8Ww_p7?c`!?Ng=Sot@nTn) zLxuTk0=9i|@Ac8c>Jf6>{lt2iOqU~f(}%p%tW!zB_tIq2K-Bo2mnR6e5-0PuA*V(} zrfIdz!LRiTDW`zDAPGLR;{s2*@b;knr2UQy+*@Puci_tzVX82vSECDD6R#X}-aQI% zgR#a^yF~O}qPtpL?9)cCO-%*&_Zwu&m!A-B!MAIgfP4(L`Wm;{;Ah^ot~Z9)`kqI< zhPS?`ph?Q-i?_a?ck6@yc5?3^;*bf`%gya5%tcTAdaX(=etqQsOq_q1RQ}=>E}Oageo%V#P4QR%XLMdIJ*ns&lq{2w17sqn5D&!Cdakry99&vI6+_&rNkuiH zA8Z&CuBiP<*3~d_SMgnf-RDW`EFxwuejN+Hno&J&l?4H=QY=?3XWtqvl7Hm?oFU&c z9A#({E>wVN+j0C|l-pE!5;vIrMKk<}`A{uWxhsb9J}a`f1(R)-O6!|7)UtWStbN6- z>i}>kZJ<0Oze!`qM8l6MB{MeoJrpR8Q}gbdwka4_^bJ~s^?vqRXxT$}*+bv9V^Yv) zmy~_i0VZ2C|I#Lz)CQD>%!?@KI*=LHx;i!w8>zYhR=%u6XR9BiM~m0EHfKW7Qkm6pX#Hao#IKjmYO$(VTz zr+AHFZbWY)NOqhR3^;@})XrO=BX0xo)sHHsaiC# zqv++-xc$nR*{ckeyhxGMTtHxqMamot4|nJ=Dt9=;&G!4DX!fkpY}H-%@eC?P8P$N& zOV3Xn1UM{|lIQ0EnHfhjE>JBfNC$=hkJ#093@3GSCTj(S8Vu%JbYQQ3i+PO7h_5ZM zkvmdMm^jD?h-tFSsOjJO|5hdxM~8fl9<<Khr@c-G2W zbxCzCl+RzgLg9<*KP#J|B91hT>0`Zv7MU61vxVg`k1GC%=*MUIc3plZC@m|?)K#ok zJ_uHGoap7dA;UE1S-e#rx4Wq|iDleadojNI9tDSouI6|d(e3S23DbZ?L&^z1gF0#! zb8715ZJWe4ZQCO8g)*chY+wr{W`YJFd8+C#T_Ggtm#|(7!#838o~cMtPjB1;9I-th zi{!ss8Z8^O_!tSls`$kksYA-HSgj6rf5JN8!$O9+%{ywgp;1Ui;Ii38dSTG z^ggG$&nA9E6VAz?dY<-c+T;J^n8?Y|5A@^ckPLFf+(?9Qm4>e)NmoMJ3iGYz)3WkD zrao*6+gt9J>0Q{Rg3tLPt#YQ_FX6%^vgZd>#b2%D7qB%e$rb$G!(mSBepN9UHFWkt z={b+>8BMqnF;BXrwd{Q6mmIn2xmFRgIdd`qWje3kv3K9~z97DgN~6{=@+(8+#h3>( z9R&u~Rz=(Q`>$YoSB&_bFd=NlZX> z*Ept|3;n|sD){>l{^IMxt}-qOdBwc*+mu~r*_$RDH4+LISudX&EVk#q7e%7?uIDZA zb?@!DPzl!8*B%Z_WdXCUQ?;E4tUr$v+ATO3c@06hx&pa)l_IDu@X|}RM`BQ&e*VJl zd16nX@T*}pCQ)X#Pxn-vJtW0C$c{R7QHVi&5nU(lbSc+nUi1B%w)J-HlGpAlp!+2z zh7H5}lCV&GN>Jh%jRMZPE(pbWEoqPb*`(&WA^kHQ+NSW|cSirC>8gXG>c74cN-0Zs zEU|PWolBR%5>nFAh;)N=cXyYRAS@xBBHbMl(%t=DpZUEr_YY=k_5yRh=X~n$epUIg z-^}}kh-AB1sb+(?>L>0R&1zeFyF9dF=gYS*tP(`2z?dApt+jRHkmMP@mb#I+BIoGJ3a*CQk+39yPM_7*tFDEW z)#_BLmc1(loOu~6OK_RBTY6D3#vocsb6NT1flw39sgR_`>XGUxb9 zn8-p;1V$#Za3)0HyfHoba`GJq{X5kU)bAQ!ix7s`#a@X}p@77{Vj!3jx}>Q9;1~8` z3vW?RJoaE>YH?2;P4cH5AEGqNbTT}m!H(}V#SB!F*nhj&FIIoUC=1^@nudW$a`KA^ z2W5-TcoO(C{L8+dfQ(4gL-hR}P@x2DUo3A~>*3`RZqtiHA9RK)tu7O~6*S(mYVft_ ze5zV7EZb815TDF`QW(ajsIKUR6-;i6B`N`!qn}dvifB=&?{GgF#UX%Eg2cx@VH^Cy zm6IOBXV4%-QpzRQepAXI^^PYuI+qnAeIoypR^6Knc%6l@$~7_`!Hk~PXKP)X?g7XU zFZBJCSM&$Hzcad|x|NPjOS=+H*9l(?Ie%wo!8s{^ z&99{{M)bjb=IUwfa_+KeJ)KI>DjvNuCBpY0N+vKJ*!rsp%CbQ|1(!)fOSsRZm?gjG zAUC@b*=Hua7DyV~y^(bp4>m^gk!t>3{s4P6tS%{^jByF1lbyeUrd9n;Tf2P10*(p| zg5tOKA6YWnR}M*I(i#1zirYfTZB__s6ALxm#k-#y`oAsHnT6p3ur3(|B>3tios#P)K2u z|0D0^AU{gh$@|AJ)QqK+K$W_jNrA0|NUfwS*-Kg!1fRoMpQvph!3j4)>gsT@A8xx? zI=lbz_Js;q+BR%G&Q49o4aJX2jlc6(XLW+^4abD_=l48rJZo$Hw~oU{z=(vXyR)$@ z8_JdOjmr6MzHGa6Qd04K2EaQUh9y@2{k)aX+LFR8Xa20@-M>2D)kGOV6fDk@u)DmB z+-;{O^hC)e`XD2Xxe{=ZzNtXnvR-eumc_gBYqVv3>tMMxqjlS$V`aNNwS2{i?D(#7 z^>462YxcJ2wiivfo$v`++r<`}-M1@a?HT3Qr=R|8Qazt-RogEfJ`ag{wV6CSZGR|L zFK5ow+q`)Y`ssRT3eYm@*6(~C5@g(W`bG1WXV8d&t%pJv8!~P;as6L}V?OBLa!Sk7 zwmo-Kj9a;`tO;c_9%Q|~nKM|Kn&m$`Tjd7@aq|sqi{`ipt&ZBN-UI;}@vkwql#w>u z!^aWlBEMujGG{Db&)x$%?-BREH+|d32Z_%q#|t-__gly7qu3#kxhW5k#HynD1h2aQCVz!${%%w4GNYPPAmqvxo^&y!{dp zQ%NwS{*A+lj3oY=A6w5J%k_C)jL2M0smpEA!oYHhYd>wOT(@e4wJ9MYdzqo1L_J#UXm*B(D~KTNVG!Z-n8 zd-nD9K1CTkt$oAp^$>+Q*7Y*h@Tr>KJsrtn{Ef)V%mOR>8x&?c1R1Ci@EdWh{$qTKT)a#gA zw*Iy%kQzR>C_o8>q6UR7!mKoi0$l=dnwm_^>o5g2L&ti)&Ml?Vd@T$rETU1OwG58; zpf3x0H9eL~OsMWP(9EWFPFtRBIE|wt-Zu$gfG)oVG&`VNhHZcBTmEtafMUhO=8i`~ zxAmL1z;N~Npk(>LU#I?O3>FlifcKWw%JacAC?G;3x)MMW^#RJsq-MsjX~nd1ym-RF zvQgwS2wPHg4GsJqBOuaN^@~jNcGWTZ#hPpu_mw!%l7MIO8@NL>f z2mf7WMKi;$xX8s<8}QClTXrj=#5dvtAlx_NouEx>P6=s1hm+UIg9HcHyg_8z%P2OY z98T1J)x{GF4>QV0h5K0&2}H(Q8mHqI8yOKp;6}0aG*U90*<7j2T8b&8kTb{@hAGuM z!XV|ZF)=X&aQjF6%8nxPn~G*~bfuJnY5do->7;tL4qxhfJL~af7;XP%yksI*Pb#FP z$xfs%>lP-Gwt)m=%~nfn)+iwkM*b2BhDR?%y(OT1gTtT{$c;6zGsGVUlfibOE5hC= zIFSscgDwQtsf$rxl-}9SwBqmkWXSKR(iPGo+fH`;U`FVNIFTkMfZ18REGIP6GiNZ6 zqEuk$$kQq?jd&}1ZA==bD&sOdb^~iio63Q>B%O(N%zuA6y{*$;wEu)EI7~ubUTG9$ zy6ZCi@6P~p%k9%2cXA;we+qJek(jsS8-D_1+%Ha4qjOUGZKnoMq~?OKF$?TeidgZ(ZwkrsqH40#)n82d4? z>zIR0-OVVJB)C!j55t$FT(Uj5O%piDBcrIEk;z(PX4bk+hc$1)+0`Zi#?e0~>sRC8 z=hY{TZJ6c{1qW=Tq7YraC}M{Om32rP${=xa!GtH`@WzrN`RcIK)i$03;i+3FrQmQXe5@p~awL;#oviAb(WelLAFV4ln6{F!aE zzL@9{y(`tsJon}Gq$@U0c6C8M&Q`RXdrde@k{-ti*VPm@+jvlFUif>_tzA&#zZ?~W z19!aiYX`QRJ+RJ&1f7?1y4SeUNn>To?w4t$C6_gi)XC@#ebR@UlDc?|58t2R`Xy%? zOHPX~tISzp$$X9%nB!Ar_drrf<6$xhPa$CCUgZ1oSn<~JY@0$)St=zP}C|R--M`0agkTQ>bIvbrpa| z_lD8r!H5J{jW;eY)n%({B5gF{J|mX-mk1ROCin$V_+q}!_iWXspd(iJcrG!aY}WK7 z*6xEH^*sM%`kgL1)s1&By1`1LOnfu_$o<>%JAG%hc56N*3`)2#mMp`7>5tZ*p1H9; z=sz7u%j^3#ugD%Isvj76TP{Ditw<5TetjZjB53^UEZTkid}Mci1)TIB+Sk#{N=hu6 z$PJqB$)EnrC_7%PnILutyqtqSCdQ!KFE6gVYYb*+x%#c3T|VW$mM7zhY1`rX+}<8z zyXdu9kh3!5G@)^mTsYfP-3yIgbhl=g@ispy^IlMb2t5Cgt1_9Y<*37x9SFGsM0c2%MO;6YEDVoa*#541W2}rf5b~|-!SgMAH&+5A~ zhLKYHDw1v#pB%oL)Cybt3zh9V7ZM;w^NyLGj)CI7siV_5)>6vKyk-NBzxz z-32h?GU+?M!yfXV57-&l}0&jPT6-s0? z`pit&qgHBa8C>+ba~=ow({?9$25xys_v<9?^h(GR7;NazryRxF-47FOYc22F0$1+` z8yx40ErWM@t*cuSH5a$Cj~_~}$A~uSKj{DNG&v^~a>3WeF4Q#4e`?6hI*xfh>H{(Gj$BQmy>qVepp*$uj4-9qi-Vq!o&99u3r`Q%90! z?Yy?5X6f_^lvNVj=oiIdgEMqtt~B@Y=b!m}ub(z8^fX&O_{V$59J`zJTt-x@74GF{ zEiE6>j=k3{!__RkSbVZ27<9&pg5=NlS%eT~cd5S$j#n+jRPu>~8WI~48m6-gXqwr0 zMFt;FT(kX$B&A&g(4fzc=7D+ z5wa;nIX`iPBugg0L8yH8KYj&YwDB#)grc}3_a#^Q&D_|=+9 zCV~VcV?XVDS37lgPE%dZuPr)?E+e+4GtY%0;9C*^^lew+Za z?0y#;e^jh#FHBsxB@PzQV*^QVtL44b-UP+=nopq+{lFDeAc7MBsW%W>9A-R94Y5-H z(l@wy1RxKQ;jvtzbO1}oCni^Lj!DuK;{w%5gvFFe5XqGdXo$r|s(Lp_VXw$(V z!M-lo(0iLC-$ltliGpWDco-qw`ed(qte|eP&bfn2@$GA@SO^S;CJNjD=IA*J^zmnq zWtu5z2$WSBoI{s05bH`>3s~3cIPg;C4e6K7>%zmjWD?E_L%TESp~~g>?v?mH0MpPM znEl%5D@sz^QmO#_reCgfAHAap!o1-&e&J?S@qFeEiE~tC=B! z$JRseN?r%Zapb0{n(ez37Q1dO-d3}A2O^E8bjX$1I?%ylJMgG`Q=#%ba9kZV2La@X zy#7aMJWd%tQD7=#PxRCW45u9yHF;*ReIN}r646U-tav;%Osk+~|+K(okI?s^@7514~tFVv|L_v*cLbNA}0dQPy6icji zv>>*Nxv@Q&tnV(r+$wfO5IjiBzGu*Y1I45n<)N!}JCHs5bVb_TOAe~wVyu*T2KV!N z4f$oZ+lQ+6W7@@%K*O37lueuvmab~{DVhl_p`-F8fCc zC6Qg|v7@||u4stl?0f=?^zr4D_3KBW3F}=>XPvk}@`yg}EcB~poS7*M z&dJ-Tdhg9Iv!~!-J~|8L>m&aCMxaHQ^F{u0{PJpfN#?I1V%2)_VjbhaQsuq7@^7rH z#A(cHa>G^O>NWV|hYQhJ4c=W7Z+o`aSIgZ@pBG2*mInR2d4D9OL*^V28%P`51Zk9& z$$03ZEtWhDDO#sjIC)yzxOv*!hWGeg_g}WTZ~0W^92(3HpIHsr?>}9S4YSNeu5tEP zzHS>xy012e+qLozk^pvrtM(Gifo+U28y;<4-F=w-Jcc|2U^`P%0tXs8JLAnd1IECA z(tBe-hvW5^E62#*zJ}NOHFS)1*x@1G`ZNkd(Dw|Plg<}Gj3=W=oiR(8&iAuPV^NzM z_TMNUKIiA~??w-Hc{vw{24(j3c|&j4-OF6tNbZ~-2w%qOP!ZQ!i97H{)8ZkPvljkeznw*T546a^w zWNYv}HE_RJGeWy1t@gfTavx-SM!?x9c$jPlGMxJVj)?3clU;50xgKvT7c{i+qb})85&G(V&nuZH zzz-mFqNCNAAiBJ%lO1)lA^K$EaTt1E;B|}sJT}|rcsv{LHM!_o3!M2C%vqZX1~(X(B45~I9(Kh4 zJ$dt6)Hhl4M<*?thb7eGW#i5*Td@#lUtB*0Aga81O@6o1;J%r6d|LUly=U2VC3^Am zdFJ-VyFghsWf`vxKiQg61-Y%Mv5kg;rwL5ppnVPo+JR{A5IeRPDf$aV`ZfE_8Lt?{ zIspu1mMkdQXAj9d5Xhf-PSpo)kBq&#H!p=}U0mes=y9nL70B7?t_7CUfU}$2sP?zK zeYyBnYZh-eL^i$v#IeK1{b%1UJHLzme_nT=+5FkH;l2mpmo*ec5vo3o-E#|}HnxS((v8<F?3!>akx3is zGh^RE^Nn8aPQ(tOAB~RWcUZI(YckDna4l7d;+~3sW2W1W{FlwEr@qm6%4@YJB^FP zi?$}-;`&W6GDkDe3%oK3!q~WFmRjsfj;B=T*3lU*nz6#7rvT{Tq%%Hv6imgssbWS) zT}R~d#X?s{%B!}-Kx}B43WvLxOM}Uh&s{bR`IS<|w%e$1wyUCji1(6C6J!5#UisuO_EhOD?a8*a?X6^N#FWX3VM<0NpB}Q4)}u*(25dPovbi z_0L0L|HVE@A_B0m>23X*AeKp);e)q~oF<{sX<9Q^%xVMD`)@p2RXo~d4O(_}W%YXL zwlVPSja#sIu8M5n)E)y-KUGc*!*(rTW>idyupwcn@va++uEBDNj`_0EL->=fe@;-i z6PYVPkB@7>5w3KSnP$(LNxtzfA}^EpkN*JnwleO#puXX?mfyLw!vVedyz1}otvl>l z4QapA70odEYzI?(n1P?}1ynj_>2cW}9ntd!hdSE1!x-7rQ~S|=}= z*CYf`P?U8u%Q0{i_QVR%DCe_N;Zkuja9b*o$Vx(!Lx&Z+V$$Z~)ayQJ69xwvrN$9Q zVli||nh}PUlAnsuzDWeKWvFSg$MRwqcD~56ytHMo$S(~7#e^ZLN^oW=SsQ7e)IoJRv zirK{q&Aq7%+Xa1DLjPEuOS+A@#Bl!FXsNZP(y@MS+V_1l89&&S{8U~-1SHdowVm%^ zLTr1^j8xxx&`~|S7aHNtA2sc|3$I+OjatwD0^;r;D|Na9=aLnU!otogNW0mmlD6IM zyjsnNymk?M-Ve+_k_2)XCbk!s?g~l%#iUY6<_UlGJSN)NJM8@E#i_}@tE$qp|JVB< zqKE?4c-_(F_kI`2b$O`G`!$tR5blw1N82#zxNc`>%z3uRrToTP|7v%e_r3F6g8q`{ zSMPfsY`OR7|GoYm`!c1o(f_ewnTK6ytTU$F+>oSwdn4nywyL^DnFNP_?)oxbf6R*G zcs_;R!|4lffVYMWE0>*Lm9t@q8?I_Q>>=_+OR%O0Zl#k+p!mVUU4BL-V)xst$g7r5 z-vFHOP>JZ{u3Ef)OBb$!r-2faptSoI#{r&Fcp_|1r9yfe`P^L4>3MJVgFxy}XWyJA z%00S!yTg0Wr`Fe3!l(R(T!m9%jn~=3qmCyVE#_-2_am_9zU$+XQ0|+NR~X&7 zTJ)4#WtJ3ei|b>`K0U&&zERQzaR->)v6u2m$ImA+OLt9+x5v-hEK6s;k2y=u+XUKt zJ~nC-=wgtJUicwP%(!EaWB$$A>*FUD*14Y6qOm1Y=n;eFA^zeIm={BjlM!)Q{;Wy0Fp$;z9)81O< zr=6ImP#d1X&Vbjq|6+YsHea;tD%q`TRC{RDJ_fOQ`BnQq?#IHsZh^#^?UMq`5{EZ^ zbDP{L&qMq1wnb2K_Z@L^&zskcWhw?i#%bI7ci8abrL2R9Kk!ANn+{%npG}a_MKOVD zc0~3wmCJ!hJJTb`oI+RglZPf)GjXSSj=je)6J5#=xIfN+i44>yP8wb>fAPJo_PE^| z`kXN%8gKXVH73QMa)Vjlu)c2`96FUeIS)hTFB|WiMHi(!_?>R(@1L)qZvvlu{u>HM zWqVUF<|8sL*_Qq2OX zriNCXUwkg%?7dxIE;~mXms>m@z9bY#6O*!=pnF?U_=dYig3(XiFc?jZL2pL~kTdYe<#;Z|O|=RJw+umjiw#uW;9q ztx0CPWBP6w=WRupEm~oqeCxo3PSS0XK>r7lDPi$toEr;gKVxZj{aSuIAZN`X+PF^) zp=w*L?45SyR2f`HeUYV4dN497@t4+HC}D-VF7u2|W+{*MXU{3bFVyA^6)4yZI`pQS z&5))9IfKZfPY$B6Yv#|B7d%d!b`Iq)9vyt>lAaFuCO2A}zw2)|D~?cTuzie&<<|Lx zQYxzJnlhRu&$Nsq(Ic|cs=84U@jt(N*%6s&sMtOB{@J44mQY~meIcYEi4Uo3?Au%D zZ%O-3S?}3Bs&PWKaFrnXq9iIYblkwN6oHNUp%lS^dEx3t{+pBkECoA(fv1Ri222At zc@X6$aX>hW0xg^cbiQUpuZ*^-?Hd zUZ`F==SZiZ|@uJ(h!u^R9+GhamF>Ik}-W5SkX3vNXHg;!s-Wr zFg89_%UV5GOGdJ9is|@WoY{_stWzw(A6wItg?pwOqhu3D+#pu~etC^|eEdq-ictDq9IbB@*CX{| zoC?h`#WVdEE*`dc4819Dc7Oi8DU`S@9FrCGbrOy!gl{xoMi;z&|134Q;%JL(?<&U<+PnlE~JtNi1w_X1dHE(vu&oq#MR+YQN9-D`YXd3OENH71@xFkwDh zHq}-zIGgc;UL;ehJ|qVpN&M@6b7PePVX*h?@`|%CWay@CbYfy6m|#AM`&8O>j1-V$ zh=j~nVVla={r*w$UJuG8C~bL2sTSjfRSrjr%lWRyx>1TCehr#jUUsTvE zAX@amwEb#C7#XE}Ho;W2xn13q-1^h3Ox^lnKc#I*bonV}YTtP;>Q(mQ`)s_|;wa*M z_VoA>bMR%)n=q4d6Y4I0aGTy!Ji)0#`w1jGNazp#w3g4=oVdt!kZeiQU66Qdoq(V4hZ03l&Dl*ErZO5wG zA3M+WYMX?5&rqN?BRkgbNYrO%Q8Z*(Kjf+~W^s;!%+W=?X<~EU*N)D2Hhrmh8^#7$ z5+@@{xK6ji^3@s7%#VMD9deQ2T}_lulRO+hpPEIJdK&MKH>KuG5@H*`+&IJ72Ewwv z))iWGLsM@iP>-R7XH*~^A}DGksDiQw1Bx*#QKu(a9%a+O(E{qmjBC^Mkqc#;7#n@S zW?rO8aQsEUi5sr1sAeo{(U8&jIU@5-fqMeFtKxTamrPr=g2+S%tz@LUlZUTcm&E50 z`^*};%z)@Xq)y_H#@QyI%po`}?w>0R5M48)W^b$6S=6T#U82d;7oy@6P)8;!1m@*# zF-BLa1SZV6mFFXl z%amSx91s0{ygN(=+}!f*f!8rFg8z=BWtZZ%r{ZN;H@#e)gsyy^t^`UXePI|@!Uw97 zLNKzX4Kx8~{gD%v&W=G%H5wYoT_8~?xye*_uGZttMTBq{(I&Ito&iaALV=8nbYMyd ziB3rdrQTrsZv)7fs2l|&*;D@;+-xY*aVco^nmY{wNKYgW!K#VOy`gUw(IAuV3H~U* zB3@*G)GQ%Nr5CsUI}iHwCy9n_TU@gj{JX)-&EXJy7G^I+1PokhNnm|00*bdJAr+Ji zLE-u>DkJQL>K(EAo)2{Uy4$U#WPmUohUFVGK~qSb^`Yo@=yq%B6jm>jgvI>65Ah2I#X z_|YP1rr-EO+DuUfNW%S#QpxR8i3V#uw`m07F>H>3Z<&Sn#V17vk3K z1+?|23ny^f4mJX0OVieea*X#z`3TTUG3+qy&rS zsZi#O=8QLsf^?maFt?+?JbuUb>g5`H474fYAZYCHmive!+aD!SUzSDlhV!)12SkIX z?M2Uo&uINXMM2|>c68~NjI4}~LfHnw@bXEg@9qRtz%G8Gcp&+vE6%D>yd(bYPf_ zOF}5Z{`Tk1oS;E&ce~Jjw`I%!sD`erhh0csZ17Hu;~yab@1Q%726S@JlYL~18RWKHgKJd?G z7;Jd=vUs0bmz8^Q>@&3<0oMyIm0zGpF~J<9^dXQSdsc-~TWW#IZ&l{4Q*VOmWtxkR zraN4kb*bdT4yW0cE_M{=PBh9qCINxT0G0~Z)~aH6b%?4D?)PBoK<|8AOB4ZP?XDU? z_+f$!3D>So7T>uCQcyL&v1l^BOJ>A7bI)+#AH0!Gi!=7K>NcxBF3au2W7hd{Dmwk&yD3kKnO^e8Z!~l)i?3f< zUmP8-%xueLR_#ihI`1HjSB=0mlfe#reKdsLEF4%;($TVhN7qy?^3sWKDtEBT`u>?r z7@{O;`{EJQ{7newg7d<-gQt*?!R&98O2d*&jg2FXBtNAs&yp~DBim2oaLR;2dXeOH z3vbrlMXL6(kn(tteM&8wL=Gu?QlJR}LMia=@v{d>4(Z7}+)uBi$vV~F0~vd!P)xxK z4t2sfJ|rW0Co+VfVKi5J7~grj+clfAXxAXhn^6iR)SE5%TE63(Sm#4=D%jir!u zib}u9XPK(n?8&RlXT?ik7szQIc52KlJzVFn>K>I};GD0f3{6yi08)m7L_Y6KqIn77 zt>Fy2OG}!`isui&ia~_3ipbK&9{N~Dp=eNb#ds76?w2$UKK+}erK>JwMTLD?p{T(O zy#yh#6)KVZ{`2FcY)gWJN46V@XZ-2nzrrqG^?$G)0!TZ{I&E9ezzH!q#BNRyY|Vqb zjp8deYGUqTkUFt#^6b94F00aLe+;f8$f79Mf;m{e9JlukX^1ox%zJz{f0SNX@q|Mx z1Rj2t_LVje(v};>7OjGMN^93;q0gyWJKt*JpS9SZmPxP1xK)FJil)c5CKE%xb#(75 zS&!$RMy-M^3aVh%&^D;tXhxeww+tD>N0{ZYB=&E{us{z|4!OZ*;Rn!((&F`%lrz`VR1ofN2d&D0QtkcKFrE%c$X&}m2JF6{6j+Gct&msnPhEnPfyBD zHVtx*lQ5oh)aUhxgLAR#c<|{bAdk{yrvpe3p~DjgsgzHbPFsQ4!h)sBxfM+AjMIZG zp-cf6x~ApmrUVq7-&hTsogZzOkec+OjceSl0GG;ilV~Py@EnpngV1P!PUVD6Jur&~ zeVVt)$ogdFLPv*>&)3{5OZx&qis^BIHI2GxKb)lJ_U1yrybopLh=0Ao0+pJmY-Pvb zr)d^KKUD;`0aEdK=?ed|14z4MA;J5zTg;`Ql9)?!igk59g6Qf0zO#?VsSRCQ4^Vir z2meK(2%0s3gA9%A!|sosS+9NW1vB{Gy~o@({$CZ^uE{xEPRiXs?o32vT6w>kB;phQ ze&R-VeS24qRImm{d$n@#mgQ7E~+*ydBY&X zSB8xu(t}b!{ju@erha%qt)7ae&;Jx6e(YVN>=? z+y5iir;!XIqvHdiC;h)=zW-Zi8ji((4lQ ziQJF^Q37yJr3_Lq-qelAh2KzJ*34a29UDyq2zb-CDoZ_=iMIYyY%$ECm;f373(I zsx%aL;tFv|3`@TC5E+m+?R=3F|4M;W+7Zw}%Y<+itULh2RSF{WNX?5uspAZTv{g4B~ba3I<2r%99m3MRac|3RQ94&@z#T!MbxxjyP4`P)QD zFmk%RKa8vvnd6VtCCC8gsuia>x|6*aFM=B$dV`(CrRk-belzjbTc~I{0s1m;>XG@g z?neS|(RFqyD)nC~;D8-2BqByh!jP-*yTW`0KYB=;Y$#OJ*(&owxjBsO72uPP$hYad zatJ2K5*@r(c^4W{gpejULBW38$U5#ivWf9-;Hh+0 zz{i?omf^A3{ibdY9qUv|>Y7TCDf@!4&54y)BvKx0bbbFlG?uo9AdC#hK!uOq0qe9V zGcNM4l3B^%&`2cvPh0=faVqWpu2_F|47Zy#(rcj;_VRZGyxd}Ql(w2F))kdAmhZgN z55KzPTA=%xr`0Kne+*8)!7lqrd7-r=<`pJ56?-$`+!9do^0WwOr8CoS;@k4ye7ey8 ztSlJ%vL|Jv$Qj3==f%20^GM2@f1|=Ug~aPC0E*c?@iqvtFWV2Ll4k(+l>f#i#+OBv zH$o!svZJEPsZWfz;Zev_>EIq-G0x$G?=4Y`kVs@A$TN7YmS|Q_T5$8Y&m4*Ye6kJ_ zBh5+6N;e5*U7o^!2j2o!)!6UUg@aan&u5)8PZS)*DG7%gpG@ z?%YQSp&v%+uLK*EvR&E*@U9M0jVztBbWE&m;D8#qw&v+%dCBEo)N!#v`GVX7{}0mj z%F`(D$g+^rUaY^wMJMo~H{u=R?qq@&Hio0oL^F)s6*evlRvArusKY16hH zE)`Fcjhdw?EIltDpKn#K04jz%=cC6fS3Z}AgQ|vqg{Aw~7N{(>QM|r6jO%IF zufO)Er>irFza3{=eb4Ssm5PLYI=NHkyP5#HmGKSr-E$e2^~U`gb$>s6!PtrD!V#Ai zwvW=sS7k~&2!hTLp==9DxcoV|ac9uN3v7QpdJNuBD5*QJTw8)92(3&^dNcG!9p)d4 zFv)3O`!>ipdtDZ5k`o1yqG7sl`CCE*EkI;^T$G+~_0HU_p(=vDn#2W!k^rSVoxxn8 zu2uG~j9Xa8CzOC5ungGK;8434_lGN{oJVewV&JVekw4yJA{6d7pfcCB|8_Ck0y>#S033Nrhi3o32t0BZOk#j@HZNdy~CxbCKOwu9U~Z z5fWI0I>x~4s!HyoL=0M#!$Ujb7BS;u(_Wg>Bl&>>Lup83tC2+37|0g-)hv2LRmR`qR#N5?A#u0HHWR2^Bkr{+ z4_^W?jPH1J_im?zV!rLG*?OukvL?Y5-qq?176C!JKSkc~=I$cbJ;b`?-be2D8%5&E z|461ni0t5|i3ZoOBvgEguTiRn%k|X`@bmO$DuMS^e(~XQy)Ci{hRqZw*3i$#=cCf< zs=0_XJXFZ(wWJY^Nb<#S zqh0?p^Iigss?aYH=w)$~6UR&my-ARaOzl%4Y*SVO3slG%vcnL`YYwIS@ki#!y9VrZ zmBMRl>=@1OyQOm$&u-kW@~MMWOl<-N9OB}=!Zz(o63IWV=p3J zGfa=SeeUs(We&2!bUU6?ukD5JC|L`xJ=+hJz!zkPZGAsXS`WvAR<3rG*gTFTj!#)~ zc|U!4%(qtQU8j5T;`u^OQd~WHmJ5hoTOD_Nl_dfW5|b{sw3?Rh>lz9R@ff+f2#8~& zghdY-J|2|$Vsi9|xec!f|0@9e!7e8msX%m_d+E-xIgtmU4@tMR(G82_8W>p3!=aSm zCE|M|%l1OE%2nAH1DM?2zr+&m&qxkR%rwo6_T=ZD(I}Sw=>o zlt_8K>>R)C{a0k{rGJ@`nU(a7dbBV6FbOvMIsCghndu*C66^*s2I^5DE@`&lyys42 zjd?#t_OYXb`wij`M#!g&!K-hSorwYlA;O=iU8kn*U- znJk2_7Rvqz>PC;Nim`Fmx$S*Zph8pgLryS8>H-2!)RB6Z;7lKrJV0=6TKl%`c)Viv zi|!kQ>fTM+?5xQGK+R;_e?b^j5%x(eG=B?OeUAm`teLB(%Xox!03s;CDx5ze4i;QC zPK8q>RFoNHg5ot=Fm6!;NIEAh#xD?<=_G2jObb&>5(h!A!r>&})g861nk$;Xmnwgx z7>Oj`dMsZo5S3&NU?U!G&g#YD+EdEm2xhCj-Q7vvby{vIhCgSWMes@peO)B zz0J)Kqh7D$kdcz~O0p1a6&)cEd~2exm;F@3SjCsFm%vw8U>%khrXH6srN|STPB_UC zphAoUlg?QHnAli16rm0c1Of|c3np=0aRpQzFgM;E7Yd-qcIGYo`)VqG)M~I`;jEPV z<4lI_%)SzEF<#;)1;echtaDphP`C|`E}@n9&XIqAsVesz{i zt=!u}t;bQl5Ep0o1KD9nG!~YTx}=uuS&t>vdT98zHdaD_)1oPJLYQ&3gNOo0N+(>g z5R9T8N??T@v44+5zr;Sq$Rl~~V%{s3tKt2i@`ovigx;UN0t?GeDkfoS(Cw5@WBF5W zKlVP9`wHf$#QUeHz=wK#PpmkyI~bK&B7Tt1l#3_Y)|e2x2!VK*r#@L`@!S%=6#8H$ ziB*i0O|j&~(f>Xr<2w1U4W~9t zsDQFlcGcsqd!xCpLE-3PKw)I|@gtm9DrayRqeR1n?QS_jJcVt2J7%PHzCw6M?ftO} zhWAL6wLyFS2##~kPL*Mp-ocOC7%sDJmupB|wUV#&+pJA3 z*L7ApZJVrs#|E4jMX&ggQ@*+Y5xf3f96F_yi{ozw%1!nQB0VWLTxIE@D?Kr!@psFf zl`!KV`%r4UIDG^q56gQ({m?&5k$lWDn|cILPn{wL4rW9_lQ^bL`l#ezreuLg4aGo3t2>Ddq|W*)udbdhPXag5HGP~xt! zKm1OqIiY9RO!;G#kyL>*F5}I_H8@q<1_zl6t3RrIuvWV-TpDauzR2lHiph;6nJQe< zwX%VmCHvoLOQ&Rk-17Hm>=x`Q-b-eOjg}8iJaX>YVM#h=QJF3EFmm)*aWK?lf(6ky zP=xYCz3ZTnA&|~S6g}iBe>jw>?n%4YMr7yTxxtK2f4p+O>2`3=xJi>-*9T07K_ht|I&u=PtsWb{zYkmQXAY6rj$SZ zsjws2BZmbU{t}L)fM$~?YJwfbQUrNHguRHX6|9*x{+kP7aO~T=WN?C)8RROlfWmO2KstFeN2gS%k2EI|C(xp;pBky(3f{62l(#wc#g9-F{9NIX zgEW7=xQS+ylhNM?&8>e|R@IyYrE;Rh&>%&d{F#Fg^>?w0p^){Jlc6kp7Au&S%++vG zNq@27AyP#F3dE{WLW2^OpX}JS!Y%xBr0zFom&&g%oYatiM@Y&M5qGMSd`_Xdmx<7=2X~9hQA~qu|BtfZexc zDWkT{C9X`AbzXu^yS+aj9e+uF6!Kfu2k!GE@e&;)%8&Re!xhwt6!M?})nn<=ph*`;qEq#1e9$c)sWbbU<* z8vDiybrQhz{6oiD%~j$-#XyDCgbVhI^If{R6}K|Kb;1vt-~NuD!iLSqYf-pUXlPGeH%HaCzok9kg!~hF zdz37TpG?XnBy`1-E}6+X@AB2kdZvjvh!VpkK9pPp7$e8<=_xUK!Ak2Eq)GcAxYEFS zW=ha2zxW$jFWNe*$Z$Lps`^y1v6H-`kg3+#Zv@|u?keEknhPRKEHPWfgI0^5>VeIV z^Ti7ms^tw9GrDydN)MVi(^gn;-L-}|@iB1A&C5Ir>>D&uILM0@wNTD*9tj>`w}_M+ z#cr!O*cp!>Z4}4B0?3qCqGj2mCHVK*(+4?|M=hB)3E4IYS^g3-{Uv0vBw^Y;tbs9U8kDt;ntjIfAZjG^dmk)q%UOe*5-tSPzk9_sRL}t zj%lK9s0AKjH;6FUM{bCdHaV|ah8RDaV#2BkDX;6?yO5h51AP?p1VNbYb+{>1#R=jc zXhkoCZoQSs z>0KfR7($Lp7QsRD5Al3~K{f|4vxyQYWcYpTk(*qawXrpK%L#s!0@oqueV~#sCkj{6 z5Ixoq+eNZ^{GyHC;+$QKZc3aY7W@d@Gk4O(_k9Bi;s2N+T}Ycv4sm}C%UcRj-uM-X zhYO>^R6q|hbfyzD{X-Tu|8M#j6fQSS2mh-TbT~nJ!M1896&gVAp4m#F>r`6s^|omG z|A7%;SCJ{#2mp*kCMn=L5WTOem~?4f0r33H@>~8WbDC4yhM$Xr<6B?lyko|big^th z8fZq|#ok~SDUpg|)p7ASuUxpmd@`Hv8(FP4u7{cBWbRr}tzS+cp?~~yR44Z<<`qd~);fsRZ|16@$jUvSq#&>Ackppkh7?S?FYGF@u4B$I#qPw332}PSw2X?tG zHdYD*vL<7(YYqWBkJkOOCJ$D4-jBdRD0?h1^*R|vRtddU2}vFi>HFhvp@rZ90mw!X z^JD=sM8Uleh-MA;Q&#~Lku&MQMnWC^7l6MyB~Sf#!sw^(WC6jzG-7RCl#)F9jYQ>A zVj=E#D6HArxE4JwIwCTZ4O_`=oF-a7;#kg=q@;4S^$^ChbNi=caA))Q|CoB~xF-Ma zecS-08QrN!gY-Z`MvRgYknZm8cnKq<21utgNVlYt6X_5{a@54pqw{#wXc3!%)Xoy#vh(Wn4eEq;C+vQ6MtwCunVi%;+nB>TWZY3=ES>FYJ87ApS95-KtB z=2Y3oaZ&Y^xH+E%E4d;Rne8LP)63nvLG$nYACed-8c{DdeYqu+a}**}8yRSRB%HtBrvvEm4 zc3Iu}Z;2yW)|mddC65zylQym)epr9blE+FQzk%9IO_AXv3F4D8T`EsRkxfcms$FB* zdn;;c6&ATj5-v6JGT|iF{0U~A&vJ}$3a^VoYj4l8sfzjUhM8C3`^^cuPr{0THQJPZz)?@UrwjSDd9d{UB^Cu6Uf647%C= z#^_0Tdx+BCj#FbsxC;ro{WT;^_Nm`ADgRaO)M@Dm73=3DY2{S*0bxQl)phWTsDBNu z7NE6jMy^ATMLt$-Cp>bP(h~DG;>XrHVjrgGL4%g~*ycEpXD_gQ9eIF>pfPUwF}j78 zwkGoaAJR+%=bB;sWT-c72|eF?ycCUzLw@I9mY}}yl^|Fv{?OBb4ydjqt{GL`&iB#3 z3u-aui~b?y^$#cQ;`fy&u<-R0{nm%N*@|LlOwl$KA$#GrP~AC zgzT-9(hk7$| zbB5m)>58{3+aLOV0tQ@9!>AlMl31wCZPWzY2}(V)v@7NLa^yJdb-0@8bYZKkR*|Wg z7q4s;!C~{{ugamB99y!uhI{{&o>F3*2>;CiHgIa#k8TbJnPG1>zWBIBzlez2&E^ow z9?`DnfsuZ3{rVf4H_||#ruqsDS1H??QMT4ouW+ft5Evi z>Jd_V>^srDk4zsvju&{X;aG^r;dxn@MKk(=gbnf%L?R;MgTaA+j~V?gc^t6+@~y6x zmKKSYfX3?AJ(t&9=~ZlYXl%QY*v3?U#$=mosl&(8SPdG(?3I4#v?3vDn1K zgj2CRc{r+-1NtDFn)%I?}$upzuiS&J@&MD*d4y&%WH?M6GkKwEE}9=V{16Cz_J z7wh;z>Np?S3(M;Amhil^r7z)PJ!_@B7q#=jt9^b!Bj=roREz1rmCJ8bx0=0pRBUbWsvozWKg$3KFa zG32J&$_Xj7FubYIA5^n{&U}7^qE*xTfLCyT>AAF+D*~b~uzP4~ua}k##a7D#^n>7d`pVG-o z6~EnQ%?-6b=-%ZUdW$Bnlvx)kcad2d-|GRyp8~n@~M{8C|Z{cezP;-crlgdi%V=2E4Y_8D6fwKowGe zU}_aANdJ2Cm!_#>vj9+epttFV^Ak)$RVtz9dvPo(_AfpT-{f9gU0UhI(R{r=+k*PM zkO}ecwba8P4$Q3fP~JFC~BvBXV$%t&!+*S z{BJHiM~1!n%24$5LQgY3#LP>0{*LKCdrg|^e%KL@{i2u*UK`fQfONmGyOF5f(43!q zMDkW`;GE@+ygOx-D50OH4jjZ%lYIOsM9EEuR%6P@d*)dVpZ zusXD7<7nb^zkfA;Cjy49iZ*u=_WA1k@ao|SI5zKnr@?eMMnxR(#jvqL_4k}-)1JRa z3uk#2pJ~ zXumiCx;Ob^uD}ShCC6)uhPHah^o(gQT;~cc;fG$ls*cGEKCHrrX_h}bDQQTYAb_mf z=#0ip9*{;4X{(N?>8JNKrlv=}obA)J(3E~|g4g#Xx{1h%cRZk)>O_4qa9G{GwP?*S zbxG564`0-@p&>jkGVf?N9)Bl8Ek?9ykGK_02-sz3=tPI=(S)xyex0Uwd+a9ItQuvn zOr52-ws{Xt(LwEay9poBY)H;UoGOcYf`%gPENBfBKly;zDI`h;EAf#jpv2Ef)qle~bVeN+9(Upxl3lfka;`8`Zo z(}<=Vx`;_kEiYU-DOTS5$;$uCVGeHK6jd{Prp?_Y|H+*7BBq%9VDpglMHu;m1lAq` z#Gvc|RczFWH|bO9@4n5XI+3<|^QomJe8A>Y?a4MNlbI<4=nP#6-)o0JU_1!YYF|KE zt@H&p5xmzT67k*L%_3OwZ`#y4#}K{^X}A2VMBk4*d1A{w#pA*SSE5mqls&{oH4Vf(-5ZDvrMdm)-U^6n01ZMx*KXSxnxQ1hTtGG$QpS zZ{^?7WAU;iuN_K!IAk3}-WKl9&h~3i_E-!0tzAvr-Jc3(jl#cOgU>M?$(LRbicdsg z&$@}vDSP~hCm7Avy6>$mx`o1|?Q1SF!Y2>y(hXSp$OL1b?-5(cQbTt73Z;hfkE{(L8&A=^fA!~W?{1@DK7Jv zF)DavRm$q!&fv)*e~LznH2l&4&y4bG{=UT6tcS4gnfv-hjQ5}KvZ-C%R%HmpwW~|? zI})_A5Ak>jna#m(8ROGGSI?gQ`Z8Og^qU-%I|?I*Mm}DCrDI3(2JZYy-cEC_kij0y zutL^-t@uzOoz%oB9gSUenzB%>h}Y}dRJiPVSXQi)zomw`upFk+0P@HV_5_pJ%Q}^k zBx5%t(`DhVFGNKd-7VhZ7Tu+@_0<&cIj?-vYVm>zeLc1n)%a861KO3P-S&mOof@l# z%qCM_1716wG)1Lfz6@@yuS|Cd_-QrJ&-17_)#d+ue8~Z|%y_9*`E>(5rO5zSs9x6# zV;_ok`zhc{pI`5N7ws+_z1n-L8fsW}9`@r&Q316;VEPO7`Aud*hbqOyS>b$-!@eEl zmq%O{m60}_^r{vH$_R+QBa$OhZbN&Nu#0$|ijEgY7$=x6?7XaoYFDk03IS|YZW_jfpStrrjoPbXw-`aZcy+k35P2o zIxh_V$bD|n+Wchkfj&oOKn?fl1{lGl02;5LHYxg690^s`Fi}ePku{;3^`Y5LAkZ@H zSZO$5cysVl-F)K)53P)Ixo(_9ri8U3-i0|CnxuJnK4K7Wf*D5 zg87E(Dz_sJ6-sop|0CTo#g6^seqKkjmf5+mBuSzM&Q`qJNeSM^pXc6m8LE;woK!(^1! zdomli(pXM)gIoyHK;^jjn18~x`TEvCr>oyA6nT1hJrr#m7OdFeo81sm&nCqrC&LtF z54O9gR`+4N3rUIO&l+{;!dKr_1A->ub40|w^rp`<3`$;rxUc?vZKW@x^+4` z`;9xJS?0?z{cAu$v04v)uFCIw~HNkg3T^@N9<+RC?=5@&&$` z`bQ!{A_kIW9uFY}Lh3s)pTjQt0PDoMfK6eEu0#eUx#mn{>IS;Zs`6$k=pp2JMyO=E zI^AdlPNMl<@!DVj-iDS5+&(CH1~UJwM(z>>41S|#mZR(P2JcjCv&H?4KMfqT6o5V` zL)-hlGG5ijnQ+uQJaiuV7m`slm~Appp=T)DnT!1dEZUd`}@uy#X7*ljX;f zdg|2u$tQnKcH_pp<`fY0PaJu6d3Sd?8r2Sdc?*12qly%&L3ZpF)3|0I-hIY(NDuch zZgXjVUcd88wD-(qqG!k9cYc{bb4{TG!5cdViIG-^h`qCB7lgB3li_G4MYf7cR}+f~ zs8R!B_^IK+a~!kRgBAQ!Hi;)JdE;8Jn5o!5P%kz7_s1;L@yDOK~1V=Zp5T+}0E^n~1f|Lpjv!(GN zKbZN$4L9{K1c%C>Ya)_e*L~99UaJ8x;y$VbDDLE=l0$)5xUDod*z6~H$7}9{2H63M zp;ki3bADNl*A`dQmxIxiU}OW9mtctzsqzg3G?F{(TcXI0R1HpqyZZQ>%NZoMMsp1v z9GC~b9#9gg%s&bg=cOZ&d#i}@=#pBw4~>^#KOJW9ZDz~|hg$m`y~Q<~ZZ9r~U_Po> zWS=TxuAMLBZ0NQyGn|M2B@IV}-N0u?LVc}~s@v?cTrUe=qyW(LlIBQy{PrKdH3mJ3 z9Xz@xTZ#z6H|vU4FljO!-wEtLwH4z{8D7c>yM4E+nTCCT*y$z{sFU7$aQS!PSvrO$ zEbxdn2XK)OTMNoOz8f?P9tMEzqD<)hRB>ao$pi*LY{2Vi+S6W!9&1(teqSVzU@ykz zCmde&uil<*C@wrs(mwpNPEaGV`n4mM@lN50a~>sNaN(Kl{U=>$*PXdXdGVZ6$d{I3 zJ_Ie%&smO^^b}6`L6BHKTQXG{Ez!5wC%f~ho^kn=eV~1Zd7eW18UbU8Fl3$0A6H$X z%bQ2fYYxS&hsHjwE^>SIdpWUZ;aPRz+@ZNH=4Og!RWj_mn3?ptrIekXL&vyB6Z-P( zRAjZg-C`yZSVhPj$k~Fj)H=FBW+UuW`O>|bwZY~yXT}GHrY6hoEel1>mI4JHp*_|u zxo>r`hr~sJ`fyQ--__T+g=H}LNLK%)_lQ((+Qw~kWAUR63$V$c@`}$gy!U!Gq#x({$w#j20m8B(YDEi>mNnV4 z!T6$~6Vu7!Kiwrqtaw)84{Y5&IHU!WcT?9I_3K)FaSor{a$NQJ>217k#COh+(U~rG z9W~z>HDc1A16+IgVv7v#a-=dArQR(@Rk`$!l_3KcJ)0cb6A$WqdFL0SM#d7+dx<79 z<~1AZ&SS+GFTF_HvTsAe5<}R@*$FA||CqE-o+C#&0b5iS=e} zzSwDi+MUmWTzxx}UW|nEah1$^=k-!YxA5VZJ>Zt4Jk(xGqn|!lE)96E)nDG1Q7q{3 zs#|TY)noO|xa3Qzmsd*!i^Y#-8m$IOc1xsYT9*qx9B%Q_&wcSD52lkzJG40obS}r@ zFFbM2`r=z|h4cXm)c;kIM6d@rGwIfXVRJaRTb3Q?uE+mCschP(s+|=-Pd6m~w^oOX z;~lwiJ^Z_i<*KA-VjlxE@}(tERd#RyiXOtlE?fgIo^BZ9G+eI*N6y7&xOvWI0BIDn z7TmH{7kY|)-MS%j-+xdZErT~?$S4E0(n|}@DF)E+SO2?Hv)kAJlcTo#8>na}oPX}u zE^WS)1PXZN>CbH{y3;L$egPQ}Ze0wwU^1w_q@TZ3F4&o|i|=U^=548i*DepOc*?ZE zw8-0qxAP1?r5x7_O{3iIm?Vu5iBr?yADJskEl(QUBL0+$aiBSal{`w;aYHYje1*BJPnD?{yjK*cO4#A<*oT7b z*~EqfYb?Zd^ih@~8W(%o*S#oeR)IzEb^H}8B*F4zsVdX4d_W6-M1^{;uby;UFZS6E zuRmSUM=+f{e>!vEk@AW5R**9ifPTCKV{&tQ%H+nS~Wjb+@SpJtVoru3XiGSqojhK#Zx8o8oCWVh>U$vuN0qgVmcl4hyH|5A@N9fu0c$+%=qsD zZT}Fps7Yf!nomBe-8pEbnV~Kx{oR|9k&yn8y~KBmyg99fUg=w&y$hb>pFyK(-wfhf z^2gtQNS0Sh&MFFq;K4!Cqi_)83p_K3+Y6Fmi}8U;5Gw$qsXrV)+KTwa8Dk&rz&5Eu zl&q;zlc|dhQA*YMU*WoFWN78#Z%qGHxJ;`!BEB*;7r!#QT7=rkA_mEt{5@%#qjesE zdoUAh+bm@GOOmqt`Sf!1tTJ1)r$=@hG|Ir;_YU zYqN>QywUF!SSqxFIZz;fMZX%uq74UGgmOJpAa{VNzTcp(oq8-N5t@xvxx)9^!# zcb(td9`swA-#qvJrt8!LPI6X}wZ zNxbcr^{K$|84MeIg0gy>!caL7{bfO{Yr)yyCuFSW0A$!`IQ9i>CQy*U!E2v@>~IL^ zcJXf#TaurOdlrMGeP2orJ+StTX2GPyXhv|B%qmrqsPvmF zT=-^l9ZltOH8g%xN+6+?3#PzUe8F9;OHEPzj-s1q$67YJ>?pSTqjQb9)!LAMZ31c< zYk!6v^~iDN@UJbxb`{A>uHC?+%4aJArxcpg?N!6Y zLne<$;=CSzW}}qwy!caDQn*2(fgW2^(Wi95GQ(KT4A^VN5K$OHu+*Cr@{Kt-dkNa{ zo*8PvCEJ!E|2siEb(!obNEB^{VRp|V= z#=Qu#8^-_O{^I(ee*9<*g8-P6f1%~JWp=ym^!M}T^}CD$VUu*9#IT#&a~zfnzx#E5 zj+1C9<}4upSslGwm9*>5_3B6#-weoAT$$wUO0Ut0&H_gzs_~I?GDM zq;@OjE&}714$$gEtHd|rGB);hL6|FqCy~bMbkgxMCuD8ieQ|k`i-r?+|(=RK_PS+ zUxD5GWu<`jM#+;xkQNqbz~&L}$M0Lr)5^)v6x^8gMCOj(02t;OQt^TY{{uugH-a$P zGHRTZ1v>#J8BEWJmdmEa&-_TK&7DQfHiV*n#aqZgtty;U$ zlcxhDM3R&|wi4xz4n;$Rfyt47%i7LDICLcTX^X9ilAd9sW=ea?tctdZGE2nwomqOk zN6}>GlG#@AdaBS4H(CDYHN=pDkuucaY2wIO{#Z}{3FQYnEl0j>5xb@o-O|d^*8y>2 zPwWU+0)lC&9zw7hbSo*EAVU1Aj+GoR$x-bBTWfo*dVuIPs&GE>wJwZ(S*A?y^+m=V zvIfJ^3JF%ap6fKPc^~4mupcOzBjqxKtj4C-4%CZ_g%ZEZt#hJmk)PafRxQN#G0>#M zj+~Yqh?xyz zI*=jwqb=`{1*rkc-*2=@9>p$rA9M%Q=|JUWdx11@f)t1IkAGR%Uuauy)_>s@7j}xS z3pl$7H<>wRx-!U8YO8VuX|C%s1?52?-4OH71)m?2qNERmX#v9Ij@%FQ^29ukkI|F?D=D8zf*w23e+7_Llo^>EitG}G{>W)}t1Y8ZY=2Tc(&oipT~ zMK`wPUdi>ZUmN+XQa;3D=ZeVTYo~sSNb<5rPriY#oW`_5TcwKHlu%Px$T(8vtMwz& zu15gBHIhB(Ic(Vg0lRmXF7NG}2bBoT8oKv??%k;Pot#FP~9dHj519)k&69m5$TMD5;}rClIU8lC9kMRIGXB*A7L zt=U=`*h65T3d?wBWa?^UdcbSM?x65qc0D*IJBhVB{@TAwDm0|6)HB+mgMS9SJh&4L)<+M&44m9B$nquKNTBopiud| zMUTZZm3+4&zYM(-UmE9>g<%o-`BGiW8^@y75Op+eK~K%3{k&cF#PdrV_#4hQvS-~T zAr3fJc%Ah!yOLKVEO>D+Iq#BRa@w$Kd<_3+8)hpWK}@L=S;PUErD6&GZv32WaXJ6C zwseoT8@p6RGNrZ+Og9v9u@u?R*W|*CKa|4^}o{R-MJn|!Z zom|UfheWkHp%$ir53N08eVj6D*~L4UIm7AG^;@P^nce&u8i&LeofK0x^ur3UOTplD zW{@&HHENnUh9o>r<|cYrS4PJ3bMdH#3Tb4AneE>-wHIEGq$1XzT33BXD{g0~v;^Lt zm_6&ezwBn`2lDzv*bQUp^ZVtmS0cpYo}Xa=dStl}3%ezpD3JUXFcEgae=mRk3`_{1 zxpH7*%$vmq)A380JF&0`Z6@KZe+1>ne*`3k%1j!2P3CSstmra%pajT3G9KLv0(IPX z<-j0hjyU3b8(;%auyktVcG%UyU6g@|r%%%slO=7D#+w=68^qUJy9am2RpmF^IR#;x z83m3Z<2T;%3?l{MMm~sU#vkFLj0*<#67qJSZUu4pi?XGd+PO_ohKlwq_Rp>&(^+P$ zYm@#nTg2E_ROl)Ty^e~9XkEbFVJ?4xsN)lJKI6^vp{5$Z zYEtn45>>sadrz(aH(qM0KL4+_sF56o`9-Pgvf|0uxYKZe+H6u#uEZLr_Pvfmn~d04 zec6|M#qQ8uL=_7vM~c3MuKyC0p(?5{7wof4{jsg-c;gK7Bh`3c+NW)mWszyYA;7`3 zp7UR?m4|*VF=s+928?PTs(vOv$voPo&JkD%DWNJngs^DB!W2N6gf71Sv8ga8@+g$y za(Y32)AQnJS(dzxv)06s6>xs~D(l$6@%W-UzAP1|j7>$=yYR_J6@~ZT8(6|Qp5Is) zLz46cI)4hZs=udV%O^#tW{)>5vQQl4Ub@d^meqWjLek0Ywn^7Y72jR^GZ=Kj$1Gbf5mLVSTNz zolkF(&Ma4aMEqOT2ihMpUq~kwAEp{=zhkp0oYH2cXHn3OhoxXi_8Aq(8Q;$*$q+h%pu@*f4lo>fo1l)odaIV5!jq@Ivf4OIx z!xnjY@i4d^Zw-*BZ$Ud-tG97q*M=jS9FoTZU#WnlQ zC=v0J5ec!5-y}b4=vI0^8{5cNiB9R@B4Nxn8F6j0Yg@4R&A4T*MQ&WZEg9rJjzIuv z!l*tIC|?>@X1t@&E+bVVyg4CIH-?p0sop$XgO^3?mLgW&6_t3A|AF>Ofe&P%X}RrW zNRu)a^E0=}X6!Sc)}yPH`ei^ylBlH{X^ozXZf0X)RY(H8yo|aGagIM1&_$RRtYidT zXETjhE`AJop>}=a>RQgHQk-i|H9Q1QXJSjn?aN?BRJf1UPZ5+JO!YwhBhIxB1zTlr zAW94o1eIYWiIl8e;7TO{Tu4c>mAF!p^>XWxROFwRTDyWRAATv6dvd0XDvchylJ(B^ zrsDNwnB&z@s6>8O*2a33rM>f=94DdtftDioA!O#P-hrTVe9{1(k`iFxZu-u6)`q<2 zbwW9*riv^lTd7W}_FE1LR-HlFv%ZZ;1TH*ook}crG;N4wET(dnBL)IdDW3~$&D(OE zKmHpyQsy8a;AvBd6{q4Q%56ZIZK#UJlC-jsH@E?HQUWC@Jya;HOgU7D^cjC8)*R$q zY^+y~>QeH(SxAA&b(#U$H6ylK$bL$WfBbKh!r+5xE*P#J^bFhp4PY?UM7fJ^4m zalBgFehbvc%BJHZt4l$y62&1t&QgtEL`AmBRJt8VJUw;`npP#>`U%iu*G@ELcy1$dTf415Uz@)Qb zhmL%iZeZY{z=m_n+CnjYpw+MaMTe<>g0YtVxYOMjK)Tt%`Trzr$=of={9_I>DX%xF ztu8mt>#}s&?5Y%EMzJB&&DU`dY&5QTk?RY72MUf+!(=#5WKHV$)!_Xm8VFLf{$ARi^!-S{}vp`~T zVXpylrqY%I^D`W{%?&Lij`&|u`b{5vfE_3D^JgQ-rR2QQTr%*60n zjIge@wX5s5$FlcsLh)p{LQOz$>)O!*kRx&xObSc;B!2ltiHXsfcD48EN2H`Q{S1F< zho9b*0^tVCBi(5KfSE%_yDxunrc7adI3GUtKIU1ceNlc(f9*kiPKG#Gp9L*voVTxp z{K#FGMwODDb6m;BBriu_HwXMEynF5xqb0STf22qH=y|l0f|83cS-N;sc7E1D@!=q| zl`v&=zj$;TrJbB36*@fW6BTwhk7ZzSxFQ8mo>0*~BPSA<{P{ zU0J}wpj7Rb!YDr^@|s`=0?DQmz+tmo9X#({$Gq$!wwXD-QBwE*v#9U8gk~)vRGG4{ zUV7203ev`Pc!(Mi$8^X$My8S;>N`2SB72KVQ;pH@%U+*8mG|%ImJ!}a3Qg=43I!>Y z&c1Uf8*qi6zpz#!p^#T$++m0$NcN@l8TK?SwvK^qI$ul`1 zw7-R2odRDe`;fD*{JB#sS3A>agMUF!PU^~)ZWOmE1FM`U+ExQ^uLbv0g7nTKYIDkx zh$n{f;^!g`vs;M^at`_@_Zn`It?ZQv>lkpgE@D|IH)Dlqo*R5`rS6kDyhO)vZ#H== zdB-FH5Cl&<@EnY#*DzN#|A+yv^310s^U$@3NPhXFkyBd)a1~=jP-tv3>5v#u_*hTx zpmAfC3=<3lNV<31^JW+zCB03<&X_m4yA8X0>N^If+OdgkNk1?(jRGy%sRE4n#!-aG zo5h)Cg9jgntwZT;b%)LF2QzL)zTTu5m^3Vi{I7&EcJrMnlA#?J2X5A(AVfry2=T-_ zEoS=#5f{pD)`A5B+IabrU29)2sls|^qER?l0B<34?&2halPD{ z?ck%$hfN)por_kxRN((JBn-v> z3}MCD`N${6%P+>@MmsXZL0boDrs6zm$O$hNJ3EajzLRXY7cLKVAUQY{8|82?|{W&#JD~Pj-+oRvT;J#Nr7Gy-bs|S~9ZV4DkBt`}|rZaIV>bk<8t(g?~^J2BkV102M(#&|HLo-m= zxJ=K4Rd3vLT(cLCGLOVz#}ODXuBq$KytC3!+>pRvXT!5!!4jMV`px>HL{YE*ODfh; zQhp#*o0B<&JBP_O(AE4yJ*a@!<>hUn;Y4sja5EWrFeh&=j?%N|d}ko;-f$;D7dvEAvD??Mjrqgmy>t|Uc$^Ol;UtrlR>&42JpkbPi^UL6yf zd9TfA^jjw1b|=@ntym(FckKO4)oIBz_|DDZ&&r19Mr+BQ2OVPZZRcRcPXFmj?{MJ$ zh!Kn*>hfmM)04PxZF7`N?aJ@Z4;_9BU*JtyFpw3a&X`+r@~QsHNJOpi;F}3%oxlE% z3*s%BGqYza_?7wz;!aAsYBdK?k3(y5D&}R$&yp`>`!WW4b}st;>Rx{f6hR>`W8Ly) zwtE-wYJP&U1)$-VTZPsHbsf2wpH0x4xUUx}UoVb1F$ROKtH)+{NB`r;2SZ<|C%(%N z>k9T7t0tNv%$Mp|1GF&xal69TH+s{<_qY?*|Na&nSrZB#s@|RJ`LHAGub126fXlX4 z>}F5qHu?Ta<7>HC0piL(plJ&r5rM1aHte28{4+IG)?6~tbK;+e5+Px~C^v4W zETrf))D|-{js5J+K3kb@zD)4Jhe$kdVffre?gC#qC(kPt&IcUCI3Hv{0^A=SR8lDD zYERhBVzP^Pj>c#F7^vfpH932YRT=Zf{Lk!%=O_387Db$R95wovwDNhL$I`BZ>8fy7 z*OEu~!GbSe34U^Z+-W?*ZUhjE1Q+f|?2bHn@Cjh+-u)GhUj{0*~z7(+hcBy=nbKlF9%L86? z@FqKU=Qac^qUh(06 zcpc?EtSPatA^m4|Vxx3tU}2*MSwZ}cy%hS&bj5Np{M$3*XM8*jNlGMUFr3OB#-2>D!P(jOD)*4Hq$A#U}JeyWliyy1~c8n_q&Cb{r z88S2Dn+|Xx?3#@apuy4vWe0NHk>Bv%f2iYI*2w%XT7g~U)P=-snowgd%A$t);A^KB zQOuP1M+uGNS~R>B;dK3LW%!Tpfe$fz?Y(K2+=Zu8kiL-OIp*O!)2gNZ<31iK95d~b zjEF8k4ghGysUZEU>wi1@FaI{l!rBdM`nqmo zV(u@)F3n{An&nhAuH~TM0XJ*89TMZ*+ug*g(iAM+kZqKz0XY~oX~?jrq4nS0$+`&O z2H9r+4h;%p%&oilyYq}Om)Gjo%46jcoA}%60~Ubp2qljkdKKmrf(7uNm-|2I0LOGy zfiP`F#IQLYyyLwH zM!}u+#Z;UtuYwhaFYjPksXdR4UG#U*N^}e*V{~dt5nrX95akGV2M-8h{svInI1Z-# zDGlpClbcCNq0r@2!GKpeU&ge_R5vxP?*U+|$jC72`MPv?yA|a!({#WKF~lP@lkQoI zi?)+A90TY4uj6;RVXLu5v4S2ck%u9caHsm}%zMs8kX9)2VBrztC^9SbmZSI{ieQ|3SIG2LP|Gwh{ z!M>9M;rc~mG5QxHYYYSFv}y@moC1+eNc>Hb_5{blr2hDhzkGk650A0xj)cl0_LOSu zNq9dvqJ`3peg&_K0<5(z|K{!JuMGTJ-Cjj1VU-!qNZIvdB_`IMH92b{jhIk@EN-n7)f3Q$)*Sm^!FDYZr6V0RXdjm_wNsa$00u6uzq;?LU+S>QmKRE#{CO3 zfy&~Ms`U5>H3fT@VZraMWN^tj$2_3Ize5|;W7sq@L{MpE2ZVL5zBgxEZ1;!DPNt&i zVhqkTR;W+slL5e6s(AS%`h60S1No)=fR5Xbpj5vSjqQFlGLGkXvMjIh-#-DC#pEkI zL_-cwBd}K`jStcMjK`-NUd%BErp5p>;W%-y>UT}O4doUB;Nk(PraibqxzFw(JaqdVz()IMv{Lax45UOe`-v5jDRbd;9^r{xHfiML*v_57|uy z!TpdF8Jhu5%L?Kk0|KP%F#d)+2ZqPd!??wsmd_oNaZ+QV58+zS4jWXsYqmcwJim38W7KU#X_E?t)Gr1z7r!1>)Q2iXj#%9Uf~Wb6wqFq zm8E9E8L#IF7FYaV#208@tcz3j#I)g9cWM$C0fROiKm49XbV7mFIoguly z(*DYNh437lpMo4SDOi~4PGmM$eiWuCa}6R=|7VDTTTWR4>F@Y(<^(uO6O&(xk=5_~ZDbYx}5Q{Gayg z))P7qXxdPL-xb~3)D_&cu}C$4Gs9$hblhmM_@4l&8%3R&8Rqt!-=U`b@fmB3*%=-! z+n<&`rGS_R{pDmmOPsuv7Ra1Afnd{u^+dTDiGdG+(W`7jk5b>l3h~_zzO1C~w5SXA z?DH#Lcvd*r8~%RMbGLeb(6EV81sWeh83#CsTJh_Sg#LKJLD|DMjU>HU{JQ?S3gn$j zm&!NKJ_3Fh4cV(sL6EHvww*Uqr!qg0o(FoY4P`jocWK z9VJa<_*w8bAwRRye*3$DFn#QNXH~+-4#X7mP$DCCFAZ%!FV1V)=)VMktA(w+gFWTH z=$bvq=OXQ~RGzRGlY;3DohV1xnl9r%^EGsrF6{T7aU5h)KYV5Zd;MOiBr{3jlh7?mYVoOiesm+!lNph778kB-=AFyS=$6V!*l5TJoBiUUv{&)tVr&_rF^M(v5xGEM%LRURM(F1b!LpmEjv@kBF@TO=AUnGQsRyJs5}fWSRp^S z4+WmY6(1*xr{mB68_Dk?=R#Yum&8}*c*MBDFlP{-&_{(o&Mk?pHzp9+3p9pQQ97qu z77LriJ0o#3()g$u={-SR{?orbG7i~R4%Wo-)0NM`FI*!RrF6OD$8paC{29&$d+0J4 zJ~QH*ec=tptyA74!G6gH4Ac0jL^8-fuIWFb(yPFE>1lxZxsdC|Sgrq)4d>!)OyvG> z5C6`;eXA24MfdENVeg9UhPhjyq0>%RF=A$;{uvTq>!OIgFT)*Pcn{#eSd;ZCO4$o- z9F9m-5v61W+x;;bakCv4ZCq_TT9s0UF?M}*cXAaaW8!w{+k|RFx9?mt?Pc*K5fmdYOx;6IgaaH_);d>sl~rj`xp9 zuwFtmhYt;vh9i9?`FM!mVG~a{_FSz$Gz&Y8fug+l=Dc7JfS(C43Z`zC*zIj(vV53i0^ zh7ecP_n7^&jTF$!U|%@+$~o-mj2B|jkKuY3#yp+N?y^dKG1Bnv*fLA*~R-`-TkToFuD5wBI5Ey&HC#9LgW7;4;BP)LqdE6_sif`997X^mJ&wda!ft^{I65Mkg2dHbxwCnOns1=at-QtN%=K zZaHaq79|gNZ*d%u{$)@{#n^Us6uNA(;Og^TiII^i2L3u<<2|{+;uW%)Lh9|7?Wu)W zK*yWL52y~Tg0d3v$8BLW5}hXQ1;uB3jRlcb;=`E-;swR zYMQQDNXZE_7cSs$a%$nb|j7#SCWFVBokXxCJv9Zw zX=^iF^tl2rA<+_ESg7p$pbc@pN&UuGkzw>AtMR`&b)it=tVG9NI!Ddn9nhOBT6QoZr3@jJ|C+7K;d7Ef z^t;Yd#kJ(xvIq1zWhl>?L!i0xXKU#WZ(a+v-D@x&uRfq^1`JkDDzUsAZ$cA@bux%u zGjt1Zuo;Zo@z@I4+C-+tt^|r+=g;S)R}Hz($ZcRL!u~uoWn>MaR~Y%SHFc*C`%%97 z=_{O2Bo}-px7XvtB!N0>fZIlM$3N$AyT-NA_G~@AX-R%np0r zX4e?DbCjJB@X4oXuEH>IS%F{*Ssn*Q1|WSw90A$fI9mdK|J3J*$C(+ytQCoE91 zS`HFXh-ZRCAqiBQJovLKl7YR{Yhy>Z8}j}_c-Fx1{_ZfhoPXLoKWMiDhx6ebk;AYe(h2)Q5eRvQiJE-g zY8z#6!!Tgz*@yb7V!n$4rb+c5d_R$Car^$T9kSPl5%r$dPvzurx(Ef-Ay=?l~FftIS{M!3GmhkAAx4XZ5LbfRzq+juk(W(I##@pK;F>t zso|r%WD({wzF2Id2Dx?&7TcX>v+ue2E5AlH8G8mVWFq{0L0QzeY8c=z8vAlpC8LIH zHq#6AwL>Ei<;mM*)Tj4AtX1YB6?QKYc3PU5t!rmc2@J7@F+)rEZk7(mHq#n8) zH8b*#J9RTZ7Wq=Q-`MyJ`cn3SlsEB$u%zZ`W%%$W85+vEiEGK5H25H)(QzfQI%q&+ zbyh5#jj38^V!7Btl0C~oCA9pM3vqVFpy&q;kDOOvEE?)qn+s5J%DMtW?+HsvJn^o2 zcKz3*FP#<}9K?~==?0BeY75P&Z*&?U0&ot zPjkiLZUHWC?l$zORXy1qMW`u<%>AsmmO~+L!-<6OYT{xhRh>{k+g;)$`6(@H5gXIN zn+bnWODGvJPVNE0D8K3aE3epD9A>$1dC4X!g-Y1D|5M-rUjRY$Z%+2#7u8O)JkPHk z*Cp0X;gY#T^jgK?M!W>IL>rnR?MOU#aq#DfeU6z%nx z+0eXp8hJC%g-wxa`uGkxba#b?@c$* zY8-ZUq1?QB-mH6)I}p66B#GDmd}}{Ev>diwEc=`wosd}Pb0BXy+ZYd*2}2K-C10N7 z)~ZT(qR)E4j9>d=Jkb=YyW=u2H!;GUjqtP0fGd95@895G_XHx}oBQyNB>WB&&!xwL z|4#A$-cI+MpMja=|I>#s&>h0X6<|E%@p~HTZdTfPa#TrIG7tGg;;cX)fi$$>*icv# z~18%ej(KE@!1$B(YgSk)61+cL2)B1@H>m!m(3K zkeKKhO48=P8uX4%XurAo6relo{LPfmXt*S+HW`iUag%ZEfLG!@@`G$TzP1@1`PUhY zp~7S=p4hk&i(rwx{DcvG+88bT_SS?!%SOC2WPHIK0X3f5M9j zqF|Q9?uWOp+@y#lUKPvhS*Di8aBl?x$H!`qIP{uu3p2sXsJw#vE(%3CDjP*?7oV+A zTiH^m4K7X10Nd-ta4|p`kg6duTznG0l`^Q0qI=1c=Jf72EyU=z%+^JnuOu^pAlD+* zl}3DfhAM7`4nYL>xoCV<*(|2PCyVle zusgP2&L{zvxwoM9T$sB60$(1@P943m{h2}Wt)$3uZ6ImXXfW9+V%lgluRO%V3S9lY zM!NE%%l5%RW2HQh|yd=rqkb=vCF7empWh_8qm zU59^22}G=BwXle3ls?*iA)drXx=ba%8Xd#kQ`#Vf5OMF5!IQaQcvo13LmE#pHk3OQ zs0gjtQ;w1aWJ{iErs~KYbhs#R3w(T z#^8}2m=|XGN~T;*%y^nrgPTNG`70#-(+x|h^bdxWvrRWo)AHEuD5|s#W_L$TaGtD4 zq$}T23g~&9!TsRZJxM}VD_;eM9$7zy^fc3r>C2(UsTbGNDq@81sy3+!>F0n z2pqma+oQb%z7Bx>HR67zj>eFej`*KKv@wUVukz5=i|(Ck(Zo*1(dXJ3*_Xwej?gAH z5)aNwg#4raPTPnIZrS0^eMo5`9O?j*jw7zHmr#f*)sS~nC;SvKg)pR3z>qzpaQ@AB(BeDPsX>3&|7qK4f1wL9~ria61{oCess zW0+oWoHC@6XtZ}?zbMY`43`uI!$mZ)d16@r_H_`Ft*q5-N&KiFz_ld<&aed+cLBvD z1}El!vFju%IGek4=t|VM{&27qy^_A)L!*^Fr+$#_{2`}36R$@+g*TB=t@J!8e|NPP>|6h3L&^V=M?cn@F8TGHX&HTAj%e} zlyux-Xt`?hYyi_V*sX6Mp1HZ|g-UGev^;tYxY+8Q+rGBYB(##BxQawH%$I%()IT>9 z-DOaG&|uQw0^}hByzfXIINsI}~dvok6oRJGG4UCxK%!qJt69 z=>|E0AWJSXM)g%~So35$f#lqzf|^%Sn{k%DrgF{fMU_a_Jb95TtKU?u5Ff>^TzUd} zcVUTowf1v%7MA%bA)3UOq-_LuY{tS{Z`xTYiDJ3-l0#AlEkoH>Vrve{f)70>QY%hu z;Z$tjJ|)Mf4cFN1NSylBd(_Bx7;OcSjk|nIq*KhWt%DgsK5EMW-(Gh5Tw%PNSm6%^$vjkcu`fUE`g~nh|*7i9O0AaJ-H>$cZ|t zD{*=@8CA`u0_v@?&aJf`cSy*fUG%ZV9`n;Mwrjyl@v|cCw%ujTD?g>({sks^Cw-w} zFg0>So1Zx&@Ec71#qZ+nn&_YI#yd;Q5zFy|jvKL* z9Hc(6VV``hmT^wWESTqJbxGVnV7o)o}KZHfQ;r#8ERO< z;~DV%>()^~=QN(2&y)5wf4lYh6kiC@H7-3i;21^u=d*P2SuXFNwJ%9J-zeuPAbAX6E>Vu{+X+8&Q|az+d)Jy3LS>aLLdD2`M4{vBrND~_>; zmXZrET&Rs*V*FDF{7)?JQVR!Gr*$b3Z#$lk&e-#4j-iepLsSoMQ>FxC1ad1|3*cJG`;jOHAAqj=z-g? zrT<9qdSQfY^~2SiELjeDEe0G)*lLvMNzhX|VyWmL`@uc$fmAM6K5DIVCBC23ngu0i zOCmU*YGde2rji~INU04Mj99`xBzRLFNyiECId{j1D=zkW1qvXp!LM$Q`;|1vd+@HM zjw6cbv}$P1mY{THa!LhlM9;5@qtEEW)KW^Yin%9zL*P#_2bxoV`Y%ZzsXjPZ|1&ax zDgAQZB6iw;sqOV^r4hi<|6j#vf%JbygTP}`fGw?s9&^E4cTMvCb}FWN$J6vGxGTiS zL{V6kLt2D9deLqTn2}Q>(|!&wQhn&xzTS`!L#L<7Qs<6IVVTah(aZB_4+!rT@zJ(A zP3JuR(y}*M*h41@k2%6UsaEAJAgfu#1;7Oc*vR%nAH$!{NrZ`hwIZgkB|_YXO@q&4 zxbtiLJf$p6>~L83;g*Y(fT;XqZ5tpEBvvmp@aLl<$seApp1DfyE)QjM)8#hje;N1c z>-8`?_BSyx_UKuK>SSNKPYyH#QGl>2NaJMl+BFi1IxipN%&bKZ`x_MTs7BB8+0|@7 zo~gMH<6DaEUzfN^Txe70f4)*F+l>gp)BN^QNb+R9mk7%uN9rxU)s!`q$Gw zCxVfd@o#MX-SpnBtm>++zuECu0#t3@i;j&NW=uACO12(pmkq7rIf&=B%G{(fd?1f* z-#~sW+<&7|&A#HU`Et^36f-Y>W{fg^*qNwkT$mZb|_?1}OAIf7hlR}v>hvQd$qnh~BJ zjSjM&WcZV!a5o^{AaoT>=&dewkO(_RZ1MKTTXFcu&RA})g!p>9Og-epP#`pB0GTi# z;b3!WAaa(8d9JUZV~03}U>j5>D^6jrlEZt<94lIOoE z0AzrNQ}5JF(o&Qg;_1rNKJ4BMRPH^y?yGKddX(%u${h5iW+o)LoYoRX6F@6HZ}4(- z!`68Phz>?g8ni5MSbHqQV*O|M;C2O?|DUjmLM5OJuTCCE2DPeGCmdB@Ilj9Asf$O16x-`G>7$J`3Cf zmEjg3O^~BuNI<@8PdGBZI~k~0Yd?I+msekY?9vr9J~Pvi0=uCXBvrA&Tm?+eM@xcH zlfEqrHDvsgyMnu3L+aS79HjuNq9qQ`KaAgmJvJ3e$il2z1>p)5u4JK+dHqI`JWQXd zC+!eOYddr-`v5w2-HG-`e_bHb6TVT;0f~I!8{Hff^OQJJ1QkA55{b2+hTU z>(K`XtCQa9a2&AxtKtnSi-#OC%IU|=*VFn-#{qRNltSGZZGyhsAQ-r`kb|Tem?S}) zbKen0iY#UZF&0 zaR=HPqNzz3l^l>ZV0q<78<;CH<}y^#-#Lr-PwyUQ44<9@ku2@TE6WMg#n<2J(AN=0VK-0e5$9)&Ar3|?LQltIEq(N@4zqrtNr|cu z7osQTasisu;%vfA`;AHFGQO9=(nWM9*8|B5+tY67Cf;9Kv-M05LOo_U`t?uy@SGB7 zOmgF=5)@s86hhOK&bY=ZWa~XKnuULXx9`rJkp7%fl`Bkz(X(U8o zE;vSL-#ef`3?e@rurwri=DzCM>aZj%v&2`8qkEp+JOqDZ%*Rbq`qVBS#_^1EJ-7Z8cDxERF}}hSxdmD9oyDlYvb!U z`1@ju>-`ca5~8>TGbx0r))8$dInLHJNYf9Qt|

xQqN*Y}$C1Im6Hj@-5wtkl8i3q?Pm&6pvT0bX?LbNF9XN>&AV4kVBOl;8dSM257nvtRSc@qaV=^=N@$Al-ynrrDp$<`r3X}Vs~xwwLRGP4=z$`t@77rrTpA3 zyRXc&kxBtdp`}-eqZBxcjK=tNBl>`0Y*%tpfgB1X8onX-x752)a7#&7AC zqa@}-S|pir#pm68C0#wEl3vFg?6f>XN5~yg6JR><#Ywvg$;+e^rWbPI5(7_8EA7EY zf0+}>U!asBIrS;RDxb3z6((W(aB@nrQNmg7(%!Q{IYJO4#Y^gI?Rcd}??{Ow%c@2} z;9*?gUPasz0`Gb3*%zAX;qk)Oanv_`qVDh$iLTu4l9(5 zu$)P^n%+Myh)^nXZr7^elrTR5{Onc7k*7G_U`p~9v5Tk>D&+P105?y@lbzm0%BY`D zc#aD^jd>}_o-Yy%q>(B@VmiWNUK14msmQwIKt>59S@_aaNh0xt?B%*pwgK;6(!BYf zV;qdpbjtjn#!reUxUoO;8|V8#D$%7qUkRI~boW=s+CsS@6&}ctio5BUlB2mrIu|7t z0NQJY&Y9~&v;MPP=o&UFvn(FBz>q@=aDPrq_h*KS@{-o`Gq5V@gK-VnLfM$U)R+lz zSAHj~6%1ORZGX0uC@adPOEN{vdHaP7|MtgciX=J?QN#Ihw#e4Rpt&DEy3ksg;=SFd zbN9I>!Ng%qr2rkANiq_xshz-Ev(E$Da2?~~h(Wz6>&4R@YqeFcx4Vv=0Q|^IS68`c zsWl3a1gbSaJw|OAwL~ol0i_@GkHa?LG+msRUV#Ny%%-OuCntS81t?ipBbN4?@=c;I zdC22}ir-p>cdY#)5vw>`Ql!1^=+;vGQ)0&47Zk4 zIjZBP5-hHnL96v|r_=odS_TIek4|0;h73>X`U_utbxkX}Bn&;Jb#a>3#?q=3Y;Gz{ zj#DKQIdH=>IIyiPjqfK@0vTgbjC}4Cse70-7~ipzwh~MpF(0kTBmV@Obyv0%%T&>cq{LQ6ZoaT&>ugy1A8`NW?Cw?V z)Z3SL3`!n96Zx(!$U8p&7VlVWg1mXBAQqJOpgK6CJ=qY>@ne|Hs{1Wf6%WJ$LRTk`)a zTXRR-h(BW@ZCAub)V+Ck$a?CH&9azE zJc8&Ui

U;CHsZrDxvc)dRH3y%*?+E#9tp^TS{ZShIA*b!YTlW}r_`cueSZ%?+(r zlFGT6>I#df$T_Y{WlfQ%+?=U$!D2SNcDPU9!?RtA() zT%7ysJEcmGGQ=H7DmQ1xsVhwem#+vyp-Si@1(`R+<6J237zV3zZ43hJ_*_d}v|^&WBoH%;qMmp6rrzSqGA0B;e)^ za@o%-X~v@(QLWtAd)?1ie7-KK<-(De90qAs0~Mo@3Ffahv&jaf+`gtYy@BcaMSD9d z*9wfkkqM+5)PY6pnJ5%Na3&mei(BQfl405zm_34?L0UFXHRizSvTO;BQu2F^8@2d; zB@u%_SYpUN;U!`HEAqwpPL&HDpksd;n@4zJ;c4jO0SRt#;qVvzY?zpEy{@xkf_QnN zKNM8w^;M2e^LtL^&7091fAHoQKuV|VCf;TS++4+(^nn!L@r?1#ZZ4!|Tca0ee*Z@T zxhIeKLj=&N{#`TgJfaYHT7UECb1a#Y>r!q*qulPNPLF7fv7UomkR<)m#Zn3wkgV}n zLYwfu7`}2y2J9oxngoG4F|{&F7<0LqsuozXDmlG5wU`51H7KsMDA3U>_cLFi*4I;N zE1t!nH_(4L{G6iNPsNz+6yQE|qJ)rL%V*!Mr*fRe+M3B_4RJa3=K<`7VA29VZJjko zRjO>10Mp%qE1>wGgdMZ-z|7SO6E*X;4~F&HG)6A#fP}F79Hn`AlJAN!dTA+iX?#S3Nt6r`}mB^tMN*Ey+iV;6Z{#)@MTc2>|m0W?%P=FlFr!3u~^7t zr&DyW%G7)<9#ww5_|agKm(^z|XGg10@0=B(0~SXZO6_XcW9`D|1W+>qx={3(90y@f z;wfy-!*ois>|~gW0K<@Kx^^D|=itW+M|8!^*5$28swOoq+KmBm?uM27R}X>PEE?8b z1CO^4a`|vs2$+hga_TQ&lEue0cbVz4S*yZglKVe*CEKfEg%TD`yqgX0C{G46!zl+J zjQmn3e6eOX7aH4N=X-B^JO~i%C)pp7KES1TTkTzhC2F5@EwbA%8M#MCEuK^Qs_zv~ z5wQtt?yO$req@D89pb*u@On8ZPYX0zxA?i<{sJRi5! zZchM@+(csaQGVOW454+Yg>hyL+eB0PxT;DebALcCuLuZH!iu^abd}<7Mxdw(zzmTgjS*$QMM^U93F> zVE{%Dx2g+Kql!q^+p@c&DdUIWpTo}o@>5EH_Dh4$`NXX)WFl%;nHN_ZEP60bT5m`AwV-vk->vc0E{t>GhPOY2C(?#Fb zRgbl+!`c!uLKb38<=kX?O9MiJ+%G6xWM)aOb|qst?ioZ@j*x^};mNJY)m8_`+%MGn ztX1~0bW>kKuwm0F)X9I%XGwfDZ?x2nZW;V=+%|X4oQ|OdvBY8dK-pIpC?~=&&v2pl z7Nz|-K!?FRGsYu-CMvD0H@iu+;%~d|mNSs!^ZRLvIIQ9wkF#BFBpH^l5NMJy5dM=1 zuhhkOy_`pMQ`<5QL42f($UI9G)Th;=5l8T1SE?t@zE@{>L9Gx9dRR0-{$HjN7qdB_ zr3ZX|fI)XFvYd;=<~;S>uTP&<4^k0Xo?gfVVGYMtN$;grz!!zZs5@IBjqp8Gm65M} zi`$<51Tw4sW%^J~wEt!d>v{YtkN1FH8n|flz3p3h_&|B4RJ7 zb|pPE)EAM&??XJXZ7kH2a?0PS1v4h(X5vy@a+fv3A~5ym-peg)oo-2-XxsW}J#{&e z|EqWC)|Fe;*E-U=C(ErZ*R8Fh5;t@hs!G7HOdJPeZ2>`%``VM>JS7pj5li2OD8H;h zBBf{2l=VJ=!v%XuXaAJj6?_+sVag|#4;w?pZOlG&iDS$Um>c0N#IHmZwkueJV9l>~6a8LOG=U-pve~nmx1=W4-qUCk$>AHGm>xQU9N)unVZsVAuagap5j(HUM0q;p<8^B&$lap_Ark~CL;jE;W_ z*xPd^-M(BHwM*vI4WJ>oeg0*BBHSYEOltd;5#|4+gCMNaszo}WkDnSJ{gE;TV_6c9 z;b5K1vvLq_F+Zj#nfpDa-+-H)pp_@Qv{5fqDGsos zOQQ*8_AyE^*YpS5B8?y2kpkQrynUM4J918N&=iwz|GSLw9p-aC4V^`Lrm}XCEs$nI z4b!d%ut*{^@ca+}HUT!ezEpi3`cgzYfODy@qcTAq`n5VOU*s=Drqlk@3~QmKz3{hPQj z@T+D&_6Z0@6KgT+ArSWkWD+C-!78Sg7s)?XdJ&UN=)$4n(eh0J*9-BRf`qo+9O+_f zdUIw{XwegyVauHLF1rfkD={f9X1UMl32+b_FnFK9HgX5_g^j*V3(!eDRP-Dr6FWO3FOMzu+eTAhDCQ^Kl$#;cOpGoP1A9XMA*@q?tiGSR-GUCW~PUv34j zsJZlsB0X-vM+(a87Ci&FNx(jeTlkQLrcMSH_g*&bqPW0cBvE6P-_;(xzCiNZ+0fs> z!#p9fTsTo=HB$9cOKZkGy}wIMx5~;8|j7`5!C3dAX}jv&KR_5|&J<#2yL5Va1nCGIK)>k)237 z&(juL(*v%zES3@m-;aK-T)m1Z3+5=rnK62po3aCRp|FHxL0e31pE|PD))LQ}Gt!HA zvM7jWo2K7T-oGp{O-dt6%I0Nvkco&p2SnKp{y6%w!{|N*YE)#^z{6oqbBYQF-OyVW zdxYhMRu2Q?gNFS8bvR#Bex`)Mf<#v4_`5>FLQG;9j4gpQJaF^KiK0~{xwxOYo?$ZF zi*`hWIl1sW3@|$!iE*_*1Bh+L>x|e?)5)E`rNCv4nTawR1pp}Q4|;wSks1b5VdJc9 zMQB4W_-UeS%>@!eocVj_Jl#J&SP+GuGkWNq;HQ|rMfqo=8eN{P8q>|vI9)lmKMPt6 zCj&ENWyl=*_5vgN3RRV&7iz$R*2OWcO0Pg@>nDH#ols5|Z1tqGOj#l*>GZkCO~N4C z9P|iDog%H#iR&V9BAUcn{KlRKnb61s5;x4lcoVF*5#3}P2+9f$|f04_r7 z^SE22qC+Gv=^Ry!n=NZ)>*jJeuq(h2K-A*qON&|xasz;{yp;%AB zFS_mVvQJE%oIpTe^T>%$#}R2auC=$}cyP4SRY%*956y+-EO~moS1E+2=rY-gu#y{N zWpn+JY(f0!c?uH+FCl&rCv1?M(F89=I-L-> zs9$GyJ(b1fWLog=f{feNmbc1eeFIijy*7g1xHHwAV(}6TAVTog_+l*xR!EP*s1htF zQudFSJzye4a0#4OVStli)A~v_&$@fd1-Ybl30VaZ%S2SE7zqHSp;^Y2C<=oGk1+F$ zSd&wt#S`fgxE}5Fo}m|>G&Ge(Z<`DuIMKjhXR_UA#Z}O;A8j(zeJ$6s5QQZUT+wY& zZTuI*q1qxR`w7?trpOYqY1~j9LessXKapf^ToJl{6eYcb%1--Bu0Cl~r)M_6P3)Fs zwDp-%+~q>lO^If%OY6?2UoF^FL_zH-v%9iaaxOzS z*|!bH*m|Ya0nszb8MJ_}e(`{P;PEFvkCwbqIwuxk^{;p=&%7S9z}P4yK8Gc975;;x z1(>W)b0mYiqmASFNRO~9F^WA@h7bHt2ml2zt~fKUs}3YPEl5+=zEL=vs$U6!z~W3p zy!tY=rhI?eDi}w&*ag(v`cI9GadHD2AiM0AJTr!I#poDD(+PJXzwIxl?&9n64nZSR z`_M=`e-jr<6QiRgA({=nNjBO#vYK6HfG0OcZsO!`;^=R3LL<%%WHwYosW3fb8DH$p z?9EH?g?*)uuoQXvlK3G_X=a?qx3<-jXxFjU+ZkoSC!GBkz0y(4Wp~nb%)H z9K!j}EXzYabuiMGiv6I~AnpaU;nHTvN%9h)7g7!nu#!mOn2HtIMU*L-BL^cfR0h1r z>$vgy5?_8VF#m)21^tBi5Nzex2g!rN#~FfDZLFYWN-dlMcl6%N&W)?jo*R5vn?3Q# z`i0lLzO{n4?j6&sWI%)|K+7suMJXuY*70+?+f|Pp6CFxfyo-ew7OppP<2>iRB<#02 z8@jZ(ua~`JSBuBJAGF7amZL0eSY<>gsZJqb<7%IgY>?K6Pk>ESZITI%hZ->!n?@kw zgo0YRyERDEry}#Mw)np!)@r~wmIvPSRmJa*ylevH`vDsRrlwV4wh3+-D1$a9U^yH7 zNi+t*H|3%Q@(K1hxW5DMf<<5qax;yGOOZ-!yw5J~eYo<5*}fFs>S%Pjo{C=ju|f>J zP%3NQvqFWK>0(z1btxghD5z7*h}SoAz(07sdIj)rx?SwV!;^7NFc(B?JT4Zd1NKgR zYYRDEyrpW*xzQ_aUBh=9Qu0Z0zQ7pRlB{zb*PbZKpN}q4d-`TLFFTl9#GqH|t*1eD zrVAtdKoSQvo%VzeEOH|V8_z3*0R}Q=@4*mU!6|;EJ)r& z%K^DV$M%q$M|vKwc09KJoZbJCKuQzl$d`|t28)oIl&=;UT@)kQU5I+&A}gBPTCHzd z@)-0GYN~-Tx@c;VXr@vT!dG~43-mrTzx{DnB?TmB#V-c;<>9j%JxZ^cb&Q(~INwjG zB3wlr_Ooegl<0x9?(SH2$oXQ@t;?Ss3N0>c35jZg{;eL%MLhu0bAL5N)!V(|woLAg zC&n}^mfI|m^;$yzOW>_5uw-#DcM&tb>7)OWwx+`V>YY4 zBuicIwZA#4jykzcd|g{2GyNd=lS8sa3&_&@6O$!*sThM;C8z9QIw$LoVJJr8zzZgu z(~+$k(R}rHbn;cprLpHTqF>~$Eq|)#GPM5rv>b>p`{e|3h+j-fkspR0i5)PulPGO} z)oX(`m^2<+X{&LRdn4X*0-D4~iN)P))|1>LG<_ONvA&u8cwL~Qtnb*+an7t8Z}GY#{imY@ zU8MYX!csL){o;e{;yPAqeote@YC@Alxvm(lGcIDm|R38RnQxZ`Y#2{-* zAg3|mJD`%AVn8t~KE!(t&W=qu!k^JMQfB;Sbf^>QDuWHqRWN(+FxGV%=%xGPi~huG zGPMPrNIM{Mdv(7x#vbg`x_d25Bb;YxQ*<6SDff2|hMYa?i7QHxLdEgk_GXrnU9ms2 z@t>WILVT#FDmV*!%1DoPibRVH^F#1e@`u3R$(O)pI1-~HDiZ)rGB>97+qHFTyVqt8 zmxEb~O)M}jVw0w)8M%Dq+w8LB8I|XC@!~{sp)-q(y+SsEOqtsvJL^>HmB`oWJ;CvS z2gH+512WjNp#UhK{0wU(f;h|oi>;6}vD1^I)zY|s>pX5ZNjLD<5M%J=B@wBYijHk= zI7-i?7l~dy;l90F@7?HL6gPO?aALlC@%50h&iTs7aD-%f!scR3v^Gj}Bu3S3!tG%$ zMy6DKbT2;DSxRorc<8=?=fQP@z>ZQ_sz^vV(Y@>0jeE!Mq8t{BE+0OFq`91S*Bi-= zeE6`W{JXfR^Gff;pU=JRVs041mA!8yneub;!?l0wz8R<{4~lbO)t05-WC@H(C63UM zvGUqmJnimt3n>qfhR>Vdq5mBGp}Y?6CDzIZAJ{KBhuls5ACu*64L}2u5Hp3riLX}6 zN&$8n_Iht+JODp37;eeCQ#-4Zhy;j)Mu`ict>$87UY9H&B>4&u+?c9_m z*J14J`8Frh=-`&D~ddm1-EYu{&6zeqZ1wcvi+PBCfmAq=2i=A4wk4Vw?6mbc9g7#Ak-af@4f!%k(=~ z^+hyn@_}v-PdI$#nEiJ~e|U1;TIif*CQbP^<-RQHM77;teG&W+1pLRlv?1*{gnP=# zvP~e~c}4pZ%2t+c)Wv;+<|@Lx-MYOp+{Ue&%r)5@i$elIj) z8|pC{!RMie$bP8}NF`@1(pBTUJnmWa=p81%PU77)@|bA7I$ndR*~i{43$|QmPw|eB zdyJR<4%)qb9jj9HHl)?)0c{wTnwFloY@aie@63A|lR)dtH1VLeXabddeiIJgz0s`b z>v9P`&1Ef)*t3R;+0Zd;cU675*Sok^WY}9AHW_4yg;YZ|lQQY1^nNlnbo>kQP)CZ+ zFJVcr8(G%CGdP%s>)8VAhT(K{SLtKbDKlYIKHpP`G0pX@Z{CBrlLg;~+VkE7%v%b? zta+KeS0~Kx;6r~1x+Yu?xp=Gc@SGa9GGdF7_U(iqy<^^(=hVDnAkZ%Y|RUX%>Bf|h03mgVkz0X?6uwRKQsEt^n+PkJ#H1zYC0+JRCU%`R(3R; zU@U?9Lw}0IJN?lva$-tW=U3kobCNA~CBnm%uzr6qRn)3$dF|sV!S0BEH%`W?@85XHR+?gaXG8}EsFwaeJ69=;f+ zZ)FcIJ8OCyTdsmK;}WUcldN=y)yH#!yiF>2nkO&-ymzpDoqV zC4co=PZ^gPWzjiD>x2GL%^O(MB%22%QQk7Ud{T@I50pkH{PS9%1#SmtqH>dho2_Q=RZ~;uQ_b)lhthTWC zzrA$~eEWU^tiod#H^Eo4U?Z-IlxbL z!zUTS;{KfaXOb>29xy^yJhoE;KFKE5}xxvb~=hX zRiFlb`u!0_yp1sqV=U1}2s%RI-6?ZT1h<~7dCWWWjXp@~;e{ku?(z<1d+z4=XD7{q zrT&)>nd!#_o@`-;+-_J(_@1#P9Z!(6WK$1uu!@j#i|Bo$MqtLCq(wc@|CXXa+et|L zhEjqtf~cnk+mtQ;Ia=*5i*uouCH*LnAKSF5%aO2GK!MRYa8dkA0XsT=g+8$Iirq)- zTeG+>bIl4!hO_B(H_t|mAWX&xNVDk;N=RVl@AnjY()3x=y zn5frN;@~jxaqbGu1D-q<3I8_*OcN_PUY`H@kZ{5X9a&y;QVgg4=k*9b?Fau;_=nK* zT;g6MJsi^vg2nHa{5ai4x}5bn?G)2BA>5JNIf*j{+P}R&7&SP0>2ubwO4oVTLL#~Q zF?}U0Q#uu`G1)#Rt3+j$eo~0abU@0Mw7Bi3sHVhMSL6O>HAP2DmE>{yhtqaJAV1)a zjW5VGzOh@FPG4`_e+IYbt?sz|ZuPjI_~1?JAmcFRo8soxwQ-kpbhh^eJ54>!Ha>$8 zp`bdzbG3Y|^wr-zhb$Mxd?mq9`sqiQLTa>)7YljYR1WJ#c=~VsafA`)BD=I zeWU~_p&zK!1?f6;JB!|NRT_MDleE$6W)W&w;9EqM?o4mJeo{mIc+|{}Ie@pHYqgrN zw-8%HbA9J<3C@|u%NEH!T85P;2YuU!b);$bpqkjg@0*PLX0$qj-7?TRf{Eb;E*<;* z@Gp~up@lDo!UhlE4SMDTvcRPq{^fDxupFb;V+B<)NqU--v_n&7NfG*jMBdNTDP=Lp zx3Za<{~uFd8P#UjtX(KB#e!3uLW%?}P~07g6bSC_R-B^6-Q6kf?(SaPHMqO=%k!S| zobTN$|8lPtS$pr9OJ)Xrue~Z~JRlyaI9kb9DVen(MiB+K|2N$yf{2S>RDN4{-aY~N z{X@*KlY`QgF>jLk()K8OUVr|OF`TbFo$OuBxqtlRS)8tRG`SqY%|0Kp9`HHP3N}xs zh(U96#m|>v5goqq6s@U>Z}VgxQ&)Q5e8A8HhqT2rL1GXXC5O82%J6Nh9;X8^uVf~Y zQ;*a22in-PK&javexdfM6i-(Ybwn_jT%7rWhDz7LoxsioaFy%Gv+Ce3y4&h@a^%@v z@Jjf1XZk%Uw`6k-^R8t4d25Wd_0qT>SciEWWdJel5Gr^HGz-L6Kj2ZHx#dY;hW)aGm4<`Cgr!Kt6e-I-M@8_}Bj{<4qq5poH4 zu`;UeXZ=Vp?kZ9uWvodM1(2qM##5i$Gxa?R(ZaWanG$aveHj{0;CWiYb_W?pAgKI| zE~#ASSt$Ua)#nb8{@6x5Q)(;h{>UeE(Ia4KCkgYhma5^riT~yT;IH|YR{r#rlgSHm zd^-2><0OjDD?B7x_`=8QJm#(>GA;QO8gFPDnrZ~LLSCi*b2*?+W_?0yJIibxh6a|8 zlSI-|Hr(wp00c9`riVq`fsCH&y#HN0;C!;cRrfP$9U!Ue!zJI}p^PI*z;j*RB=l!3{D3z%Z34uU#=fP@_3ktC5G9ZpGi8hI z_oE#BMnJjI9?|)cUdK~EiOhYPkSTqAYqi+g0bRT98{p;2d1C2Z^`I*s%`Os&zq{^b0meQBKp=SbZlKLRw|q^HeG5D{`&;xJU}o&Uv&T1m~oeA z&%LACFv_~jB0+62c-+nA;a-BtGq3%@ z7TCp>=L6!`-VQ!=sG0EQ89!wVzNzze;c#t*Irk*Jlle&#J9S@x)A?X$G1wg1zS#rR zvD*T)OU{G=V1*P3m~^F@9u8pso^}tq-WbvKmabP~L9Hx@ zt*g>DM*wKfHl(%U$gMq{Pp#9UzOo(KNO}UJ{em~@^t4vsY=(p{$@Q|l=<+-M#YvOf z!N9I#+(2qhO4?oq_rW5*FgK^U42AEzFLtBnt@@ek-gdn;2B_il zan2VrxPTA+@0Nx2$rqyWzX6zT6$w27>T38vX+%VPCnf2vXHtE@RM;p$0v4Zlo(P5x zxx-HDev^<+8H@ax z2GfcB{Qls4n76gcsujNk&y^(beQ_#bE^7xKo5u!if#jYuI^o)i>yVXBGott?)4Au} zXBIJf0;`B_qMZQXeD(-sc`Ik2vkm=xMP%!aVuXo*)kJfj339(@&-M{%?I4KF=Y=(l zULl%HtADI;(dYx%k1ky%qE74(+wda4UoBBk=yw1;wy^m{fp|=wRVXV^y688huTfBD zgcL?a?1?tTTBr0ELPI=9(Zn2-)0wKJf^f8St}dpuiv6il(776vT-KOa8t+Uq2>Ohv z)54Dw0{Bk!)jzHuh!c8pox;gghjDn1?X;1@sb6AO=hA01k7yeM*5EFLgp zeHs|Nx?1y9XGBToT(O^Bnxl@v#zKL8jJ4M_#*tZuKmn2u4yp>8io5CksZTP4;pIt_ zo;=j@C>LxzDot@&K>00H&<##{TV^Xjf&R47YH*I;d=2sTmNCoNq?{{>(!aps74|sY zfngVc)q1#&hEXrmV&FP_m_h%mZuEY$43G&;8u_U`dfKeZZjmbTeSRI1`Q>{tOu8R( z!kdIpS;3gGre)#iaf3tZ`cW%l6!!yR{IA>dE4$;#r+RyL$()z7sdXP^^Yd1Rbj$d= zyV8Pox1FXz!T5zL)h|{MI?bB4h2!VkX6t(Qz4L+VX2pe;4Gm-C1hc5yxfYi)!%-FQ zkCPNFOwfs?{~V84={oOMztu=nVnemRUimxw`TwM#^y#aF+rW_O#B=@^I;q4)&clO$ zm#gYfnBU*aR~a+wXeGP-J{OO;$RcSrSBLlDAnZyEp%lL!SPhX(kwg)Vrg({FF(Qc3 zwu^e9%Wr=H;mv1$dlH;M7JYzz8I7EBXl|E!SoEfwAD7aJl~}q!ZU2%ol}n?$sPC`& zpkaoskW$0D0p$<-s7lt*9*OGs>UVr%0w{}wcT0!&i=mzdx;X*Ft+iq!(B1FTpck{J z4jo)#b!#YYFS$z}z(Q?4@#fS$XFf9LHo?v)i5l^RXbOql;8gBa`k}_j3*6vN6K{TA zE6w9Ff9Ws5VG!xq*9Vw?%^d*(oJ7suJf>5_ZwB{bp zoi)hFcT72c;#;BMGAxi?i73A2fgi4e{02AY91EUQF*{f!it8#^L~2)voUvo-K^O-m z!WKm(lPWe4XMW5ym|AGh&5aaV)M?2+U>44S-aI_}%h5Ahvf!IBmazpVEv|#WcWi3S z3Gx)&F^5quAXhT&SS`Sx0*$u;fn~)%x!hY)Km~4wJNeJ-(Le={`~D(3&iB9UjQN7n zw5sgF(NvOk#+ep(MaXI&OvX%rVmjHFq8)?eCc{k{9{eh*fWpGBJHjB_fF!tT2|Vqs$trAOcjY0 zQ6)z`h0<%3|?1t6q*G&1~{NDJZVTUh_;i=h4xK2cAqexE~G*T9F~zRriH0W=qI`ap-nKvMrHL(C^M z$}FK1w(!-B*KgApG_PF+`5}Bl)Ql7e8)orL(O)G1gu)QIJVj$77R?TzM#3$NlO*^V zSlOokx~Zk771Q&TLK;XAWBip1n2+PEo)lW-vSN?emAD-?7Wx%^ifcT%klS1zQ)woQ zO1wQfBXg2D`X-(t9#8itZ2(TBiVarcK)`xB$MQ}dL-}!OC`?_I*%pZstECj0Y^>j_ zRF7*at1dvD3MpO!gmi$&A5(M@C`j=B`$+QDB~1a82I`6n&;fcYq9{lvkq$7Kwi;`v z`Y;|JA#Ba0r@jVm3>_mWiGk&<1H;uUn*_e{{{U{PP{nK{65kY0E9D@w}QEtGE|7(|L=5V zKYuC}5yThClBm2Q!mCivdC0}Ifa1=v1N>D*>H;><)S)W1PyPH^6~Qt<6bYg#NT{Ch zvj$IrxUEc(hT9=VB$fio5-JWf%OF`+spdIHet}2u{}xM*g1n6>@PVa=RRMnt9|{y; zorK8Y+q(=+F&*#v@bKkN?Y3^1R(kniC1?Ho%?H-mw?>>M*cb989kj@m^^kGHg|pUl z`i6zZQ~}sP2rwEyXo+&~$Y{iMEuSJ-Fe|-2nJU5u4?G}EchryRdQG=#H>}h$4=^77< z2`7!htAGg?%5`%n0n@8Tc~E>f#YRng^-B`ZNY^dlz?i04W@b zr~Wrps&E%3PS^lA&3R4EK+v3m$iYlohumw;vV@H2N2DLZ_(>Dx;bk&OcM`BAOg-E^ zNR8`~LKGA@iG2ix;O=hT9lcBz859&MB)^~fCyu$0Y8>RC%z`2~-}ndNEo2NDzWi^_ zsqq$cDlqOpgwiUkaQy`}XsfrnZUp%=C0Wt3-y*u|-=ij8O!*gtm}e#mQHdG>#Srm0wRCY|r%MA5zHE@6hU)im%H zF?lnvpjp>IA@eY1C^gR<{F#zEgV+y+_k~XIkyKT%&@q-hXULJ6NCDYBXYh`BdsjZo zk+zYmoyW)$6U!YXPrqTPnN`P*c{C+IF%ZuP31tU1GzUZ|&QFVg)%qH&e~^yEOEDD) zeT-ld=hHQOGt)Qd^(hkcO4`dsdGkITi@MmPZHP%nVz9D6k7DL})G-U#;Y= z#61tiloyDJ-{EnsvuzTo7sr@YOmK} zLl3`iNU&fOy?h*F6^h%t;7Xa^X#=_1jIZ^;$LZW*lbVSP4z(Bu^ zuQ&m-Ct}sFnCFb~-55A#cduLvP*pOM(m|?0!wWHC9Zi{VFmaSi_5FffR_Y^gw?rg* zf@TmD69wvy+bkY=EO7E1M)hk0bOPjGwiDWo0^^MJWeAP;$p2(^Yv>{$QD{X8HEvwF zoeh-G%Rmxum*QHcm!B)@Z&_rXdBmPugGxaWsPkpnDh*S#2#rykkLo0S~-P+Z7c3(Egzfzh0o9-7lIA= zT49~{AfF@;foHb1ns(UDy}!{uPkwhjksp33#jc&g3}`T&fBZ*z`gg6yYb|AcGaJ9F zlGyZs@@5J_E_Fulzw4d1fVoYcMI+)k{8W0>MJr;Ybnu~^R`bB@*{QjCgkuJsznc6p_jm)mO7?r1R%jTaT3YpW09u3^8U&9muNI3ahMWv>CcTA zmL3@Nr^$(8-cNK2)yoBerKrLEp?y^wO%xzrY1!c(w?FSS5q$d% zb9?hcT9e5~(U;Vc9?G9(~mGi=II*Kns$AnK3g;%rRAt zE%h+kVd?ioa0@lf*oo_DdF)YH<_xLieNno>ek8TuoIZySN5JCIu1L>4jPGZvqV8ERudWxTh4>^_>{TQ;vO%XHUrs8ygogIMkvkooU*M zw0J(w0ZRV9v7NIw=9Y0q@}w?7%ce>{=%>5q!H*^wWnUm+aHyD2`XnV}!vGy#aE5r& zN6HhEUkOKniQ)^wm8JuX!0#XH@J}FFZkv2Yr?&xBn4xI3(FL(kA z4`88aT)04rDZEO6A7j6bTvWW_g)ARyVqE{e1KjOgT_d9a&-BoxGzTD6Vdd`2=}dn6 z#K%fN{h#fm?}JHqabC0BoQhd_6xmDp6c#ofe=h$|)%=<%Z{*Rf*mgkXll-HTWK+}g zMb367Q}ywIbJ@emiGlFeFdy^eqwH`PQ@^6b|E@vD_tt1p=MgZAq<1T&JdyoD3$4d9 z>vi=uG@;vXivacF`LZ{57Z8Sdv0qa`a6f^lqTZ~ZK}-%l;iqA&Wq8&CIAFPB9G&hD zJn3xDC@7A*HSEz5#e5$o59#N*1O3R(ek0ObjoPvb-Tjx~qzMyvz*j`_ov z=@{5EL{>3**8uH&b>&T|VBstwkilZqVLyfbCJq9>;=yc~FoOLMdF0`}!P9gNnmeDk zKY!lV&+_h{LZD9{-cxSSej}Cs$pJ5x8BOaL$}WUY*dXgwrx9uOCSrcKwfwr42|FSW z_SRazmV+SOH;PIk&x9~zTPGXAKZgbQi}f%JPpe`gVEH!Qaw--O@>#C{!*jEAMc zRg(VUt{KsPb?yNE9^m`|SEe!EqJM^_uennl#c(KsVlEY|XcwK&G`DIrVU!7riO8SF zrAL5xBTS~?H|{8_$TdhYR-+B)sx(jeyGt2Lc_J&K`Um_z`AQT?!Kg95*47r(J6fXx zJX($f6)^nj5w2jAsz@myM68APh$Ea86OP5-U4A5W%go0|Jh8%9BE1cHpEach*U@uN zo#_-wnae!XLx~zTf?mSxI#AH2gcSddM*FDLUAclzabjNP&7 zvc+&g_jk*yvzx3@4c6zy@*ILeY6UtyutW2}diT5o_W1tq0g)J#8-FwU(3Oqfpyte* zQAhddyN2(b!?b%C_{+Mu4mxp*5~4c`QYlv8fC+a$Z-FGMQ=@_8g=g90Lcxi zV9KXnn0Dxya7SVQr|P4IfdJK4gh4PX_FW<6$=5}VTt4n$r>DtuSJlGNmg>7)Tcf00Zl9<1U|%!P;vU1=PqRxXb~!ase$SuB(7HI2IBs~LL}Qj zF%5!>F>KPTPg>f$vTVJ=A~q$PKk`_j!uegCR=J>kd~q}rgs$>_+wK$~nWP+x(D?3ndb61}1QTS#Lnk@!Mr?TVdi!g-~JW&s&{1uGHe z@rL!Qak+eZq@RL{8=Ma{(orKmYnz8-<`1yaLoaZA5(ex~=8@uj^ zXuRU~U8*F^!Je={<`zLqD77}Lk^?oCZ_`U1cm!mAhUY!gP@(#+;+T~nZUwSHa$!K9 z9z=i{+usfVrLa(oZ3Td42|W;T2Cm_+8E6*}h|NIW@xt)0A2cgzec2c2sBcF&QjsCkkt)8) z^?BJExIEkS+Phqcs@76V^0|(3RkSRXTd^*)9IRGU!j_VVI+D|k!t7VT6Ho>#-;*#BN>+^*VDTiNhu&kB z&lK$zIm9Z-Ch0hY8t_HZGTJO)3aJ2Vd)Vujw(Y^>Su!ky&Va%c%|p+xGF6$X`i%r9tV z4GnZdYuFjr=<#Y+Nh-z1q9H=BL{fj9fJ%UdrHRx)H-o-UqC@nqtmCM#h;N^&Z8GW> z2~ogtWy!1BljBlEh04mD<>E!h6We4Q@>cyv`Q_0s^bVE9;5}f-QwR%ku^kQVRi7rrWMH~Ap>!VQ%Ry7eaf)9 zQm_hh!b!{*m%Pa8w6Rk}hG+w@lKCo8zvXE$l%$A~&>OXT9~xIh$#`5k&58HI`uX(Set}ZBHy%-Q0U!Z8C7U$QEmk=F#M;%jtq*A@VBuArqwG%r_0ZER7dyLDW<{pCS;fNA9 zsvJkIw^+f4qS7q(X!nYvCXvE?aXL+0uKkFQc!Y+{#X?m=C4rZ3nW;w0s!n5 zX<)*!jQ$161;@icL8rs(_YatdndYZTMII%CQyc^56tmw9Q8YGoG1W5aBQPErh=G18 zudfD<%h~%supr*EtMiPS6#_P)d3(I-uV8M(kSq0D$0l!8&U*o^GRKn{-nV51Lq7II z)$cZ-|I!THV@~0JU3aR7oKIo?9CPJSkZfw1De(lc1Z{;)`ycYAAyN*=rgs`ptZH~H1 z#BW&ORd~4kVgDDx-+SJ6*l?HnFChToh)a29T8YYt)Bj}{$86B-K!{u12#y#j3!ztS zup}95KkNW>u}5j6>S9W2qUeoG-iC~Ht-%|K6qLRgc`%3q6tomcB`yq0ryX#F zCeM+AJFqs=g1b@;2o$sp?%@a&_PZ3uhf3LuaVpn+PEd-6X57JZ($I3zls!0OHVgm= zG-;6JPA)hBu?~Q{wKQ4bG@3T)g_+))98Xu&cW{^R9>+70Tj%uKk6d96YQ8xo?4+M_ zt&nJxWZhkfjKn~=uZqHHR#<7}sG6)sc}kzk^?~ld^Q9X41?h}^N#gu@$-J0?3o-UD zTJyHf7548}KlTE+zse0mJpq?6*ROn6kANJb2bkv`2LKC)R<(L( zP74WLZO2>(3)dE^_(7w4PE)D0Bi3n-%<@0COAbUy#ocy=V+fLsp_x{^xkfYqFNgQZ zeoPfQpX$8=7D||ILou}BV?s~T)$TYuxS?X~=oU4V+n7Oo(fxVm95o{Qosym^!G{^Y zcDJLMi309-{&aSSk-||rn>a~5_}T#ZVB~ zlN)#}8ZKfWi$pPhvyn}DC1;d0j0qIlL(tXqjF53Nl8KRU<13v!8!vb*1AnN=150_- zIdl}TO(-&~`AUx<=zf$qy=?#N5QFaa%gUR~+=r7pzidLL{1}v$-7*N}hv!=8s>?QF zkCMlG*^kt9{Ih{(mR8fNm0x{0*wfH!YfNq*H!=lMkN+E^Kw}@Q&#gq(wtyC_&KsL$ z1wFA_x4<^1&s)Jet+sRj)$77W#!*}EQS0lgtfkGdtKFtOvR4tE#|R&_tR2YTgXeEw zd)l7&Y|=U(#>GFb-+Wp3+Q=Jy&wkX}vevl+vU5I7*Ucmmdc@$Ddc;6fK%k}LZu8t6 zT=5_grd>T~I&6+;d}$$GSmQeV56#x(_Pb;BY468Mq~(Rv0JkbENG7#=L!_Ds6BI6H z=0C(?L|8-jZGIc|!kDm*K2NWZK~+6~7HRuux0&E-7?x5ls$6E_Vs2M2w)DZH-6CW1LOG=HDTIy!YX z;K~{AnEHEvv!qrFAw#1sCy*S6c_H7tTH`Ibu4^l3y^7Y65J`T>Lt^il~wU8fJTQz)2vdRFf@p0W)c^JwnuXrsLaQA61D>>JFG4W`vyo#x*sPr_3JY5-G{JD}VDhlJ- zaFfmH%32+TkoltiXp6;fT;MBm#U*=KHEsI4uyH}s8A&{P-D3|KJl}#!cc?u7b0jmZ zQM3hjTCF9dcIGzg8|wq@U{dbr`^I~M4&(?CwLA=)baYQO1LIVG{O~Q9yU?3G6w^2+ zp+t!oLZw3qV7dtJ7ul2vDLGO-v#lnFAGt@$i+-z807xKp0pQ(I{g^IjL_72_f$}yy zTcasBA}$hq2D%jG{brI%<8RdZ(CouDk$Dugu1XyZMMU5ml-+Vcq*!VHxtJsg6ZmCp z#?K$z(>(?s8G+lUKa`EnM$#vm6BX;HMOmV@3l~f3=rMxpXxs|LT2|T9iFTe zT5`8+axjCXL-grmLZFR6S9{u+!{h!@eO+S|31l`s(6*y9g!)u>*)DKnXp!TcB0<9B zpG`j>Y5~>LLWwwRB47-yzh?xkg!W1LCPH0JX}$b%y9r z2m|<@em(5xR_E3}>K zO6#a_YVbbc7zb`dJ>I1Pq@#aXN2jfuc<5XrpqdW;pf7^MP@hb!-q{;f$mfKi*`bl> z0F;{5H-~n$2hZCn#uzsY%Jn;Y&E1bakJL{;-RIAFR7;=Ey}Zq~D5!aBEU3IrTlvoe z)jB<_i7H3klfG*$LBze){<-kgw;~&_4mX~-$h_8OFQOoO)dH{Gtxe(q#d&!<-)tb- zowxSc{GtQo81zw z1e-mN#FR6SLLv#a{rmmOxE)&C*PS;eXYPiro}toLPFtB6+9EGkn1A;ylQv)0UgCCI z`k->F;p%{(9)-KhAj@+I7T*5zKV?yjwQBWp~f@WJ!w!>-Y7gu!^ z!bNNqUt)Z_1SM=Zet)5;Ei=JR{2eK&RB9tBzhOsSw06NNHZ(5B+{vX`D)}rT)&s8pZ}-s|0Ng|~QFG0$Q%qBbp{Cb1yJD^Y za+LQC67Lb(gSjrSgro5i!Ai`TrXxW$&A=N0cL_%YiDQ4I&l zR1{TD*PwDqn_tbgaUEC^UdaFsFc59TZJrr}&pl5R?;v#P-l^24&q83ythz8*8-KpRqBjW*<(T8j@j9drK)=@vz~rA)@VwaZ zd}>@DDb2?E!(X;az2Gvc?deo*^DE(K`Kt|FrMpL~m$uu&$5Tcg8}_u7w@=NqJYO>@ zGUG4$w^ns6V8);@>B00byicXZtKao1tlc3Nsm4;}@V(oCfyYuDWa}3O*5W&Skj{?i zcdyB{{y`}HvD&KYJ%DUBk#YVfQ^AkxVvS5;<*%_w{{(Jm)7JGft~oZ4Yt+gTxcM+t>Fk)vMG0S@zf25jgh0G8c5_rj2@6x_^lqmYkLb zTH2yEx-8mh-9u(F`r0`Z9t&TtoG&$Q3>Zh$d94+jh4v9rEaNNuAQK$I!iUV8u#K4z ztN@DFt)Fk%3Iq>&=-`@cNgN|ZOv!^^p=*qg8;2vKtc;9cuDj@&c2MRXa|+}{0UN46 z%i?ht!&9Yypos*m_($XP2CsRG3dBFm%n8u5&x%dBX0m32hC*RClyA19%k`slNVYvt z!pIr5Q9s}j#;Nc$;u}u?WPCp)oCuq&aU?>Ot|1n(u(9r4xPGJ)RF(z)!ma0|B_L71 zS#r=-kT#9ZiAujJ>Exen{;2e(9jf!W5r2|Eh3ZuCv_cDB~Sq2`HEeAmU0D_)1(cVbkiRYS>EzntEk64+koYN?z_3Rz%n1 zhc0u)xv4+Y8qMXGgpuOoB@Y9f=lvS|#sqU2XW1coG0c+=4%Uj&EWxni)iS_87y#dR zS7YdK$SspocT3_J3xrZNcGkx4G8q?>C?P_*<&NUh^I!ENegCNA1?p!4@>2#hgrK2t z=cgio8IAABSE7v9le&isf^VrMfQ;5-5zqWEQox783#}+EAUy-aBXEtc{ovH~Way`!l=RSnG~2iVK?3iiyf4E% z3);^f2Nt}e8ryc(+V=eK$*^WmxWc7(Z)-v3mG*5XMLaMAv;EW7KB=u6K0EL_@T=<_G;gUglvFONP@zP0gkV{i0eYdYYq=Nlnc^g;$ zA$NpxuHcJg9+s*<2h&d)c%+bM6cEzkt%sFu1XN`WpEf%5vtk*L^f`gNBiTvk@G9U*GB1OPYa@Am1u;0*3 z4cD{`xjq3jBChZMIr~tga8lFCXJCmr8zlmDkKOQ6ZWL{V5#D&9J)M!>#0yI$?Tf}& zFa*v|z^F;`3Xlg=_UJ@0pKbl3D3Qj{p0Ax+Mpl&G3&NQssGBykOurHFQ-Lj}u95zG z&F-N6D~X9FCLV=`J|{{Bpd6yR-@LDv$*Qv}ixW|e=^pA$z#J}YFi8~+i}9_8`!|x} z*N{ae^=W`w+4x@?dJF{41wxoYX?|I zK5@x9Oh9}E&p-k-Cy6Z1-5{HZl)R2vxO1kA^v@z_%^ec@*Fl`bDP5f~k3u3^lDU64 z66;Zj`CjIof435>L))H0!jbFvPUEC~x%27dsWClCbIs19*xuRn=RsU4r})*0*7d(c z6R^+|Xeci`%|P~s9o6uoS;}p0t#XSY>2ctFR9S`ViOh?`bBj%(-hJ==^}6bs_hVQR z%)|b9tj{>{#jgI;NY~GX`gPSkz_U;IfDFDpJML-L21~UZ<1OYB@ZV~aQ-nKgloO{o%G(o1W zh>s8BzgC0*H!7BH5T+vv9URR=HFWH{k+Y!~bVQ@R_5B!9W~O1BIqxLm={n5f} zUUTmkZOhx%>kQ-eFJ(I~Yn~3}==54X74>`c>m503No`Mjwh(#JO*Mgeuety6NHrC^ zJ$26T|5yUJ;qF~Mjc*Q^Dk7Y~?WIkW6BvK2F1`Z^%o8*C`=5crP?1|3=ewKvC6Cty z)dHbSAwLf*hM}bZn8stOUFT_DVpMF!c4mkJDcW_ignR@+XFEWQI(r+a74;8cH6qXZ ztXf+4Rd<&(%?T^}vwRtQYMOfG(RJ#*%S*q(4|pfz%7uZS8u2LLUWa!6(Cy!-M$NA9 zHMqmX&{hxBNTS>o0X=2|UfrZ)@#&z}LVnsHiaZ@jz!QO9Dy~*W5ig5eQwv@4ZsH$F zl$=C)qJq|+js)s1-{qFhB1|?U_rk9{K>}tA9)>9Dnh1N=-{%`y@IqT8Q7AG5OIfXo zX#@7W=TP+7XLf^mMQ{l_hLNp=%uqd*o19yILa{3t=rl|&1N|l1W57b3t6`Xar|?X< zsW4#2T(~=w?nOxFuI?>fV{~c!Wrgkz)%BP!E)qn)GtteF7y@S*5+YIN_*5c48lt&n z#+d-k6JsIVdNE4byciVl14Jm=-vHc&PK1_n?hzZeBLi(qHanI{dB2M`(fg4ViI}iw zaCccbV&a+P(gYKMbJPRUDD+@6t|1P!ePGfcTcK3Y0Cph1W_z;!0ow&!jGv$e@8Jw7 z5Jnma01Hkm{4s!%z#>*JclcpvzkBcQ3qeUou>{7 zSF!$%Ou_+0{*FI^m_~R?JJPv~wX1f_GE2mhOFYRA%o%Ye%{ZNsG6a%AuhBaB7YP&> zSq$-8 zxj${&rs-<@dp%BJa@YiHK6O5)&k*nEwe5sv1I9ax-T^*;yVFDgx1QUPjfvJiDIm{> z=mwCOUgxWI`orESu|YJnDv-)1`NBA(@(Qx?&J}3;GT7Ll_EqNs<%}0m%l)T-m!0!m zAdk)#9J^e}Y2~k9WLuP7qkk#>-oQg)F4NTo&F1RqO9oZru`6pphm7-Xk}V5gyVmY= z`FR|MjuY(fFx_X=4o$|FIFH8{s#1gG@pK5S&z237=d1GS?EvROKldm}rs|=D&(`T` zbIoK?I(gBa`1^~adllFe#D}>7?lbot#D^EEJ^oH5?xcA0jFKI`EBJM=wJz!>oqoj6 zIpU@3KVE0>SNZJUZ1|V#$lWf}bOtlJ`aJ}yTD(&DEh2clBXvFdExdegk8S1xJ{x=b^+IE}l`>_nclxC8_H7?d2x3%@OuHXzeARZP(VSo?Hl_m45r(?)uc?c9Q z;C4tP|6pRrEFp`dl8apK1muVp{oWtv>N}_hEf2VdqjiyI4t~my3mC?EHbOL1Y0%r$ zl(!9RyEwt~xfU~ZJ=|!Cz+bBw4JnUizMu==)Kj!OJWB6utDF4mdh#=D7G7Z&-ci3= z8uQVQO_RlEXO*cjgTTQZz!nU^7Kt^#`vk<$<=@;I#NbjlLQ=4I@aSq@gMl;{it}Mm zZ0W^ZU92Y~W)WPs(<^7H17ss8vG@b8gdUm3rPnx_ctfWarne<#Ka>y~eo1tH#oFUq zsgrqrQ7Gh1GFy0Eq91*D(Z5`6nJ@<0Rp1n3QLDyM4qr}5{!q|7o&i`N;OH}&p@Yw6 za0*cUJcaUco{VmWkeowDa3t{$9^(zZ3gtVhWNz;v8}L)Cc=+|>B!N$~P3&)xV_*4% z*-c#}(go^A17~Tun%N92ryS)$9gqN2Rq{HhUBd)Xx$qfhe+M{hC7)SHpe+crxa18v zm+E3p33q$BGA8x7QLNy-gsVLarvzQZ3!{Y0B~mUJnTuyy)-~wC3%k;75wr{)b)vq5%#j5BSb5C(5Fji}hutJC#J~2&x>%-M(5#^o=S3wq z%Yq8((}pmp5{Q_frPKoiDX0ULc4yWMLvR_;#!!>@t2(E&NI|X_(|n}vR#EN4er`H? z0pw@D()qc!`&7q%(;fyU4sk*a=PUlf-q}d-56hbcgtGX*#iYw98vKTS+S;EnbLlUR zJOD>W)B>UO3b6`oNllR@i+F9>Nuxs-di+XoVRVV?c)iAIb07d|PkPT}mn1?9rQ$RC z!VxF-phYF=7y61C^U@OTMCe53ZyZ1DatXi<3pHMx2}j;Ka{4aicRVd;A1C$CTrCd? zz9A@J*pD1=eS%EdHMCn%887E-JOOora0p(HM~sVb(<_ux*heLF8?OdVM_kTZ{0A-t zCM0z4J4v3ka2wmt&aYWF(>8di`R(L1%o9d#6mB_49CQ||P6y&Yb0#09m%Eo2I)NJw z-O0)-c)r%ldaRU8W?HUJ@ZGN3^G$G<8X9tUEUb*x|Fu|RzgrpfR2`UN8*%gACVOFi zxAF3785Y`cRV=WZ_72(sivG0fYsZ3i=d!i-?Jt~F74!I1FSU<`?Vyjx5H6>e#`EFN z@$1p1pSGwk7(ck?aZW~ecNy-(^ggVGtFUt#$r4EN=fBPHXHC?n| zT%}oGjz+zrp1lrfYrT8h7I+(UCwn^~p5WeJ)qdF?;s0^f;qZLC-Tik6(g9bSZTBDc#KA@$vQ*`Q%_bu1;)l*JR3k#zNKAt>oNz=Kb4-Jzmzlt+hS*x2<6I zZ=6Ei^0@sLujgBm>hbel)c5U72B$SOpB$+#>>QoR>WyF zfkzYm8(R9-rZ z=>Cz?~yZW#P0nak>M%6}3{8)zLhqiG}e50kL9Q_^-WI6&AR2j^IOH=l09 z9m1$dJd#~_)Y4%(%$cccn%4GU#}%leg4HfaQB|)z_Qq~(Tcg(^$s!1NI&jvr5UJfNq1jl0=t5IQhzPuWc4BfwB1c23SP| z$D6uib?sTmsNRIN9Y_nk#U&D1+m7#WthEQE|0d1QJJ6@^$fVqv8EzyY{y(1HGAio# z`~Fs>OS)SShLG+qX({RM?ruRsxzx|Mv6!-S=8N8d%J%S#zB^ z*ExH?wzDG&VgWZEKaFytbW|NDTn_e1%+K~di!{?D_+;7y3x*ot79M5t-aI)#s}whC z7(IstSqKoL5@YzqgbVws${zUL=0>Pd$otCbgl};lC??YpM=xjJ*^k%@(8=LMb@%H= zf5w5p&V{_{P?R`5dKxk93~UAD!Wg972O13R$xY|Wn;=?%FZQ((Sap{U6EsILuj|* ziCAmIZBH)zRk&)uqv+mMImD5S;|jk4=XQ|F{&lG{v>C7RRt@Jtq=zb>GF&P*;;pwf zUTKO?ji;%4sEVNLeEi4=XDeMg2-mNG-MX3yuK5d`cpQ~vg08qJyqs!hFEi?gcq{#l zyn0>(HU(Eyvx|T{fZuSU3j9csp`=VkN{EYIf0L9hhwtScD-^Tj=U_UAahzboQ3h+J z$v-x7N!UcT+G;hx5Mb{b!wV-WF%cQsHzU^v(aD0=#;B^_}S6H zydI}{s_gQ84$Q&6SUIQgAW&&(+p)BkZ+Fn{({YmB{&_!Z8?09T_hm63gq&3vBR(L~&q-=?^FmX^pfsq`XI$AqvB(3M zK)3a>lyy8j0gIU0o8ep1@w={D>nf#Vk#>uK^@0kEQ}1K7FY{X#qEBS#)0-DKhKNG< z!w~yy!MLHYr5-2fZS4=VPsR-sz;jwbe^o4VZOA8%d<-6TOVl%*07KfF%|^RqD7Q9P z4G}-VE?0S{NP(AyQEOyn^pH!+`{B&{xM13JiR1++`vLy?y*_BvW&`MGacPnd8&SE^V6RzP%5yqe{&td^B9la5H_(-BKwn}?3O|lGhGH`Mkn|u;#jW?AN|J6#g(yqLG(vHuP6>B@ZPyCv3CoBU3ON&0%G&WBe)W`^wY8SW|>|PmQT1G zu9PZO>;Nly{-mk6NKO)@6e!u!nZ_P*NoiE)2Y)31O$CVlclqFiyll#6@7bYb3VDHn zZ0>OPL=I92U%PL=y}&y#NAn^w^|^mMU)(PejOFzeQ!766U(u0oD3xUu3ub)8bNOc! zGWYO028g2ZaFqFHVp9xR{Fk>5GR6Sh#~(>&J?8p-l*MOCfBQn#1>E z1g%oE>ryn}eX3?-fD{pi+1~)pburQMP*#Ep49#O*6|qBbK`4!ih$^tBLRAjIBZve& z>7vg&v#Vsh zCfc3qHl^G)hgv%fGe-rEZcjW;{*FQ||0KqheMQ^nfzOFAd?I+Jj-x$kd_Sx0$MkZy zGj{$e@Am18c^wNAV^5@?e&fP%<1%hGa=U@`0-QSciOFJ|H>hv-2W*@5g2DN^rEZ;Q zQuyWVvk2?MHRa+eaKC<8YJebs^Y4o`?-wy-t^U4yTvL8IKjMCT_FAjt6dfd=66xFh z5Lk;R4wRwz8hw~k&;yrOpC|YElj7xN&^d|qVnug&Q(XYdS?2?HwnMAU!t^6son736 z-sw(}#d%2Ns8!A_u(c#VLE>A@aX0hPhc-7<-f;+ zSiV=>jr2itjqDT<$ia+0?mWZY+QGv={{BVhz+r!y3qFf<$`NVYobVhmMeg9k{dDi3c!TH#y zR_BcINqu;BV9zt~!Yyz68PcNmwx!+s!kgQ-p=xqwHR#MlGxK=SZF>E1XUHxjneG@= zlPi?9T4`JK?!ofSQd2@$n~CFe zA~P3}DkK9V(q;#wQkQU@BvgPhnI*hKbPr1fNMzCWp>%+7%V! zhC!;5fpn|~iXuZPR`jF!dY{@17FWzYP_py{4HK3G4uVKgu0qBPT7VD;)NGR0{$+Qd z+c-&=;~iV$6;A!M+~6<9af!b?0n3gpqB0h#DzyxoWJbqJr2V=?HRKt7mAgn^tiX2` zISXAgv(8TCqxwyKU=Lu~j;zH6=U~uD@P^54;4;GZeSNEZ(QdXn5N>1TEl?9Z3o4YQ zhNzYtNE47WrkIM*87Z>+M6qzhOeYTF+yXk@Z-cKkVF?pHwnSAxf&&*=E;)v)LJgZZ zeY%Xxk$tiAp(b?&%}xbQVzY=)8XZu}xG3U<-}XkR;n-2LW}nl6T`vp!iLlfPQsOWD zQ?3~E@U5E_|K9mJeX-kw#zHk$xr1!IVi->nrBU_J_-wUMRsAZ(4&i=tyufvx;bjMYGv*=MjqTb3#Y8RPa(sm`XGba810GYN^lEY zbN%mr2~y0Y>?&AFRTCijAJq*$1NkN@i(Uc-*Pi}&YN&L~zc_@`YkbF^pjGr)Hkt6f zSQeQ%N4ksqYqpbhBCl7|bsedte3VG#>AV|&NMjQO9~}-RSuSRdA$L`0HYL9AWs`Go z4$Qgq!20ylpBSR+cZFg*K2*g6Xtd%g{KS9@`6pu5UIKpkV1CSS6I}dkNtxonyFacJ zReMqQnYOQ$cPE>qQZPjZ(`xjla2JR+K4(5?d3|aL;`hz;Qh6XN+;(mzJFYV86H!=J zQX0_G;9PJesw1{P2Y>;Jc;j30hUf3g%|{?`C~C zxNQt_4*fc)z6+E?r!&20v(vq{%N!MRvn^N$ut;wM6bk(VO?s zi{TuOM*2g-3L-S(t-mc93dwS1r!N+T)Gbdy$#*4Yiv3#HheGcsU zx0u}Z_E`q=G;hOFIm=AG>M5pLMmxQ1cd{LCKlnJY;H_-!sK&MZy-<&&3^>{slCyz%Ad(OuOWPq$v7huQR6(TM5ny~MP4%aS(%mOY|Q{9vCi zPC;FCgFgSJqjtz{0=c~U5$D4t825L)J+Xc~DH=;@JVv|k>>eHwgVr{xgK;OhfT1ZX zU>*W0V%n>cmFoimKACjS9$pkD9tW?QdrvZ@-CGA9E00#l#be}|*&>$^uT_57 zTt8v`Yv4h{dh`K;h`cN+227FD&>YyCl|};^6YpG8Ea^X2ele!$zQb^U$BWtZCheKV zDp3!$O6>kak1FvWkHovmFp`3Kf!!Hfz}~J;Io1DI+I8uCl-)vA1*-BTmYvL;=YobPMj>mTy8b^eA`!;f9k`$_BP1MTu~O{q*PoAwOmA zP=T#OC!Ul|5_{fM#wV*uOSB|fIxtsQt1+GY{VgNaIb4c64UZj$u3gNcF?w!-R4?Q| zyWI{0EjpagCemx4(;wW?T<=)8!VrohF5jdOQVGA+mH1*6?THm&CYEQx))UL3e(`%T zN%uQ`ri&)qxNDLG6iH;r@VCAZT)egf6%`hoGBSO>HPU*PDIvpp%>=#$Xjg}6xCGgC zRVxpTsv%r1x2aFI1Ms~V{Kg|lN%;|MischyniqJ0?SlgPagMi>>CaWl}05)Oyj~QQtIbS&a_sW=uVf#%6Z?UK;*YXTm&pF}^%sJCVK@(v!@8HydMlbpve3or?U{6$VE0s!Q6lfAZchScu}< zTOXymmM82AO#J=1;ibMT<(IYRnRRXBG7Vi7+rb9WvAdlr12i{B?>%f(R<#< z4Oi*a-J<9)>yBpK`Mvw@Zu{}_{HeQb*Yf<3ez`Yp)kjOxd`GQ6d+!l3=Vy`8z`|Io+d81Hdkv%F7Nwc9;v#WHAL)4m zr}I{w=N`Ynre5Z8dQybkH-kKBipsb?)CD-f&f9KB%t-a~`Tpekgw_RAb-yrH`W>+C ze~+U?u2sIBow=aRpeKGc6gY3_G34`?^mK2~q#~S0wKT6{?oYK#E+N^TznVZY*`cK? z2BTJ@o|EuSgDQVMS@ZsVk$mu9ep4(V5uV;*-2|O-R_*3|64$GF$ocer z;lC#2qMGwh$5~@^*n{J0L-3;A;OWURW7MpFKs)w4;~S4GIDLn-2fl_8TbHs#g5rj; zL8?Z$B=oG}?_AP&BsRpKg$Hi}nm>%qrf|vCcZ#7#v$A|0 zgI-O4X)5#NbF)paQKj6}lHCm{h0*RGd5@%iCDwZHwx=U*na_r(UTTRFYRnY(ivqA| zU!i<`<)Agq(xkn6`)}>9x6RoM)|JofC zE_8wqtk<6{LhXq0x;#}Hp(9N@fzGj)ia$m6_tZ$c+kIV0C7$!aFpO{dJ;FFPC&VarcqDCQ3v;(3{&}KS%#(^t_FM-cZ zy3HMgr3k(aDP;K0O2ABj5)IUl94Mj$O-jVMPU z36N3RDwxCJvjWH%3lACR&D@%E{TNXGs#|^pk9vxl3SxdsrApXRI4_n0MJB<4n5;5f zV^WecaT~@O+ePf-$mu;@p_ZW!|jyZFcEK5+|tc*q~(t@+A6qm z44a*I3N1nP82f~BhG~e@Y5?mtbxwJq@uYKIAP_C=0(1+WwUYXfjf^dL0a{<2U@NcfMf$@`Zf~(Fw(_Q6Ra=5L4tlK}*6;|5c zd3Cu9@!n39rB%E$yq^1{eeoNCgwCrwH7@|g-)&dv3HJCy)cH`g_k4SS_txh&a6_*Y z*|zhG(Ei=ZSxqs_ds*2iuWE`Tc3zpEFvjeBtVA*dJeu#i`iu6e+@?&bLVh*p%~r!` z?a-Uv zW|RkgB(4ROS4hh^@KC_+WVo*#(X&QxFp5YXWUiICb9v4Wib$pTkC}6lr+cr3Bf3;P zgzA>BUWmxad9#IB#~KS;(E>ZY|N4mxvG&4w|3-T}8}`Xeb23olNVfJ-BTDYQ@|PPEllPM%nPO* z*xlnX9^V%t_`3J`Z`KFtpC&Ywgnfq|wbL@C&i`LaL7XVTb}(vfmO*C&FkX|*h21ZI zYs(8TH_E$wCX&+ary2UdAv@6qVJage1*<>b^fd(@< z4vs=ut&|;Hp8sH4eL7(yyH1=9i$)Jwrh!gq(EE1?7}PVMljyJDo`hTlhnxgXMw;ZU ztL8DBgJLWa@niM@dwA%$5cLv?vRt@JKX8VaFp*QG?7+;Rb^*B#0Wd&mAPAFw$FAY~ z4gW%6;bU#?$!}ubN>%{@%o6sWY0&9)&K0$EJNjfjiZRmxqWu-M;sn8Mv}L$PgbBP7 z1!M*@hCQ^|@$dd3L&a#JGatBPXOIuGdmj6TU<%^$#3vqugi1+!lY}VIVQzlooRVn- zcwx0+tg1v%ACc2qrYGr8+%K{q6`T}222i5B0QAq7;KB7b*$e2B>#B&0XjwW4}Ud-sar}t1D3Ps|3K771Nnp;B-g(-D6pg{)O3nj&*64Yi6L$;WA@By zdQ!Cwa&cp*^dDLBdOq~1zr)ak-N$XUudZ8XD)lfL+n`G4>UJawWk3zjuE`-zj7w>Z zIWd4*&_g(N%%y$zgUJ1yDEj8}&_c?Gw(gYlyw|-?c}ufd{j+pM85DayL;nPdDIndr z6I=BzXo`)|;+nf7SIO$g$B3}93`V}dKU7|$O|iInLXc2f2!3UUtGaEzWc$fArAtL? z98Qby68{gIW8WX)UkCmr)Lb|G*Hcp* zX8uns9FGu>|yuZKZHo(1yjF=Yjx*5 ztU9u=`oHOeb+ozBJ6~-0+0BgoCu;ghkn#Bw^QahSZ-?a^|MmSRFAyjMME-f3w1_LTn!f++Ih-k-hk(@Sa%r=kCh$K6f}xeh ze%*NiuYgrf>~8WYcoYyV^cy(&bG~#tX8oUU)at7^>qpc9`_tYzPiCEfs_t^BFNZBGi!x?jee@-zEhedk#2fgF~h+uA$7u)v4K&V`1 zB8h_IKnmdwiZ}$e9B29oL&pB*;fKkW*QIhJ-np)9%G$q4zEU9zBYyodLuHP2cdMCv*t`v(#7cWg^NTWEIO^Z%|&4N_DcZ>zRsWci_B;R58bfXG@ z6oGID&s8I3A75=@$-kS7Ig=CbS=M%3$yVea)lRhpYWT{6ZAJL)DJnu)`QI!5JX}k) ze=Qxyg_vc9Hy&cnW5J%IMvJDHYXU>p!?P}8Uu@TtL=8>)bPk6MQq3JigA1XeMWzwj zHH#@>KwlBdB_)WCVFu5Pyn?GpVNwg}FA?TISh0;^qZ0o|9Ho`Zn6~^APY)b^In@Lk zB}e9u@+!a>RtGhVF+JNtK8tjHGxgzP(`UC%2KCi>X)aX0JbaV&y+-#y)!CLBcWGG^ z3DYoInIzlqrNqg!1q`S{uZtMWyZ*tV?t_&NK>j5v6OqZqj0kMI@wWGo$U2K%B*~vJ z)q-%~-eoJK5lo*9M;bA#t8!&5Fh_;RCAoOCZcRLgE}I6klWZnmLB9l&aeRB{Ya(@k zRB|hAJTb08Whax}pA7IC4j$lpBg#^6&*r@(kBJ$7m)~rw?jc zfVh~d0ZS@ZahOHoKkL1Br*UraZ|T>ITXF|C60L1W=;7=xkkOm<9&BlBRU&~KO1le} zY1Y=U0tT!Vs#t02i_pPj)H<9Ga*|&9bhey1w741Hqi&YGr?ThqCFwe3y9&+@@Vf5);4mn>AY=bu<>tQpBKBe z`?3^GsO8@4)2BjQG<$i~`8^HRsx=o(7xj*rwF})-5kN3~tOx17{mq|#v@cLmd&bt5 z-TAoTyHcE$5WjT>VW? zxL@4vw1?nL3TSU}-5~CJykjU;4bVMYO>uZEVidZr`1Y3kJZ^vgA6V2($U{6NdG+0m z=Wg;2nbU2mru_GhxvoICnQB*q zbRsXuqly*o3~w(EjTmw?+H;KbJ6ex~j!knl#N|2y=v?nM_V4yfoI|v_16pbVhf6|s zj`MVJ)z|K(HM`PQDFhwnse$PO3Q+2nf^bv1+QqC zSKBKhh`=_OWa^t76W*?b_wJ?#m6f!g3zrNkt7cF=fl2l%noh71ht@25qTT0=9of`@ zD@O04V%l^39st-noxq2Q4RXTDblQ0#!a4{Yc zS~PHB8Hnk5It1o>h$KoCG#6pUShw~7eh)L;ew00N-z3H&sr~9B5-f#wiHI8IUETnd zc@-rGvapaD!)*2;z${E_5M}*tkuZnVi>!kVzmu!6477f%(|^O(2gx)Yj%(UYv$L%j$lA zXUSg;?IX-q46mq?Z$q(57R??$3o6plVCy=TIr2j|J@fC?)c$gN34s5$s42yt-HJV< z{(B5434up}T{>@-QpC63Kg~B7Hb0lg+5bj5DH8P&9rGj$;;VK7vGmn#WnGY!*F}8sT(HTO7MD# z>R#c+-!SsYNKnDH>endA00hCq|;9v30 ze;*Ud1VIrCRfzaa61ZHd)G#pN*3^qsd8~JGf5cxE*0pn_%R_+H(NMfnKgJFFXGt5pqs_e=ky3juC#@q!aDRIe5H41gii+n zWsv~`r)qe5rGElTUrw(8dkx;lT}Y6LWMB`V=>#N0Zyj7SJ3dhC%=Oi~v+Hah=k3%= zu3UgA{Wt+FXSi?bQ%{2-@Js-DqqAGF!#(!u6L0vxPO0@_q{UCq{(oDX85dZS@E_bT zktSM;)`M9x@+=JTWcneFXSHyhOecBX5VZ;26rQnau~L3!HL0{B*H|Yiu^kPOR|_Iy zL=1r8skIF;1_*@!$?c<*{@f+K-(n)V^8aLi)8TBH!k3FH6f;pR*_3#P)f?&B(Lzv!QOfYs=Tjmx!yazbGLf(_RUWVq ztxHg@Q)7PrOK2by&I~nXfl7=NOfxm^y1!$3!qLqv@5NA&trMBR$BT6P=g-z-RZ z&bG?(lmc|8W~cbG1jLM-8gQJQ5ZBk!R#0(Rz7wYX;G4%frGx&yPremu0^cYEP!;geem5HDq93Q(nx>X%A6>YO?H_|iO9bMX9QUg9lWmv2f8>u++81{0!PE;{ zdlJkY^*@RB-)^w$so2~CT*ghT!7_cf8LoOB~hO;9Log#rGHMCBa<;n8hf+EdCRiAM&_=cbgAy4P00i zora{r6-}A8L!;$-FO}$L`5#tps+VAKZHtaZn~%!MCa1vCTcp$Ev}P?hm++G)`I1!k zDgCDJ74KeL9O<`xkfHlFm-a2?W}j3V;fYeN^u(34f3Vl{;QpOs&h!CrJ;U0TlaWbg z%mHM1KHnJ1{zvJA@&gpkm%~Ljp!f_Z#3vW~_$8A@1?|8$ik6F(5weE#X7qB_!AkmE zl>JnnEIS<#k1G3)rE4ICkIEf64^Dq;Kenh6=U3AhSYXK#M?0}Fjwiy<*%ge_@;PEB zxG3Q2K>pYt0;`Kn*uE|Evzs<^o3?YC3uL)DvlWbSMA5uUNeg7)_bPxg^**^g+noC;n@(aW;YZL{S2c`zIEXoyT1|+V_|O68QHzaj;NjXrfK;@nojehNFY~d25&5J}_Bnq}%ku!>}-+!x& zea}vy9sdp?_+!*DXzKdPCuE9KF)`5!q?3t*@S*(W-Ah~FH2C@YMUnOTFigbyD~L;4 zErEq&mX=O|Z60!5C4oNr^x?or$`ZXY7O-fm;Re_&i3ybR?GcI2Pt|8)q|e7p%^t@DdxC(XU-Qai_TI+ zhqkbzrUyFBkxSP(+pN7-{6!SfiA`_`IEfTW=I?B?;qH?~j8^K7$5Hxd5Gg6&AeHa- z2{DS2zileoE8@=&Y9Cc+AEDg`KeJx1-7icPCZZA2ucOxzcGIdE)NfM;F@p*q+^=4} zT**m^f8O`p@vo!JY$Q+siG$u_tHjM5MXEbeqfd~eu!oAp7_Z*^iuDgX7(@v?NK5{M zd5;cFIAq>Fa-Y7r84=>hz4+xm5LlRDCw6};9*T9^yRfK2G{p@0Zb6I$3w23qU_j-7 z+8*cTl$a@k#wtjcW$UTrBl2u(+_ZGBLw*QcNL8>V0+0!nLdB^!w_?oj*jOZz%E;lt z=7OL9j8FtY2T`8SSOvhRnv;ucw7fu@^j6q4EP`?!It#_U#^i8?vS-;{*G_*|BxFvC z`HZTzDLIN=N-56TKSI5NiRB|;(Z7@bLODNEeV{Z9z^c)XNrC>WSLG`)Kr z2KZ*)$soU}W$E;}il&72+z2yP- zpJgG>-MBJ9rk(JBnb!4k-sEak9-d5mMUv!7C&gypi;>Tra(cy{u6?)F;(eLzDh4Db z>4-uorsd%v!LQu(B)7KJ$!TnRDM&b5uw%;lXP4Y*N8Hc>nUO%QYLo;p){&MUK%35y zaP%$N)hTih=MNGLx(Pe+`2UfGqSnY;i%s+w!o%JIK`YX%5Dm@Vndpz83bhZ->SQ(W zYCLI-4#OPMQZ2O^o?^uNSA~~wiN(L}(39X(_q{(t(+7l|=rn@S?m;FOc>gqBY+(hm zXi>CC4#*|OL=V|3r!L!9JZ-tld-I-o;=iFNY+>)mT`^11D7rZIBF96X=Goek*53Y9 zGyW-SgB(EgSIF}twO~<#355RY6}pWfn=&V+C0cXKj{=A_R)b&+fHDcjvUUKX9O5B_ z`nhHLQ>djQ39o(d4{EYes~_UA;6V6lnxEJ#R?}aI@+YV=jY@5`4^t4qDo4qz5px2| zO=4ZOON@~BC!In$yW?Be4$oY1I-|P|C3Lm)pXb7X=rGb!`p?uBRBAv2C7q}?K zSgkujR&=>c&MbPx5zhAyNQ&3BYymr}mhqyKj}vsWK#GQY%uf_eE9+!+ZTeU-R6wRU zjav#!xuYp#fJdfeD(-Tk{ZONDfIa%!g$!$$!leA*=3T3o3_4-}+@{R9JeTJBR|@$% zWYv2u+ zZa^Mx!)Plj5wfH*5e+qR$`376W!yayh-^te-#NchhUyNj^R{~kODE#4iyz9 zTw(va&lJ6%fjFwaKdM$sbt-c~e*G1ZSR?CN3P>pycBJq?WED$Ci}_-Lj*@MQn?H6t zoH0mI`DoAypKawO`e_5gHev z>F_G%*TpPGx;6mwc=1iTnqX7 zJ!PfibBnf=QJa^P{biMis6`V2M_0qY*OJ+AN6pfsF0U)pZ2TiOH?Z>cFJIjRUNkDi zUg|mRYMd+pE@GY!L71vtZ1!9KO39h_TbiX>VuybMn$8Nu&1cDt%Yla_gW34;J-3|w zxb)oK3!9I%S-`cA(x12QaX76B-1d^tCIk)%BOT@l)6P0OUp?c?J=ooYz81NN^Ma+l z4I1ZfY4!n}$d`W&SO13zFq?L<4Fmgc51nl=gqF8^Z_4B@nUzmziTpj4T#0hkCm)hN zPJHk=(|zd(ScT#1o8=0IMw~t%1Gs?FyU`pgB_YqA`HD8pe}%B?`mdLe(|^SU-;uvb zK{QUbDdgq(r+W6JBSYQJn>;^3ufTskzlP`>sKThn41e$c$XP;t-ZNpGSI`7nAcfCN z*?4m9rSB6&dNlpz=Iy#V5_fi!IIOLIv6RJen}e!}1msdxMP-CCD%E>5B8W=G2!tyaVY9uh&>+7IZ$>s!jR3L~)9RNk)T4kUg-pzmGf5Or z50APd2E+yaUKE8>9K)i*<$4h8%am?J{ z5gO+0Bcfmc?eqklze(5}FHEY}G?9RenS-Lcd-UeR8FqLS-z9$w_wOITEf5cfF`k)M zKc_^fQFQl`Yd5mk*b=|uTp>N(BEQGJ|DBEt)TSQ&Gs|XnT}V<3SoT=6zFMcBES>{- z$0r0ietN zwN7E`5e4F~S2cFUSOGA2iM!t=%wiN$f6r3Oh2k(kBpJgJcF$%lCOUeMV_KQ_h@oHk ziWV|sY!Nc*)@6&GP{~z zS7fz4uIJkE-uS!S%^sGJX|xP!%C)pVpSc?C+wNrRyO)3&3|NlT0$a5eQ#B{H?#1j7 z<qBgt0v94o^^`$Cd5tZxLU>a_*Du@Vw_x)_$^v*_N~XQTI*h zmT~vi!~)8u->u6Bk-+|EMNTDZ5bZ;9D3hN$L72bu=x zpmdD~%@XS8{tiU`wEe0)8&Z)jwGFh-A7n??DJ^{{)loBSUE-WK&Gi{aO2(>cl@H-Q zL&oU}ZHp{(^ad%sx_7S^cv8QQ2Gpi$k*4(3iTAnr>ug)P`**bJi5*WpVK{9Y9V-QD z>c5F|{cHT>&-V*mDXE#J-m z&|A~{_eGVp8OodZzQEOvtg*XYCjWz2Ku8zAu4q*Ce@r_8fn2#ztnTwcn|qgUEzTDg zJyudzvkeorW0v@;2w$qDNzi-#lfWGWPL6_?rYO+rO}}T!792Xf{C6gT|qSZ22u8wI!)ozA_jC zxJ@7tWIh_0W3rjW(oJzE`+Q8YsSf)IBOYci;xh#%xOC$&mf6Mn2DJaESc6FgKPA!% z(-{Ys9V>=x5W8yKmg3@$_sSoZ*K=m+fOr9rQn1*PBk69h+Lt8R5_$GWTqT43R_#f$Nzy-3*@a0~moh+LIO6w1<1@L^uRbhRRiCsTrOG8q90iqdo!LaQX#m{L79 z<`LI`5DrJp+ z4me_y7YRUgx%)-_Zws;?lvs#B>O*{v8-lmz{FVVy?GD-A;%{Ju#@VHcvoW9Tgp_CE zB|H&<7_u+9tbiOQ1l{f}OnOImqtMNng7`&{&Ff6u`WZ>w0+b{PO2XrjA9fvouop zy*_i#LSg|2%+he7dz}GJnBmK-aYWfK3||(uzKF#mU&WBQ{2I!HDSvkB=SPCClFc7L zM23U^c7(I;alDb0kw_gUqQ`tV0gq(aJHH_lI`$1W@!I~E4!EVGqvKBG!SCtG_w~>s zx4Dydke<&wm$0v}qq%hyHt(35Ur$`B$jnJ2-isaz3+`Mw1)PXZL9R;g>1KoLF$Lb#1h-1^|CIPBZriUbK2_ zR0Z@QUB}NwJ74cVHN?NX5BvkA57a*Oe1#(PLikeV)kW+7sC+BhEqUcSMyfA-kVC!U z(*5IRTJ)Ln1>^kew(tFn0!^YUZQvm7w!)6g0Dm6_6`s#Xok*Z;?jdvmCsOL)Z*d|i z@oH%MZVyDD;*qdi!gO9Dvv@6qKMfeDrwB-AF==aYZWKSAx>)M{?0nL@sCz3YW=R>2 z6KlkI5LMg0Ez%e$1~)6cna#rYJ|~EmmW`O8`#STbn;A>Oq`IWiuA18#VJLXdTn%v> zyqwZ>nBbC;42p!Qkd8KT=(8}@E@4EEsOZEeGZv3KBhrzTq)j67mlY)?n~b+lj+aPz zvn@8*2<&A=Es``6{Sqd|Abu-9AvAP{W!_W4=*Ytzy{MuWFTE8SQ<(mA8K_#xYfyT# zQyUw<-=iGq)Iph^3q7RFazI}{JGwSY^>STX-0=<*MRXEW!E0FS97PbBK8L4H$H#dG z&lAN@1yvu|lXF+M zgQyqF#wW@)W!6gHH%=>m4PZE;^ro3ff)hEXltwR`w{_1qeia|@54}>L?!?q5!!y2= z_3A^AwD=d4mn%`9Ja^6iKJNa%{yPgrl_Y^Tf87=}lrpRym+9Yjc=4>SSUunRkBh#D zKA+vvgQiq>gzuhScKden9-Y)Pi~c(+fl>N(1wAF*9gVi-K2^X< zUrl8Fdh5>rTGPAwyuJMmu{u{dks@wOaOKwtDqR4wcVjfSNh}=-1S`{UGKBLdC!-Wy z5LIZPSL+nJn-eR3gMJIzx9UWo+u3Op##s2NVt z!$RaL_=xed5byGnh|duN2P7RFRpp_y zAfQWMZ@4`dbcid;j`;&#)!Dgw4V4Y9Iz_v}nd6&*#BAbv%?a{OL#a}R9)@J^eC z#cgclf^$P-180?nMThk+Dfh+mX=wG(@)jmdNxwS_cp+rLcf3<~t{Efe@IX<dg(Rl%G_}LBCd*qOJ^;lesP4>*Z8l#Oz)`Ag9Q7$?hq$!hy z?X-NoR~5#CGG}@Rz_CRh>mG6cu=1IjjVjGcXmx zJ4$~7w!l^y90qEMvl{}If}c?mb{r*b6?TK2f=CHam`@*U#jkXOpS4(xgRlDAP3}!| z!{?F4>Y{!tgBjw8&rynY6GCPJ#BLl}Y|Ln`B`R zFAVH(ecXGd1_cbZqYtekH$$s&*R2*0-DkeQXYk^yGqbroPwjeMtw_N8MT(1pfh&zwjTMO$}nUCp`=oRq)@E{0KP zO|uwjx|L3`;Zg|jhO!6D`<(@7NbzPoXhN4R6*(x(^(Q|Hb$)j_vmYEUE@s961~KX1=-bQzfI?u^e~qTB^IKb2IBo?jpPwFnh#<+c?{49s1!A5%o5Lh_Sb*c2j1V zVmHoW)x)%d`{gSQCZY&B@~R(+W3=Hl%_?;}3!B8xVP$$c!_W}q?F*_e7w(H{ZRE#F zQ>C`nnV9?HR>Zn?DM`rKbJYCN4Lq%>$RG=;+vH!h#DHis)| zwI|kHajl&0i>BG5J`zvG@D=AXEkC5U6|97-I(P4m`He(;hADdkrX7UIp^V>KgI>T@AlekAkOd% znk6_;|LB#%6TiY+T~yZ^22Q1nVLWjfzMlHC>si6?|tG0|vY*5u}m#_+HcX9)d#z-%T&!j={DJvIUzj7~?U>pP*Z{&6Ng zji#9H4|Cy3Laexl_+ye!gNK!%vwTO@v{%Z;#cmo5yn*I{^wcGK!CN0BI-u&+z95L{ zw@|Yi&?llDcmpvYqd0aJb+T~UeR??lu)!tzU;6=kz;7#Po)3dAjCyyqo~3poB<+TW z?Fa2S)~2hn(soNxgH$*$&Cu24JMlvQ^(+5Qg#VjnHSc{D`9zB-eVdV@BoiI`B^?J_ zLnBse0k`^-w{o6=ab&S=b5qIm#zEcp-EUtYeUA_?+^&O9k85EXk1Sm;Y&{qpe__{c z!QZXkaKXh=!SV&)9HaKtnrOTu{mO%RBKEL#Ti7iVnc(}H85wCd@vW=nK7Drf)c2gp z^Tj5^TAM7AIiBn|*AMUM0J?=_AZ$F~G!K)XoKTi*TBmaH!dDfxTZI5|;_24coX3p$ z%(v|_NZn0Mm^FP`XXlGxa<@J2JF_(<4lmFzD)47xt=o7tHXmHCAzS$BWLK zCHgqR-GD+xn*;r3Jv{y~v!RA419gFWtaiW)(FuJ~JG0cH!_bY>==}ajpqS29F99dR z!W(`O_=;JX{@E;Ej^nQePjx6TGj!pYW zB8rElFqDLheY8uN?c$PAeT2h_C~k4~Xxce@-ge9Nf6C6v;+H^vUxeb#<^VQrvFz1P zp0Z5Y-(P$9o9FgiC2Hn=_Imjc^qz@bV6@;u;_FZN$h>bHG!gv>7h@DpJ%RoZO65)N zx}^|gY-xfnG{Tpjp7`fn&j9&S!};IBi-Os253}_}MXklppC(jSQhdNFzSA7TGRa9s zxp5stsftJl(MnUtcTudhi;w@0ulJ0GJMR8PQxLsH@4Yj687+Dz2oceH?_%`mqPHMw zl!z{RCx}6GqD38@!5F=}znuT`oO8=s_wBrzwPyBr?@!ws<}qsh{B6ALGPy8x%1?`Q zS-tX7l-5c%aQMK_y7ckLQ~d8!sp3C<1tC;2NA#z>_$dWbOOy^rS{^~y$KiV*XuJmwx?Mc73uL2P_+ik{wxwl91~?QisSQGs9UGKL1W|?_@K*-QMfvG zT1xn<&x4)%KBB*?Vt`Up@wQ%6>q)PF+NBk`qK+;^=7{P!JDGid|Ep88((5ay5!RiT z4+qFVsX(_#q5_^O_a!djyQ2!yfb)YC+nS6@jwpBXSnuIci@vtKQm`5)sv`TJA@mZt z5xSyL6i3^#h=D;9wkEjUeJKliw_G?q^GGVqNhXi#0buQ0<$jXmgIuicc0qiaPY*mj z<0oSGLmp^aHFz%VQ9Im#71x7q>{(r;7n?HNZ^`=|`Eb%bVbg8AI_I1N>WN4~G*}+t zGv-3&7`CLpG{N1?i2a89r6z<+*T%xn-wk}yE}QmQk!H$TuT z(NqF#3I2oa8NnCXwijx)RkA)j$7xdYemlP4C4Y%!?Z<*QykpGWL6BZsQUT3K<~{~F zh9A)SJ*pZ~$~==5049Bo8Q5VB`0$vfqvC;4QNC`|2^-F49?uXuCE;MLWf;KtL@bke zq7eaVSM&4_41WF4(wlLw;ez^N^+)%{*E{2b7q=@JBGd?y#T#nSRS+cKufshaPvNRp zhBeH2zJ>Hx_^Z>gAkkvpXGy&&lkP{HG_~dyoX`?QAE9@PA8W71CcLS$Myne7&>AeYg$(ngSrTWSqyL;R z|FTP&K#i!&u4?Fns*FgMJ$#Bzk7(7PdfPFtt7Q`reET9#I=J7jUJ+x+jrSFLNOO9d z(4Q)EhFmE+iggj zZSaF8*RY8{E&aekFKp#oz+R%eo+*uLAh`c<@$ zn=iZ*hE6`Ix+FvJ-n%D;g9@r)i`+0ZB8fpZ>`5B_ z&|T)|s`TV+{VF`7lx$L+BCw5EcD!-cxJXpzIrq+LUysi9z0oXN5#m#5-M)klYEN1{ z+K$sMtE|qup1MXOSBG?OC1);ZYbk2XzgJO2{^w?0p)e9+ssUo zjXF%%8i`a^BzRlebrijP27P{4`YS=LO{X5JjivP+MPCs%yh6OsdKm$_w?FSCZCwpc z%zH1RUiaQ>t(K59oCQPem%b69RM@Vy)|Cex??U_A3)H~dKz5U6Uf1Tzd|Dn?e9>06 zkG1~ZN3LI&nTccqSW(J-Uc{VR_}gD5((FTQb8q)Lg(1PPVV6fl-){GPO`>g}+dl+m z%#CGq_ET==+4)&U5r1a-b*(-5ON+3VB|az~)#^cfCsS@c(I#nfa7Wi%GB6nV#t z)e?99Vq2;D;7|FVnoT+0`hS!st?^aeKu>~CFX`$F)uLnON|N-K5g>{_cA?7!-bNwa z_o*U6zV8_;`^e>t=eS5XO}c{m&qs5%{NlZF%e<9MnV71)1)&py)Xs~O@Jq}}pb zw$ZR?efG*fqT~^vz`<3lUTSy}ZS--(e7-*{Hnx}a5)Bp$j{uEXcJ^G)Oa1)${wRaI zI}`W%%IcRA>&s&7&`8z{&Mv>sb+~VTy>(wV&g1@~ z!eizanDp#vR+SRd*fjar*Si6|ws=}%**Mw|VdsGgz0v6kVp9_yNTWk*IXL`{^0y|7 zs1@o`nYts1x$H>m|)i+?-EVia3-{B3o6w{(U z%1~rbBi4cI3(}5Mmj*qFBih6QJBRCST3FoPUfQISJRgVKimo$Gx1r78HHO7IA7u?FF5nkejCX+Z4-yl{ia*2wPM$7I6|LHeQ5Pq9O=r#%z0#Td+>L zfIVu{;~%5rF9$^EnRJHMZc=PZxs>*?zj-FbWcSC+z4Z*b?4Jv`-W02GO?TR-AP_<~ z3{9hVP8snC=MlZ%70{G^h&z?O6=KZ~h#`yr$_CsmSB=P^=-#=gt4K`z^m+Yk^8TcH z@7}jr+Q&NoaFd$)D`_&yQ+4&g&2`(H9i;XZArxgtauyAS_0c1*_uZI4|3Fb_qOF`1 zIG2};--El==ubz>!f`(w)zr8q(}OQ=HRX@c*}$$?)#Y0XZD!J7^v5d)B$Y(E+f z9(!W(dCG5567S(U`**WAjeKP7YSu)*2-=(Wzxx~p^AgN+2p6i#AH>j|*?*$T$iki& z)4>FeO_>7URiQu>B-kC~)dQl<^OU0Gqs*}5c3-Kl#;M^Uy?Yq8mGBqaIoHt86VJk( z)f;e_uCvGaT-uy5Y42xysaz^0MJ;>s7X>Zohl1_x&JvdNF%Qf$ieqRN)D5-=L@N6y5oTL8CS}YnJ8oi;y9Q6^Pe;jan35 zTUAtndRaz@)3gN_U375f8t2`dP8O zLp%@W$z)Q11Vg_1L>K`Br;JUDvudf-$K|MLf7_`1!DY&U$}pJPueCNJboL@n6*kRR zJE))CN8kzB<hP_snm z`vU|b`{>kovBUY@e*sMLmAP_n#y>LTznlo7gHyQy=kHDDN0R`#_g8CZ1(H2O{eJS6 zX4A4W^bTQyxLXo2bX?l}9l6Vh_fRh-FjiT3OW-?JE=Y6Em@oA>!NXJe4xBd~8ulJW+BI>^f|E zA}HoQl+X0-QYGj9*c$3)tIeBj_218!zm`*y=#9>l*i4cMlYl|)vgSo>UwwSV&>>x& z1&Ch*A|tm`-Fh@DI;+OnVcsjf$hOszj@XU!)rvd5poB!EkHJ7!R+d+I*uivB5qH*Q*3=VYO?kB@BZrnN9OPvUE%iT8I z9V%J$1|2KIqGIgBO|F%N-IS?B+8%R`xfFBl*PCLkNBtm&n#gwH%Q0AGpLilZAp&*G zkK~kjJWq0GFLkE_@(?rQ5ntOgTpeaYocXDeV2>3imbt9YNYq9ThKl2#kN+(GAu5y6OzWjzb? z-pQPctSGmh@g|e-W%+{Zsi`RTy)P#@o#>ed#+Ps|OtU|;#8z`}0+;2sRwXjq*sw8B zxoE<^pC-)u|24UrBCDPyp3BZE!D9c8`7M)(+_oEwZudJbXC{NI1~75)F~&+dQmEUh_IOqMP}9=%lr;dUxD z&v0w@`4ooZhWF)#vt*m+U>jC%h6YxnOcRN<TF(w?t9zT5XTii8`7){ZwKP8)hyb=oki?GssU|E@-aE1gpx zE~KY739vo%GqK*dmg-zGR~A*Muh@xIL4LKkwp?R|#V?2zJXAw_Q#nU}aFK6{Mf`3W z0~xY~KE}$sP@74t_`agE9%QtvvXnACKHC$>lML<|ep|EP#F5ho$U}usca?Uk-Mz2W_>=N9lu zVVKTAhl7SQ23ywiI@hMOrV)SfOiuYzM#{W3-cmGJ3%?Vhbtvh$VQ|#i^~X4h6Z;U&=oJe}L`s@rvSWmP0nVpA*It32FP3Al`#*I>C*hX9 z)jxk*;G>1?(PnNbKRDBY1rd|&@Qs;w;O&s1+EtMXk~nO2P?yqy07lctjR=Zqdc(wY+?-`n0#1ZSq35(KQOX)$~<~QlP*0+)m z=&diK`)_Ui^U;0bm!)zy6icHpgsqadm{fXPcRTfg`(prJqUVpAqLlx3f;)TwOaGt7 zz!dcA;+e;_OFN707*Jy(O2QyuQ5}*Per+5XRKVn z#ALTeZWX$~yWwTvCd@&;lVyZL8dKsqQjrCp0T9*%S=3Yv?CYi%zl8+b1QH?w&+=Wa zJK$=3&PYVkier1(8+sYj&rC>*j+is{p;cOdSag6O z3$50~2A_vuWXDgHPH&Wjgj|zwH>skyKToVLA24j|+6RY3!Kn6R%kAX3v?UnR>@>-F>iVW6k2C#$>D|WBFF^lnt*B*;kp(ZG}`L5WDmu8-5%WAm7w`TE$}e=+G7 zbqr&3p7;>ovKWN;@Yq3D;=PZ``&xXw&65kBZmp;5Lk&em#-^my4U*wu*`(6<$t0xE ze4`si@nvD>CpL1DK;OYAOb=J`nF3V)Gj`lfL7_+()%?i{D{OayHaKx*Kys zK{G$w6etsgX|`USm*J(+>Bi70VYqPUVAzm;9~ra3W@W77vaK((6Bep=nHb|8fu+8% zd^jQ9x!X?#E~_F@tyv}sUDAm&VuiGLG9=m!0p-MfT2xrm;+y<-jJlYIxhSp8Qa(|D zhcGPll)S#sENw?H-crw7qW#^8NA3EIhX$P6_lqY#x%;CNRB-$YTmMYmc7MR=aQTL} zvjJn7Hl2{k;PT=_pg#t|6wPG!^5gP?Nw)K++rWXNT+e~}l_C9Krz8ovOe@gpqk?Tj z_m+t0n91|bB9*3&%dSgEJ{_!y!~L-tQPIAK7(Ko}hvsf{3ZV?goF%LD7TuA-EY?^4 zMqpT9_I_4xl(s9N_pMgGu1I8Cqt}Y%Q!h6`ZA0L)i}kz0SVHZz&3b{DR4f+SK!$|M|db2OCEF2C}VZUy~Ms2B5NhTxm$gS@6SQwu}gNC(1*G1 z3`bGgu5+GOQ2)xrP%OMz1!PL&8rAO$ix)%6y-Z<=T8aT5q_%pBH!a@$Fw!>~7rA?w z$pO{Oc9g%b7VrBF7RGhJs=|8q`Qfj#e{=NSXY}#1Bpn`}b3XFq#4_iOSbLvP@z*43 z^k&6t4Syu^aIQv+Jf+$*Pj?QeLr-rRRH82k6|9Scskmw;>QVea;i_^*oS|> z1LZ z;>M6M`&oGUd7Gt4V3P{0Ae2@D5{O=_B zj<|#K+hqh}d-f(oeeq)T5E|H>n7VTKP5m!7Rv_p9ilxH!pZPFOU{ejYO>lJpbW;L))M|-?i#9|M-yUCXs%i zMfx}@Ep2c?pRybr%@up-b-G3RVz^uDVVyM5_u(e%x*TkhcJ=XkX5ezD!KU^v6-bpS zhzMFl$ZoZtD$SRs6NiQ>GOTeW{d%3X@9W?Y|LE#HZ1107BTv}%e_-&h7rptR$KOoJ z{#csCc?kzm7#iyRc1OulAd&7L|652G3{PI*&1 z&zV=^g!0lR*i`k)50E1Klf&fDLoQ{mri5|4^(?F8$^eK^7WWEc{CgtpafOz&g&-Fh zPRwCTBwnDTC^U1;iylP>&Wn%HV`0o^T7A6^w+*;IV|L!Li|-TtAqylNeiP}ay-6>q z&v6oEpw##x0U0@i4PYMy%lje-x~O2WJv}({FLI@+03Dz)ZaCrcf(51~jRu^sx8Mvw z&PLkid&>L@kuPKmx22=LR1!-pLCw!g0Jzz3M{e)slYTg}sc{vg&ET}$!9WqFW{p}E zyPmO2MC8A-@+pGv95{N7-b=bCi5Yr~N~WA?mEvENqcuKoce0XZDk*AiWOtk}yp=^N zqSn<`eRWf^U4Ah_33&M;7={H^Rp^Q-%egxMw%xDOY}(sv%sJNvHym{T>B^NOz??H^ z&sCNn0%WM_QI-j)81lVudI153o9VKI8PA4M%GV8gKaH_bN2iNvOAYYc!+@hhGO&Ng zw9`yatX%W;TzYvEfN8h&)T#vIXXp6`@YKlCuVgnGeqqvvg9=Hz!UZ*D8fp>eQ))gR z0_#nuYM6U5o;MFP1T8yDuZ})>b%bHMc@Y#ddQKey>HvPQSkn&ygTM-d?eZN6*Frwe zL-ym1*+P^u@aJDq0R694i3n>F+VrK+4T@oc4M%DGzsh19VDO%;X2e3&J-@#$0&^E z3MAsZ$A;*3Rw_3$X!B3rc6W{6$MSuzf2z0qW93c2^+{xnDG1W&)M`jMOF{v1*C`Yv z`eG?Ok(1~N5QWD>{b{KTmRXKzuh&q_Ht)YR0_H{-x$1`Xd>?(hA#7-V3C_jh{}hOEn(((1=gt` z#OdYzG8}Y{*_p#gD)MHSa({+jwA~9(G5|N}MQ=2nI-d=tvM@_a6gmM~p$YHwEH?yD zhYf*CmCAgKS7*rQLDxf!Xx7lLBhBou0r4@@i7 zvxjW}>Ydkiuqu&JDIRb%)weEyD|qYCCxv4s?bPRkVYBNR{dk5x3;W|Dp7 zGsXb?pV7I;sD3cDVDAqAy}Er7(A=BGhUPb&*|b`7OFdxUN-P$!jS;&Z)EvlA*)Hjl za|9ax09!c=IcC04=mrHWz(elCJl;2UZbZ{+U)4z}8p(uz#%)@7TxP;BbvZ@ed;aGC zF2Euh&HNuhWCGk`-USKl^Ee$<=mm@c|3yno0OSac=_Q3p^tL(2FxpE_iT-K}@eZpr zv|3IfPiz8Mv)*=md}8_;W&Ey>V?^)r`0}vHRy zJzl?FsbD^B@)V5Ug3n=@ELCRC332BE^N<&KyJs&hbuvliOZ{I#qm|0*JBLvOk4OVL zxNtglNz67}P|1SRPYl27pjUl0OG^C%5s&y4**S2v zHsxM#;3;W}?4KLI+TX(}|2z4CCQZ?bylx_zOZ3-ZhkS-y$t8{3#nnqq+TdP5G3+%9 zrH$j0wcI-5o^Tso|4brNd8~`ybtd2kiw%xFW)6t_0Hx^ghG=2eb28;&4;YA*{5~B% zK6GxY3qfe5W0Z^1e9#@`wNaC7IKcs~@QeD}(tFf9F4N-F!8aM~l#-b6nK zfky_>Wk<0?uM~PkQ*%gDv^16OuKRRpauY=Jv3X}-3vrto^IV^thwfA)yoD!t@7PlV zH&pH8-}HEwc)B%~LBOW9F5>%aP5T{qdx!_+UQ(bHy(8)xDYjuU=b~oZB6rLrjyW|S zV09hcX991*8JKpc}UWG%NXZpTD0*+Sa&2PEN8i zh9l=H5*i8q=$l!ZvFq~wMy(H7gDpgB9p$?TQ@HVSC6B@4t&N4a=5WTVNjglNc|Y<9 z)~90FPGjs$^YwAb(YF}R{A)}z9qX|QVazCk1U>yM;pr0eu&^fuvWwKfeFE{o%n`)8 zQ#QYM6NCTxW{&KzZ@;;9WQMLMbakr{n>xOmlbfmfFPGdi)rQ|0@ zA}LP{b)jQuYzVxQKvtE!I-^tub%~@6XbECyZcOCDWf! z)x|l9mSm2I`d$%SN?oT7bt=UjN&eh_R)mR781=V__#kvlCXg5v%m1JmadEl}?soJG zUU7Mszxr+U(tR)N@BokQ#o}3pq-tT|ala+?d=iqUL;}+?rk=!=TG+-9IhbiOsB1xn zKFnJGG=BtbDIZ&mI=vy+veh2q=i7G*X9mp)%{5w@Gjnpzi`o?(_utSNp8VR;B}xvT zJos5+$LUo-nsR_;+Cdk)DuD5vJ=5mc&gi(p|D}{z{_*5Oni0m&(*uW|)CyEBQlasG zLweobld4ztY1sC0l~I7KXwhgs=ybm={(QN?n!r@y-Re)js0e6=P0-4?anKM3*4`g;jN`6F8U+fHd*?W^^R za8QTw)ztJ{LHBg96Kwe5*!>^0+EKakh#k1p1OT{%ueZi>!$9nR94!w_t-*lXpXzw# zR8831v*YUjQylu;`cH5!V!bI;4 z^fj(3%Lct|JqFN}WYS@hf2v?Jb+Cnwb%hyZU6VgI*)z95Gf?;ys&b?M#yv0eEO#EN ziMQ^Ae1Z_wB<$+=a=CKp@6pkWXL?0=Cy++U#)`lAlrHY6`QqBedR2cm?(&<>bBfj$ z`7zTCR};o5dujf*y8PTjjk1d0F%Xht5$l%s;*MqQktOib_VoS|o7!sMhm$PQ8drlV zL*Yi$T>3gHz4TpJw39@EgqUSOP$IJPg*rl-iE@0R2t(hY^7U1co%LeLEp1?0Cn?E! zcvj@+rhHcn#Z{qBX~gR*LWQTb6j9&snh@1PF8zINu82t!;6u<@Sp1jx%1eVCw=wg> zixm38OvZsE%T_kxOSG6oEf$HKgu&0f;^Q+G{lnSB<1YFgMm|POKX^kwzZ9IEd>0$P zp0OhEi(rZ?qE`5izyHmm=il6-CxY^T@r7#|-GiRYIy6@26q0hfw~%SNlxUSL>*-houEt#n8reg(QUT)ghrtT%}sd6}=wnX>`Qvrr3 zxy>k&E`VAw2h*HvovM|)68!lO{dgsIVjQfb6MQZ5xSLNsuKs@`8}Je-aKk8H{o=`3 z>uxPwi`u-reB7sVo67jvb!`3CkLlT-M7ie#nb9%~PGZ_T2z0r`qh+&WWtdD0J|vmu z8|u3PHZ;m~53%jQt^s@R_|?HAc_KnfOq@K)Ud9&8^OiCX zfWuO&#~urHf}y{MlU9_`J($V1VotSt!uFB|Y%P!CqegyHaih%ore|jXk&23~EVy?> zPXSi{kkhXPuMsEF;t`LIDDP$W#&X|A7l@uWSNwP6GZ53@?v)3A7a{%9Y|KkT|!-`@tquP^4#t2mce zcKoN?#Se2-ieEJ1&<#+WS%#fLY2Rhq(BR6M^ zUi5bEvB;!aeERs5H2!GQVtLe;%xOechT7ZYeKJEy%hp5SaZAQF=c%*3h<`URy@ zs2XglwscHTaCwt?>%xzDCxdDcu8J)0WgWof97>8=!0*LJM;S+^ZWA}gleyym^Q_0R z1dm2QqFYN?QX!2ohv!&!z3A2M*Qy);V9r+yc!rnK?N7Xeb5}{?y8bn_f@5nDp2J6~ zKc||qfw$$f=&HXiXhkP@qf1xuTdpSN=dPlw?D{OKWK=%shw|%U^GBq^Zr;OpE5Gwe zMU!=+Gh|ggVZe-z{+)F{Y5D!MHE^IGwJtcpSr?{k!I1MK%g2tLEsbF=MLU_^^09u* z+;YEgHd1S&&-()X%^miA2wmHWS#Hz{k`p|0a9@89EIBTI()jZ0h4o@CU6T`JTLr~1 zybwisPmVOH{Zv0(@YNT5D;?fJ!B&z@@RGmb7TE?LiJ_(aUy_}T#>V%J+W3UN_&Pvv zxb*c~t5qLag)p~jR-=k8#QBcoK$g(SCqWi#IpKpQda#rx0qhiw^tD`s7 zJ_DKlb3q>lb`z|FY}*{Vs=Ks5Z1Pq8o5uon&$G3}!fu}F$ zV@s*I51u;%mOzkK{y zav16#?qdgIZ!`n{$r(orxY&&|4e;t=O&o6#rRHa2f_fR%hyat_$vGUgW{!IkpN(rMpnFWGoXcEe_c&P!YJAsW`fhP2SxV|J}m4xM0%ggqji@Eem3W-)p-y6lsc0;aTg zP&Zz)e*v;wH$VO@D`6WjIUmJmY&0>|GP=x3iqBhYxdnCDWdd=YT(geVF54X~9qZ>a zLN(Q{Ehd%k09VBMw-Yo}+YgX%UL-@l!pm%BmX6f|V7RJ18rMX(D46W2{J;vAU!b70jeH zX2^oi*9WUUjLYWm`F4~0={hZqhL>FjyW_s^Z*J|imgo!h?SebqMhXG6*`gqullwTkAt zgQ>NN#1u$Y;{LZRb_}=&8zMFIPH+5rwnjsb-rv1oLVgH}DNA_hH+|f}52J%|?{=Cg z1yJ78O*9!3P00;H>GW}2^ejb)>pYb=*mJ%%#8mnOXycY~p~of(qtv_*+Zf6$9unzJ-x{oQGTEPL8tj*-MVs&84z@C|`{~&@f4q5~0x})_mpsWH6KGxo zawi%ED1GYg`Zf|d*jN3XFKKeH#VBp;_G)MAN-3)6h(R@T9W5pn9RpZBo&GP6a^uCQ z6Um8Dr!dNA-5RTXYfB*?>TO)i!V2XqlIT7hHe=a9_e9T~!$JO^Uap@x-?*(GZqQT( zO89c_-?+%GiPbe<_vE>J`v#%BZyPXSLu^ydb-R{!uB~Q^YAT$KLQYqFcCOJ@I`&oy zj}J#Hj^^$U;WTsoNy(@q1=|g*B7d8a4@fiX$E$Gw~ZqnUZ|`o7=9Y{gJuw8|MQf9uZWrYu^zq)nKWz-^dCNeZi1)t~ zuzT#6FW^VCK99HVuJ{0@eCEBnT!Bv=Tu9dM`{UzzD9LWez`_sQ5-r`WBDy~Xfl7)4 zzU-~iBiA#Xr%=&DAN4mWH-K@}Xd5L=hGy+UXJ1`9aMQ4hrqHJlH>b)kc znK^2u8u!@In!cnBAtGe{=cZ6iQj;n8`X)4lDD5YHCVS7P^qkKzO7-;JEk3Kb9W5u$xhuEiMxWb?}ykLi`pdQ@KF)O!GE=FSH--&<2e^R zh2#RO$DQNj<{NlFkh$pE^y>o8^B@ne*)dHY5W&~oky2)a&pB>oh{sLV(v67}=msSQ zHL&I!@5-V2+*4>i(YlpvN9vn+sbx71sUd<@CcIXkjT@v?CCRRKW;;agt!}hRyWxy3OdY zUVnwRT6|%um?F9pEwOkUwev!RS)#sZg2%GVnaF&$6w}InyTQ_jqeC+o@~pW>2^PEa zgFkAz7K-q0`7wp`?ZR8#4_IeGI9!LNalnSfk&E!_?auB~s@0CYMUo!Mj8#dW{4fe2 zYZ7{_uolJ{g^y0};~@HiEOytBe_AIqYrj8(tLyH?eC2EJ2s;Y-MIgO1mPHq^s~d{( z>68X80oY9&j5E1WR+M1zAJwrGt%tk>H}9;Vd;j91xP=)EjKiLF5LxmMfR-=t zicBnTY7d)HATFT!IKt#kwq?h9b;N)4=z^umw0Q$r#q9pzas$b|H}iS)ZRyD0?|U5Z zPlO-1b`0C10OHw7lv^-<*N>N7Ev25d^xO%}G&|fY?~6s)z&7ML89>r-{K^VIPX;=w zD$`^D*L!`NftANaX`-KQ?Km$V^?4R-xMQFD{iPWSgj()lEEMZLeZJfOsckScWzDbf zX~Yx3so`Vnm!Sorsn}y!SN+#Xrpx)qYhJ(Z7e3DV&Q@*tjJsZ~pQixyWySA0;R)0* z5`eJFoitQhCQpaQT>JI?H2@Ng^t{?!n@AaS7*)ICsZ1r~PtH$oJCB)50{(i+?~M}> zxNtM0d`ThFzDiM(DP#JUmIjIZDc>uPsVTTbOB&$82PBVJ0|MN8OGk#%iiHxI>hF$X zZSNi`NG1Fm*8sQn27ft8dPUYu3D#cElL{dR2`1?;6X`K5qzx3{8S&O%4a&9E?Z}hp z!})e;_zwg%mg$8{P0(01;xaJk)>rUxHtC?5Vd@aFOj~U_=l^p+P`_O(aq<1P)uE#& z472wxu;DJauE8}AfF}dnyB{uFob@cVR>dEGXmsblz7!FX_P=GtFg@CANAv#AWE+$a zFt?hVa?FgB`C7bL6txp@arN|z{^G0O@CvbvQ)2JwgGXA>e;kKM#;>0(z%*{|;Ek09 zC(burSs>sM8#iz+-_RgjP0BAk-ZFidk&cjZW7tYQj=(pAKW~1*fa3M-lnu*c`R5jp zBre#KRV6|Zi|MT(dqc#Aq2pKl!79Q+f`=^eMI4kdj<52qqq;2;*Mpg@aWlZs!-%VL>Wk6|q7|=62RUhWWcL%{Yrh zW;|Jjm}*bSVavjsjvNq>J2Er&c@u99rg7&dKSf@r#qQzLGv|7bh;V0A#E{0~yww}% z@NSSbw~8cY=-Qxu{<#@_PwN@dQ{2}-Y~xW1wP0v*i}1F#<^H^BJsyFSwsv_W3p7Hc zPi#SMoPU7{L)}-yUA;{Ztt#`EJWp<i^c;!~ z5q#=rvitPpC?MS8=hj^1j@qLXdfcUX!96Dx!LOQd+Ti$5hPa*v`sHL#8`hi?_qROg zrj=NlygHF@`0hhu)U`>n7VHL{__yz@emn77fGJ*+h`lS(He_s6Bt?Cvy%vF;z>N-u z1IZ+w8d5+37aKR}*qniL>g#FPQq8`w$CCvfDn(`LsdSZyISNTCzOI&mvmQAcpq-Cv z67&&Ban`o|#P}5Z$&(4WX_0+#*hcy6<_s*}gMoBP1Ra>WJuXk*p57=Fg6_|#Z|5U>t*9Q_9PD(XeO^H;KuhdSxz1;*; zxtLf)=3|O33&Q*xqj>WW(4zThBy}`fV0pQ^!fP^c13aKT{l9N5VZ$&1t8SA()$h5y z*5hAP&Ro>oQzSz?jHtGw2rr0n8zj4v-1pZWa0e3M%Th>jAOW=)F!xpSe{7Gx#*+~@ z;!{VTN9EU*EDT8QzCR-KPvKg$MV~h-c51GJA1fY1g72HVci^N2mcgmgge?DszoKN0 ztywi!x6%@@$J5n&?#By+l&!H)&M3>>_ux}Xz+%=1ya2(^&e-K|1eI>X-89$&%@iP^ zE&aaowggo&4QTdUzkSax4zJ1Tlf!Xta3bi2`C018r&C{|^$~m-!~aO~nhjv?RM^Gx zNhCyRPj^@1UXbcS`>?6QvZ~yS2!*{CU?cYBnKTS18rBZC2!Zl#;egn%jJUh$F z47GNIG_4@X8ww<`3H&G_mo#uH@5LVmBdu)#qcdBMv%_Lz4d{po29(pz_|OPZv!K$z zb?1)Y+-9waKB;!KAwHuX`rtIX|TnvioR~7X)`5a>?dO%KRWxb*tQqMFpcYl2*psu6O~SFcJ+V zXE|_(#I$-O>i3POsX77^ua%=1I07tGS=l8FL(@Y6uU=l*K(%nY?XkPAs>!eJ!HhY3UKcS-Odczjeb>-swV*rL%W`mL8@$=Zov4q9R`Zk=g z?G5p(q?egNJiyf4akuO&J}w_G3YNP%r%a5(DYwsgSQ@Nl-V=Fxuu?5`KY zB`%Pglavx?vD^eP=`XxAxS2Jx2H8RRx5A|`{N$ZQ`*tJf&$*~!(#uCb1kQ#eQcYcE zqT5+B0B*3J45bH0If3P8WM}BaV?D&_<7H8hSTyWZI(}X(WbtY?td^RZHbmATo_S5q zWpK1ltQox2_G2t!|47p-Slq3xr6vorZwbM(tXY0jk&X@%BrtwfDD;;IG|(ggoJp8E zKerHIR~WUr;n!2~2? z^cJULY|8Hk8`rbx*!N8BOUy5{JB%k>Z##n>#lhTE>ap|lx7NeRF+AL}Fn4^zyA{00 z&ZC1UTmR0L4>SXFJn4X)%h&}0>x3-EG~*J;O>{Db`JnHtuhS)^MkoCq5NqJut(Ivd zcJbBlxsP!P`t)v@PF>x8Gt3R@g=Ptk!@woHpC~OxUq@x0XC*&l!W} zgpTLYh(1J^}qktd|o%pG{Q^YOv{8&Px; zjBd(QUwo_}kzYzCvW)DLWIq0(7%s8j%lUq4w();4^_4+!ZQIrf0YY#K?h+ulyGw9) z2n2Vx1{w%XXx!Z)!Ciy9O9<}TxVwJ)+;`9W-Y$wNegIuvYt5c>%psbZumjjM0yw1G z^zEm$0Hq~22&$VAWlz-g_8n~3bp*&q?6eBOVJJx4dxUHb`Ev61LjOx_#7}F!v~lB% z9==dF5@tBDVmrpNOCGd-K0X`AtaWsX+a=bpUt!KkD>4Y+Y!cg!=Y{$X^jvxfh)@nc z=$oo+L4swmvY37L1c8546gNRMW|ltvumFTf?lZ!&BZx|j7SOvFomfWx

W9?CH*>_%yPZ{k8O4 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�MPu05skFoM82lDixY)6HBh~ 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

  • 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

    9*(%Yk%I)Ecpmd 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