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/.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 a448c6c3..251e6c95 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,5 +1,71 @@
# Changelog
+## Version 6.0.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 gifski GIF encoder support with automatic PATH detection and manual path configuration
+* Adding external subtitle file support - import .srt, .ass, .ssa, .vtt, and .sup files into the subtitle track list
+* Adding AVC (x264) encoder options: AQ Mode, Psy-RD, Level, and custom x264 params
+* 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 video info bar below file area showing bit depth, color space, chroma subsampling, HDR10, and HDR10+ at a glance
+* Adding HDR10+ passthrough awareness for AOM AV1 encoder with FFmpeg 8.0+ version detection and UI indicators
+* Adding HDR10+ tool auto-download on Windows startup and Help menu
+* Adding AOM AV1 encoder options: Tune (psnr/ssim), Denoise, AQ Mode, allintra usage mode, and custom aom-params pass-through
+* Adding rav1e encoder options: Tune (Psychovisual/Psnr), Photon Noise (film grain synthesis), Scene Detection toggle, HDR10 metadata passthrough, and custom rav1e-params pass-through
+* Adding Dolby Vision copy support for NVEncC, QSVEncC, and VCEEncC HEVC/AV1 encoders
+* Adding preview time slider to overlay at bottom of preview image
+* Adding time display next to preview slider in H:MM:SS format
+* 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
+* Adding save dialog for subtitle extraction allowing users to choose output location, defaulting to the output directory
+* Adding VVC encoder options: Perceptual QPA toggle, Intra Period, Threads, IFP (inter-frame parallelism), 8-bit pixel format support, and HDR10 metadata passthrough (mastering display, content light level, color signaling, chroma location)
+* Adding VP9 encoder options: Auto Alt-Ref Frames, Lag in Frames, Tune Content, AQ Mode, and Sharpness
+* 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 encoding incorrectly reported as successful when FFmpeg fails immediately (e.g. VAAPI on Windows) due to race condition between worker and output reader thread
+* Fixing copy encoder failing when OpenCL is enabled by not adding hardware device flags when filters are disabled
+* Fixing zscale "no path between colorspaces" error during HDR tonemapping by explicitly specifying input transfer characteristic
+* Fixing thumbnail generation failing on non-full-range YUV sources (e.g. HDR AV1) with FFmpeg 8.0+ by adding -strict unofficial to mjpeg encoder
+* Fixing FFmpeg download failing when destination directories (e.g. presets) already exist from a previous install
+* Fixing HDR10+ detection to use ffprobe for non-HEVC codecs (AV1, VP9, etc.) and as fallback when hdr10plus_tool is unavailable
+* 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 rav1e and AOM AV1 custom params list being corrupted to "[]" string on profile load/reload, breaking HDR10+ metadata passthrough
+* 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
+* 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
+* 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
+* 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)
@@ -10,7 +76,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
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..693e83a8
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,113 @@
+# 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
+
+## 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
+- `develop`: Development branch (PRs merge here)
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_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..00d47fff 100644
--- a/README.md
+++ b/README.md
@@ -16,21 +16,22 @@ Check out [the FastFlix github wiki](https://github.com/cdgriffith/FastFlix/wiki
| Encoder | x265 | x264 | rav1e | AOM AV1 | SVT AV1 | VP9 | VVC |
|-----------|------|------|-------|---------|---------|-----|-----|
-| HDR10 | ✓ | | | | ✓ | ✓* | |
+| HDR10 | ✓ | | ✓ | ✓ | ✓ | ✓* | ✓ |
| HDR10+ | ✓ | | | | | | |
-| Audio | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
-| Subtitles | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
-| Covers | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
-| bt.2020 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
+| Audio | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
+| Subtitles | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
+| Covers | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
+| bt.2020 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
If one of the above software encoders is not listed, it is due to your version of FFmpeg not having that encoder compiled in.
## 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,7 +106,7 @@ 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
@@ -97,14 +120,14 @@ FastFlix was created to easily extract / copy HDR10 data, which it can do with t
VP9 has limited support to copy some existing HDR10 metadata, usually from other VP9 files. Will have the line "Mastering Display Metadata, has_primaries:1 has_luminance:1 ..." when it works.
-AV1 is still in development, and hopefully all encoder will support it in the future, but only SVT AV1 works through ffmpeg as of now for software encoders.
+AV1 HDR10 support varies by encoder:
-* QSVEnc - Works!
+* QSVEnc - Works!
* NVEncC - Works!
* VCEEncC - Works!
-* rav1e - can set mastering data and CLL via their CLI but [not through ffmpeg](https://github.com/xiph/rav1e/issues/2554).
-* SVT AV1 - Now supports HDR10 with latest master ffmpeg build, make sure to update before trying!
-* aomenc (libaom-av1) - does not look to support HDR10
+* rav1e - Works! Mastering display and content light level passed via rav1e-params.
+* SVT AV1 - Works! Supports HDR10 with latest ffmpeg build.
+* aomenc (libaom-av1) - HDR10 color metadata (bt.2020/PQ) is passed through, but mastering display and content light level rely on FFmpeg's automatic side data passthrough.
## HDR10+
@@ -122,7 +145,7 @@ FastFlix (v4.0.2+) passes through HLG color transfer information to everything e
## Dolby Vision
-FastFlix does not plan to support Dolby Vision's proprietary format at this time.
+FastFlix v6.0.0+ supports copying existing Dolby Vision metadata from the input video for HEVC and AV1 videos used with rigaya's hardware encoders.
# Multilingual Support
@@ -137,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/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/docs/gui_preview.png b/docs/gui_preview.png
index 57c14d41..c84fe8f4 100644
Binary files a/docs/gui_preview.png and b/docs/gui_preview.png differ
diff --git a/fastflix/__main__.py b/fastflix/__main__.py
index bffdf715..e616c929 100644
--- a/fastflix/__main__.py
+++ b/fastflix/__main__.py
@@ -1,11 +1,40 @@
# -*- coding: utf-8 -*-
+import os
import sys
import traceback
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
+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 +46,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/application.py b/fastflix/application.py
index 7acd95c1..b98c7bb0 100644
--- a/fastflix/application.py
+++ b/fastflix/application.py
@@ -12,7 +12,7 @@
from fastflix.models.config import Config, MissingFF
from fastflix.models.fastflix import FastFlix
from fastflix.models.fastflix_app import FastFlixApp
-from fastflix.program_downloads import ask_for_ffmpeg, grab_stable_ffmpeg
+from fastflix.program_downloads import ask_for_ffmpeg, grab_stable_ffmpeg, download_hdr10plus_tool
from fastflix.resources import main_icon, breeze_styles_path
from fastflix.shared import file_date, message, latest_fastflix, DEVMODE, yes_no_message
from fastflix.widgets.container import Container
@@ -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)
@@ -36,9 +41,17 @@ 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))
+ 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
@@ -66,6 +79,7 @@ def init_encoders(app: FastFlixApp, **_):
from fastflix.encoders.avc_x264 import main as avc_plugin
from fastflix.encoders.copy import main as copy_plugin
from fastflix.encoders.gif import main as gif_plugin
+ from fastflix.encoders.gifski import main as gifski_plugin
from fastflix.encoders.ffmpeg_hevc_nvenc import main as nvenc_plugin
from fastflix.encoders.hevc_x265 import main as hevc_plugin
from fastflix.encoders.rav1e import main as rav1e_plugin
@@ -113,6 +127,9 @@ def init_encoders(app: FastFlixApp, **_):
modify_plugin,
]
+ if DEVMODE or app.fastflix.config.gifski:
+ encoders.insert(encoders.index(gif_plugin) + 1, gifski_plugin)
+
if DEVMODE:
encoders.insert(1, qsvencc_plugin)
encoders.insert(encoders.index(av1_plugin), qsvencc_av1_plugin)
@@ -164,14 +181,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
@@ -239,6 +254,27 @@ def app_setup(
app, [Task(name=t("Detect GPUs"), command=automatic_rigaya_download)], signal_task=True, can_cancel=True
)
+ if app.fastflix.config.auto_hdr10plus_check is None and not app.fastflix.config.hdr10plus_parser:
+ app.fastflix.config.auto_hdr10plus_check = yes_no_message(
+ t(
+ "HDR10+ tool not found. Do you want FastFlix to automatically download it?\n\nThis tool is used for extracting and injecting HDR10+ dynamic metadata during encoding."
+ ),
+ title="Download HDR10+ Tool",
+ )
+ if app.fastflix.config.auto_hdr10plus_check:
+ try:
+ ProgressBar(
+ app,
+ [Task(t("Downloading HDR10+ Tool"), download_hdr10plus_tool)],
+ signal_task=True,
+ can_cancel=True,
+ )
+ from fastflix.models.config import find_hdr10plus_tool
+
+ app.fastflix.config.hdr10plus_parser = find_hdr10plus_tool()
+ except Exception:
+ logger.exception("Failed to download HDR10+ tool")
+
app.fastflix.config.save()
startup_tasks = [
@@ -263,20 +299,18 @@ 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
-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 7a7273f5..98a63c39 100644
--- a/fastflix/command_runner.py
+++ b/fastflix/command_runner.py
@@ -6,9 +6,11 @@
import shlex
import time
from pathlib import Path
+from queue import Full
from subprocess import PIPE
from threading import Thread
from typing import Literal
+import sys
from psutil import Popen
@@ -23,12 +25,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 = {
@@ -72,13 +74,23 @@ 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")
+ 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=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",
)
@@ -88,16 +100,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"]
@@ -109,6 +130,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,
@@ -119,18 +147,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:
@@ -139,8 +167,13 @@ def read_output(self):
err_line = err_file.readline().rstrip()
if err_line:
logger.info(err_line)
- self.log_queue.put(err_line)
- if "Conversion failed!" in err_line or "Error during output" in err_line:
+ self._safe_log_put(err_line)
+ if (
+ "Conversion failed!" in err_line
+ or "Error during output" in err_line
+ or "Error parsing global options" in err_line
+ or "Device creation failed" in err_line
+ ):
self.error_detected = True
if not self.error_detected:
for error in self.error_message:
diff --git a/fastflix/conversion_worker.py b/fastflix/conversion_worker.py
index fbd92c1a..44f042a1 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
@@ -32,6 +32,7 @@ def queue_worker(gui_proc, worker_queue, status_queue, log_queue):
command = None
work_dir = None
log_name = ""
+ shell = False
priority: Literal["Realtime", "High", "Above Normal", "Normal", "Below Normal", "Idle"] = "Normal"
def start_command():
@@ -49,16 +50,27 @@ def start_command():
runner.start_exec(
command,
work_dir=work_dir,
+ shell=shell,
)
runner.change_priority(priority)
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:
+ # Check error_detected (set by read_output thread) AND check the
+ # process return code directly. The read_output daemon thread may
+ # not have run yet when FFmpeg exits very quickly (e.g. VAAPI init
+ # failure on Windows), so we must not rely solely on error_detected.
+ process_failed = (
+ runner.process is not None and runner.process.returncode is not None and runner.process.returncode > 0
+ )
+ if runner.error_detected or process_failed:
logger.info(t("Error detected while converting"))
status_queue.put(("error", video_uuid, command_uuid))
@@ -87,7 +99,7 @@ def start_command():
return
else:
if request[0] == "execute":
- _, video_uuid, command_uuid, command, work_dir, log_name = request
+ _, video_uuid, command_uuid, command, work_dir, log_name, shell = request
start_command()
if request[0] == "cancel":
@@ -95,7 +107,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 +130,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/data/encoders/icon_gifski.png b/fastflix/data/encoders/icon_gifski.png
new file mode 100644
index 00000000..69837901
Binary files /dev/null and b/fastflix/data/encoders/icon_gifski.png differ
diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml
index 11e4a6db..b4a6a1a9 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:
@@ -5421,6 +5203,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.
@@ -5440,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: シングルフレームスレッドを使用すると、圧縮率が少し向上します。
@@ -5667,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
@@ -5796,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:
@@ -5938,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- 적응 룩어헤드가 수행하는 작업량입니다.
@@ -6048,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
@@ -6081,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
@@ -6286,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
@@ -6302,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.'
@@ -6372,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
@@ -6439,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
@@ -6481,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
@@ -6583,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.
@@ -6613,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.
@@ -6652,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 - должен соответствовать битовой глубине'
@@ -6664,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
@@ -6697,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
@@ -6774,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-
@@ -6827,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: 特定の種類のソースや状況に合わせて設定をチューニングする
@@ -6889,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.
@@ -7217,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':
@@ -7422,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
@@ -7547,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
@@ -8030,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
@@ -8133,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
@@ -8163,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
@@ -8204,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
@@ -8473,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: Для этого профиля не подобраны звуковые дорожки, включить первую дорожку?
@@ -8545,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
@@ -8804,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: Шкала інтерфейсу
@@ -8902,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:'
@@ -8958,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
@@ -9115,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
@@ -9148,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
@@ -9187,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
@@ -9659,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)
@@ -9779,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
@@ -10702,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!
@@ -10880,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: のすべてのストリームに対してオーディオ正規化処理を実行します。
@@ -10923,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
@@ -10974,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
@@ -11178,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)이므로 추출 건너뛰기
@@ -11272,3 +10698,1743 @@ 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
+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
+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
+Video Resolution:
+ eng: Video Resolution
+ deu: Video-Auflösung
+ fra: Résolution vidéo
+ ita: Risoluzione video
+ spa: Resolución de vídeo
+ jpn: ビデオ解像度
+ rus: Разрешение видео
+ por: Resolução de vídeo
+ swe: Videoupplösning
+ pol: Rozdzielczość wideo
+ chs: 视频分辨率
+ ukr: Роздільна здатність відео
+ kor: 비디오 해상도
+ ron: Rezoluție video
+Output Resolution:
+ eng: Output Resolution
+ deu: Ausgang Auflösung
+ fra: Résolution de sortie
+ ita: Risoluzione di uscita
+ spa: Resolución de salida
+ jpn: 出力解像度
+ rus: Выходное разрешение
+ por: Resolução de saída
+ swe: Utgångsupplösning
+ pol: Rozdzielczość wyjściowa
+ chs: 输出分辨率
+ ukr: Вихідна роздільна здатність
+ kor: 출력 해상도
+ ron: Rezoluția de ieșire
+Set start time from preview position:
+ eng: Set start time from preview position
+ deu: Startzeit von der Vorschauposition aus festlegen
+ fra: Régler l'heure de début à partir de la position de prévisualisation
+ ita: Impostare l'ora di inizio dalla posizione di anteprima
+ spa: Fijar la hora de inicio desde la posición de vista previa
+ jpn: プレビュー位置から開始時間を設定
+ rus: Установите время начала из позиции предварительного просмотра
+ por: Definir a hora de início a partir da posição de pré-visualização
+ swe: Ställ in starttid från förhandsgranskningsposition
+ pol: Ustaw czas rozpoczęcia z pozycji podglądu
+ chs: 从预览位置设置启动时间
+ ukr: Встановіть час запуску з позиції попереднього перегляду
+ kor: 미리보기 위치에서 시작 시간 설정
+ ron: Setați ora de începere din poziția de previzualizare
+Set end time from preview position:
+ eng: Set end time from preview position
+ deu: Endzeit von der Vorschauposition aus einstellen
+ fra: Définir l'heure de fin à partir de la position de prévisualisation
+ ita: Impostare l'ora di fine dalla posizione di anteprima
+ spa: Fijar la hora de finalización desde la posición de vista previa
+ jpn: プレビュー位置から終了時間を設定
+ rus: Установите время окончания из позиции предварительного просмотра
+ por: Definir a hora de fim a partir da posição de pré-visualização
+ swe: Ställ in sluttid från förhandsgranskningsposition
+ pol: Ustawianie czasu zakończenia z pozycji podglądu
+ chs: 从预览位置设置结束时间
+ ukr: Встановити час завершення з позиції попереднього перегляду
+ kor: 미리보기 위치에서 종료 시간 설정
+ ron: Setați ora finală din poziția de previzualizare
+Large Preview:
+ eng: Large Preview
+ deu: Große Vorschau
+ fra: Grand aperçu
+ ita: Anteprima grande
+ spa: Vista previa grande
+ jpn: 大きなプレビュー
+ rus: Большой предварительный просмотр
+ por: Pré-visualização grande
+ swe: Stor förhandsgranskning
+ pol: Duży podgląd
+ chs: 大型预览
+ ukr: Великий попередній перегляд
+ kor: 큰 미리보기
+ ron: Previzualizare mare
+Fast Decode:
+ eng: Fast Decode
+ deu: Schnelles Dekodieren
+ fra: Décodage rapide
+ ita: Decodifica rapida
+ spa: Descodificación rápida
+ jpn: 高速デコード
+ rus: Быстрое декодирование
+ por: Descodificação rápida
+ swe: Snabb avkodning
+ pol: Szybkie dekodowanie
+ chs: 快速解码
+ ukr: Швидке декодування
+ kor: 빠른 디코딩
+ ron: Decodare rapidă
+Tune settings for faster decoding at the cost of quality:
+ eng: Tune settings for faster decoding at the cost of quality
+ deu: Einstellungen für eine schnellere Dekodierung auf Kosten der Qualität abstimmen
+ fra: Régler les paramètres pour un décodage plus rapide au détriment de la qualité
+ ita: Regolazione delle impostazioni per una decodifica più veloce a scapito della qualità
+ spa: Ajuste la configuración para una descodificación más rápida a costa de la calidad
+ jpn: 品質を犠牲にしながらも、より高速なデコードのために設定を調整する
+ rus: Настройте параметры для ускорения декодирования за счет снижения качества
+ por: Ajuste as definições para uma descodificação mais rápida à custa da qualidade
+ swe: Justera inställningarna för snabbare avkodning på bekostnad av kvaliteten
+ pol: Dostosowanie ustawień w celu szybszego dekodowania kosztem jakości
+ chs: 调整设置,加快解码速度,降低解码质量
+ ukr: Налаштуйте параметри для швидшого декодування ціною якості
+ kor: 품질을 희생하면서 더 빠른 디코딩을 위한 설정 조정
+ ron: Reglați setările pentru o decodare mai rapidă în detrimentul calității
+Film Grain:
+ eng: Film Grain
+ deu: Filmkorn
+ fra: Grain de pellicule
+ ita: Grana del film
+ spa: Grano de película
+ jpn: フィルムグレイン
+ rus: Зернистость пленки
+ por: Grão de película
+ swe: Filmkorn
+ pol: Ziarnistość filmu
+ chs: 胶片颗粒
+ ukr: Фільм Зерно
+ kor: 필름 그레인
+ ron: Granulația filmului
+Film grain synthesis level (0=off, higher=more grain):
+ eng: Film grain synthesis level (0=off, higher=more grain)
+ deu: Filmkornsynthesestufe (0=aus, höher=mehr Korn)
+ fra: Niveau de synthèse du grain du film (0=désactivé, plus élevé=plus de grain)
+ ita: Livello di sintesi della grana del film (0=spento, più alto=più grana)
+ spa: Nivel de síntesis del grano de la película (0=desactivado, mayor=más grano)
+ jpn: フィルムグレイン合成レベル(0=オフ、高いほどグレインが多い)
+ rus: Уровень синтеза зернистости пленки (0=выключено, выше=больше зернистости)
+ por: Nível de síntese do grão do filme (0=desligado, mais alto=mais grão)
+ swe: Nivå för filmkornsyntes (0=av, högre=mer korn)
+ pol: Poziom syntezy ziarnistości filmu (0=wyłączony, wyższy=więcej ziarna)
+ chs: 胶片颗粒合成级别(0= 关闭,更高= 颗粒更多)
+ ukr: Рівень синтезу зернистості плівки (0=вимкнено, вище=більше зернистості)
+ kor: 필름 그레인 합성 수준(0=꺼짐, 높음=더 많은 그레인)
+ ron: Nivelul de sinteză a granulației filmului (0 = oprit, mai mare = mai multă granulație)
+Custom film grain value (0-50):
+ eng: Custom film grain value (0-50)
+ deu: Benutzerdefinierter Filmkornwert (0-50)
+ fra: Valeur de grain de film personnalisée (0-50)
+ ita: Valore di grana della pellicola personalizzato (0-50)
+ spa: Valor de grano de película personalizado (0-50)
+ jpn: カスタムフィルムグレイン値(0-50)
+ rus: Пользовательское значение зернистости пленки (0-50)
+ por: Valor de grão de filme personalizado (0-50)
+ swe: Anpassat värde för filmkorn (0-50)
+ pol: Niestandardowa wartość ziarnistości filmu (0-50)
+ chs: 自定义胶片颗粒值(0-50)
+ ukr: Індивідуальне значення зернистості плівки (0-50)
+ kor: 사용자 지정 필름 입자 값(0-50)
+ ron: Valoare personalizată a granulației filmului (0-50)
+Film Grain Denoise:
+ eng: Film Grain Denoise
+ deu: Filmkorn-Entrauschen
+ fra: Dénoisement du grain de film
+ ita: Denoise della grana del film
+ spa: Retoque de grano de película
+ jpn: フィルムグレインデノイズ
+ rus: Обесцвечивание зернистости пленки
+ por: Denoise de grãos de filme
+ swe: Filmkorn Denoise
+ pol: Odszumianie ziarna filmowego
+ chs: 胶片颗粒去噪
+ ukr: Фільм Зерно Деніз
+ kor: 필름 그레인 노이즈 제거
+ ron: Denozarea granulelor de film
+Apply denoising when film grain is enabled:
+ eng: Apply denoising when film grain is enabled
+ deu: Rauschunterdrückung anwenden, wenn Filmkorn aktiviert ist
+ fra: Appliquer le débruitage lorsque le grain de film est activé
+ ita: Applicare il denoising quando è abilitata la grana della pellicola
+ spa: Aplicar eliminación de ruido cuando el grano de película está activado
+ jpn: フィルムグレインが有効な場合にノイズ除去を適用する
+ rus: Применяйте ослабление, если включено зерно пленки
+ por: Aplicar a redução de ruído quando a granulação da película está activada
+ swe: Tillämpa denoising när filmkorn är aktiverat
+ pol: Zastosuj odszumianie, gdy ziarno filmu jest włączone
+ chs: 启用胶片颗粒时应用去噪功能
+ ukr: Застосувати денозацію, якщо ввімкнено зернистість плівки
+ kor: 필름 그레인이 활성화된 경우 노이즈 제거 적용
+ ron: Aplicați denoising atunci când granulația filmului este activată
+Application Locations:
+ eng: Application Locations
+ deu: Einsatzorte
+ fra: Lieux d'application
+ ita: Luoghi di applicazione
+ spa: Lugares de aplicación
+ jpn: 申請場所
+ rus: Места применения
+ por: Locais de aplicação
+ swe: Applikationsplatser
+ pol: Lokalizacje aplikacji
+ chs: 申请地点
+ ukr: Місця застосування
+ kor: 애플리케이션 위치
+ ron: Locații de aplicare
+Add External:
+ eng: Add External
+ deu: Extern hinzufügen
+ fra: Ajouter un élément externe
+ ita: Aggiungi esterno
+ spa: Añadir externo
+ jpn: 外部追加
+ rus: Добавить внешний
+ por: Adicionar externo
+ swe: Lägg till extern
+ pol: Dodaj zewnętrzne
+ chs: 添加外部
+ ukr: Додати зовнішній
+ kor: 외부 추가
+ ron: Adăugați extern
+Optimize encoding for different quality metrics:
+ eng: Optimize encoding for different quality metrics
+ deu: Optimierung der Kodierung für verschiedene Qualitätsmetriken
+ fra: Optimiser l'encodage pour différentes mesures de qualité
+ ita: Ottimizzare la codifica per diverse metriche di qualità
+ spa: Optimizar la codificación para distintas métricas de calidad
+ jpn: さまざまな品質メトリクスに対してエンコーディングを最適化する
+ rus: Оптимизация кодирования для различных показателей качества
+ por: Otimizar a codificação para diferentes métricas de qualidade
+ swe: Optimera kodningen för olika kvalitetsmått
+ pol: Optymalizacja kodowania pod kątem różnych wskaźników jakości
+ chs: 针对不同质量指标优化编码
+ ukr: Оптимізуйте кодування для різних показників якості
+ kor: 다양한 품질 지표에 맞게 인코딩 최적화
+ ron: Optimizarea codării pentru diferite metrici de calitate
+AQ Mode:
+ eng: AQ Mode
+ deu: AQ-Modus
+ fra: Mode AQ
+ ita: Modalità AQ
+ spa: Modo AQ
+ jpn: AQモード
+ rus: Режим AQ
+ por: Modo AQ
+ swe: AQ-läge
+ pol: Tryb AQ
+ chs: AQ 模式
+ ukr: Режим AQ
+ kor: AQ 모드
+ ron: Modul AQ
+Adaptive quantization mode for quality distribution:
+ eng: Adaptive quantization mode for quality distribution
+ deu: Adaptiver Quantisierungsmodus für die Qualitätsverteilung
+ fra: Mode de quantification adaptatif pour la distribution de la qualité
+ ita: Modalità di quantizzazione adattiva per la distribuzione della qualità
+ spa: Modo de cuantificación adaptativo para la distribución de la calidad
+ jpn: 品質分布のための適応的量子化モード
+ rus: Адаптивный режим квантования для распределения качества
+ por: Modo de quantização adaptável para distribuição de qualidade
+ swe: Adaptivt kvantiseringsläge för kvalitetsfördelning
+ pol: Adaptacyjny tryb kwantyzacji dla dystrybucji jakości
+ chs: 质量分布的自适应量化模式
+ ukr: Адаптивний режим квантування для розподілу якості
+ kor: 품질 분배를 위한 적응형 양자화 모드
+ ron: Mod de cuantizare adaptiv pentru distribuția calității
+Noise removal amount (0=off, higher=more denoising):
+ eng: Noise removal amount (0=off, higher=more denoising)
+ deu: Rauschentfernungsgrad (0=aus, höher=mehr Rauschentfernung)
+ fra: Niveau de suppression du bruit (0=off, plus élevé=plus de débruitage)
+ ita: Quantità di rimozione del rumore (0=spento, più alto=più denoising)
+ spa: Cantidad de eliminación de ruido (0=no, mayor=mayor eliminación de ruido)
+ jpn: ノイズ除去量(0=オフ、高いほどノイズ除去量が多い)
+ rus: Количество удаляемых шумов (0=отключено, больше=больше шумов)
+ por: Quantidade de remoção de ruído (0=desligado, mais elevado=mais redução de ruído)
+ swe: Antal brusborttagningar (0=av, högre=mer brusborttagning)
+ pol: Ilość usuwanego szumu (0=wyłączone, wyższa=więcej odszumiania)
+ chs: 去噪量(0= 关闭,越高去噪量越大)
+ ukr: Рівень шумозаглушення (0=вимкнено, вище=більше шумозаглушення)
+ kor: 노이즈 제거량(0=끄기, 높음=노이즈 제거량 증가)
+ ron: Valoarea de eliminare a zgomotului (0 = dezactivat, mai mare = mai multă denotare)
+Custom denoise value (0-50):
+ eng: Custom denoise value (0-50)
+ deu: Benutzerdefinierter Entrauschungswert (0-50)
+ fra: Valeur de débruitage personnalisée (0-50)
+ ita: Valore di denoise personalizzato (0-50)
+ spa: Valor de eliminación de ruido personalizado (0-50)
+ jpn: カスタムノイズ除去値 (0-50)
+ rus: Пользовательское значение денуазиса (0-50)
+ por: Valor de redução de ruído personalizado (0-50)
+ swe: Anpassat värde för denoise (0-50)
+ pol: Niestandardowa wartość odszumiania (0-50)
+ chs: 自定义去噪值(0-50)
+ ukr: Користувацьке значення шумозаглушення (0-50)
+ kor: 사용자 지정 노이즈 제거 값(0-50)
+ ron: Valoare denoise personalizată (0-50)
+Additional aom params:
+ eng: Additional aom params
+ deu: Zusätzliche aom-Parameter
+ fra: Paramètres supplémentaires de l'aom
+ ita: Parametri aggiuntivi di aom
+ spa: Parámetros aom adicionales
+ jpn: 追加のaomパラメータ
+ rus: Дополнительные параметры aom
+ por: Parâmetros aom adicionais
+ swe: Ytterligare aom-parametrar
+ pol: Dodatkowe parametry aom
+ chs: 附加的 aom 参数
+ ukr: Додаткові параметри aom
+ kor: 추가 aom 매개변수
+ ron: Parame aom suplimentare
+Extra aom params in opt=1:opt2=0 format:
+ eng: Extra aom params in opt=1:opt2=0 format
+ deu: Zusätzliche aom-Parameter im Format opt=1:opt2=0
+ fra: Paramètres aom supplémentaires au format opt=1:opt2=0
+ ita: Parametri aom extra nel formato opt=1:opt2=0
+ spa: Parámetros aom adicionales en formato opt=1:opt2=0
+ jpn: opt=1:opt2=0形式の追加aomパラメータ
+ rus: Дополнительные параметры aom в формате opt=1:opt2=0
+ por: Parâmetros aom extra no formato opt=1:opt2=0
+ swe: Extra aom-parametrar i formatet opt=1:opt2=0
+ pol: Dodatkowe parametry aom w formacie opt=1:opt2=0
+ chs: 额外的 aom 参数,格式为 opt=1:opt2=0
+ ukr: Додаткові параметри aom у форматі opt=1:opt2=0
+ kor: opt=1:opt2=0 형식의 추가 aom 매개변수
+ ron: Parame aom suplimentare în format opt=1:opt2=0
+Quality tuning metric (Psychovisual for perceptual quality, Psnr for objective quality):
+ eng: Quality tuning metric (Psychovisual for perceptual quality, Psnr for objective quality)
+ deu: Metrik zur Qualitätsverbesserung (Psychovisual für Wahrnehmungsqualität, Psnr für objektive Qualität)
+ fra: Mesure d'ajustement de la qualité (Psychovisual pour la qualité perceptuelle, Psnr pour la qualité objective)
+ ita: Metrica di regolazione della qualità (Psychovisual per la qualità percettiva, Psnr per la qualità oggettiva)
+ spa: Métrica de ajuste de la calidad (Psicovisual para la calidad perceptiva, Psnr para la calidad objetiva)
+ jpn: 品質調整指標(知覚的品質はPsychovisual、客観的品質はPsnr)
+ rus: Метрика настройки качества (Psychovisual для перцептивного качества, Psnr для объективного качества)
+ por: Métrica de afinação da qualidade (Psychovisual para a qualidade perceptiva, Psnr para a qualidade objetiva)
+ swe: Kvalitetsjusteringsmått (psykovisuell för perceptuell kvalitet, Psnr för objektiv kvalitet)
+ pol: Metryka dostrajania jakości (Psychovisual dla jakości percepcyjnej, Psnr dla jakości obiektywnej)
+ chs: 质量调整指标(Psychovisual 表示感知质量,Psnr 表示客观质量)
+ ukr: Метрика налаштування якості (Psychovisual для якості сприйняття, Psnr для об'єктивної якості)
+ kor: 품질 조정 지표(지각적 품질에 대한 심리 시각적, 객관적 품질에 대한 Psnr)
+ ron: Măsură de reglare a calității (Psychovisual pentru calitatea perceptivă, Psnr pentru calitatea obiectivă)
+Enable scene detection for better keyframe placement:
+ eng: Enable scene detection for better keyframe placement
+ deu: Aktivieren Sie die Szenenerkennung für eine bessere Keyframe-Platzierung
+ fra: Activation de la détection de scène pour un meilleur placement des images clés
+ ita: Abilita il rilevamento della scena per un migliore posizionamento dei fotogrammi chiave
+ spa: Activar la detección de escenas para una mejor colocación de los fotogramas clave
+ jpn: より良いキーフレーム配置のためにシーン検出を有効にする
+ rus: Включите функцию обнаружения сцены для лучшего размещения ключевых кадров
+ por: Ativar a deteção de cenas para uma melhor colocação de fotogramas-chave
+ swe: Aktivera scenavkänning för bättre placering av nyckelrutor
+ pol: Włącz wykrywanie scen w celu lepszego rozmieszczenia klatek kluczowych
+ chs: 启用场景检测,更好地放置关键帧
+ ukr: Увімкніть розпізнавання сцени для кращого розміщення ключових кадрів
+ kor: 더 나은 키프레임 배치를 위한 장면 감지 활성화
+ ron: Activați detectarea scenei pentru o mai bună plasare a cadrelor cheie
+Photon Noise:
+ eng: Photon Noise
+ deu: Photonenrauschen
+ fra: Bruit des photons
+ ita: Rumore dei fotoni
+ spa: Ruido de fotones
+ jpn: フォトンノイズ
+ rus: Фотонный шум
+ por: Ruído de fotões
+ swe: Fotonbrus
+ pol: Szum fotonowy
+ chs: 光子噪声
+ ukr: Фотонний шум
+ kor: 광자 노이즈
+ ron: Zgomotul fotonic
+Film grain synthesis strength (0=off, higher=more grain):
+ eng: Film grain synthesis strength (0=off, higher=more grain)
+ deu: Stärke der Filmkornsynthese (0=aus, höher=mehr Korn)
+ fra: Force de synthèse du grain du film (0=off, plus élevé=plus de grain)
+ ita: Forza di sintesi della grana del film (0=spento, più alto=più grana)
+ spa: Intensidad de la síntesis del grano de la película (0=desactivado, mayor=más grano)
+ jpn: フィルムグレイン合成強度(0=オフ、高い=グレインが多い)
+ rus: Сила синтеза зерна пленки (0=отключено, выше=больше зерна)
+ por: Força de síntese do grão da película (0=desligado, mais alto=mais grão)
+ swe: Syntesstyrka för filmkorn (0=av, högre=mer korn)
+ pol: Siła syntezy ziaren filmu (0=wyłączona, wyższa=więcej ziaren)
+ chs: 薄膜晶粒合成强度(0= 关闭,越高= 晶粒越多)
+ ukr: Сила синтезу зерен плівки (0=виключено, вище=більше зерен)
+ kor: 필름 입자 합성 강도(0=끄기, 높음=입자 더 많이)
+ ron: Puterea de sinteză a granulației filmului (0 = oprit, mai mare = mai multă granulație)
+Custom photon noise value (0-64):
+ eng: Custom photon noise value (0-64)
+ deu: Benutzerdefinierter Photonenrauschwert (0-64)
+ fra: Valeur personnalisée du bruit des photons (0-64)
+ ita: Valore personalizzato del rumore dei fotoni (0-64)
+ spa: Valor de ruido fotónico personalizado (0-64)
+ jpn: カスタムフォトンノイズ値(0~64)
+ rus: Пользовательское значение фотонного шума (0-64)
+ por: Valor personalizado do ruído de fotões (0-64)
+ swe: Anpassat värde för fotonbrus (0-64)
+ pol: Niestandardowa wartość szumu fotonów (0-64)
+ chs: 自定义光子噪声值(0-64)
+ ukr: Користувацьке значення фотонного шуму (0-64)
+ kor: 커스텀 광자 노이즈 값(0-64)
+ ron: Valoare personalizată a zgomotului fotonic (0-64)
+Additional rav1e params:
+ eng: Additional rav1e params
+ deu: Zusätzliche rav1e-Parameter
+ fra: Paramètres supplémentaires de rav1e
+ ita: Parametri aggiuntivi di rav1e
+ spa: Parámetros adicionales de rav1e
+ jpn: rav1e の追加パラメータ
+ rus: Дополнительные параметры rav1e
+ por: Parâmetros rav1e adicionais
+ swe: Ytterligare rav1e-param
+ pol: Dodatkowe parametry rav1e
+ chs: 附加 rav1e 参数
+ ukr: Додаткові параметри rav1e
+ kor: 추가 rav1e 매개변수
+ ron: Parame suplimentare rav1e
+Extra rav1e params in opt=1:opt2=0 format:
+ eng: Extra rav1e params in opt=1:opt2=0 format
+ deu: Zusätzliche rav1e-Parameter im Format opt=1:opt2=0
+ fra: Paramètres rav1e supplémentaires au format opt=1:opt2=0
+ ita: Parametri rav1e extra nel formato opt=1:opt2=0
+ spa: Parámetros rav1e adicionales en formato opt=1:opt2=0
+ jpn: opt=1:opt2=0形式のrav1eパラメータを追加。
+ rus: Дополнительные параметры rav1e в формате opt=1:opt2=0
+ por: Parâmetros rav1e extra no formato opt=1:opt2=0
+ swe: Extra rav1e-parametrar i formatet opt=1:opt2=0
+ pol: Dodatkowe parametry rav1e w formacie opt=1:opt2=0
+ chs: 额外的 rav1e 参数,格式为 opt=1:opt2=0
+ ukr: Додаткові параметри rav1e у форматі opt=1:opt2=0
+ kor: opt=1:opt2=0 형식의 추가 rav1e 매개변수
+ ron: Parame rav1e suplimentare în format opt=1:opt2=0
+Sharpness:
+ eng: Sharpness
+ deu: Schärfe
+ fra: Netteté
+ ita: Nitidezza
+ spa: Nitidez
+ jpn: シャープネス
+ rus: Резкость
+ por: Nitidez
+ swe: Skärpa
+ pol: Ostrość
+ chs: 锐度
+ ukr: Різкість
+ kor: 선명도
+ ron: Sharpness
+Deblocking loop filter sharpness (-7 to 7, 0=default):
+ eng: Deblocking loop filter sharpness (-7 to 7, 0=default)
+ deu: Schärfe des Deblocking-Loop-Filters (-7 bis 7, 0=Standard)
+ fra: Netteté du filtre de la boucle de déblocage (-7 à 7, 0=par défaut)
+ ita: Nitidezza del filtro del loop di deblocking (da -7 a 7, 0=default)
+ spa: Nitidez del filtro del bucle de desbloqueo (-7 a 7, 0=por defecto)
+ jpn: デブロッキング・ループ・フィルターのシャープネス(-7~7、0=デフォルト)
+ rus: Резкость фильтра петли деблокирования (от -7 до 7, 0=умолчанию)
+ por: Nitidez do filtro do laço de desbloqueio (-7 a 7, 0=predefinição)
+ swe: Skärpa på filter för deblockeringsslinga (-7 till 7, 0=standard)
+ pol: Ostrość filtra pętli odblokowującej (-7 do 7, 0=domyślnie)
+ chs: 去时钟环路滤波器锐度(-7 至 7,0=默认值)
+ ukr: Різкість фільтра циклу деблокування (від -7 до 7, 0=за замовчуванням)
+ kor: 차단 해제 루프 필터 선명도(-7~7, 0=기본값)
+ ron: Ascuțimea filtrului buclei de deblocare (de la -7 la 7, 0=default)
+"0: disabled":
+ eng: "0: disabled"
+ deu: '0: deaktiviert'
+ fra: '0 : désactivé'
+ ita: '0: disabilitato'
+ spa: '0: desactivado'
+ jpn: 0:無効
+ rus: '0: отключено'
+ por: '0: desativado'
+ swe: '0: inaktiverad'
+ pol: '0: wyłączony'
+ chs: '0: 禁用'
+ ukr: '0: вимкнено'
+ kor: '0: 사용 안 함'
+ ron: '0: dezactivat'
+"1: variance AQ (complexity mask)":
+ eng: "1: variance AQ (complexity mask)"
+ deu: '1: Varianz AQ (Komplexitätsmaske)'
+ fra: '1 : variance AQ (masque de complexité)'
+ ita: '1: varianza AQ (maschera di complessità)'
+ spa: '1: varianza AQ (máscara de complejidad)'
+ jpn: 1:分散AQ(複雑さマスク)
+ rus: '1: дисперсия AQ (маска сложности)'
+ por: '1: variância AQ (máscara de complexidade)'
+ swe: '1: varians AQ (komplexitetsmask)'
+ pol: '1: wariancja AQ (maska złożoności)'
+ chs: 1:方差 AQ(复杂性掩码)
+ ukr: '1: дисперсія AQ (маска складності)'
+ kor: '1: 분산 AQ(복잡도 마스크)'
+ ron: '1: variație AQ (mască de complexitate)'
+"2: autovariance AQ":
+ eng: "2: autovariance AQ"
+ deu: '2: Autovarianz AQ'
+ fra: '2 : autovariance AQ'
+ ita: '2: autovarianza AQ'
+ spa: '2: autovarianza AQ'
+ jpn: '2: 自己分散AQ'
+ rus: '2: автовариация AQ'
+ por: '2: autovariância AQ'
+ swe: '2: autovarians AQ'
+ pol: '2: autowariancja AQ'
+ chs: 2:自变量 AQ
+ ukr: '2: автоваріація AQ'
+ kor: '2: 자동 분산 AQ'
+ ron: '2: autovarianța AQ'
+"3: autovariance AQ with bias to dark scenes":
+ eng: "3: autovariance AQ with bias to dark scenes"
+ deu: '3: Autovarianz AQ mit Vorliebe für dunkle Szenen'
+ fra: "3 : QA d'autovariance avec biais pour les scènes sombres"
+ ita: '3: autovarianza AQ con polarizzazione sulle scene scure'
+ spa: '3: autovarianza AQ con sesgo a escenas oscuras'
+ jpn: '3: 暗いシーンに偏った自己分散AQ'
+ rus: '3: автовариация AQ со смещением в сторону темных сцен'
+ por: '3: AQ de autovariância com tendência para cenas escuras'
+ swe: '3: autovarians AQ med förskjutning till mörka scener'
+ pol: '3: Autowariancja AQ z tendencją do ciemnych scen'
+ chs: 3:偏向于黑暗场景的自方差 AQ
+ ukr: '3: автоваріація AQ зі зміщенням до темних сцен'
+ kor: '3: 어두운 장면에 편향된 오토바리언스 AQ'
+ ron: '3: autovarianță AQ cu prejudecăți pentru scenele întunecate'
+"Default: variance AQ":
+ eng: "Default: variance AQ"
+ deu: 'Standard: Varianz AQ'
+ fra: 'Valeur par défaut : variance AQ'
+ ita: 'Predefinito: varianza AQ'
+ spa: 'Por defecto: varianza AQ'
+ jpn: デフォルト:分散 AQ
+ rus: 'По умолчанию: дисперсия AQ'
+ por: 'Predefinição: variância AQ'
+ swe: 'Standard: varians AQ'
+ pol: 'Wartość domyślna: wariancja AQ'
+ chs: 默认值:方差 AQ
+ ukr: 'За замовчуванням: дисперсія AQ'
+ kor: '기본값: 분산 AQ'
+ ron: 'Implicit: variație AQ'
+Psy-RD:
+ eng: Psy-RD
+ deu: Psy-RD
+ fra: Psy-RD
+ ita: Psy-RD
+ spa: Psy-RD
+ jpn: サイRD
+ rus: Psy-RD
+ por: Psy-RD
+ swe: Psy-RD
+ pol: Psy-RD
+ chs: Psy-RD
+ ukr: Psy-RD
+ kor: 싸이-RD
+ ron: Psy-RD
+"psy-rd: Psychovisual rate-distortion optimization strength.":
+ eng: "psy-rd: Psychovisual rate-distortion optimization strength."
+ deu: 'psy-rd: Psychovisuelle Rate-Verzerrungs-Optimierungsstärke.'
+ fra: "psy-rd : Force d'optimisation du taux de distorsion psychovisuelle."
+ ita: 'psy-rd: Forza di ottimizzazione del tasso di distorsione psicovisiva.'
+ spa: 'psy-rd: Fuerza de optimización de la distorsión de la tasa psicovisual.'
+ jpn: psy-rd:サイコビジュアル・レート・ディストーション最適化強度。
+ rus: 'psy-rd: Психовизуальная оптимизация скорости и искажений.'
+ por: 'psy-rd: Força de otimização da taxa de distorção psico-visual.'
+ swe: 'psy-rd: Optimering av psykovisuell hastighet-distorsion styrka.'
+ pol: 'psy-rd: Psychowizualna siła optymalizacji zniekształceń.'
+ chs: psy-rd:心理视觉速率-失真优化强度。
+ ukr: 'пси-рд: Сила оптимізації психовізуальних спотворень швидкості.'
+ kor: '싸이-rd: 심리 시각적 속도 왜곡 최적화 강도.'
+ ron: 'psy-rd: Putere de optimizare a ratei-distorsiunii psihovizuale.'
+"Format: rd:trellis (e.g. 1.0:0.0)":
+ eng: "Format: rd:trellis (e.g. 1.0:0.0)"
+ deu: 'Format: rd:trellis (z. B. 1.0:0.0)'
+ fra: 'Format : rd:trellis (par exemple 1.0:0.0)'
+ ita: 'Formato: rd:traliccio (ad es. 1.0:0.0)'
+ spa: 'Formato: rd:enrejado (por ejemplo, 1,0:0,0)'
+ jpn: フォーマット:rd:トレリス(例:1.0:0.0)
+ rus: 'Формат: rd:trellis (например, 1.0:0.0)'
+ por: 'Formato: rd:trellis (por exemplo, 1.0:0.0)'
+ swe: 'Format: rd:trellis (t.ex. 1,0:0,0)'
+ pol: 'Format: rd:trellis (np. 1.0:0.0)'
+ chs: 格式:rd:trellis(例如:1.0:0.0)
+ ukr: 'Формат: rd:trellis (наприклад, 1.0:0.0)'
+ kor: '형식: rd:격자(예: 1.0:0.0)'
+ ron: 'Format: rd:trellis (de exemplu, 1.0:0.0)'
+First value is psy-rd strength, second is psy-trellis strength.:
+ eng: First value is psy-rd strength, second is psy-trellis strength.
+ deu: Der erste Wert ist die Psy-rd-Stärke, der zweite die Psy-trellis-Stärke.
+ fra: La première valeur est la force psy-rd, la seconde la force psy-trellis.
+ ita: Il primo valore è la forza psy-rd, il secondo è la forza psy-trellis.
+ spa: El primer valor es la fuerza psy-rd, el segundo es la fuerza psy-trellis.
+ jpn: 最初の値はサイrdの強さ、2番目の値はサイ・トレリスの強さだ。
+ rus: Первое значение - сила psy-rd, второе - сила psy-trellis.
+ por: O primeiro valor é a força psy-rd, o segundo é a força psy-trellis.
+ swe: Det första värdet är psy-rd styrka, det andra är psy-trellis styrka.
+ pol: Pierwsza wartość to siła psy-rd, druga to siła psy-trellis.
+ chs: 第一个值是 psy-rd 强度,第二个值是 psy-trellis 强度。
+ ukr: Перше значення - це сила пси-руху, друге - сила пси-трелісу.
+ kor: 첫 번째 값은 싸이-드 강도, 두 번째 값은 싸이-트렐리스 강도입니다.
+ ron: Prima valoare este puterea psy-rd, a doua este puterea psy-trellis.
+Requires subme >= 6 for psy-rd and trellis for psy-trellis.:
+ eng: Requires subme >= 6 for psy-rd and trellis for psy-trellis.
+ deu: Erfordert subme >= 6 für psy-rd und trellis für psy-trellis.
+ fra: Requiert subme >= 6 pour psy-rd et trellis pour psy-trellis.
+ ita: Richiede subme >= 6 per psy-rd e traliccio per psy-trellis.
+ spa: Requiere subme >= 6 para psy-rd y trellis para psy-trellis.
+ jpn: psy-rdにはsubme >= 6、psy-trellisにはtrellisが必要。
+ rus: Требуется subme >= 6 для psy-rd и trellis для psy-trellis.
+ por: Requer subme >= 6 para psy-rd e trellis para psy-trellis.
+ swe: Kräver subme >= 6 för psy-rd och spaljé för psy-trellis.
+ pol: Wymaga subme >= 6 dla psy-rd i trellis dla psy-trellis.
+ chs: psy-rd 需要 subme >= 6,psy-trellis 需要 trellis。
+ ukr: Потрібен subme >= 6 для psy-rd і trellis для psy-trellis.
+ kor: psy-rd의 경우 subme >= 6, psy-trellis의 경우 trellis가 필요합니다.
+ ron: Necesită subme >= 6 pentru psy-rd și trellis pentru psy-trellis.
+"level: Set the H.264 level restriction.":
+ eng: "level: Set the H.264 level restriction."
+ deu: 'Stufe: Legen Sie die Beschränkung der H.264-Stufe fest.'
+ fra: 'niveau : Définit la restriction du niveau H.264.'
+ ita: 'livello: Imposta la restrizione del livello H.264.'
+ spa: 'nivel: Establece la restricción de nivel H.264.'
+ jpn: レベルを設定します:H.264レベルの制限を設定します。
+ rus: 'уровень: Установите ограничение уровня H.264.'
+ por: 'nível: Definir a restrição de nível H.264.'
+ swe: 'nivå: Ställ in begränsningen av H.264-nivån.'
+ pol: 'level: Ustaw ograniczenie poziomu H.264.'
+ chs: 级别:设置 H.264 级别限制。
+ ukr: 'рівень: Встановіть обмеження рівня H.264.'
+ kor: '레벨을 설정합니다: H.264 레벨 제한을 설정합니다.'
+ ron: 'nivel: Setați restricția nivelului H.264.'
+Limits the maximum bitrate, resolution, and other parameters.:
+ eng: Limits the maximum bitrate, resolution, and other parameters.
+ deu: Begrenzt die maximale Bitrate, Auflösung und andere Parameter.
+ fra: Limite le débit maximal, la résolution et d'autres paramètres.
+ ita: Limita la velocità massima di trasmissione, la risoluzione e altri parametri.
+ spa: Limita el bitrate máximo, la resolución y otros parámetros.
+ jpn: 最大ビットレート、解像度、その他のパラメーターを制限する。
+ rus: Ограничивает максимальный битрейт, разрешение и другие параметры.
+ por: Limita a taxa de bits máxima, a resolução e outros parâmetros.
+ swe: Begränsar den maximala bithastigheten, upplösningen och andra parametrar.
+ pol: Ogranicza maksymalną szybkość transmisji bitów, rozdzielczość i inne parametry.
+ chs: 限制最大比特率、分辨率和其他参数。
+ ukr: Обмежує максимальний бітрейт, роздільну здатність та інші параметри.
+ kor: 최대 비트 전송률, 해상도 및 기타 매개변수를 제한합니다.
+ ron: Limitează bitrate-ul maxim, rezoluția și alți parametri.
+Useful for ensuring compatibility with target devices.:
+ eng: Useful for ensuring compatibility with target devices.
+ deu: Nützlich, um die Kompatibilität mit den Zielgeräten zu gewährleisten.
+ fra: Utile pour assurer la compatibilité avec les appareils cibles.
+ ita: Utile per garantire la compatibilità con i dispositivi di destinazione.
+ spa: Útil para garantizar la compatibilidad con los dispositivos de destino.
+ jpn: ターゲット・デバイスとの互換性を確保するのに役立つ。
+ rus: Пригодится для обеспечения совместимости с целевыми устройствами.
+ por: Útil para garantir a compatibilidade com os dispositivos de destino.
+ swe: Användbar för att säkerställa kompatibilitet med målenheter.
+ pol: Przydatne do zapewnienia kompatybilności z urządzeniami docelowymi.
+ chs: 有助于确保与目标设备的兼容性。
+ ukr: Корисно для забезпечення сумісності з цільовими пристроями.
+ kor: 대상 디바이스와의 호환성을 보장하는 데 유용합니다.
+ ron: Util pentru asigurarea compatibilității cu dispozitivele țintă.
+Additional x264 params:
+ eng: Additional x264 params
+ deu: Zusätzliche x264-Parameter
+ fra: Paramètres x264 supplémentaires
+ ita: Parametri x264 aggiuntivi
+ spa: Parámetros x264 adicionales
+ jpn: 追加の x264 パラメータ
+ rus: Дополнительные параметры x264
+ por: Parâmetros x264 adicionais
+ swe: Ytterligare x264-parametrar
+ pol: Dodatkowe parametry x264
+ chs: 附加 x264 参数
+ ukr: Додаткові параметри x264
+ kor: 추가 x264 매개변수
+ ron: Parame x264 suplimentare
+Extra x264 params in opt=1:opt2=0 format:
+ eng: Extra x264 params in opt=1:opt2=0 format
+ deu: Zusätzliche x264-Parameter im Format opt=1:opt2=0
+ fra: Paramètres x264 supplémentaires au format opt=1:opt2=0
+ ita: Parametri x264 extra nel formato opt=1:opt2=0
+ spa: Parámetros x264 adicionales en formato opt=1:opt2=0
+ jpn: opt=1:opt2=0フォーマットの余分なx264パラメータ
+ rus: Дополнительные параметры x264 в формате opt=1:opt2=0
+ por: Parâmetros x264 extra no formato opt=1:opt2=0
+ swe: Extra x264-parametrar i formatet opt=1:opt2=0
+ pol: Dodatkowe parametry x264 w formacie opt=1:opt2=0
+ chs: 额外的 x264 参数,格式为 opt=1:opt2=0
+ ukr: Додаткові параметри x264 у форматі opt=1:opt2=0
+ kor: opt=1:opt2=0 형식의 추가 x264 매개변수
+ ron: Parame x264 suplimentare în format opt=1:opt2=0
+"examples: rc-lookahead=40:ref=6":
+ eng: "examples: rc-lookahead=40:ref=6"
+ deu: 'Beispiele: rc-lookahead=40:ref=6'
+ fra: 'exemples : rc-lookahead=40:ref=6'
+ ita: 'esempi: rc-lookahead=40:ref=6'
+ spa: 'ejemplos: rc-lookahead=40:ref=6'
+ jpn: 例:rc-lookahead=40:ref=6
+ rus: 'примеры: rc-lookahead=40:ref=6'
+ por: 'exemplos: rc-lookahead=40:ref=6'
+ swe: 'exempel: rc-lookahead=40:ref=6'
+ pol: 'Przykłady: rc-lookahead=40:ref=6'
+ chs: 示例:RC-lookahead=40:Ref=6
+ ukr: 'Приклади: rc-lookahead=40:ref=6'
+ kor: '예: rc-lookahead=40:ref=6'
+ ron: 'exemple: rc-lookahead=40:ref=6'
+Overall quality (1-100, higher is better quality but larger file size):
+ eng: Overall quality (1-100, higher is better quality but larger file size)
+ deu: Gesamtqualität (1-100, je höher, desto besser die Qualität, aber desto größer die Datei)
+ fra: Qualité globale (1-100, plus la valeur est élevée, meilleure est la qualité, mais plus le fichier est volumineux)
+ ita: Qualità complessiva (1-100, maggiore è la qualità ma maggiore è la dimensione del file)
+ spa: Calidad general (de 1 a 100; a mayor calidad, mayor tamaño del archivo)
+ jpn: 総合品質(1~100、高いほど高品質だがファイルサイズが大きい)
+ rus: Общее качество (1-100, выше - лучше качество, но больше размер файла)
+ por: Qualidade geral (1-100, quanto maior, melhor a qualidade mas maior o tamanho do ficheiro)
+ swe: Övergripande kvalitet (1-100, högre är bättre kvalitet men större filstorlek)
+ pol: Ogólna jakość (1-100, wyższa oznacza lepszą jakość, ale większy rozmiar pliku)
+ chs: 整体质量(1-100,越高质量越好,但文件大小越大)
+ ukr: Загальна якість (1-100, чим вище, тим краща якість, але більший розмір файлу)
+ kor: 전체 품질(1~100, 높을수록 품질이 좋지만 파일 크기가 커짐)
+ ron: Calitate generală (1-100, mai mare înseamnă calitate mai bună, dar dimensiune mai mare a fișierului)
+Lossy Quality:
+ eng: Lossy Quality
+ deu: Verlustbehaftete Qualität
+ fra: Qualité avec perte
+ ita: Qualità lossy
+ spa: Calidad con pérdidas
+ jpn: ロッシークオリティ
+ rus: Качество с потерями
+ por: Qualidade com perdas
+ swe: Förlustkvalitet
+ pol: Jakość stratna
+ chs: 有损质量
+ ukr: Якість із втратами
+ kor: 손실 품질
+ ron: Calitate Lossy
+Lower values reduce file size at cost of more noise/grain (1-100, or auto to let gifski decide):
+ eng: Lower values reduce file size at cost of more noise/grain (1-100, or auto to let gifski decide)
+ deu: Niedrigere Werte reduzieren die Dateigröße auf Kosten von mehr Rauschen/Körnung (1-100, oder Auto, um gifski entscheiden zu lassen)
+ fra: Des valeurs plus faibles réduisent la taille du fichier au prix de plus de bruit/grains (1-100, ou auto pour laisser gifski décider).
+ ita: Valori più bassi riducono le dimensioni del file al costo di un maggior rumore/grana (1-100, o auto per far decidere a gifski)
+ spa: Los valores más bajos reducen el tamaño del archivo a costa de más ruido/grano (1-100, o auto para dejar que gifski decida)
+ jpn: 値を下げるとファイルサイズが小さくなるが、その代償としてノイズやグレインが大きくなる(1-100、またはgifskiの判断に任せる場合はauto)。
+ rus: Более низкие значения уменьшают размер файла за счет большего количества шума/зерна (1-100, или авто, чтобы gifski сам решал).
+ por: Valores mais baixos reduzem o tamanho do ficheiro à custa de mais ruído/grão (1-100, ou automático para deixar o gifski decidir)
+ swe: Lägre värden minskar filstorleken på bekostnad av mer brus/korn (1-100, eller auto för att låta gifski bestämma)
+ pol: Niższe wartości zmniejszają rozmiar pliku kosztem większego szumu/ziarnistości (1-100 lub automatycznie, aby pozwolić gifski zdecydować).
+ chs: 数值越小,文件尺寸越小,但噪点/颗粒越多(1-100,或自动让 gifski 决定)。
+ ukr: Менші значення зменшують розмір файлу за рахунок більшого шуму/зернистості (1-100 або автоматично, щоб дозволити gifski вирішити).
+ kor: 값이 낮을수록 노이즈/입자가 커지는 대신 파일 크기가 줄어듭니다(1~100, 또는 자동 설정으로 기프스키가 결정).
+ ron: Valorile mai mici reduc dimensiunea fișierului cu prețul unui zgomot/grain mai mare (1-100, sau auto pentru a lăsa gifski să decidă)
+Lower values reduce file size for animations with motion (1-100, or auto to let gifski decide):
+ eng: Lower values reduce file size for animations with motion (1-100, or auto to let gifski decide)
+ deu: Niedrigere Werte reduzieren die Dateigröße für Animationen mit Bewegung (1-100, oder auto, um gifski entscheiden zu lassen)
+ fra: Des valeurs plus faibles réduisent la taille des fichiers pour les animations avec mouvement (1-100, ou auto pour laisser gifski décider).
+ ita: Valori più bassi riducono le dimensioni del file per le animazioni con movimento (1-100, o auto per far decidere a gifski)
+ spa: Los valores más bajos reducen el tamaño del archivo para animaciones con movimiento (1-100, o auto para dejar que gifski decida)
+ jpn: 値を小さくすると、動きのあるアニメーションのファイルサイズが小さくなります。
+ rus: Меньшие значения уменьшают размер файла для анимации с движением (1-100, или авто, чтобы gifski сам решал).
+ por: Valores mais baixos reduzem o tamanho do ficheiro para animações com movimento (1-100, ou automático para deixar o gifski decidir)
+ swe: Lägre värden minskar filstorleken för animationer med rörelse (1-100, eller auto för att låta gifski bestämma)
+ pol: Niższe wartości zmniejszają rozmiar pliku w przypadku animacji z ruchem (1-100 lub automatycznie, aby pozwolić gifski zdecydować).
+ chs: 较低的值可减少动态动画的文件大小(1-100,或自动让 gifski 决定)。
+ ukr: Менші значення зменшують розмір файлу для анімації з рухом (1-100 або автоматично, щоб gifski вирішив)
+ kor: 값이 낮을수록 모션이 있는 애니메이션의 파일 크기가 줄어듭니다(1~100 또는 자동, GIF 스키가 결정하도록 허용).
+ ron: Valorile mai mici reduc dimensiunea fișierului pentru animațiile cu mișcare (1-100, sau auto pentru a lăsa gifski să decidă)
+Fast Mode:
+ eng: Fast Mode
+ deu: Schneller Modus
+ fra: Mode rapide
+ ita: Modalità veloce
+ spa: Modo rápido
+ jpn: 高速モード
+ rus: Быстрый режим
+ por: Modo rápido
+ swe: Snabbt läge
+ pol: Tryb szybki
+ chs: 快速模式
+ ukr: Швидкий режим
+ kor: 빠른 모드
+ ron: Mod rapid
+Encode faster at cost of quality:
+ eng: Encode faster at cost of quality
+ deu: Schnellere Kodierung auf Kosten der Qualität
+ fra: Coder plus vite au détriment de la qualité
+ ita: Codificare più velocemente a scapito della qualità
+ spa: Codificar más rápido a costa de la calidad
+ jpn: 品質を犠牲にしてでも、より速くエンコードする
+ rus: Кодирование быстрее за счет снижения качества
+ por: Codificar mais rapidamente à custa da qualidade
+ swe: Snabbare kodning på bekostnad av kvalitet
+ pol: Szybsze kodowanie kosztem jakości
+ chs: 以质量为代价加快编码速度
+ ukr: Кодуйте швидше за рахунок якості
+ kor: 품질 비용으로 더 빠르게 인코딩
+ ron: Codificare mai rapidă în detrimentul calității
+Download HDR10+ Tool:
+ eng: Download HDR10+ Tool
+ deu: HDR10+ Tool herunterladen
+ fra: Télécharger l'outil HDR10
+ ita: Scarica lo strumento HDR10+
+ spa: Descargar la herramienta HDR10
+ jpn: HDR10+ツールのダウンロード
+ rus: Скачать инструмент HDR10+
+ por: Descarregar a ferramenta HDR10
+ swe: Ladda ner HDR10+ verktyg
+ pol: Pobierz narzędzie HDR10+
+ chs: 下载 HDR10+ 工具
+ ukr: Завантажити HDR10+ Tool
+ kor: HDR10+ 도구 다운로드
+ ron: Descărcați HDR10+ Tool
+Perceptual QPA:
+ eng: Perceptual QPA
+ deu: Wahrnehmungs-QPA
+ fra: QPA perceptuel
+ ita: QPA percettivo
+ spa: QPA perceptivo
+ jpn: 知覚的QPA
+ rus: Перцептивная QPA
+ por: QPA percetual
+ swe: Perceptuell QPA
+ pol: Percepcyjne QPA
+ chs: 感知 QPA
+ ukr: Перцептивний QPA
+ kor: 지각적 QPA
+ ron: QPA perceptual
+QPA perceptual optimization (enabled by default in vvenc).:
+ eng: QPA perceptual optimization (enabled by default in vvenc).
+ deu: QPA-Wahrnehmungsoptimierung (in vvenc standardmäßig aktiviert).
+ fra: Optimisation perceptuelle QPA (activée par défaut dans vvenc).
+ ita: Ottimizzazione percettiva QPA (abilitata di default in vvenc).
+ spa: Optimización perceptiva QPA (activada por defecto en vvenc).
+ jpn: QPA 知覚最適化(vvenc のデフォルトで有効)。
+ rus: Оптимизация восприятия QPA (включена по умолчанию в vvenc).
+ por: Otimização perceptiva QPA (activada por defeito em vvenc).
+ swe: QPA-perceptuell optimering (aktiverad som standard i vvenc).
+ pol: Optymalizacja percepcyjna QPA (domyślnie włączona w vvenc).
+ chs: QPA 感知优化(vvenc 默认启用)。
+ ukr: Оптимізація сприйняття QPA (у vvenc увімкнено за замовчуванням).
+ kor: QPA 지각 최적화(vvenc에서 기본적으로 활성화됨).
+ ron: Optimizarea percepției QPA (activată implicit în vvenc).
+Disabling sends -qpa 0 to vvenc.:
+ eng: Disabling sends -qpa 0 to vvenc.
+ deu: Bei Deaktivierung wird -qpa 0 an vvenc gesendet.
+ fra: La désactivation envoie -qpa 0 à vvenc.
+ ita: La disabilitazione invia -qpa 0 a vvenc.
+ spa: La desactivación envía -qpa 0 a vvenc.
+ jpn: 無効にすると-qpa 0がvvencに送られる。
+ rus: Отключение отправляет -qpa 0 на vvenc.
+ por: A desativação envia -qpa 0 para vvenc.
+ swe: Inaktivering skickar -qpa 0 till vvenc.
+ pol: Wyłączenie wysyła -qpa 0 do vvenc.
+ chs: 禁用后会向 vvenc 发送 -qpa 0。
+ ukr: Вимкнення надсилає -qpa 0 до vvenc.
+ kor: 비활성화하면 -qpa 0이 vvenc로 전송됩니다.
+ ron: Dezactivarea trimite -qpa 0 către vvenc.
+Intra Period:
+ eng: Intra Period
+ deu: Innerhalb des Zeitraums
+ fra: Intra période
+ ita: Intra periodo
+ spa: Dentro del periodo
+ jpn: 期中
+ rus: Внутрипериодный
+ por: Intra-período
+ swe: Inom perioden
+ pol: Wewnątrz okresu
+ chs: 期间内
+ ukr: Внутрішній період
+ kor: 기간 내
+ ron: În interiorul perioadei
+Intra refresh period in seconds. Auto lets vvenc decide.:
+ eng: Intra refresh period in seconds. Auto lets vvenc decide.
+ deu: Intra-Refresh-Zeitraum in Sekunden. Auto lässt vvenc entscheiden.
+ fra: Période de rafraîchissement interne en secondes. Auto laisse vvenc décider.
+ ita: Periodo di aggiornamento interno in secondi. Auto lascia decidere a vvenc.
+ spa: Periodo intra refresh en segundos. Auto deja que vvenc decida.
+ jpn: リフレッシュ期間(秒)。Autoはvvencが決定します。
+ rus: Период обновления в секундах. Авто позволяет vvenc принимать решения.
+ por: Período de atualização intra em segundos. Auto deixa o vvenc decidir.
+ swe: Intra refresh period i sekunder. Auto låter vvenc bestämma.
+ pol: Okres odświeżania w sekundach. Auto pozwala vvenc zdecydować.
+ chs: 刷新周期(秒)。自动由 vvenc 决定。
+ ukr: Період між оновленнями в секундах. Автоматично дозволяє vvenc вирішити.
+ kor: 새로 고침 간격(초). 자동을 선택하면 vvenc가 결정합니다.
+ ron: Perioada de reîmprospătare în secunde. Auto lasă vvenc să decidă.
+Threads:
+ eng: Threads
+ deu: Fäden
+ fra: Fils
+ ita: Fili
+ spa: Hilos
+ jpn: スレッド
+ rus: Нитки
+ por: Fios
+ swe: Trådar
+ pol: Nici
+ chs: 线程
+ ukr: Нитки
+ kor: 스레드
+ ron: Fire
+Number of threads for encoding. Auto lets vvenc decide.:
+ eng: Number of threads for encoding. Auto lets vvenc decide.
+ deu: Anzahl der Threads für die Kodierung. Auto lässt vvenc entscheiden.
+ fra: Nombre de threads pour l'encodage. Auto laisse vvenc décider.
+ ita: Numero di thread per la codifica. Auto lascia decidere a vvenc.
+ spa: Número de hilos para la codificación. Auto deja que vvenc decida.
+ jpn: エンコードのスレッド数。自動でvvencが決める。
+ rus: Количество потоков для кодирования. Авто позволяет vvenc решать.
+ por: Número de threads para codificação. Auto deixa o vvenc decidir.
+ swe: Antal trådar för kodning. Auto låter vvenc bestämma.
+ pol: Liczba wątków dla kodowania. Auto pozwala vvenc zdecydować.
+ chs: 编码线程数。自动由 vvenc 决定。
+ ukr: Кількість потоків для кодування. Автоматично дозволяє vvenc вирішити.
+ kor: 인코딩을 위한 스레드 수입니다. 자동을 선택하면 vvenc가 결정합니다.
+ ron: Numărul de fire pentru codare. Auto lasă vvenc să decidă.
+IFP (Inter-Frame Parallelism):
+ eng: IFP (Inter-Frame Parallelism)
+ deu: IFP (Inter-Frame-Parallelität)
+ fra: IFP (Inter-Frame Parallelism)
+ ita: IFP (Parallelismo inter-frame)
+ spa: IFP (paralelismo entre tramas)
+ jpn: IFP(フレーム間並列性)
+ rus: IFP (межкадровый параллелизм)
+ por: IFP (Paralelismo entre quadros)
+ swe: IFP (parallellism mellan ramar)
+ pol: IFP (równoległość międzyramkowa)
+ chs: IFP(帧间并行)
+ ukr: IFP (міжкадровий паралелізм)
+ kor: IFP(프레임 간 병렬 처리)
+ ron: IFP (Paralelism între cadre)
+Enable inter-frame parallelism for faster encoding (vvenc 1.11+).:
+ eng: Enable inter-frame parallelism for faster encoding (vvenc 1.11+).
+ deu: Aktivieren Sie Inter-Frame-Parallelität für schnellere Kodierung (vvenc 1.11+).
+ fra: Activation du parallélisme inter-images pour un encodage plus rapide (vvenc 1.11+).
+ ita: Abilita il parallelismo inter-frame per una codifica più veloce (vvenc 1.11+).
+ spa: Habilita el paralelismo entre fotogramas para una codificación más rápida (vvenc 1.11+).
+ jpn: フレーム間並列処理を有効にしてエンコードを高速化(vvenc 1.11+)。
+ rus: Включите межкадровый параллелизм для ускорения кодирования (vvenc 1.11+).
+ por: Ativar o paralelismo entre fotogramas para uma codificação mais rápida (vvenc 1.11+).
+ swe: Aktivera parallellism mellan ramar för snabbare kodning (vvenc 1.11+).
+ pol: Włączenie równoległości międzyramkowej w celu szybszego kodowania (vvenc 1.11+).
+ chs: 启用帧间并行,以加快编码速度(vvenc 1.11+)。
+ ukr: Увімкнути міжкадровий паралелізм для швидшого кодування (vvenc 1.11+).
+ kor: 더 빠른 인코딩을 위해 프레임 간 병렬 처리를 활성화합니다(vvenc 1.11+).
+ ron: Activați paralelismul inter-frame pentru o codificare mai rapidă (vvenc 1.11+).
+Appends ifp=1 to vvc-params.:
+ eng: Appends ifp=1 to vvc-params.
+ deu: Hängt ifp=1 an vvc-params an.
+ fra: Ajoute ifp=1 à vvc-params.
+ ita: Aggiunge ifp=1 a vvc-params.
+ spa: Añade ifp=1 a vvc-params.
+ jpn: vvc-paramsにifp=1を追加する。
+ rus: Добавляет ifp=1 к vvc-params.
+ por: Acrescenta ifp=1 a vvc-params.
+ swe: Lägger till ifp=1 till vvc-params.
+ pol: Dodaje ifp=1 do vvc-params.
+ chs: 将 ifp=1 添加到 vvc-params 中。
+ ukr: Додає до vvc-параметрів значення ifp=1.
+ kor: vvc-params에 ifp=1을 추가합니다.
+ ron: Adaugă ifp=1 la vvc-params.
+High quality GIF encoding:
+ eng: High quality GIF encoding
+ deu: Hochwertige GIF-Kodierung
+ fra: Encodage GIF de haute qualité
+ ita: Codifica GIF di alta qualità
+ spa: Codificación GIF de alta calidad
+ jpn: 高品質GIFエンコーディング
+ rus: Высококачественное кодирование GIF
+ por: Codificação GIF de alta qualidade
+ swe: GIF-kodning av hög kvalitet
+ pol: Wysokiej jakości kodowanie GIF
+ chs: 高质量 GIF 编码
+ ukr: Високоякісне кодування GIF
+ kor: 고품질 GIF 인코딩
+ ron: Codificare GIF de înaltă calitate
+HDR10+ detected — will be preserved via FFmpeg passthrough:
+ eng: HDR10+ detected — will be preserved via FFmpeg passthrough
+ deu: HDR10+ erkannt - wird über FFmpeg Passthrough erhalten
+ fra: HDR10+ détecté - sera préservé via FFmpeg passthrough
+ ita: Rilevato HDR10+ - sarà conservato tramite FFmpeg passthrough
+ spa: HDR10+ detectado - se conservará a través de FFmpeg passthrough
+ jpn: HDR10+を検出 - FFmpegパススルーで保存されます。
+ rus: HDR10+ обнаружен - будет сохранен через FFmpeg passthrough
+ por: HDR10+ detectado - será preservado através do FFmpeg passthrough
+ swe: HDR10+ upptäckt - kommer att bevaras via FFmpeg passthrough
+ pol: Wykryto HDR10+ - zostanie zachowany przez FFmpeg passthrough
+ chs: 检测到 HDR10+ - 将通过 FFmpeg 直通保存
+ ukr: Виявлено HDR10+ - буде збережено за допомогою проходження FFmpeg
+ kor: HDR10+ 감지 - FFmpeg 패스스루를 통해 보존됩니다.
+ ron: HDR10+ detectat - va fi păstrat prin intermediul FFmpeg passthrough
+Tune Content:
+ eng: Tune Content
+ deu: Inhalt abstimmen
+ fra: Contenu de l'accord
+ ita: Sintonizzare il contenuto
+ spa: Sintonizar contenido
+ jpn: チューン・コンテンツ
+ rus: Содержание мелодии
+ por: Conteúdo do Tune
+ swe: Tune Innehåll
+ pol: Dostrajanie zawartości
+ chs: 调整内容
+ ukr: Зміст мелодії
+ kor: 콘텐츠 조정
+ ron: Reglați conținutul
+Content type tuning.:
+ eng: Content type tuning.
+ deu: Abstimmung des Inhaltstyps.
+ fra: Réglage du type de contenu.
+ ita: Messa a punto del tipo di contenuto.
+ spa: Ajuste del tipo de contenido.
+ jpn: コンテンツタイプのチューニング。
+ rus: Настройка типа содержимого.
+ por: Afinação do tipo de conteúdo.
+ swe: Justering av innehållstyp.
+ pol: Dostrajanie typu zawartości.
+ chs: 内容类型调整。
+ ukr: Налаштування типу вмісту.
+ kor: 콘텐츠 유형 조정.
+ ron: Reglarea tipului de conținut.
+"screen: for screen capture content":
+ eng: "screen: for screen capture content"
+ deu: 'screen: für Bildschirmaufnahmen'
+ fra: "écran : pour le contenu de la capture d'écran"
+ ita: 'schermo: per il contenuto della cattura dello schermo'
+ spa: 'pantalla: para contenido de captura de pantalla'
+ jpn: 'screen: スクリーンキャプチャコンテンツ用'
+ rus: 'экран: для захвата содержимого экрана'
+ por: 'ecrã: para conteúdo de captura de ecrã'
+ swe: 'screen: för innehåll från skärmdump'
+ pol: 'ekran: dla zawartości przechwytywania ekranu'
+ chs: 屏幕:用于屏幕截图内容
+ ukr: 'screen: для вмісту скріншоту'
+ kor: '화면: 화면 캡처 콘텐츠의 경우'
+ ron: 'ecran: pentru conținutul capturii de ecran'
+"film: for film content":
+ eng: "film: for film content"
+ deu: 'Film: für Filminhalte'
+ fra: 'film : pour le contenu des films'
+ ita: 'film: per i contenuti cinematografici'
+ spa: 'película: para contenidos cinematográficos'
+ jpn: 'film: 映画コンテンツ用'
+ rus: 'фильм: для содержания фильмов'
+ por: 'filme: para conteúdos de filmes'
+ swe: 'film: för filminnehåll'
+ pol: 'Film: dla treści filmowych'
+ chs: 电影:用于电影内容
+ ukr: 'фільм: для кіноконтенту'
+ kor: '영화: 영화 콘텐츠용'
+ ron: 'film: pentru conținut cinematografic'
+Adaptive quantization mode.:
+ eng: Adaptive quantization mode.
+ deu: Adaptiver Quantisierungsmodus.
+ fra: Mode de quantification adaptative.
+ ita: Modalità di quantizzazione adattiva.
+ spa: Modo de cuantización adaptativa.
+ jpn: 適応量子化モード。
+ rus: Режим адаптивного квантования.
+ por: Modo de quantização adaptativa.
+ swe: Adaptivt kvantiseringsläge.
+ pol: Tryb kwantyzacji adaptacyjnej.
+ chs: 自适应量化模式
+ ukr: Режим адаптивного квантування.
+ kor: 적응형 양자화 모드.
+ ron: Mod de cuantizare adaptivă.
+Alt Ref Frames:
+ eng: Alt Ref Frames
+ deu: Alt-Ref-Rahmen
+ fra: Alt Ref Frames
+ ita: Cornici Alt Ref
+ spa: Alt Ref Marcos
+ jpn: オルトレフフレーム
+ rus: Alt Ref Frames
+ por: Quadros de referência Alt
+ swe: Alt Ref ramar
+ pol: Alt Ref Frames
+ chs: 辅助参考框
+ ukr: Alt Ref Frames
+ kor: 대체 참조 프레임
+ ron: Cadrele Alt Ref
+Enable automatic alternate reference frames.:
+ eng: Enable automatic alternate reference frames.
+ deu: Aktivieren Sie automatische alternative Referenzrahmen.
+ fra: Activer les cadres de référence alternatifs automatiques.
+ ita: Abilita i quadri di riferimento alternativi automatici.
+ spa: Activar marcos de referencia alternativos automáticos.
+ jpn: 自動代替参照フレームを有効にする。
+ rus: Включить автоматическое чередование систем отсчета.
+ por: Ativar quadros de referência alternativos automáticos.
+ swe: Aktivera automatiska alternativa referensramar.
+ pol: Włącz automatyczne alternatywne ramy odniesienia.
+ chs: 启用自动交替参照基准。
+ ukr: Увімкніть автоматичну альтернативну систему відліку.
+ kor: 자동 대체 참조 프레임을 사용하도록 설정합니다.
+ ron: Activați cadrele de referință alternative automate.
+Most impactful VP9 quality feature for multi-pass encoding.:
+ eng: Most impactful VP9 quality feature for multi-pass encoding.
+ deu: Die wichtigste VP9-Qualitätsfunktion für Multi-Pass-Codierung.
+ fra: Fonctionnalité de qualité VP9 la plus importante pour l'encodage multipasse.
+ ita: La funzione di qualità VP9 di maggior impatto per la codifica multi-pass.
+ spa: La función de calidad VP9 más impactante para la codificación multipase.
+ jpn: マルチパスエンコーディングのための最もインパクトのあるVP9品質機能。
+ rus: Наиболее эффективная функция качества VP9 для многопроходного кодирования.
+ por: A caraterística de qualidade VP9 mais impactante para codificação multi-passagem.
+ swe: Den mest effektiva VP9-kvalitetsfunktionen för multi-pass-kodning.
+ pol: Najważniejsza funkcja jakości VP9 dla kodowania wieloprzebiegowego.
+ chs: 对多通道编码影响最大的 VP9 质量功能。
+ ukr: Найвпливовіша характеристика якості VP9 для багатопрохідного кодування.
+ kor: 멀티패스 인코딩을 위한 가장 영향력 있는 VP9 품질 기능입니다.
+ ron: Cea mai importantă caracteristică de calitate VP9 pentru codarea multi-pass.
+Lag in Frames:
+ eng: Lag in Frames
+ deu: Verzögerung in Frames
+ fra: Décalage dans les encadrements
+ ita: Ritardo in fotogrammi
+ spa: Retraso en fotogramas
+ jpn: フレームの遅れ
+ rus: Отставание в кадрах
+ por: Atraso nos fotogramas
+ swe: Fördröjning i ramar
+ pol: Opóźnienie w ramkach
+ chs: 帧滞后
+ ukr: Відставання у кадрах
+ kor: 프레임 지연
+ ron: Decalaj în cadre
+Number of frames to look ahead for alternate reference frame selection.:
+ eng: Number of frames to look ahead for alternate reference frame selection.
+ deu: Anzahl der Bilder, die für die Auswahl des alternativen Referenzbildes vorauszusehen sind.
+ fra: Nombre d'images à anticiper pour la sélection du cadre de référence alternatif.
+ ita: Numero di fotogrammi da guardare avanti per la selezione del quadro di riferimento alternativo.
+ spa: Número de fotogramas que deben adelantarse para la selección del fotograma de referencia alternativo.
+ jpn: 代替参照フレーム選択のために先読みするフレーム数。
+ rus: Количество кадров для выбора альтернативного опорного кадра.
+ por: Número de fotogramas a considerar para a seleção do fotograma de referência alternativo.
+ swe: Antal bildrutor att se framåt för val av alternativ referensram.
+ pol: Liczba klatek do sprawdzenia w celu wybrania alternatywnej ramki odniesienia.
+ chs: 选择备用参考帧时的前瞻帧数。
+ ukr: Кількість кадрів для вибору альтернативного опорного кадру.
+ kor: 대체 기준 프레임 선택을 위해 앞을 내다볼 프레임 수입니다.
+ ron: Numărul de cadre pe care trebuie să le priviți înainte pentru selectarea cadrului de referință alternativ.
+"Recommended: 25.":
+ eng: "Recommended: 25."
+ deu: 'Empfohlen: 25.'
+ fra: 'Recommandé : 25.'
+ ita: 'Consigliato: 25.'
+ spa: 'Recomendado: 25.'
+ jpn: 推奨:25
+ rus: 'Рекомендуется: 25.'
+ por: 'Recomendado: 25.'
+ swe: 'Rekommenderas: 25.'
+ pol: 'Zalecane: 25.'
+ chs: 建议:25。
+ ukr: 'Рекомендовано: 25.'
+ kor: '권장: 25.'
+ ron: 'Recomandat: 25.'
+"Loop filter sharpness (0-7).":
+ eng: "Loop filter sharpness (0-7)."
+ deu: Schärfe des Schleifenfilters (0-7).
+ fra: Netteté du filtre de boucle (0-7).
+ ita: Nitidezza del filtro Loop (0-7).
+ spa: Nitidez del filtro de bucle (0-7).
+ jpn: ループフィルターのシャープネス(0-7)。
+ rus: Резкость контурного фильтра (0-7).
+ por: Nitidez do filtro de laço (0-7).
+ swe: Loop-filtrets skärpa (0-7).
+ pol: Ostrość filtra pętli (0-7).
+ chs: 环路滤波器清晰度(0-7)。
+ ukr: Різкість петлевого фільтра (0-7).
+ kor: 루프 필터 선명도(0~7).
+ ron: Finețea filtrului de buclă (0-7).
+Video Info:
+ eng: Video Info
+ deu: Video-Infos
+ fra: Info vidéo
+ ita: Video Info
+ spa: Información en vídeo
+ jpn: ビデオ情報
+ rus: Видеоинформация
+ por: Informações sobre o vídeo
+ swe: Videoinformation
+ pol: Informacje wideo
+ chs: 视频信息
+ ukr: Інформація про відео
+ kor: 비디오 정보
+ ron: Informații video
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/av1_aom/command_builder.py b/fastflix/encoders/av1_aom/command_builder.py
index 32287105..8fe1a638 100644
--- a/fastflix/encoders/av1_aom/command_builder.py
+++ b/fastflix/encoders/av1_aom/command_builder.py
@@ -1,37 +1,77 @@
# -*- coding: utf-8 -*-
+import logging
import secrets
+import shlex
from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null
from fastflix.models.encode import AOMAV1Settings
from fastflix.models.fastflix import FastFlix
+logger = logging.getLogger("fastflix")
+
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)} "
+ if fastflix.current_video.hdr10_plus and "10" in settings.pix_fmt:
+ if fastflix.libavcodec_version >= 62:
+ logger.info("HDR10+ detected — passthrough will be handled automatically by FFmpeg 8.0+")
+ else:
+ logger.warning(
+ "HDR10+ detected but FFmpeg 8.0+ (libavcodec 62+) is required for AV1 HDR10+ passthrough. "
+ f"Current libavcodec version: {fastflix.libavcodec_version}"
+ )
+
+ beginning.extend(
+ [
+ "-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"])
+
+ if settings.tune != "default":
+ beginning.extend(["-tune", settings.tune])
+
+ if settings.denoise_noise_level > 0:
+ beginning.extend(["-denoise-noise-level", str(settings.denoise_noise_level)])
+
+ if settings.aq_mode != "default":
+ beginning.extend(["-aq-mode", settings.aq_mode])
+
+ if settings.aom_params:
+ beginning.extend(["-aom-params", ":".join(settings.aom_params)])
+
+ 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 + ["-crf", str(settings.crf)] + extra + ending
return [Command(command=command_1, name="Single Pass CRF")]
diff --git a/fastflix/encoders/av1_aom/settings_panel.py b/fastflix/encoders/av1_aom/settings_panel.py
index 69a00daf..7bdced2c 100644
--- a/fastflix/encoders/av1_aom/settings_panel.py
+++ b/fastflix/encoders/av1_aom/settings_panel.py
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
import logging
+from pathlib import Path
from box import Box
-from PySide6 import QtCore, QtWidgets
+from PySide6 import QtCore, QtGui, QtWidgets
from fastflix.encoders.common.setting_panel import SettingPanel
from fastflix.language import t
from fastflix.models.encode import AOMAV1Settings
from fastflix.models.fastflix_app import FastFlixApp
+from fastflix.resources import loading_movie
from fastflix.shared import link
logger = logging.getLogger("fastflix")
@@ -41,19 +43,29 @@
"12-bit 444: yuv444p12le",
]
+denoise_options = [
+ "0 - Disabled",
+ "5 - Light",
+ "10 - Medium",
+ "25 - Heavy",
+ "50 - Maximum",
+ "Custom",
+]
+
class AV1(SettingPanel):
profile_name = "aom_av1"
+ hdr10plus_signal = QtCore.Signal(str)
+ hdr10plus_ffmpeg_signal = QtCore.Signal(str)
def __init__(self, parent, main, app: FastFlixApp):
super().__init__(parent, main, app)
self.main = main
self.app = app
+ self.extract_thread = None
grid = QtWidgets.QGridLayout()
- # grid.addWidget(QtWidgets.QLabel("FFMPEG libaom-av1_aom"), 0, 0)
-
self.widgets = Box(fps=None, mode=None)
self.mode = "CRF"
@@ -63,13 +75,21 @@ def __init__(self, parent, main, app: FastFlixApp):
grid.addLayout(self.init_tile_columns(), 2, 0, 1, 2)
grid.addLayout(self.init_tile_rows(), 3, 0, 1, 2)
grid.addLayout(self.init_usage(), 4, 0, 1, 2)
- grid.addLayout(self.init_max_mux(), 5, 0, 1, 2)
- grid.addLayout(self.init_pix_fmt(), 6, 0, 1, 2)
+ grid.addLayout(self.init_tune(), 5, 0, 1, 2)
+ grid.addLayout(self.init_aq_mode(), 6, 0, 1, 2)
+ grid.addLayout(self.init_max_mux(), 7, 0, 1, 2)
+ grid.addLayout(self.init_pix_fmt(), 8, 0, 1, 2)
grid.addLayout(self.init_modes(), 0, 2, 5, 4)
+ grid.addLayout(self.init_denoise(), 5, 2, 1, 4)
+ grid.addLayout(self.init_aom_params(), 6, 2, 1, 4)
+ grid.addLayout(self.init_hdr10plus_row(), 7, 2, 1, 4)
+
+ self.ffmpeg_level = QtWidgets.QLabel()
+ grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4)
grid.addLayout(self._add_custom(), 10, 0, 1, 6)
- grid.setRowStretch(8, 1)
+ grid.setRowStretch(9, 1)
guide_label = QtWidgets.QLabel(
link("https://trac.ffmpeg.org/wiki/Encode/AV1", t("FFMPEG AV1 Encoding Guide"), app.fastflix.config.theme)
)
@@ -77,6 +97,8 @@ def __init__(self, parent, main, app: FastFlixApp):
guide_label.setOpenExternalLinks(True)
grid.addWidget(guide_label, 11, 0, -1, 1)
+ self.hdr10plus_signal.connect(self.done_hdr10plus_extract)
+ self.hdr10plus_ffmpeg_signal.connect(lambda x: self.ffmpeg_level.setText(x))
self.setLayout(grid)
self.hide()
@@ -130,10 +152,28 @@ def init_usage(self):
label="Usage",
tooltip="Quality and compression efficiency vs speed trade-off",
widget_name="usage",
- options=["good", "realtime"],
+ options=["good", "realtime", "allintra"],
opt="usage",
)
+ def init_tune(self):
+ return self._add_combo_box(
+ label="Tune",
+ tooltip="Optimize encoding for different quality metrics",
+ widget_name="tune",
+ options=["default", "psnr", "ssim"],
+ opt="tune",
+ )
+
+ def init_aq_mode(self):
+ return self._add_combo_box(
+ label="AQ Mode",
+ tooltip="Adaptive quantization mode for quality distribution",
+ widget_name="aq_mode",
+ options=["default", "0 - None", "1 - Variance", "2 - Complexity", "3 - Cyclic"],
+ opt="aq_mode",
+ )
+
def init_pix_fmt(self):
return self._add_combo_box(
label="Bit Depth",
@@ -143,6 +183,127 @@ def init_pix_fmt(self):
opt="pix_fmt",
)
+ def init_denoise(self):
+ layout = QtWidgets.QHBoxLayout()
+ self.labels.denoise = QtWidgets.QLabel(t("Denoise"))
+ self.labels.denoise.setFixedWidth(200)
+ self.labels.denoise.setToolTip(t("Noise removal amount (0=off, higher=more denoising)"))
+ layout.addWidget(self.labels.denoise)
+ self.widgets.denoise = QtWidgets.QComboBox()
+ self.widgets.denoise.addItems(denoise_options)
+ self.widgets.denoise.setToolTip(t("Noise removal amount (0=off, higher=more denoising)"))
+ self.widgets.denoise.currentIndexChanged.connect(lambda: self.denoise_update())
+ # denoise is handled manually in reload() due to custom combo box format
+ layout.addWidget(self.widgets.denoise)
+ self.widgets.custom_denoise = QtWidgets.QLineEdit()
+ self.widgets.custom_denoise.setFixedWidth(60)
+ self.widgets.custom_denoise.setDisabled(True)
+ self.widgets.custom_denoise.setToolTip(t("Custom denoise value (0-50)"))
+ self.widgets.custom_denoise.textChanged.connect(lambda: self.main.page_update())
+ layout.addWidget(self.widgets.custom_denoise)
+
+ saved = self.app.fastflix.config.encoder_opt(self.profile_name, "denoise_noise_level")
+ if saved and str(saved) != "0":
+ matched = False
+ for i, opt in enumerate(denoise_options):
+ if opt.startswith(str(saved)):
+ self.widgets.denoise.setCurrentIndex(i)
+ matched = True
+ break
+ if not matched:
+ self.widgets.denoise.setCurrentIndex(len(denoise_options) - 1)
+ self.widgets.custom_denoise.setText(str(saved))
+
+ return layout
+
+ def denoise_update(self):
+ custom = self.widgets.denoise.currentText() == "Custom"
+ self.widgets.custom_denoise.setDisabled(not custom)
+ self.main.page_update()
+
+ def reload(self):
+ super().reload()
+ saved = self.app.fastflix.current_video.video_settings.video_encoder_settings.denoise_noise_level
+ if saved and str(saved) != "0":
+ matched = False
+ for i, opt in enumerate(denoise_options):
+ if opt.startswith(str(saved)):
+ self.widgets.denoise.setCurrentIndex(i)
+ matched = True
+ break
+ if not matched:
+ self.widgets.denoise.setCurrentIndex(len(denoise_options) - 1)
+ self.widgets.custom_denoise.setText(str(saved))
+ else:
+ self.widgets.denoise.setCurrentIndex(0)
+
+ def init_aom_params(self):
+ layout = QtWidgets.QHBoxLayout()
+ self.labels.aom_params = QtWidgets.QLabel(t("Additional aom params"))
+ self.labels.aom_params.setFixedWidth(200)
+ tool_tip = f"{t('Extra aom params in opt=1:opt2=0 format')},\n{t('cannot modify generated settings')}"
+ self.labels.aom_params.setToolTip(tool_tip)
+ layout.addWidget(self.labels.aom_params)
+ self.widgets.aom_params = QtWidgets.QLineEdit()
+ self.widgets.aom_params.setToolTip(tool_tip)
+ self.widgets.aom_params.setText(":".join(self.app.fastflix.config.encoder_opt(self.profile_name, "aom_params")))
+ self.opts["aom_params"] = "aom_params"
+ self.widgets.aom_params.textChanged.connect(lambda: self.main.page_update())
+ layout.addWidget(self.widgets.aom_params)
+ return layout
+
+ def init_hdr10plus_row(self):
+ layout = QtWidgets.QHBoxLayout()
+
+ self.hdr10plus_status_label = QtWidgets.QLabel()
+ self.hdr10plus_status_label.hide()
+ layout.addWidget(self.hdr10plus_status_label)
+
+ self.extract_button = QtWidgets.QPushButton(t("Extract HDR10+"))
+ self.extract_button.hide()
+ self.extract_button.clicked.connect(self.extract_hdr10plus)
+ layout.addWidget(self.extract_button)
+
+ self.extract_label = QtWidgets.QLabel(self)
+ self.extract_label.hide()
+ self.movie = QtGui.QMovie(loading_movie)
+ self.movie.setScaledSize(QtCore.QSize(25, 25))
+ self.extract_label.setMovie(self.movie)
+ layout.addWidget(self.extract_label)
+
+ layout.addStretch(1)
+ return layout
+
+ def done_hdr10plus_extract(self, metadata: str):
+ self.extract_button.show()
+ self.extract_label.hide()
+ self.movie.stop()
+ self.ffmpeg_level.setText("")
+ if Path(metadata).exists():
+ logger.info(f"HDR10+ metadata extracted to {metadata}")
+
+ def new_source(self):
+ if not self.app.fastflix.current_video:
+ return
+ super().new_source()
+ if self.app.fastflix.current_video.hdr10_plus:
+ self.extract_button.show()
+ if self.app.fastflix.libavcodec_version >= 62:
+ self.hdr10plus_status_label.setStyleSheet("")
+ self.hdr10plus_status_label.setText(t("HDR10+ detected — will be preserved via FFmpeg passthrough"))
+ else:
+ self.hdr10plus_status_label.setStyleSheet("")
+ self.hdr10plus_status_label.setText(t("HDR10+ detected but requires FFmpeg 8.0+ for AV1 passthrough"))
+ self.hdr10plus_status_label.show()
+ else:
+ self.extract_button.hide()
+ self.hdr10plus_status_label.hide()
+ if self.extract_thread:
+ try:
+ self.extract_thread.terminate()
+ except Exception:
+ pass
+
def init_modes(self):
return self._add_modes(recommended_bitrates, recommended_crfs, qp_name="crf")
@@ -152,16 +313,32 @@ def mode_update(self):
self.main.build_commands()
def update_video_encoder_settings(self):
+ # Parse denoise value from combo box or custom field
+ denoise_text = self.widgets.denoise.currentText()
+ if denoise_text == "Custom":
+ try:
+ denoise_noise_level = int(self.widgets.custom_denoise.text())
+ except (ValueError, TypeError):
+ denoise_noise_level = 0
+ else:
+ denoise_noise_level = int(denoise_text.split(" ")[0])
+
+ aom_params_text = self.widgets.aom_params.text().strip()
+
settings = AOMAV1Settings(
usage=self.widgets.usage.currentText(),
cpu_used=self.widgets.cpu_used.currentText(),
row_mt=self.widgets.row_mt.currentText(),
+ tune=self.widgets.tune.currentText(),
+ aq_mode=self.widgets.aq_mode.currentText().split(" ")[0],
tile_rows=self.widgets.tile_rows.currentText(),
tile_columns=self.widgets.tile_columns.currentText(),
max_muxing_queue_size=self.widgets.max_mux.currentText(),
pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(),
+ denoise_noise_level=denoise_noise_level,
extra=self.ffmpeg_extras,
extra_both_passes=self.widgets.extra_both_passes.isChecked(),
+ aom_params=aom_params_text.split(":") if aom_params_text else [],
)
encode_type, q_value = self.get_mode_settings()
settings.crf = q_value if encode_type == "qp" else None
diff --git a/fastflix/encoders/avc_x264/command_builder.py b/fastflix/encoders/avc_x264/command_builder.py
index 04b8f531..6f7137b9 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,77 @@ 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])
+
+ if settings.aq_mode and settings.aq_mode != "default":
+ aq_mode_map = {"none": "0", "variance": "1", "autovariance": "2", "autovariance-biased": "3"}
+ beginning.extend(["-aq-mode", aq_mode_map[settings.aq_mode]])
+
+ if settings.psy_rd:
+ beginning.extend(["-psy-rd", settings.psy_rd])
+
+ if settings.level and settings.level != "auto":
+ beginning.extend(["-level", settings.level])
+
+ x264_params = settings.x264_params.copy()
+ if x264_params:
+ beginning.extend(["-x264-params", ":".join(x264_params)])
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/avc_x264/settings_panel.py b/fastflix/encoders/avc_x264/settings_panel.py
index 6b7a44df..ab36a25f 100644
--- a/fastflix/encoders/avc_x264/settings_panel.py
+++ b/fastflix/encoders/avc_x264/settings_panel.py
@@ -82,6 +82,11 @@ def __init__(self, parent, main, app: FastFlixApp):
grid.addLayout(self.init_profile(), 3, 0, 1, 2)
grid.addLayout(self.init_pix_fmt(), 4, 0, 1, 2)
+ grid.addLayout(self.init_aq_mode(), 5, 0, 1, 2)
+ grid.addLayout(self.init_psy_rd(), 5, 2, 1, 4)
+ grid.addLayout(self.init_level(), 6, 0, 1, 2)
+ grid.addLayout(self.init_x264_params(), 6, 2, 1, 4)
+
grid.setRowStretch(9, 1)
guide_label = QtWidgets.QLabel(
@@ -150,6 +155,88 @@ def init_pix_fmt(self):
opt="pix_fmt",
)
+ def init_aq_mode(self):
+ return self._add_combo_box(
+ label="AQ Mode",
+ widget_name="aq_mode",
+ options=["default", "none", "variance", "autovariance", "autovariance-biased"],
+ tooltip=(
+ "aq-mode: Adaptive Quantization operating mode.\n"
+ "0: disabled\n"
+ "1: variance AQ (complexity mask)\n"
+ "2: autovariance AQ\n"
+ "3: autovariance AQ with bias to dark scenes\n"
+ "Default: variance AQ"
+ ),
+ opt="aq_mode",
+ )
+
+ def init_psy_rd(self):
+ return self._add_text_box(
+ label="Psy-RD",
+ widget_name="psy_rd",
+ tooltip=(
+ "psy-rd: Psychovisual rate-distortion optimization strength.\n"
+ "Format: rd:trellis (e.g. 1.0:0.0)\n"
+ "First value is psy-rd strength, second is psy-trellis strength.\n"
+ "Requires subme >= 6 for psy-rd and trellis for psy-trellis."
+ ),
+ opt="psy_rd",
+ placeholder="1.0:0.0",
+ )
+
+ def init_level(self):
+ return self._add_combo_box(
+ label="Level",
+ widget_name="level",
+ options=[
+ "auto",
+ "1",
+ "1.1",
+ "1.2",
+ "1.3",
+ "2",
+ "2.1",
+ "2.2",
+ "3",
+ "3.1",
+ "3.2",
+ "4",
+ "4.1",
+ "4.2",
+ "5",
+ "5.1",
+ "5.2",
+ ],
+ tooltip=(
+ "level: Set the H.264 level restriction.\n"
+ "Limits the maximum bitrate, resolution, and other parameters.\n"
+ "Useful for ensuring compatibility with target devices."
+ ),
+ opt="level",
+ )
+
+ def init_x264_params(self):
+ layout = QtWidgets.QHBoxLayout()
+ self.labels.x264_params = QtWidgets.QLabel(t("Additional x264 params"))
+ self.labels.x264_params.setFixedWidth(200)
+ tool_tip = (
+ f"{t('Extra x264 params in opt=1:opt2=0 format')},\n"
+ f"{t('cannot modify generated settings')}\n"
+ f"{t('examples: rc-lookahead=40:ref=6')}\n"
+ )
+ self.labels.x264_params.setToolTip(tool_tip)
+ layout.addWidget(self.labels.x264_params)
+ self.widgets.x264_params = QtWidgets.QLineEdit()
+ self.widgets.x264_params.setToolTip(tool_tip)
+ self.widgets.x264_params.setText(
+ ":".join(self.app.fastflix.config.encoder_opt(self.profile_name, "x264_params"))
+ )
+ self.opts["x264_params"] = "x264_params"
+ self.widgets.x264_params.textChanged.connect(lambda: self.main.page_update())
+ layout.addWidget(self.widgets.x264_params)
+ return layout
+
def init_modes(self):
return self._add_modes(recommended_bitrates, recommended_crfs, qp_name="crf", show_bitrate_passes=True)
@@ -169,6 +256,8 @@ def setting_change(self, update=True):
def update_video_encoder_settings(self):
tune = self.widgets.tune.currentText()
+ x264_params_text = self.widgets.x264_params.text().strip()
+ psy_rd_text = self.widgets.psy_rd.text().strip()
settings = x264Settings(
preset=self.widgets.preset.currentText(),
@@ -179,6 +268,10 @@ def update_video_encoder_settings(self):
tune=tune if tune.lower() != "default" else None,
extra_both_passes=self.widgets.extra_both_passes.isChecked(),
bitrate_passes=int(self.widgets.bitrate_passes.currentText()),
+ aq_mode=self.widgets.aq_mode.currentText(),
+ psy_rd=psy_rd_text if psy_rd_text else None,
+ level=self.widgets.level.currentText(),
+ x264_params=x264_params_text.split(":") if x264_params_text else [],
)
encode_type, q_value = self.get_mode_settings()
settings.crf = q_value if encode_type == "qp" else None
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 97ff1eb7..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,33 +52,54 @@ 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.append(
- f"-map {audio_file_index}:{track.index} "
- f'-metadata:s:{track.outdex} title="{track.title}" '
- f'-metadata:s:{track.outdex} handler="{track.title}"'
- )
+ command_list.extend(["-map", f"{audio_file_index}:{track.index}"])
+ if track.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:
+ 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):
+ except (AssertionError, KeyError, AttributeError):
cl = "stereo"
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 = (
@@ -85,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 = ""
@@ -100,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 706e3394..22250b5a 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,46 @@ 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(
- track.conversion_aq, track.conversion_codec, track.raw_info.get("channels"), audio_id
+ 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]:
+ # Rigaya encoders only support embedded streams, filter out external tracks
+ subtitle_tracks = [t for t in subtitle_tracks if not t.external]
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 +183,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 +191,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 2279b369..0012c8a9 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,21 +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"
+
+
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,
@@ -47,13 +69,53 @@ def generate_ffmpeg_start(
concat: bool = False,
enable_opencl: bool = False,
remove_hdr: bool = True,
- start_extra: str = "",
+ start_extra: Union[List[str], str] = "",
+ extra_inputs: Optional[List[str]] = None,
**_,
-) -> 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(start_extra if isinstance(start_extra, list) else 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)])
+
+ if extra_inputs:
+ command.extend(extra_inputs)
+
+ # 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:
@@ -62,50 +124,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 = video_title.replace('"', '\\"')
- 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('"', '\\"')
- 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, **_):
@@ -133,25 +169,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 = []
+
+ if not disable_rotate_metadata and not remove_metadata:
+ command.extend(["-metadata:s:v", "rotate=0"])
+
+ 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"])
- # Create a list of command parts
- command_parts = [rotate_option, metadata_option, chapters_option, fps_option, audio, subtitles, cover, data_option]
+ fps_option = []
+ if output_fps:
+ fps_option = ["-r", str(output_fps)]
+ command.extend(fps_option)
- # 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 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(
@@ -167,6 +220,7 @@ def generate_filters(
horizontal_flip=None,
burn_in_subtitle_track=None,
burn_in_subtitle_type=None,
+ burn_in_file_index: int = 0,
custom_filters=None,
start_filters=None,
raw_filters=False,
@@ -180,6 +234,7 @@ def generate_filters(
deblock: Union[str, None] = None,
deblock_size: int = 4,
denoise: Union[str, None] = None,
+ color_transfer: Optional[str] = None,
**_,
):
filter_list = []
@@ -234,8 +289,9 @@ def generate_filters(
elif vaapi:
filter_list.append("tonemap_vaapi=format=nv12:p=bt709:t=bt709:m=bt709")
else:
+ tin = color_transfer if color_transfer else "smpte2084"
filter_list.append(
- f"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap={tone_map}:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
+ f"zscale=tin={tin}:t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap={tone_map}:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
)
filters = ",".join(filter_list) if filter_list else ""
@@ -249,21 +305,23 @@ def generate_filters(
if burn_in_subtitle_type == "picture":
if filters:
# You have to overlay first for it to work when scaled
- filter_complex = f"[0:{selected_track}][0:{burn_in_subtitle_track}]overlay[subbed];[subbed]{filters}[v]"
+ filter_complex = f"[0:{selected_track}][{burn_in_file_index}:{burn_in_subtitle_track}]overlay[subbed];[subbed]{filters}[v]"
else:
- filter_complex = f"[0:{selected_track}][0:{burn_in_subtitle_track}]overlay[v]"
+ filter_complex = f"[0:{selected_track}][{burn_in_file_index}:{burn_in_subtitle_track}]overlay[v]"
else:
filter_prefix = f"{filters}," if filters else ""
- filter_complex = f"[0:{selected_track}]{filter_prefix}subtitles='{quoted_path(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(
@@ -273,22 +331,46 @@ def generate_all(
subs: bool = True,
disable_filters: bool = False,
vaapi: bool = False,
- start_extra: str = "",
+ start_extra: Union[List[str], 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
+ # Assign file_index to external subtitle tracks and collect unique external file paths
+ subtitle_tracks = fastflix.current_video.subtitle_tracks
+ extra_input_files = []
+ for track in subtitle_tracks:
+ if track.external and track.file_path:
+ if track.file_path not in extra_input_files:
+ extra_input_files.append(track.file_path)
+ track.file_index = extra_input_files.index(track.file_path) + 1
+ else:
+ track.file_index = 0
+
+ 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(
+ 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:
burn_in_track = i
break
+ # Look up external burn-in info from the track list
+ burn_in_file_path = None
+ burn_in_file_index = 0
+ if burn_in_track is not None:
+ for track in subtitle_tracks:
+ if track.burn_in and track.enabled:
+ if track.external and track.file_path:
+ burn_in_file_path = track.file_path
+ burn_in_file_index = track.file_index
+ break
+
attachments_cmd = build_attachments(fastflix.current_video.attachment_tracks)
enable_opencl = fastflix.opencl_support
@@ -299,10 +381,15 @@ def generate_all(
if not disable_filters:
filter_details = fastflix.current_video.video_settings.model_dump().copy()
filter_details.update(filters_extra)
+ # For text burn-in from external file, pass the external file path as source
+ filter_source = (
+ burn_in_file_path if (burn_in_file_path and burn_in_type == "text") else fastflix.current_video.source
+ )
filters_cmd = generate_filters(
- source=fastflix.current_video.source,
+ source=filter_source,
burn_in_subtitle_track=burn_in_track,
burn_in_subtitle_type=burn_in_type,
+ burn_in_file_index=burn_in_file_index,
scale=fastflix.current_video.scale,
enable_opencl=enable_opencl,
vaapi=vaapi,
@@ -318,15 +405,29 @@ def generate_all(
**fastflix.current_video.video_settings.model_dump(),
)
+ # Build extra -i arguments for external subtitle files
+ # When fast seek is used, -ss/-to before -i only apply to the next input.
+ # External inputs need their own -ss/-to to stay in sync with the seeked video.
+ vs = fastflix.current_video.video_settings
+ extra_inputs = []
+ for file_path in extra_input_files:
+ if vs.fast_seek:
+ if vs.start_time:
+ extra_inputs.extend(["-ss", str(vs.start_time)])
+ if vs.end_time:
+ extra_inputs.extend(["-to", str(vs.end_time)])
+ extra_inputs.extend(["-i", str(file_path)])
+
beginning = generate_ffmpeg_start(
source=fastflix.current_video.source,
ffmpeg=fastflix.config.ffmpeg,
encoder=encoder,
filters=filters_cmd,
concat=fastflix.current_video.concat,
- enable_opencl=enable_opencl,
+ enable_opencl=enable_opencl if not disable_filters else False,
ffmpeg_version=fastflix.ffmpeg_version,
start_extra=start_extra,
+ extra_inputs=extra_inputs if extra_inputs else None,
**fastflix.current_video.video_settings.model_dump(),
**settings.model_dump(),
)
@@ -334,18 +435,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/setting_panel.py b/fastflix/encoders/common/setting_panel.py
index 898cdbfd..af6e86e5 100644
--- a/fastflix/encoders/common/setting_panel.py
+++ b/fastflix/encoders/common/setting_panel.py
@@ -4,11 +4,12 @@
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
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
@@ -112,6 +113,10 @@ def determine_default(self, widget_name, opt, items: List, raise_error: bool = F
try:
return items.index(opt)
except Exception:
+ # Try matching by prefix (e.g. "1" matches "1 - PSNR")
+ for i, item in enumerate(items):
+ if item.split(" - ")[0].strip() == opt:
+ return i
if raise_error:
raise FastFlixInternalException
else:
@@ -143,9 +148,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 +232,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 +374,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 +394,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 +414,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,8 +435,10 @@ 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}"].setValidator(QtGui.QDoubleValidator())
+ self.widgets[f"custom_{qp_name}"].setMinimumWidth(scaler.scale(83))
+ 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())
@@ -500,7 +507,7 @@ def update_profile(self):
self.widgets[widget_name].setChecked(checked)
elif isinstance(self.widgets[widget_name], QtWidgets.QLineEdit):
data = self.app.fastflix.config.encoder_opt(self.profile_name, opt)
- if widget_name in ("x265_params", "svtav1_params", "vvc_params"):
+ if isinstance(data, list):
data = ":".join(data)
self.widgets[widget_name].setText(str(data) or "")
try:
@@ -564,7 +571,7 @@ def reload(self):
elif isinstance(self.widgets[widget_name], QtWidgets.QCheckBox):
self.widgets[widget_name].setChecked(data)
elif isinstance(self.widgets[widget_name], QtWidgets.QLineEdit):
- if widget_name in ("x265_params", "svtav1_params", "vvc_params"):
+ if isinstance(data, list):
data = ":".join(data)
self.widgets[widget_name].setText(str(data) or "")
if getattr(self, "mode", None):
@@ -659,6 +666,15 @@ def init_dhdr10_info(self):
)
return layout
+ def init_dolby_vision_copy(self):
+ layout = self._add_check_box(
+ label="Copy Dolby Vision",
+ widget_name="copy_dv",
+ tooltip="Copy Dolby Vision RPU metadata from input file",
+ opt="copy_dv",
+ )
+ return layout
+
def init_parallel_mode(self, add_split=False):
options = ["none", "parallel"]
if add_split:
diff --git a/fastflix/encoders/common/subtitles.py b/fastflix/encoders/common/subtitles.py
index ee4bc53f..16be1b5f 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 typing import Tuple, Union
+from pathlib import Path
+from typing import List, Tuple, Union
from fastflix.models.video import SubtitleTrack
def build_subtitle(
- subtitle_tracks: list[SubtitleTrack], subtitle_file_index=0
-) -> Tuple[str, Union[int, None], Union[str, None]]:
+ subtitle_tracks: list[SubtitleTrack], subtitle_file_index=0, output_path=None
+) -> Tuple[List[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,10 @@ 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"
+ file_idx = track.file_index if track.file_index else subtitle_file_index
+ command_list.extend(["-map", f"{file_idx}:{track.index}", f"-c:{outdex}", codec])
added = ""
for disposition, is_set in track.dispositions.items():
if is_set:
@@ -29,10 +43,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 60c50023..02a80a20 100644
--- a/fastflix/encoders/gif/command_builder.py
+++ b/fastflix/encoders/gif/command_builder.py
@@ -1,55 +1,98 @@
# -*- 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):
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
+ # This returns List[str] since raw_filters=False (default)
+ 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)
+ output_video = str(sanitize(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' -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} {palletgen_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 []
- gif_filters = f"fps={settings.fps}"
- if filters:
- gif_filters += f",{filters}"
+ 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}:diff_mode=rectangle[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}" '
+ beginning
+ + ["-i", str(temp_palette)]
+ + ["-filter_complex", filter_complex, "-map", "[o]"]
+ + 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/encoders/gifski/__init__.py b/fastflix/encoders/gifski/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/fastflix/encoders/gifski/command_builder.py b/fastflix/encoders/gifski/command_builder.py
new file mode 100644
index 00000000..7eddb069
--- /dev/null
+++ b/fastflix/encoders/gifski/command_builder.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+import shlex
+import subprocess
+import sys
+
+from fastflix.encoders.common.helpers import Command, generate_filters
+from fastflix.models.encode import GifskiSettings
+from fastflix.models.fastflix import FastFlix
+from fastflix.shared import sanitize
+
+
+def build(fastflix: FastFlix):
+ settings: GifskiSettings = fastflix.current_video.video_settings.video_encoder_settings
+ video_settings = fastflix.current_video.video_settings
+
+ scale = fastflix.current_video.scale
+ crop = video_settings.crop.model_dump() if video_settings.crop else None
+
+ 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,
+ remove_hdr=video_settings.remove_hdr,
+ tone_map=video_settings.tone_map,
+ custom_filters=f"fps={settings.fps},format=yuv420p",
+ raw_filters=True,
+ )
+
+ output_video = str(sanitize(fastflix.current_video.video_settings.output_path))
+
+ # Build FFmpeg command to output yuv4mpegpipe to stdout
+ ffmpeg_cmd = [str(fastflix.config.ffmpeg), "-y"]
+ if video_settings.start_time:
+ ffmpeg_cmd.extend(["-ss", str(video_settings.start_time)])
+ if video_settings.end_time:
+ ffmpeg_cmd.extend(["-to", str(video_settings.end_time)])
+ if video_settings.source_fps:
+ ffmpeg_cmd.extend(["-r", str(video_settings.source_fps)])
+ ffmpeg_cmd.extend(["-i", str(fastflix.current_video.source)])
+
+ if filters:
+ ffmpeg_cmd.extend(["-filter_complex", filters, "-map", "[v]"])
+ else:
+ ffmpeg_cmd.extend(["-map", f"0:{video_settings.selected_track}"])
+
+ ffmpeg_cmd.extend(["-f", "yuv4mpegpipe", "-"])
+
+ # Build gifski command to read from stdin
+ gifski_cmd = [str(fastflix.config.gifski)]
+ gifski_cmd.extend(["--fps", str(settings.fps)])
+ gifski_cmd.extend(["--quality", str(settings.quality)])
+
+ if settings.lossy_quality != "auto":
+ gifski_cmd.extend(["--lossy-quality", str(settings.lossy_quality)])
+ if settings.motion_quality != "auto":
+ gifski_cmd.extend(["--motion-quality", str(settings.motion_quality)])
+ if settings.fast:
+ gifski_cmd.append("--fast")
+
+ gifski_cmd.extend(["--width", str(fastflix.current_video.width)])
+ gifski_cmd.extend(["--height", str(fastflix.current_video.height)])
+
+ extra = shlex.split(settings.extra) if settings.extra else []
+ gifski_cmd.extend(extra)
+
+ gifski_cmd.extend(["-o", output_video, "-"])
+
+ # Build shell pipe command string with proper platform quoting
+ if sys.platform == "win32":
+ full_command = subprocess.list2cmdline(ffmpeg_cmd) + " | " + subprocess.list2cmdline(gifski_cmd)
+ else:
+ full_command = shlex.join(ffmpeg_cmd) + " | " + shlex.join(gifski_cmd)
+
+ return [
+ Command(
+ command=full_command,
+ name="GIF (gifski)",
+ exe="gifski",
+ shell=True,
+ ),
+ ]
diff --git a/fastflix/encoders/gifski/main.py b/fastflix/encoders/gifski/main.py
new file mode 100644
index 00000000..38872f6a
--- /dev/null
+++ b/fastflix/encoders/gifski/main.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+__author__ = "Chris Griffith"
+import importlib.resources
+
+name = "GIF (gifski)"
+
+video_extensions = [".gif"]
+video_dimension_divisor = 1
+
+ref = importlib.resources.files("fastflix") / "data/encoders/icon_gifski.png"
+with importlib.resources.as_file(ref) as icon_file:
+ icon = str(icon_file.resolve())
+
+enable_subtitles = False
+enable_audio = False
+enable_attachments = False
+enable_concat = False
+
+audio_formats = []
+
+from fastflix.encoders.gifski.command_builder import build # noqa: F401,E402
+from fastflix.encoders.gifski.settings_panel import Gifski as settings_panel # noqa: F401,E402
diff --git a/fastflix/encoders/gifski/settings_panel.py b/fastflix/encoders/gifski/settings_panel.py
new file mode 100644
index 00000000..4e9f7db7
--- /dev/null
+++ b/fastflix/encoders/gifski/settings_panel.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+from box import Box
+from PySide6 import QtWidgets
+
+from fastflix.encoders.common.setting_panel import SettingPanel
+from fastflix.models.encode import GifskiSettings
+from fastflix.models.fastflix_app import FastFlixApp
+
+
+class Gifski(SettingPanel):
+ profile_name = "gifski"
+
+ def __init__(self, parent, main, app: FastFlixApp):
+ super().__init__(parent, main, app)
+ self.main = main
+ self.app = app
+
+ grid = QtWidgets.QGridLayout()
+ self.widgets = Box(fps=None, quality=None)
+
+ grid.addLayout(self.init_fps(), 0, 0, 1, 2)
+ grid.addLayout(self.init_quality(), 1, 0, 1, 2)
+ grid.addLayout(self.init_lossy_quality(), 2, 0, 1, 2)
+ grid.addLayout(self.init_motion_quality(), 3, 0, 1, 2)
+ grid.addLayout(self.init_fast(), 4, 0, 1, 2)
+ grid.addLayout(self._add_custom(disable_both_passes=True), 11, 0, 1, 6)
+
+ grid.addWidget(QtWidgets.QWidget(), 5, 0, 5, 6)
+ grid.rowStretch(5)
+ self.setLayout(grid)
+
+ def init_fps(self):
+ return self._add_combo_box(
+ label="FPS",
+ widget_name="fps",
+ tooltip="Frames Per Second",
+ options=[str(x) for x in range(1, 51)],
+ opt="fps",
+ )
+
+ def init_quality(self):
+ return self._add_combo_box(
+ label="Quality",
+ widget_name="quality",
+ tooltip="Overall quality (1-100, higher is better quality but larger file size)",
+ options=[str(x) for x in range(1, 101)],
+ default=89,
+ opt="quality",
+ )
+
+ def init_lossy_quality(self):
+ return self._add_combo_box(
+ label="Lossy Quality",
+ widget_name="lossy_quality",
+ tooltip="Lower values reduce file size at cost of more noise/grain (1-100, or auto to let gifski decide)",
+ options=["auto"] + [str(x) for x in range(1, 101)],
+ opt="lossy_quality",
+ )
+
+ def init_motion_quality(self):
+ return self._add_combo_box(
+ label="Motion Quality",
+ widget_name="motion_quality",
+ tooltip="Lower values reduce file size for animations with motion (1-100, or auto to let gifski decide)",
+ options=["auto"] + [str(x) for x in range(1, 101)],
+ opt="motion_quality",
+ )
+
+ def init_fast(self):
+ return self._add_check_box(
+ label="Fast Mode",
+ widget_name="fast",
+ tooltip="Encode faster at cost of quality",
+ opt="fast",
+ )
+
+ def update_video_encoder_settings(self):
+ self.app.fastflix.current_video.video_settings.video_encoder_settings = GifskiSettings(
+ fps=self.widgets.fps.currentText(),
+ quality=self.widgets.quality.currentText(),
+ lossy_quality=self.widgets.lossy_quality.currentText(),
+ motion_quality=self.widgets.motion_quality.currentText(),
+ fast=self.widgets.fast.isChecked(),
+ extra=self.ffmpeg_extras,
+ pix_fmt="yuv420p",
+ )
+
+ def new_source(self):
+ super().new_source()
diff --git a/fastflix/encoders/h264_videotoolbox/command_builder.py b/fastflix/encoders/h264_videotoolbox/command_builder.py
index 8467ddc9..9e8ebad3 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",
+ str(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..6746fb63 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",
+ str(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 3c8d4b19..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,53 +12,69 @@ def build(fastflix: FastFlix):
ffmpeg = fastflix.config.ffmpeg
source = fastflix.current_video.source
- if video_title:
- video_title = video_title.replace('"', '\\"')
- 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('"', '\\"')
- track_title = f'-metadata:s:v:0 title="{video_track_title}"'
+ beginning = [str(ffmpeg), "-y", "-i", str(source)]
- beginning = " ".join(
- [
- f'"{ffmpeg}"',
- "-y",
- f'-i "{source}"',
- " ", # Leave space after commands
- ]
+ 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 []
+
+ 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..45af1dab 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,133 @@ 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", 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(["--cqp", str(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))
- source_fps = f"--fps {video.video_settings.source_fps}" if video.video_settings.source_fps else ""
+ 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"])
+ if settings.copy_dv:
+ command.extend(["--dolby-vision-rpu", "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"
-
- 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_av1/settings_panel.py b/fastflix/encoders/nvencc_av1/settings_panel.py
index 4c650053..43d2e6e5 100644
--- a/fastflix/encoders/nvencc_av1/settings_panel.py
+++ b/fastflix/encoders/nvencc_av1/settings_panel.py
@@ -127,7 +127,11 @@ def __init__(self, parent, main, app: FastFlixApp):
even_more.addLayout(self.init_metrics())
grid.addLayout(even_more, 7, 2, 1, 4)
- grid.addLayout(self.init_dhdr10_info(), 8, 2, 1, 2)
+ hdr_line = QtWidgets.QHBoxLayout()
+ hdr_line.addLayout(self.init_dhdr10_info())
+ hdr_line.addStretch(1)
+ hdr_line.addLayout(self.init_dolby_vision_copy())
+ grid.addLayout(hdr_line, 8, 2, 1, 2)
grid.addLayout(self.init_parallel_mode(add_split=True), 8, 4, 1, 2)
grid.setRowStretch(9, 1)
@@ -391,6 +395,7 @@ def update_video_encoder_settings(self):
aq=self.widgets.aq.currentText(),
aq_strength=self.widgets.aq_strength.currentIndex(),
copy_hdr10=self.widgets.copy_hdr10.isChecked(),
+ copy_dv=self.widgets.copy_dv.isChecked(),
multipass=self.widgets.multipass.currentText(),
mv_precision=self.widgets.mv_precision.currentText(),
init_q_i=self.widgets.init_q_i.currentText() if self.widgets.init_q_i.currentIndex() != 0 else None,
diff --git a/fastflix/encoders/nvencc_avc/command_builder.py b/fastflix/encoders/nvencc_avc/command_builder.py
index ad6fc932..376b68c8 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", 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", "avc"])
+
+ if settings.bitrate:
+ command.extend(["--vbr", settings.bitrate.rstrip("k")])
+ else:
+ command.extend(["--cqp", str(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..939a774a 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,133 @@ 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", 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(["--cqp", str(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"])
+ if settings.copy_dv:
+ command.extend(["--dolby-vision-rpu", "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/nvencc_hevc/settings_panel.py b/fastflix/encoders/nvencc_hevc/settings_panel.py
index 55de2af3..abe4b746 100644
--- a/fastflix/encoders/nvencc_hevc/settings_panel.py
+++ b/fastflix/encoders/nvencc_hevc/settings_panel.py
@@ -127,7 +127,11 @@ def __init__(self, parent, main, app: FastFlixApp):
even_more.addLayout(self.init_metrics())
grid.addLayout(even_more, 7, 2, 1, 4)
- grid.addLayout(self.init_dhdr10_info(), 8, 2, 1, 2)
+ hdr_line = QtWidgets.QHBoxLayout()
+ hdr_line.addLayout(self.init_dhdr10_info())
+ hdr_line.addStretch(1)
+ hdr_line.addLayout(self.init_dolby_vision_copy())
+ grid.addLayout(hdr_line, 8, 2, 1, 2)
grid.addLayout(self.init_parallel_mode(add_split=True), 8, 4, 1, 2)
grid.setRowStretch(9, 1)
@@ -391,6 +395,7 @@ def update_video_encoder_settings(self):
aq=self.widgets.aq.currentText(),
aq_strength=self.widgets.aq_strength.currentIndex(),
copy_hdr10=self.widgets.copy_hdr10.isChecked(),
+ copy_dv=self.widgets.copy_dv.isChecked(),
multipass=self.widgets.multipass.currentText(),
mv_precision=self.widgets.mv_precision.currentText(),
init_q_i=self.widgets.init_q_i.currentText() if self.widgets.init_q_i.currentIndex() != 0 else None,
diff --git a/fastflix/encoders/qsvencc_av1/command_builder.py b/fastflix/encoders/qsvencc_av1/command_builder.py
index 176679ac..da79c16b 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,166 @@ 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"])
+ if settings.copy_dv:
+ command.extend(["--dolby-vision-rpu", "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_av1/settings_panel.py b/fastflix/encoders/qsvencc_av1/settings_panel.py
index 2284acdf..e47e0c50 100644
--- a/fastflix/encoders/qsvencc_av1/settings_panel.py
+++ b/fastflix/encoders/qsvencc_av1/settings_panel.py
@@ -130,7 +130,11 @@ def __init__(self, parent, main, app: FastFlixApp):
# adapt_line.addLayout(self.init_adapt_cqm())
# grid.addLayout(adapt_line, 7, 2, 1, 4)
#
- grid.addLayout(self.init_dhdr10_info(), 7, 2, 1, 2)
+ hdr_line = QtWidgets.QHBoxLayout()
+ hdr_line.addLayout(self.init_dhdr10_info())
+ hdr_line.addStretch(1)
+ hdr_line.addLayout(self.init_dolby_vision_copy())
+ grid.addLayout(hdr_line, 7, 2, 1, 2)
grid.addLayout(self.init_parallel_mode(), 7, 4, 1, 2)
self.ffmpeg_level = QtWidgets.QLabel()
grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4)
@@ -318,6 +322,7 @@ def update_video_encoder_settings(self):
force_ten_bit=self.widgets.force_ten_bit.isChecked(),
lookahead=self.widgets.lookahead.currentText() if self.widgets.lookahead.currentIndex() > 0 else None,
copy_hdr10=self.widgets.copy_hdr10.isChecked(),
+ copy_dv=self.widgets.copy_dv.isChecked(),
max_q_i=self.widgets.max_q_i.currentText() if self.widgets.max_q_i.currentIndex() != 0 else None,
max_q_p=self.widgets.max_q_p.currentText() if self.widgets.max_q_p.currentIndex() != 0 else None,
max_q_b=self.widgets.max_q_b.currentText() if self.widgets.max_q_b.currentIndex() != 0 else None,
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..1a2dc62e 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,166 @@ 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"])
+ if settings.copy_dv:
+ command.extend(["--dolby-vision-rpu", "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/qsvencc_hevc/settings_panel.py b/fastflix/encoders/qsvencc_hevc/settings_panel.py
index 3117698e..46d3fb70 100644
--- a/fastflix/encoders/qsvencc_hevc/settings_panel.py
+++ b/fastflix/encoders/qsvencc_hevc/settings_panel.py
@@ -122,7 +122,11 @@ def __init__(self, parent, main, app: FastFlixApp):
advanced.addLayout(self.init_metrics())
grid.addLayout(advanced, 6, 2, 1, 4)
- grid.addLayout(self.init_dhdr10_info(), 7, 2, 1, 2)
+ hdr_line = QtWidgets.QHBoxLayout()
+ hdr_line.addLayout(self.init_dhdr10_info())
+ hdr_line.addStretch(1)
+ hdr_line.addLayout(self.init_dolby_vision_copy())
+ grid.addLayout(hdr_line, 7, 2, 1, 2)
grid.addLayout(self.init_parallel_mode(), 7, 4, 1, 2)
self.ffmpeg_level = QtWidgets.QLabel()
grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4)
@@ -311,6 +315,7 @@ def update_video_encoder_settings(self):
force_ten_bit=self.widgets.force_ten_bit.isChecked(),
lookahead=self.widgets.lookahead.currentText() if self.widgets.lookahead.currentIndex() > 0 else None,
copy_hdr10=self.widgets.copy_hdr10.isChecked(),
+ copy_dv=self.widgets.copy_dv.isChecked(),
max_q_i=self.widgets.max_q_i.currentText() if self.widgets.max_q_i.currentIndex() != 0 else None,
max_q_p=self.widgets.max_q_p.currentText() if self.widgets.max_q_p.currentIndex() != 0 else None,
max_q_b=self.widgets.max_q_b.currentText() if self.widgets.max_q_b.currentIndex() != 0 else None,
diff --git a/fastflix/encoders/rav1e/command_builder.py b/fastflix/encoders/rav1e/command_builder.py
index 3c8ce349..0166dcd2 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,51 +16,75 @@ 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(
+ [
+ "-speed",
+ str(settings.speed),
+ "-tile-columns",
+ str(settings.tile_columns),
+ "-tile-rows",
+ str(settings.tile_rows),
+ "-tiles",
+ str(settings.tiles),
+ ]
)
+ beginning.extend(generate_color_details(fastflix))
+
+ rav1e_params = settings.rav1e_params.copy()
+
+ if settings.tune != "default":
+ rav1e_params.append(f"tune={settings.tune}")
+
+ if settings.photon_noise > 0:
+ rav1e_params.append(f"photon_noise={settings.photon_noise}")
- # if not fastflix.current_video.video_settings.remove_hdr:
-
- # Currently unsupported https://github.com/xiph/rav1e/issues/2554
- # rav1e_options = []
- # if side_data.master_display:
- # rav1e_options.append(
- # "mastering-display="
- # f"G{side_data.master_display.green}"
- # f"B{side_data.master_display.blue}"
- # f"R{side_data.master_display.red}"
- # f"WP{side_data.master_display.white}"
- # f"L{side_data.master_display.luminance}"
- # )
- #
- # if side_data.cll:
- # rav1e_options.append(f"content-light={side_data.cll}")
- # if rav1e_options:
- # opts = ":".join(rav1e_options)
- # beginning += f'-rav1e-params "{opts}"'
+ if not settings.scene_detection:
+ rav1e_params.append("no_scene_detection=true")
+
+ if not fastflix.current_video.video_settings.remove_hdr:
+ if settings.pix_fmt in ("yuv420p10le", "yuv420p12le"):
+ if fastflix.current_video.master_display:
+ rav1e_params.append(
+ "mastering_display="
+ f"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}"
+ )
+
+ if fastflix.current_video.cll:
+ rav1e_params.append(f"content_light={fastflix.current_video.cll}")
+
+ if rav1e_params:
+ beginning.extend(["-rav1e-params", ":".join(rav1e_params)])
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/rav1e/settings_panel.py b/fastflix/encoders/rav1e/settings_panel.py
index 2f7d9940..f0ebdc62 100644
--- a/fastflix/encoders/rav1e/settings_panel.py
+++ b/fastflix/encoders/rav1e/settings_panel.py
@@ -28,20 +28,15 @@
]
recommended_qp = [
- "20",
- "21",
- "22",
- "23",
- "24 - recommended",
- "25",
- "26",
- "27",
- "28",
- "29",
- "30 - standard",
- "31",
- "32",
- '50 - "I\'m just testing to see if this works"',
+ "60 - high quality",
+ "70",
+ "80 - recommended",
+ "90",
+ "100 - rav1e default",
+ "110",
+ "120",
+ "140",
+ '200 - "I\'m just testing to see if this works"',
"Custom",
]
pix_fmts = [
@@ -56,6 +51,15 @@
"12-bit 444: yuv444p12le",
]
+photon_noise_options = [
+ "0 - Disabled",
+ "4 - Light",
+ "8 - Normal",
+ "16 - Heavy",
+ "32 - Very heavy",
+ "Custom",
+]
+
class RAV1E(SettingPanel):
profile_name = "rav1e"
@@ -70,14 +74,19 @@ def __init__(self, parent, main, app: FastFlixApp):
self.mode = "QP"
grid.addLayout(self.init_speed(), 0, 0, 1, 2)
- grid.addLayout(self.init_tiles(), 1, 0, 1, 2)
- grid.addLayout(self.init_tile_rows(), 2, 0, 1, 2)
- grid.addLayout(self.init_tile_columns(), 3, 0, 1, 2)
- grid.addLayout(self.init_pix_fmt(), 4, 0, 1, 2)
- grid.addLayout(self.init_max_mux(), 5, 0, 1, 2)
+ grid.addLayout(self.init_tune(), 1, 0, 1, 2)
+ grid.addLayout(self.init_tiles(), 2, 0, 1, 2)
+ grid.addLayout(self.init_tile_rows(), 3, 0, 1, 2)
+ grid.addLayout(self.init_tile_columns(), 4, 0, 1, 2)
+ grid.addLayout(self.init_pix_fmt(), 5, 0, 1, 2)
+ grid.addLayout(self.init_sc_detection(), 6, 0, 1, 2)
+ grid.addLayout(self.init_max_mux(), 7, 0, 1, 2)
grid.addLayout(self.init_modes(), 0, 2, 5, 4)
grid.addLayout(self.init_single_pass(), 5, 2, 1, 1)
+ grid.addLayout(self.init_photon_noise(), 6, 2, 1, 4)
+ grid.addLayout(self.init_rav1e_params(), 7, 2, 1, 4)
+
grid.addLayout(self._add_custom(), 10, 0, 1, 6)
grid.setRowStretch(9, 1)
@@ -99,6 +108,15 @@ def init_speed(self):
opt="speed",
)
+ def init_tune(self):
+ return self._add_combo_box(
+ label="Tune",
+ tooltip="Quality tuning metric (Psychovisual for perceptual quality, Psnr for objective quality)",
+ widget_name="tune",
+ options=["default", "Psychovisual", "Psnr"],
+ opt="tune",
+ )
+
def init_tile_rows(self):
return self._add_combo_box(
label="Tile Rows",
@@ -125,6 +143,15 @@ def init_tiles(self):
def init_single_pass(self):
return self._add_check_box(label="Single Pass (Bitrate)", widget_name="single_pass", opt="single_pass")
+ def init_sc_detection(self):
+ return self._add_combo_box(
+ label="Scene Detection",
+ tooltip="Enable scene detection for better keyframe placement",
+ options=["true", "false"],
+ widget_name="sc_detection",
+ opt="scene_detection",
+ )
+
def init_pix_fmt(self):
return self._add_combo_box(
label="Bit Depth",
@@ -134,6 +161,61 @@ def init_pix_fmt(self):
opt="pix_fmt",
)
+ def init_photon_noise(self):
+ layout = QtWidgets.QHBoxLayout()
+ self.labels.photon_noise = QtWidgets.QLabel(t("Photon Noise"))
+ self.labels.photon_noise.setFixedWidth(200)
+ self.labels.photon_noise.setToolTip(t("Film grain synthesis strength (0=off, higher=more grain)"))
+ layout.addWidget(self.labels.photon_noise)
+ self.widgets.photon_noise = QtWidgets.QComboBox()
+ self.widgets.photon_noise.addItems(photon_noise_options)
+ self.widgets.photon_noise.setToolTip(t("Film grain synthesis strength (0=off, higher=more grain)"))
+ self.widgets.photon_noise.currentIndexChanged.connect(lambda: self.photon_noise_update())
+ self.opts["photon_noise"] = "photon_noise"
+ layout.addWidget(self.widgets.photon_noise)
+ self.widgets.custom_photon_noise = QtWidgets.QLineEdit()
+ self.widgets.custom_photon_noise.setFixedWidth(60)
+ self.widgets.custom_photon_noise.setDisabled(True)
+ self.widgets.custom_photon_noise.setToolTip(t("Custom photon noise value (0-64)"))
+ self.widgets.custom_photon_noise.textChanged.connect(lambda: self.main.page_update())
+ layout.addWidget(self.widgets.custom_photon_noise)
+
+ saved = self.app.fastflix.config.encoder_opt(self.profile_name, "photon_noise")
+ if saved and str(saved) != "0":
+ matched = False
+ for i, opt in enumerate(photon_noise_options):
+ if opt.startswith(str(saved)):
+ self.widgets.photon_noise.setCurrentIndex(i)
+ matched = True
+ break
+ if not matched:
+ self.widgets.photon_noise.setCurrentIndex(len(photon_noise_options) - 1)
+ self.widgets.custom_photon_noise.setText(str(saved))
+
+ return layout
+
+ def photon_noise_update(self):
+ custom = self.widgets.photon_noise.currentText() == "Custom"
+ self.widgets.custom_photon_noise.setDisabled(not custom)
+ self.main.page_update()
+
+ def init_rav1e_params(self):
+ layout = QtWidgets.QHBoxLayout()
+ self.labels.rav1e_params = QtWidgets.QLabel(t("Additional rav1e params"))
+ self.labels.rav1e_params.setFixedWidth(200)
+ tool_tip = f"{t('Extra rav1e params in opt=1:opt2=0 format')},\n{t('cannot modify generated settings')}"
+ self.labels.rav1e_params.setToolTip(tool_tip)
+ layout.addWidget(self.labels.rav1e_params)
+ self.widgets.rav1e_params = QtWidgets.QLineEdit()
+ self.widgets.rav1e_params.setToolTip(tool_tip)
+ self.widgets.rav1e_params.setText(
+ ":".join(self.app.fastflix.config.encoder_opt(self.profile_name, "rav1e_params"))
+ )
+ self.opts["rav1e_params"] = "rav1e_params"
+ self.widgets.rav1e_params.textChanged.connect(lambda: self.main.page_update())
+ layout.addWidget(self.widgets.rav1e_params)
+ return layout
+
def init_modes(self):
return self._add_modes(recommended_bitrates, recommended_qp, qp_name="qp")
@@ -143,16 +225,32 @@ def mode_update(self):
self.main.build_commands()
def update_video_encoder_settings(self):
+ # Parse photon noise value from combo box or custom field
+ photon_noise_text = self.widgets.photon_noise.currentText()
+ if photon_noise_text == "Custom":
+ try:
+ photon_noise = int(self.widgets.custom_photon_noise.text())
+ except (ValueError, TypeError):
+ photon_noise = 0
+ else:
+ photon_noise = int(photon_noise_text.split(" ")[0])
+
+ rav1e_params_text = self.widgets.rav1e_params.text().strip()
+
settings = rav1eSettings(
speed=self.widgets.speed.currentText(),
+ tune=self.widgets.tune.currentText(),
tile_columns=self.widgets.tile_columns.currentText(),
tile_rows=self.widgets.tile_rows.currentText(),
tiles=self.widgets.tiles.currentText(),
single_pass=self.widgets.single_pass.isChecked(),
+ scene_detection=bool(self.widgets.sc_detection.currentIndex() == 0),
+ photon_noise=photon_noise,
max_muxing_queue_size=self.widgets.max_mux.currentText(),
extra=self.ffmpeg_extras,
extra_both_passes=self.widgets.extra_both_passes.isChecked(),
pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(),
+ rav1e_params=rav1e_params_text.split(":") if rav1e_params_text else [],
)
encode_type, q_value = self.get_mode_settings()
settings.qp = q_value if encode_type == "qp" else None
diff --git a/fastflix/encoders/svt_av1/command_builder.py b/fastflix/encoders/svt_av1/command_builder.py
index fea25f04..41ff80c7 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,9 +19,22 @@ 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()
+
+ if settings.tune != "1":
+ svtav1_params.append(f"tune={settings.tune}")
+ if settings.film_grain:
+ svtav1_params.append(f"film-grain={settings.film_grain}")
+ if settings.film_grain_denoise:
+ svtav1_params.append("film-grain-denoise=1")
+ if settings.sharpness != "0":
+ svtav1_params.append(f"sharpness={settings.sharpness}")
+ if settings.fast_decode != "0":
+ svtav1_params.append(f"fast-decode={settings.fast_decode}")
+
svtav1_params.extend(
[
f"tile-columns={settings.tile_columns}",
@@ -78,31 +92,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/settings_panel.py b/fastflix/encoders/svt_av1/settings_panel.py
index 6f37d8c6..a5825d5b 100644
--- a/fastflix/encoders/svt_av1/settings_panel.py
+++ b/fastflix/encoders/svt_av1/settings_panel.py
@@ -57,6 +57,16 @@
]
pix_fmts = ["8-bit: yuv420p", "10-bit: yuv420p10le"]
+film_grain_options = [
+ "0 - Disabled",
+ "4 - Animation",
+ "6 - Light grain",
+ "8 - Normal",
+ "10 - Heavy grain",
+ "15 - Very heavy",
+ "Custom",
+]
+
class SVT_AV1(SettingPanel):
profile_name = "svt_av1"
@@ -73,17 +83,21 @@ def __init__(self, parent, main, app: FastFlixApp):
self.mode = "CRF"
grid.addLayout(self.init_preset(), 0, 0, 1, 2)
- grid.addLayout(self.init_pix_fmt(), 1, 0, 1, 2)
- grid.addLayout(self.init_tile_rows(), 2, 0, 1, 2)
- grid.addLayout(self.init_tile_columns(), 3, 0, 1, 2)
- grid.addLayout(self.init_qp_or_crf(), 6, 0, 1, 2)
- grid.addLayout(self.init_sc_detection(), 4, 0, 1, 2)
- grid.addLayout(self.init_max_mux(), 5, 0, 1, 2)
+ grid.addLayout(self.init_tune(), 1, 0, 1, 2)
+ grid.addLayout(self.init_pix_fmt(), 2, 0, 1, 2)
+ grid.addLayout(self.init_tile_rows(), 3, 0, 1, 2)
+ grid.addLayout(self.init_tile_columns(), 4, 0, 1, 2)
+ grid.addLayout(self.init_sc_detection(), 5, 0, 1, 2)
+ grid.addLayout(self.init_max_mux(), 6, 0, 1, 2)
+ grid.addLayout(self.init_qp_or_crf(), 7, 0, 1, 2)
+ grid.addLayout(self.init_sharpness(), 8, 0, 1, 2)
+ grid.addLayout(self.init_fast_decode(), 9, 0, 1, 2)
grid.addLayout(self.init_modes(), 0, 2, 5, 4)
- # grid.addLayout(self.init_single_pass(), 6, 2, 1, 1)
- grid.addLayout(self.init_svtav1_params(), 5, 2, 1, 4)
+ grid.addLayout(self.init_film_grain(), 5, 2, 1, 4)
+ grid.addLayout(self.init_film_grain_denoise(), 6, 2, 1, 4)
+ grid.addLayout(self.init_svtav1_params(), 7, 2, 1, 4)
- grid.setRowStretch(8, 1)
+ grid.setRowStretch(12, 1)
guide_label = QtWidgets.QLabel(
link(
"https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Ffmpeg.md",
@@ -93,8 +107,8 @@ def __init__(self, parent, main, app: FastFlixApp):
)
guide_label.setAlignment(QtCore.Qt.AlignBottom)
guide_label.setOpenExternalLinks(True)
- grid.addLayout(self._add_custom(), 10, 0, 1, 6)
- grid.addWidget(guide_label, 11, 0, -1, 1)
+ grid.addLayout(self._add_custom(), 14, 0, 1, 6)
+ grid.addWidget(guide_label, 15, 0, -1, 1)
self.setLayout(grid)
self.hide()
@@ -147,6 +161,79 @@ def init_preset(self):
opt="speed",
)
+ def init_tune(self):
+ return self._add_combo_box(
+ label="Tune",
+ widget_name="tune",
+ options=["0 - VQ (Psychovisual)", "1 - PSNR", "2 - SSIM"],
+ tooltip="Optimize encoding for different quality metrics",
+ opt="tune",
+ )
+
+ def init_sharpness(self):
+ return self._add_combo_box(
+ label="Sharpness",
+ widget_name="sharpness",
+ options=[str(x) for x in range(-7, 8)],
+ tooltip="Deblocking loop filter sharpness (-7 to 7, 0=default)",
+ opt="sharpness",
+ )
+
+ def init_fast_decode(self):
+ return self._add_combo_box(
+ label="Fast Decode",
+ widget_name="fast_decode",
+ options=["0 - Disabled", "1 - Level 1", "2 - Level 2"],
+ tooltip="Tune settings for faster decoding at the cost of quality",
+ opt="fast_decode",
+ )
+
+ def init_film_grain(self):
+ layout = QtWidgets.QHBoxLayout()
+ self.labels.film_grain = QtWidgets.QLabel(t("Film Grain"))
+ self.labels.film_grain.setFixedWidth(200)
+ self.labels.film_grain.setToolTip(t("Film grain synthesis level (0=off, higher=more grain)"))
+ layout.addWidget(self.labels.film_grain)
+ self.widgets.film_grain = QtWidgets.QComboBox()
+ self.widgets.film_grain.addItems(film_grain_options)
+ self.widgets.film_grain.setToolTip(t("Film grain synthesis level (0=off, higher=more grain)"))
+ self.widgets.film_grain.currentIndexChanged.connect(lambda: self.film_grain_update())
+ self.opts["film_grain"] = "film_grain"
+ layout.addWidget(self.widgets.film_grain)
+ self.widgets.custom_film_grain = QtWidgets.QLineEdit()
+ self.widgets.custom_film_grain.setFixedWidth(60)
+ self.widgets.custom_film_grain.setDisabled(True)
+ self.widgets.custom_film_grain.setToolTip(t("Custom film grain value (0-50)"))
+ self.widgets.custom_film_grain.textChanged.connect(lambda: self.main.page_update())
+ layout.addWidget(self.widgets.custom_film_grain)
+
+ saved = self.app.fastflix.config.encoder_opt(self.profile_name, "film_grain")
+ if saved and str(saved) != "0":
+ matched = False
+ for i, opt in enumerate(film_grain_options):
+ if opt.startswith(str(saved)):
+ self.widgets.film_grain.setCurrentIndex(i)
+ matched = True
+ break
+ if not matched:
+ self.widgets.film_grain.setCurrentIndex(len(film_grain_options) - 1)
+ self.widgets.custom_film_grain.setText(str(saved))
+
+ return layout
+
+ def film_grain_update(self):
+ custom = self.widgets.film_grain.currentText() == "Custom"
+ self.widgets.custom_film_grain.setDisabled(not custom)
+ self.main.page_update()
+
+ def init_film_grain_denoise(self):
+ return self._add_check_box(
+ label="Film Grain Denoise",
+ widget_name="film_grain_denoise",
+ tooltip="Apply denoising when film grain is enabled",
+ opt="film_grain_denoise",
+ )
+
def init_qp_or_crf(self):
return self._add_combo_box(
label="Quantization Mode",
@@ -184,16 +271,30 @@ def mode_update(self):
def update_video_encoder_settings(self):
svtav1_params_text = self.widgets.svtav1_params.text().strip()
+ # Parse film grain value from combo box or custom field
+ film_grain_text = self.widgets.film_grain.currentText()
+ if film_grain_text == "Custom":
+ try:
+ film_grain = int(self.widgets.custom_film_grain.text())
+ except (ValueError, TypeError):
+ film_grain = 0
+ else:
+ film_grain = int(film_grain_text.split(" ")[0])
+
settings = SVTAV1Settings(
speed=self.widgets.speed.currentText(),
+ tune=self.widgets.tune.currentText().split(" ")[0],
tile_columns=self.widgets.tile_columns.currentText(),
tile_rows=self.widgets.tile_rows.currentText(),
- # single_pass=self.widgets.single_pass.isChecked(),
single_pass=True,
scene_detection=bool(self.widgets.sc_detection.currentIndex()),
qp_mode=self.widgets.qp_mode.currentText(),
pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(),
max_muxing_queue_size=self.widgets.max_mux.currentText(),
+ film_grain=film_grain,
+ film_grain_denoise=self.widgets.film_grain_denoise.isChecked(),
+ sharpness=self.widgets.sharpness.currentText(),
+ fast_decode=self.widgets.fast_decode.currentText().split(" ")[0],
extra=self.ffmpeg_extras,
extra_both_passes=self.widgets.extra_both_passes.isChecked(),
svtav1_params=svtav1_params_text.split(":") if svtav1_params_text else [],
diff --git a/fastflix/encoders/svt_av1_avif/command_builder.py b/fastflix/encoders/svt_av1_avif/command_builder.py
index 93e98abe..ac0751e4 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,10 +18,16 @@ 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()
+ if settings.tune != "1":
+ svtav1_params.append(f"tune={settings.tune}")
+ if settings.sharpness != "0":
+ svtav1_params.append(f"sharpness={settings.sharpness}")
+
if not fastflix.current_video.video_settings.remove_hdr:
if (
fastflix.current_video.video_settings.color_primaries == "bt2020"
@@ -66,15 +73,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/svt_av1_avif/settings_panel.py b/fastflix/encoders/svt_av1_avif/settings_panel.py
index 00ebd18f..63501404 100644
--- a/fastflix/encoders/svt_av1_avif/settings_panel.py
+++ b/fastflix/encoders/svt_av1_avif/settings_panel.py
@@ -73,8 +73,10 @@ def __init__(self, parent, main, app: FastFlixApp):
self.mode = "QP"
grid.addLayout(self.init_preset(), 0, 0, 1, 2)
- grid.addLayout(self.init_pix_fmt(), 1, 0, 1, 2)
- grid.addLayout(self.init_qp_or_crf(), 5, 0, 1, 2)
+ grid.addLayout(self.init_tune(), 1, 0, 1, 2)
+ grid.addLayout(self.init_pix_fmt(), 2, 0, 1, 2)
+ grid.addLayout(self.init_sharpness(), 3, 0, 1, 2)
+ grid.addLayout(self.init_qp_or_crf(), 4, 0, 1, 2)
grid.addLayout(self.init_modes(), 0, 2, 5, 4)
grid.addLayout(self.init_svtav1_params(), 5, 2, 1, 4)
@@ -111,6 +113,24 @@ def init_preset(self):
opt="speed",
)
+ def init_tune(self):
+ return self._add_combo_box(
+ label="Tune",
+ widget_name="tune",
+ options=["0 - VQ (Psychovisual)", "1 - PSNR", "2 - SSIM"],
+ tooltip="Optimize encoding for different quality metrics",
+ opt="tune",
+ )
+
+ def init_sharpness(self):
+ return self._add_combo_box(
+ label="Sharpness",
+ widget_name="sharpness",
+ options=[str(x) for x in range(-7, 8)],
+ tooltip="Deblocking loop filter sharpness (-7 to 7, 0=default)",
+ opt="sharpness",
+ )
+
def init_qp_or_crf(self):
return self._add_combo_box(
label="Quantization Mode",
@@ -150,6 +170,8 @@ def update_video_encoder_settings(self):
settings = SVTAVIFSettings(
speed=self.widgets.speed.currentText(),
+ tune=self.widgets.tune.currentText().split(" ")[0],
+ sharpness=self.widgets.sharpness.currentText(),
qp_mode=self.widgets.qp_mode.currentText(),
pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(),
extra=self.ffmpeg_extras,
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..f98cc92d 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,115 @@ 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", 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([f"--{settings.bitrate_mode}", settings.bitrate.rstrip("k")])
+ else:
+ command.extend(["--cqp", str(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"])
+ if settings.copy_dv:
+ command.extend(["--dolby-vision-rpu", "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_av1/settings_panel.py b/fastflix/encoders/vceencc_av1/settings_panel.py
index ad407c0f..949798c6 100644
--- a/fastflix/encoders/vceencc_av1/settings_panel.py
+++ b/fastflix/encoders/vceencc_av1/settings_panel.py
@@ -112,7 +112,11 @@ def __init__(self, parent, main, app: FastFlixApp):
# grid.addLayout(self.pa_row_2, 8, 0, 1, 6)
grid.addLayout(self.init_devices(), 9, 0, 1, 2)
- grid.addLayout(self.init_dhdr10_info(), 9, 2, 1, 4)
+ hdr_line = QtWidgets.QHBoxLayout()
+ hdr_line.addLayout(self.init_dhdr10_info())
+ hdr_line.addStretch(1)
+ hdr_line.addLayout(self.init_dolby_vision_copy())
+ grid.addLayout(hdr_line, 9, 2, 1, 4)
self.ffmpeg_level = QtWidgets.QLabel()
grid.addWidget(self.ffmpeg_level, 10, 2, 1, 4)
@@ -284,6 +288,7 @@ def update_video_encoder_settings(self):
vbaq=self.widgets.vbaq.isChecked(),
decoder=self.widgets.decoder.currentText(),
copy_hdr10=self.widgets.copy_hdr10.isChecked(),
+ copy_dv=self.widgets.copy_dv.isChecked(),
bitrate_mode=self.widgets.bitrate_mode.currentText(),
device=int(self.widgets.device.currentText().split(":", 1)[0] or 0),
pa_sc=self.widgets.pa_sc.currentText(),
diff --git a/fastflix/encoders/vceencc_avc/command_builder.py b/fastflix/encoders/vceencc_avc/command_builder.py
index 81b8cb4c..dc07c097 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", 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", "avc"])
+
+ if settings.bitrate:
+ command.extend(["--vbr", settings.bitrate.rstrip("k")])
+ else:
+ command.extend(["--cqp", str(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..baf5d26a 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,116 @@ 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", 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(["--cqp", str(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"])
+ if settings.copy_dv:
+ command.extend(["--dolby-vision-rpu", "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_hevc/settings_panel.py b/fastflix/encoders/vceencc_hevc/settings_panel.py
index b4f10179..70327315 100644
--- a/fastflix/encoders/vceencc_hevc/settings_panel.py
+++ b/fastflix/encoders/vceencc_hevc/settings_panel.py
@@ -110,7 +110,11 @@ def __init__(self, parent, main, app: FastFlixApp):
self.init_pa_row()
grid.addLayout(self.pa_area, 7, 0, 2, 6)
grid.addLayout(self.init_parallel_mode(), 8, 4, 1, 2)
- grid.addLayout(self.init_dhdr10_info(), 9, 2, 1, 4)
+ hdr_line = QtWidgets.QHBoxLayout()
+ hdr_line.addLayout(self.init_dhdr10_info())
+ hdr_line.addStretch(1)
+ hdr_line.addLayout(self.init_dolby_vision_copy())
+ grid.addLayout(hdr_line, 9, 2, 1, 4)
self.ffmpeg_level = QtWidgets.QLabel()
grid.addWidget(self.ffmpeg_level, 10, 2, 1, 4)
@@ -279,6 +283,7 @@ def update_video_encoder_settings(self):
vbaq=self.widgets.vbaq.isChecked(),
decoder=self.widgets.decoder.currentText(),
copy_hdr10=self.widgets.copy_hdr10.isChecked(),
+ copy_dv=self.widgets.copy_dv.isChecked(),
device=int(self.widgets.device.currentText().split(":", 1)[0] or 0),
pa_sc=self.widgets.pa_sc.currentText(),
pa_ss=self.widgets.pa_ss.currentText(),
diff --git a/fastflix/encoders/vp9/command_builder.py b/fastflix/encoders/vp9/command_builder.py
index 9d4759b6..22f6dd29 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,100 @@ 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} "
+ if settings.auto_alt_ref >= 0:
+ beginning.extend(["-auto-alt-ref", str(settings.auto_alt_ref)])
+
+ if settings.lag_in_frames >= 0:
+ beginning.extend(["-lag-in-frames", str(settings.lag_in_frames)])
+
+ if settings.tune_content != "default":
+ beginning.extend(["-tune-content", settings.tune_content])
+
+ if settings.aq_mode >= 0:
+ beginning.extend(["-aq-mode", str(settings.aq_mode)])
+
+ if settings.sharpness >= 0:
+ beginning.extend(["-sharpness", str(settings.sharpness)])
+
+ 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/vp9/settings_panel.py b/fastflix/encoders/vp9/settings_panel.py
index 0b6210e1..fb289dad 100644
--- a/fastflix/encoders/vp9/settings_panel.py
+++ b/fastflix/encoders/vp9/settings_panel.py
@@ -86,9 +86,16 @@ def __init__(self, parent, main, app: FastFlixApp):
grid.addLayout(checkboxes, 5, 2, 1, 4)
- # grid.addWidget(QtWidgets.QWidget(), 8, 0)
- grid.setRowStretch(8, 1)
- grid.addLayout(self._add_custom(), 10, 0, 1, 6)
+ grid.addLayout(self.init_tune_content(), 7, 0, 1, 2)
+ grid.addLayout(self.init_aq_mode(), 7, 2, 1, 4)
+
+ grid.addLayout(self.init_auto_alt_ref(), 8, 0, 1, 2)
+ grid.addLayout(self.init_lag_in_frames(), 8, 2, 1, 4)
+
+ grid.addLayout(self.init_sharpness(), 9, 0, 1, 2)
+
+ grid.setRowStretch(10, 1)
+ grid.addLayout(self._add_custom(), 11, 0, 1, 6)
link_1 = link(
"https://trac.ffmpeg.org/wiki/Encode/VP9", t("FFMPEG VP9 Encoding Guide"), app.fastflix.config.theme
@@ -102,7 +109,7 @@ def __init__(self, parent, main, app: FastFlixApp):
guide_label = QtWidgets.QLabel(f"{link_1} | {link_2}")
guide_label.setAlignment(QtCore.Qt.AlignBottom)
guide_label.setOpenExternalLinks(True)
- grid.addWidget(guide_label, 11, 0, 1, 6)
+ grid.addWidget(guide_label, 12, 0, 1, 6)
self.setLayout(grid)
self.hide()
@@ -194,6 +201,51 @@ def init_fast_first_pass(self):
def init_single_pass(self):
return self._add_check_box(label="Single Pass (CRF)", tooltip="", widget_name="single_pass", opt="single_pass")
+ def init_auto_alt_ref(self):
+ return self._add_combo_box(
+ label="Alt Ref Frames",
+ tooltip="Enable automatic alternate reference frames.\nMost impactful VP9 quality feature for multi-pass encoding.",
+ widget_name="auto_alt_ref",
+ options=["Default", "0 (disabled)", "1", "2", "3", "4", "5", "6"],
+ opt="auto_alt_ref",
+ )
+
+ def init_lag_in_frames(self):
+ return self._add_combo_box(
+ label="Lag in Frames",
+ tooltip="Number of frames to look ahead for alternate reference frame selection.\nRecommended: 25.",
+ widget_name="lag_in_frames",
+ options=["Default", "0", "10", "16", "20", "25", "30", "40", "50"],
+ opt="lag_in_frames",
+ )
+
+ def init_tune_content(self):
+ return self._add_combo_box(
+ label="Tune Content",
+ tooltip="Content type tuning.\nscreen: for screen capture content\nfilm: for film content",
+ widget_name="tune_content",
+ options=["default", "screen", "film"],
+ opt="tune_content",
+ )
+
+ def init_aq_mode(self):
+ return self._add_combo_box(
+ label="AQ Mode",
+ tooltip="Adaptive quantization mode.",
+ widget_name="aq_mode",
+ options=["Default", "0 (none)", "1 (variance)", "2 (complexity)", "3 (cyclic)", "4 (equator360)"],
+ opt="aq_mode",
+ )
+
+ def init_sharpness(self):
+ return self._add_combo_box(
+ label="Sharpness",
+ tooltip="Loop filter sharpness (0-7).",
+ widget_name="sharpness",
+ options=["Default", "0", "1", "2", "3", "4", "5", "6", "7"],
+ opt="sharpness",
+ )
+
def init_modes(self):
return self._add_modes(recommended_bitrates, recommended_crfs, qp_name="crf")
@@ -203,6 +255,24 @@ def mode_update(self):
self.main.build_commands()
def update_video_encoder_settings(self):
+ auto_alt_ref_text = self.widgets.auto_alt_ref.currentText()
+ if not auto_alt_ref_text or auto_alt_ref_text == "Default":
+ auto_alt_ref = -1
+ else:
+ auto_alt_ref = int(auto_alt_ref_text.split()[0])
+
+ lag_in_frames_text = self.widgets.lag_in_frames.currentText()
+ lag_in_frames = -1 if not lag_in_frames_text or lag_in_frames_text == "Default" else int(lag_in_frames_text)
+
+ aq_mode_text = self.widgets.aq_mode.currentText()
+ if not aq_mode_text or aq_mode_text == "Default":
+ aq_mode = -1
+ else:
+ aq_mode = int(aq_mode_text.split()[0])
+
+ sharpness_text = self.widgets.sharpness.currentText()
+ sharpness = -1 if not sharpness_text or sharpness_text == "Default" else int(sharpness_text)
+
settings = VP9Settings(
quality=self.widgets.quality.currentText(),
speed=self.widgets.speed.currentText(),
@@ -218,6 +288,11 @@ def update_video_encoder_settings(self):
self.widgets.tile_columns.currentText() if self.widgets.tile_columns.currentIndex() > 0 else "-1"
),
tile_rows=self.widgets.tile_rows.currentText() if self.widgets.tile_rows.currentIndex() > 0 else "-1",
+ auto_alt_ref=auto_alt_ref,
+ lag_in_frames=lag_in_frames,
+ tune_content=self.widgets.tune_content.currentText(),
+ aq_mode=aq_mode,
+ sharpness=sharpness,
)
encode_type, q_value = self.get_mode_settings()
settings.crf = q_value if encode_type == "qp" else None
diff --git a/fastflix/encoders/vvc/command_builder.py b/fastflix/encoders/vvc/command_builder.py
index 428fc795..8ec6d186 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,19 +84,68 @@ 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])
+
+ if not settings.subjopt:
+ beginning.extend(["-qpa", "0"])
+
+ if settings.period is not None:
+ beginning.extend(["-period", str(settings.period)])
+
+ if settings.threads > 0:
+ beginning.extend(["-threads", str(settings.threads)])
vvc_params = settings.vvc_params.copy() or []
+ if settings.ifp:
+ vvc_params.append("ifp=1")
+
if fastflix.current_video.video_settings.maxrate:
vvc_params.append(f"vbv-maxrate={fastflix.current_video.video_settings.maxrate}")
vvc_params.append(f"vbv-bufsize={fastflix.current_video.video_settings.bufsize}")
- if fastflix.current_video.cll:
- pass
+ if not fastflix.current_video.video_settings.remove_hdr:
+ # Color primaries/transfer/matrix via FFmpeg flags (libvvenc reads these for VUI)
+ if fastflix.current_video.video_settings.color_primaries:
+ beginning.extend(["-color_primaries", fastflix.current_video.video_settings.color_primaries])
+ elif fastflix.current_video.color_primaries:
+ cp = fastflix.current_video.color_primaries
+ cp = color_primaries_mapping.get(cp, cp)
+ if cp in vvc_valid_color_primaries:
+ beginning.extend(["-color_primaries", cp])
+
+ if fastflix.current_video.video_settings.color_transfer:
+ beginning.extend(["-color_trc", fastflix.current_video.video_settings.color_transfer])
+ elif fastflix.current_video.color_transfer:
+ ct = fastflix.current_video.color_transfer
+ ct = color_transfer_mapping.get(ct, ct)
+ if ct in vvc_valid_color_transfers:
+ beginning.extend(["-color_trc", ct])
+
+ if fastflix.current_video.video_settings.color_space:
+ beginning.extend(["-colorspace", fastflix.current_video.video_settings.color_space])
+ elif fastflix.current_video.color_space:
+ cs = fastflix.current_video.color_space
+ cs = color_matrix_mapping.get(cs, cs)
+ if cs in vvc_valid_color_matrix:
+ beginning.extend(["-colorspace", cs])
+
+ if settings.pix_fmt in ("yuv420p10le",):
+ if fastflix.current_video.master_display:
+ # vvenc format: Gx,Gy,Bx,By,Rx,Ry,WPx,WPy,Lmax,Lmin (bare numbers, comma-separated)
+ md = fastflix.current_video.master_display
+ md_values = ",".join(v.strip("()") for v in [md.green, md.blue, md.red, md.white, md.luminance])
+ vvc_params.append(f"MasteringDisplayColourVolume={md_values}")
+
+ if fastflix.current_video.cll:
+ vvc_params.append(f"MaxContentLightLevel={fastflix.current_video.cll}")
+
+ current_chroma_loc = fastflix.current_video.current_video_stream.get("chroma_location")
+ if current_chroma_loc in chromaloc_mapping:
+ beginning.extend(["-chroma_sample_location", str(chromaloc_mapping[current_chroma_loc])])
pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}"
@@ -103,20 +153,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 +184,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/vvc/settings_panel.py b/fastflix/encoders/vvc/settings_panel.py
index 3732c95c..120f71e2 100644
--- a/fastflix/encoders/vvc/settings_panel.py
+++ b/fastflix/encoders/vvc/settings_panel.py
@@ -49,6 +49,7 @@
]
pix_fmts = [
+ "8-bit: yuv420p",
"10-bit: yuv420p10le",
]
@@ -91,6 +92,12 @@ def __init__(self, parent, main, app: FastFlixApp):
grid.addLayout(breaker, 5, 0, 1, 6)
+ grid.addLayout(self.init_subjopt(), 6, 0, 1, 2)
+ grid.addLayout(self.init_period(), 6, 2, 1, 4)
+
+ grid.addLayout(self.init_threads(), 7, 0, 1, 2)
+ grid.addLayout(self.init_ifp(), 7, 2, 1, 4)
+
grid.addLayout(self.init_max_mux(), 8, 0, 1, 2)
grid.addLayout(self.init_vvc_params(), 8, 2, 1, 4)
@@ -166,6 +173,40 @@ def init_pix_fmt(self):
opt="pix_fmt",
)
+ def init_subjopt(self):
+ return self._add_check_box(
+ label="Perceptual QPA",
+ tooltip="QPA perceptual optimization (enabled by default in vvenc).\nDisabling sends -qpa 0 to vvenc.",
+ widget_name="subjopt",
+ opt="subjopt",
+ )
+
+ def init_period(self):
+ return self._add_combo_box(
+ label="Intra Period",
+ tooltip="Intra refresh period in seconds. Auto lets vvenc decide.",
+ widget_name="period",
+ options=["Auto", "0", "1", "2", "3", "5", "10"],
+ opt="period",
+ )
+
+ def init_threads(self):
+ return self._add_combo_box(
+ label="Threads",
+ tooltip="Number of threads for encoding. Auto lets vvenc decide.",
+ widget_name="threads",
+ options=["Auto", "1", "2", "4", "6", "8", "12", "16", "24", "32"],
+ opt="threads",
+ )
+
+ def init_ifp(self):
+ return self._add_check_box(
+ label="IFP (Inter-Frame Parallelism)",
+ tooltip="Enable inter-frame parallelism for faster encoding (vvenc 1.11+).\nAppends ifp=1 to vvc-params.",
+ widget_name="ifp",
+ opt="ifp",
+ )
+
def init_max_mux(self):
return self._add_combo_box(
label="Max Muxing Queue Size",
@@ -230,6 +271,12 @@ def update_video_encoder_settings(self):
level = self.widgets.levelidc.currentText() if self.widgets.levelidc.currentIndex() > 0 else None
+ period_text = self.widgets.period.currentText()
+ period = None if period_text == "Auto" else int(period_text)
+
+ threads_text = self.widgets.threads.currentText()
+ threads = 0 if threads_text == "Auto" else int(threads_text)
+
settings = VVCSettings(
preset=self.widgets.preset.currentText(),
max_muxing_queue_size=self.widgets.max_mux.currentText(),
@@ -239,6 +286,10 @@ def update_video_encoder_settings(self):
vvc_params=vvc_params_text.split(":") if vvc_params_text else [],
extra=self.ffmpeg_extras,
extra_both_passes=self.widgets.extra_both_passes.isChecked(),
+ subjopt=self.widgets.subjopt.isChecked(),
+ period=period,
+ threads=threads,
+ ifp=self.widgets.ifp.isChecked(),
)
encode_type, q_value = self.get_mode_settings()
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/entry.py b/fastflix/entry.py
index 2cd6f29a..164a1068 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,8 +21,13 @@
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"""
+ 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)
@@ -35,8 +40,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 +104,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/flix.py b/fastflix/flix.py
index 5d96c0b7..385e440f 100644
--- a/fastflix/flix.py
+++ b/fastflix/flix.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
+import json
import logging
import os
import re
@@ -174,7 +175,15 @@ def ffmpeg_configuration(app, config: Config, **_):
config = [x[9:].strip() for x in line[len(line_denote) :].split(" ") if x.startswith("--enable")]
app.fastflix.ffmpeg_version = version
app.fastflix.ffmpeg_config = config
- # return version, config
+
+ # Parse libavcodec major version (e.g. 62 for FFmpeg 8.x)
+ for line in res.stdout.split("\n"):
+ if line.strip().startswith("libavcodec"):
+ try:
+ app.fastflix.libavcodec_version = int(line.split()[1].rstrip("."))
+ except (ValueError, IndexError):
+ logger.warning(f"Could not parse libavcodec version from: {line.strip()}")
+ break
def ffprobe_configuration(app, config: Config, **_):
@@ -324,7 +333,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,13 +347,26 @@ 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)]
+ command += [
+ "-an",
+ "-y",
+ "-map_metadata",
+ "-1",
+ "-strict",
+ "unofficial",
+ "-frames:v",
+ "1",
+ clean_file_string(output),
+ ]
return command
@@ -592,57 +614,97 @@ def get_hdr10_parser_version(config: Config) -> version:
return HDR10_parser_version
-def detect_hdr10_plus(app: FastFlixApp, config: Config, **_):
- if not config.hdr10plus_parser or not config.hdr10plus_parser.exists():
- return
-
- hdr10plus_streams = []
-
- parser_version = get_hdr10_parser_version(config)
-
- for stream in app.fastflix.current_video.streams.video:
- logger.debug(f"Checking for hdr10+ in stream {stream.index}")
- process = Popen(
+def _detect_hdr10_plus_ffprobe(app: FastFlixApp, config: Config, stream) -> bool:
+ """Detect HDR10+ in a stream using ffprobe frame side data. Works with any codec."""
+ logger.debug(f"Checking for hdr10+ via ffprobe in stream {stream.index}")
+ try:
+ result = execute(
[
- str(config.ffmpeg),
- "-y",
- "-i",
+ str(config.ffprobe),
+ "-show_frames",
+ "-read_intervals",
+ "%+#1",
+ "-select_streams",
+ f"v:{stream.index}",
+ "-show_entries",
+ "frame=side_data_list",
+ "-of",
+ "json",
clean_file_string(app.fastflix.current_video.source),
- "-map",
- f"0:{stream.index}",
- "-loglevel",
- "panic",
- "-c:v",
- "copy",
- "-bsf:v",
- "hevc_mp4toannexb",
- "-f",
- "hevc",
- "-",
],
- stdout=PIPE,
- stderr=PIPE,
- stdin=PIPE, # FFmpeg can try to read stdin and wrecks havoc
+ timeout=30,
)
+ if result.returncode != 0:
+ return False
+ data = json.loads(result.stdout)
+ for frame in data.get("frames", []):
+ for side_data in frame.get("side_data_list", []):
+ if "HDR10+" in side_data.get("side_data_type", ""):
+ return True
+ except Exception:
+ logger.exception(f"Unexpected error during ffprobe HDR10+ detection for stream {stream.index}")
+ return False
- hdr10_parser_command = [str(config.hdr10plus_parser), "--verify", "-"]
- if parser_version >= version.parse("1.0.0"):
- hdr10_parser_command.insert(-1, "extract")
- process_two = Popen(
- hdr10_parser_command,
- stdout=PIPE,
- stderr=PIPE,
- stdin=process.stdout,
- encoding="utf-8",
- )
+def _detect_hdr10_plus_tool(app: FastFlixApp, config: Config, stream) -> bool:
+ """Detect HDR10+ in an HEVC stream using hdr10plus_tool."""
+ logger.debug(f"Checking for hdr10+ via hdr10plus_tool in stream {stream.index}")
+ process = Popen(
+ [
+ str(config.ffmpeg),
+ "-y",
+ "-i",
+ clean_file_string(app.fastflix.current_video.source),
+ "-map",
+ f"0:{stream.index}",
+ "-loglevel",
+ "panic",
+ "-c:v",
+ "copy",
+ "-bsf:v",
+ "hevc_mp4toannexb",
+ "-f",
+ "hevc",
+ "-",
+ ],
+ stdout=PIPE,
+ stderr=PIPE,
+ stdin=PIPE, # FFmpeg can try to read stdin and wrecks havoc
+ )
- try:
- stdout, stderr = process_two.communicate()
- except Exception:
- logger.exception(f"Unexpected error while trying to detect HDR10+ metadata in stream {stream.index}")
+ parser_version = get_hdr10_parser_version(config)
+ hdr10_parser_command = [str(config.hdr10plus_parser), "--verify", "-"]
+ if parser_version >= version.parse("1.0.0"):
+ hdr10_parser_command.insert(-1, "extract")
+
+ process_two = Popen(
+ hdr10_parser_command,
+ stdout=PIPE,
+ stderr=PIPE,
+ stdin=process.stdout,
+ encoding="utf-8",
+ )
+
+ try:
+ stdout, stderr = process_two.communicate()
+ except Exception:
+ logger.exception(f"Unexpected error while trying to detect HDR10+ metadata in stream {stream.index}")
+ return False
+ return "Dynamic HDR10+ metadata detected." in stdout
+
+
+def detect_hdr10_plus(app: FastFlixApp, config: Config, **_):
+ has_hdr10plus_tool = config.hdr10plus_parser and config.hdr10plus_parser.exists()
+
+ hdr10plus_streams = []
+
+ for stream in app.fastflix.current_video.streams.video:
+ codec = stream.get("codec_name", "")
+ if has_hdr10plus_tool and codec == "hevc":
+ if _detect_hdr10_plus_tool(app, config, stream):
+ hdr10plus_streams.append(stream.index)
else:
- if "Dynamic HDR10+ metadata detected." in stdout:
+ if _detect_hdr10_plus_ffprobe(app, config, stream):
hdr10plus_streams.append(stream.index)
if hdr10plus_streams:
diff --git a/fastflix/models/config.py b/fastflix/models/config.py
index d4f5ee0e..93dd8569 100644
--- a/fastflix/models/config.py
+++ b/fastflix/models/config.py
@@ -87,6 +87,10 @@ def find_hdr10plus_tool():
return Path(location)
if location := shutil.which("hdr10plus_parser"):
return Path(location)
+ # Check the FFmpeg download folder (where auto-download places it)
+ hdr10plus_in_ffmpeg = ffmpeg_folder / "hdr10plus_tool.exe"
+ if hdr10plus_in_ffmpeg.exists():
+ return hdr10plus_in_ffmpeg
return None
@@ -99,15 +103,137 @@ 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
+ 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
+
+
class Config(BaseModel):
version: str = __version__
config_path: Path = Field(default_factory=get_config)
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"))
+ gifski: Path | None = Field(default_factory=lambda: where("gifski"))
output_directory: Path | None = None
source_directory: Path | None = None
output_name_format: str = "{source}-fastflix-{rand_4}"
@@ -132,6 +258,7 @@ class Config(BaseModel):
ui_scale: str = "1"
clean_old_logs: bool = True
auto_gpu_check: bool | None = None
+ auto_hdr10plus_check: bool | None = None
gpu_fingerprint: str | None = None
opencl_support: bool | None = None
seven_zip: Path | None = None
@@ -168,6 +295,20 @@ 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
+
+ @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)
if encoder_settings:
@@ -289,6 +430,7 @@ def load(self, portable_mode=False):
"seven_zip",
"vceencc",
"qsvencc",
+ "gifski",
)
for key, value in data.items():
if key == "profiles":
@@ -330,6 +472,12 @@ def load(self, portable_mode=False):
self.qsvencc = where("QSVEncC64", portable_mode=portable_mode) or where(
"QSVEncC", portable_mode=portable_mode
)
+ if not self.gifski:
+ self.gifski = where("gifski", portable_mode=portable_mode)
+ if not self.gifski and win_based:
+ cargo_bin_path = Path(os.environ.get("USERPROFILE", "")) / ".cargo" / "bin" / "gifski.exe"
+ if cargo_bin_path.exists():
+ self.gifski = cargo_bin_path
self.profiles.update(get_preset_defaults())
if self.selected_profile not in self.profiles:
diff --git a/fastflix/models/encode.py b/fastflix/models/encode.py
index c8140b44..fdb97d2d 100644
--- a/fastflix/models/encode.py
+++ b/fastflix/models/encode.py
@@ -38,6 +38,9 @@ class SubtitleTrack(BaseModel):
enabled: bool = True
long_name: str = ""
raw_info: Optional[Union[dict, Box]] = None
+ external: bool = False
+ file_path: Optional[str] = None
+ file_index: int = 0
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -92,6 +95,8 @@ class VVCSettings(EncoderSettings):
subjopt: bool = True
levelidc: str | None = None
period: int | None = None
+ threads: int = 0 # 0 = auto
+ ifp: bool = False
class x264Settings(EncoderSettings):
@@ -100,9 +105,13 @@ class x264Settings(EncoderSettings):
profile: str = "default"
tune: Optional[str] = None
pix_fmt: str = "yuv420p"
+ aq_mode: str = "default"
+ psy_rd: Optional[str] = None
+ level: str = "auto"
crf: Optional[Union[int, float]] = 23
bitrate: Optional[str] = None
bitrate_passes: int = 2
+ x264_params: list[str] = Field(default_factory=list)
class FFmpegNVENCSettings(EncoderSettings):
@@ -163,6 +172,7 @@ class NVEncCSettings(EncoderSettings):
device: int = 0
decoder: str = "Auto"
copy_hdr10: bool = False
+ copy_dv: bool = False
split_mode: str = "none"
@field_validator("cqp", mode="before")
@@ -205,6 +215,7 @@ class NVEncCAV1Settings(EncoderSettings):
device: int = 0
decoder: str = "Auto"
copy_hdr10: bool = False
+ copy_dv: bool = False
split_mode: str = "none"
@field_validator("cqp", mode="before")
@@ -239,6 +250,7 @@ class QSVEncCSettings(EncoderSettings):
adapt_cqm: bool = False
adapt_ltr: bool = False
copy_hdr10: bool = False
+ copy_dv: bool = False
split_mode: str = "none"
@field_validator("cqp", mode="before")
@@ -273,6 +285,7 @@ class QSVEncCAV1Settings(EncoderSettings):
adapt_cqm: bool = False
adapt_ltr: bool = False
copy_hdr10: bool = False
+ copy_dv: bool = False
split_mode: str = "none"
@field_validator("cqp", mode="before")
@@ -389,6 +402,7 @@ class VCEEncCSettings(EncoderSettings):
pa_motion_quality: str | None = None
output_depth: str | None = None
copy_hdr10: bool = False
+ copy_dv: bool = False
split_mode: str = "none"
@field_validator("cqp", mode="before")
@@ -432,6 +446,7 @@ class VCEEncCAV1Settings(EncoderSettings):
pa_motion_quality: str | None = None
output_depth: str | None = None
copy_hdr10: bool = False
+ copy_dv: bool = False
split_mode: str = "none"
@field_validator("cqp", mode="before")
@@ -491,8 +506,12 @@ class rav1eSettings(EncoderSettings):
tile_rows: str = "-1"
tiles: str = "0"
single_pass: bool = False
- qp: Optional[Union[int, float]] = 24
+ tune: str = "Psychovisual" # default, Psychovisual, Psnr
+ photon_noise: int = 0 # 0-64, 0=off, grain synthesis strength
+ scene_detection: bool = True # disable = no_scene_detection=true
+ qp: Optional[Union[int, float]] = 80 # 0-255 scale (NOT 0-63), 80 ≈ CRF 20
bitrate: Optional[str] = None
+ rav1e_params: list[str] = Field(default_factory=list)
class SVTAV1Settings(EncoderSettings):
@@ -502,6 +521,11 @@ class SVTAV1Settings(EncoderSettings):
scene_detection: bool = False
single_pass: bool = False
speed: str = "7" # Renamed preset in svtav1 encoder
+ tune: str = "1" # 0=VQ (Psychovisual), 1=PSNR, 2=SSIM
+ film_grain: int = 0 # 0-50, 0=off
+ film_grain_denoise: bool = False
+ sharpness: str = "0" # -7 to 7
+ fast_decode: str = "0" # 0=off, 1=level 1, 2=level 2
qp: Optional[Union[int, float]] = 24
qp_mode: str = "crf"
bitrate: Optional[str] = None
@@ -512,6 +536,8 @@ class SVTAVIFSettings(EncoderSettings):
name: str = "AVIF (SVT AV1)"
single_pass: bool = True
speed: str = "7" # Renamed preset in svtav1 encoder
+ tune: str = "1" # 0=VQ (Psychovisual), 1=PSNR, 2=SSIM
+ sharpness: str = "0" # -7 to 7
qp: Optional[Union[int, float]] = 24
qp_mode: str = "qp"
bitrate: Optional[str] = None
@@ -530,6 +556,11 @@ class VP9Settings(EncoderSettings):
fast_first_pass: Optional[bool] = True
tile_columns: str = "-1"
tile_rows: str = "-1"
+ auto_alt_ref: int = -1 # -1 = codec default, 0 = off, 1-6 = max alt-ref count
+ lag_in_frames: int = -1 # -1 = codec default
+ tune_content: str = "default"
+ aq_mode: int = -1 # -1 = codec default
+ sharpness: int = -1 # -1 = codec default, 0-7
class HEVCVideoToolboxSettings(EncoderSettings):
@@ -562,11 +593,15 @@ class AOMAV1Settings(EncoderSettings):
name: str = "AV1 (AOM)"
tile_columns: str = "0"
tile_rows: str = "0"
- usage: str = "good"
+ usage: str = "good" # good, realtime, allintra
row_mt: str = "enabled"
cpu_used: str = "4"
+ tune: str = "ssim" # default, psnr, ssim
+ denoise_noise_level: int = 0 # 0-50, 0=off
+ aq_mode: str = "default" # default, 0=none, 1=variance, 2=complexity, 3=cyclic
crf: Optional[Union[int, float]] = 26
bitrate: Optional[str] = None
+ aom_params: list[str] = Field(default_factory=list)
class WebPSettings(EncoderSettings):
@@ -610,6 +645,22 @@ def fps_field_validate(cls, value):
return value
+class GifskiSettings(EncoderSettings):
+ name: str = "GIF (gifski)"
+ fps: str = "15"
+ quality: str = "90"
+ lossy_quality: str = "auto"
+ motion_quality: str = "auto"
+ fast: bool = False
+
+ @field_validator("fps", mode="before")
+ @classmethod
+ def fps_field_validate(cls, value):
+ if isinstance(value, (int, float)):
+ return str(int(value))
+ return value
+
+
class CopySettings(EncoderSettings):
name: str = "Copy"
@@ -687,6 +738,7 @@ class VAAPIMPEG2Settings(EncoderSettings):
"vp9": VP9Settings,
"aom_av1": AOMAV1Settings,
"gif": GIFSettings,
+ "gifski": GifskiSettings,
"webp": WebPSettings,
"copy_settings": CopySettings,
"modify_settings": ModifySettings,
diff --git a/fastflix/models/fastflix.py b/fastflix/models/fastflix.py
index 12b53241..01ca63a1 100644
--- a/fastflix/models/fastflix.py
+++ b/fastflix/models/fastflix.py
@@ -19,6 +19,7 @@ class FastFlix(BaseModel):
ffmpeg_version: str = ""
ffmpeg_config: list[str] = ""
ffprobe_version: str = ""
+ libavcodec_version: int = 0
opencl_support: bool = False
# Queues
diff --git a/fastflix/models/profiles.py b/fastflix/models/profiles.py
index c92fa3d4..a30bd2c9 100644
--- a/fastflix/models/profiles.py
+++ b/fastflix/models/profiles.py
@@ -10,6 +10,7 @@
CopySettings,
ModifySettings,
GIFSettings,
+ GifskiSettings,
FFmpegNVENCSettings,
SVTAV1Settings,
VP9Settings,
@@ -37,7 +38,7 @@
)
-__all__ = ["MatchItem", "MatchType", "AudioMatch", "Profile", "SubtitleMatch", "AdvancedOptions"]
+__all__ = ["MatchItem", "MatchType", "TitleMode", "AudioMatch", "Profile", "SubtitleMatch", "AdvancedOptions"]
class MatchItem(Enum):
@@ -54,13 +55,22 @@ 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[MatchType]]
+ match_item: Union[MatchItem, list[MatchItem]]
match_input: str = "*"
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
@@ -73,13 +83,22 @@ 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("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):
- 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 +117,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
@@ -164,6 +183,7 @@ class Profile(BaseModel):
vp9: Optional[VP9Settings] = None
aom_av1: Optional[AOMAV1Settings] = None
gif: Optional[GIFSettings] = None
+ gifski: Optional[GifskiSettings] = None
webp: Optional[WebPSettings] = None
modify_settings: Optional[ModifySettings] = None
copy_settings: Optional[CopySettings] = None
diff --git a/fastflix/models/video.py b/fastflix/models/video.py
index 13a17bf3..ebfd175a 100644
--- a/fastflix/models/video.py
+++ b/fastflix/models/video.py
@@ -12,6 +12,7 @@
AudioTrack,
CopySettings,
GIFSettings,
+ GifskiSettings,
FFmpegNVENCSettings,
SubtitleTrack,
SVTAV1Settings,
@@ -120,6 +121,7 @@ class VideoSettings(BaseModel):
AOMAV1Settings,
VP9Settings,
GIFSettings,
+ GifskiSettings,
WebPSettings,
CopySettings,
FFmpegNVENCSettings,
diff --git a/fastflix/program_downloads.py b/fastflix/program_downloads.py
index a485d660..412225c0 100644
--- a/fastflix/program_downloads.py
+++ b/fastflix/program_downloads.py
@@ -162,7 +162,12 @@ def stop_me():
sub_dir = next(Path(extract_folder).glob("ffmpeg-*"))
for item in os.listdir(sub_dir):
+ dest = ffmpeg_folder / item
try:
+ if dest.is_dir():
+ shutil.rmtree(str(dest))
+ elif dest.exists():
+ dest.unlink()
shutil.move(str(sub_dir / item), str(ffmpeg_folder))
except Exception as err:
message(f"{t('Error while moving files in')} {ffmpeg_folder}: {err}")
@@ -289,6 +294,122 @@ def stop_me():
return filename
+def download_hdr10plus_tool(signal, stop_signal, **_):
+ stop = False
+ logger.debug("Downloading hdr10plus_tool")
+
+ def stop_me():
+ nonlocal stop
+ stop = True
+
+ stop_signal.connect(stop_me)
+ ffmpeg_folder = Path(user_data_dir("FFmpeg", appauthor=False, roaming=True))
+ ffmpeg_folder.mkdir(exist_ok=True)
+
+ extract_folder = ffmpeg_folder / "temp_hdr10plus_download"
+ if extract_folder.exists():
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ if extract_folder.exists():
+ message(t("Could not delete previous temp extract directory: ") + str(extract_folder))
+ raise FastFlixError("Could not delete previous temp extract directory")
+
+ url = "https://api.github.com/repos/quietvoid/hdr10plus_tool/releases/latest"
+
+ try:
+ data = requests.get(url, timeout=15).json()
+ except Exception:
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ message(t("Could not connect to github to check for newer versions."))
+ raise
+
+ if stop:
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ message(t("Download Cancelled"))
+ return
+
+ asset = None
+ for possible_asset in data.get("assets", []):
+ if "x86_64-pc-windows-msvc.zip" in possible_asset["name"]:
+ asset = possible_asset
+ break
+
+ if not asset:
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ message(
+ t("Could not find any matching expected patterns, please check")
+ + f" {t('latest release from')} "
+ "https://github.com/quietvoid/hdr10plus_tool/releases/"
+ )
+ raise Exception()
+
+ logger.debug(f"Downloading version {asset['name']}")
+
+ req = requests.get(asset["browser_download_url"], stream=True)
+
+ filename = ffmpeg_folder / "hdr10plus_tool.zip"
+ with open(filename, "wb") as f:
+ for i, block in enumerate(req.iter_content(chunk_size=1024)):
+ if i % 1000 == 0.0:
+ signal.emit(int(((i * 1024) / asset["size"]) * 90))
+ f.write(block)
+ if stop:
+ f.close()
+ Path(filename).unlink()
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ message(t("Download Cancelled"))
+ return
+
+ if filename.stat().st_size < 1000:
+ message(t("hdr10plus_tool was not properly downloaded as the file size is too small"))
+ try:
+ Path(filename).unlink()
+ except OSError:
+ pass
+ raise FastFlixError("hdr10plus_tool download too small")
+
+ try:
+ reusables.extract(filename, path=extract_folder)
+ except Exception:
+ message(f"{t('Could not extract hdr10plus_tool files from')} {filename}!")
+ raise
+
+ if stop:
+ Path(filename).unlink()
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ message(t("Download Cancelled"))
+ return
+
+ signal.emit(95)
+
+ try:
+ Path(filename).unlink()
+ except OSError:
+ pass
+
+ signal.emit(96)
+
+ exe_file = None
+ for item in extract_folder.rglob("hdr10plus_tool.exe"):
+ exe_file = item
+ break
+
+ if not exe_file:
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ raise FastFlixError("Could not find hdr10plus_tool.exe in extracted files")
+
+ dest = ffmpeg_folder / "hdr10plus_tool.exe"
+ try:
+ shutil.move(str(exe_file), str(dest))
+ except Exception as err:
+ message(f"{t('Error while moving files in')} {ffmpeg_folder}: {err}")
+ raise
+
+ signal.emit(98)
+ shutil.rmtree(extract_folder, ignore_errors=True)
+ signal.emit(100)
+ return dest
+
+
def find_seven_zip_windows() -> Path | None:
"""Finds the 7-Zip executable"""
seven_zip_path = seven_zip_folder / "7zr.exe"
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/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/ui_constants.py b/fastflix/ui_constants.py
new file mode 100644
index 00000000..689526e9
--- /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 = 62
+ 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..3d3c1394
--- /dev/null
+++ b/fastflix/ui_styles.py
@@ -0,0 +1,113 @@
+# -*- 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
+
+
+# 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."""
+ # 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_pt}pt; }}"
+
+ if theme == "onyx":
+ base += f"""
+ QAbstractItemView {{ background-color: #4f5962; }}
+ QComboBox QAbstractItemView {{ background-color: #1d2023; border: 2px solid #76797c; }}
+ QPushButton {{ border-radius: {border_radius}px; }}
+ QLineEdit {{
+ background-color: #4a555e;
+ color: white;
+ border-radius: {border_radius}px;
+ }}
+ QTextEdit {{ background-color: #4a555e; color: white; }}
+ QTabBar::tab {{ background-color: #4f5962; }}
+ 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_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_pt}pt; 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_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:
+ """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/version.py b/fastflix/version.py
index 59da427c..9434956f 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__ = "6.0.0"
__author__ = "Chris Griffith"
diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py
index 75421799..22b8f1ca 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
@@ -15,6 +16,20 @@
logger = logging.getLogger("fastflix")
+
+def _format_command(command):
+ """Format a command list as a copy-pastable shell string with proper quoting."""
+ if isinstance(command, str):
+ return command
+ parts = []
+ for arg in command:
+ if " " in arg or "'" in arg or "[" in arg or ";" in arg or "," in arg or "\\" in arg:
+ parts.append(f'"{arg}"')
+ else:
+ parts.append(arg)
+ return " ".join(parts)
+
+
__all__ = ["ThumbnailCreator", "ExtractSubtitleSRT", "ExtractHDR10"]
@@ -25,7 +40,7 @@ def __init__(self, main, command=""):
self.command = command
def run(self):
- self.main.thread_logging_signal.emit(f"DEBUG:{t('Generating thumbnail')}: {self.command}")
+ self.main.thread_logging_signal.emit(f"DEBUG:{t('Generating thumbnail')}: {_format_command(self.command)}")
result = run(self.command, stdin=PIPE, stdout=PIPE, stderr=STDOUT)
if result.returncode > 0:
if "No such filter: 'zscale'" in result.stdout.decode(encoding="utf-8", errors="ignore"):
@@ -46,13 +61,35 @@ 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, output_path=None):
super().__init__(main)
self.main = main
self.app = app
self.index = index
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()
@@ -60,9 +97,12 @@ 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
+ should_convert_to_srt = False
+
if subtitle_format == "srt":
extension = "srt"
output_args = ["-c", "srt", "-f", "srt"]
@@ -75,47 +115,95 @@ 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.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.signal.emit()
+ self.main.thread_logging_signal.emit(f"WARNING:{t('OCR conversion failed, kept .sup file')}")
+
+ 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:
@@ -164,6 +252,139 @@ 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 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\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 extracted .sup PGS subtitle to .srt using pgsrip OCR
+
+ Args:
+ sup_filepath: Path to the extracted .sup file
+
+ 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 - 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
+ 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)
+
+ # 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
+
+ sup_path = Path(sup_filepath)
+ 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:
+ # 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(sup_path.parent.glob("*.srt"))
+
+ # Run pgsrip conversion using Python API
+ pgsrip.rip(media, options)
+
+ # Find newly created .srt files
+ 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 {sup_path.parent}")
+
+ # Get the first new .srt file
+ srt_files = list(new_srts)
+
+ # Move the .srt file to the user's originally desired location
+ created_srt = srt_files[0]
+
+ if created_srt != desired_srt:
+ import shutil
+
+ shutil.move(str(created_srt), str(desired_srt))
+
+ 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:
+ 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/container.py b/fastflix/widgets/container.py
index 6cd70375..99d0d599 100644
--- a/fastflix/widgets/container.py
+++ b/fastflix/widgets/container.py
@@ -16,7 +16,7 @@
from fastflix.language import t
from fastflix.models.config import setting_types, get_preset_defaults
from fastflix.models.fastflix_app import FastFlixApp
-from fastflix.program_downloads import latest_ffmpeg, grab_stable_ffmpeg
+from fastflix.program_downloads import latest_ffmpeg, grab_stable_ffmpeg, download_hdr10plus_tool
from fastflix.gpu_detect import update_rigaya_encoders
from fastflix.resources import main_icon, get_icon, changes_file, local_changes_file, local_package_changes_file
from fastflix.shared import (
@@ -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,33 +95,104 @@ 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; }
- 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()
+ 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 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
+ 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
@@ -140,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)
@@ -178,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"))
@@ -268,6 +350,9 @@ def init_menu(self):
rigaya_update_action = QAction(self.si(QtWidgets.QStyle.SP_ArrowDown), t("Update Rigaya's Encoders"), self)
rigaya_update_action.triggered.connect(self.download_rigaya)
+ hdr10plus_download_action = QAction(self.si(QtWidgets.QStyle.SP_ArrowDown), t("Download HDR10+ Tool"), self)
+ hdr10plus_download_action.triggered.connect(self.download_hdr10plus_tool)
+
clean_logs_action = QAction(self.si(QtWidgets.QStyle.SP_DialogResetButton), t("Clean Old Logs"), self)
clean_logs_action.triggered.connect(self.clean_old_logs)
@@ -287,6 +372,7 @@ def init_menu(self):
help_menu.addAction(ffmpeg_update_stable_action)
help_menu.addAction(ffmpeg_update_action)
help_menu.addAction(rigaya_update_action)
+ help_menu.addAction(hdr10plus_download_action)
help_menu.addSeparator()
help_menu.addAction(about_action)
@@ -392,6 +478,28 @@ def download_rigaya(self):
error_message(t("Could not update Rigaya's encoders"), traceback=True)
self.pb = None
+ def download_hdr10plus_tool(self):
+ try:
+ self.pb = ProgressBar(
+ self.app,
+ [Task(t("Downloading HDR10+ Tool"), download_hdr10plus_tool)],
+ signal_task=True,
+ can_cancel=True,
+ )
+ except Exception:
+ error_message(t("Could not download HDR10+ tool"), traceback=True)
+ else:
+ from fastflix.models.config import find_hdr10plus_tool
+
+ result = find_hdr10plus_tool()
+ if result:
+ self.app.fastflix.config.hdr10plus_parser = result
+ self.app.fastflix.config.save()
+ message(f"{t('HDR10+ tool has been downloaded to')} {result}")
+ else:
+ error_message(t("Could not locate the downloaded HDR10+ tool"))
+ self.pb = None
+
def clean_old_logs(self, show_errors=True):
try:
self.pb = ProgressBar(self.app, [Task(t("Clean Old Logs"), clean_logs)], signal_task=True, can_cancel=False)
@@ -437,12 +545,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 8d7d45a8..a0eae41b 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
@@ -21,6 +22,9 @@
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.ui_styles import ONYX_COLORS, get_onyx_combobox_style, get_onyx_button_style
from fastflix.flix import (
detect_hdr10_plus,
detect_interlaced,
@@ -37,7 +41,6 @@
from fastflix.resources import (
get_icon,
group_box_style,
- reset_button_style,
onyx_convert_icon,
onyx_queue_add_icon,
get_text_color,
@@ -64,8 +67,8 @@
Request = namedtuple(
"Request",
- ["request", "video_uuid", "command_uuid", "command", "work_dir", "log_name"],
- defaults=[None, None, None, None, None],
+ ["request", "video_uuid", "command_uuid", "command", "work_dir", "log_name", "shell"],
+ defaults=[None, None, None, None, None, False],
)
Response = namedtuple("Response", ["status", "video_uuid", "command_uuid"])
@@ -119,6 +122,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)
@@ -133,9 +137,11 @@ 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
+ video_res_label: QtWidgets.QLabel = None
+ output_res_label: QtWidgets.QLabel = None
output_directory: QtWidgets.QPushButton = None
output_directory_combo: QtWidgets.QComboBox = None
output_type_combo: QtWidgets.QComboBox = Field(default_factory=QtWidgets.QComboBox)
@@ -223,7 +229,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(
@@ -232,9 +238,11 @@ 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.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))
@@ -267,6 +275,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)
@@ -278,13 +296,13 @@ 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.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(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)
self.grid.setSpacing(5)
@@ -292,6 +310,15 @@ def __init__(self, parent, app: FastFlixApp):
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
@@ -321,19 +348,22 @@ 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.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() + 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))
@@ -343,9 +373,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())
@@ -377,15 +406,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())
@@ -395,33 +424,91 @@ 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.setSingleStep(1)
+ self.widgets.thumb_time.setPageStep(10)
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.installEventFilter(self)
+ 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(4)
- 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()
+ def eventFilter(self, obj, event):
+ if obj == self.widgets.thumb_time:
+ if event.type() == QtCore.QEvent.KeyRelease and event.key() in (
+ QtCore.Qt.Key_Left,
+ QtCore.Qt.Key_Right,
+ QtCore.Qt.Key_Up,
+ QtCore.Qt.Key_Down,
+ QtCore.Qt.Key_PageUp,
+ QtCore.Qt.Key_PageDown,
+ ):
+ if not event.isAutoRepeat():
+ self.thumb_time_change()
+ return super().eventFilter(obj, event)
+
def get_temp_work_path(self):
new_temp = self.app.fastflix.config.work_path / f"temp_{get_filesafe_datetime()}_{secrets.token_hex(8)}"
if new_temp.exists():
@@ -451,118 +538,357 @@ 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))
+ 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)
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))
+ 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(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))
+ if self.app.fastflix.config.theme == "onyx":
+ self.widgets.output_type_combo.setStyleSheet(get_onyx_combobox_style())
self.widgets.output_type_combo.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False))
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)
+
+ # Video info bar (bit depth, color space, chroma subsampling, HDR10, HDR10+)
+ self.video_bit_depth_label = QtWidgets.QLabel()
+ self.video_chroma_label = QtWidgets.QLabel()
+ self.video_hdr10_label = QtWidgets.QLabel()
+ self.video_hdr10plus_label = QtWidgets.QLabel()
+ for lbl in (
+ self.video_bit_depth_label,
+ self.video_chroma_label,
+ self.video_hdr10_label,
+ self.video_hdr10plus_label,
+ ):
+ lbl.hide()
+
+ info_layout = QtWidgets.QHBoxLayout()
+ self.video_info_label = QtWidgets.QLabel(t("Video Info"))
+ self.video_info_label.setFixedWidth(scaler.scale(WIDTHS.SOURCE_LABEL))
+ if self.app.fastflix.config.theme == "onyx":
+ self.video_info_label.setStyleSheet("color: white;")
+ self.video_info_label.hide()
+ info_layout.addWidget(self.video_info_label)
+ info_layout.addWidget(self.video_bit_depth_label)
+ info_layout.addSpacing(scaler.scale(12))
+ info_layout.addWidget(self.video_chroma_label)
+ info_layout.addSpacing(scaler.scale(12))
+ info_layout.addWidget(self.video_hdr10_label)
+ info_layout.addSpacing(scaler.scale(12))
+ info_layout.addWidget(self.video_hdr10plus_label)
+ info_layout.addStretch()
+ file_group_layout.addLayout(info_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)))
+ if self.app.fastflix.config.theme == "onyx":
+ tabs.setStyleSheet("QLabel{ color: white; } QCheckBox{ color: white; }")
+
+ # 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 info labels
+ self.widgets.video_res_label = QtWidgets.QLabel(t("Video Resolution") + ": --")
+ self.widgets.output_res_label = QtWidgets.QLabel(t("Output Resolution") + ": --")
+ size_layout.addWidget(self.widgets.video_res_label)
+ size_layout.addWidget(self.widgets.output_res_label)
+
+ # 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)
+ if self.app.fastflix.config.theme == "onyx":
+ self.widgets.resolution_drop_down.setStyleSheet(get_onyx_combobox_style())
+ 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)
+ if self.app.fastflix.config.theme == "onyx":
+ time_reset.setStyleSheet(get_onyx_button_style())
+ 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(get_onyx_combobox_style())
+ 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())
+ start_from_preview = QtWidgets.QPushButton()
+ start_from_preview.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon))
+ start_from_preview.setFixedSize(scaler.scale(24), scaler.scale(28))
+ start_from_preview.setToolTip(t("Set start time from preview position"))
+ start_from_preview.clicked.connect(lambda: self.set_time_from_preview(self.widgets.start_time))
+ self.buttons.append(start_from_preview)
+ start_row.addWidget(start_from_preview)
+
+ self.widgets.end_time, end_row = self.build_hoz_int_field(
+ t("End"),
+ left_stretch=False,
+ right_stretch=False,
+ time_field=True,
+ )
+ self.widgets.end_time.textChanged.connect(lambda: self.page_update())
+ end_from_preview = QtWidgets.QPushButton()
+ end_from_preview.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon))
+ end_from_preview.setFixedSize(scaler.scale(24), scaler.scale(28))
+ end_from_preview.setToolTip(t("Set end time from preview position"))
+ end_from_preview.clicked.connect(lambda: self.set_time_from_preview(self.widgets.end_time))
+ self.buttons.append(end_from_preview)
+ end_row.addWidget(end_from_preview)
+
+ time_col2.addLayout(start_row)
+ time_col2.addLayout(end_row)
+ time_col2.addStretch(1)
+
+ time_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)
+ if self.app.fastflix.config.theme == "onyx":
+ auto_crop.setStyleSheet(get_onyx_button_style())
+ self.buttons.append(auto_crop)
+ reset = QtWidgets.QPushButton(t("Reset"))
+ reset.setFixedHeight(scaler.scale(22))
+ reset.setToolTip(t("Reset crop"))
+ reset.clicked.connect(self.reset_crop)
+ if self.app.fastflix.config.theme == "onyx":
+ reset.setStyleSheet(get_onyx_button_style())
+ self.buttons.append(reset)
+ 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)
@@ -578,42 +904,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:
@@ -626,7 +946,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")
@@ -677,6 +997,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()
@@ -701,14 +1023,15 @@ 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)
+ if self.app.fastflix.config.theme == "onyx":
+ self.widgets.flip.setStyleSheet(get_onyx_combobox_style())
return self.widgets.flip
def get_flips(self) -> Tuple[bool, bool]:
@@ -739,15 +1062,15 @@ 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)
-
+ if self.app.fastflix.config.theme == "onyx":
+ self.widgets.rotate.setStyleSheet(get_onyx_combobox_style())
return self.widgets.rotate
def change_output_types(self):
@@ -756,21 +1079,23 @@ 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.widgets.convert_to.setStyleSheet("font-size: 14px;")
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)
@@ -797,85 +1122,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)
@@ -914,62 +1164,65 @@ 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)
+ def update_resolution_labels(self):
+ if not self.initialized or not self.app.fastflix.current_video:
+ self.widgets.video_res_label.setText(t("Video Resolution") + ": --")
+ self.widgets.output_res_label.setText(t("Output Resolution") + ": --")
+ return
- l1 = QtWidgets.QVBoxLayout()
- l1.addWidget(label, alignment=(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft))
+ src_w = self.app.fastflix.current_video.width
+ src_h = self.app.fastflix.current_video.height
+ self.widgets.video_res_label.setText(t("Video Resolution") + f": {src_w}w {src_h}h")
- l2 = QtWidgets.QVBoxLayout()
- l2.addWidget(auto_crop, alignment=(QtCore.Qt.AlignTop | QtCore.Qt.AlignRight))
+ # Start with source dimensions, apply crop
+ out_w = src_w
+ out_h = src_h
+ try:
+ crop_top = int(self.widgets.crop.top.text() or 0)
+ crop_bottom = int(self.widgets.crop.bottom.text() or 0)
+ crop_left = int(self.widgets.crop.left.text() or 0)
+ crop_right = int(self.widgets.crop.right.text() or 0)
+ out_w -= crop_left + crop_right
+ out_h -= crop_top + crop_bottom
+ except (ValueError, AttributeError):
+ pass
- reset_layout = QtWidgets.QHBoxLayout()
- reset_layout.addWidget(QtWidgets.QLabel("Reset"))
- reset_layout.addWidget(reset)
+ if out_w <= 0 or out_h <= 0:
+ self.widgets.output_res_label.setText(t("Output Resolution") + ": --")
+ return
- l2.addLayout(reset_layout)
- l2.addStretch(1)
+ # Apply scale based on resolution method
+ method = self.resolution_method()
+ custom = self.resolution_custom()
- 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)
+ if method != "auto" and custom:
+ try:
+ if method == "custom":
+ parts = custom.split(":")
+ if len(parts) == 2:
+ cw, ch = int(parts[0]), int(parts[1])
+ if cw > 0 and ch > 0:
+ out_w, out_h = cw, ch
+ elif method == "width":
+ new_w = int(custom)
+ out_h = ((out_h * new_w // out_w) // 8) * 8
+ out_w = new_w
+ elif method == "height":
+ new_h = int(custom)
+ out_w = ((out_w * new_h // out_h) // 8) * 8
+ out_h = new_h
+ elif method == "long edge":
+ pixels = int(custom)
+ if out_w >= out_h:
+ out_h = ((out_h * pixels // out_w) // 8) * 8
+ out_w = pixels
+ else:
+ out_w = ((out_w * pixels // out_h) // 8) * 8
+ out_h = pixels
+ except (ValueError, ZeroDivisionError):
+ pass
- return crop_box
+ self.widgets.output_res_label.setText(t("Output Resolution") + f": {out_w}w {out_h}h")
def reset_crop(self):
self.loading_video = True
@@ -999,11 +1252,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)
@@ -1012,7 +1266,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: [
@@ -1022,7 +1276,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: [
@@ -1033,9 +1287,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)
@@ -1051,30 +1305,123 @@ 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.setCursor(
- QtGui.QCursor(
- QtGui.QPixmap(get_icon("onyx-magnifier", self.main.app.fastflix.config.theme)).scaledToWidth(32)
- )
+ 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(QtCore.Qt.CursorShape.ArrowCursor))
+ border_width = scaler.scale(2)
+ margin = scaler.scale(7)
+ 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."""
+ 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,
)
- self.setStyleSheet("border: 2px solid #567781; margin: 8px;")
+ super(PreviewImage, self).setPixmap(scaled)
+
+ def resizeEvent(self, event):
+ self._update_scaled_pixmap()
+ super(PreviewImage, self).resizeEvent(event)
def mousePressEvent(self, QMouseEvent):
- if (
- not self.main.initialized
- or not self.main.app.fastflix.current_video
- or self.main.large_preview.isVisible()
- ):
+ if not self.main.initialized or not self.main.app.fastflix.current_video:
return
- self.main.large_preview.generate_image()
- self.main.large_preview.show()
+ self.main.widgets.thumb_time.setFocus()
super(PreviewImage, self).mousePressEvent(QMouseEvent)
+ # 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)
+
+ # 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_()
+
+ # Large preview button at top right
+ self.large_preview_button = QtWidgets.QPushButton(self.preview_container)
+ btn_size = scaler.scale(24)
+ self.large_preview_button.setFixedSize(btn_size, btn_size)
+ self.large_preview_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DesktopIcon))
+ self.large_preview_button.setToolTip(t("Large Preview"))
+ self.large_preview_button.clicked.connect(self.open_large_preview)
+ self.large_preview_button.setStyleSheet(
+ "QPushButton { background: rgba(0,0,0,128); border: none; border-radius: 4px; }"
+ "QPushButton:hover { background: rgba(0,0,0,180); }"
+ )
+ self.large_preview_button.raise_()
- return self.widgets.preview
+ return self.preview_container
+
+ def open_large_preview(self):
+ if not self.initialized or not self.app.fastflix.current_video or self.large_preview.isVisible():
+ return
+ self.large_preview.generate_image()
+ self.large_preview.show()
+
+ def reposition_thumb_overlay(self):
+ """Reposition the thumb time overlay and large preview button."""
+ if hasattr(self, "thumb_time_overlay") and hasattr(self, "preview_container"):
+ container_rect = self.preview_container.rect()
+ overlay_height = self.thumb_time_overlay.height()
+ margin = scaler.scale(15)
+ self.thumb_time_overlay.setGeometry(
+ margin,
+ container_rect.height() - overlay_height - margin,
+ container_rect.width() - (2 * margin),
+ overlay_height,
+ )
+ if hasattr(self, "large_preview_button") and hasattr(self, "preview_container"):
+ btn_margin = scaler.scale(15)
+ btn_size = self.large_preview_button.width()
+ self.large_preview_button.move(
+ self.preview_container.width() - btn_size - btn_margin,
+ btn_margin,
+ )
def modify_int(self, widget, method="add", time_field=False):
modifier = 1
@@ -1336,18 +1683,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):
@@ -1381,7 +1721,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
@@ -1416,6 +1756,10 @@ def clear_current_video(self):
# self.widgets.scale.height.setText("Auto")
self.widgets.preview.setPixmap(QtGui.QPixmap())
self.video_options.clear_tracks()
+ self.video_bit_depth_label.hide()
+ self.video_chroma_label.hide()
+ self.video_hdr10_label.hide()
+ self.video_hdr10plus_label.hide()
self.disable_all()
self.loading_video = False
@@ -1448,6 +1792,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)
@@ -1484,12 +1833,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)
@@ -1498,6 +1847,7 @@ def reload_video_from_queue(self, video: Video):
self.enable_all()
self.app.fastflix.current_video.status = Status()
+ self.update_video_info_labels()
self.loading_video = False
self.page_update(build_thumbnail=True, force_build_thumbnail=True)
@@ -1550,7 +1900,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')}")
@@ -1591,12 +1945,82 @@ def update_video_info(self, hide_progress=False):
# self.widgets.convert_button.setStyleSheet("background-color:green;")
self.loading_video = False
+ self.update_resolution_labels()
+ self.update_video_info_labels()
+
+ # Set preview slider steps: ~1 per 10 seconds, minimum 100
+ slider_steps = max(100, int(self.app.fastflix.current_video.duration / 10))
+ self.widgets.thumb_time.setMaximum(slider_steps)
+ self.widgets.thumb_time.setPageStep(max(1, slider_steps // 20))
+ self.widgets.thumb_time.setValue(max(1, slider_steps // 4))
+
if self.app.fastflix.config.opt("auto_crop"):
self.get_auto_crop()
if not getattr(self.current_encoder, "enable_concat", False) and self.app.fastflix.current_video.concat:
error_message(f"{self.current_encoder.name} {t('does not support concatenating files together')}")
+ @staticmethod
+ def _chroma_from_pix_fmt(pix_fmt: str) -> str:
+ if not pix_fmt:
+ return ""
+ fmt = pix_fmt.lower()
+ if "444" in fmt:
+ return "4:4:4"
+ if "422" in fmt:
+ return "4:2:2"
+ if "420" in fmt or fmt in ("nv12", "nv12m", "nv21", "p010le"):
+ return "4:2:0"
+ if "411" in fmt:
+ return "4:1:1"
+ if "410" in fmt:
+ return "4:1:0"
+ if "440" in fmt:
+ return "4:4:0"
+ return ""
+
+ def update_video_info_labels(self):
+ if not self.app.fastflix.current_video:
+ self.video_info_label.hide()
+ self.video_bit_depth_label.hide()
+ self.video_chroma_label.hide()
+ self.video_hdr10_label.hide()
+ self.video_hdr10plus_label.hide()
+ return
+
+ track_index = self.widgets.video_track.currentIndex()
+ if track_index < 0:
+ return
+ stream = self.app.fastflix.current_video.streams.video[track_index]
+ stream_idx = stream.index
+
+ bit_depth = stream.get("bit_depth", "8")
+ self.video_bit_depth_label.setText(f"{bit_depth}-bit")
+ self.video_bit_depth_label.show()
+ self.video_info_label.show()
+
+ chroma = self._chroma_from_pix_fmt(stream.get("pix_fmt", ""))
+ if chroma:
+ self.video_chroma_label.setText(chroma)
+ self.video_chroma_label.show()
+ else:
+ self.video_chroma_label.hide()
+
+ hdr10_indexes = [x.index for x in self.app.fastflix.current_video.hdr10_streams]
+ if stream_idx in hdr10_indexes:
+ self.video_hdr10_label.setText("\u2714 HDR10")
+ self.video_hdr10_label.setStyleSheet("color: #00cc00;")
+ self.video_hdr10_label.show()
+ else:
+ self.video_hdr10_label.hide()
+
+ if self.app.fastflix.config.hdr10plus_parser and stream_idx in self.app.fastflix.current_video.hdr10_plus:
+ self.video_hdr10plus_label.setText("\u2714 HDR10+")
+ self.video_hdr10plus_label.setStyleSheet("color: #00cc00;")
+ self.video_hdr10plus_label.show()
+ else:
+ self.video_hdr10plus_label.hide()
+
@property
def video_track(self) -> int:
return self.widgets.video_track.currentIndex()
@@ -1619,6 +2043,11 @@ def pix_fmt(self) -> str:
def number_to_time(number) -> str:
return str(timedelta(seconds=round(number, 2)))[:10]
+ def set_time_from_preview(self, widget):
+ if not self.app.fastflix.current_video:
+ return
+ widget.setText(self.number_to_time(self.preview_place))
+
@property
def start_time(self) -> float:
return time_to_number(self.widgets.start_time.text())
@@ -1629,7 +2058,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:
@@ -1645,7 +2074,7 @@ def remove_hdr(self) -> bool:
@property
def preview_place(self) -> Union[float, int]:
- ticks = self.app.fastflix.current_video.duration / 100
+ ticks = self.app.fastflix.current_video.duration / self.widgets.thumb_time.maximum()
return (self.widgets.thumb_time.value() - 1) * ticks
@reusables.log_exception("fastflix", show_traceback=False)
@@ -1660,6 +2089,8 @@ def generate_thumbnail(self):
and self.app.fastflix.current_video.color_space.startswith("bt2020")
):
settings["remove_hdr"] = True
+ if not settings.get("color_transfer"):
+ settings["color_transfer"] = self.app.fastflix.current_video.color_transfer
custom_filters = "scale='min(440\\,iw):-8'"
if self.resolution_method() == "custom":
@@ -1668,8 +2099,11 @@ def generate_thumbnail(self):
# if self.app.fastflix.current_video.color_transfer == "arib-std-b67":
# custom_filters += ",select=eq(pict_type\\,I)"
+ use_keyframes = (
+ self.app.fastflix.config.use_keyframes_for_preview and self.app.fastflix.current_video.duration >= 60
+ )
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 use_keyframes else None,
custom_filters=custom_filters,
enable_opencl=False,
**settings,
@@ -1826,6 +2260,7 @@ def page_update(self, build_thumbnail=True, force_build_thumbnail=False):
if not self.initialized or self.loading_video or not self.app.fastflix.current_video:
return
self.last_page_update = time.time()
+ self.update_resolution_labels()
self.video_options.refresh()
self.build_commands()
if build_thumbnail:
@@ -1844,13 +2279,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()
@@ -1882,7 +2332,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
@@ -1891,12 +2341,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)
@@ -1915,7 +2365,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")
@@ -1949,7 +2399,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:
@@ -1988,7 +2438,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)
@@ -2038,7 +2488,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)
@@ -2133,6 +2583,7 @@ def send_video_request_to_worker_queue(self, video: Video):
command=command.command,
work_dir=str(video.work_path),
log_name=video.video_settings.video_title or video.video_settings.output_path.stem,
+ shell=command.shell,
)
)
video.status.running = True
@@ -2157,22 +2608,23 @@ 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/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/advanced_panel.py b/fastflix/widgets/panels/advanced_panel.py
index 8caa92d0..e059976d 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):
@@ -228,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))
@@ -356,7 +363,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 c70c7474..5aedbe85 100644
--- a/fastflix/widgets/panels/audio_panel.py
+++ b/fastflix/widgets/panels/audio_panel.py
@@ -9,9 +9,12 @@
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.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
@@ -21,6 +24,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",
@@ -54,7 +108,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(
@@ -92,24 +146,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
@@ -123,7 +177,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)
@@ -143,7 +197,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:
@@ -178,11 +232,15 @@ 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.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(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)
@@ -268,7 +326,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,23 +334,26 @@ 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):
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(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: #0055ff")
+ 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):
@@ -407,14 +468,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,
@@ -431,7 +516,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 +547,38 @@ 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)
+
+ # 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)
if not og_only:
@@ -490,6 +595,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/panels/command_panel.py b/fastflix/widgets/panels/command_panel.py
index d6602a5f..a7077c67 100644
--- a/fastflix/widgets/panels/command_panel.py
+++ b/fastflix/widgets/panels/command_panel.py
@@ -1,6 +1,8 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
-import math
+import shlex
+import subprocess
+import sys
from pathlib import Path
import reusables
@@ -9,6 +11,17 @@
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
+
+
+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):
@@ -29,15 +42,10 @@ 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)
- 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)
@@ -53,6 +61,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):
@@ -87,14 +109,14 @@ 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 = []
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):
@@ -120,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/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py
index 9a4676b1..8837f233 100644
--- a/fastflix/widgets/panels/queue_panel.py
+++ b/fastflix/widgets/panels/queue_panel.py
@@ -16,9 +16,10 @@
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.ui_scale import scaler
from fastflix.widgets.panels.abstract_list import FlixList
from fastflix.exceptions import FastFlixInternalException
from fastflix.windows_tools import allow_sleep_mode, prevent_sleep_mode
@@ -95,23 +96,22 @@ def __init__(self, parent, video: Video, index, first=False):
del settings
open_button = QtWidgets.QPushButton(
- QtGui.QIcon(get_icon("play", self.parent.app.fastflix.config.theme)), t("Open Directory")
+ self.parent.app.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DirOpenIcon), t("Open Directory")
)
- open_button.setLayoutDirection(QtCore.Qt.RightToLeft)
- open_button.setIconSize(QtCore.QSize(14, 14))
+ open_button.setIconSize(scaler.scale_size(12, 12))
open_button.clicked.connect(lambda: open_folder(video.video_settings.output_path.parent))
view_button = QtWidgets.QPushButton(
- QtGui.QIcon(get_icon("play", self.parent.app.fastflix.config.theme)), t("Watch")
+ self.parent.app.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MediaPlay), t("Watch")
)
- view_button.setLayoutDirection(QtCore.Qt.RightToLeft)
- view_button.setIconSize(QtCore.QSize(14, 14))
+ view_button.setIconSize(scaler.scale_size(12, 12))
view_button.clicked.connect(
lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(str(video.video_settings.output_path)))
)
- open_button.setStyleSheet(no_border)
- view_button.setStyleSheet(no_border)
+ button_style = no_border + " QPushButton { padding: 0 4px; }"
+ open_button.setStyleSheet(button_style)
+ view_button.setStyleSheet(button_style)
add_retry = False
status = t("Ready to encode")
@@ -131,8 +131,10 @@ def __init__(self, parent, video: Video, index, first=False):
if not self.video.status.running:
self.widgets.cancel_button.clicked.connect(lambda: self.parent.remove_item(self.video))
self.widgets.reload_button.clicked.connect(lambda: self.parent.reload_from_queue(self.video))
- self.widgets.cancel_button.setFixedWidth(25)
- self.widgets.reload_button.setFixedWidth(25)
+ self.widgets.cancel_button.setFixedWidth(scaler.scale(20))
+ self.widgets.cancel_button.setIconSize(scaler.scale_size(12, 12))
+ self.widgets.reload_button.setFixedWidth(scaler.scale(20))
+ self.widgets.reload_button.setIconSize(scaler.scale_size(12, 12))
else:
self.widgets.cancel_button.hide()
self.widgets.reload_button.hide()
@@ -154,7 +156,8 @@ def __init__(self, parent, video: Video, index, first=False):
grid.addWidget(open_button, 0, 9)
elif add_retry:
grid.addWidget(self.widgets.retry_button, 0, 8)
- self.widgets.retry_button.setFixedWidth(25)
+ self.widgets.retry_button.setFixedWidth(scaler.scale(20))
+ self.widgets.retry_button.setIconSize(scaler.scale_size(12, 12))
self.widgets.retry_button.clicked.connect(lambda: self.parent.retry_video(self.video))
right_buttons = QtWidgets.QHBoxLayout()
@@ -170,9 +173,13 @@ def __init__(self, parent, video: Video, index, first=False):
def init_move_buttons(self):
layout = QtWidgets.QVBoxLayout()
layout.setSpacing(0)
- 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.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)
@@ -230,8 +237,10 @@ 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(
@@ -302,8 +311,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)
@@ -337,7 +344,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)
+ # No explicit save needed - new_source() triggers reorder() which saves the queue
def manually_save_queue(self):
filename = QtWidgets.QFileDialog.getSaveFileName(
@@ -393,7 +400,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):
@@ -438,7 +445,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)
+ # Queue is saved by new_source() -> reorder() -> save_queue_async()
def reload_from_queue(self, video):
try:
@@ -549,7 +556,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)
+ # 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 e6f0f3d5..33048588 100644
--- a/fastflix/widgets/panels/status_panel.py
+++ b/fastflix/widgets/panels/status_panel.py
@@ -6,11 +6,14 @@
from datetime import timedelta
from typing import Optional
+from queue import Empty
+
from PySide6 import QtCore, QtWidgets
from fastflix.exceptions import FlixError
from fastflix.language import t
from fastflix.models.fastflix_app import FastFlixApp
+from fastflix.models.encode import GifskiSettings
from fastflix.models.video import Video
from fastflix.shared import time_to_number, timedelta_to_str
@@ -68,7 +71,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()
@@ -211,6 +217,12 @@ def blank(self, data):
logger.error(f"Couldn't find video or command for UUID {video_uuid}:{command_uuid}")
self.parent.current_video = None
self.current_command = None
+ if self.parent.current_video and isinstance(
+ self.parent.current_video.video_settings.video_encoder_settings, GifskiSettings
+ ):
+ self.parent.size_label.setVisible(False)
+ else:
+ self.parent.size_label.setVisible(True)
self.setText("")
self.parent.started_at = datetime.datetime.now(datetime.timezone.utc)
@@ -219,6 +231,7 @@ def timer_update(self, cmd):
def closeEvent(self, event):
self.hide()
+ event.ignore()
class ElapsedTimeTicker(QtCore.QThread):
@@ -236,9 +249,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)
@@ -262,13 +272,18 @@ 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")
diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py
index ff814210..bcedb44e 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 -*-
+from pathlib import Path
from typing import Union
from box import Box
@@ -12,6 +13,8 @@
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.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
@@ -46,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
@@ -106,14 +109,59 @@ 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 dependencies are available
+ if not self.app.fastflix.config.pgs_ocr_available:
+ ocr_action.setEnabled(False)
+ 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)
self.gif_label = QtWidgets.QLabel(self)
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
@@ -122,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()
@@ -130,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)
@@ -148,32 +203,90 @@ 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)
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)
return layout
- def extract(self):
- worker = ExtractSubtitleSRT(
- self.parent.app, self.parent.main, self.index, self.extract_completed_signal, language=self.language
+ 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):
+ 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})",
)
- worker.start()
- self.gif_label.show()
+ 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,
+ )
+ 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):
@@ -237,7 +350,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
@@ -257,9 +370,195 @@ 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(get_onyx_disposition_style(enabled=True))
else:
- self.widgets.disposition.setStyleSheet("")
+ self.widgets.disposition.setStyleSheet(get_onyx_disposition_style(enabled=False))
+
+
+ext_subtitle_types = {
+ ".srt": "text",
+ ".ass": "text",
+ ".ssa": "text",
+ ".vtt": "text",
+ ".sup": "picture",
+}
+
+
+class ExternalSubtitle(QtWidgets.QTabWidget):
+ def __init__(self, app, parent, index, enabled=True, first=False):
+ self.loading = True
+ super(ExternalSubtitle, self).__init__(parent)
+ self.app = app
+ self.parent: "SubtitleList" = parent
+ self.setObjectName("Subtitle")
+ self.index = index
+ self.outdex = None
+ self.first = first
+ self.last = False
+
+ sub_track: SubtitleTrack = self.app.fastflix.current_video.subtitle_tracks[index]
+ filename = Path(sub_track.file_path).name if sub_track.file_path else "unknown"
+
+ self.widgets = Box(
+ track_number=QtWidgets.QLabel("[EXT]" if enabled else "❌"),
+ title=QtWidgets.QLabel(f" [EXT] {filename}"),
+ up_button=QtWidgets.QPushButton(
+ QtGui.QIcon(get_icon("up-arrow", self.parent.app.fastflix.config.theme)), ""
+ ),
+ down_button=QtWidgets.QPushButton(
+ QtGui.QIcon(get_icon("down-arrow", self.parent.app.fastflix.config.theme)), ""
+ ),
+ enable_check=QtWidgets.QCheckBox(t("Preserve")),
+ disposition=QtWidgets.QPushButton(t("Dispositions")),
+ language=QtWidgets.QComboBox(),
+ burn_in=QtWidgets.QCheckBox(t("Burn In")),
+ remove_button=QtWidgets.QPushButton(t("Remove")),
+ )
+
+ self.widgets.up_button.setStyleSheet(no_border)
+ self.widgets.down_button.setStyleSheet(no_border)
+
+ self.widgets.enable_check.setChecked(enabled)
+ self.widgets.enable_check.toggled.connect(self.update_enable)
+ self.widgets.burn_in.toggled.connect(self.update_burn_in)
+ self.widgets.remove_button.clicked.connect(self.remove)
+
+ self.setFixedHeight(60)
+ self.widgets.title.setToolTip(str(sub_track.file_path))
+ self.widgets.burn_in.setToolTip(
+ f"""{t("Overlay this subtitle track onto the video during conversion.")}\n
+ {t("Please make sure seek method is set to exact")}.\n
+ {t("Cannot remove afterwards!")}
+ """
+ )
+
+ self.disposition_widget = Disposition(
+ app=self.app, parent=self, track_name=f"Subtitle Track {index}", track_index=index, audio=False
+ )
+ self.widgets.disposition.clicked.connect(self.disposition_widget.show)
+
+ disposition_layout = QtWidgets.QHBoxLayout()
+ disposition_layout.addWidget(self.widgets.disposition)
+
+ self.grid = QtWidgets.QGridLayout()
+ self.grid.addLayout(self.init_move_buttons(), 0, 0)
+ self.grid.addWidget(self.widgets.track_number, 0, 1)
+ self.grid.addWidget(self.widgets.title, 0, 2)
+ self.grid.setColumnStretch(2, True)
+ self.grid.addWidget(self.widgets.remove_button, 0, 3)
+ self.grid.addLayout(disposition_layout, 0, 4)
+ self.grid.addWidget(self.widgets.burn_in, 0, 5)
+ self.grid.addLayout(self.init_language(sub_track), 0, 6)
+ self.grid.addWidget(self.widgets.enable_check, 0, 8)
+
+ self.setLayout(self.grid)
+ self.check_dis_button()
+ self.loading = False
+ self.updating_burn = False
+
+ def init_move_buttons(self):
+ layout = QtWidgets.QVBoxLayout()
+ layout.setSpacing(0)
+ self.widgets.up_button.setDisabled(self.first)
+ self.widgets.up_button.setFixedWidth(scaler.scale(17))
+ self.widgets.up_button.setFixedHeight(scaler.scale(20))
+ self.widgets.up_button.setIconSize(scaler.scale_size(12, 12))
+ self.widgets.up_button.clicked.connect(lambda: self.parent.move_up(self))
+ self.widgets.down_button.setDisabled(self.last)
+ self.widgets.down_button.setFixedWidth(scaler.scale(17))
+ self.widgets.down_button.setFixedHeight(scaler.scale(20))
+ self.widgets.down_button.setIconSize(scaler.scale_size(12, 12))
+ self.widgets.down_button.clicked.connect(lambda: self.parent.move_down(self))
+ layout.addWidget(self.widgets.up_button)
+ layout.addWidget(self.widgets.down_button)
+ return layout
+
+ def init_language(self, sub_track: SubtitleTrack):
+ self.widgets.language.addItems(language_list)
+ self.widgets.language.setMaximumWidth(110)
+ try:
+ self.widgets.language.setCurrentIndex(language_list.index(Language(sub_track.language).name))
+ except Exception:
+ self.widgets.language.setCurrentIndex(language_list.index("English"))
+ self.widgets.language.currentIndexChanged.connect(self.update_language)
+ layout = QtWidgets.QHBoxLayout()
+ layout.addWidget(QtWidgets.QLabel(t("Language")))
+ layout.addWidget(self.widgets.language)
+ return layout
+
+ def set_first(self, first=True):
+ self.first = first
+ self.widgets.up_button.setDisabled(self.first)
+
+ def set_last(self, last=True):
+ self.last = last
+ self.widgets.down_button.setDisabled(self.last)
+
+ def set_outdex(self, outdex):
+ self.app.fastflix.current_video.subtitle_tracks[self.index].outdex = outdex
+ self.outdex = outdex
+ if not self.enabled:
+ self.widgets.track_number.setText("❌")
+ else:
+ self.widgets.track_number.setText("[EXT]")
+
+ @property
+ def enabled(self):
+ try:
+ return self.app.fastflix.current_video.subtitle_tracks[self.index].enabled
+ except IndexError:
+ return False
+
+ @property
+ def language(self):
+ return Language(self.widgets.language.currentText()).pt2b
+
+ @property
+ def burn_in(self):
+ return self.widgets.burn_in.isChecked()
+
+ def update_enable(self):
+ enabled = self.widgets.enable_check.isChecked()
+ sub_track = self.app.fastflix.current_video.subtitle_tracks[self.index]
+ sub_track.enabled = enabled
+ self.widgets.track_number.setText("[EXT]" if enabled else "❌")
+ self.parent.reorder(update=True)
+
+ def update_burn_in(self):
+ if self.updating_burn:
+ return
+ self.updating_burn = True
+ enable = self.widgets.burn_in.isChecked()
+ if enable and [1 for track in self.parent.tracks if track.enabled and track.burn_in and track is not 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.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
+ self.page_update()
+
+ def update_language(self):
+ if not self.loading:
+ sub_track = self.app.fastflix.current_video.subtitle_tracks[self.index]
+ sub_track.language = self.language
+ self.page_update()
+
+ def page_update(self):
+ if not self.loading:
+ self.check_dis_button()
+ return self.parent.main.page_update(build_thumbnail=False)
+
+ 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(get_onyx_disposition_style(enabled=True))
+ else:
+ self.widgets.disposition.setStyleSheet(get_onyx_disposition_style(enabled=False))
+
+ def remove(self):
+ self.parent.remove_external_track(self)
class SubtitleList(FlixList):
@@ -271,6 +570,9 @@ def __init__(self, parent, app: FastFlixApp):
top_layout.addWidget(label)
top_layout.addStretch(1)
+ self.add_subtitle_button = QtWidgets.QPushButton(t("Add External"))
+ self.add_subtitle_button.setFixedWidth(150)
+ self.add_subtitle_button.clicked.connect(self.add_external_subtitle)
self.remove_all_button = QtWidgets.QPushButton(t("Unselect All"))
self.remove_all_button.setFixedWidth(150)
self.remove_all_button.clicked.connect(lambda: self.select_all(False))
@@ -278,6 +580,7 @@ def __init__(self, parent, app: FastFlixApp):
self.save_all_button.setFixedWidth(150)
self.save_all_button.clicked.connect(lambda: self.select_all(True))
+ top_layout.addWidget(self.add_subtitle_button)
top_layout.addWidget(self.remove_all_button)
top_layout.addWidget(self.save_all_button)
@@ -290,10 +593,62 @@ 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 add_external_subtitle(self):
+ if not self.app.fastflix.current_video:
+ return
+ filenames, _ = QtWidgets.QFileDialog.getOpenFileNames(
+ self,
+ caption=t("Select Subtitle File"),
+ filter=f"{t('Subtitle Files')} (*.srt *.ass *.ssa *.vtt *.sup)",
+ )
+ if not filenames:
+ return
+ for filename in filenames:
+ ext = Path(filename).suffix.lower()
+ sub_type = ext_subtitle_types.get(ext, "text")
+ index = len(self.app.fastflix.current_video.subtitle_tracks)
+ audio_end = len([x for x in self.app.fastflix.current_video.audio_tracks if x.enabled])
+ self.app.fastflix.current_video.subtitle_tracks.append(
+ SubtitleTrack(
+ index=0,
+ outdex=audio_end + index + 1,
+ burn_in=False,
+ language="",
+ subtitle_type=sub_type,
+ enabled=True,
+ long_name=f"[EXT] {Path(filename).name}",
+ external=True,
+ file_path=str(filename),
+ )
+ )
+ new_widget = ExternalSubtitle(
+ app=self.app,
+ parent=self,
+ index=index,
+ first=False,
+ enabled=True,
+ )
+ self.tracks.append(new_widget)
+ self.inner_layout.addWidget(new_widget)
+ self.reorder(update=True)
+
+ def remove_external_track(self, widget):
+ track_index = widget.index
+ self.app.fastflix.current_video.subtitle_tracks.pop(track_index)
+ self.tracks.remove(widget)
+ widget.close()
+ # Re-index all remaining widgets
+ for i, w in enumerate(self.tracks):
+ w.index = i
+ self.reorder(update=True)
+
+ 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
@@ -377,19 +732,69 @@ 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)
for i, track in enumerate(self.app.fastflix.current_video.subtitle_tracks):
- self.tracks.append(
- Subtitle(
- app=self.app,
- parent=self,
- index=i,
- first=True if i == 0 else False,
- enabled=track.enabled,
+ if track.external:
+ self.tracks.append(
+ ExternalSubtitle(
+ app=self.app,
+ parent=self,
+ index=i,
+ first=True if i == 0 else False,
+ enabled=track.enabled,
+ )
+ )
+ else:
+ self.tracks.append(
+ Subtitle(
+ app=self.app,
+ parent=self,
+ index=i,
+ first=True if i == 0 else False,
+ enabled=track.enabled,
+ )
)
- )
super()._new_source(self.tracks)
def move_up(self, widget):
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/settings.py b/fastflix/widgets/settings.py
index 4dfd5a47..20b9efe2 100644
--- a/fastflix/widgets/settings.py
+++ b/fastflix/widgets/settings.py
@@ -48,162 +48,161 @@ def __init__(self, app: FastFlixApp, main, *args, **kwargs):
self.setWindowTitle(t("Settings"))
self.setMinimumSize(600, 200)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
- layout = QtWidgets.QGridLayout()
- 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.clicked.connect(lambda: self.select_ffmpeg())
- layout.addWidget(ffmpeg_label, 0, 0)
- layout.addWidget(self.ffmpeg_path, 0, 1)
- layout.addWidget(ffmpeg_path_button, 0, 2)
+ main_layout = QtWidgets.QVBoxLayout()
- ffprobe_label = QtWidgets.QLabel("FFprobe")
- 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.clicked.connect(lambda: self.select_ffprobe())
- layout.addWidget(ffprobe_label, 1, 0)
- layout.addWidget(self.ffprobe_path, 1, 1)
- layout.addWidget(ffprobe_path_button, 1, 2)
+ tab_widget = QtWidgets.QTabWidget()
+ tab_widget.addTab(self._build_settings_tab(), t("Settings"))
+ tab_widget.addTab(self._build_locations_tab(), t("Application Locations"))
+ main_layout.addWidget(tab_widget)
+
+ # Save/Cancel buttons at the bottom (outside tabs)
+ save = QtWidgets.QPushButton(
+ icon=self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton), text=t("Save")
+ )
+ save.clicked.connect(lambda: self.save())
+
+ cancel = QtWidgets.QPushButton(
+ icon=self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton), text=t("Cancel")
+ )
+ cancel.clicked.connect(lambda: self.close())
+
+ button_layout = QtWidgets.QHBoxLayout()
+ button_layout.addStretch()
+ button_layout.addWidget(cancel)
+ button_layout.addWidget(save)
+ main_layout.addLayout(button_layout)
+
+ self.setLayout(main_layout)
+
+ def _build_settings_tab(self):
+ tab = QtWidgets.QWidget()
+ layout = QtWidgets.QGridLayout()
+ layout.setColumnStretch(1, 1)
+ row = 0
+
+ # Config File
+ layout.addWidget(QtWidgets.QLabel(t("Config File")), row, 0)
+ layout.addWidget(QtWidgets.QLabel(str(self.config_file)), row, 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)))
+ )
+ layout.addWidget(config_button, row, 2)
+ row += 1
+ # Work Directory
work_dir_label = QtWidgets.QLabel(t("Work Directory"))
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)
- layout.addWidget(work_path_button, 2, 2)
-
- layout.addWidget(QtWidgets.QLabel(t("Config File")), 4, 0)
- layout.addWidget(QtWidgets.QLabel(str(self.config_file)), 4, 1)
+ layout.addWidget(work_dir_label, row, 0)
+ layout.addWidget(self.work_dir, row, 1)
+ layout.addWidget(work_path_button, row, 2)
+ row += 1
+ # Language
self.language_combo = QtWidgets.QComboBox(self)
self.language_combo.addItems(known_language_list)
try:
if self.app.fastflix.config.language in ("chs", "zho"):
index = known_language_list.index("Chinese (Simplified)")
-
- # reserved for future use
- # elif self.app.fastflix.config.language == "cht":
- # index = known_language_list.index("Chinese (Traditional)")
-
else:
index = known_language_list.index(Language(self.app.fastflix.config.language).name)
except (IndexError, InvalidLanguageValue):
logger.exception(f"{t('Could not find language for')} {self.app.fastflix.config.language}")
index = known_language_list.index("English")
self.language_combo.setCurrentIndex(index)
+ layout.addWidget(QtWidgets.QLabel(t("Language")), row, 0)
+ layout.addWidget(self.language_combo, row, 1)
+ row += 1
- layout.addWidget(QtWidgets.QLabel(t("Language")), 5, 0)
- layout.addWidget(self.language_combo, 5, 1)
-
- config_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_FileIcon))
- config_button.clicked.connect(
- lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(str(self.config_file)))
- )
- layout.addWidget(config_button, 4, 2)
-
- save = QtWidgets.QPushButton(
- icon=self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton), text=t("Save")
- )
- save.clicked.connect(lambda: self.save())
-
- cancel = QtWidgets.QPushButton(
- icon=self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton), text=t("Cancel")
- )
- cancel.clicked.connect(lambda: self.close())
-
- self.use_sane_audio = QtWidgets.QCheckBox(t("Use Sane Audio Selection (updatable in config file)"))
- if self.app.fastflix.config.use_sane_audio:
- self.use_sane_audio.setChecked(True)
- self.disable_version_check = QtWidgets.QCheckBox(t("Disable update check on startup"))
- if not self.app.fastflix.config.disable_version_check:
- self.disable_version_check.setChecked(False)
- elif self.app.fastflix.config.disable_version_check:
- self.disable_version_check.setChecked(True)
-
- self.disable_end_message = QtWidgets.QCheckBox(t("Disable completion and error messages"))
- if self.app.fastflix.config.disable_complete_message:
- self.disable_end_message.setChecked(True)
+ # Theme
+ self.theme = QtWidgets.QComboBox()
+ self.theme.addItems(["onyx", "light", "dark", "system"])
+ self.theme.setCurrentText(self.app.fastflix.config.theme)
+ layout.addWidget(QtWidgets.QLabel(t("Theme")), row, 0)
+ layout.addWidget(self.theme, row, 1)
+ row += 1
+ # GUI Logging Level
self.logger_level_widget = QtWidgets.QComboBox()
self.logger_level_widget.addItems(["Debug", "Info", "Warning", "Error"])
self.logger_level_widget.setCurrentIndex(int(self.app.fastflix.config.logging_level // 10) - 1)
+ layout.addWidget(QtWidgets.QLabel(t("GUI Logging Level")), row, 0)
+ layout.addWidget(self.logger_level_widget, row, 1)
+ row += 1
- self.theme = QtWidgets.QComboBox()
- self.theme.addItems(["onyx", "light", "dark", "system"])
- self.theme.setCurrentText(self.app.fastflix.config.theme)
-
+ # Crop Detect Points
self.crop_detect_points_widget = QtWidgets.QComboBox()
self.crop_detect_points_widget.addItems(possible_detect_points)
-
try:
self.crop_detect_points_widget.setCurrentIndex(
possible_detect_points.index(str(self.app.fastflix.config.crop_detect_points))
)
except ValueError:
self.crop_detect_points_widget.setCurrentIndex(5)
+ layout.addWidget(QtWidgets.QLabel(t("Crop Detect Points")), row, 0)
+ layout.addWidget(self.crop_detect_points_widget, row, 1)
+ row += 1
+ # UI Scale
self.ui_scale_widget = QtWidgets.QComboBox()
self.ui_scale_widget.addItems(scale_percents)
self.ui_scale_widget.setCurrentText(scale_percents[scale_digits.index(self.app.fastflix.config.ui_scale)])
+ layout.addWidget(QtWidgets.QLabel(t("UI Scale")), row, 0)
+ layout.addWidget(self.ui_scale_widget, row, 1)
+ row += 1
- nvencc_label = QtWidgets.QLabel(
- link("https://github.com/rigaya/NVEnc/releases", "NVEncC", app.fastflix.config.theme)
- )
- nvencc_label.setOpenExternalLinks(True)
- self.nvencc_path = QtWidgets.QLineEdit()
- 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.clicked.connect(lambda: self.select_nvencc())
- layout.addWidget(nvencc_label, 12, 0)
- layout.addWidget(self.nvencc_path, 12, 1)
- layout.addWidget(nvenc_path_button, 12, 2)
+ # Checkboxes
+ self.use_sane_audio = QtWidgets.QCheckBox(t("Use Sane Audio Selection (updatable in config file)"))
+ if self.app.fastflix.config.use_sane_audio:
+ self.use_sane_audio.setChecked(True)
+ layout.addWidget(self.use_sane_audio, row, 0, 1, 2)
+ row += 1
- vceenc_label = QtWidgets.QLabel(
- link("https://github.com/rigaya/VCEEnc/releases", "VCEEncC", app.fastflix.config.theme)
- )
- vceenc_label.setOpenExternalLinks(True)
- self.vceenc_path = QtWidgets.QLineEdit()
- 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.clicked.connect(lambda: self.select_vceenc())
- layout.addWidget(vceenc_label, 13, 0)
- layout.addWidget(self.vceenc_path, 13, 1)
- layout.addWidget(vceenc_path_button, 13, 2)
+ self.disable_version_check = QtWidgets.QCheckBox(t("Disable update check on startup"))
+ if self.app.fastflix.config.disable_version_check:
+ self.disable_version_check.setChecked(True)
+ layout.addWidget(self.disable_version_check, row, 0, 1, 2)
+ row += 1
- qsvencc_label = QtWidgets.QLabel(
- link("https://github.com/rigaya/QSVEnc/releases", "QSVEncC", app.fastflix.config.theme)
- )
- qsvencc_label.setOpenExternalLinks(True)
- self.qsvenc_path = QtWidgets.QLineEdit()
- if self.app.fastflix.config.qsvencc:
- self.qsvenc_path.setText(str(self.app.fastflix.config.qsvencc))
- qsvencc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon))
- qsvencc_path_button.clicked.connect(lambda: self.select_qsvencc())
- layout.addWidget(qsvencc_label, 14, 0)
- layout.addWidget(self.qsvenc_path, 14, 1)
- layout.addWidget(qsvencc_path_button, 14, 2)
+ self.disable_end_message = QtWidgets.QCheckBox(t("Disable completion and error messages"))
+ if self.app.fastflix.config.disable_complete_message:
+ self.disable_end_message.setChecked(True)
+ layout.addWidget(self.disable_end_message, row, 0, 1, 2)
+ row += 1
- hdr10_parser_label = QtWidgets.QLabel(
- link("https://github.com/quietvoid/hdr10plus_tool", "HDR10+ Parser Tool", app.fastflix.config.theme)
+ self.clean_old_logs_button = QtWidgets.QCheckBox(
+ t("Remove GUI logs and compress conversion logs older than 30 days at exit")
)
- hdr10_parser_label.setOpenExternalLinks(True)
- self.hdr10_parser_path = QtWidgets.QLineEdit()
- if self.app.fastflix.config.hdr10plus_parser:
- self.hdr10_parser_path.setText(str(self.app.fastflix.config.hdr10plus_parser))
- hdr10_parser_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon))
- hdr10_parser_path_button.clicked.connect(lambda: self.select_hdr10_parser())
- layout.addWidget(hdr10_parser_label, 15, 0)
- layout.addWidget(self.hdr10_parser_path, 15, 1)
- layout.addWidget(hdr10_parser_path_button, 15, 2)
+ self.clean_old_logs_button.setChecked(self.app.fastflix.config.clean_old_logs)
+ layout.addWidget(self.clean_old_logs_button, row, 0, 1, 3)
+ row += 1
+
+ self.disable_deinterlace_button = QtWidgets.QCheckBox(t("Disable interlace check"))
+ self.disable_deinterlace_button.setChecked(self.app.fastflix.config.disable_deinterlace_check)
+ layout.addWidget(self.disable_deinterlace_button, row, 0, 1, 3)
+ row += 1
+
+ self.use_keyframes_for_preview = QtWidgets.QCheckBox(t("Use keyframes for preview images"))
+ self.use_keyframes_for_preview.setChecked(self.app.fastflix.config.use_keyframes_for_preview)
+ layout.addWidget(self.use_keyframes_for_preview, row, 0, 1, 3)
+ row += 1
+
+ self.sticky_tabs = QtWidgets.QCheckBox(t("Disable Automatic Tab Switching"))
+ self.sticky_tabs.setChecked(self.app.fastflix.config.sticky_tabs)
+ layout.addWidget(self.sticky_tabs, row, 0, 1, 2)
+ row += 1
+
+ # Default Output Directory
+ self.default_output_dir = QtWidgets.QCheckBox(t("Use same output directory as source file"))
+ layout.addWidget(self.default_output_dir, row, 0, 1, 2)
+ row += 1
- # OUTPUT DIR
output_label = QtWidgets.QLabel(t("Default Output Folder"))
self.output_path_line_edit = QtWidgets.QLineEdit()
if self.app.fastflix.config.output_directory:
@@ -211,12 +210,13 @@ 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)
- layout.addWidget(self.output_label_path_button, 17, 2)
+ layout.addWidget(output_label, row, 0)
+ layout.addWidget(self.output_path_line_edit, row, 1)
+ layout.addWidget(self.output_label_path_button, row, 2)
+ row += 1
- self.default_output_dir = QtWidgets.QCheckBox(t("Use same output directory as source file"))
if not self.app.fastflix.config.output_directory:
self.default_output_dir.setChecked(True)
self.output_path_line_edit.setDisabled(True)
@@ -227,74 +227,193 @@ def out_click():
self.output_label_path_button.setEnabled(self.output_path_line_edit.isEnabled())
self.default_output_dir.clicked.connect(out_click)
- layout.addWidget(self.default_output_dir, 16, 0, 1, 2)
- # SOURCE DIR
+ # Default Source Directory
+ self.default_source_dir = QtWidgets.QCheckBox(t("No Default Source Folder"))
+ layout.addWidget(self.default_source_dir, row, 0, 1, 2)
+ row += 1
source_label = QtWidgets.QLabel(t("Default Source Folder"))
self.source_path_line_edit = QtWidgets.QLineEdit()
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.clicked.connect(lambda: self.select_source_directory())
- layout.addWidget(source_label, 19, 0)
- layout.addWidget(self.source_path_line_edit, 19, 1)
- layout.addWidget(source_label_path_button, 19, 2)
+ self.source_label_path_button = QtWidgets.QPushButton(
+ icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon)
+ )
+ self.source_label_path_button.setFixedWidth(30)
+ self.source_label_path_button.clicked.connect(lambda: self.select_source_directory())
+ layout.addWidget(source_label, row, 0)
+ layout.addWidget(self.source_path_line_edit, row, 1)
+ layout.addWidget(self.source_label_path_button, row, 2)
+ row += 1
- self.default_source_dir = QtWidgets.QCheckBox(t("No Default Source Folder"))
if not self.app.fastflix.config.source_directory:
self.default_source_dir.setChecked(True)
self.source_path_line_edit.setDisabled(True)
- source_label_path_button.setDisabled(True)
+ self.source_label_path_button.setDisabled(True)
def in_dir():
self.source_path_line_edit.setDisabled(self.source_path_line_edit.isEnabled())
- source_label_path_button.setEnabled(self.source_path_line_edit.isEnabled())
+ self.source_label_path_button.setEnabled(self.source_path_line_edit.isEnabled())
self.default_source_dir.clicked.connect(in_dir)
- self.sticky_tabs = QtWidgets.QCheckBox(t("Disable Automatic Tab Switching"))
- self.sticky_tabs.setChecked(self.app.fastflix.config.sticky_tabs)
- mm = QtWidgets.QHBoxLayout()
- mm.addWidget(self.default_source_dir)
- mm.addWidget(self.sticky_tabs)
+ # Spacer
+ layout.setRowStretch(row, 1)
- layout.addLayout(mm, 18, 0, 1, 2)
+ tab.setLayout(layout)
+ return tab
- self.clean_old_logs_button = QtWidgets.QCheckBox(
- t("Remove GUI logs and compress conversion logs older than 30 days at exit")
- )
- self.clean_old_logs_button.setChecked(self.app.fastflix.config.clean_old_logs)
+ def _build_locations_tab(self):
+ tab = QtWidgets.QWidget()
+ layout = QtWidgets.QGridLayout()
+ layout.setColumnStretch(1, 1)
+ row = 0
- self.disable_deinterlace_button = QtWidgets.QCheckBox(t("Disable interlace check"))
- self.disable_deinterlace_button.setChecked(self.app.fastflix.config.disable_deinterlace_check)
+ # FFmpeg
+ 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, row, 0)
+ layout.addWidget(self.ffmpeg_path, row, 1)
+ layout.addWidget(ffmpeg_path_button, row, 2)
+ row += 1
- # Layouts
+ # FFprobe
+ ffprobe_label = QtWidgets.QLabel("FFprobe")
+ 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, row, 0)
+ layout.addWidget(self.ffprobe_path, row, 1)
+ layout.addWidget(ffprobe_path_button, row, 2)
+ row += 1
- 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)
- layout.addWidget(self.logger_level_widget, 9, 1)
- layout.addWidget(QtWidgets.QLabel(t("Theme")), 10, 0)
- layout.addWidget(self.theme, 10, 1)
- layout.addWidget(QtWidgets.QLabel(t("Crop Detect Points")), 11, 0, 1, 1)
- layout.addWidget(self.crop_detect_points_widget, 11, 1, 1, 1)
+ # NVEncC
+ nvencc_label = QtWidgets.QLabel(
+ link("https://github.com/rigaya/NVEnc/releases", "NVEncC", self.app.fastflix.config.theme)
+ )
+ nvencc_label.setOpenExternalLinks(True)
+ self.nvencc_path = QtWidgets.QLineEdit()
+ 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, row, 0)
+ layout.addWidget(self.nvencc_path, row, 1)
+ layout.addWidget(nvenc_path_button, row, 2)
+ row += 1
- layout.addWidget(QtWidgets.QLabel(t("UI Scale")), 20, 0, 1, 1)
- layout.addWidget(self.ui_scale_widget, 20, 1, 1, 1)
+ # VCEEncC
+ vceenc_label = QtWidgets.QLabel(
+ link("https://github.com/rigaya/VCEEnc/releases", "VCEEncC", self.app.fastflix.config.theme)
+ )
+ vceenc_label.setOpenExternalLinks(True)
+ self.vceenc_path = QtWidgets.QLineEdit()
+ 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, row, 0)
+ layout.addWidget(self.vceenc_path, row, 1)
+ layout.addWidget(vceenc_path_button, row, 2)
+ row += 1
- 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)
+ # QSVEncC
+ qsvencc_label = QtWidgets.QLabel(
+ link("https://github.com/rigaya/QSVEnc/releases", "QSVEncC", self.app.fastflix.config.theme)
+ )
+ qsvencc_label.setOpenExternalLinks(True)
+ self.qsvenc_path = QtWidgets.QLineEdit()
+ if self.app.fastflix.config.qsvencc:
+ self.qsvenc_path.setText(str(self.app.fastflix.config.qsvencc))
+ qsvencc_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon))
+ qsvencc_path_button.setFixedWidth(30)
+ qsvencc_path_button.clicked.connect(lambda: self.select_qsvencc())
+ layout.addWidget(qsvencc_label, row, 0)
+ layout.addWidget(self.qsvenc_path, row, 1)
+ layout.addWidget(qsvencc_path_button, row, 2)
+ row += 1
- button_layout = QtWidgets.QHBoxLayout()
- button_layout.addStretch()
- button_layout.addWidget(cancel)
- button_layout.addWidget(save)
+ # HDR10+ Parser
+ hdr10_parser_label = QtWidgets.QLabel(
+ link("https://github.com/quietvoid/hdr10plus_tool", "HDR10+ Parser Tool", self.app.fastflix.config.theme)
+ )
+ hdr10_parser_label.setOpenExternalLinks(True)
+ self.hdr10_parser_path = QtWidgets.QLineEdit()
+ if self.app.fastflix.config.hdr10plus_parser:
+ self.hdr10_parser_path.setText(str(self.app.fastflix.config.hdr10plus_parser))
+ hdr10_parser_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon))
+ hdr10_parser_path_button.setFixedWidth(30)
+ hdr10_parser_path_button.clicked.connect(lambda: self.select_hdr10_parser())
+ layout.addWidget(hdr10_parser_label, row, 0)
+ layout.addWidget(self.hdr10_parser_path, row, 1)
+ layout.addWidget(hdr10_parser_path_button, row, 2)
+ row += 1
+
+ # gifski
+ gifski_label = QtWidgets.QLabel(link("https://gif.ski/", "gifski", self.app.fastflix.config.theme))
+ gifski_label.setOpenExternalLinks(True)
+ self.gifski_path = QtWidgets.QLineEdit()
+ if self.app.fastflix.config.gifski:
+ self.gifski_path.setText(str(self.app.fastflix.config.gifski))
+ gifski_path_button = QtWidgets.QPushButton(icon=self.style().standardIcon(QtWidgets.QStyle.SP_DirIcon))
+ gifski_path_button.setFixedWidth(30)
+ gifski_path_button.clicked.connect(lambda: self.select_gifski())
+ layout.addWidget(gifski_label, row, 0)
+ layout.addWidget(self.gifski_path, row, 1)
+ layout.addWidget(gifski_path_button, row, 2)
+ row += 1
+
+ # 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.gifski is not None, "gifski", t("High quality GIF encoding")),
+ (self.app.fastflix.config.pgs_ocr_available, "Tesseract + pgsrip", t("PGS subtitle OCR")),
+ ]
+
+ for det_row, (detected, name, description) in enumerate(programs):
+ icon = "\u2714" if detected else "\u2718"
+ color = "green" if detected else "red"
+ status_label = QtWidgets.QLabel(f'{icon}')
+ detected_layout.addWidget(status_label, det_row, 0)
+ detected_layout.addWidget(QtWidgets.QLabel(f"{name}"), det_row, 1)
+ detected_layout.addWidget(QtWidgets.QLabel(description), det_row, 2)
+
+ 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.addLayout(button_layout, 25, 0, 1, 3)
+ detected_group.setLayout(detected_layout)
+ layout.addWidget(detected_group, row, 0, 1, 3)
+ row += 1
- self.setLayout(layout)
+ # Spacer
+ layout.setRowStretch(row, 1)
+
+ tab.setLayout(layout)
+ return tab
def save(self):
new_ffmpeg = Path(self.ffmpeg_path.text())
@@ -360,6 +479,11 @@ def save(self):
restart_needed = True
self.app.fastflix.config.hdr10plus_parser = new_hdr10_parser
+ new_gifski = Path(self.gifski_path.text()) if self.gifski_path.text().strip() else None
+ if str(self.app.fastflix.config.gifski) != str(new_gifski):
+ restart_needed = True
+ self.app.fastflix.config.gifski = new_gifski
+
new_output_path = None
if self.output_path_line_edit.text().strip() and not self.default_output_dir.isChecked():
new_output_path = Path(self.output_path_line_edit.text())
@@ -379,6 +503,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()
@@ -431,6 +556,15 @@ def select_hdr10_parser(self):
return
self.hdr10_parser_path.setText(str(Path(filename[0]).absolute()))
+ def select_gifski(self):
+ dirname = Path(self.gifski_path.text()).parent
+ if not dirname.exists():
+ dirname = Path()
+ filename = QtWidgets.QFileDialog.getOpenFileName(self, caption="gifski location", dir=str(dirname))
+ if not filename or not filename[0]:
+ return
+ self.gifski_path.setText(str(Path(filename[0]).absolute()))
+
def select_output_directory(self):
dirname = Path(self.output_path_line_edit.text()).parent
if not dirname.exists():
diff --git a/fastflix/widgets/video_options.py b/fastflix/widgets/video_options.py
index 691981a0..d35d1555 100644
--- a/fastflix/widgets/video_options.py
+++ b/fastflix/widgets/video_options.py
@@ -4,11 +4,13 @@
import logging
from typing import TYPE_CHECKING
-from PySide6 import QtGui, QtWidgets, QtCore
+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
@@ -61,12 +63,18 @@ 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;}"
+ "QTabBar{ font-size: 13px; } "
+ "QTabBar::tab{ border-top: 2px solid transparent; } "
+ f"QTabBar::tab:selected{{ border-top: 2px solid {ONYX_COLORS['primary']}; }} "
+ "QLineEdit{ color: white; } "
+ "QTextEdit{ color: white; } "
+ "QPlainTextEdit{ color: white; } "
+ f"QComboBox{{ min-height: 1.1em; {get_onyx_combobox_style()} }}"
+ "QComboBox:hover{ background-color: #6a8a96; } "
+ f"QComboBox QAbstractItemView{{ background-color: {ONYX_COLORS['dark_bg']}; border: 2px solid {ONYX_COLORS['input_bg']}; }} "
)
- 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")
)
@@ -85,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)))
@@ -160,6 +185,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:
@@ -228,7 +255,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/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):
diff --git a/fastflix/widgets/windows/large_preview.py b/fastflix/widgets/windows/large_preview.py
index c24d3a62..14c80cce 100644
--- a/fastflix/widgets/windows/large_preview.py
+++ b/fastflix/widgets/windows/large_preview.py
@@ -66,10 +66,14 @@ def generate_image(self):
and self.main.app.fastflix.current_video.color_space.startswith("bt2020")
):
settings["remove_hdr"] = True
+ if not settings.get("color_transfer"):
+ settings["color_transfer"] = self.main.app.fastflix.current_video.color_transfer
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 611cc3d7..8f531870 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:
@@ -394,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/pyproject.toml b/pyproject.toml
index add07dce..5485a730 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",
@@ -18,13 +18,20 @@ 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",
"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]
@@ -91,8 +98,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/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/conftest.py b/tests/conftest.py
index 4f98434a..5658a3b0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -50,6 +50,8 @@ def create_fastflix_instance(
"color_transfer": "smpte2084",
"color_primaries": "bt2020",
"chroma_location": "left",
+ "width": 3840,
+ "height": 2160,
}
)
]
@@ -257,6 +259,8 @@ def fastflix_instance(sample_audio_tracks, sample_attachment_tracks, sample_subt
"color_transfer": "smpte2084",
"color_primaries": "bt2020",
"chroma_location": "left",
+ "width": 3840,
+ "height": 2160,
}
)
],
diff --git a/tests/encoders/test_aom_av1_command_builder.py b/tests/encoders/test_aom_av1_command_builder.py
new file mode 100644
index 00000000..feb23700
--- /dev/null
+++ b/tests/encoders/test_aom_av1_command_builder.py
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from fastflix.encoders.av1_aom.command_builder import build
+from fastflix.models.encode import AOMAV1Settings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def _build_with_settings(hdr10=False, **kwargs):
+ """Helper to build AOM AV1 commands with custom settings."""
+ defaults = dict(
+ crf=26,
+ cpu_used="4",
+ usage="good",
+ row_mt="enabled",
+ tile_rows="0",
+ tile_columns="0",
+ bitrate=None,
+ )
+ defaults.update(kwargs)
+ fastflix = create_fastflix_instance(
+ encoder_settings=AOMAV1Settings(**defaults),
+ video_settings=VideoSettings(remove_hdr=False, maxrate=None, bufsize=None),
+ hdr10_metadata=hdr10,
+ )
+ with mock.patch("fastflix.encoders.av1_aom.command_builder.generate_all") as mock_gen:
+ mock_gen.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"])
+ with mock.patch("fastflix.encoders.av1_aom.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+ result = build(fastflix)
+ return result
+
+
+def test_aom_av1_basic_crf():
+ """Test basic CRF encoding with default settings."""
+ result = _build_with_settings()
+ assert len(result) == 1
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "-crf" in cmd
+ assert "26" in cmd
+ assert "-cpu-used" in cmd
+ assert "4" in cmd
+ assert "-usage" in cmd
+ assert "good" in cmd
+ assert "-row-mt" in cmd
+ assert "1" in cmd
+ # Default tune is ssim
+ assert "-tune" in cmd
+ tune_idx = cmd.index("-tune")
+ assert cmd[tune_idx + 1] == "ssim"
+ # -strict experimental should not be present
+ assert "-strict" not in cmd
+
+
+def test_aom_av1_bitrate_two_pass():
+ """Test bitrate mode produces two-pass commands."""
+ result = _build_with_settings(crf=None, bitrate="3000k")
+ assert len(result) == 2
+ cmd1 = result[0].command
+ cmd2 = result[1].command
+ assert "-b:v" in cmd1
+ assert "3000k" in cmd1
+ assert "-pass" in cmd1
+ assert "1" in cmd1
+ assert "-b:v" in cmd2
+ assert "3000k" in cmd2
+ assert "-pass" in cmd2
+ assert "2" in cmd2
+
+
+def test_aom_av1_tune_psnr():
+ """Test that tune parameter is included when not default."""
+ result = _build_with_settings(tune="psnr")
+ cmd = result[0].command
+ assert "-tune" in cmd
+ tune_idx = cmd.index("-tune")
+ assert cmd[tune_idx + 1] == "psnr"
+
+
+def test_aom_av1_tune_ssim():
+ """Test tune ssim parameter."""
+ result = _build_with_settings(tune="ssim")
+ cmd = result[0].command
+ assert "-tune" in cmd
+ tune_idx = cmd.index("-tune")
+ assert cmd[tune_idx + 1] == "ssim"
+
+
+def test_aom_av1_tune_default_not_included():
+ """Test that default tune is not added to command."""
+ result = _build_with_settings(tune="default")
+ cmd = result[0].command
+ assert "-tune" not in cmd
+
+
+def test_aom_av1_denoise():
+ """Test that denoise parameter is included when > 0."""
+ result = _build_with_settings(denoise_noise_level=10)
+ cmd = result[0].command
+ assert "-denoise-noise-level" in cmd
+ idx = cmd.index("-denoise-noise-level")
+ assert cmd[idx + 1] == "10"
+
+
+def test_aom_av1_denoise_zero_not_included():
+ """Test that denoise is not added when 0."""
+ result = _build_with_settings(denoise_noise_level=0)
+ cmd = result[0].command
+ assert "-denoise-noise-level" not in cmd
+
+
+def test_aom_av1_aq_mode():
+ """Test that AQ mode is included when not default."""
+ result = _build_with_settings(aq_mode="2")
+ cmd = result[0].command
+ assert "-aq-mode" in cmd
+ idx = cmd.index("-aq-mode")
+ assert cmd[idx + 1] == "2"
+
+
+def test_aom_av1_aq_mode_default_not_included():
+ """Test that default AQ mode is not added to command."""
+ result = _build_with_settings(aq_mode="default")
+ cmd = result[0].command
+ assert "-aq-mode" not in cmd
+
+
+def test_aom_av1_usage_allintra():
+ """Test allintra usage mode."""
+ result = _build_with_settings(usage="allintra")
+ cmd = result[0].command
+ assert "-usage" in cmd
+ idx = cmd.index("-usage")
+ assert cmd[idx + 1] == "allintra"
+
+
+def test_aom_av1_aom_params():
+ """Test pass-through aom params."""
+ result = _build_with_settings(aom_params=["enable-cdef=0", "enable-restoration=0"])
+ cmd = result[0].command
+ assert "-aom-params" in cmd
+ idx = cmd.index("-aom-params")
+ assert "enable-cdef=0" in cmd[idx + 1]
+ assert "enable-restoration=0" in cmd[idx + 1]
+
+
+def test_aom_av1_no_aom_params_when_empty():
+ """Test that -aom-params is not added when list is empty."""
+ result = _build_with_settings(aom_params=[])
+ cmd = result[0].command
+ assert "-aom-params" not in cmd
+
+
+def test_aom_av1_row_mt_disabled():
+ """Test row-mt is not added when disabled."""
+ result = _build_with_settings(row_mt="disabled")
+ cmd = result[0].command
+ assert "-row-mt" not in cmd
+
+
+def test_aom_av1_all_elements_are_strings():
+ """Test that all command elements are strings."""
+ result = _build_with_settings()
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
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..ec0e074b 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,207 @@ 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}"
+
+
+def test_avc_x264_aq_mode():
+ """Test the build function with aq-mode setting."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=x264Settings(
+ crf=23,
+ preset="medium",
+ profile="default",
+ tune=None,
+ pix_fmt="yuv420p",
+ bitrate=None,
+ aq_mode="autovariance",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ 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"], [])
+ with mock.patch(
+ "fastflix.encoders.avc_x264.command_builder.generate_color_details"
+ ) as mock_generate_color_details:
+ mock_generate_color_details.return_value = []
+
+ result = build(fastflix)
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-aq-mode" in cmd
+ assert "2" in cmd
+
+
+def test_avc_x264_psy_rd():
+ """Test the build function with psy-rd setting."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=x264Settings(
+ crf=23,
+ preset="medium",
+ profile="default",
+ tune=None,
+ pix_fmt="yuv420p",
+ bitrate=None,
+ psy_rd="1.0:0.15",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ 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"], [])
+ with mock.patch(
+ "fastflix.encoders.avc_x264.command_builder.generate_color_details"
+ ) as mock_generate_color_details:
+ mock_generate_color_details.return_value = []
+
+ result = build(fastflix)
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-psy-rd" in cmd
+ assert "1.0:0.15" in cmd
+
+
+def test_avc_x264_level():
+ """Test the build function with level setting."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=x264Settings(
+ crf=23,
+ preset="medium",
+ profile="default",
+ tune=None,
+ pix_fmt="yuv420p",
+ bitrate=None,
+ level="4.1",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ 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"], [])
+ with mock.patch(
+ "fastflix.encoders.avc_x264.command_builder.generate_color_details"
+ ) as mock_generate_color_details:
+ mock_generate_color_details.return_value = []
+
+ result = build(fastflix)
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-level" in cmd
+ assert "4.1" in cmd
+
+
+def test_avc_x264_x264_params():
+ """Test the build function with custom x264 parameters."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=x264Settings(
+ crf=23,
+ preset="medium",
+ profile="default",
+ tune=None,
+ pix_fmt="yuv420p",
+ bitrate=None,
+ x264_params=["rc-lookahead=40", "ref=6"],
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ 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"], [])
+ with mock.patch(
+ "fastflix.encoders.avc_x264.command_builder.generate_color_details"
+ ) as mock_generate_color_details:
+ mock_generate_color_details.return_value = []
+
+ result = build(fastflix)
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-x264-params" in cmd
+ params_idx = cmd.index("-x264-params")
+ params_str = cmd[params_idx + 1]
+ assert "rc-lookahead=40" in params_str
+ assert "ref=6" in params_str
+
+
+def test_avc_x264_defaults_no_extra():
+ """Test that defaults don't add aq-mode/psy-rd/level/x264-params."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=x264Settings(
+ crf=23,
+ preset="medium",
+ profile="default",
+ tune=None,
+ pix_fmt="yuv420p",
+ bitrate=None,
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ 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"], [])
+ with mock.patch(
+ "fastflix.encoders.avc_x264.command_builder.generate_color_details"
+ ) as mock_generate_color_details:
+ mock_generate_color_details.return_value = []
+
+ result = build(fastflix)
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-aq-mode" not in cmd
+ assert "-psy-rd" not in cmd
+ assert "-level" not in cmd
+ assert "-x264-params" not in cmd
diff --git a/tests/encoders/test_encc_helpers.py b/tests/encoders/test_encc_helpers.py
index 68f72c0b..6cef1aab 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,40 @@ 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_filters_external_tracks(sample_subtitle_tracks):
+ """Test that build_subtitle filters out external subtitle tracks for rigaya encoders."""
+ from fastflix.models.encode import SubtitleTrack
+
+ # Add an external track
+ external_track = SubtitleTrack(
+ index=0,
+ outdex=3,
+ language="fre",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="[EXT] french.srt",
+ external=True,
+ file_path="/path/to/french.srt",
+ file_index=1,
+ )
+ tracks_with_external = sample_subtitle_tracks + [external_track]
+
+ subtitle_streams = [Box({"index": 0}), Box({"index": 1}), Box({"index": 2})]
+
+ result = build_subtitle(tracks_with_external, subtitle_streams, 1080)
+
+ # External track should not appear in the output
+ # Only embedded tracks should be processed
+ assert "french" not in str(result)
+ # Embedded tracks should still be present
+ assert "--sub-copy" in result or "--vpp-subburn" in result
def test_build_subtitle_with_4k_scaling(sample_subtitle_tracks):
@@ -397,4 +427,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_gifski_command_builder.py b/tests/encoders/test_gifski_command_builder.py
new file mode 100644
index 00000000..4b7c7453
--- /dev/null
+++ b/tests/encoders/test_gifski_command_builder.py
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+from pathlib import Path
+
+from fastflix.encoders.gifski.command_builder import build
+from fastflix.models.encode import GifskiSettings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def test_gifski_basic_build():
+ """Test basic gifski command generation with pipe approach."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=GifskiSettings(
+ fps="15",
+ quality="90",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ output_path=Path("output.gif"),
+ ),
+ )
+ fastflix.config.gifski = Path("gifski")
+
+ result = build(fastflix)
+
+ assert isinstance(result, list)
+ assert len(result) == 1
+
+ cmd = result[0]
+ assert cmd.shell is True
+ assert cmd.exe == "gifski"
+ assert isinstance(cmd.command, str)
+ assert "yuv4mpegpipe" in cmd.command
+ assert "--fps" in cmd.command
+ assert "--quality" in cmd.command
+ assert "|" in cmd.command
+ assert "output.gif" in cmd.command
+
+
+def test_gifski_with_lossy_quality():
+ """Test gifski with lossy quality setting."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=GifskiSettings(
+ fps="10",
+ quality="80",
+ lossy_quality="60",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ output_path=Path("output.gif"),
+ ),
+ )
+ fastflix.config.gifski = Path("gifski")
+
+ result = build(fastflix)
+ cmd_str = result[0].command
+ assert "--lossy-quality" in cmd_str
+
+
+def test_gifski_with_motion_quality():
+ """Test gifski with motion quality setting."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=GifskiSettings(
+ fps="15",
+ quality="90",
+ motion_quality="70",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ output_path=Path("output.gif"),
+ ),
+ )
+ fastflix.config.gifski = Path("gifski")
+
+ result = build(fastflix)
+ cmd_str = result[0].command
+ assert "--motion-quality" in cmd_str
+
+
+def test_gifski_fast_mode():
+ """Test gifski with fast mode enabled."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=GifskiSettings(
+ fps="15",
+ quality="90",
+ fast=True,
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ output_path=Path("output.gif"),
+ ),
+ )
+ fastflix.config.gifski = Path("gifski")
+
+ result = build(fastflix)
+ cmd_str = result[0].command
+ assert "--fast" in cmd_str
+
+
+def test_gifski_auto_qualities_excluded():
+ """Test that 'auto' quality values are not passed as flags."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=GifskiSettings(
+ fps="15",
+ quality="90",
+ lossy_quality="auto",
+ motion_quality="auto",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ output_path=Path("output.gif"),
+ ),
+ )
+ fastflix.config.gifski = Path("gifski")
+
+ result = build(fastflix)
+ cmd_str = result[0].command
+ assert "--lossy-quality" not in cmd_str
+ assert "--motion-quality" not in cmd_str
+
+
+def test_gifski_with_start_end_time():
+ """Test gifski with start and end time."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=GifskiSettings(
+ fps="15",
+ quality="90",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ output_path=Path("output.gif"),
+ start_time=5.0,
+ end_time=15.0,
+ ),
+ )
+ fastflix.config.gifski = Path("gifski")
+
+ result = build(fastflix)
+ cmd_str = result[0].command
+ assert "-ss" in cmd_str
+ assert "-to" in cmd_str
diff --git a/tests/encoders/test_helpers.py b/tests/encoders/test_helpers.py
index 44e62d9c..c0021e86 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,131 @@ 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_ffmpeg_start_with_list_start_extra(fastflix_instance):
+ """Test generate_ffmpeg_start with start_extra as a list (VAAPI-style).
+
+ VAAPI encoders pass start_extra as a list of hardware init options.
+ Previously this crashed because shlex.split() was called on the list.
+ """
+ start_extra_list = [
+ "-init_hw_device",
+ "vaapi=hwdev:/dev/dri/renderD128",
+ "-hwaccel",
+ "vaapi",
+ "-hwaccel_device",
+ "hwdev",
+ "-hwaccel_output_format",
+ "vaapi",
+ ]
+ result = generate_ffmpeg_start(
+ source=Path("input.mkv"),
+ ffmpeg=Path("ffmpeg"),
+ encoder="hevc_vaapi",
+ selected_track=0,
+ ffmpeg_version="n5.0",
+ pix_fmt="vaapi",
+ start_extra=start_extra_list,
+ )
+
+ assert isinstance(result, list)
+ # All start_extra elements should appear in the command
+ assert "-init_hw_device" in result
+ assert "vaapi=hwdev:/dev/dri/renderD128" in result
+ assert "-hwaccel" in result
+ assert "vaapi" in result
+ assert "-hwaccel_device" in result
+ assert "hwdev" in result
+ assert "-hwaccel_output_format" in result
+ # start_extra should come before -y
+ init_idx = result.index("-init_hw_device")
+ y_idx = result.index("-y")
+ assert init_idx < y_idx
+
+
+def test_generate_ffmpeg_start_with_empty_list_start_extra(fastflix_instance):
+ """Test generate_ffmpeg_start with start_extra as an empty list."""
+ result = generate_ffmpeg_start(
+ source=Path("input.mkv"),
+ ffmpeg=Path("ffmpeg"),
+ encoder="libx265",
+ selected_track=0,
+ ffmpeg_version="n5.0",
+ pix_fmt="yuv420p10le",
+ start_extra=[],
+ )
+
+ assert isinstance(result, list)
+ assert result[0] == "ffmpeg"
+ assert "-y" in result
+
+
+def test_generate_ffmpeg_start_numeric_times_are_strings(fastflix_instance):
+ """Test that numeric start_time and end_time values are converted to strings."""
+ result = generate_ffmpeg_start(
+ source=Path("input.mkv"),
+ ffmpeg=Path("ffmpeg"),
+ encoder="libx265",
+ selected_track=0,
+ ffmpeg_version="n5.0",
+ pix_fmt="yuv420p10le",
+ start_time=10.5,
+ end_time=120.0,
+ )
+
+ assert isinstance(result, list)
+ for i, element in enumerate(result):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+ assert "-ss" in result
+ assert "10.5" in result
+ assert "-to" in result
+ assert "120.0" 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 +211,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 +239,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 +251,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 +267,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 +283,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 +305,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 +331,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,22 +346,286 @@ 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)
+def test_generate_all_with_list_start_extra(fastflix_instance):
+ """Test generate_all passes list start_extra through to generate_ffmpeg_start.
+
+ VAAPI encoders pass start_extra as a list. This test verifies the parameter
+ is forwarded correctly without being mangled by shlex.split().
+ """
+ with (
+ mock.patch("fastflix.encoders.common.helpers.build_audio") as mock_build_audio,
+ mock.patch("fastflix.encoders.common.helpers.build_subtitle") as mock_build_subtitle,
+ mock.patch("fastflix.encoders.common.helpers.build_attachments") as mock_build_attachments,
+ mock.patch("fastflix.encoders.common.helpers.generate_filters") as mock_generate_filters,
+ 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,
+ ):
+ mock_build_audio.return_value = []
+ mock_build_subtitle.return_value = ([], None, None)
+ mock_build_attachments.return_value = []
+ mock_generate_filters.return_value = []
+ mock_generate_ending.return_value = (["output.mkv"], [])
+ mock_generate_ffmpeg_start.return_value = ["ffmpeg", "-y", "-i", "input.mkv"]
+
+ fastflix_instance.current_video.video_settings.video_encoder_settings = x265Settings()
+
+ vaapi_start_extra = [
+ "-init_hw_device",
+ "vaapi=hwdev:/dev/dri/renderD128",
+ "-hwaccel",
+ "vaapi",
+ ]
+
+ generate_all(fastflix_instance, "hevc_vaapi", start_extra=vaapi_start_extra)
+
+ # Verify start_extra was passed as-is (list, not string)
+ call_kwargs = mock_generate_ffmpeg_start.call_args
+ assert call_kwargs.kwargs["start_extra"] == vaapi_start_extra
+
+
+def test_generate_ffmpeg_start_with_extra_inputs(fastflix_instance):
+ """Test generate_ffmpeg_start with extra -i inputs for external subtitles."""
+ result = generate_ffmpeg_start(
+ source=Path("input.mkv"),
+ ffmpeg=Path("ffmpeg"),
+ encoder="libx265",
+ selected_track=0,
+ ffmpeg_version="n5.0",
+ pix_fmt="yuv420p10le",
+ extra_inputs=["-i", "/path/to/subs.srt", "-i", "/path/to/subs2.ass"],
+ )
+
+ assert isinstance(result, list)
+ # Extra inputs should appear after the primary -i source
+ i_indices = [i for i, x in enumerate(result) if x == "-i"]
+ assert len(i_indices) == 3 # primary + 2 external
+ # Primary source comes first
+ assert result[i_indices[0] + 1] == "input.mkv"
+ # External subs come after
+ assert result[i_indices[1] + 1] == "/path/to/subs.srt"
+ assert result[i_indices[2] + 1] == "/path/to/subs2.ass"
+
+
+def test_generate_ffmpeg_start_no_extra_inputs(fastflix_instance):
+ """Test generate_ffmpeg_start without extra_inputs (default behavior)."""
+ result = generate_ffmpeg_start(
+ source=Path("input.mkv"),
+ ffmpeg=Path("ffmpeg"),
+ encoder="libx265",
+ selected_track=0,
+ ffmpeg_version="n5.0",
+ pix_fmt="yuv420p10le",
+ )
+
+ # Only one -i for the primary source
+ i_indices = [i for i, x in enumerate(result) if x == "-i"]
+ assert len(i_indices) == 1
+
+
+def test_generate_filters_with_external_burn_in_picture():
+ """Test generate_filters with burn_in_file_index for picture-based external subtitle."""
+ result = generate_filters(
+ selected_track=0,
+ source=Path("input.mkv"),
+ burn_in_subtitle_track=0,
+ burn_in_subtitle_type="picture",
+ burn_in_file_index=1,
+ )
+
+ assert isinstance(result, list)
+ assert result[0] == "-filter_complex"
+ # Should reference file index 1 for the subtitle overlay
+ assert "[1:0]" in result[1]
+ assert "[0:0]" in result[1]
+ assert "overlay" in result[1]
+
+
+def test_generate_filters_burn_in_file_index_default():
+ """Test generate_filters defaults burn_in_file_index=0 for embedded tracks."""
+ result = generate_filters(
+ selected_track=0,
+ source=Path("input.mkv"),
+ burn_in_subtitle_track=2,
+ burn_in_subtitle_type="picture",
+ )
+
+ assert isinstance(result, list)
+ assert result[0] == "-filter_complex"
+ # Should reference file index 0 (default)
+ assert "[0:0][0:2]overlay" in result[1]
+
+
+def test_generate_all_with_external_subtitles(fastflix_instance):
+ """Test generate_all collects external subtitle file paths and builds extra -i inputs."""
+ from fastflix.models.encode import SubtitleTrack
+
+ # Add an external subtitle track
+ fastflix_instance.current_video.subtitle_tracks.append(
+ SubtitleTrack(
+ index=0,
+ outdex=4,
+ language="fre",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="[EXT] french.srt",
+ external=True,
+ file_path="/path/to/french.srt",
+ )
+ )
+
+ with (
+ mock.patch("fastflix.encoders.common.helpers.build_audio") as mock_build_audio,
+ mock.patch("fastflix.encoders.common.helpers.build_subtitle") as mock_build_subtitle,
+ mock.patch("fastflix.encoders.common.helpers.build_attachments") as mock_build_attachments,
+ mock.patch("fastflix.encoders.common.helpers.generate_filters") as mock_generate_filters,
+ 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,
+ ):
+ mock_build_audio.return_value = []
+ mock_build_subtitle.return_value = ([], None, None)
+ mock_build_attachments.return_value = []
+ mock_generate_filters.return_value = []
+ mock_generate_ending.return_value = (["output.mkv"], [])
+ mock_generate_ffmpeg_start.return_value = ["ffmpeg", "-y", "-i", "input.mkv"]
+
+ fastflix_instance.current_video.video_settings.video_encoder_settings = x265Settings()
+
+ generate_all(fastflix_instance, "libx265")
+
+ # Verify extra_inputs was passed with the external subtitle file
+ call_kwargs = mock_generate_ffmpeg_start.call_args
+ assert call_kwargs.kwargs["extra_inputs"] == ["-i", "/path/to/french.srt"]
+
+ # Verify external track got file_index=1 assigned
+ ext_track = fastflix_instance.current_video.subtitle_tracks[-1]
+ assert ext_track.file_index == 1
+
+
+def test_generate_all_external_subs_fast_seek_includes_ss(fastflix_instance):
+ """Test that external subtitle inputs get -ss/-to when fast seek is used with a start time.
+
+ Without this, external subtitle timing is wrong because the primary video is seeked
+ but the external subtitle input starts from the beginning of the file.
+ """
+ from fastflix.models.encode import SubtitleTrack
+
+ # Set up fast seek with start/end times
+ fastflix_instance.current_video.video_settings.fast_seek = True
+ fastflix_instance.current_video.video_settings.start_time = 300
+ fastflix_instance.current_video.video_settings.end_time = 600
+
+ # Add an external subtitle track
+ fastflix_instance.current_video.subtitle_tracks.append(
+ SubtitleTrack(
+ index=0,
+ outdex=4,
+ language="fre",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="[EXT] french.srt",
+ external=True,
+ file_path="/path/to/french.srt",
+ )
+ )
+
+ with (
+ mock.patch("fastflix.encoders.common.helpers.build_audio") as mock_build_audio,
+ mock.patch("fastflix.encoders.common.helpers.build_subtitle") as mock_build_subtitle,
+ mock.patch("fastflix.encoders.common.helpers.build_attachments") as mock_build_attachments,
+ mock.patch("fastflix.encoders.common.helpers.generate_filters") as mock_generate_filters,
+ 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,
+ ):
+ mock_build_audio.return_value = []
+ mock_build_subtitle.return_value = ([], None, None)
+ mock_build_attachments.return_value = []
+ mock_generate_filters.return_value = []
+ mock_generate_ending.return_value = (["output.mkv"], [])
+ mock_generate_ffmpeg_start.return_value = ["ffmpeg", "-y", "-ss", "300", "-to", "600", "-i", "input.mkv"]
+
+ fastflix_instance.current_video.video_settings.video_encoder_settings = x265Settings()
+
+ generate_all(fastflix_instance, "libx265")
+
+ # Verify extra_inputs includes -ss and -to before the external -i
+ call_kwargs = mock_generate_ffmpeg_start.call_args
+ extra_inputs = call_kwargs.kwargs["extra_inputs"]
+ assert extra_inputs == ["-ss", "300", "-to", "600", "-i", "/path/to/french.srt"]
+
+
+def test_generate_all_external_subs_exact_seek_no_ss_in_extra(fastflix_instance):
+ """Test that external subtitle inputs do NOT get -ss/-to when exact seek (non-fast) is used.
+
+ With exact seek, -ss is placed after all inputs as an output option, applying globally.
+ """
+ from fastflix.models.encode import SubtitleTrack
+
+ # Set up exact seek with start/end times
+ fastflix_instance.current_video.video_settings.fast_seek = False
+ fastflix_instance.current_video.video_settings.start_time = 300
+ fastflix_instance.current_video.video_settings.end_time = 600
+
+ # Add an external subtitle track
+ fastflix_instance.current_video.subtitle_tracks.append(
+ SubtitleTrack(
+ index=0,
+ outdex=4,
+ language="fre",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="[EXT] french.srt",
+ external=True,
+ file_path="/path/to/french.srt",
+ )
+ )
+
+ with (
+ mock.patch("fastflix.encoders.common.helpers.build_audio") as mock_build_audio,
+ mock.patch("fastflix.encoders.common.helpers.build_subtitle") as mock_build_subtitle,
+ mock.patch("fastflix.encoders.common.helpers.build_attachments") as mock_build_attachments,
+ mock.patch("fastflix.encoders.common.helpers.generate_filters") as mock_generate_filters,
+ 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,
+ ):
+ mock_build_audio.return_value = []
+ mock_build_subtitle.return_value = ([], None, None)
+ mock_build_attachments.return_value = []
+ mock_generate_filters.return_value = []
+ mock_generate_ending.return_value = (["output.mkv"], [])
+ mock_generate_ffmpeg_start.return_value = ["ffmpeg", "-y", "-i", "input.mkv"]
+
+ fastflix_instance.current_video.video_settings.video_encoder_settings = x265Settings()
+
+ generate_all(fastflix_instance, "libx265")
+
+ # With exact seek, extra_inputs should only have -i (no -ss/-to)
+ call_kwargs = mock_generate_ffmpeg_start.call_args
+ extra_inputs = call_kwargs.kwargs["extra_inputs"]
+ assert extra_inputs == ["-i", "/path/to/french.srt"]
+
+
def test_generate_color_details(fastflix_instance):
"""Test the generate_color_details function."""
# 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 +634,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_nvencc_hevc_command_builder.py b/tests/encoders/test_nvencc_hevc_command_builder.py
new file mode 100644
index 00000000..5750e08a
--- /dev/null
+++ b/tests/encoders/test_nvencc_hevc_command_builder.py
@@ -0,0 +1,204 @@
+# -*- coding: utf-8 -*-
+from pathlib import Path
+from unittest import mock
+
+from box import Box
+
+from fastflix.encoders.nvencc_hevc.command_builder import build
+from fastflix.models.encode import NVEncCSettings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def _make_fastflix(encoder_settings, video_settings=None, stream_extras=None):
+ """Create a FastFlix instance with NVEncC-compatible video stream data."""
+ fastflix = create_fastflix_instance(encoder_settings=encoder_settings, video_settings=video_settings)
+ # NVEncC command builders need stream fields that the base conftest doesn't set
+ stream_data = {
+ "index": 0,
+ "id": "0x1",
+ "codec_name": "hevc",
+ "codec_type": "video",
+ "pix_fmt": "yuv420p10le",
+ "color_space": "bt2020nc",
+ "color_transfer": "smpte2084",
+ "color_primaries": "bt2020",
+ "chroma_location": "left",
+ "bit_depth": 10,
+ "r_frame_rate": "24000/1001",
+ "avg_frame_rate": "24000/1001",
+ "width": 1920,
+ "height": 1080,
+ }
+ if stream_extras:
+ stream_data.update(stream_extras)
+ fastflix.current_video.streams = Box({"video": [Box(stream_data)], "audio": [], "subtitle": []})
+ fastflix.config.nvencc = Path("NVEncC64")
+ return fastflix
+
+
+def test_nvencc_hevc_basic_cqp():
+ """Test NVEncC HEVC build with CQP mode produces all-string command list."""
+ fastflix = _make_fastflix(
+ encoder_settings=NVEncCSettings(
+ bitrate=None,
+ cqp=22,
+ preset="quality",
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ # Every element must be a string (this was the bug - float values weren't wrapped)
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "--cqp" in cmd
+ assert "22" in cmd
+ assert "--preset" in cmd
+ assert "quality" in cmd
+ assert "-c" in cmd
+ assert "hevc" in cmd
+ assert "NVEncC64" in cmd
+
+
+def test_nvencc_hevc_with_start_end_time():
+ """Test that start_time and end_time (float values) are properly stringified."""
+ fastflix = _make_fastflix(
+ encoder_settings=NVEncCSettings(bitrate=None, cqp=20),
+ video_settings=VideoSettings(
+ start_time=10.5,
+ end_time=120.0,
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "--seek" in cmd
+ assert "10.5" in cmd
+ assert "--seekto" in cmd
+ assert "120.0" in cmd
+
+
+def test_nvencc_hevc_with_source_fps():
+ """Test that source_fps is properly stringified in the command."""
+ fastflix = _make_fastflix(
+ encoder_settings=NVEncCSettings(bitrate=None, cqp=20),
+ video_settings=VideoSettings(
+ source_fps="24",
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "--fps" in cmd
+ assert "24" in cmd
+
+
+def test_nvencc_hevc_with_bitrate():
+ """Test NVEncC HEVC build with VBR bitrate mode."""
+ fastflix = _make_fastflix(
+ encoder_settings=NVEncCSettings(
+ bitrate="6000k",
+ cqp=None,
+ preset="quality",
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "--vbr" in cmd
+ assert "6000" in cmd
+ assert "--cqp" not in cmd
+
+
+def test_nvencc_hevc_with_crop_scale():
+ """Test NVEncC HEVC with crop and scale options."""
+ from fastflix.models.video import Crop
+
+ fastflix = _make_fastflix(
+ encoder_settings=NVEncCSettings(bitrate=None, cqp=20),
+ video_settings=VideoSettings(
+ crop=Crop(left=10, top=20, right=10, bottom=20, width=1900, height=1040),
+ resolution_method="custom",
+ resolution_custom="1280x720",
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ assert "--crop" in cmd
+ assert "10,20,10,20" in cmd
+ assert "--output-res" in cmd
+ assert "1280x720" in cmd
+
+
+def test_nvencc_hevc_all_elements_are_strings():
+ """Comprehensive test: build with many options and verify all elements are strings.
+
+ This is the key regression test for the float-to-string fix.
+ """
+ from fastflix.models.video import Crop
+
+ fastflix = _make_fastflix(
+ encoder_settings=NVEncCSettings(
+ bitrate="5000k",
+ cqp=None,
+ preset="quality",
+ tier="high",
+ level="5.1",
+ lookahead=32,
+ aq="spatial",
+ aq_strength=5,
+ b_frames="3",
+ ref="4",
+ vbr_target="20",
+ ),
+ video_settings=VideoSettings(
+ start_time=5.5,
+ end_time=60.0,
+ source_fps="30",
+ remove_hdr=False,
+ maxrate=8000,
+ bufsize=16000,
+ video_title="Test Title",
+ crop=Crop(left=0, top=0, right=0, bottom=0, width=1920, height=1080),
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.nvencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
diff --git a/tests/encoders/test_rav1e_command_builder.py b/tests/encoders/test_rav1e_command_builder.py
new file mode 100644
index 00000000..e9a89dfb
--- /dev/null
+++ b/tests/encoders/test_rav1e_command_builder.py
@@ -0,0 +1,206 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from fastflix.encoders.rav1e.command_builder import build
+from fastflix.models.encode import rav1eSettings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def _build_with_settings(hdr10=False, **kwargs):
+ """Helper to build rav1e commands with custom settings."""
+ defaults = dict(
+ qp=80,
+ speed="-1",
+ tile_columns="-1",
+ tile_rows="-1",
+ tiles="0",
+ single_pass=False,
+ bitrate=None,
+ )
+ defaults.update(kwargs)
+ fastflix = create_fastflix_instance(
+ encoder_settings=rav1eSettings(**defaults),
+ video_settings=VideoSettings(remove_hdr=False, maxrate=None, bufsize=None),
+ hdr10_metadata=hdr10,
+ )
+ with mock.patch("fastflix.encoders.rav1e.command_builder.generate_all") as mock_gen:
+ mock_gen.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"])
+ with mock.patch("fastflix.encoders.rav1e.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+ result = build(fastflix)
+ return result
+
+
+def test_rav1e_basic_qp():
+ """Test basic QP encoding with default settings."""
+ result = _build_with_settings()
+ assert len(result) == 1
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "-qp" in cmd
+ assert "80" in cmd
+ assert "-speed" in cmd
+ assert "-1" in cmd
+ # Default tune is Psychovisual
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ assert "tune=Psychovisual" in cmd[idx + 1]
+ # -strict experimental should not be present
+ assert "-strict" not in cmd
+
+
+def test_rav1e_bitrate_single_pass():
+ """Test bitrate mode with single pass."""
+ result = _build_with_settings(qp=None, bitrate="3000k", single_pass=True)
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-b:v" in cmd
+ assert "3000k" in cmd
+ assert "-pass" not in cmd
+
+
+def test_rav1e_bitrate_two_pass():
+ """Test bitrate mode with two pass."""
+ result = _build_with_settings(qp=None, bitrate="3000k", single_pass=False)
+ assert len(result) == 2
+ cmd1 = result[0].command
+ cmd2 = result[1].command
+ assert "-b:v" in cmd1
+ assert "3000k" in cmd1
+ assert "-pass" in cmd1
+ assert "1" in cmd1
+ assert "-b:v" in cmd2
+ assert "3000k" in cmd2
+ assert "-pass" in cmd2
+ assert "2" in cmd2
+
+
+def test_rav1e_tune_psychovisual():
+ """Test tune parameter is included when not default."""
+ result = _build_with_settings(tune="Psychovisual")
+ cmd = result[0].command
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ assert "tune=Psychovisual" in cmd[idx + 1]
+
+
+def test_rav1e_tune_psnr():
+ """Test tune Psnr parameter."""
+ result = _build_with_settings(tune="Psnr")
+ cmd = result[0].command
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ assert "tune=Psnr" in cmd[idx + 1]
+
+
+def test_rav1e_tune_default_not_included():
+ """Test that default tune is not added to params."""
+ result = _build_with_settings(tune="default")
+ cmd = result[0].command
+ if "-rav1e-params" in cmd:
+ idx = cmd.index("-rav1e-params")
+ assert "tune=" not in cmd[idx + 1]
+
+
+def test_rav1e_photon_noise():
+ """Test photon noise parameter is included when > 0."""
+ result = _build_with_settings(photon_noise=8)
+ cmd = result[0].command
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ assert "photon_noise=8" in cmd[idx + 1]
+
+
+def test_rav1e_photon_noise_zero_not_included():
+ """Test photon noise is not added when 0."""
+ result = _build_with_settings(photon_noise=0)
+ cmd = result[0].command
+ if "-rav1e-params" in cmd:
+ idx = cmd.index("-rav1e-params")
+ assert "photon_noise=" not in cmd[idx + 1]
+
+
+def test_rav1e_scene_detection_disabled():
+ """Test no_scene_detection is added when scene detection disabled."""
+ result = _build_with_settings(scene_detection=False)
+ cmd = result[0].command
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ assert "no_scene_detection=true" in cmd[idx + 1]
+
+
+def test_rav1e_scene_detection_enabled():
+ """Test no_scene_detection is not added when scene detection enabled."""
+ result = _build_with_settings(scene_detection=True)
+ cmd = result[0].command
+ if "-rav1e-params" in cmd:
+ idx = cmd.index("-rav1e-params")
+ assert "no_scene_detection" not in cmd[idx + 1]
+
+
+def test_rav1e_rav1e_params():
+ """Test pass-through rav1e params."""
+ result = _build_with_settings(rav1e_params=["low_latency=true", "threads=4"])
+ cmd = result[0].command
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ assert "low_latency=true" in cmd[idx + 1]
+ assert "threads=4" in cmd[idx + 1]
+
+
+def test_rav1e_no_rav1e_params_when_nothing_set():
+ """Test -rav1e-params not added when nothing to set."""
+ result = _build_with_settings(tune="default", photon_noise=0, scene_detection=True, rav1e_params=[])
+ cmd = result[0].command
+ assert "-rav1e-params" not in cmd
+
+
+def test_rav1e_hdr10_metadata():
+ """Test HDR10 metadata is passed via rav1e-params."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ params = cmd[idx + 1]
+ assert "mastering_display=" in params
+ assert "content_light=" in params
+
+
+def test_rav1e_hdr10_not_included_when_8bit():
+ """Test HDR10 metadata not added for 8-bit pixel format."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p")
+ cmd = result[0].command
+ if "-rav1e-params" in cmd:
+ idx = cmd.index("-rav1e-params")
+ params = cmd[idx + 1]
+ assert "mastering_display=" not in params
+
+
+def test_rav1e_all_elements_are_strings():
+ """Test that all command elements are strings."""
+ result = _build_with_settings()
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+
+def test_rav1e_combined_params():
+ """Test multiple rav1e-params are combined correctly with colon separator."""
+ result = _build_with_settings(
+ tune="Psychovisual",
+ photon_noise=16,
+ scene_detection=False,
+ rav1e_params=["threads=8"],
+ )
+ cmd = result[0].command
+ assert "-rav1e-params" in cmd
+ idx = cmd.index("-rav1e-params")
+ params = cmd[idx + 1]
+ parts = params.split(":")
+ param_keys = [p.split("=")[0] for p in parts]
+ assert "threads" in param_keys
+ assert "tune" in param_keys
+ assert "photon_noise" in param_keys
+ assert "no_scene_detection" in param_keys
diff --git a/tests/encoders/test_subtitles.py b/tests/encoders/test_subtitles.py
index 5b40601c..a44d980d 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,84 @@ 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
+
+
+def test_build_subtitle_with_external_track():
+ """Test the build_subtitle function with an external subtitle track using file_index."""
+ external_track = SubtitleTrack(
+ index=0,
+ outdex=1,
+ language="eng",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="[EXT] subs.srt",
+ dispositions={},
+ external=True,
+ file_path="/path/to/subs.srt",
+ file_index=1,
+ )
+
+ result, burn_in_track, burn_in_type = build_subtitle([external_track])
+
+ # External track should use its own file_index (1) in the -map
+ assert "-map" in result
+ assert "1:0" in result
+ assert burn_in_track is None
+ assert burn_in_type is None
+
+
+def test_build_subtitle_mixed_embedded_and_external():
+ """Test the build_subtitle function with both embedded and external tracks."""
+ embedded_track = SubtitleTrack(
+ index=2,
+ outdex=1,
+ language="eng",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="English",
+ dispositions={"default": True},
+ file_index=0,
+ )
+ external_track = SubtitleTrack(
+ index=0,
+ outdex=2,
+ language="jpn",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="[EXT] jp.srt",
+ dispositions={},
+ external=True,
+ file_path="/path/to/jp.srt",
+ file_index=1,
+ )
+
+ result, burn_in_track, burn_in_type = build_subtitle([embedded_track, external_track])
+
+ # Embedded track should map from file 0
+ assert "0:2" in result
+ # External track should map from file 1
+ assert "1:0" in result
+ assert burn_in_track is None
+
+
+def test_build_subtitle_external_defaults_no_break():
+ """Test that default file_index=0 preserves existing behavior for embedded tracks."""
+ track = SubtitleTrack(
+ index=3,
+ outdex=1,
+ language="eng",
+ subtitle_type="text",
+ enabled=True,
+ burn_in=False,
+ long_name="English",
+ dispositions={"default": True},
+ )
+
+ # Default file_index is 0, so -map should be 0:3
+ result, _, _ = build_subtitle([track])
+ assert "0:3" in result
diff --git a/tests/encoders/test_svt_av1_avif_command_builder.py b/tests/encoders/test_svt_av1_avif_command_builder.py
new file mode 100644
index 00000000..c3185d4b
--- /dev/null
+++ b/tests/encoders/test_svt_av1_avif_command_builder.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from fastflix.encoders.svt_av1_avif.command_builder import build
+from fastflix.models.encode import SVTAVIFSettings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def _build_with_settings(**kwargs):
+ """Helper to build AVIF commands with custom settings."""
+ defaults = dict(
+ qp=24,
+ qp_mode="qp",
+ speed="7",
+ single_pass=True,
+ bitrate=None,
+ )
+ defaults.update(kwargs)
+ fastflix = create_fastflix_instance(
+ encoder_settings=SVTAVIFSettings(**defaults),
+ video_settings=VideoSettings(remove_hdr=False, maxrate=None, bufsize=None),
+ )
+ with mock.patch("fastflix.encoders.svt_av1_avif.command_builder.generate_all") as mock_gen:
+ mock_gen.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.avif"], ["-r", "24"])
+ with mock.patch("fastflix.encoders.svt_av1_avif.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+ result = build(fastflix)
+ return result
+
+
+def test_svt_av1_avif_basic():
+ """Test basic AVIF encoding with default settings."""
+ result = _build_with_settings()
+ assert len(result) == 1
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "-f" in cmd
+ assert "avif" in cmd
+ assert "-qp" in cmd
+ assert "24" in cmd
+ assert "-strict" in cmd
+ assert "experimental" in cmd
+
+
+def test_svt_av1_avif_with_tune():
+ """Test that tune parameter is included when non-default."""
+ result = _build_with_settings(tune="2")
+ cmd = result[0].command
+ params_idx = cmd.index("-svtav1-params")
+ params_value = cmd[params_idx + 1]
+ assert "tune=2" in params_value
+
+
+def test_svt_av1_avif_with_sharpness():
+ """Test that sharpness parameter is included when non-default."""
+ result = _build_with_settings(sharpness="-3")
+ cmd = result[0].command
+ params_idx = cmd.index("-svtav1-params")
+ params_value = cmd[params_idx + 1]
+ assert "sharpness=-3" in params_value
+
+
+def test_svt_av1_avif_defaults_no_extra_params():
+ """Test that default settings don't add tune or sharpness to svtav1-params."""
+ result = _build_with_settings()
+ cmd = result[0].command
+ # With defaults, there should be no svtav1-params at all (no custom params set)
+ if "-svtav1-params" in cmd:
+ params_idx = cmd.index("-svtav1-params")
+ params_value = cmd[params_idx + 1]
+ assert "tune=" not in params_value
+ assert "sharpness=" not in params_value
+
+
+def test_svt_av1_avif_bitrate():
+ """Test AVIF encoding with bitrate mode."""
+ result = _build_with_settings(qp=None, bitrate="2000k")
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-b:v" in cmd
+ assert "2000k" in cmd
+ assert "-f" in cmd
+ assert "avif" in cmd
diff --git a/tests/encoders/test_svt_av1_command_builder.py b/tests/encoders/test_svt_av1_command_builder.py
index ae47e417..adf1f4a8 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
@@ -10,6 +9,34 @@
from tests.conftest import create_fastflix_instance
+def _build_with_settings(**kwargs):
+ """Helper to build SVT-AV1 commands with custom settings."""
+ defaults = dict(
+ qp=24,
+ qp_mode="crf",
+ speed="7",
+ tile_columns="0",
+ tile_rows="0",
+ scene_detection=False,
+ single_pass=True,
+ bitrate=None,
+ )
+ defaults.update(kwargs)
+ fastflix = create_fastflix_instance(
+ encoder_settings=SVTAV1Settings(**defaults),
+ video_settings=VideoSettings(remove_hdr=False, maxrate=None, bufsize=None),
+ )
+ with mock.patch("fastflix.encoders.svt_av1.command_builder.generate_all") as mock_gen:
+ mock_gen.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"])
+ with mock.patch("fastflix.encoders.svt_av1.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+ result = build(fastflix)
+ cmd = result[0].command
+ params_idx = cmd.index("-svtav1-params")
+ params_value = cmd[params_idx + 1]
+ return cmd, params_value
+
+
def test_svt_av1_single_pass_qp():
"""Test the build function with single-pass QP settings."""
fastflix = create_fastflix_instance(
@@ -30,23 +57,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 +117,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 +137,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 +179,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 +229,76 @@ 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
+
+
+def test_svt_av1_with_tune():
+ """Test that tune parameter is included when non-default."""
+ _, params = _build_with_settings(tune="0")
+ assert "tune=0" in params
+
+
+def test_svt_av1_with_film_grain():
+ """Test that film-grain parameter is included when set."""
+ _, params = _build_with_settings(film_grain=8)
+ assert "film-grain=8" in params
+ assert "film-grain-denoise" not in params
+
+
+def test_svt_av1_with_film_grain_denoise():
+ """Test that film-grain-denoise is included when both film_grain and denoise are set."""
+ _, params = _build_with_settings(film_grain=8, film_grain_denoise=True)
+ assert "film-grain=8" in params
+ assert "film-grain-denoise=1" in params
+
+
+def test_svt_av1_with_sharpness():
+ """Test that sharpness parameter is included when non-default."""
+ _, params = _build_with_settings(sharpness="3")
+ assert "sharpness=3" in params
+
+
+def test_svt_av1_with_fast_decode():
+ """Test that fast-decode parameter is included when non-default."""
+ _, params = _build_with_settings(fast_decode="2")
+ assert "fast-decode=2" in params
+
+
+def test_svt_av1_defaults_no_extra_params():
+ """Test that default settings don't add tune/film-grain/sharpness/fast-decode."""
+ _, params = _build_with_settings()
+ assert "tune=" not in params
+ assert "film-grain=" not in params
+ assert "sharpness=" not in params
+ assert "fast-decode=" not in params
diff --git a/tests/encoders/test_vaapi_hevc_command_builder.py b/tests/encoders/test_vaapi_hevc_command_builder.py
new file mode 100644
index 00000000..eed3682f
--- /dev/null
+++ b/tests/encoders/test_vaapi_hevc_command_builder.py
@@ -0,0 +1,164 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from fastflix.encoders.vaapi_hevc.command_builder import build
+from fastflix.models.encode import VAAPIHEVCSettings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def test_vaapi_hevc_basic_qp():
+ """Test VAAPI HEVC build with QP mode.
+
+ This is the key test for the start_extra-as-list fix: VAAPI encoders
+ pass start_extra as a list to generate_all, which previously crashed
+ when generate_ffmpeg_start tried to call shlex.split() on it.
+ """
+ fastflix = create_fastflix_instance(
+ encoder_settings=VAAPIHEVCSettings(
+ qp=26,
+ bitrate=None,
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_all") as mock_generate_all:
+ mock_generate_all.return_value = (
+ ["ffmpeg", "-init_hw_device", "vaapi=hwdev:/dev/dri/renderD128", "-y", "-i", "input.mkv"],
+ ["output.mkv"],
+ ["-r", "24"],
+ )
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+
+ result = build(fastflix)
+
+ # Verify generate_all was called with start_extra as a list
+ call_kwargs = mock_generate_all.call_args
+ start_extra = call_kwargs.kwargs.get("start_extra") or call_kwargs[1].get("start_extra")
+ assert isinstance(start_extra, list), f"start_extra should be a list, got {type(start_extra)}"
+ assert "-init_hw_device" in start_extra
+ assert "-hwaccel" in start_extra
+ assert "vaapi" in start_extra
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "-qp" in cmd
+ assert "26" in cmd
+
+
+def test_vaapi_hevc_with_bitrate():
+ """Test VAAPI HEVC build with bitrate mode."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=VAAPIHEVCSettings(
+ qp=None,
+ bitrate="6000k",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_all") as mock_generate_all:
+ mock_generate_all.return_value = (
+ ["ffmpeg", "-y", "-i", "input.mkv"],
+ ["output.mkv"],
+ [],
+ )
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+
+ result = build(fastflix)
+
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "-b:v" in cmd
+ assert "6000k" in cmd
+
+
+def test_vaapi_hevc_start_extra_contains_hw_init():
+ """Verify the start_extra list has the correct VAAPI hardware init options."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=VAAPIHEVCSettings(
+ vaapi_device="/dev/dri/renderD128",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_all") as mock_generate_all:
+ mock_generate_all.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], [])
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+
+ build(fastflix)
+
+ call_kwargs = mock_generate_all.call_args
+ start_extra = call_kwargs.kwargs.get("start_extra") or call_kwargs[1].get("start_extra")
+
+ assert start_extra == [
+ "-init_hw_device",
+ "vaapi=hwdev:/dev/dri/renderD128",
+ "-hwaccel",
+ "vaapi",
+ "-hwaccel_device",
+ "hwdev",
+ "-hwaccel_output_format",
+ "vaapi",
+ ]
+
+
+def test_vaapi_hevc_all_elements_are_strings():
+ """Verify the final command list contains only strings."""
+ fastflix = create_fastflix_instance(
+ encoder_settings=VAAPIHEVCSettings(
+ qp=26,
+ bitrate=None,
+ level="5.1",
+ aud=True,
+ low_power=True,
+ rc_mode="CQP",
+ async_depth="4",
+ b_depth="2",
+ idr_interval="0",
+ ),
+ video_settings=VideoSettings(
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_all") as mock_generate_all:
+ mock_generate_all.return_value = (
+ ["ffmpeg", "-y", "-i", "input.mkv"],
+ ["output.mkv"],
+ [],
+ )
+ with mock.patch("fastflix.encoders.vaapi_hevc.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+
+ result = build(fastflix)
+
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "-rc_mode" in cmd
+ assert "CQP" in cmd
+ assert "-level" in cmd
+ assert "5.1" in cmd
+ assert "-aud" in cmd
+ assert "-low-power" in cmd
diff --git a/tests/encoders/test_vceencc_hevc_command_builder.py b/tests/encoders/test_vceencc_hevc_command_builder.py
new file mode 100644
index 00000000..8b24ae36
--- /dev/null
+++ b/tests/encoders/test_vceencc_hevc_command_builder.py
@@ -0,0 +1,196 @@
+# -*- coding: utf-8 -*-
+from pathlib import Path
+from unittest import mock
+
+from box import Box
+
+from fastflix.encoders.vceencc_hevc.command_builder import build
+from fastflix.models.encode import VCEEncCSettings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def _make_fastflix(encoder_settings, video_settings=None, stream_extras=None):
+ """Create a FastFlix instance with VCEEncC-compatible video stream data."""
+ fastflix = create_fastflix_instance(encoder_settings=encoder_settings, video_settings=video_settings)
+ stream_data = {
+ "index": 0,
+ "id": "0x1",
+ "codec_name": "hevc",
+ "codec_type": "video",
+ "pix_fmt": "yuv420p10le",
+ "color_space": "bt2020nc",
+ "color_transfer": "smpte2084",
+ "color_primaries": "bt2020",
+ "chroma_location": "left",
+ "bit_depth": 10,
+ "r_frame_rate": "24000/1001",
+ "avg_frame_rate": "24000/1001",
+ "width": 1920,
+ "height": 1080,
+ }
+ if stream_extras:
+ stream_data.update(stream_extras)
+ fastflix.current_video.streams = Box({"video": [Box(stream_data)], "audio": [], "subtitle": []})
+ fastflix.config.vceencc = Path("VCEEncC64")
+ return fastflix
+
+
+def test_vceencc_hevc_basic_cqp():
+ """Test VCEEncC HEVC build with CQP mode produces all-string command list."""
+ fastflix = _make_fastflix(
+ encoder_settings=VCEEncCSettings(
+ bitrate=None,
+ cqp=22,
+ preset="slow",
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ assert len(result) == 1
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "--cqp" in cmd
+ assert "22" in cmd
+ assert "--preset" in cmd
+ assert "slow" in cmd
+ assert "-c" in cmd
+ assert "hevc" in cmd
+
+
+def test_vceencc_hevc_with_start_end_time():
+ """Test that start_time and end_time (float values) are properly stringified.
+
+ This is the key regression test: VCEEncC HEVC previously crashed with
+ pydantic ValidationError because float values weren't converted to strings.
+ """
+ fastflix = _make_fastflix(
+ encoder_settings=VCEEncCSettings(bitrate=None, cqp=20),
+ video_settings=VideoSettings(
+ start_time=10.5,
+ end_time=120.0,
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "--seek" in cmd
+ assert "10.5" in cmd
+ assert "--seekto" in cmd
+ assert "120.0" in cmd
+
+
+def test_vceencc_hevc_with_source_fps():
+ """Test that source_fps is properly stringified in the command."""
+ fastflix = _make_fastflix(
+ encoder_settings=VCEEncCSettings(bitrate=None, cqp=20),
+ video_settings=VideoSettings(
+ source_fps="30",
+ remove_hdr=False,
+ maxrate=None,
+ bufsize=None,
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "--fps" in cmd
+ assert "30" in cmd
+
+
+def test_vceencc_hevc_with_bitrate():
+ """Test VCEEncC HEVC build with VBR bitrate mode."""
+ fastflix = _make_fastflix(
+ encoder_settings=VCEEncCSettings(
+ bitrate="6000k",
+ cqp=None,
+ preset="slow",
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "--vbr" in cmd
+ assert "6000" in cmd
+ assert "--cqp" not in cmd
+
+
+def test_vceencc_hevc_cqp_float_coercion():
+ """Test that CQP as float (e.g., from Qt spinbox) is handled properly.
+
+ This was the original bug: the Qt widget returned 1.0 (float) which
+ ended up in the command list without str() conversion.
+ """
+ fastflix = _make_fastflix(
+ encoder_settings=VCEEncCSettings(
+ bitrate=None,
+ cqp=1.0, # Float, as it comes from Qt widget
+ preset="slow",
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+ assert "--cqp" in cmd
+
+
+def test_vceencc_hevc_all_elements_are_strings():
+ """Comprehensive test: build with many options and verify all elements are strings."""
+ fastflix = _make_fastflix(
+ encoder_settings=VCEEncCSettings(
+ bitrate="5000k",
+ cqp=None,
+ preset="slow",
+ tier="high",
+ level="5.1",
+ ref="4",
+ min_q="10",
+ max_q="51",
+ vbaq=True,
+ pre_encode=True,
+ ),
+ video_settings=VideoSettings(
+ start_time=5.5,
+ end_time=60.0,
+ source_fps="24",
+ remove_hdr=False,
+ maxrate=8000,
+ bufsize=16000,
+ video_title="Test Title",
+ ),
+ )
+
+ with mock.patch("fastflix.encoders.vceencc_hevc.command_builder.rigaya_auto_options", return_value=[]):
+ result = build(fastflix)
+
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
diff --git a/tests/encoders/test_vp9_command_builder.py b/tests/encoders/test_vp9_command_builder.py
new file mode 100644
index 00000000..b8479277
--- /dev/null
+++ b/tests/encoders/test_vp9_command_builder.py
@@ -0,0 +1,191 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from fastflix.encoders.vp9.command_builder import build
+from fastflix.models.encode import VP9Settings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def _build_with_settings(**kwargs):
+ """Helper to build VP9 commands with custom settings."""
+ defaults = dict(
+ crf=31,
+ bitrate=None,
+ quality="good",
+ speed="0",
+ row_mt=1,
+ single_pass=False,
+ profile=2,
+ tile_columns="-1",
+ tile_rows="-1",
+ )
+ defaults.update(kwargs)
+ fastflix = create_fastflix_instance(
+ encoder_settings=VP9Settings(**defaults),
+ video_settings=VideoSettings(remove_hdr=False, maxrate=None, bufsize=None),
+ )
+ with mock.patch("fastflix.encoders.vp9.command_builder.generate_all") as mock_gen:
+ mock_gen.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"])
+ with mock.patch("fastflix.encoders.vp9.command_builder.generate_color_details") as mock_color:
+ mock_color.return_value = []
+ result = build(fastflix)
+ return result
+
+
+def test_vp9_basic_crf():
+ """Test basic CRF encoding with default settings."""
+ result = _build_with_settings()
+ assert len(result) == 2 # two-pass by default
+ cmd = result[1].command
+ assert isinstance(cmd, list)
+ assert "-crf:v" in cmd
+ assert "31" in cmd
+ assert "-quality:v" in cmd
+ assert "good" in cmd
+
+
+def test_vp9_single_pass_crf():
+ """Test single pass CRF mode."""
+ result = _build_with_settings(single_pass=True)
+ assert len(result) == 1
+ cmd = result[0].command
+ assert "-crf:v" in cmd
+
+
+def test_vp9_bitrate_two_pass():
+ """Test bitrate mode produces two-pass commands."""
+ result = _build_with_settings(crf=None, bitrate="3000k")
+ assert len(result) == 2
+ cmd1 = result[0].command
+ cmd2 = result[1].command
+ assert "-b:v" in cmd1
+ assert "3000k" in cmd1
+ assert "-pass" in cmd1
+ assert "-b:v" in cmd2
+ assert "3000k" in cmd2
+
+
+def test_vp9_auto_alt_ref():
+ """Test auto-alt-ref parameter is included when >= 0."""
+ result = _build_with_settings(auto_alt_ref=1)
+ cmd = result[1].command
+ assert "-auto-alt-ref" in cmd
+ idx = cmd.index("-auto-alt-ref")
+ assert cmd[idx + 1] == "1"
+
+
+def test_vp9_auto_alt_ref_default_not_included():
+ """Test auto-alt-ref is not included when -1 (default)."""
+ result = _build_with_settings(auto_alt_ref=-1)
+ cmd = result[1].command
+ assert "-auto-alt-ref" not in cmd
+
+
+def test_vp9_auto_alt_ref_disabled():
+ """Test auto-alt-ref 0 (disabled) is emitted."""
+ result = _build_with_settings(auto_alt_ref=0)
+ cmd = result[1].command
+ assert "-auto-alt-ref" in cmd
+ idx = cmd.index("-auto-alt-ref")
+ assert cmd[idx + 1] == "0"
+
+
+def test_vp9_lag_in_frames():
+ """Test lag-in-frames parameter is included when >= 0."""
+ result = _build_with_settings(lag_in_frames=25)
+ cmd = result[1].command
+ assert "-lag-in-frames" in cmd
+ idx = cmd.index("-lag-in-frames")
+ assert cmd[idx + 1] == "25"
+
+
+def test_vp9_lag_in_frames_default_not_included():
+ """Test lag-in-frames is not included when -1 (default)."""
+ result = _build_with_settings(lag_in_frames=-1)
+ cmd = result[1].command
+ assert "-lag-in-frames" not in cmd
+
+
+def test_vp9_tune_content():
+ """Test tune-content parameter is included when not default."""
+ result = _build_with_settings(tune_content="screen")
+ cmd = result[1].command
+ assert "-tune-content" in cmd
+ idx = cmd.index("-tune-content")
+ assert cmd[idx + 1] == "screen"
+
+
+def test_vp9_tune_content_film():
+ """Test tune-content film."""
+ result = _build_with_settings(tune_content="film")
+ cmd = result[1].command
+ assert "-tune-content" in cmd
+ idx = cmd.index("-tune-content")
+ assert cmd[idx + 1] == "film"
+
+
+def test_vp9_tune_content_default_not_included():
+ """Test tune-content is not included when default."""
+ result = _build_with_settings(tune_content="default")
+ cmd = result[1].command
+ assert "-tune-content" not in cmd
+
+
+def test_vp9_aq_mode():
+ """Test aq-mode parameter is included when >= 0."""
+ result = _build_with_settings(aq_mode=2)
+ cmd = result[1].command
+ assert "-aq-mode" in cmd
+ idx = cmd.index("-aq-mode")
+ assert cmd[idx + 1] == "2"
+
+
+def test_vp9_aq_mode_default_not_included():
+ """Test aq-mode is not included when -1 (default)."""
+ result = _build_with_settings(aq_mode=-1)
+ cmd = result[1].command
+ assert "-aq-mode" not in cmd
+
+
+def test_vp9_sharpness():
+ """Test sharpness parameter is included when >= 0."""
+ result = _build_with_settings(sharpness=4)
+ cmd = result[1].command
+ assert "-sharpness" in cmd
+ idx = cmd.index("-sharpness")
+ assert cmd[idx + 1] == "4"
+
+
+def test_vp9_sharpness_default_not_included():
+ """Test sharpness is not included when -1 (default)."""
+ result = _build_with_settings(sharpness=-1)
+ cmd = result[1].command
+ assert "-sharpness" not in cmd
+
+
+def test_vp9_all_new_options():
+ """Test all new options together."""
+ result = _build_with_settings(
+ auto_alt_ref=6,
+ lag_in_frames=25,
+ tune_content="screen",
+ aq_mode=3,
+ sharpness=2,
+ )
+ cmd = result[1].command
+ assert "-auto-alt-ref" in cmd
+ assert "-lag-in-frames" in cmd
+ assert "-tune-content" in cmd
+ assert "-aq-mode" in cmd
+ assert "-sharpness" in cmd
+
+
+def test_vp9_all_elements_are_strings():
+ """Test that all command elements are strings."""
+ result = _build_with_settings()
+ for r in result:
+ cmd = r.command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
diff --git a/tests/encoders/test_vvc_command_builder.py b/tests/encoders/test_vvc_command_builder.py
new file mode 100644
index 00000000..c9a7d551
--- /dev/null
+++ b/tests/encoders/test_vvc_command_builder.py
@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+from unittest import mock
+
+from fastflix.encoders.vvc.command_builder import build
+from fastflix.models.encode import VVCSettings
+from fastflix.models.video import VideoSettings
+
+from tests.conftest import create_fastflix_instance
+
+
+def _build_with_settings(hdr10=False, remove_hdr=False, **kwargs):
+ """Helper to build VVC commands with custom settings."""
+ defaults = dict(
+ qp=22,
+ bitrate=None,
+ preset="medium",
+ tier="main",
+ )
+ defaults.update(kwargs)
+ fastflix = create_fastflix_instance(
+ encoder_settings=VVCSettings(**defaults),
+ video_settings=VideoSettings(remove_hdr=remove_hdr, maxrate=None, bufsize=None),
+ hdr10_metadata=hdr10,
+ )
+ with mock.patch("fastflix.encoders.vvc.command_builder.generate_all") as mock_gen:
+ mock_gen.return_value = (["ffmpeg", "-y", "-i", "input.mkv"], ["output.mkv"], ["-r", "24"])
+ result = build(fastflix)
+ return result
+
+
+def test_vvc_basic_qp():
+ """Test basic QP encoding with default settings."""
+ result = _build_with_settings()
+ assert len(result) == 1
+ cmd = result[0].command
+ assert isinstance(cmd, list)
+ assert "-qp:v" in cmd
+ assert "22" in cmd
+ assert "-preset:v" in cmd
+ assert "medium" in cmd
+
+
+def test_vvc_bitrate_two_pass():
+ """Test bitrate mode produces two-pass commands."""
+ result = _build_with_settings(qp=None, bitrate="6000k")
+ assert len(result) == 2
+ cmd1 = result[0].command
+ cmd2 = result[1].command
+ assert "-b:v" in cmd1
+ assert "6000k" in cmd1
+ assert "-b:v" in cmd2
+ assert "6000k" in cmd2
+
+
+def test_vvc_tier():
+ """Test tier parameter is included."""
+ result = _build_with_settings(tier="high")
+ cmd = result[0].command
+ assert "-tier:v" in cmd
+ idx = cmd.index("-tier:v")
+ assert cmd[idx + 1] == "high"
+
+
+def test_vvc_level():
+ """Test level parameter is included when set."""
+ result = _build_with_settings(levelidc="5.1")
+ cmd = result[0].command
+ assert "-level" in cmd
+ idx = cmd.index("-level")
+ assert cmd[idx + 1] == "5.1"
+
+
+def test_vvc_subjopt_enabled_by_default():
+ """Test that QPA is not disabled when subjopt is True (default)."""
+ result = _build_with_settings(subjopt=True)
+ cmd = result[0].command
+ assert "-qpa" not in cmd
+
+
+def test_vvc_subjopt_disabled():
+ """Test that -qpa 0 is emitted when subjopt is False."""
+ result = _build_with_settings(subjopt=False)
+ cmd = result[0].command
+ assert "-qpa" in cmd
+ idx = cmd.index("-qpa")
+ assert cmd[idx + 1] == "0"
+
+
+def test_vvc_period():
+ """Test intra period parameter is included when set."""
+ result = _build_with_settings(period=2)
+ cmd = result[0].command
+ assert "-period" in cmd
+ idx = cmd.index("-period")
+ assert cmd[idx + 1] == "2"
+
+
+def test_vvc_period_none():
+ """Test period is not included when None."""
+ result = _build_with_settings(period=None)
+ cmd = result[0].command
+ assert "-period" not in cmd
+
+
+def test_vvc_threads():
+ """Test threads parameter is included when > 0."""
+ result = _build_with_settings(threads=8)
+ cmd = result[0].command
+ assert "-threads" in cmd
+ idx = cmd.index("-threads")
+ assert cmd[idx + 1] == "8"
+
+
+def test_vvc_threads_auto():
+ """Test threads is not included when 0 (auto)."""
+ result = _build_with_settings(threads=0)
+ cmd = result[0].command
+ assert "-threads" not in cmd
+
+
+def test_vvc_ifp_enabled():
+ """Test IFP is appended to vvc-params when enabled."""
+ result = _build_with_settings(ifp=True)
+ cmd = result[0].command
+ assert "-vvenc-params" in cmd
+ idx = cmd.index("-vvenc-params")
+ assert "ifp=1" in cmd[idx + 1]
+
+
+def test_vvc_ifp_disabled():
+ """Test IFP is not included when disabled."""
+ result = _build_with_settings(ifp=False)
+ cmd = result[0].command
+ # Should not have vvenc-params at all with no other params
+ if "-vvenc-params" in cmd:
+ idx = cmd.index("-vvenc-params")
+ assert "ifp=1" not in cmd[idx + 1]
+
+
+def test_vvc_vvc_params():
+ """Test pass-through vvc params."""
+ result = _build_with_settings(vvc_params=["rcstatsfile=test"])
+ cmd = result[0].command
+ assert "-vvenc-params" in cmd
+ idx = cmd.index("-vvenc-params")
+ assert "rcstatsfile=test" in cmd[idx + 1]
+
+
+def test_vvc_no_vvc_params_when_empty():
+ """Test that -vvenc-params is not added when no params."""
+ result = _build_with_settings(vvc_params=[], ifp=False)
+ cmd = result[0].command
+ assert "-vvenc-params" not in cmd
+
+
+def test_vvc_all_elements_are_strings():
+ """Test that all command elements are strings."""
+ result = _build_with_settings()
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
+
+
+def test_vvc_hdr10_mastering_display():
+ """Test HDR10 mastering display metadata is passed to vvenc-params."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-vvenc-params" in cmd
+ idx = cmd.index("-vvenc-params")
+ params = cmd[idx + 1]
+ assert "MasteringDisplayColourVolume=" in params
+
+
+def test_vvc_hdr10_cll():
+ """Test HDR10 content light level is passed to vvenc-params."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-vvenc-params" in cmd
+ idx = cmd.index("-vvenc-params")
+ params = cmd[idx + 1]
+ assert "MaxContentLightLevel=1000,300" in params
+
+
+def test_vvc_hdr10_color_primaries():
+ """Test HDR10 color primaries are passed as FFmpeg flags."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-color_primaries" in cmd
+ idx = cmd.index("-color_primaries")
+ assert cmd[idx + 1] == "bt2020"
+
+
+def test_vvc_hdr10_color_trc():
+ """Test HDR10 color transfer is passed as FFmpeg flags."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-color_trc" in cmd
+ idx = cmd.index("-color_trc")
+ assert cmd[idx + 1] == "smpte2084"
+
+
+def test_vvc_hdr10_colorspace():
+ """Test HDR10 colorspace is passed as FFmpeg flags."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-colorspace" in cmd
+ idx = cmd.index("-colorspace")
+ assert cmd[idx + 1] == "bt2020nc"
+
+
+def test_vvc_hdr10_not_included_when_remove_hdr():
+ """Test HDR10 metadata is not included when remove_hdr is set."""
+ result = _build_with_settings(hdr10=True, remove_hdr=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-color_primaries" not in cmd
+ if "-vvenc-params" in cmd:
+ idx = cmd.index("-vvenc-params")
+ params = cmd[idx + 1]
+ assert "MasteringDisplayColourVolume" not in params
+ assert "MaxContentLightLevel" not in params
+
+
+def test_vvc_hdr10_not_included_when_8bit():
+ """Test HDR10 mastering display is not included for 8-bit output."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p")
+ cmd = result[0].command
+ # Color signaling should still be present (it's independent of bit depth)
+ assert "-color_primaries" in cmd
+ # But mastering display / CLL should not be in vvenc-params for 8-bit
+ if "-vvenc-params" in cmd:
+ idx = cmd.index("-vvenc-params")
+ params = cmd[idx + 1]
+ assert "MasteringDisplayColourVolume" not in params
+ assert "MaxContentLightLevel" not in params
+
+
+def test_vvc_hdr10_chroma_location():
+ """Test chroma location is passed when present."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ assert "-chroma_sample_location" in cmd
+ idx = cmd.index("-chroma_sample_location")
+ assert cmd[idx + 1] == "0" # "left" maps to 0
+
+
+def test_vvc_hdr10_all_elements_are_strings():
+ """Test that all command elements are strings with HDR10 metadata."""
+ result = _build_with_settings(hdr10=True, pix_fmt="yuv420p10le")
+ cmd = result[0].command
+ for i, element in enumerate(cmd):
+ assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}"
diff --git a/tests/test_audio.py b/tests/test_audio.py
index d7ede509..b020cb97 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" 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."""
+ 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" in result and "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."""
diff --git a/tests/test_pyside6_fixes.py b/tests/test_pyside6_fixes.py
new file mode 100644
index 00000000..c9a0e4d8
--- /dev/null
+++ b/tests/test_pyside6_fixes.py
@@ -0,0 +1,167 @@
+# -*- 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 os
+import sys
+from pathlib import Path
+
+import pytest
+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)
+ 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}"
+
+ @requires_display
+ 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..dd7629fa
--- /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 "#4f5962" 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 7241bcdf..d88db8f8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,14 +1,15 @@
version = 1
-revision = 2
-requires-python = ">=3.12"
+revision = 3
+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]]
@@ -20,22 +21,49 @@ 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 = "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 +77,73 @@ 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]]
+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]]
@@ -105,45 +169,52 @@ 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]]
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'" },
]
@@ -161,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.9.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" },
]
@@ -194,7 +272,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 +281,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 +321,77 @@ 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/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/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" }
-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" }
+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/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/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.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 +405,37 @@ wheels = [
[[package]]
name = "msgpack"
-version = "1.1.1"
+version = "1.1.2"
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" },
+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 +449,66 @@ 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/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/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/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/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/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/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]]
@@ -359,20 +531,104 @@ 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/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/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/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/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/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/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.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 +642,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 +651,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 +672,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 +680,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 +749,7 @@ wheels = [
[[package]]
name = "pyinstaller"
-version = "6.14.1"
+version = "6.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
@@ -493,32 +760,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]]
@@ -532,7 +799,7 @@ wheels = [
[[package]]
name = "pyside6"
-version = "6.9.0"
+version = "6.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
@@ -540,47 +807,69 @@ 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]]
+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 = "8.4.1"
+version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -589,9 +878,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 +901,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 +923,66 @@ 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 = "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.4"
+version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -668,9 +990,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]]
@@ -683,66 +1005,119 @@ wheels = [
]
[[package]]
-name = "ruamel-yaml"
-version = "0.18.14"
+name = "rpds-py"
+version = "0.28.0"
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" }
+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/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" },
+ { 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-clib"
-version = "0.2.12"
+name = "ruamel-yaml"
+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]]
@@ -756,14 +1131,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]]
@@ -787,69 +1162,82 @@ 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.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]]