diff --git a/CLAUDE.md b/CLAUDE.md
index e995d1c..8f37472 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -161,6 +161,8 @@ not finished until the README shows the application as it now is.
screenshots match what ships. A pull request that adds one and leaves the
README describing the previous application is incomplete, the same way one
without a test is.
+- **A new user-visible string is not done until it is wrapped for Qt
+ translation and present in the German catalogue.**
- **A minor release checks the README and the screenshots before the version
bump.** A patch carries corrections and needs no pass. A minor carries
something new — and the screenshots are the part that rots silently, because
diff --git a/README.md b/README.md
index 014f7ef..bd6c06b 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,10 @@ composite. MatteLoop is the whole path from that clip to that loop:
- **Open Preferences** from the gear button to choose or clear the output
directory.
+The interface is available in English and German. Change the language in
+Preferences from the gear button; the choice applies after restarting
+MatteLoop.
+
The result is lossless and genuinely transparent — an alpha channel, not a
matte painted onto a colour.
diff --git a/docs/building.md b/docs/building.md
index 01d1347..c77b734 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -37,6 +37,21 @@ build also includes Nuitka 2.8.10, PyAV 16.1.0, Pillow 12.3.0, NumPy 2.5.2,
`rembg` 2.0.75, and CPU `onnxruntime` 1.29.0. Do not install a second Python
environment for the build; run the commands below from the repository root.
+## Translation catalogues
+
+Regenerate the English and German Qt translation sources after changing
+user-visible copy:
+
+```sh
+uv run --frozen --no-sync pyside6-lupdate -extensions py -no-obsolete src/matteloop -ts resources/matteloop_en.ts resources/matteloop_de.ts
+```
+
+Compile the checked-in sources into the catalogues loaded by the application:
+
+```sh
+uv run --frozen --no-sync pyside6-lrelease resources/matteloop_en.ts resources/matteloop_de.ts
+```
+
## Licensing gate
The repository's original code, documentation, and visual assets use 0BSD.
diff --git a/packaging/pysidedeploy.spec b/packaging/pysidedeploy.spec
index 737e2cf..aa2493f 100644
--- a/packaging/pysidedeploy.spec
+++ b/packaging/pysidedeploy.spec
@@ -74,6 +74,8 @@ extra_args =
--include-module=PIL.WebPImagePlugin
--include-data-files=resources/model-manifest.json=resources/model-manifest.json
--include-data-files=resources/model-provenance.json=resources/model-provenance.json
+ --include-data-files=resources/matteloop_en.qm=resources/matteloop_en.qm
+ --include-data-files=resources/matteloop_de.qm=resources/matteloop_de.qm
--include-data-files=LICENSE=LICENSE
--include-data-files=THIRD_PARTY_NOTICES.md=THIRD_PARTY_NOTICES.md
--include-data-files=legal/GPL-3.0.txt=GPL-3.0.txt
diff --git a/pyproject.toml b/pyproject.toml
index 671e217..510f16b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,6 +51,10 @@ packages = ["src/matteloop"]
[tool.hatch.build.targets.wheel.force-include]
"resources/model-manifest.json" = "matteloop/resources/model-manifest.json"
"resources/model-provenance.json" = "matteloop/resources/model-provenance.json"
+"resources/matteloop_en.qm" = "matteloop/resources/matteloop_en.qm"
+"resources/matteloop_de.qm" = "matteloop/resources/matteloop_de.qm"
+"resources/matteloop_en.ts" = "matteloop/resources/matteloop_en.ts"
+"resources/matteloop_de.ts" = "matteloop/resources/matteloop_de.ts"
"resources/fonts/IBMPlexSans-Regular.ttf" = "matteloop/resources/fonts/IBMPlexSans-Regular.ttf"
"resources/fonts/IBMPlexSans-SemiBold.ttf" = "matteloop/resources/fonts/IBMPlexSans-SemiBold.ttf"
"resources/fonts/IBMPlexMono-Regular.ttf" = "matteloop/resources/fonts/IBMPlexMono-Regular.ttf"
diff --git a/resources/matteloop_de.qm b/resources/matteloop_de.qm
new file mode 100644
index 0000000..983d582
Binary files /dev/null and b/resources/matteloop_de.qm differ
diff --git a/resources/matteloop_de.ts b/resources/matteloop_de.ts
new file mode 100644
index 0000000..4d2c875
--- /dev/null
+++ b/resources/matteloop_de.ts
@@ -0,0 +1,2461 @@
+
+
+
+
+ ActionShelf
+
+
+
+ Preview Frame
+ Vorschau anzeigen
+
+
+
+
+ Render Video
+ Video rendern
+
+
+
+
+ Preferences
+ Einstellungen
+
+
+
+ CropCanvas
+
+
+ Original video frame
+ Original-Videobild
+
+
+
+ Crop bounds: x %s, y %s, width %s, height %s source pixels; source %s × %s
+ Zuschnittgrenzen: x %s, y %s, Breite %s, Höhe %s Quellpixel; Quelle %s × %s
+
+
+
+ Inspector
+
+
+ Segmentation
+ Segmentierung
+
+
+
+ Time & Sampling
+ Zeit & Abtastung
+
+
+
+ Crop & Cleanup
+ Zuschnitt und Bereinigung
+
+
+
+ Transform
+ Transformation
+
+
+
+ Output
+ Ausgabe
+
+
+
+ Workspace
+ Arbeitsbereich
+
+
+
+
+
+ X
+ X
+
+
+
+
+
+ Y
+ Y
+
+
+
+
+
+ Width
+ Breite
+
+
+
+
+
+ Height
+ Höhe
+
+
+
+ Ready
+ Bereit
+
+
+
+ Downloading
+ Wird heruntergeladen
+
+
+
+
+ Not cached
+ Nicht zwischengespeichert
+
+
+
+ Model
+ Modell
+
+
+
+
+ Compute acceleration
+ Beschleunigung
+
+
+
+
+ Edge treatment
+ Kantenbehandlung
+
+
+
+
+ Output FPS
+ Ausgabe-FPS
+
+
+
+
+ Start
+ Start
+
+
+
+
+ End
+ Ende
+
+
+
+
+ Duration
+ Dauer
+
+
+
+ Alpha threshold
+ Alpha-Schwellenwert
+
+
+
+ Padding
+ Rand
+
+
+
+
+ Horizontal stretch
+ Horizontale Streckung
+
+
+
+ Directory
+ Ordner
+
+
+
+ Filename
+ Dateiname
+
+
+
+ Maximum size
+ Maximale Größe
+
+
+
+ Processing settings
+ Verarbeitungseinstellungen
+
+
+
+ Retry Rebuild
+ Neuaufbau wiederholen
+
+
+
+ Edited cut recovery
+ Wiederherstellung bearbeiteter Schnitte
+
+
+
+ Edited cut frames could not be validated. Retry the rebuild scan.
+ Bearbeitete Schnittbilder konnten nicht validiert werden. Wiederholen Sie die Neuaufbauprüfung.
+
+
+
+
+ Rebuild from edited cuts
+ Aus bearbeiteten Schnitten neu aufbauen
+
+
+
+
+ Manage Models…
+ Modelle verwalten…
+
+
+
+
+ Manage Workspaces…
+ Arbeitsbereiche verwalten…
+
+
+
+ Segmentation model
+ Segmentierungsmodell
+
+
+
+ cached locally
+ lokal zwischengespeichert
+
+
+
+ not cached yet
+ noch nicht zwischengespeichert
+
+
+
+ Model status
+ Modellstatus
+
+
+
+ Standard
+ Standard
+
+
+
+ Decontaminate colors
+ Farben bereinigen
+
+
+
+ fps
+ FPS
+
+
+
+ High output FPS may increase render cost
+ Hohe Ausgabe-FPS können den Renderaufwand erhöhen
+
+
+
+ Output FPS cost warning
+ Warnung zum Aufwand der Ausgabe-FPS
+
+
+
+ Global trim
+ Globaler Randbeschnitt
+
+
+
+ Global alpha trim
+ Globaler Alpha-Beschnitt
+
+
+
+ Padding pixels
+ Randpixel
+
+
+
+ Output directory
+ Ausgabeordner
+
+
+
+ Choose…
+ Auswählen…
+
+
+
+ Choose output directory
+ Ausgabeordner auswählen
+
+
+
+ Output filename
+ Ausgabedateiname
+
+
+
+
+ Use one non-empty filename ending in .webp; path separators are not allowed.
+ Verwenden Sie einen einzelnen, nicht leeren Dateinamen mit der Endung .webp; Pfadtrenner sind nicht erlaubt.
+
+
+
+ Maximum file size in MiB
+ Maximale Dateigröße in MiB
+
+
+
+ MiB
+ MiB
+
+
+
+ Filename must be a single non-empty .webp filename.
+ Der Dateiname muss ein einzelner, nicht leerer .webp-Dateiname sein.
+
+
+
+ s
+ s
+
+
+
+ Crop
+ Zuschnitt
+
+
+
+ Crop overlay
+ Zuschnitt-Overlay
+
+
+
+ Reset Crop
+ Zuschnitt zurücksetzen
+
+
+
+ Reset crop
+ Zuschnitt zurücksetzen
+
+
+
+ Crop %s
+ Zuschnitt %s
+
+
+
+ Model status: %s
+ Modellstatus: %s
+
+
+
+ InspectorDisclosure
+
+
+ expanded
+ ausgeklappt
+
+
+
+ collapsed
+ eingeklappt
+
+
+
+ MainWindow
+
+
+ MatteLoop
+ MatteLoop
+
+
+
+ Couldn’t read this video
+ Dieses Video konnte nicht gelesen werden
+
+
+
+ Video load error
+ Fehler beim Laden des Videos
+
+
+
+ Render complete
+ Rendern abgeschlossen
+
+
+
+ Open output
+ Ausgabe öffnen
+
+
+
+ Open folder
+ Ordner öffnen
+
+
+
+ ModelCopy
+
+
+ U²-Net
+ U²-Net
+
+
+
+ U²-Net P
+ U²-Net P
+
+
+
+ U²-Net Human Segmentation
+ U²-Net Human Segmentation
+
+
+
+ U²-Net Cloth Segmentation
+ U²-Net Cloth Segmentation
+
+
+
+ Silueta
+ Silueta
+
+
+
+ IS-Net General Use
+ IS-Net General Use
+
+
+
+ IS-Net Anime
+ IS-Net Anime
+
+
+
+ BiRefNet General
+ BiRefNet General
+
+
+
+ BiRefNet General Lite
+ BiRefNet General Lite
+
+
+
+ BiRefNet Portrait
+ BiRefNet Portrait
+
+
+
+ BiRefNet DIS
+ BiRefNet DIS
+
+
+
+ BiRefNet HRSOD
+ BiRefNet HRSOD
+
+
+
+ BiRefNet COD
+ BiRefNet COD
+
+
+
+ BiRefNet Massive
+ BiRefNet Massive
+
+
+
+ BRIA RMBG 2.0
+ BRIA RMBG 2.0
+
+
+
+ General-purpose foreground extraction.
+ Allgemeine Freistellung von Vordergrundobjekten.
+
+
+
+ Small, faster general-purpose foreground extraction.
+ Kleinere, schnellere allgemeine Freistellung von Vordergrundobjekten.
+
+
+
+ Human-focused foreground extraction.
+ Auf Personen ausgerichtete Vordergrundfreistellung.
+
+
+
+ Clothing-focused segmentation.
+ Auf Kleidung ausgerichtete Segmentierung.
+
+
+
+ Compact general-purpose foreground extraction.
+ Kompakte allgemeine Freistellung von Vordergrundobjekten.
+
+
+
+ General salient-object segmentation.
+ Allgemeine Segmentierung auffälliger Objekte.
+
+
+
+ Anime and illustration foreground extraction.
+ Freistellung von Vordergrundobjekten in Anime und Illustrationen.
+
+
+
+ High-quality general foreground extraction.
+ Hochwertige allgemeine Freistellung von Vordergrundobjekten.
+
+
+
+ Smaller BiRefNet general foreground extraction.
+ Kleinere allgemeine BiRefNet-Freistellung von Vordergrundobjekten.
+
+
+
+ High-quality portrait and hair foreground extraction.
+ Hochwertige Freistellung von Porträts und Haaren.
+
+
+
+ Dichotomous image segmentation.
+ Dichotome Bildsegmentierung.
+
+
+
+ High-resolution salient-object detection.
+ Erkennung auffälliger Objekte in hoher Auflösung.
+
+
+
+ Camouflaged-object detection.
+ Erkennung getarnter Objekte.
+
+
+
+ Broad high-capacity foreground extraction.
+ Leistungsstarke, breit einsetzbare Vordergrundfreistellung.
+
+
+
+ High-quality general background removal.
+ Hochwertige allgemeine Hintergrundentfernung.
+
+
+
+ Model terms are provided by the upstream U²-Net project; review them before redistribution.
+ Die Modellbedingungen stammen vom U²-Net-Projekt; prüfen Sie sie vor der Weitergabe.
+
+
+
+ Review the Silueta model's upstream terms before redistribution.
+ Prüfen Sie die Bedingungen des Silueta-Projekts vor der Weitergabe.
+
+
+
+ Review the IS-Net model's upstream terms before redistribution.
+ Prüfen Sie die Bedingungen des IS-Net-Projekts vor der Weitergabe.
+
+
+
+ Review the IS-Net Anime model's upstream terms before redistribution.
+ Prüfen Sie die Bedingungen des IS-Net-Anime-Projekts vor der Weitergabe.
+
+
+
+ Review the BiRefNet model's upstream terms before redistribution or commercial use.
+ Prüfen Sie die Bedingungen des BiRefNet-Projekts vor der Weitergabe oder kommerziellen Nutzung.
+
+
+
+ BRIA RMBG 2.0 has model-specific license terms; commercial use requires checking and satisfying BRIA's current license.
+ Für BRIA RMBG 2.0 gelten modellspezifische Lizenzbedingungen; für die kommerzielle Nutzung müssen Sie die aktuelle BRIA-Lizenz prüfen und einhalten.
+
+
+
+ ModelEntries
+
+
+ cached locally
+ lokal zwischengespeichert
+
+
+
+ ; outdated copy from rembg %s
+ ; veraltete Kopie von rembg %s
+
+
+
+ outdated weight
+ veraltetes Gewicht
+
+
+
+ obsolete rembg
+ veraltetes rembg
+
+
+
+ %s from rembg %s; %s on disk
+ %s von rembg %s; %s auf der Festplatte
+
+
+
+ not cached
+ nicht zwischengespeichert
+
+
+
+ active model
+ aktives Modell
+
+
+
+ not active
+ nicht aktiv
+
+
+
+ %s; %s; %s; %s
+ %s; %s; %s; %s
+
+
+
+ ModelManagerController
+
+
+ Download %n model weight(s) (%1)? Each outdated copy from rembg %2 is deleted once its replacement has been verified. Other files in the %2 %3 stay until you use Delete outdated.
+
+ %n Modellgewicht (%1) herunterladen? Jede veraltete Kopie von rembg %2 wird gelöscht, sobald ihr Ersatz verifiziert wurde. Andere Dateien im %2 %3 bleiben erhalten, bis Sie „Veraltete löschen“ verwenden.
+ %n Modellgewichte (%1) herunterladen? Jede veraltete Kopie von rembg %2 wird gelöscht, sobald ihr Ersatz verifiziert wurde. Andere Dateien im %2 %3 bleiben erhalten, bis Sie „Veraltete löschen“ verwenden.
+
+
+
+
+ Re-downloading %n outdated model weight(s)…
+
+ %n veraltetes Modellgewicht wird erneut heruntergeladen…
+ %n veraltete Modellgewichte werden erneut heruntergeladen…
+
+
+
+
+ %s Re-downloaded %n of %1 outdated model weight(s).
+
+ %s %n von %1 veraltetem Modellgewicht erneut heruntergeladen.
+ %s %n von %1 veralteten Modellgewichten erneut heruntergeladen.
+
+
+
+
+ Download cancelled. Re-downloaded %n of %1 outdated model weight(s).
+
+ Download abgebrochen. %n von %1 veraltetem Modellgewicht erneut heruntergeladen.
+ Download abgebrochen. %n von %1 veralteten Modellgewichten erneut heruntergeladen.
+
+
+
+
+ Re-downloaded %n outdated model weight(s).
+
+ %n veraltetes Modellgewicht erneut heruntergeladen.
+ %n veraltete Modellgewichte erneut heruntergeladen.
+
+
+
+
+ Removed %n outdated model version(s) from the cache.
+
+ %n veraltete Modellversion aus dem Cache entfernt.
+ %n veraltete Modellversionen aus dem Cache entfernt.
+
+
+
+
+ ModelManagerDialog
+
+
+ directory
+ Ordner
+
+
+
+ directories
+ Ordner
+
+
+
+ Re-download outdated model weights?
+ Veraltete Modellgewichte erneut herunterladen?
+
+
+
+
+ Cannot remove a model while a job is running.
+ Ein Modell kann während eines laufenden Auftrags nicht entfernt werden.
+
+
+
+
+
+ Model removal is unavailable in this runtime.
+ Das Entfernen von Modellen ist in dieser Laufzeit nicht verfügbar.
+
+
+
+
+ Cannot download a model while a job is running.
+ Ein Modell kann während eines laufenden Auftrags nicht heruntergeladen werden.
+
+
+
+ Downloading %s…
+ %s wird heruntergeladen…
+
+
+
+ Delete all weights from rembg %s?
+ Alle Gewichte von rembg %s löschen?
+
+
+
+ This removes the whole %s %s: %s on disk. Weights this version needs are downloaded again on demand.
+ Dies entfernt den gesamten %s %s: %s auf der Festplatte. Von dieser Version benötigte Gewichte werden bei Bedarf erneut heruntergeladen.
+
+
+
+ Removing outdated model weights…
+ Veraltete Modellgewichte werden entfernt…
+
+
+
+ (%s of %s)
+ (%s von %s)
+
+
+
+ Downloading %s%s…
+ %s%s wird heruntergeladen…
+
+
+
+ Downloaded %s%s.
+ %s%s heruntergeladen.
+
+
+
+ Could not download %s: %s.
+ %s konnte nicht heruntergeladen werden: %s.
+
+
+
+ Downloaded %s.
+ %s heruntergeladen.
+
+
+
+ Download cancelled.
+ Download abgebrochen.
+
+
+
+ Could not download the weight: %s
+ Das Gewicht konnte nicht heruntergeladen werden: %s
+
+
+
+ Could not download model
+ Modell konnte nicht heruntergeladen werden
+
+
+
+ Could not remove outdated weights: %s: %s
+ Veraltete Gewichte konnten nicht entfernt werden: %s: %s
+
+
+
+ Remove model weight?
+ Modellgewicht entfernen?
+
+
+
+ Remove %s's downloaded weight?
+This frees %s.
+ Heruntergeladenes Gewicht von %s entfernen?
+Dadurch werden %s freigegeben.
+
+
+
+ No weight could be removed at %s.
+ Bei %s konnte kein Gewicht entfernt werden.
+
+
+
+ Removed %s; freed %s.
+ %s entfernt; %s freigegeben.
+
+
+
+ Model removed
+ Modell entfernt
+
+
+
+ Removed %s's downloaded weight.
+Freed %s.
+ Heruntergeladenes Gewicht von %s entfernt.
+%s freigegeben.
+
+
+
+ Could not remove the selected weight: %s
+ Das ausgewählte Gewicht konnte nicht entfernt werden: %s
+
+
+
+ Could not remove model
+ Modell konnte nicht entfernt werden
+
+
+
+ Could not open the model folder: %s
+ Der Modellordner konnte nicht geöffnet werden: %s
+
+
+
+ Model folder: %s
+ Modellordner: %s
+
+
+
+
+ Model manager
+ Modellverwaltung
+
+
+
+ Total model cache size
+ Gesamtgröße des Modellzwischenspeichers
+
+
+
+ Model cache location
+ Speicherort des Modellzwischenspeichers
+
+
+
+ V1 models
+ V1-Modelle
+
+
+
+ Download weight
+ Gewicht herunterladen
+
+
+
+ Download selected model weight
+ Ausgewähltes Modellgewicht herunterladen
+
+
+
+ Remove downloaded weight
+ Heruntergeladenes Gewicht entfernen
+
+
+
+ Remove selected model weight
+ Ausgewähltes Modellgewicht entfernen
+
+
+
+ Re-download outdated
+ Veraltete erneut herunterladen
+
+
+
+ Re-download outdated model weights
+ Veraltete Modellgewichte erneut herunterladen
+
+
+
+ Delete outdated
+ Veraltete löschen
+
+
+
+ Delete outdated model weights
+ Veraltete Modellgewichte löschen
+
+
+
+ Show cache location
+ Speicherort des Zwischenspeichers anzeigen
+
+
+
+ Show model cache location
+ Speicherort des Modellzwischenspeichers anzeigen
+
+
+
+ Close
+ Schließen
+
+
+
+ Close model manager
+ Modellverwaltung schließen
+
+
+
+
+
+ Cancel
+ Abbrechen
+
+
+
+ Cancel the running model download
+ Laufenden Modelldownload abbrechen
+
+
+
+ Manage the downloaded V1 model weights.
+ Heruntergeladene V1-Modellgewichte verwalten.
+
+
+
+
+ Cancelling…
+ Wird abgebrochen…
+
+
+
+ Cannot remove the model used by the active session.
+ Das Modell der aktiven Sitzung kann nicht entfernt werden.
+
+
+
+ Total on disk: %s
+ Insgesamt auf der Festplatte: %s
+
+
+
+ %n V1 model(s); cache: %1
+
+ %n V1-Modell; Cache: %1
+ %n V1-Modelle; Cache: %1
+
+
+
+
+ Outdated weights from rembg %s occupy %s on disk and cannot be used by this version.
+ Veraltete Gewichte von rembg %s belegen %s auf der Festplatte und können von dieser Version nicht verwendet werden.
+
+
+
+ Remove the selected downloaded weight and free its disk space.
+ Ausgewähltes heruntergeladenes Gewicht entfernen und Speicherplatz freigeben.
+
+
+
+ Download the selected weight now instead of waiting for a preview.
+ Ausgewähltes Gewicht jetzt herunterladen, statt auf eine Vorschau zu warten.
+
+
+
+ Presenter
+
+
+ Preview this frame to inspect the cutout
+ Vorschau dieses Bildes anzeigen, um die Freistellung zu prüfen
+
+
+
+ Reading video…
+ Video wird gelesen…
+
+
+
+ This video could not be read. Open another video.
+ Dieses Video konnte nicht gelesen werden. Öffnen Sie ein anderes Video.
+
+
+
+
+ Preview failed
+ Vorschau fehlgeschlagen
+
+
+
+ retry Preview Frame
+ Vorschau anzeigen wiederholen
+
+
+
+ Current preview — previewing selected frame
+ Aktuelle Vorschau — ausgewähltes Bild wird angezeigt
+
+
+
+ Previewing selected frame
+ Ausgewähltes Bild wird in der Vorschau angezeigt
+
+
+
+ Current preview
+ Aktuelle Vorschau
+
+
+
+ preview again
+ Vorschau erneut anzeigen
+
+
+
+ Settings changed — preview again
+ Einstellungen geändert — Vorschau erneut anzeigen
+
+
+
+ Download required
+ Download erforderlich
+
+
+
+ Model preview — rebuild uses edited cut frames
+ Modellvorschau — der Neuaufbau verwendet bearbeitete Schnittbilder
+
+
+
+ Edited cuts changed
+ Bearbeitete Schnitte geändert
+
+
+
+ Prepare & Preview
+ Vorbereiten und Vorschau anzeigen
+
+
+
+ Preview Frame
+ Vorschau anzeigen
+
+
+
+ Open another video
+ Anderes Video öffnen
+
+
+
+ Drop a video here
+ Legen Sie hier ein Video ab
+
+
+
+ Background-removed result
+ Freigestelltes Ergebnis
+
+
+
+ Retry Rebuild
+ Neuaufbau wiederholen
+
+
+
+ Rebuild from edited cuts
+ Aus bearbeiteten Schnitten neu aufbauen
+
+
+
+ Render complete
+ Rendern abgeschlossen
+
+
+
+ Preview failed: %s
+ Vorschau fehlgeschlagen: %s
+
+
+
+ Preview failed — %s
+ Vorschau fehlgeschlagen — %s
+
+
+
+ Segmentation
+ Segmentierung
+
+
+
+ Compute acceleration
+ Beschleunigung
+
+
+
+ Sampling
+ Abtastung
+
+
+
+ Crop & cleanup
+ Zuschnitt und Bereinigung
+
+
+
+ Crop
+ Zuschnitt
+
+
+
+ Framing
+ Rahmung
+
+
+
+ Playhead
+ Abspielposition
+
+
+
+ Export range
+ Exportbereich
+
+
+
+ Edited cuts
+ bearbeitete Schnitte
+
+
+
+ PreviewCanvas
+
+
+ Original video frame
+ Original-Videobild
+
+
+
+ Background-removed result
+ Freigestelltes Ergebnis
+
+
+
+ Original
+ Original
+
+
+
+ PreviewJobDialog
+
+
+ Rebuilding from edited cuts
+ Aus bearbeiteten Schnitten wird neu aufgebaut
+
+
+
+ Rendering video
+ Video wird gerendert
+
+
+
+
+ Previewing selected frame
+ Ausgewähltes Bild wird in der Vorschau angezeigt
+
+
+
+
+
+ Preparing model
+ Modell wird vorbereitet
+
+
+
+ Downloading model
+ Modell wird heruntergeladen
+
+
+
+ Segmentation
+ Segmentierung
+
+
+
+ Decode
+ Dekodierung
+
+
+
+ Validation
+ Validierung
+
+
+
+ Post-process
+ Nachbearbeitung
+
+
+
+ Auto-fit
+ Automatische Anpassung
+
+
+
+ Encode
+ Kodierung
+
+
+
+ Validate
+ Validierung
+
+
+
+
+ Complete
+ Abgeschlossen
+
+
+
+
+ Preview job
+ Vorschauauftrag
+
+
+
+ Active model and provider
+ Aktives Modell und Anbieter
+
+
+
+ Output file
+ Ausgabedatei
+
+
+
+ Provider notice
+ Hinweis zum Anbieter
+
+
+
+
+ Stage progress
+ Fortschritt der Phase
+
+
+
+
+ Overall progress
+ Gesamtfortschritt
+
+
+
+
+ Cancel
+ Abbrechen
+
+
+
+ Render completion summary
+ Zusammenfassung des Renderns
+
+
+
+ Output dimensions
+ Ausgabeabmessungen
+
+
+
+ Output frame count
+ Anzahl der Ausgabebilder
+
+
+
+
+ Animation duration
+ Animationsdauer
+
+
+
+ Output file size
+ Ausgabedateigröße
+
+
+
+ Output frame rate
+ Ausgabebildrate
+
+
+
+ Render duration
+ Renderdauer
+
+
+
+ Cut segmentation
+ Schnittsegmentierung
+
+
+
+ Dimensions
+ Abmessungen
+
+
+
+ Frames
+ Bilder
+
+
+
+ File size
+ Dateigröße
+
+
+
+ Output FPS
+ Ausgabe-FPS
+
+
+
+ Job time
+ Auftragsdauer
+
+
+
+ Cuts
+ Schnitte
+
+
+
+
+ Open output
+ Ausgabe öffnen
+
+
+
+
+ Open folder
+ Ordner öffnen
+
+
+
+
+ Close
+ Schließen
+
+
+
+
+ Stage progress (indeterminate)
+ Fortschritt der Phase (unbestimmt)
+
+
+
+
+ Overall progress (indeterminate)
+ Gesamtfortschritt (unbestimmt)
+
+
+
+
+ Output: %s
+ Ausgabe: %s
+
+
+
+ · %s
+ · %s
+
+
+
+
+ %v / %m frames
+ %v / %m Bilder
+
+
+
+ %v / %m
+ %v / %m
+
+
+
+
+ Cancelling…
+ Wird abgebrochen…
+
+
+
+ Waiting for the current safe checkpoint…
+ Warten auf den nächsten sicheren Kontrollpunkt…
+
+
+
+
+ Render complete
+ Rendern abgeschlossen
+
+
+
+ Output is ready
+ Ausgabe ist bereit
+
+
+
+
+
+
+
+
+ —
+ —
+
+
+
+ Reused existing cuts
+ Vorhandene Schnitte wiederverwendet
+
+
+
+ Fresh segmentation
+ Neue Segmentierung
+
+
+
+ Elapsed %s
+ Verstrichen: %s
+
+
+
+ %n frames
+
+ %n Bild
+ %n Bilder
+
+
+
+
+ PreviewStage
+
+
+ Original
+ Original
+
+
+
+ ProgressCopy
+
+
+ Promoting cut frames
+ Schnittbilder werden freigegeben
+
+
+
+ Validating cut set
+ Schnittset wird validiert
+
+
+
+ Validating cut snapshot
+ Schnitt-Snapshot wird validiert
+
+
+
+ Validating encoded output
+ Kodierte Ausgabe wird validiert
+
+
+
+ Starting segmentation session
+ Segmentierungssitzung wird gestartet
+
+
+
+ Using cached model weights
+ Zwischengespeicherte Modellgewichte werden verwendet
+
+
+
+ Reusing prepared session
+ Vorbereitete Sitzung wird wiederverwendet
+
+
+
+ Frame %1 of %2
+ Frame %1 von %2
+
+
+
+ Cut frame %1 of %2
+ Schnittbild %1 von %2
+
+
+
+ ProviderCopy
+
+
+
+ CPU
+ CPU
+
+
+
+ Core ML
+ Core ML
+
+
+
+ CUDA
+ CUDA
+
+
+
+ ROCm
+ ROCm
+
+
+
+ MIGraphX
+ MIGraphX
+
+
+
+ DirectML
+ DirectML
+
+
+
+ Apple CoreML
+ Apple CoreML
+
+
+
+ NVIDIA CUDA
+ NVIDIA CUDA
+
+
+
+ AMD ROCm
+ AMD ROCm
+
+
+
+ AMD MIGraphX
+ AMD MIGraphX
+
+
+
+ GPU over DirectML
+ GPU über DirectML
+
+
+
+ Apple CoreML – experimental
+ Apple CoreML – experimentell
+
+
+
+ CPU – recommended
+ CPU – empfohlen
+
+
+
+ Apple CoreML – recommended
+ Apple CoreML – empfohlen
+
+
+
+ NVIDIA CUDA – recommended
+ NVIDIA CUDA – empfohlen
+
+
+
+ AMD ROCm – recommended
+ AMD ROCm – empfohlen
+
+
+
+ AMD MIGraphX – recommended
+ AMD MIGraphX – empfohlen
+
+
+
+ GPU over DirectML – recommended
+ GPU über DirectML – empfohlen
+
+
+
+ Apple CoreML could not load this model. Processing continues on the CPU.
+ Apple CoreML konnte dieses Modell nicht laden. Die Verarbeitung wird auf der CPU fortgesetzt.
+
+
+
+ NVIDIA CUDA could not load this model. Processing continues on the CPU.
+ NVIDIA CUDA konnte dieses Modell nicht laden. Die Verarbeitung wird auf der CPU fortgesetzt.
+
+
+
+ AMD ROCm could not load this model. Processing continues on the CPU.
+ AMD ROCm konnte dieses Modell nicht laden. Die Verarbeitung wird auf der CPU fortgesetzt.
+
+
+
+ AMD MIGraphX could not load this model. Processing continues on the CPU.
+ AMD MIGraphX konnte dieses Modell nicht laden. Die Verarbeitung wird auf der CPU fortgesetzt.
+
+
+
+ GPU over DirectML could not load this model. Processing continues on the CPU.
+ GPU over DirectML konnte dieses Modell nicht laden. Die Verarbeitung wird auf der CPU fortgesetzt.
+
+
+
+ RenderController
+
+
+ Preview recommended
+ Vorschau empfohlen
+
+
+
+ Preview this frame before rendering?
+ Vorschau dieses Bildes vor dem Rendern anzeigen?
+
+
+
+ A preview lets you verify the cutout before processing the whole video.
+ Mit einer Vorschau können Sie die Freistellung prüfen, bevor das gesamte Video verarbeitet wird.
+
+
+
+ Preview first
+ Zuerst Vorschau anzeigen
+
+
+
+ Render anyway
+ Trotzdem rendern
+
+
+
+ Cancel
+ Abbrechen
+
+
+
+ Matching cut set found
+ Passendes Schnittset gefunden
+
+
+
+ A validated cut set matches the current source and settings.
+ Ein validiertes Schnittset passt zur aktuellen Quelle und den Einstellungen.
+
+
+
+ Rebuild reuses the cuts and only reruns framing and encoding.
+ Der Neuaufbau verwendet die Schnitte wieder und führt nur Framing und Kodierung erneut aus.
+
+
+
+ Rebuild
+ Neu aufbauen
+
+
+
+ Regenerate
+ Neu generieren
+
+
+
+ Output already exists
+ Ausgabe ist bereits vorhanden
+
+
+
+ %s already exists.
+ %s ist bereits vorhanden.
+
+
+
+ Choose how to handle the existing output.
+ Wählen Sie, wie mit der vorhandenen Ausgabe verfahren werden soll.
+
+
+
+ Replace
+ Ersetzen
+
+
+
+ Choose another name
+ Anderen Namen auswählen
+
+
+
+ Choose output name
+ Ausgabenamen auswählen
+
+
+
+ WebP files (*.webp)
+ WebP-Dateien (*.webp)
+
+
+
+ ResultPlayerCanvas
+
+
+ Result
+ Ergebnis
+
+
+
+
+ Play
+ Wiedergeben
+
+
+
+
+ Play the result loop
+ Ergebniswiedergabe abspielen
+
+
+
+ Pause
+ Pause
+
+
+
+ Pause the result loop
+ Ergebniswiedergabe pausieren
+
+
+
+ Previewing the first %s of %s frames
+ Die ersten %s von %s Bildern werden in der Vorschau angezeigt
+
+
+
+ Cut frames could not be read
+ Schnittbilder konnten nicht gelesen werden
+
+
+
+ SettingsDialog
+
+
+ German
+ Deutsch
+
+
+
+ English
+ Englisch
+
+
+
+
+ Preferences
+ Einstellungen
+
+
+
+
+ Output directory
+ Ausgabeordner
+
+
+
+ Directory used for output files
+ Für Ausgabedateien verwendeter Ordner
+
+
+
+ Choose…
+ Auswählen…
+
+
+
+
+ Choose output directory
+ Ausgabeordner auswählen
+
+
+
+ Clear
+ Leeren
+
+
+
+ Clear output directory
+ Ausgabeordner leeren
+
+
+
+
+ Interface language
+ Oberflächensprache
+
+
+
+ Preferences actions
+ Einstellungsaktionen
+
+
+
+ Preferences description
+ Beschreibung der Einstellungen
+
+
+
+ Interface language changes apply after restarting MatteLoop.
+ Die Oberflächensprache wird nach einem Neustart von MatteLoop angewendet.
+
+
+
+ Language restart note
+ Hinweis zum Neustart für die Sprache
+
+
+
+ Output directory label
+ Ausgabeordner
+
+
+
+ Close preferences
+ Einstellungen schließen
+
+
+
+ Choose where rendered output files are saved.
+ Wählen Sie den Speicherort für gerenderte Ausgabedateien.
+
+
+
+ Output directory controls are disabled while a job is running.
+ Ausgabeordner-Steuerelemente sind während eines Auftrags deaktiviert.
+
+
+
+ SourceController
+
+
+ Open video
+ Video öffnen
+
+
+
+ Video files (*.mp4 *.mov *.webm *.mkv)
+ Videodateien (*.mp4 *.mov *.webm *.mkv)
+
+
+
+ Choose output directory
+ Ausgabeordner auswählen
+
+
+
+ SourceDropSurface
+
+
+ Video drop area
+ Video-Ablagebereich
+
+
+
+ Drop a video here
+ Legen Sie hier ein Video ab
+
+
+
+ Open a video
+ Video öffnen
+
+
+
+ Open Video…
+ Video öffnen…
+
+
+
+ Open Video
+ Video öffnen
+
+
+
+ SourceErrors
+
+
+ Open a video stored on this Mac.
+ Öffnen Sie ein auf diesem Mac gespeichertes Video.
+
+
+
+ Open a video file that can be opened and read.
+ Öffnen Sie eine Videodatei, die geöffnet und gelesen werden kann.
+
+
+
+ Open a file that contains a video track.
+ Öffnen Sie eine Datei mit einer Videospur.
+
+
+
+ Open another video file; this one appears damaged.
+ Öffnen Sie eine andere Videodatei; diese scheint beschädigt zu sein.
+
+
+
+ Open a video with a positive duration.
+ Öffnen Sie ein Video mit positiver Dauer.
+
+
+
+ Convert to 8-bit SDR and try again.
+ Konvertieren Sie das Video in 8-Bit-SDR und versuchen Sie es erneut.
+
+
+
+ Resize to between 8×8 and 3840×2160.
+ Ändern Sie die Größe auf einen Wert zwischen 8×8 und 3840×2160.
+
+
+
+ Convert the video to 60 fps or less.
+ Konvertieren Sie das Video auf höchstens 60 Bilder/s.
+
+
+
+ Open a video under 10 minutes.
+ Öffnen Sie ein Video mit weniger als 10 Minuten Dauer.
+
+
+
+ Open an MP4, MOV, WebM, or MKV video.
+ Öffnen Sie ein MP4-, MOV-, WebM- oder MKV-Video.
+
+
+
+ This video could not be read. Open another video.
+ Dieses Video konnte nicht gelesen werden. Öffnen Sie ein anderes Video.
+
+
+
+ SourcePresentation
+
+
+ %s remaining
+ %s verbleiben
+
+
+
+
+ %s fps
+ %s fps
+
+
+
+ %s/s
+ %s/s
+
+
+
+ %s of %s
+ %s von %s
+
+
+
+ Downloading %s
+ %s wird heruntergeladen
+
+
+
+ — %s
+ — %s
+
+
+
+ · %s
+ · %s
+
+
+
+ Reading video…
+ Video wird gelesen…
+
+
+
+ SourceStrip
+
+
+ Source video
+ Quellvideo
+
+
+
+ Open Video…
+ Video öffnen…
+
+
+
+ Open Video
+ Video öffnen
+
+
+
+ TimelineWidget
+
+
+ Video timeline
+ Videotimeline
+
+
+
+
+ Frame —
+ Bild —
+
+
+
+
+ IN — OUT —
+ IN — OUT —
+
+
+
+ Set IN
+ IN setzen
+
+
+
+ Set export start to playhead
+ Exportstart auf Abspielposition setzen
+
+
+
+ Set OUT
+ OUT setzen
+
+
+
+ Set export end to playhead
+ Exportende auf Abspielposition setzen
+
+
+
+ Reset Range
+ Bereich zurücksetzen
+
+
+
+ Reset export range
+ Exportbereich zurücksetzen
+
+
+
+ Timeline
+ Zeitleiste
+
+
+
+ Frame %s
+ Bild %s
+
+
+
+ IN %s OUT %s
+ IN %s OUT %s
+
+
+
+ Outside export range
+ Außerhalb des Exportbereichs
+
+
+
+ Preview frame playhead %s, %s; %s%s
+ Vorschau-Abspielposition %s, %s; %s%s
+
+
+
+ ; Outside export range
+ ; Außerhalb des Exportbereichs
+
+
+
+ TransformGroup
+
+
+ Free
+ Frei
+
+
+
+
+ First frame
+ Erstes Bild
+
+
+
+
+ Last frame
+ Letztes Bild
+
+
+
+ X
+ X
+
+
+
+ Y
+ Y
+
+
+
+ Width
+ Breite
+
+
+
+ Height
+ Höhe
+
+
+
+ Aspect
+ Seitenverhältnis
+
+
+
+ Percent
+ Prozent
+
+
+
+ Mismatch
+ Abweichung
+
+
+
+ Preset
+ Voreinstellung
+
+
+
+ Keep original aspect ratio
+ Originales Seitenverhältnis beibehalten
+
+
+
+ Stretch to fit
+ Auf Füllung strecken
+
+
+
+ Center and crop to fit
+ Zentrieren und passend zuschneiden
+
+
+
+ Add transparent padding
+ Transparente Ränder hinzufügen
+
+
+
+ Crop
+ Zuschnitt
+
+
+
+ Trim
+ Trimmen
+
+
+
+ Resize
+ Größe ändern
+
+
+
+ 1:1
+ 1:1
+
+
+
+ 2:1
+ 2:1
+
+
+
+ 3:1
+ 3:1
+
+
+
+ 4:3
+ 4:3
+
+
+
+ 16:9
+ 16:9
+
+
+
+ Transform
+ Transformation
+
+
+
+
+ Use Playhead
+ Abspielposition verwenden
+
+
+
+ Use playhead for first frame
+ Abspielposition für erstes Bild verwenden
+
+
+
+ Use playhead for last frame
+ Abspielposition für letztes Bild verwenden
+
+
+
+
+ Edit crop
+ Zuschnitt bearbeiten
+
+
+
+ Crop aspect ratio
+ Zuschnitt-Seitenverhältnis
+
+
+
+ Crop %s
+ Zuschnitt %s
+
+
+
+ Resize percentage
+ Größenänderung in Prozent
+
+
+
+ %
+ %
+
+
+
+ Aspect mismatch handling
+ Umgang mit abweichendem Seitenverhältnis
+
+
+
+ Output size preset
+ Ausgabegrößenvoreinstellung
+
+
+
+ Auto
+ Automatisch
+
+
+
+ Custom
+ Benutzerdefiniert
+
+
+
+ Transform summary
+ Zusammenfassung der Transformation
+
+
+
+ %n of %1 frames · %2 s · %3 px
+
+ %n von %1 Bildern · %2 s · %3 px
+ %n von %1 Bildern · %2 s · %3 px
+
+
+
+
+ · rendered %s px, %s
+ · gerendert %s px, %s
+
+
+
+ Set at least one of width or height.
+ Legen Sie mindestens Breite oder Höhe fest.
+
+
+
+ WorkspacePicker
+
+
+ Cut set is in use
+ Schnittset wird verwendet
+
+
+
+ This cut set is being used by a running job.
+ Dieses Schnittset wird von einem laufenden Auftrag verwendet.
+
+
+
+ Could not delete cut set
+ Schnittset konnte nicht gelöscht werden
+
+
+
+ Delete pinned cut set?
+ Angeheftetes Schnittset löschen?
+
+
+
+ This set is pinned. Delete it anyway?
+ Dieses Set ist angeheftet. Trotzdem löschen?
+
+
+
+ WorkspacePickerDialog
+
+
+
+
+ Promoted cut sets
+ Freigegebene Schnittsets
+
+
+
+ Use this set
+ Dieses Set verwenden
+
+
+
+ Use selected cut set
+ Ausgewähltes Schnittset verwenden
+
+
+
+ Open folder
+ Ordner öffnen
+
+
+
+ Open selected cut folder
+ Ordner des ausgewählten Schnittsets öffnen
+
+
+
+ Delete set
+ Set löschen
+
+
+
+ Delete selected cut set
+ Ausgewähltes Schnittset löschen
+
+
+
+ Close
+ Schließen
+
+
+
+ Choose a validated cut set for this output directory.
+ Wählen Sie ein validiertes Schnittset für diesen Ausgabeordner.
+
+
+
+ Could not read cut sets: %s
+ Schnittsets konnten nicht gelesen werden: %s
+
+
+
+ %n promoted cut set(s) in %1
+
+ %n freigegebenes Schnittset in %1
+ %n freigegebene Schnittsets in %1
+
+
+
+
+ No promoted cut sets found.
+ Keine freigegebenen Schnittsets gefunden.
+
+
+
+ WorkspacePresentation
+
+
+ edited
+ bearbeitet
+
+
+
+ pinned
+ angeheftet
+
+
+
+ unchanged
+ unverändert
+
+
+
+ %n frames
+
+ %n Bild
+ %n Bilder
+
+
+
+
+ %s fps
+ %s fps
+
+
+
+ %1–%2 · %3
+ %1–%2 · %3
+
+
+
+ %1; %2
+ %1; %2
+
+
+
+ %1; %2; %3; %4 at %5; %6; created %7; purpose: %8; licence: %9; %10
+ %1; %2; %3; %4 bei %5; %6; erstellt %7; Zweck: %8; Lizenz: %9; %10
+
+
+
+ unknown time
+ unbekannte Zeit
+
+
+
diff --git a/resources/matteloop_en.qm b/resources/matteloop_en.qm
new file mode 100644
index 0000000..ccc1d1f
Binary files /dev/null and b/resources/matteloop_en.qm differ
diff --git a/resources/matteloop_en.ts b/resources/matteloop_en.ts
new file mode 100644
index 0000000..d0ec6de
--- /dev/null
+++ b/resources/matteloop_en.ts
@@ -0,0 +1,2461 @@
+
+
+
+
+ ActionShelf
+
+
+
+ Preview Frame
+ Preview Frame
+
+
+
+
+ Render Video
+ Render Video
+
+
+
+
+ Preferences
+ Preferences
+
+
+
+ CropCanvas
+
+
+ Original video frame
+ Original video frame
+
+
+
+ Crop bounds: x %s, y %s, width %s, height %s source pixels; source %s × %s
+ Crop bounds: x %s, y %s, width %s, height %s source pixels; source %s × %s
+
+
+
+ Inspector
+
+
+ Segmentation
+ Segmentation
+
+
+
+ Time & Sampling
+ Time & Sampling
+
+
+
+ Crop & Cleanup
+ Crop & Cleanup
+
+
+
+ Transform
+ Transform
+
+
+
+ Output
+ Output
+
+
+
+ Workspace
+ Workspace
+
+
+
+
+
+ X
+ X
+
+
+
+
+
+ Y
+ Y
+
+
+
+
+
+ Width
+ Width
+
+
+
+
+
+ Height
+ Height
+
+
+
+ Ready
+ Ready
+
+
+
+ Downloading
+ Downloading
+
+
+
+
+ Not cached
+ Not cached
+
+
+
+ Model
+ Model
+
+
+
+
+ Compute acceleration
+ Compute acceleration
+
+
+
+
+ Edge treatment
+ Edge treatment
+
+
+
+
+ Output FPS
+ Output FPS
+
+
+
+
+ Start
+ Start
+
+
+
+
+ End
+ End
+
+
+
+
+ Duration
+ Duration
+
+
+
+ Alpha threshold
+ Alpha threshold
+
+
+
+ Padding
+ Padding
+
+
+
+
+ Horizontal stretch
+ Horizontal stretch
+
+
+
+ Directory
+ Directory
+
+
+
+ Filename
+ Filename
+
+
+
+ Maximum size
+ Maximum size
+
+
+
+ Processing settings
+ Processing settings
+
+
+
+ Retry Rebuild
+ Retry Rebuild
+
+
+
+ Edited cut recovery
+ Edited cut recovery
+
+
+
+ Edited cut frames could not be validated. Retry the rebuild scan.
+ Edited cut frames could not be validated. Retry the rebuild scan.
+
+
+
+
+ Rebuild from edited cuts
+ Rebuild from edited cuts
+
+
+
+
+ Manage Models…
+ Manage Models…
+
+
+
+
+ Manage Workspaces…
+ Manage Workspaces…
+
+
+
+ Segmentation model
+ Segmentation model
+
+
+
+ cached locally
+ cached locally
+
+
+
+ not cached yet
+ not cached yet
+
+
+
+ Model status
+ Model status
+
+
+
+ Standard
+ Standard
+
+
+
+ Decontaminate colors
+ Decontaminate colors
+
+
+
+ fps
+ fps
+
+
+
+ High output FPS may increase render cost
+ High output FPS may increase render cost
+
+
+
+ Output FPS cost warning
+ Output FPS cost warning
+
+
+
+ Global trim
+ Global trim
+
+
+
+ Global alpha trim
+ Global alpha trim
+
+
+
+ Padding pixels
+ Padding pixels
+
+
+
+ Output directory
+ Output directory
+
+
+
+ Choose…
+ Choose…
+
+
+
+ Choose output directory
+ Choose output directory
+
+
+
+ Output filename
+ Output filename
+
+
+
+
+ Use one non-empty filename ending in .webp; path separators are not allowed.
+ Use one non-empty filename ending in .webp; path separators are not allowed.
+
+
+
+ Maximum file size in MiB
+ Maximum file size in MiB
+
+
+
+ MiB
+ MiB
+
+
+
+ Filename must be a single non-empty .webp filename.
+ Filename must be a single non-empty .webp filename.
+
+
+
+ s
+ s
+
+
+
+ Crop
+ Crop
+
+
+
+ Crop overlay
+ Crop overlay
+
+
+
+ Reset Crop
+ Reset Crop
+
+
+
+ Reset crop
+ Reset crop
+
+
+
+ Crop %s
+ Crop %s
+
+
+
+ Model status: %s
+ Model status: %s
+
+
+
+ InspectorDisclosure
+
+
+ expanded
+ expanded
+
+
+
+ collapsed
+ collapsed
+
+
+
+ MainWindow
+
+
+ MatteLoop
+ MatteLoop
+
+
+
+ Couldn’t read this video
+ Couldn’t read this video
+
+
+
+ Video load error
+ Video load error
+
+
+
+ Render complete
+ Render complete
+
+
+
+ Open output
+ Open output
+
+
+
+ Open folder
+ Open folder
+
+
+
+ ModelCopy
+
+
+ U²-Net
+ U²-Net
+
+
+
+ U²-Net P
+ U²-Net P
+
+
+
+ U²-Net Human Segmentation
+ U²-Net Human Segmentation
+
+
+
+ U²-Net Cloth Segmentation
+ U²-Net Cloth Segmentation
+
+
+
+ Silueta
+ Silueta
+
+
+
+ IS-Net General Use
+ IS-Net General Use
+
+
+
+ IS-Net Anime
+ IS-Net Anime
+
+
+
+ BiRefNet General
+ BiRefNet General
+
+
+
+ BiRefNet General Lite
+ BiRefNet General Lite
+
+
+
+ BiRefNet Portrait
+ BiRefNet Portrait
+
+
+
+ BiRefNet DIS
+ BiRefNet DIS
+
+
+
+ BiRefNet HRSOD
+ BiRefNet HRSOD
+
+
+
+ BiRefNet COD
+ BiRefNet COD
+
+
+
+ BiRefNet Massive
+ BiRefNet Massive
+
+
+
+ BRIA RMBG 2.0
+ BRIA RMBG 2.0
+
+
+
+ General-purpose foreground extraction.
+ General-purpose foreground extraction.
+
+
+
+ Small, faster general-purpose foreground extraction.
+ Small, faster general-purpose foreground extraction.
+
+
+
+ Human-focused foreground extraction.
+ Human-focused foreground extraction.
+
+
+
+ Clothing-focused segmentation.
+ Clothing-focused segmentation.
+
+
+
+ Compact general-purpose foreground extraction.
+ Compact general-purpose foreground extraction.
+
+
+
+ General salient-object segmentation.
+ General salient-object segmentation.
+
+
+
+ Anime and illustration foreground extraction.
+ Anime and illustration foreground extraction.
+
+
+
+ High-quality general foreground extraction.
+ High-quality general foreground extraction.
+
+
+
+ Smaller BiRefNet general foreground extraction.
+ Smaller BiRefNet general foreground extraction.
+
+
+
+ High-quality portrait and hair foreground extraction.
+ High-quality portrait and hair foreground extraction.
+
+
+
+ Dichotomous image segmentation.
+ Dichotomous image segmentation.
+
+
+
+ High-resolution salient-object detection.
+ High-resolution salient-object detection.
+
+
+
+ Camouflaged-object detection.
+ Camouflaged-object detection.
+
+
+
+ Broad high-capacity foreground extraction.
+ Broad high-capacity foreground extraction.
+
+
+
+ High-quality general background removal.
+ High-quality general background removal.
+
+
+
+ Model terms are provided by the upstream U²-Net project; review them before redistribution.
+ Model terms are provided by the upstream U²-Net project; review them before redistribution.
+
+
+
+ Review the Silueta model's upstream terms before redistribution.
+ Review the Silueta model's upstream terms before redistribution.
+
+
+
+ Review the IS-Net model's upstream terms before redistribution.
+ Review the IS-Net model's upstream terms before redistribution.
+
+
+
+ Review the IS-Net Anime model's upstream terms before redistribution.
+ Review the IS-Net Anime model's upstream terms before redistribution.
+
+
+
+ Review the BiRefNet model's upstream terms before redistribution or commercial use.
+ Review the BiRefNet model's upstream terms before redistribution or commercial use.
+
+
+
+ BRIA RMBG 2.0 has model-specific license terms; commercial use requires checking and satisfying BRIA's current license.
+ BRIA RMBG 2.0 has model-specific license terms; commercial use requires checking and satisfying BRIA's current license.
+
+
+
+ ModelEntries
+
+
+ cached locally
+ cached locally
+
+
+
+ ; outdated copy from rembg %s
+ ; outdated copy from rembg %s
+
+
+
+ outdated weight
+ outdated weight
+
+
+
+ obsolete rembg
+ obsolete rembg
+
+
+
+ %s from rembg %s; %s on disk
+ %s from rembg %s; %s on disk
+
+
+
+ not cached
+ not cached
+
+
+
+ active model
+ active model
+
+
+
+ not active
+ not active
+
+
+
+ %s; %s; %s; %s
+ %s; %s; %s; %s
+
+
+
+ ModelManagerController
+
+
+ Download %n model weight(s) (%1)? Each outdated copy from rembg %2 is deleted once its replacement has been verified. Other files in the %2 %3 stay until you use Delete outdated.
+
+ Download %n model weight (%1)? Each outdated copy from rembg %2 is deleted once its replacement has been verified. Other files in the %2 %3 stay until you use Delete outdated.
+ Download %n model weights (%1)? Each outdated copy from rembg %2 is deleted once its replacement has been verified. Other files in the %2 %3 stay until you use Delete outdated.
+
+
+
+
+ Re-downloading %n outdated model weight(s)…
+
+ Re-downloading %n outdated model weight…
+ Re-downloading %n outdated model weights…
+
+
+
+
+ %s Re-downloaded %n of %1 outdated model weight(s).
+
+ %s Re-downloaded %n of %1 outdated model weight.
+ %s Re-downloaded %n of %1 outdated model weights.
+
+
+
+
+ Download cancelled. Re-downloaded %n of %1 outdated model weight(s).
+
+ Download cancelled. Re-downloaded %n of %1 outdated model weight.
+ Download cancelled. Re-downloaded %n of %1 outdated model weights.
+
+
+
+
+ Re-downloaded %n outdated model weight(s).
+
+ Re-downloaded %n outdated model weight.
+ Re-downloaded %n outdated model weights.
+
+
+
+
+ Removed %n outdated model version(s) from the cache.
+
+ Removed %n outdated model version from the cache.
+ Removed %n outdated model versions from the cache.
+
+
+
+
+ ModelManagerDialog
+
+
+ directory
+ directory
+
+
+
+ directories
+ directories
+
+
+
+ Re-download outdated model weights?
+ Re-download outdated model weights?
+
+
+
+
+ Cannot remove a model while a job is running.
+ Cannot remove a model while a job is running.
+
+
+
+
+
+ Model removal is unavailable in this runtime.
+ Model removal is unavailable in this runtime.
+
+
+
+
+ Cannot download a model while a job is running.
+ Cannot download a model while a job is running.
+
+
+
+ Downloading %s…
+ Downloading %s…
+
+
+
+ Delete all weights from rembg %s?
+ Delete all weights from rembg %s?
+
+
+
+ This removes the whole %s %s: %s on disk. Weights this version needs are downloaded again on demand.
+ This removes the whole %s %s: %s on disk. Weights this version needs are downloaded again on demand.
+
+
+
+ Removing outdated model weights…
+ Removing outdated model weights…
+
+
+
+ (%s of %s)
+ (%s of %s)
+
+
+
+ Downloading %s%s…
+ Downloading %s%s…
+
+
+
+ Downloaded %s%s.
+ Downloaded %s%s.
+
+
+
+ Could not download %s: %s.
+ Could not download %s: %s.
+
+
+
+ Downloaded %s.
+ Downloaded %s.
+
+
+
+ Download cancelled.
+ Download cancelled.
+
+
+
+ Could not download the weight: %s
+ Could not download the weight: %s
+
+
+
+ Could not download model
+ Could not download model
+
+
+
+ Could not remove outdated weights: %s: %s
+ Could not remove outdated weights: %s: %s
+
+
+
+ Remove model weight?
+ Remove model weight?
+
+
+
+ Remove %s's downloaded weight?
+This frees %s.
+ Remove %s's downloaded weight?
+This frees %s.
+
+
+
+ No weight could be removed at %s.
+ No weight could be removed at %s.
+
+
+
+ Removed %s; freed %s.
+ Removed %s; freed %s.
+
+
+
+ Model removed
+ Model removed
+
+
+
+ Removed %s's downloaded weight.
+Freed %s.
+ Removed %s's downloaded weight.
+Freed %s.
+
+
+
+ Could not remove the selected weight: %s
+ Could not remove the selected weight: %s
+
+
+
+ Could not remove model
+ Could not remove model
+
+
+
+ Could not open the model folder: %s
+ Could not open the model folder: %s
+
+
+
+ Model folder: %s
+ Model folder: %s
+
+
+
+
+ Model manager
+ Model manager
+
+
+
+ Total model cache size
+ Total model cache size
+
+
+
+ Model cache location
+ Model cache location
+
+
+
+ V1 models
+ V1 models
+
+
+
+ Download weight
+ Download weight
+
+
+
+ Download selected model weight
+ Download selected model weight
+
+
+
+ Remove downloaded weight
+ Remove downloaded weight
+
+
+
+ Remove selected model weight
+ Remove selected model weight
+
+
+
+ Re-download outdated
+ Re-download outdated
+
+
+
+ Re-download outdated model weights
+ Re-download outdated model weights
+
+
+
+ Delete outdated
+ Delete outdated
+
+
+
+ Delete outdated model weights
+ Delete outdated model weights
+
+
+
+ Show cache location
+ Show cache location
+
+
+
+ Show model cache location
+ Show model cache location
+
+
+
+ Close
+ Close
+
+
+
+ Close model manager
+ Close model manager
+
+
+
+
+
+ Cancel
+ Cancel
+
+
+
+ Cancel the running model download
+ Cancel the running model download
+
+
+
+ Manage the downloaded V1 model weights.
+ Manage the downloaded V1 model weights.
+
+
+
+
+ Cancelling…
+ Cancelling…
+
+
+
+ Cannot remove the model used by the active session.
+ Cannot remove the model used by the active session.
+
+
+
+ Total on disk: %s
+ Total on disk: %s
+
+
+
+ %n V1 model(s); cache: %1
+
+ %n V1 model; cache: %1
+ %n V1 models; cache: %1
+
+
+
+
+ Outdated weights from rembg %s occupy %s on disk and cannot be used by this version.
+ Outdated weights from rembg %s occupy %s on disk and cannot be used by this version.
+
+
+
+ Remove the selected downloaded weight and free its disk space.
+ Remove the selected downloaded weight and free its disk space.
+
+
+
+ Download the selected weight now instead of waiting for a preview.
+ Download the selected weight now instead of waiting for a preview.
+
+
+
+ Presenter
+
+
+ Preview this frame to inspect the cutout
+ Preview this frame to inspect the cutout
+
+
+
+ Reading video…
+ Reading video…
+
+
+
+ This video could not be read. Open another video.
+ This video could not be read. Open another video.
+
+
+
+
+ Preview failed
+ Preview failed
+
+
+
+ retry Preview Frame
+ retry Preview Frame
+
+
+
+ Current preview — previewing selected frame
+ Current preview — previewing selected frame
+
+
+
+ Previewing selected frame
+ Previewing selected frame
+
+
+
+ Current preview
+ Current preview
+
+
+
+ preview again
+ preview again
+
+
+
+ Settings changed — preview again
+ Settings changed — preview again
+
+
+
+ Download required
+ Download required
+
+
+
+ Model preview — rebuild uses edited cut frames
+ Model preview — rebuild uses edited cut frames
+
+
+
+ Edited cuts changed
+ Edited cuts changed
+
+
+
+ Prepare & Preview
+ Prepare & Preview
+
+
+
+ Preview Frame
+ Preview Frame
+
+
+
+ Open another video
+ Open another video
+
+
+
+ Drop a video here
+ Drop a video here
+
+
+
+ Background-removed result
+ Background-removed result
+
+
+
+ Retry Rebuild
+ Retry Rebuild
+
+
+
+ Rebuild from edited cuts
+ Rebuild from edited cuts
+
+
+
+ Render complete
+ Render complete
+
+
+
+ Preview failed: %s
+ Preview failed: %s
+
+
+
+ Preview failed — %s
+ Preview failed — %s
+
+
+
+ Segmentation
+ Segmentation
+
+
+
+ Compute acceleration
+ Compute acceleration
+
+
+
+ Sampling
+ Sampling
+
+
+
+ Crop & cleanup
+ Crop & cleanup
+
+
+
+ Crop
+ Crop
+
+
+
+ Framing
+ Framing
+
+
+
+ Playhead
+ Playhead
+
+
+
+ Export range
+ Export range
+
+
+
+ Edited cuts
+ Edited cuts
+
+
+
+ PreviewCanvas
+
+
+ Original video frame
+ Original video frame
+
+
+
+ Background-removed result
+ Background-removed result
+
+
+
+ Original
+ Original
+
+
+
+ PreviewJobDialog
+
+
+ Rebuilding from edited cuts
+ Rebuilding from edited cuts
+
+
+
+ Rendering video
+ Rendering video
+
+
+
+
+ Previewing selected frame
+ Previewing selected frame
+
+
+
+
+
+ Preparing model
+ Preparing model
+
+
+
+ Downloading model
+ Downloading model
+
+
+
+ Segmentation
+ Segmentation
+
+
+
+ Decode
+ Decode
+
+
+
+ Validation
+ Validation
+
+
+
+ Post-process
+ Post-process
+
+
+
+ Auto-fit
+ Auto-fit
+
+
+
+ Encode
+ Encode
+
+
+
+ Validate
+ Validate
+
+
+
+
+ Complete
+ Complete
+
+
+
+
+ Preview job
+ Preview job
+
+
+
+ Active model and provider
+ Active model and provider
+
+
+
+ Output file
+ Output file
+
+
+
+ Provider notice
+ Provider notice
+
+
+
+
+ Stage progress
+ Stage progress
+
+
+
+
+ Overall progress
+ Overall progress
+
+
+
+
+ Cancel
+ Cancel
+
+
+
+ Render completion summary
+ Render completion summary
+
+
+
+ Output dimensions
+ Output dimensions
+
+
+
+ Output frame count
+ Output frame count
+
+
+
+
+ Animation duration
+ Animation duration
+
+
+
+ Output file size
+ Output file size
+
+
+
+ Output frame rate
+ Output frame rate
+
+
+
+ Render duration
+ Render duration
+
+
+
+ Cut segmentation
+ Cut segmentation
+
+
+
+ Dimensions
+ Dimensions
+
+
+
+ Frames
+ Frames
+
+
+
+ File size
+ File size
+
+
+
+ Output FPS
+ Output FPS
+
+
+
+ Job time
+ Job time
+
+
+
+ Cuts
+ Cuts
+
+
+
+
+ Open output
+ Open output
+
+
+
+
+ Open folder
+ Open folder
+
+
+
+
+ Close
+ Close
+
+
+
+
+ Stage progress (indeterminate)
+ Stage progress (indeterminate)
+
+
+
+
+ Overall progress (indeterminate)
+ Overall progress (indeterminate)
+
+
+
+
+ Output: %s
+ Output: %s
+
+
+
+ · %s
+ · %s
+
+
+
+
+ %v / %m frames
+ %v / %m frames
+
+
+
+ %v / %m
+ %v / %m
+
+
+
+
+ Cancelling…
+ Cancelling…
+
+
+
+ Waiting for the current safe checkpoint…
+ Waiting for the current safe checkpoint…
+
+
+
+
+ Render complete
+ Render complete
+
+
+
+ Output is ready
+ Output is ready
+
+
+
+
+
+
+
+
+ —
+ —
+
+
+
+ Reused existing cuts
+ Reused existing cuts
+
+
+
+ Fresh segmentation
+ Fresh segmentation
+
+
+
+ Elapsed %s
+ Elapsed %s
+
+
+
+ %n frames
+
+ %n frame
+ %n frames
+
+
+
+
+ PreviewStage
+
+
+ Original
+ Original
+
+
+
+ ProgressCopy
+
+
+ Promoting cut frames
+ Promoting cut frames
+
+
+
+ Validating cut set
+ Validating cut set
+
+
+
+ Validating cut snapshot
+ Validating cut snapshot
+
+
+
+ Validating encoded output
+ Validating encoded output
+
+
+
+ Starting segmentation session
+ Starting segmentation session
+
+
+
+ Using cached model weights
+ Using cached model weights
+
+
+
+ Reusing prepared session
+ Reusing prepared session
+
+
+
+ Frame %1 of %2
+ Frame %1 of %2
+
+
+
+ Cut frame %1 of %2
+ Cut frame %1 of %2
+
+
+
+ ProviderCopy
+
+
+
+ CPU
+ CPU
+
+
+
+ Core ML
+ Core ML
+
+
+
+ CUDA
+ CUDA
+
+
+
+ ROCm
+ ROCm
+
+
+
+ MIGraphX
+ MIGraphX
+
+
+
+ DirectML
+ DirectML
+
+
+
+ Apple CoreML
+ Apple CoreML
+
+
+
+ NVIDIA CUDA
+ NVIDIA CUDA
+
+
+
+ AMD ROCm
+ AMD ROCm
+
+
+
+ AMD MIGraphX
+ AMD MIGraphX
+
+
+
+ GPU over DirectML
+ GPU over DirectML
+
+
+
+ Apple CoreML – experimental
+ Apple CoreML – experimental
+
+
+
+ CPU – recommended
+ CPU – recommended
+
+
+
+ Apple CoreML – recommended
+ Apple CoreML – recommended
+
+
+
+ NVIDIA CUDA – recommended
+ NVIDIA CUDA – recommended
+
+
+
+ AMD ROCm – recommended
+ AMD ROCm – recommended
+
+
+
+ AMD MIGraphX – recommended
+ AMD MIGraphX – recommended
+
+
+
+ GPU over DirectML – recommended
+ GPU over DirectML – recommended
+
+
+
+ Apple CoreML could not load this model. Processing continues on the CPU.
+ Apple CoreML could not load this model. Processing continues on the CPU.
+
+
+
+ NVIDIA CUDA could not load this model. Processing continues on the CPU.
+ NVIDIA CUDA could not load this model. Processing continues on the CPU.
+
+
+
+ AMD ROCm could not load this model. Processing continues on the CPU.
+ AMD ROCm could not load this model. Processing continues on the CPU.
+
+
+
+ AMD MIGraphX could not load this model. Processing continues on the CPU.
+ AMD MIGraphX could not load this model. Processing continues on the CPU.
+
+
+
+ GPU over DirectML could not load this model. Processing continues on the CPU.
+ GPU over DirectML could not load this model. Processing continues on the CPU.
+
+
+
+ RenderController
+
+
+ Preview recommended
+ Preview recommended
+
+
+
+ Preview this frame before rendering?
+ Preview this frame before rendering?
+
+
+
+ A preview lets you verify the cutout before processing the whole video.
+ A preview lets you verify the cutout before processing the whole video.
+
+
+
+ Preview first
+ Preview first
+
+
+
+ Render anyway
+ Render anyway
+
+
+
+ Cancel
+ Cancel
+
+
+
+ Matching cut set found
+ Matching cut set found
+
+
+
+ A validated cut set matches the current source and settings.
+ A validated cut set matches the current source and settings.
+
+
+
+ Rebuild reuses the cuts and only reruns framing and encoding.
+ Rebuild reuses the cuts and only reruns framing and encoding.
+
+
+
+ Rebuild
+ Rebuild
+
+
+
+ Regenerate
+ Regenerate
+
+
+
+ Output already exists
+ Output already exists
+
+
+
+ %s already exists.
+ %s already exists.
+
+
+
+ Choose how to handle the existing output.
+ Choose how to handle the existing output.
+
+
+
+ Replace
+ Replace
+
+
+
+ Choose another name
+ Choose another name
+
+
+
+ Choose output name
+ Choose output name
+
+
+
+ WebP files (*.webp)
+ WebP files (*.webp)
+
+
+
+ ResultPlayerCanvas
+
+
+ Result
+ Result
+
+
+
+
+ Play
+ Play
+
+
+
+
+ Play the result loop
+ Play the result loop
+
+
+
+ Pause
+ Pause
+
+
+
+ Pause the result loop
+ Pause the result loop
+
+
+
+ Previewing the first %s of %s frames
+ Previewing the first %s of %s frames
+
+
+
+ Cut frames could not be read
+ Cut frames could not be read
+
+
+
+ SettingsDialog
+
+
+ German
+ German
+
+
+
+ English
+ English
+
+
+
+
+ Preferences
+ Preferences
+
+
+
+
+ Output directory
+ Output directory
+
+
+
+ Directory used for output files
+ Directory used for output files
+
+
+
+ Choose…
+ Choose…
+
+
+
+
+ Choose output directory
+ Choose output directory
+
+
+
+ Clear
+ Clear
+
+
+
+ Clear output directory
+ Clear output directory
+
+
+
+
+ Interface language
+ Interface language
+
+
+
+ Preferences actions
+ Preferences actions
+
+
+
+ Preferences description
+ Preferences description
+
+
+
+ Interface language changes apply after restarting MatteLoop.
+ Interface language changes apply after restarting MatteLoop.
+
+
+
+ Language restart note
+ Language restart note
+
+
+
+ Output directory label
+ Output directory label
+
+
+
+ Close preferences
+ Close preferences
+
+
+
+ Choose where rendered output files are saved.
+ Choose where rendered output files are saved.
+
+
+
+ Output directory controls are disabled while a job is running.
+ Output directory controls are disabled while a job is running.
+
+
+
+ SourceController
+
+
+ Open video
+ Open video
+
+
+
+ Video files (*.mp4 *.mov *.webm *.mkv)
+ Video files (*.mp4 *.mov *.webm *.mkv)
+
+
+
+ Choose output directory
+ Choose output directory
+
+
+
+ SourceDropSurface
+
+
+ Video drop area
+ Video drop area
+
+
+
+ Drop a video here
+ Drop a video here
+
+
+
+ Open a video
+ Open a video
+
+
+
+ Open Video…
+ Open Video…
+
+
+
+ Open Video
+ Open Video
+
+
+
+ SourceErrors
+
+
+ Open a video stored on this Mac.
+ Open a video stored on this Mac.
+
+
+
+ Open a video file that can be opened and read.
+ Open a video file that can be opened and read.
+
+
+
+ Open a file that contains a video track.
+ Open a file that contains a video track.
+
+
+
+ Open another video file; this one appears damaged.
+ Open another video file; this one appears damaged.
+
+
+
+ Open a video with a positive duration.
+ Open a video with a positive duration.
+
+
+
+ Convert to 8-bit SDR and try again.
+ Convert to 8-bit SDR and try again.
+
+
+
+ Resize to between 8×8 and 3840×2160.
+ Resize to between 8×8 and 3840×2160.
+
+
+
+ Convert the video to 60 fps or less.
+ Convert the video to 60 fps or less.
+
+
+
+ Open a video under 10 minutes.
+ Open a video under 10 minutes.
+
+
+
+ Open an MP4, MOV, WebM, or MKV video.
+ Open an MP4, MOV, WebM, or MKV video.
+
+
+
+ This video could not be read. Open another video.
+ This video could not be read. Open another video.
+
+
+
+ SourcePresentation
+
+
+ %s remaining
+ %s remaining
+
+
+
+
+ %s fps
+ %s fps
+
+
+
+ %s/s
+ %s/s
+
+
+
+ %s of %s
+ %s of %s
+
+
+
+ Downloading %s
+ Downloading %s
+
+
+
+ — %s
+ — %s
+
+
+
+ · %s
+ · %s
+
+
+
+ Reading video…
+ Reading video…
+
+
+
+ SourceStrip
+
+
+ Source video
+ Source video
+
+
+
+ Open Video…
+ Open Video…
+
+
+
+ Open Video
+ Open Video
+
+
+
+ TimelineWidget
+
+
+ Video timeline
+ Video timeline
+
+
+
+
+ Frame —
+ Frame —
+
+
+
+
+ IN — OUT —
+ IN — OUT —
+
+
+
+ Set IN
+ Set IN
+
+
+
+ Set export start to playhead
+ Set export start to playhead
+
+
+
+ Set OUT
+ Set OUT
+
+
+
+ Set export end to playhead
+ Set export end to playhead
+
+
+
+ Reset Range
+ Reset Range
+
+
+
+ Reset export range
+ Reset export range
+
+
+
+ Timeline
+ Timeline
+
+
+
+ Frame %s
+ Frame %s
+
+
+
+ IN %s OUT %s
+ IN %s OUT %s
+
+
+
+ Outside export range
+ Outside export range
+
+
+
+ Preview frame playhead %s, %s; %s%s
+ Preview frame playhead %s, %s; %s%s
+
+
+
+ ; Outside export range
+ ; Outside export range
+
+
+
+ TransformGroup
+
+
+ Free
+ Free
+
+
+
+
+ First frame
+ First frame
+
+
+
+
+ Last frame
+ Last frame
+
+
+
+ X
+ X
+
+
+
+ Y
+ Y
+
+
+
+ Width
+ Width
+
+
+
+ Height
+ Height
+
+
+
+ Aspect
+ Aspect
+
+
+
+ Percent
+ Percent
+
+
+
+ Mismatch
+ Mismatch
+
+
+
+ Preset
+ Preset
+
+
+
+ Keep original aspect ratio
+ Keep original aspect ratio
+
+
+
+ Stretch to fit
+ Stretch to fit
+
+
+
+ Center and crop to fit
+ Center and crop to fit
+
+
+
+ Add transparent padding
+ Add transparent padding
+
+
+
+ Crop
+ Crop
+
+
+
+ Trim
+ Trim
+
+
+
+ Resize
+ Resize
+
+
+
+ 1:1
+ 1:1
+
+
+
+ 2:1
+ 2:1
+
+
+
+ 3:1
+ 3:1
+
+
+
+ 4:3
+ 4:3
+
+
+
+ 16:9
+ 16:9
+
+
+
+ Transform
+ Transform
+
+
+
+
+ Use Playhead
+ Use Playhead
+
+
+
+ Use playhead for first frame
+ Use playhead for first frame
+
+
+
+ Use playhead for last frame
+ Use playhead for last frame
+
+
+
+
+ Edit crop
+ Edit crop
+
+
+
+ Crop aspect ratio
+ Crop aspect ratio
+
+
+
+ Crop %s
+ Crop %s
+
+
+
+ Resize percentage
+ Resize percentage
+
+
+
+ %
+ %
+
+
+
+ Aspect mismatch handling
+ Aspect mismatch handling
+
+
+
+ Output size preset
+ Output size preset
+
+
+
+ Auto
+ Auto
+
+
+
+ Custom
+ Custom
+
+
+
+ Transform summary
+ Transform summary
+
+
+
+ %n of %1 frames · %2 s · %3 px
+
+ %n of %1 frames · %2 s · %3 px
+ %n of %1 frames · %2 s · %3 px
+
+
+
+
+ · rendered %s px, %s
+ · rendered %s px, %s
+
+
+
+ Set at least one of width or height.
+ Set at least one of width or height.
+
+
+
+ WorkspacePicker
+
+
+ Cut set is in use
+ Cut set is in use
+
+
+
+ This cut set is being used by a running job.
+ This cut set is being used by a running job.
+
+
+
+ Could not delete cut set
+ Could not delete cut set
+
+
+
+ Delete pinned cut set?
+ Delete pinned cut set?
+
+
+
+ This set is pinned. Delete it anyway?
+ This set is pinned. Delete it anyway?
+
+
+
+ WorkspacePickerDialog
+
+
+
+
+ Promoted cut sets
+ Promoted cut sets
+
+
+
+ Use this set
+ Use this set
+
+
+
+ Use selected cut set
+ Use selected cut set
+
+
+
+ Open folder
+ Open folder
+
+
+
+ Open selected cut folder
+ Open selected cut folder
+
+
+
+ Delete set
+ Delete set
+
+
+
+ Delete selected cut set
+ Delete selected cut set
+
+
+
+ Close
+ Close
+
+
+
+ Choose a validated cut set for this output directory.
+ Choose a validated cut set for this output directory.
+
+
+
+ Could not read cut sets: %s
+ Could not read cut sets: %s
+
+
+
+ %n promoted cut set(s) in %1
+
+ %n promoted cut set in %1
+ %n promoted cut sets in %1
+
+
+
+
+ No promoted cut sets found.
+ No promoted cut sets found.
+
+
+
+ WorkspacePresentation
+
+
+ edited
+ edited
+
+
+
+ pinned
+ pinned
+
+
+
+ unchanged
+ unchanged
+
+
+
+ %n frames
+
+ %n frame
+ %n frames
+
+
+
+
+ %s fps
+ %s fps
+
+
+
+ %1–%2 · %3
+ %1–%2 · %3
+
+
+
+ %1; %2
+ %1; %2
+
+
+
+ %1; %2; %3; %4 at %5; %6; created %7; purpose: %8; licence: %9; %10
+ %1; %2; %3; %4 at %5; %6; created %7; purpose: %8; licence: %9; %10
+
+
+
+ unknown time
+ unknown time
+
+
+
diff --git a/scripts/guardrails-baseline.json b/scripts/guardrails-baseline.json
index 56a3fac..b5e8b93 100644
--- a/scripts/guardrails-baseline.json
+++ b/scripts/guardrails-baseline.json
@@ -28,11 +28,11 @@
"long_functions": 11
},
"src/matteloop/jobs/segmentation_host.py": {
- "lines": 1875,
+ "lines": 1874,
"long_functions": 8
},
"src/matteloop/jobs/source.py": {
- "lines": 1710,
+ "lines": 1708,
"long_functions": 5
},
"src/matteloop/jobs/thumbnails.py": {
@@ -80,11 +80,19 @@
"long_functions": 1
},
"src/matteloop/ui/main_window.py": {
- "lines": 380,
+ "lines": 328,
"long_functions": 1
},
"src/matteloop/ui/presenter.py": {
- "lines": 179,
+ "lines": 176,
"long_functions": 1
+ },
+ "src/matteloop/ui/inspector.py": {
+ "lines": 897,
+ "long_functions": 0
+ },
+ "src/matteloop/ui/timeline.py": {
+ "lines": 842,
+ "long_functions": 0
}
}
diff --git a/src/matteloop/app.py b/src/matteloop/app.py
index a3de375..c987d54 100644
--- a/src/matteloop/app.py
+++ b/src/matteloop/app.py
@@ -36,6 +36,11 @@ def _run_gui() -> int:
from matteloop.core.state import AppState, ModelAvailabilityChanged
from matteloop.logs import configure_logging
from matteloop.ui.controller import SourceController
+ from matteloop.ui.i18n import (
+ configure_locale,
+ install_translators,
+ selected_language,
+ )
from matteloop.ui.main_window import MainWindow
from matteloop.ui.preferences import load_parameters
from matteloop.ui.store import ReducerStore
@@ -49,8 +54,11 @@ def _run_gui() -> int:
application.setApplicationDisplayName("MatteLoop")
application.setOrganizationName("MatteLoop")
application.setApplicationVersion(__version__)
- install_theme(application)
settings = QSettings()
+ language = selected_language(settings)
+ configure_locale(language)
+ _translators = install_translators(application, language)
+ install_theme(application)
stored_model = settings.value("parameters/model_id")
model_id = stored_model if stored_model in V1_MODEL_IDS else "birefnet-portrait"
_log_runtime_diagnostics()
diff --git a/src/matteloop/ui/action_shelf.py b/src/matteloop/ui/action_shelf.py
index 95372e5..dad04e2 100644
--- a/src/matteloop/ui/action_shelf.py
+++ b/src/matteloop/ui/action_shelf.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from PySide6.QtCore import QRectF, QSize, Qt
+from PySide6.QtCore import QCoreApplication, QRectF, QSettings, QSize, Qt
from PySide6.QtGui import QColor, QIcon, QKeySequence, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QFrame,
@@ -44,6 +44,8 @@ def __init__(
store: StateStore,
services: WindowServices,
parent: QWidget | None = None,
+ *,
+ settings: QSettings | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("action_shelf")
@@ -51,16 +53,28 @@ def __init__(
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 12)
row = QHBoxLayout()
- self.preview_button = QPushButton("Preview Frame")
+ self.preview_button = QPushButton(
+ QCoreApplication.translate("ActionShelf", "Preview Frame")
+ )
self.preview_button.setObjectName("preview_action")
- self.preview_button.setAccessibleName("Preview Frame")
- self.render_button = QPushButton("Render Video")
+ self.preview_button.setAccessibleName(
+ QCoreApplication.translate("ActionShelf", "Preview Frame")
+ )
+ self.render_button = QPushButton(
+ QCoreApplication.translate("ActionShelf", "Render Video")
+ )
self.render_button.setObjectName("render_action")
- self.render_button.setAccessibleName("Render Video")
+ self.render_button.setAccessibleName(
+ QCoreApplication.translate("ActionShelf", "Render Video")
+ )
self.preferences_button = QPushButton()
self.preferences_button.setObjectName("preferences_action")
- self.preferences_button.setAccessibleName("Preferences")
- self.preferences_button.setToolTip("Preferences")
+ self.preferences_button.setAccessibleName(
+ QCoreApplication.translate("ActionShelf", "Preferences")
+ )
+ self.preferences_button.setToolTip(
+ QCoreApplication.translate("ActionShelf", "Preferences")
+ )
self.preferences_button.setIcon(_gear_icon())
self.preferences_button.setIconSize(QSize(20, 20))
self.preferences_button.setFixedWidth(48)
@@ -79,7 +93,9 @@ def __init__(
layout.addLayout(row)
self.setTabOrder(self.preview_button, self.render_button)
self.setTabOrder(self.render_button, self.preferences_button)
- self.preferences_dialog = SettingsDialog(store, services, self)
+ self.preferences_dialog = SettingsDialog(
+ store, services, self, settings=settings
+ )
self.preferences_button.clicked.connect(self.open_preferences)
def open_preferences(self) -> None:
diff --git a/src/matteloop/ui/controller.py b/src/matteloop/ui/controller.py
index 5565552..e168ff0 100644
--- a/src/matteloop/ui/controller.py
+++ b/src/matteloop/ui/controller.py
@@ -75,7 +75,6 @@
from matteloop.ui.transform_stage import TransformStageController
from matteloop.ui.worker_thread import WorkerThread
-VIDEO_FILE_FILTER = "Video files (*.mp4 *.mov *.webm *.mkv)"
_THREAD_SHUTDOWN_TIMEOUT_MS = 5000
@@ -365,12 +364,11 @@ def _build_model_manager(self) -> ModelManagerController:
def _choose_video(self, replace: bool) -> None:
del replace
- caption = "Open video"
filename, _selected_filter = QFileDialog.getOpenFileName(
self._dialog_parent,
- caption,
+ self.tr("Open video"),
"",
- VIDEO_FILE_FILTER,
+ self.tr("Video files (*.mp4 *.mov *.webm *.mkv)"),
)
if filename:
self._start_load(Path(filename))
@@ -524,7 +522,7 @@ def _choose_output_directory(self) -> None:
current = output_directory_for_source(state.parameters, source)
selected = QFileDialog.getExistingDirectory(
self._dialog_parent,
- "Choose output directory",
+ self.tr("Choose output directory"),
str(current),
)
if selected:
diff --git a/src/matteloop/ui/copy.py b/src/matteloop/ui/copy.py
new file mode 100644
index 0000000..d96d587
--- /dev/null
+++ b/src/matteloop/ui/copy.py
@@ -0,0 +1,583 @@
+"""Literal-backed translations for data-driven UI values."""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QCoreApplication
+
+
+def model_display_name(model_id: str, fallback: str | None = None) -> str:
+ """Translate a model name while keeping the catalog ID as the key."""
+ names = {
+ "u2net": QCoreApplication.translate("ModelCopy", "U²-Net"),
+ "u2netp": QCoreApplication.translate("ModelCopy", "U²-Net P"),
+ "u2net_human_seg": QCoreApplication.translate(
+ "ModelCopy", "U²-Net Human Segmentation"
+ ),
+ "u2net_cloth_seg": QCoreApplication.translate(
+ "ModelCopy", "U²-Net Cloth Segmentation"
+ ),
+ "silueta": QCoreApplication.translate("ModelCopy", "Silueta"),
+ "isnet-general-use": QCoreApplication.translate(
+ "ModelCopy", "IS-Net General Use"
+ ),
+ "isnet-anime": QCoreApplication.translate("ModelCopy", "IS-Net Anime"),
+ "birefnet-general": QCoreApplication.translate("ModelCopy", "BiRefNet General"),
+ "birefnet-general-lite": QCoreApplication.translate(
+ "ModelCopy", "BiRefNet General Lite"
+ ),
+ "birefnet-portrait": QCoreApplication.translate(
+ "ModelCopy", "BiRefNet Portrait"
+ ),
+ "birefnet-dis": QCoreApplication.translate("ModelCopy", "BiRefNet DIS"),
+ "birefnet-hrsod": QCoreApplication.translate("ModelCopy", "BiRefNet HRSOD"),
+ "birefnet-cod": QCoreApplication.translate("ModelCopy", "BiRefNet COD"),
+ "birefnet-massive": QCoreApplication.translate("ModelCopy", "BiRefNet Massive"),
+ "bria-rmbg": QCoreApplication.translate("ModelCopy", "BRIA RMBG 2.0"),
+ }
+ return names.get(model_id, fallback or model_id)
+
+
+def model_purpose(model_id: str, fallback: str) -> str:
+ """Translate the catalog purpose associated with a model ID."""
+ purposes = {
+ "u2net": QCoreApplication.translate(
+ "ModelCopy", "General-purpose foreground extraction."
+ ),
+ "u2netp": QCoreApplication.translate(
+ "ModelCopy", "Small, faster general-purpose foreground extraction."
+ ),
+ "u2net_human_seg": QCoreApplication.translate(
+ "ModelCopy", "Human-focused foreground extraction."
+ ),
+ "u2net_cloth_seg": QCoreApplication.translate(
+ "ModelCopy", "Clothing-focused segmentation."
+ ),
+ "silueta": QCoreApplication.translate(
+ "ModelCopy", "Compact general-purpose foreground extraction."
+ ),
+ "isnet-general-use": QCoreApplication.translate(
+ "ModelCopy", "General salient-object segmentation."
+ ),
+ "isnet-anime": QCoreApplication.translate(
+ "ModelCopy", "Anime and illustration foreground extraction."
+ ),
+ "birefnet-general": QCoreApplication.translate(
+ "ModelCopy", "High-quality general foreground extraction."
+ ),
+ "birefnet-general-lite": QCoreApplication.translate(
+ "ModelCopy", "Smaller BiRefNet general foreground extraction."
+ ),
+ "birefnet-portrait": QCoreApplication.translate(
+ "ModelCopy", "High-quality portrait and hair foreground extraction."
+ ),
+ "birefnet-dis": QCoreApplication.translate(
+ "ModelCopy", "Dichotomous image segmentation."
+ ),
+ "birefnet-hrsod": QCoreApplication.translate(
+ "ModelCopy", "High-resolution salient-object detection."
+ ),
+ "birefnet-cod": QCoreApplication.translate(
+ "ModelCopy", "Camouflaged-object detection."
+ ),
+ "birefnet-massive": QCoreApplication.translate(
+ "ModelCopy", "Broad high-capacity foreground extraction."
+ ),
+ "bria-rmbg": QCoreApplication.translate(
+ "ModelCopy", "High-quality general background removal."
+ ),
+ }
+ return purposes.get(model_id, fallback)
+
+
+def model_license(model_id: str, fallback: str) -> str:
+ """Translate the user-facing model licence notice associated with an ID."""
+ if model_id in {"u2net", "u2netp", "u2net_human_seg", "u2net_cloth_seg"}:
+ return QCoreApplication.translate(
+ "ModelCopy",
+ "Model terms are provided by the upstream U²-Net project; review them "
+ "before redistribution.",
+ )
+ if model_id == "silueta":
+ return QCoreApplication.translate(
+ "ModelCopy",
+ "Review the Silueta model's upstream terms before redistribution.",
+ )
+ if model_id == "isnet-general-use":
+ return QCoreApplication.translate(
+ "ModelCopy",
+ "Review the IS-Net model's upstream terms before redistribution.",
+ )
+ if model_id == "isnet-anime":
+ return QCoreApplication.translate(
+ "ModelCopy",
+ "Review the IS-Net Anime model's upstream terms before redistribution.",
+ )
+ if model_id.startswith("birefnet"):
+ return QCoreApplication.translate(
+ "ModelCopy",
+ "Review the BiRefNet model's upstream terms before redistribution or "
+ "commercial use.",
+ )
+ if model_id == "bria-rmbg":
+ return QCoreApplication.translate(
+ "ModelCopy",
+ "BRIA RMBG 2.0 has model-specific license terms; commercial use requires "
+ "checking and satisfying BRIA's current license.",
+ )
+ return fallback
+
+
+def provider_label(provider: str, *, recommended: bool, model_id: str) -> str:
+ """Translate an allowlisted execution-provider label and its qualifier."""
+ if not model_id:
+ return {
+ "CPUExecutionProvider": QCoreApplication.translate("ProviderCopy", "CPU"),
+ "CoreMLExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "Core ML"
+ ),
+ "CUDAExecutionProvider": QCoreApplication.translate("ProviderCopy", "CUDA"),
+ "ROCMExecutionProvider": QCoreApplication.translate("ProviderCopy", "ROCm"),
+ "MIGraphXExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "MIGraphX"
+ ),
+ "DmlExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "DirectML"
+ ),
+ }.get(provider, provider)
+ base = {
+ "CPUExecutionProvider": QCoreApplication.translate("ProviderCopy", "CPU"),
+ "CoreMLExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "Apple CoreML"
+ ),
+ "CUDAExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "NVIDIA CUDA"
+ ),
+ "ROCMExecutionProvider": QCoreApplication.translate("ProviderCopy", "AMD ROCm"),
+ "MIGraphXExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "AMD MIGraphX"
+ ),
+ "DmlExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "GPU over DirectML"
+ ),
+ }.get(provider, provider)
+ if provider == "CoreMLExecutionProvider" and model_id.startswith("birefnet"):
+ return QCoreApplication.translate("ProviderCopy", "Apple CoreML – experimental")
+ if recommended:
+ return {
+ "CPUExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "CPU – recommended"
+ ),
+ "CoreMLExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "Apple CoreML – recommended"
+ ),
+ "CUDAExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "NVIDIA CUDA – recommended"
+ ),
+ "ROCMExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "AMD ROCm – recommended"
+ ),
+ "MIGraphXExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "AMD MIGraphX – recommended"
+ ),
+ "DmlExecutionProvider": QCoreApplication.translate(
+ "ProviderCopy", "GPU over DirectML – recommended"
+ ),
+ }.get(provider, base)
+ return base
+
+
+def provider_notice(value: str) -> str:
+ """Translate the bounded provider-fallback notice shown by job dialogs."""
+ return {
+ "Apple CoreML could not load this model. Processing "
+ "continues on the CPU.": (
+ QCoreApplication.translate(
+ "ProviderCopy",
+ "Apple CoreML could not load this model. Processing "
+ "continues on the CPU.",
+ )
+ ),
+ "NVIDIA CUDA could not load this model. Processing "
+ "continues on the CPU.": (
+ QCoreApplication.translate(
+ "ProviderCopy",
+ "NVIDIA CUDA could not load this model. Processing "
+ "continues on the CPU.",
+ )
+ ),
+ "AMD ROCm could not load this model. Processing "
+ "continues on the CPU.": (
+ QCoreApplication.translate(
+ "ProviderCopy",
+ "AMD ROCm could not load this model. Processing "
+ "continues on the CPU.",
+ )
+ ),
+ "AMD MIGraphX could not load this model. Processing "
+ "continues on the CPU.": (
+ QCoreApplication.translate(
+ "ProviderCopy",
+ "AMD MIGraphX could not load this model. Processing "
+ "continues on the CPU.",
+ )
+ ),
+ "GPU over DirectML could not load this model. Processing "
+ "continues on the CPU.": (
+ QCoreApplication.translate(
+ "ProviderCopy",
+ "GPU over DirectML could not load this model. Processing "
+ "continues on the CPU.",
+ )
+ ),
+ }.get(value, value)
+
+
+def progress_detail(value: str) -> str:
+ """Translate known render progress details while preserving their counts."""
+ exact = {
+ "Promoting cut frames": QCoreApplication.translate(
+ "ProgressCopy", "Promoting cut frames"
+ ),
+ "Validating cut set": QCoreApplication.translate(
+ "ProgressCopy", "Validating cut set"
+ ),
+ "Validating cut snapshot": QCoreApplication.translate(
+ "ProgressCopy", "Validating cut snapshot"
+ ),
+ "Validating encoded output": QCoreApplication.translate(
+ "ProgressCopy", "Validating encoded output"
+ ),
+ "Starting segmentation session": QCoreApplication.translate(
+ "ProgressCopy", "Starting segmentation session"
+ ),
+ "Using cached model weights": QCoreApplication.translate(
+ "ProgressCopy", "Using cached model weights"
+ ),
+ "Reusing prepared session": QCoreApplication.translate(
+ "ProgressCopy", "Reusing prepared session"
+ ),
+ }
+ if value in exact:
+ return exact[value]
+ if value.startswith("Frame ") and " of " in value:
+ first, second = value.removeprefix("Frame ").split(" of ", 1)
+ return QCoreApplication.translate("ProgressCopy", "Frame %1 of %2").replace(
+ "%1", first
+ ).replace("%2", second)
+ if value.startswith("Cut frame ") and " of " in value:
+ first, second = value.removeprefix("Cut frame ").split(" of ", 1)
+ return QCoreApplication.translate(
+ "ProgressCopy", "Cut frame %1 of %2"
+ ).replace("%1", first).replace("%2", second)
+ return value
+
+
+def presented_copy(value: str) -> str:
+ """Translate a presenter value at the widget render boundary."""
+ exact = {
+ "Preview this frame to inspect the cutout": QCoreApplication.translate(
+ "Presenter", "Preview this frame to inspect the cutout"
+ ),
+ "Reading video…": QCoreApplication.translate("Presenter", "Reading video…"),
+ "This video could not be read. Open another video.": QCoreApplication.translate(
+ "Presenter", "This video could not be read. Open another video."
+ ),
+ "Preview failed": QCoreApplication.translate("Presenter", "Preview failed"),
+ "retry Preview Frame": QCoreApplication.translate(
+ "Presenter", "retry Preview Frame"
+ ),
+ "Current preview — previewing selected frame": QCoreApplication.translate(
+ "Presenter", "Current preview — previewing selected frame"
+ ),
+ "Previewing selected frame": QCoreApplication.translate(
+ "Presenter", "Previewing selected frame"
+ ),
+ "Current preview": QCoreApplication.translate("Presenter", "Current preview"),
+ "preview again": QCoreApplication.translate("Presenter", "preview again"),
+ "Settings changed — preview again": QCoreApplication.translate(
+ "Presenter", "Settings changed — preview again"
+ ),
+ "Download required": QCoreApplication.translate(
+ "Presenter", "Download required"
+ ),
+ "Model preview — rebuild uses edited cut frames": QCoreApplication.translate(
+ "Presenter", "Model preview — rebuild uses edited cut frames"
+ ),
+ "Edited cuts changed": QCoreApplication.translate(
+ "Presenter", "Edited cuts changed"
+ ),
+ "Prepare & Preview": QCoreApplication.translate(
+ "Presenter", "Prepare & Preview"
+ ),
+ "Preview Frame": QCoreApplication.translate("Presenter", "Preview Frame"),
+ "Open another video": QCoreApplication.translate(
+ "Presenter", "Open another video"
+ ),
+ "Drop a video here": QCoreApplication.translate(
+ "Presenter", "Drop a video here"
+ ),
+ "Background-removed result": QCoreApplication.translate(
+ "Presenter", "Background-removed result"
+ ),
+ "Retry Rebuild": QCoreApplication.translate("Presenter", "Retry Rebuild"),
+ "Rebuild from edited cuts": QCoreApplication.translate(
+ "Presenter", "Rebuild from edited cuts"
+ ),
+ "Render complete": QCoreApplication.translate("Presenter", "Render complete"),
+ }
+ if value in exact:
+ return exact[value]
+ return _presented_dynamic_copy(value)
+
+
+def _presented_dynamic_copy(value: str) -> str:
+ """Translate presenter messages whose stable prefix carries data."""
+ if value.startswith("Preview failed: "):
+ detail = value.removeprefix("Preview failed: ")
+ return QCoreApplication.translate("Presenter", "Preview failed: %s") % detail
+ if value.startswith("Preview failed — "):
+ retry = value.removeprefix("Preview failed — ")
+ return QCoreApplication.translate("Presenter", "Preview failed — %s") % (
+ presented_copy(retry)
+ )
+ if ": " in value:
+ category, message = value.split(": ", 1)
+ translated_category = {
+ "Segmentation": QCoreApplication.translate("Presenter", "Segmentation"),
+ "Compute acceleration": QCoreApplication.translate(
+ "Presenter", "Compute acceleration"
+ ),
+ "Sampling": QCoreApplication.translate("Presenter", "Sampling"),
+ "Crop & cleanup": QCoreApplication.translate("Presenter", "Crop & cleanup"),
+ "Crop": QCoreApplication.translate("Presenter", "Crop"),
+ "Framing": QCoreApplication.translate("Presenter", "Framing"),
+ "Playhead": QCoreApplication.translate("Presenter", "Playhead"),
+ "Export range": QCoreApplication.translate("Presenter", "Export range"),
+ "Preview failed": QCoreApplication.translate("Presenter", "Preview failed"),
+ "Edited cuts": QCoreApplication.translate("Presenter", "Edited cuts"),
+ }.get(category)
+ if translated_category is not None:
+ return f"{translated_category}: {presented_copy(message)}"
+ return value
+
+
+def main_window_copy(value: str) -> str:
+ """Translate fixed copy owned by the main-window render boundary."""
+ exact = {
+ "MatteLoop": QCoreApplication.translate("MainWindow", "MatteLoop"),
+ "Couldn’t read this video": QCoreApplication.translate(
+ "MainWindow", "Couldn’t read this video"
+ ),
+ "Video load error": QCoreApplication.translate(
+ "MainWindow", "Video load error"
+ ),
+ "Render complete": QCoreApplication.translate("MainWindow", "Render complete"),
+ "Open output": QCoreApplication.translate("MainWindow", "Open output"),
+ "Open folder": QCoreApplication.translate("MainWindow", "Open folder"),
+ }
+ return exact.get(value, value)
+
+
+def render_copy(value: str) -> str:
+ """Translate fixed copy owned by render confirmations."""
+ translated = _render_copy_dialog(value)
+ return translated if translated != value else _render_copy_output(value)
+
+
+def _render_copy_dialog(value: str) -> str:
+ """Translate preview and existing-output confirmation copy."""
+ exact = {
+ "Preview recommended": QCoreApplication.translate(
+ "RenderController", "Preview recommended"
+ ),
+ "Preview this frame before rendering?": QCoreApplication.translate(
+ "RenderController", "Preview this frame before rendering?"
+ ),
+ "A preview lets you verify the cutout before processing the whole video.": (
+ QCoreApplication.translate(
+ "RenderController",
+ "A preview lets you verify the cutout before processing the whole "
+ "video.",
+ )
+ ),
+ "Preview first": QCoreApplication.translate(
+ "RenderController", "Preview first"
+ ),
+ "Render anyway": QCoreApplication.translate(
+ "RenderController", "Render anyway"
+ ),
+ "Cancel": QCoreApplication.translate("RenderController", "Cancel"),
+ "Matching cut set found": QCoreApplication.translate(
+ "RenderController", "Matching cut set found"
+ ),
+ "A validated cut set matches the current source and settings.": (
+ QCoreApplication.translate(
+ "RenderController",
+ "A validated cut set matches the current source and settings.",
+ )
+ ),
+ "Rebuild reuses the cuts and only reruns framing and encoding.": (
+ QCoreApplication.translate(
+ "RenderController",
+ "Rebuild reuses the cuts and only reruns framing and encoding.",
+ )
+ ),
+ "Rebuild": QCoreApplication.translate("RenderController", "Rebuild"),
+ "Regenerate": QCoreApplication.translate("RenderController", "Regenerate"),
+ }
+ translated = exact.get(value, value)
+ return (
+ translated
+ if translated != value
+ else _render_copy_output_confirmation(value)
+ )
+
+
+def _render_copy_output_confirmation(value: str) -> str:
+ """Translate existing-output choice labels."""
+ return {
+ "Output already exists": QCoreApplication.translate(
+ "RenderController", "Output already exists"
+ ),
+ "%s already exists.": QCoreApplication.translate(
+ "RenderController", "%s already exists."
+ ),
+ "Choose how to handle the existing output.": QCoreApplication.translate(
+ "RenderController", "Choose how to handle the existing output."
+ ),
+ "Replace": QCoreApplication.translate("RenderController", "Replace"),
+ "Choose another name": QCoreApplication.translate(
+ "RenderController", "Choose another name"
+ ),
+ "Choose output name": QCoreApplication.translate(
+ "RenderController", "Choose output name"
+ ),
+ "WebP files (*.webp)": QCoreApplication.translate(
+ "RenderController", "WebP files (*.webp)"
+ ),
+ }.get(value, value)
+
+
+def _render_copy_output(value: str) -> str:
+ """Translate the two render-progress labels shared with the dialog."""
+ return {
+ "Rebuilding from edited cuts": QCoreApplication.translate(
+ "PreviewJobDialog", "Rebuilding from edited cuts"
+ ),
+ "Rendering video": QCoreApplication.translate(
+ "PreviewJobDialog", "Rendering video"
+ ),
+ }.get(value, value)
+
+
+def source_error_message(value: str) -> str:
+ """Translate the plain-language source recovery instruction."""
+ messages = {
+ "Open a video stored on this Mac.": QCoreApplication.translate(
+ "SourceErrors", "Open a video stored on this Mac."
+ ),
+ "Open a video file that can be opened and read.": QCoreApplication.translate(
+ "SourceErrors", "Open a video file that can be opened and read."
+ ),
+ "Open a file that contains a video track.": QCoreApplication.translate(
+ "SourceErrors", "Open a file that contains a video track."
+ ),
+ "Open another video file; this one appears damaged.": (
+ QCoreApplication.translate(
+ "SourceErrors", "Open another video file; this one appears damaged."
+ )
+ ),
+ "Open a video with a positive duration.": QCoreApplication.translate(
+ "SourceErrors", "Open a video with a positive duration."
+ ),
+ "Convert to 8-bit SDR and try again.": QCoreApplication.translate(
+ "SourceErrors", "Convert to 8-bit SDR and try again."
+ ),
+ "Resize to between 8×8 and 3840×2160.": QCoreApplication.translate(
+ "SourceErrors", "Resize to between 8×8 and 3840×2160."
+ ),
+ "Convert the video to 60 fps or less.": QCoreApplication.translate(
+ "SourceErrors", "Convert the video to 60 fps or less."
+ ),
+ "Open a video under 10 minutes.": QCoreApplication.translate(
+ "SourceErrors", "Open a video under 10 minutes."
+ ),
+ "Open an MP4, MOV, WebM, or MKV video.": QCoreApplication.translate(
+ "SourceErrors", "Open an MP4, MOV, WebM, or MKV video."
+ ),
+ "This video could not be read. Open another video.": QCoreApplication.translate(
+ "SourceErrors", "This video could not be read. Open another video."
+ ),
+ }
+ return messages.get(value, value)
+
+
+def section_title(key: str) -> str:
+ """Translate one inspector section identified by its stable key."""
+ return {
+ "segmentation": QCoreApplication.translate("Inspector", "Segmentation"),
+ "time_sampling": QCoreApplication.translate("Inspector", "Time & Sampling"),
+ "crop_cleanup": QCoreApplication.translate("Inspector", "Crop & Cleanup"),
+ "transform": QCoreApplication.translate("Inspector", "Transform"),
+ "output": QCoreApplication.translate("Inspector", "Output"),
+ "workspace": QCoreApplication.translate("Inspector", "Workspace"),
+ }.get(key, key)
+
+
+def crop_field_label(name: str) -> str:
+ """Translate a crop field label without translating its stable field key."""
+ return {
+ "x": QCoreApplication.translate("Inspector", "X"),
+ "y": QCoreApplication.translate("Inspector", "Y"),
+ "width": QCoreApplication.translate("Inspector", "Width"),
+ "height": QCoreApplication.translate("Inspector", "Height"),
+ }.get(name, name)
+
+
+def model_status(status: str) -> tuple[str, str]:
+ """Return translated status marker and label for the model picker."""
+ return {
+ "ready": ("●", QCoreApplication.translate("Inspector", "Ready")),
+ "downloading": ("◌", QCoreApplication.translate("Inspector", "Downloading")),
+ "not_cached": ("○", QCoreApplication.translate("Inspector", "Not cached")),
+ }.get(status, ("○", QCoreApplication.translate("Inspector", "Not cached")))
+
+
+def inspector_label(value: str) -> str:
+ """Translate a fixed inspector form label."""
+ return {
+ "Model": QCoreApplication.translate("Inspector", "Model"),
+ "Compute acceleration": QCoreApplication.translate(
+ "Inspector", "Compute acceleration"
+ ),
+ "Edge treatment": QCoreApplication.translate("Inspector", "Edge treatment"),
+ "Output FPS": QCoreApplication.translate("Inspector", "Output FPS"),
+ "Start": QCoreApplication.translate("Inspector", "Start"),
+ "End": QCoreApplication.translate("Inspector", "End"),
+ "Duration": QCoreApplication.translate("Inspector", "Duration"),
+ "Alpha threshold": QCoreApplication.translate("Inspector", "Alpha threshold"),
+ "Padding": QCoreApplication.translate("Inspector", "Padding"),
+ "Horizontal stretch": QCoreApplication.translate(
+ "Inspector", "Horizontal stretch"
+ ),
+ "Directory": QCoreApplication.translate("Inspector", "Directory"),
+ "Filename": QCoreApplication.translate("Inspector", "Filename"),
+ "Maximum size": QCoreApplication.translate("Inspector", "Maximum size"),
+ "X": QCoreApplication.translate("Inspector", "X"),
+ "Y": QCoreApplication.translate("Inspector", "Y"),
+ "Width": QCoreApplication.translate("Inspector", "Width"),
+ "Height": QCoreApplication.translate("Inspector", "Height"),
+ }.get(value, value)
+
+
+def accessible_field_name(name: str) -> str:
+ """Translate a field's spoken name from its stable object key."""
+ return {
+ "start": QCoreApplication.translate("Inspector", "Start"),
+ "end": QCoreApplication.translate("Inspector", "End"),
+ "duration": QCoreApplication.translate("Inspector", "Duration"),
+ "width": QCoreApplication.translate("Inspector", "Width"),
+ "height": QCoreApplication.translate("Inspector", "Height"),
+ "x": QCoreApplication.translate("Inspector", "X"),
+ "y": QCoreApplication.translate("Inspector", "Y"),
+ }.get(name, name)
diff --git a/src/matteloop/ui/crop_canvas.py b/src/matteloop/ui/crop_canvas.py
index 2ddc08b..75e915b 100644
--- a/src/matteloop/ui/crop_canvas.py
+++ b/src/matteloop/ui/crop_canvas.py
@@ -4,7 +4,7 @@
from pathlib import Path
-from PySide6.QtCore import QPointF, QRectF, Qt, Signal
+from PySide6.QtCore import QCoreApplication, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QKeyEvent, QMouseEvent, QPainter, QPen
from PySide6.QtWidgets import QWidget
@@ -89,7 +89,9 @@ def apply_presentation(
self._editable = editable
if presentation is None:
self._geometry = None
- self.setAccessibleDescription("Original video frame")
+ self.setAccessibleDescription(
+ QCoreApplication.translate("CropCanvas", "Original video frame")
+ )
else:
self._rebuild_geometry()
self._announce_crop()
@@ -328,9 +330,19 @@ def _announce_crop(self) -> None:
return
crop = presentation.crop
self.setAccessibleDescription(
- f"Crop bounds: x {crop.x}, y {crop.y}, width {crop.width}, "
- f"height {crop.height} source pixels; source {presentation.width} × "
- f"{presentation.height}"
+ QCoreApplication.translate(
+ "CropCanvas",
+ "Crop bounds: x %s, y %s, width %s, height %s source pixels; "
+ "source %s × %s",
+ )
+ % (
+ crop.x,
+ crop.y,
+ crop.width,
+ crop.height,
+ presentation.width,
+ presentation.height,
+ )
)
diff --git a/src/matteloop/ui/i18n.py b/src/matteloop/ui/i18n.py
new file mode 100644
index 0000000..29ae9fd
--- /dev/null
+++ b/src/matteloop/ui/i18n.py
@@ -0,0 +1,132 @@
+"""Application language selection and Qt catalogue loading."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PySide6.QtCore import (
+ QCoreApplication,
+ QLibraryInfo,
+ QLocale,
+ QSettings,
+ QTranslator,
+)
+from PySide6.QtWidgets import QApplication
+
+from matteloop.resources import resource_path
+
+LANGUAGE_KEY = "ui/language"
+SUPPORTED_LANGUAGES = ("en", "de")
+_CATALOGUE_NAMES = {"en": "matteloop_en.qm", "de": "matteloop_de.qm"}
+_CONFIGURED_LANGUAGE: str | None = None
+
+
+def language_for_locale(locale: QLocale) -> str:
+ """Return the supported application language closest to *locale*."""
+ return "de" if locale.language() is QLocale.Language.German else "en"
+
+
+def locale_for_language(language: str) -> QLocale:
+ """Return the stable number/date locale for one supported language."""
+ if language == "de":
+ return QLocale(QLocale.Language.German, QLocale.Country.Germany)
+ return QLocale(QLocale.Language.English, QLocale.Country.UnitedStates)
+
+
+def configure_locale(language: str) -> None:
+ """Set the display locale once during application startup."""
+ global _CONFIGURED_LANGUAGE
+ _CONFIGURED_LANGUAGE = language if language in SUPPORTED_LANGUAGES else "en"
+ QLocale.setDefault(locale_for_language(_CONFIGURED_LANGUAGE))
+
+
+def display_locale() -> QLocale:
+ """Return the selected locale, with English as the pre-start fallback."""
+ if _CONFIGURED_LANGUAGE is None:
+ return locale_for_language("en")
+ return QLocale()
+
+
+def catalogue_exists(language: str, *, runtime_root: Path | None = None) -> bool:
+ """Report whether the application catalogue is present in this runtime."""
+ filename = _CATALOGUE_NAMES.get(language)
+ if filename is None:
+ return False
+ try:
+ resource_path(filename, runtime_root=runtime_root)
+ except (FileNotFoundError, RuntimeError, ValueError):
+ return False
+ return True
+
+
+def available_languages(*, runtime_root: Path | None = None) -> tuple[str, ...]:
+ """Return supported languages whose checked-in catalogue is loadable."""
+ return tuple(
+ language
+ for language in SUPPORTED_LANGUAGES
+ if catalogue_exists(language, runtime_root=runtime_root)
+ )
+
+
+def selected_language(settings: QSettings, *, runtime_root: Path | None = None) -> str:
+ """Read a persisted language, otherwise choose the system language if built."""
+ stored = settings.value(LANGUAGE_KEY)
+ available = available_languages(runtime_root=runtime_root)
+ if isinstance(stored, str) and stored in available:
+ return stored
+ system_language = language_for_locale(QLocale.system())
+ return system_language if system_language in available else "en"
+
+
+def persist_language(settings: QSettings, language: str) -> None:
+ """Persist a supported language immediately; restart applies it to the UI."""
+ if language not in SUPPORTED_LANGUAGES:
+ raise ValueError("unsupported application language")
+ settings.setValue(LANGUAGE_KEY, language)
+ settings.sync()
+
+
+def application_translator(
+ language: str, *, runtime_root: Path | None = None
+) -> QTranslator | None:
+ """Load one application catalogue from the source or frozen resource root."""
+ filename = _CATALOGUE_NAMES.get(language)
+ if filename is None:
+ return None
+ try:
+ path = resource_path(filename, runtime_root=runtime_root)
+ except (FileNotFoundError, RuntimeError, ValueError):
+ return None
+ translator = QTranslator()
+ return translator if translator.load(str(path)) else None
+
+
+def install_translators(
+ application: QApplication,
+ language: str,
+ *,
+ runtime_root: Path | None = None,
+) -> tuple[QTranslator, ...]:
+ """Install the app catalogue and Qt's standard-widget catalogue if present."""
+ translators: list[QTranslator] = []
+ app_translator = application_translator(language, runtime_root=runtime_root)
+ if app_translator is not None:
+ application.installTranslator(app_translator)
+ translators.append(app_translator)
+
+ qt_translations = Path(
+ QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
+ )
+ qt_translator = QTranslator()
+ qt_catalogue = qt_translations / f"qtbase_{language}.qm"
+ if qt_catalogue.is_file() and qt_translator.load(str(qt_catalogue)):
+ application.installTranslator(qt_translator)
+ translators.append(qt_translator)
+ return tuple(translators)
+
+
+def translate_language_name(language: str) -> str:
+ """Translate the language selector's native language labels."""
+ if language == "de":
+ return QCoreApplication.translate("SettingsDialog", "German")
+ return QCoreApplication.translate("SettingsDialog", "English")
diff --git a/src/matteloop/ui/inspector.py b/src/matteloop/ui/inspector.py
index 9d38c69..a3aac4a 100644
--- a/src/matteloop/ui/inspector.py
+++ b/src/matteloop/ui/inspector.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from PySide6.QtCore import QSettings, QSignalBlocker, Qt, Signal
+from PySide6.QtCore import QCoreApplication, QSettings, QSignalBlocker, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
@@ -61,6 +61,17 @@
MiddleElidingLineEdit,
compact_field,
)
+from matteloop.ui.copy import (
+ accessible_field_name,
+ crop_field_label,
+ inspector_label,
+ model_display_name,
+ model_license,
+ model_purpose,
+ model_status,
+ provider_label,
+ section_title,
+)
from matteloop.ui.crop_presentation import CropPresentation
from matteloop.ui.inspector_disclosure import configure_disclosure, read_bool
from matteloop.ui.parameter_presentation import (
@@ -95,7 +106,9 @@ def __init__(
) -> None:
super().__init__(parent)
self.setObjectName("inspector")
- self.setAccessibleName("Processing settings")
+ self.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Processing settings")
+ )
self._settings = settings
self._model_options = dict(model_options or ())
self._provider_options = (
@@ -127,23 +140,42 @@ def _build_scrollable_content(self) -> None:
content_layout.setContentsMargins(16, 12, 16, 16)
content_layout.setSpacing(4)
self.disclosures: dict[str, tuple[QToolButton, QWidget]] = {}
- self.edited_cut_recovery = QPushButton("Retry Rebuild")
+ self.edited_cut_recovery = QPushButton(
+ QCoreApplication.translate("Inspector", "Retry Rebuild")
+ )
self.edited_cut_recovery.setObjectName("edited_cut_recovery")
- self.edited_cut_recovery.setAccessibleName("Edited cut recovery")
+ self.edited_cut_recovery.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Edited cut recovery")
+ )
self.edited_cut_recovery.setToolTip(
- "Edited cut frames could not be validated. Retry the rebuild scan."
+ QCoreApplication.translate(
+ "Inspector",
+ "Edited cut frames could not be validated. Retry the rebuild scan.",
+ )
)
self.edited_cut_recovery.hide()
- self.rebuild_button = QPushButton("Rebuild from edited cuts")
+ self.rebuild_button = QPushButton(
+ QCoreApplication.translate("Inspector", "Rebuild from edited cuts")
+ )
self.rebuild_button.setObjectName("rebuild_action")
- self.rebuild_button.setAccessibleName("Rebuild from edited cuts")
+ self.rebuild_button.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Rebuild from edited cuts")
+ )
self.rebuild_button.setMinimumHeight(40)
- self.manage_models = QPushButton("Manage Models…")
+ self.manage_models = QPushButton(
+ QCoreApplication.translate("Inspector", "Manage Models…")
+ )
self.manage_models.setObjectName("manage_models")
- self.manage_models.setAccessibleName("Manage Models…")
- self.manage_workspaces = QPushButton("Manage Workspaces…")
+ self.manage_models.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Manage Models…")
+ )
+ self.manage_workspaces = QPushButton(
+ QCoreApplication.translate("Inspector", "Manage Workspaces…")
+ )
self.manage_workspaces.setObjectName("manage_workspaces")
- self.manage_workspaces.setAccessibleName("Manage Workspaces…")
+ self.manage_workspaces.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Manage Workspaces…")
+ )
self.transform_group = TransformGroup(self.command_requested.emit)
for key, title, default in _DISCLOSURES:
section = self._section(key, title, default)
@@ -163,7 +195,9 @@ def _build_parameter_controls(self) -> None:
def _build_segmentation_parameter_controls(self) -> None:
self.model_picker = compact_field(ElidingComboBox())
self.model_picker.setObjectName("model_picker")
- self.model_picker.setAccessibleName("Segmentation model")
+ self.model_picker.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Segmentation model")
+ )
self.model_picker.setView(QListView())
self.model_picker.view().setItemDelegate(AlignedRowDelegate(self.model_picker))
self.model_picker.view().setMinimumWidth(460)
@@ -175,15 +209,21 @@ def _build_segmentation_parameter_controls(self) -> None:
artifact.size_bytes if artifact is not None else None
)
availability = self._model_options.get(model_id, False)
- status_words = "cached locally" if availability else "not cached yet"
+ status_words = (
+ QCoreApplication.translate("Inspector", "cached locally")
+ if availability
+ else QCoreApplication.translate("Inspector", "not cached yet")
+ )
+ display_name = model_display_name(model_id, spec.display_name)
+ purpose = model_purpose(model_id, spec.purpose)
+ license_note = model_license(model_id, spec.license_note)
detail = (
- f"{spec.display_name}; {spec.purpose}; {size}; {status_words}; "
- f"{spec.license_note}"
+ f"{display_name}; {purpose}; {size}; {status_words}; {license_note}"
)
row = AlignedRow(
"✓" if availability else "↓",
RowStatus.CACHED if availability else RowStatus.UNCACHED,
- (AlignedColumn(spec.display_name), AlignedColumn(size, True)),
+ (AlignedColumn(display_name), AlignedColumn(size, True)),
detail,
)
self.model_picker.addItem(status_icon(row), row.display_text, model_id)
@@ -195,7 +235,9 @@ def _build_segmentation_parameter_controls(self) -> None:
)
self.model_status = QLabel()
self.model_status.setObjectName("model_status")
- self.model_status.setAccessibleName("Model status")
+ self.model_status.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Model status")
+ )
self.model_status.setSizePolicy(
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
)
@@ -203,72 +245,113 @@ def _build_segmentation_parameter_controls(self) -> None:
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
self.set_model_status("ready")
+ self._build_provider_controls(catalog.default_id)
+ self.model_picker.currentIndexChanged.connect(self._update_model_accessibility)
+
+ def _build_provider_controls(self, model_id: str) -> None:
self.provider_picker = compact_field(ElidingComboBox())
self.provider_picker.setObjectName("provider_picker")
- self.provider_picker.setAccessibleName("Compute acceleration")
- self._set_provider_options(catalog.default_id)
+ self.provider_picker.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Compute acceleration")
+ )
+ self._set_provider_options(model_id)
self.edge_picker = compact_field(ElidingComboBox())
self.edge_picker.setObjectName("edge_picker")
- self.edge_picker.setAccessibleName("Edge treatment")
- self.edge_picker.addItem("Standard", EdgeMode.STANDARD)
- self.edge_picker.addItem("Decontaminate colors", EdgeMode.DECONTAMINATE_COLORS)
- self.model_picker.currentIndexChanged.connect(self._update_model_accessibility)
+ self.edge_picker.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Edge treatment")
+ )
+ self.edge_picker.addItem(
+ QCoreApplication.translate("Inspector", "Standard"), EdgeMode.STANDARD
+ )
+ self.edge_picker.addItem(
+ QCoreApplication.translate("Inspector", "Decontaminate colors"),
+ EdgeMode.DECONTAMINATE_COLORS,
+ )
def _build_sampling_parameter_controls(self) -> None:
self.fps_spinbox = compact_field(QSpinBox())
self.fps_spinbox.setObjectName("output_fps")
- self.fps_spinbox.setAccessibleName("Output FPS")
+ self.fps_spinbox.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Output FPS")
+ )
self.fps_spinbox.setRange(1, 240)
- self.fps_spinbox.setSuffix(" fps")
- self.fps_warning = QLabel("High output FPS may increase render cost")
+ self.fps_spinbox.setSuffix(QCoreApplication.translate("Inspector", " fps"))
+ self.fps_warning = QLabel(
+ QCoreApplication.translate(
+ "Inspector", "High output FPS may increase render cost"
+ )
+ )
self.fps_warning.setObjectName("fps_warning")
self.fps_warning.setProperty("warning", True)
- self.fps_warning.setAccessibleName("Output FPS cost warning")
+ self.fps_warning.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Output FPS cost warning")
+ )
self.fps_warning.hide()
self.start_spinbox = self._time_spinbox("start")
self.end_spinbox = self._time_spinbox("end")
self.duration_spinbox = self._time_spinbox("duration")
def _build_cleanup_parameter_controls(self) -> None:
- self.trim_checkbox = QCheckBox("Global trim")
+ self.trim_checkbox = QCheckBox(
+ QCoreApplication.translate("Inspector", "Global trim")
+ )
self.trim_checkbox.setObjectName("global_trim")
- self.trim_checkbox.setAccessibleName("Global alpha trim")
+ self.trim_checkbox.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Global alpha trim")
+ )
self.alpha_threshold_spinbox = self._decimal_spinbox(
"alpha_threshold", 0.0, 100.0, 1
)
self.alpha_threshold_spinbox.setSuffix(" %")
self.padding_spinbox = compact_field(QSpinBox())
self.padding_spinbox.setObjectName("padding")
- self.padding_spinbox.setAccessibleName("Padding pixels")
+ self.padding_spinbox.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Padding pixels")
+ )
self.padding_spinbox.setRange(0, 2_147_483_647)
self.stretch_spinbox = self._decimal_spinbox(
"horizontal_stretch", 0.000001, 1_000_000_000.0, 6
)
- self.stretch_spinbox.setAccessibleName("Horizontal stretch")
+ self.stretch_spinbox.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Horizontal stretch")
+ )
def _build_output_parameter_controls(self) -> None:
self.output_directory_edit = compact_field(MiddleElidingLineEdit())
self.output_directory_edit.setObjectName("output_directory")
- self.output_directory_edit.setAccessibleName("Output directory")
+ self.output_directory_edit.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Output directory")
+ )
self.output_directory_edit.setProperty("mono", True)
self.output_directory_edit.setReadOnly(True)
- self.output_directory_button = QPushButton("Choose…")
+ self.output_directory_button = QPushButton(
+ QCoreApplication.translate("Inspector", "Choose…")
+ )
self.output_directory_button.setObjectName("choose_output_directory")
- self.output_directory_button.setAccessibleName("Choose output directory")
+ self.output_directory_button.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Choose output directory")
+ )
self.output_filename_edit = compact_field(QLineEdit())
self.output_filename_edit.setObjectName("output_filename")
- self.output_filename_edit.setAccessibleName("Output filename")
+ self.output_filename_edit.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Output filename")
+ )
self.output_filename_edit.setProperty("mono", True)
self.output_filename_edit.setPlaceholderText("filename.webp")
self.output_filename_edit.setToolTip(
- "Use one non-empty filename ending in .webp; "
- "path separators are not allowed."
+ QCoreApplication.translate(
+ "Inspector",
+ "Use one non-empty filename ending in .webp; path separators are "
+ "not allowed.",
+ )
)
self.max_size_spinbox = self._decimal_spinbox(
"maximum_size", 0.0, 1_000_000_000.0, 3
)
- self.max_size_spinbox.setAccessibleName("Maximum file size in MiB")
- self.max_size_spinbox.setSuffix(" MiB")
+ self.max_size_spinbox.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Maximum file size in MiB")
+ )
+ self.max_size_spinbox.setSuffix(QCoreApplication.translate("Inspector", " MiB"))
def _connect_parameter_controls(self) -> None:
self.model_picker.currentIndexChanged.connect(self._model_changed)
@@ -357,7 +440,9 @@ def _filename_changed(self) -> None:
self.command_requested.emit(OutputFilenameChanged(filename))
def _show_filename_error(self) -> None:
- message = "Filename must be a single non-empty .webp filename."
+ message = QCoreApplication.translate(
+ "Inspector", "Filename must be a single non-empty .webp filename."
+ )
self.output_filename_edit.setProperty("invalid", True)
self.output_filename_edit.setToolTip(message)
self.output_filename_edit.setAccessibleDescription(message)
@@ -367,8 +452,11 @@ def _show_filename_error(self) -> None:
def _clear_filename_error(self) -> None:
self.output_filename_edit.setProperty("invalid", False)
self.output_filename_edit.setToolTip(
- "Use one non-empty filename ending in .webp; "
- "path separators are not allowed."
+ QCoreApplication.translate(
+ "Inspector",
+ "Use one non-empty filename ending in .webp; path separators are "
+ "not allowed.",
+ )
)
self.output_filename_edit.setAccessibleDescription("")
self.output_filename_edit.style().unpolish(self.output_filename_edit)
@@ -441,9 +529,7 @@ def _apply_parameter_values(self, presentation: ParameterPresentation) -> None:
self.output_filename_edit.setText(presentation.output_filename)
self.max_size_spinbox.setValue(float(presentation.max_mib))
self._apply_time_values(presentation)
- self.output_directory_edit.setText(
- str(presentation.output_directory or "")
- )
+ self.output_directory_edit.setText(str(presentation.output_directory or ""))
self._clear_filename_error()
self._update_model_accessibility()
finally:
@@ -472,12 +558,12 @@ def _apply_time_values(self, presentation: ParameterPresentation) -> None:
def _time_spinbox(self, name: str) -> QDoubleSpinBox:
field = compact_field(QDoubleSpinBox())
field.setObjectName(f"{name}_time")
- field.setAccessibleName(name.capitalize())
+ field.setAccessibleName(accessible_field_name(name))
field.setProperty("mono", True)
field.setDecimals(3)
field.setRange(0.0, 1.0)
field.setSingleStep(0.001)
- field.setSuffix(" s")
+ field.setSuffix(QCoreApplication.translate("Inspector", " s"))
return field
def _decimal_spinbox(
@@ -493,13 +579,19 @@ def _decimal_spinbox(
def _build_crop_controls(self) -> None:
self._crop_syncing = False
- self.crop_toggle = QCheckBox("Crop")
+ self.crop_toggle = QCheckBox(QCoreApplication.translate("Inspector", "Crop"))
self.crop_toggle.setObjectName("crop_toggle")
- self.crop_toggle.setAccessibleName("Crop overlay")
+ self.crop_toggle.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Crop overlay")
+ )
self.crop_toggle.setChecked(True)
- self.crop_reset_button = QPushButton("Reset Crop")
+ self.crop_reset_button = QPushButton(
+ QCoreApplication.translate("Inspector", "Reset Crop")
+ )
self.crop_reset_button.setObjectName("crop_reset")
- self.crop_reset_button.setAccessibleName("Reset crop")
+ self.crop_reset_button.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Reset crop")
+ )
self.crop_x_spinbox = self._crop_spinbox("x")
self.crop_y_spinbox = self._crop_spinbox("y")
self.crop_width_spinbox = self._crop_spinbox("width")
@@ -522,9 +614,10 @@ def apply_crop(
) -> None:
"""Render reducer-owned crop values into standard inspector widgets."""
self._crop_syncing = True
- blockers = [QSignalBlocker(self.crop_toggle), *(
- QSignalBlocker(field) for field in self.crop_fields.values()
- )]
+ blockers = [
+ QSignalBlocker(self.crop_toggle),
+ *(QSignalBlocker(field) for field in self.crop_fields.values()),
+ ]
try:
self.crop_toggle.setChecked(enabled)
if presentation is None:
@@ -553,7 +646,9 @@ def apply_crop(
def _crop_spinbox(self, name: str) -> QSpinBox:
field = compact_field(QSpinBox())
field.setObjectName(f"crop_{name}")
- field.setAccessibleName(f"Crop {name}")
+ field.setAccessibleName(
+ QCoreApplication.translate("Inspector", "Crop %s") % crop_field_label(name)
+ )
field.setProperty("mono", True)
field.setMinimum(0 if name in {"x", "y"} else 1)
field.setMaximum(1)
@@ -604,7 +699,7 @@ def _section(self, key: str, title: str, default: bool) -> QFrame:
body_layout.addWidget(self.rebuild_button)
body_layout.addWidget(self.manage_workspaces)
checked = read_bool(self._settings, f"inspector/{key}", default)
- configure_disclosure(button, key, title, checked)
+ configure_disclosure(button, key, section_title(key), checked)
body.setVisible(checked)
button.toggled.connect(body.setVisible)
button.toggled.connect(
@@ -686,21 +781,19 @@ def _form_layout(self, controls: QWidget) -> QFormLayout:
@staticmethod
def _form_label(text: str) -> QLabel:
- label = QLabel(text)
+ label = QLabel(inspector_label(text))
label.setFixedWidth(136)
label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
return label
def set_model_status(self, status: str) -> None:
"""Render the presenter-derived model availability beside the picker."""
- marker, label = {
- "ready": ("●", "Ready"),
- "downloading": ("◌", "Downloading"),
- "not_cached": ("○", "Not cached"),
- }.get(status, ("○", "Not cached"))
+ marker, label = model_status(status)
self.model_status.setText(f"{marker} {label}")
self.model_status.setProperty("status", status)
- self.model_status.setAccessibleDescription(f"Model status: {label}")
+ self.model_status.setAccessibleDescription(
+ QCoreApplication.translate("Inspector", "Model status: %s") % label
+ )
self.model_status.style().unpolish(self.model_status)
self.model_status.style().polish(self.model_status)
@@ -788,7 +881,14 @@ def _set_provider_options(self, model_id: str) -> None:
try:
self.provider_picker.clear()
for option in options:
- self.provider_picker.addItem(option.label, option.provider)
+ self.provider_picker.addItem(
+ provider_label(
+ option.provider,
+ recommended=option.recommended,
+ model_id=model_id,
+ ),
+ option.provider,
+ )
finally:
self.provider_picker.blockSignals(False)
if selected is not None:
diff --git a/src/matteloop/ui/inspector_disclosure.py b/src/matteloop/ui/inspector_disclosure.py
index dba0b9f..88f56a7 100644
--- a/src/matteloop/ui/inspector_disclosure.py
+++ b/src/matteloop/ui/inspector_disclosure.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from PySide6.QtCore import QSettings, Qt
+from PySide6.QtCore import QCoreApplication, QSettings, Qt
from PySide6.QtWidgets import QSizePolicy, QToolButton
@@ -34,7 +34,11 @@ def configure_disclosure(
def update_disclosure(button: QToolButton, title: str, expanded: bool) -> None:
"""Set the disclosure arrow and non-visual state wording together."""
- state = "expanded" if expanded else "collapsed"
+ state = (
+ QCoreApplication.translate("InspectorDisclosure", "expanded")
+ if expanded
+ else QCoreApplication.translate("InspectorDisclosure", "collapsed")
+ )
button.setArrowType(
Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow
)
diff --git a/src/matteloop/ui/main_window.py b/src/matteloop/ui/main_window.py
index 88570c0..ff14a03 100644
--- a/src/matteloop/ui/main_window.py
+++ b/src/matteloop/ui/main_window.py
@@ -20,8 +20,9 @@
from matteloop.core.execution_providers import ProviderOption
from matteloop.core.state import AppState, FocusTarget
from matteloop.ui.action_shelf import ActionShelf
-from matteloop.ui.crop_view import render_source_editor
+from matteloop.ui.copy import main_window_copy
from matteloop.ui.inspector import Inspector
+from matteloop.ui.main_window_render import render_source_error, render_window
from matteloop.ui.ports import (
ChooseVideoRequested,
ManageModelsRequested,
@@ -57,7 +58,7 @@ def __init__(
) -> None:
super().__init__(parent)
self.setObjectName("main_window")
- self.setAccessibleName("MatteLoop")
+ self.setAccessibleName(main_window_copy("MatteLoop"))
self.setMinimumSize(1100, 720)
self.resize(1440, 900)
self._store = store
@@ -67,6 +68,7 @@ def __init__(
self._provider_options = provider_options
self._unsubscribe: Callable[[], None] | None = None
self._last_focus: FocusTarget | None = None
+ self._success_artifact_path = ""
self._build()
self._set_tab_order()
self._restore_geometry()
@@ -101,9 +103,13 @@ def _build(self) -> None:
self.result_canvas = self.preview_stage.result_canvas
self.timeline_widget = TimelineWidget()
self.timeline_placeholder = self.timeline_widget
- self.source_error_heading = StatusLabel("Couldn’t read this video")
+ self.source_error_heading = StatusLabel(
+ main_window_copy("Couldn’t read this video")
+ )
self.source_error_heading.setObjectName("source_error_heading")
- self.source_error_heading.setAccessibleName("Video load error")
+ self.source_error_heading.setAccessibleName(
+ main_window_copy("Video load error")
+ )
self.source_error_heading.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.source_error_heading.hide()
self.source_error_copy = QLabel()
@@ -128,7 +134,9 @@ def _build(self) -> None:
)
self.inspector_scroll = self.inspector.scroll_area
self.inspector_content = self.inspector.scroll_area.widget()
- self.action_shelf = ActionShelf(self._store, self._services)
+ self.action_shelf = ActionShelf(
+ self._store, self._services, settings=self._settings
+ )
self.preview_button = self.action_shelf.preview_button
self.render_button = self.action_shelf.render_button
self.rebuild_button = self.inspector.rebuild_button
@@ -141,9 +149,9 @@ def _build(self) -> None:
success_info_row.setObjectName("success_info_row")
info_layout = QHBoxLayout(success_info_row)
info_layout.setContentsMargins(0, 0, 0, 0)
- self.success_banner = QLabel("Render complete")
+ self.success_banner = QLabel(main_window_copy("Render complete"))
self.success_banner.setObjectName("success_banner")
- self.success_banner.setAccessibleName("Render complete")
+ self.success_banner.setAccessibleName(main_window_copy("Render complete"))
self.success_banner.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.success_artifact = QLabel()
self.success_artifact.setObjectName("success_artifact")
@@ -159,12 +167,12 @@ def _build(self) -> None:
actions_layout = QHBoxLayout(success_actions_row)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.addStretch(1)
- self.open_output_button = QPushButton("Open output")
+ self.open_output_button = QPushButton(main_window_copy("Open output"))
self.open_output_button.setObjectName("open_output")
- self.open_output_button.setAccessibleName("Open output")
- self.open_folder_button = QPushButton("Open folder")
+ self.open_output_button.setAccessibleName(main_window_copy("Open output"))
+ self.open_folder_button = QPushButton(main_window_copy("Open folder"))
self.open_folder_button.setObjectName("open_output_folder")
- self.open_folder_button.setAccessibleName("Open folder")
+ self.open_folder_button.setAccessibleName(main_window_copy("Open folder"))
actions_layout.addWidget(self.open_output_button)
actions_layout.addWidget(self.open_folder_button)
success_layout.addWidget(success_info_row)
@@ -217,7 +225,8 @@ def _build(self) -> None:
def _set_tab_order(self) -> None:
"""Declare a deterministic keyboard route through the shell."""
widgets = [
- self.source_drop_target, self.choose_video_button,
+ self.source_drop_target,
+ self.choose_video_button,
self.replace_video_button,
self.original_canvas,
self.result_canvas,
@@ -252,71 +261,10 @@ def render_state(self, state: AppState) -> None:
self._render(model)
def _render_source_error(self, model: PresentationModel) -> None:
- """Show the source failure with its icon, message and disclosed detail."""
- self.source_error_heading.setVisible(model.source_error_visible)
- self.source_error_copy.setVisible(model.source_error_visible)
- self.source_error_heading.setText("Couldn’t read this video")
- self.source_error_heading.set_status_icon(model.source_error_icon)
- self.source_error_heading.setToolTip(model.source_error_detail or "")
- self.source_error_heading.setAccessibleDescription(
- model.source_error_detail or ""
- )
- self.source_error_copy.setText(model.source_error_message or "")
+ render_source_error(self, model)
def _render(self, model: PresentationModel) -> None:
- self.source_drop_surface.setVisible(model.source_surface_visible)
- self.source_strip.setVisible(model.source_strip_visible)
- self.preview_stage.setVisible(model.show_stage)
- self.timeline_widget.setVisible(model.show_timeline)
- self.timeline_widget.apply_presentation(model.timeline, not model.editor_locked)
- self._render_source_error(model)
- self.source_drop_surface.heading.setText(model.source_surface_heading)
- self.source_strip.set_presented_metadata(model)
- render_source_editor(self.original_canvas, self.inspector, model)
- self.result_canvas.set_presented_frame(model.result_frame, model.result_message)
- self.result_canvas.setAccessibleName(model.result_accessible_name)
- self.result_canvas.setAccessibleDescription(model.result_accessible_description)
- self.result_canvas.setProperty("status", model.result_status)
- self.result_canvas.setProperty("checkerboard", model.result_checkerboard)
- self.result_canvas.set_status_marker(
- model.result_status_marker, model.result_status_icon
- )
- self.result_canvas.style().unpolish(self.result_canvas)
- self.result_canvas.style().polish(self.result_canvas)
- self.choose_video_button.setEnabled(model.choose_enabled)
- self.replace_video_button.setEnabled(model.replace_enabled)
- self.preview_button.setEnabled(model.preview_enabled)
- self.preview_button.setText(model.preview_label)
- self.render_button.setEnabled(model.render_enabled)
- self.rebuild_button.setEnabled(model.rebuild_enabled)
- self.rebuild_button.setVisible(model.show_rebuild)
- self.edited_cut_recovery.setVisible(model.recovery_visible)
- self.edited_cut_recovery.setText(model.recovery_label)
- self.inspector.set_workspace_state(
- model.workspace_attention, model.workspace_open
- )
- self.open_output_button.setEnabled(model.open_output_enabled)
- self.open_folder_button.setEnabled(model.open_folder_enabled)
- self.success_container.setVisible(model.show_success)
- self.success_banner.setText(model.success_label)
- artifact_path = model.artifact_path or ""
- self._success_artifact_path = artifact_path
- self._update_success_artifact()
- self.success_artifact.setToolTip(artifact_path)
- self.success_artifact.setAccessibleDescription(artifact_path)
- self.success_banner.setToolTip(artifact_path)
- self.success_banner.setAccessibleDescription(model.success_accessible_description)
- self.inspector.setEnabled(model.inspector_enabled)
- for name, button in (
- ("preview", self.preview_button),
- ("render", self.render_button),
- ):
- button.setProperty("primaryAction", model.primary_action == name)
- button.setProperty("primary", model.primary_action == name)
- button.style().unpolish(button)
- button.style().polish(button)
- self.preview_button.setAccessibleName(model.preview_label)
- self._queue_focus(model.focus_target)
+ render_window(self, model)
def _update_success_artifact(self) -> None:
if not hasattr(self, "success_artifact"):
diff --git a/src/matteloop/ui/main_window_render.py b/src/matteloop/ui/main_window_render.py
new file mode 100644
index 0000000..5ddee28
--- /dev/null
+++ b/src/matteloop/ui/main_window_render.py
@@ -0,0 +1,101 @@
+"""Rendering helpers kept outside the main-window construction module."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from matteloop.ui.copy import main_window_copy, presented_copy, source_error_message
+from matteloop.ui.crop_view import render_source_editor
+from matteloop.ui.presentation_model import PresentationModel
+
+if TYPE_CHECKING:
+ from matteloop.ui.main_window import MainWindow
+
+
+def render_source_error(window: MainWindow, model: PresentationModel) -> None:
+ """Show the source failure with its icon, message and disclosed detail."""
+ window.source_error_heading.setVisible(model.source_error_visible)
+ window.source_error_copy.setVisible(model.source_error_visible)
+ window.source_error_heading.setText(main_window_copy("Couldn’t read this video"))
+ window.source_error_heading.set_status_icon(model.source_error_icon)
+ window.source_error_heading.setToolTip(model.source_error_detail or "")
+ window.source_error_heading.setAccessibleDescription(
+ model.source_error_detail or ""
+ )
+ window.source_error_copy.setText(
+ source_error_message(model.source_error_message or "")
+ )
+
+
+def render_window(window: MainWindow, model: PresentationModel) -> None:
+ """Render the immutable presentation model into the main-window widgets."""
+ _render_source_and_result(window, model)
+ _render_actions(window, model)
+
+
+def _render_source_and_result(window: MainWindow, model: PresentationModel) -> None:
+ window.source_drop_surface.setVisible(model.source_surface_visible)
+ window.source_strip.setVisible(model.source_strip_visible)
+ window.preview_stage.setVisible(model.show_stage)
+ window.timeline_widget.setVisible(model.show_timeline)
+ window.timeline_widget.apply_presentation(model.timeline, not model.editor_locked)
+ render_source_error(window, model)
+ window.source_drop_surface.heading.setText(
+ presented_copy(model.source_surface_heading)
+ )
+ window.source_strip.set_presented_metadata(model)
+ render_source_editor(window.original_canvas, window.inspector, model)
+ window.result_canvas.set_presented_frame(
+ model.result_frame, presented_copy(model.result_message)
+ )
+ window.result_canvas.setAccessibleName(presented_copy(model.result_accessible_name))
+ window.result_canvas.setAccessibleDescription(
+ presented_copy(model.result_accessible_description)
+ )
+ window.result_canvas.setProperty("status", model.result_status)
+ window.result_canvas.setProperty("checkerboard", model.result_checkerboard)
+ status_marker = presented_copy(model.result_status_marker or "")
+ window.result_canvas.set_status_marker(
+ status_marker or None, model.result_status_icon
+ )
+ window.result_canvas.style().unpolish(window.result_canvas)
+ window.result_canvas.style().polish(window.result_canvas)
+
+
+def _render_actions(window: MainWindow, model: PresentationModel) -> None:
+ window.choose_video_button.setEnabled(model.choose_enabled)
+ window.replace_video_button.setEnabled(model.replace_enabled)
+ window.preview_button.setEnabled(model.preview_enabled)
+ window.preview_button.setText(presented_copy(model.preview_label))
+ window.render_button.setEnabled(model.render_enabled)
+ window.rebuild_button.setEnabled(model.rebuild_enabled)
+ window.rebuild_button.setVisible(model.show_rebuild)
+ window.edited_cut_recovery.setVisible(model.recovery_visible)
+ window.edited_cut_recovery.setText(presented_copy(model.recovery_label))
+ window.inspector.set_workspace_state(
+ model.workspace_attention, model.workspace_open
+ )
+ window.open_output_button.setEnabled(model.open_output_enabled)
+ window.open_folder_button.setEnabled(model.open_folder_enabled)
+ window.success_container.setVisible(model.show_success)
+ window.success_banner.setText(presented_copy(model.success_label))
+ artifact_path = model.artifact_path or ""
+ window._success_artifact_path = artifact_path
+ window._update_success_artifact()
+ window.success_artifact.setToolTip(artifact_path)
+ window.success_artifact.setAccessibleDescription(artifact_path)
+ window.success_banner.setToolTip(artifact_path)
+ window.success_banner.setAccessibleDescription(
+ presented_copy(model.success_accessible_description)
+ )
+ window.inspector.setEnabled(model.inspector_enabled)
+ for name, button in (
+ ("preview", window.preview_button),
+ ("render", window.render_button),
+ ):
+ button.setProperty("primaryAction", model.primary_action == name)
+ button.setProperty("primary", model.primary_action == name)
+ button.style().unpolish(button)
+ button.style().polish(button)
+ window.preview_button.setAccessibleName(presented_copy(model.preview_label))
+ window._queue_focus(model.focus_target)
diff --git a/src/matteloop/ui/model_manager/_controller.py b/src/matteloop/ui/model_manager/_controller.py
index 954ad1d..486ee8d 100644
--- a/src/matteloop/ui/model_manager/_controller.py
+++ b/src/matteloop/ui/model_manager/_controller.py
@@ -4,7 +4,7 @@
from pathlib import Path
from typing import Protocol
-from PySide6.QtCore import QObject, Qt, QThread, QUrl, Slot
+from PySide6.QtCore import QCoreApplication, QObject, Qt, QThread, QUrl, Slot
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox, QWidget
@@ -12,6 +12,7 @@
from matteloop.core.state import JobState, ModelAvailabilityChanged
from matteloop.jobs.context import CancellationState
from matteloop.jobs.models.catalog import ModelCatalog
+from matteloop.ui.copy import model_display_name
from matteloop.ui.ports import StateStore
from matteloop.ui.source_presentation import (
DownloadRateEstimator,
@@ -29,29 +30,11 @@
_OutdatedRedownloadWorker,
)
-_DOWNLOAD_BLOCKED_MESSAGE = "Cannot download a model while a job is running."
-
-
-def _confirm_redownload(
- dialog: ModelManagerDialog, entries: tuple[ModelEntry, ...]
-) -> QMessageBox.StandardButton:
- count = len(entries)
- size = format_source_file_size(sum(entry.download_size_bytes for entry in entries))
- versions = ", ".join(dialog.obsolete_versions)
- directory_word = (
- "directory" if len(dialog.obsolete_versions) == 1 else "directories"
- )
- return QMessageBox.question(
- dialog,
- "Re-download outdated model weights?",
- f"Download {count} model weight(s) ({size})? "
- f"Each outdated copy from rembg {versions} is deleted once its replacement "
- "has been verified. "
- f"Other files in the {versions} {directory_word} stay until you use "
- "Delete outdated.",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No,
- )
+
+def _directory_word(single: bool) -> str:
+ if single:
+ return QCoreApplication.translate("ModelManagerDialog", "directory")
+ return QCoreApplication.translate("ModelManagerDialog", "directories")
class ModelRemovalService(Protocol):
@@ -135,11 +118,45 @@ def close(self) -> None:
self.dialog.set_busy(False)
self.dialog.close()
+ def _confirm_redownload(
+ self, entries: tuple[ModelEntry, ...]
+ ) -> QMessageBox.StandardButton:
+ count = len(entries)
+ size = format_source_file_size(
+ sum(entry.download_size_bytes for entry in entries)
+ )
+ versions = ", ".join(self.dialog.obsolete_versions)
+ question = QCoreApplication.translate(
+ "ModelManagerDialog", "Re-download outdated model weights?"
+ )
+ message = self.tr(
+ "Download %n model weight(s) (%1)? Each outdated copy from rembg %2 is "
+ "deleted "
+ "once its replacement has been verified. Other files in the %2 %3 stay "
+ "until you use Delete outdated.",
+ "",
+ count,
+ )
+ message = message.replace("%1", size).replace("%2", versions).replace(
+ "%3", _directory_word(len(self.dialog.obsolete_versions) == 1)
+ )
+ return QMessageBox.question(
+ self.dialog,
+ question,
+ message,
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+
def _removal_block_reason(self, entry: ModelEntry) -> str | None:
if self._store.state.job.phase is not JobState.IDLE:
- return "Cannot remove a model while a job is running."
+ return QCoreApplication.translate(
+ "ModelManagerDialog", "Cannot remove a model while a job is running."
+ )
if self._manager is None:
- return "Model removal is unavailable in this runtime."
+ return QCoreApplication.translate(
+ "ModelManagerDialog", "Model removal is unavailable in this runtime."
+ )
return None
def _download_requested(self, value: object) -> None:
@@ -148,7 +165,12 @@ def _download_requested(self, value: object) -> None:
return
if self._remove_thread or self._store.state.job.phase is not JobState.IDLE:
if self._remove_thread is None:
- self.dialog.set_message(_DOWNLOAD_BLOCKED_MESSAGE)
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Cannot download a model while a job is running.",
+ )
+ )
return
cancel = CancellationState()
worker = _ModelDownloadWorker(manager, value.model_id, cancel)
@@ -166,7 +188,10 @@ def _download_requested(self, value: object) -> None:
self._cancel = cancel
self._batch_position = ""
self.dialog.set_busy(True, cancellable=True)
- self.dialog.set_message(f"Downloading {value.display_name}…")
+ self.dialog.set_message(
+ QCoreApplication.translate("ModelManagerDialog", "Downloading %s…")
+ % model_display_name(value.model_id, value.display_name)
+ )
thread.start()
def _delete_outdated_requested(self) -> None:
@@ -174,21 +199,39 @@ def _delete_outdated_requested(self) -> None:
if self.dialog.obsolete_size_bytes <= 0:
return
if self._store.state.job.phase is not JobState.IDLE:
- self.dialog.set_message("Cannot remove a model while a job is running.")
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Cannot remove a model while a job is running.",
+ )
+ )
return
if manager is None:
- self.dialog.set_message("Model removal is unavailable in this runtime.")
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Model removal is unavailable in this runtime.",
+ )
+ )
return
versions = ", ".join(self.dialog.obsolete_versions)
- directory_word = (
- "directory" if len(self.dialog.obsolete_versions) == 1 else "directories"
- )
size = format_source_file_size(self.dialog.obsolete_size_bytes)
answer = QMessageBox.question(
self.dialog,
- f"Delete all weights from rembg {versions}?",
- f"This removes the whole {versions} {directory_word}: {size} on disk. "
- "Weights this version needs are downloaded again on demand.",
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Delete all weights from rembg %s?"
+ )
+ % versions,
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "This removes the whole %s %s: %s on disk. Weights this version "
+ "needs are downloaded again on demand.",
+ )
+ % (
+ versions,
+ _directory_word(len(self.dialog.obsolete_versions) == 1),
+ size,
+ ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
@@ -203,15 +246,25 @@ def _redownload_outdated_requested(self) -> None:
if not entries:
return
if self._store.state.job.phase is not JobState.IDLE:
- self.dialog.set_message(_DOWNLOAD_BLOCKED_MESSAGE)
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Cannot download a model while a job is running.",
+ )
+ )
return
manager = self._manager
if manager is None:
- self.dialog.set_message("Model removal is unavailable in this runtime.")
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Model removal is unavailable in this runtime.",
+ )
+ )
return
if self._remove_thread is not None:
return
- if _confirm_redownload(self.dialog, entries) != QMessageBox.StandardButton.Yes:
+ if self._confirm_redownload(entries) != QMessageBox.StandardButton.Yes:
return
cancel = CancellationState()
self._batch = {entry.model_id: entry for entry in entries}
@@ -233,8 +286,13 @@ def _redownload_outdated_requested(self) -> None:
self._batch_failure = None
self._batch_position = ""
self.dialog.set_busy(True, cancellable=True)
+ batch_count = len(self._batch)
self.dialog.set_message(
- f"Re-downloading {len(self._batch)} outdated model weight(s)…"
+ self.tr(
+ "Re-downloading %n outdated model weight(s)…",
+ "",
+ batch_count,
+ )
)
thread.start()
@@ -257,7 +315,11 @@ def _start_obsolete_removal(self, manager: ModelRemovalService) -> None:
self._remove_worker = worker
self._removal_entry = None
self.dialog.set_busy(True)
- self.dialog.set_message("Removing outdated model weights…")
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Removing outdated model weights…"
+ )
+ )
thread.start()
@Slot(int, int)
@@ -270,7 +332,8 @@ def _download_progressed(self, completed: int, total: int) -> None:
speed = self._download_rate.update(completed, time.monotonic())
self.dialog.set_message(
format_model_download_detail(
- entry.display_name + self._batch_position,
+ model_display_name(entry.model_id, entry.display_name)
+ + self._batch_position,
completed,
total,
speed,
@@ -283,10 +346,16 @@ def _batch_model_started(self, model_id: str) -> None:
if entry is None:
return
self._removal_entry = entry
- self._batch_position = f" ({self._batch_done + 1} of {len(self._batch)})"
+ self._batch_position = QCoreApplication.translate(
+ "ModelManagerDialog", " (%s of %s)"
+ ) % (self._batch_done + 1, len(self._batch))
self._download_rate = DownloadRateEstimator()
self.dialog.set_message(
- f"Downloading {entry.display_name}{self._batch_position}…"
+ QCoreApplication.translate("ModelManagerDialog", "Downloading %s%s…")
+ % (
+ model_display_name(entry.model_id, entry.display_name),
+ self._batch_position,
+ )
)
@Slot(str)
@@ -295,7 +364,11 @@ def _batch_model_downloaded(self, model_id: str) -> None:
if entry is None:
return
self.dialog.set_message(
- f"Downloaded {entry.display_name}{self._batch_position}."
+ QCoreApplication.translate("ModelManagerDialog", "Downloaded %s%s.")
+ % (
+ model_display_name(entry.model_id, entry.display_name),
+ self._batch_position,
+ )
)
self._batch_done += 1
if self._store.state.parameters.model_id == model_id:
@@ -305,23 +378,38 @@ def _batch_model_downloaded(self, model_id: str) -> None:
def _batch_model_failed(self, model_id: str, error: object) -> None:
entry = self._batch.get(model_id)
if entry is not None:
- self._batch_failure = f"Could not download {entry.display_name}: {error}."
+ self._batch_failure = QCoreApplication.translate(
+ "ModelManagerDialog", "Could not download %s: %s."
+ ) % (model_display_name(entry.model_id, entry.display_name), error)
@Slot()
def _batch_finished(self) -> None:
self.dialog.refresh()
if self._batch_failure is not None:
+ completed = self._batch_done
message = (
- f"{self._batch_failure} Re-downloaded {self._batch_done} of "
- f"{len(self._batch)} outdated model weight(s)."
+ self.tr(
+ "%s Re-downloaded %n of %1 outdated model weight(s).",
+ "",
+ completed,
+ )
+ .replace("%1", str(len(self._batch)))
+ .replace("%s", self._batch_failure, 1)
)
elif self._cancel is not None and self._cancel.requested:
- message = (
- f"Download cancelled. Re-downloaded {self._batch_done} of "
- f"{len(self._batch)} outdated model weight(s)."
- )
+ completed = self._batch_done
+ message = self.tr(
+ "Download cancelled. Re-downloaded %n of %1 outdated model weight(s).",
+ "",
+ completed,
+ ).replace("%1", str(len(self._batch)))
else:
- message = f"Re-downloaded {len(self._batch)} outdated model weight(s)."
+ batch_count = len(self._batch)
+ message = self.tr(
+ "Re-downloaded %n outdated model weight(s).",
+ "",
+ batch_count,
+ )
self.dialog.set_message(message)
@Slot(str)
@@ -330,7 +418,10 @@ def _download_succeeded(self, model_id: str) -> None:
if entry is None or entry.model_id != model_id:
return
self.dialog.refresh()
- self.dialog.set_message(f"Downloaded {entry.display_name}.")
+ self.dialog.set_message(
+ QCoreApplication.translate("ModelManagerDialog", "Downloaded %s.")
+ % model_display_name(entry.model_id, entry.display_name)
+ )
if self._store.state.parameters.model_id == model_id:
self._store.dispatch(ModelAvailabilityChanged(True))
@@ -340,23 +431,43 @@ def _download_failed(self, model_id: str, error: object) -> None:
if entry is None or entry.model_id != model_id:
return
if isinstance(error, AppError) and error.code is ErrorCode.JOB_CANCELLED:
- self.dialog.set_message("Download cancelled.")
+ self.dialog.set_message(
+ QCoreApplication.translate("ModelManagerDialog", "Download cancelled.")
+ )
return
- self.dialog.set_message(f"Could not download the weight: {error}")
- QMessageBox.warning(self.dialog, "Could not download model", str(error))
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Could not download the weight: %s"
+ )
+ % error
+ )
+ QMessageBox.warning(
+ self.dialog,
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Could not download model"
+ ),
+ str(error),
+ )
@Slot(int)
def _obsolete_removal_succeeded(self, removed: int) -> None:
self.dialog.refresh()
self.dialog.set_message(
- f"Removed {removed} outdated model version(s) from the cache."
+ self.tr(
+ "Removed %n outdated model version(s) from the cache.",
+ "",
+ removed,
+ )
)
@Slot(object)
def _obsolete_removal_failed(self, error: object) -> None:
self.dialog.refresh()
self.dialog.set_message(
- f"Could not remove outdated weights: {type(error).__name__}: {error}"
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Could not remove outdated weights: %s: %s"
+ )
+ % (type(error).__name__, error)
)
def _remove_requested(self, value: object) -> None:
@@ -370,9 +481,15 @@ def _remove_requested(self, value: object) -> None:
return
answer = QMessageBox.question(
self.dialog,
- "Remove model weight?",
- f"Remove {value.display_name}'s downloaded weight?\n"
- f"This frees {format_source_file_size(value.disk_size_bytes or 0)}.",
+ QCoreApplication.translate("ModelManagerDialog", "Remove model weight?"),
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Remove %s's downloaded weight?\nThis frees %s.",
+ )
+ % (
+ model_display_name(value.model_id, value.display_name),
+ format_source_file_size(value.disk_size_bytes or 0),
+ ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
@@ -407,18 +524,29 @@ def _removal_succeeded(self, model_id: str, removed: bool) -> None:
if not removed:
self.dialog.refresh()
self.dialog.set_message(
- f"No weight could be removed at {entry.artifact_path}."
+ QCoreApplication.translate(
+ "ModelManagerDialog", "No weight could be removed at %s."
+ )
+ % entry.artifact_path
)
return
freed = format_source_file_size(entry.disk_size_bytes or 0)
self.dialog.refresh()
- self.dialog.set_message(f"Removed {entry.display_name}; freed {freed}.")
+ display_name = model_display_name(entry.model_id, entry.display_name)
+ self.dialog.set_message(
+ QCoreApplication.translate("ModelManagerDialog", "Removed %s; freed %s.")
+ % (display_name, freed)
+ )
if self._store.state.parameters.model_id == model_id:
self._store.dispatch(ModelAvailabilityChanged(False))
QMessageBox.information(
self.dialog,
- "Model removed",
- f"Removed {entry.display_name}'s downloaded weight.\nFreed {freed}.",
+ QCoreApplication.translate("ModelManagerDialog", "Model removed"),
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Removed %s's downloaded weight.\nFreed %s.",
+ )
+ % (display_name, freed),
)
@Slot(str, object)
@@ -426,8 +554,17 @@ def _removal_failed(self, model_id: str, error: object) -> None:
entry = self._removal_entry
if entry is None or entry.model_id != model_id:
return
- self.dialog.set_message(f"Could not remove the selected weight: {error}")
- QMessageBox.warning(self.dialog, "Could not remove model", str(error))
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Could not remove the selected weight: %s"
+ )
+ % error
+ )
+ QMessageBox.warning(
+ self.dialog,
+ QCoreApplication.translate("ModelManagerDialog", "Could not remove model"),
+ str(error),
+ )
@Slot()
def _removal_thread_finished(self) -> None:
@@ -447,7 +584,15 @@ def _show_cache(self) -> None:
try:
root.mkdir(parents=True, exist_ok=True)
except OSError as error:
- self.dialog.set_message(f"Could not open the model folder: {error}")
+ self.dialog.set_message(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Could not open the model folder: %s"
+ )
+ % error
+ )
return
if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(root))):
- self.dialog.set_message(f"Model folder: {root}")
+ self.dialog.set_message(
+ QCoreApplication.translate("ModelManagerDialog", "Model folder: %s")
+ % root
+ )
diff --git a/src/matteloop/ui/model_manager/_dialog.py b/src/matteloop/ui/model_manager/_dialog.py
index 6c43110..ae68425 100644
--- a/src/matteloop/ui/model_manager/_dialog.py
+++ b/src/matteloop/ui/model_manager/_dialog.py
@@ -3,7 +3,7 @@
from collections.abc import Callable
from pathlib import Path
-from PySide6.QtCore import QSize, Signal
+from PySide6.QtCore import QCoreApplication, QSize, Signal
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
@@ -53,8 +53,12 @@ def __init__(
if not isinstance(cache_root, Path):
raise TypeError("cache_root must be a Path")
self.setObjectName("model_manager")
- self.setWindowTitle("Model manager")
- self.setAccessibleName("Model manager")
+ self.setWindowTitle(
+ QCoreApplication.translate("ModelManagerDialog", "Model manager")
+ )
+ self.setAccessibleName(
+ QCoreApplication.translate("ModelManagerDialog", "Model manager")
+ )
self.setModal(True)
self.resize(1080, 460)
self._catalog = catalog
@@ -77,49 +81,108 @@ def _build_widgets(self) -> None:
self._message.setProperty("secondary", True)
self.total_size_label = QLabel()
self.total_size_label.setObjectName("model_cache_total")
- self.total_size_label.setAccessibleName("Total model cache size")
+ self.total_size_label.setAccessibleName(
+ QCoreApplication.translate("ModelManagerDialog", "Total model cache size")
+ )
self.cache_location_label = QLabel(str(self._cache_root))
self.cache_location_label.setObjectName("model_cache_location")
- self.cache_location_label.setAccessibleName("Model cache location")
+ self.cache_location_label.setAccessibleName(
+ QCoreApplication.translate("ModelManagerDialog", "Model cache location")
+ )
self.cache_location_label.setToolTip(str(self._cache_root))
self.cache_location_label.setAccessibleDescription(str(self._cache_root))
self.model_list = QListWidget()
self.model_list.setObjectName("model_list")
- self.model_list.setAccessibleName("V1 models")
+ self.model_list.setAccessibleName(
+ QCoreApplication.translate("ModelManagerDialog", "V1 models")
+ )
self.model_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
self.model_list.setItemDelegate(AlignedRowDelegate(self.model_list))
self.model_list.setUniformItemSizes(True)
- self.download_button = QPushButton("Download weight")
+ self._build_action_widgets()
+
+ def _build_action_widgets(self) -> None:
+ self._build_model_actions()
+ self._build_cache_actions()
+
+ def _build_model_actions(self) -> None:
+ self.download_button = QPushButton(
+ QCoreApplication.translate("ModelManagerDialog", "Download weight")
+ )
self.download_button.setObjectName("download_model")
- self.download_button.setAccessibleName("Download selected model weight")
- self.remove_button = QPushButton("Remove downloaded weight")
+ self.download_button.setAccessibleName(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Download selected model weight"
+ )
+ )
+ self.remove_button = QPushButton(
+ QCoreApplication.translate("ModelManagerDialog", "Remove downloaded weight")
+ )
self.remove_button.setObjectName("remove_model")
- self.remove_button.setAccessibleName("Remove selected model weight")
- self.redownload_outdated_button = QPushButton("Re-download outdated")
+ self.remove_button.setAccessibleName(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Remove selected model weight"
+ )
+ )
+ self.redownload_outdated_button = QPushButton(
+ QCoreApplication.translate("ModelManagerDialog", "Re-download outdated")
+ )
self.redownload_outdated_button.setObjectName("redownload_outdated")
self.redownload_outdated_button.setAccessibleName(
- "Re-download outdated model weights"
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Re-download outdated model weights"
+ )
)
+
+ def _build_cache_actions(self) -> None:
self.outdated_notice_label = QLabel()
self.outdated_notice_label.setObjectName("outdated_model_notice")
self.outdated_notice_label.setWordWrap(True)
self.outdated_notice_label.setVisible(False)
- self.delete_outdated_button = QPushButton("Delete outdated")
+ self.delete_outdated_button = QPushButton(
+ QCoreApplication.translate("ModelManagerDialog", "Delete outdated")
+ )
self.delete_outdated_button.setObjectName("delete_outdated")
- self.delete_outdated_button.setAccessibleName("Delete outdated model weights")
- self.show_cache_button = QPushButton("Show cache location")
+ self.delete_outdated_button.setAccessibleName(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Delete outdated model weights"
+ )
+ )
+ self.show_cache_button = QPushButton(
+ QCoreApplication.translate("ModelManagerDialog", "Show cache location")
+ )
self.show_cache_button.setObjectName("show_model_cache")
- self.show_cache_button.setAccessibleName("Show model cache location")
- self.close_button = QPushButton("Close")
- self.close_button.setAccessibleName("Close model manager")
- self.cancel_button = QPushButton("Cancel")
+ self.show_cache_button.setAccessibleName(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Show model cache location"
+ )
+ )
+ self.close_button = QPushButton(
+ QCoreApplication.translate("ModelManagerDialog", "Close")
+ )
+ self.close_button.setAccessibleName(
+ QCoreApplication.translate("ModelManagerDialog", "Close model manager")
+ )
+ self.cancel_button = QPushButton(
+ QCoreApplication.translate("ModelManagerDialog", "Cancel")
+ )
self.cancel_button.setObjectName("cancel_model_download")
- self.cancel_button.setAccessibleName("Cancel the running model download")
+ self.cancel_button.setAccessibleName(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Cancel the running model download"
+ )
+ )
self.cancel_button.setVisible(False)
def _build_layout(self) -> None:
layout = QVBoxLayout(self)
- layout.addWidget(QLabel("Manage the downloaded V1 model weights."))
+ layout.addWidget(
+ QLabel(
+ QCoreApplication.translate(
+ "ModelManagerDialog", "Manage the downloaded V1 model weights."
+ )
+ )
+ )
layout.addWidget(self._message)
layout.addWidget(self.total_size_label)
layout.addWidget(self.cache_location_label)
@@ -199,19 +262,27 @@ def set_busy(self, busy: bool, *, cancellable: bool = False) -> None:
self.show_cache_button.setEnabled(not busy)
self.close_button.setEnabled(not busy)
if busy and cancellable:
- self.cancel_button.setText("Cancel")
+ self.cancel_button.setText(
+ QCoreApplication.translate("ModelManagerDialog", "Cancel")
+ )
self.cancel_button.setVisible(True)
self.cancel_button.setEnabled(True)
else:
- self.cancel_button.setText("Cancel")
+ self.cancel_button.setText(
+ QCoreApplication.translate("ModelManagerDialog", "Cancel")
+ )
self.cancel_button.setVisible(False)
self.cancel_button.setEnabled(True)
self._update_actions()
def set_cancelling(self) -> None:
- self.cancel_button.setText("Cancelling…")
+ self.cancel_button.setText(
+ QCoreApplication.translate("ModelManagerDialog", "Cancelling…")
+ )
self.cancel_button.setEnabled(False)
- self.set_message("Cancelling…")
+ self.set_message(
+ QCoreApplication.translate("ModelManagerDialog", "Cancelling…")
+ )
def reject(self) -> None:
if self._busy:
@@ -225,7 +296,10 @@ def set_message(self, message: str) -> None:
def _active_removal_guard(self, entry: ModelEntry) -> str | None:
if self._active_model() == entry.model_id:
- return "Cannot remove the model used by the active session."
+ return QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Cannot remove the model used by the active session.",
+ )
return None
def refresh(self) -> None:
@@ -263,13 +337,16 @@ def refresh(self) -> None:
self.model_list.setCurrentRow(0)
total = sum(entry.disk_size_bytes or 0 for entry in self._entries)
total += self._obsolete_size_bytes
- self.total_size_label.setText(
- f"Total on disk: {format_source_file_size(total)}"
- )
- self.total_size_label.setAccessibleDescription(
- f"Total on disk: {format_source_file_size(total)}"
- )
- self.set_message(f"{len(self._entries)} V1 model(s); cache: {self._cache_root}")
+ total_text = QCoreApplication.translate(
+ "ModelManagerDialog", "Total on disk: %s"
+ ) % format_source_file_size(total)
+ self.total_size_label.setText(total_text)
+ self.total_size_label.setAccessibleDescription(total_text)
+ entry_count = len(self._entries)
+ message = self.tr(
+ "%n V1 model(s); cache: %1", "", entry_count
+ ).replace("%1", str(self._cache_root))
+ self.set_message(message)
self._update_outdated_notice(self._obsolete_size_bytes)
self._update_actions()
@@ -280,9 +357,12 @@ def _update_outdated_notice(self, size_bytes: int) -> None:
return
versions = ", ".join(self._catalog.obsolete_rembg_versions)
self.outdated_notice_label.setText(
- f"Outdated weights from rembg {versions} occupy "
- f"{format_source_file_size(size_bytes)} on disk and cannot be used by "
- "this version."
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Outdated weights from rembg %s occupy %s on disk and cannot be "
+ "used by this version.",
+ )
+ % (versions, format_source_file_size(size_bytes))
)
self.outdated_notice_label.setVisible(True)
@@ -298,14 +378,20 @@ def _update_actions(self) -> None:
self.remove_button.setAccessibleDescription(blocked)
else:
self.remove_button.setToolTip(
- "Remove the selected downloaded weight and free its disk space."
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Remove the selected downloaded weight and free its disk space.",
+ )
)
self.remove_button.setAccessibleDescription("")
self.download_button.setEnabled(
not self._busy and entry is not None and not entry.cached
)
self.download_button.setToolTip(
- "Download the selected weight now instead of waiting for a preview."
+ QCoreApplication.translate(
+ "ModelManagerDialog",
+ "Download the selected weight now instead of waiting for a preview.",
+ )
)
has_outdated = self._obsolete_size_bytes > 0
self.delete_outdated_button.setVisible(has_outdated)
diff --git a/src/matteloop/ui/model_manager/_entries.py b/src/matteloop/ui/model_manager/_entries.py
index d1ecf0c..470c241 100644
--- a/src/matteloop/ui/model_manager/_entries.py
+++ b/src/matteloop/ui/model_manager/_entries.py
@@ -5,6 +5,8 @@
from dataclasses import dataclass
from pathlib import Path
+from PySide6.QtCore import QCoreApplication
+
from matteloop.jobs.models.catalog import ModelCatalog
from matteloop.ui.aligned_rows import (
ROW_DATA_ROLE,
@@ -12,6 +14,7 @@
AlignedRow,
RowStatus,
)
+from matteloop.ui.copy import model_display_name
from matteloop.ui.source_presentation import format_source_file_size
MODEL_ENTRY_ROLE = ROW_DATA_ROLE + 11
@@ -37,32 +40,48 @@ def cached(self) -> bool:
def present_model(entry: ModelEntry) -> AlignedRow:
"""Present one model with aligned metadata and spoken status words."""
+ display_name = model_display_name(entry.model_id, entry.display_name)
if entry.cached:
- cache_status = "cached locally"
+ cache_status = QCoreApplication.translate("ModelEntries", "cached locally")
cache_detail = cache_status
if entry.outdated_rembg_version is not None:
- cache_detail += "; outdated copy from rembg " + entry.outdated_rembg_version
+ cache_detail += (
+ QCoreApplication.translate(
+ "ModelEntries", "; outdated copy from rembg %s"
+ )
+ % entry.outdated_rembg_version
+ )
glyph = "◆" if entry.active else "✓"
elif entry.outdated_size_bytes is not None:
- cache_status = "outdated weight"
- version = entry.outdated_rembg_version or "obsolete rembg"
- cache_detail = (
- f"{cache_status} from rembg {version}; "
- f"{format_source_file_size(entry.outdated_size_bytes)} on disk"
+ cache_status = QCoreApplication.translate("ModelEntries", "outdated weight")
+ version = entry.outdated_rembg_version or QCoreApplication.translate(
+ "ModelEntries", "obsolete rembg"
)
+ cache_detail = QCoreApplication.translate(
+ "ModelEntries", "%s from rembg %s; %s on disk"
+ ) % (cache_status, version, format_source_file_size(entry.outdated_size_bytes))
glyph = "⟳"
else:
- cache_status = "not cached"
+ cache_status = QCoreApplication.translate("ModelEntries", "not cached")
cache_detail = cache_status
glyph = "◆" if entry.active else "↓"
- active_status = "active model" if entry.active else "not active"
+ active_status = (
+ QCoreApplication.translate("ModelEntries", "active model")
+ if entry.active
+ else QCoreApplication.translate("ModelEntries", "not active")
+ )
size = format_source_file_size(entry.download_size_bytes)
- detail = f"{entry.display_name}; {size}; {cache_detail}; {active_status}"
+ detail = QCoreApplication.translate("ModelEntries", "%s; %s; %s; %s") % (
+ display_name,
+ size,
+ cache_detail,
+ active_status,
+ )
return AlignedRow(
glyph,
RowStatus.CACHED if entry.cached else RowStatus.UNCACHED,
(
- AlignedColumn(entry.display_name),
+ AlignedColumn(display_name),
AlignedColumn(size, True),
AlignedColumn(cache_status),
AlignedColumn(active_status),
diff --git a/src/matteloop/ui/presenter.py b/src/matteloop/ui/presenter.py
index 0e8db89..77d2a6e 100644
--- a/src/matteloop/ui/presenter.py
+++ b/src/matteloop/ui/presenter.py
@@ -125,9 +125,7 @@ def present(state: AppState) -> PresentationModel:
preview_enabled=allowed.can_preview,
model_available=state.model_available,
preview_label=(
- "Prepare & Preview"
- if not state.model_available
- else "Preview Frame"
+ "Prepare & Preview" if not state.model_available else "Preview Frame"
),
render_enabled=allowed.can_render,
rebuild_enabled=allowed.can_rebuild,
@@ -174,6 +172,5 @@ def present(state: AppState) -> PresentationModel:
state.preview_result.value if state.preview_result is not None else None
),
source_loading=state.source is Source.LOADING,
- inspector_enabled=not active
- and state.source in {Source.EMPTY, Source.READY},
+ inspector_enabled=not active and state.source in {Source.EMPTY, Source.READY},
)
diff --git a/src/matteloop/ui/preview_canvas.py b/src/matteloop/ui/preview_canvas.py
index bc4df1a..25b73c4 100644
--- a/src/matteloop/ui/preview_canvas.py
+++ b/src/matteloop/ui/preview_canvas.py
@@ -4,7 +4,7 @@
from pathlib import Path
-from PySide6.QtCore import QRect, QSize, Qt
+from PySide6.QtCore import QCoreApplication, QRect, QSize, Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
from PySide6.QtWidgets import (
QFrame,
@@ -39,7 +39,10 @@ def __init__(
self.setMinimumHeight(24)
margins = self.contentsMargins()
self._text_margins = (
- margins.left(), margins.top(), margins.right(), margins.bottom()
+ margins.left(),
+ margins.top(),
+ margins.right(),
+ margins.bottom(),
)
def set_status_icon(self, name: str | None, requested_size: int = 24) -> None:
@@ -127,9 +130,11 @@ def __init__(
self._cover_frame = False
self.setObjectName(object_name)
self.setAccessibleName(
- "Original video frame"
- if title == "Original"
- else "Background-removed result"
+ QCoreApplication.translate("PreviewCanvas", "Original video frame")
+ if object_name == "original_canvas"
+ else QCoreApplication.translate(
+ "PreviewCanvas", "Background-removed result"
+ )
)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
@@ -276,11 +281,15 @@ def __init__(
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
- self.original_canvas = CropCanvas()
+ self.original_canvas = CropCanvas(
+ title=QCoreApplication.translate("PreviewCanvas", "Original")
+ )
self.result_canvas = ResultPlayerCanvas(runtime_root=runtime_root)
self.original_canvas.set_cover_frame(False)
self.result_canvas.set_cover_frame(True)
- self.original_canvas.setText("Original")
+ self.original_canvas.setText(
+ QCoreApplication.translate("PreviewStage", "Original")
+ )
self.result_canvas.setProperty("status", "none")
self.result_canvas.setProperty("checkerboard", False)
layout.addWidget(self.original_canvas, 1)
diff --git a/src/matteloop/ui/preview_controller/controller.py b/src/matteloop/ui/preview_controller/controller.py
index 1d6a6af..895834f 100644
--- a/src/matteloop/ui/preview_controller/controller.py
+++ b/src/matteloop/ui/preview_controller/controller.py
@@ -5,7 +5,7 @@
from threading import Thread
from uuid import uuid4
-from PySide6.QtCore import QObject, QThread, Signal, Slot
+from PySide6.QtCore import QCoreApplication, QObject, QThread, Signal, Slot
from PySide6.QtWidgets import QWidget
from matteloop.core.execution_providers import ProviderOption
@@ -128,9 +128,7 @@ def dispatch(self, command: object) -> None:
metadata = state.source_value
if source_id is None or state.source is not SourceState.READY:
return
- inputs = _preview_inputs(
- metadata, state.timeline, state.crop, state.parameters
- )
+ inputs = _preview_inputs(metadata, state.timeline, state.crop, state.parameters)
job_id = uuid4().hex
request_id = uuid4().hex
self._store.dispatch(
@@ -197,9 +195,7 @@ def progress_sink(event: ProgressEvent) -> None:
worker.provider_notice.connect(self._provider_notice)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
- thread.finished.connect(
- lambda job_id=job_id: self._thread_finished(job_id)
- )
+ thread.finished.connect(lambda job_id=job_id: self._thread_finished(job_id))
self._contexts[job_id] = context
self._threads[job_id] = (thread, worker)
self._active_job_id = job_id
@@ -212,7 +208,7 @@ def _open_dialog(self, job_id: str, inputs: _PreviewInputs) -> None:
if not self._dialog_cancel_connected:
self._dialog.cancel_requested.connect(self._cancel_active)
self._dialog_cancel_connected = True
- self._dialog.reset("Preparing model")
+ self._dialog.reset()
self._dialog.set_job_details(inputs.parameters.model_id)
self._dialog.setProperty("jobId", job_id)
self._dialog.open()
@@ -288,9 +284,15 @@ def _update_dialog_for_stage(self, notification: object) -> None:
if isinstance(stage, str) and stage:
self._dialog.set_stage(stage)
if isinstance(notification, ModelPrepared):
- self._dialog.setWindowTitle("Previewing selected frame")
+ self._dialog.setWindowTitle(
+ QCoreApplication.translate(
+ "PreviewJobDialog", "Previewing selected frame"
+ )
+ )
elif stage and stage is not ProgressStage.SEGMENTATION:
- self._dialog.setWindowTitle("Preparing model")
+ self._dialog.setWindowTitle(
+ QCoreApplication.translate("PreviewJobDialog", "Preparing model")
+ )
def _terminal_notification(
self, notification: PreviewSucceeded | PreviewFailed | CancelAcknowledged
diff --git a/src/matteloop/ui/preview_controller/dialog.py b/src/matteloop/ui/preview_controller/dialog.py
index a41f2a4..f525ac5 100644
--- a/src/matteloop/ui/preview_controller/dialog.py
+++ b/src/matteloop/ui/preview_controller/dialog.py
@@ -6,7 +6,7 @@
from fractions import Fraction
from pathlib import Path
-from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal, Slot
+from PySide6.QtCore import QCoreApplication, QEvent, QObject, Qt, QTimer, Signal, Slot
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import (
QDialog,
@@ -22,6 +22,12 @@
from matteloop.core.state import ArtifactResult
from matteloop.core.tokens import ProgressStage
from matteloop.jobs.context import ProgressEvent
+from matteloop.ui.copy import (
+ model_display_name,
+ progress_detail,
+ provider_label,
+ provider_notice,
+)
from matteloop.ui.source_presentation import (
JobProgressMetrics,
JobProgressPresenter,
@@ -32,17 +38,29 @@
format_source_frame_rate,
)
-_STAGE_COPY: dict[str | ProgressStage, str] = {
- ProgressStage.PREPARING_MODEL: "Preparing model",
- ProgressStage.DOWNLOADING_MODEL: "Downloading model",
- ProgressStage.SEGMENTATION: "Segmentation",
- ProgressStage.DECODE: "Decode",
- ProgressStage.RENDER_CUT: "Decode",
-}
-
def _stage_copy(stage: str | ProgressStage) -> str:
- return _STAGE_COPY.get(stage, stage)
+ if stage is ProgressStage.PREPARING_MODEL:
+ return QCoreApplication.translate("PreviewJobDialog", "Preparing model")
+ if stage is ProgressStage.DOWNLOADING_MODEL:
+ return QCoreApplication.translate("PreviewJobDialog", "Downloading model")
+ if stage is ProgressStage.SEGMENTATION:
+ return QCoreApplication.translate("PreviewJobDialog", "Segmentation")
+ if stage in {ProgressStage.DECODE, ProgressStage.RENDER_CUT}:
+ return QCoreApplication.translate("PreviewJobDialog", "Decode")
+ if stage == "Validation":
+ return QCoreApplication.translate("PreviewJobDialog", "Validation")
+ if stage == "Post-process":
+ return QCoreApplication.translate("PreviewJobDialog", "Post-process")
+ if stage == "Auto-fit":
+ return QCoreApplication.translate("PreviewJobDialog", "Auto-fit")
+ if stage == "Encode":
+ return QCoreApplication.translate("PreviewJobDialog", "Encode")
+ if stage == "Validate":
+ return QCoreApplication.translate("PreviewJobDialog", "Validate")
+ if stage == "Complete":
+ return QCoreApplication.translate("PreviewJobDialog", "Complete")
+ return stage
class PreviewJobDialog(QDialog):
@@ -56,24 +74,42 @@ def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("preview_job_dialog")
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
- self.setAccessibleName("Preview job")
+ self.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Preview job")
+ )
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setModal(True)
+ self._build_widgets()
+ self._assemble_layout()
+ self._connect_controls()
+ self._progress_presenter = JobProgressPresenter()
+ self._elapsed_timer = QTimer(self)
+ self._elapsed_timer.setInterval(250)
+ self._elapsed_timer.timeout.connect(self._refresh_metrics)
+ self.reset()
+
+ def _build_widgets(self) -> None:
self.stage_label = QLabel()
self.stage_label.setObjectName("job_stage")
self.detail_label = QLabel()
self.detail_label.setObjectName("job_detail")
self.model_provider_label = QLabel()
self.model_provider_label.setObjectName("job_model_provider")
- self.model_provider_label.setAccessibleName("Active model and provider")
+ self.model_provider_label.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Active model and provider")
+ )
self.output_label = QLabel()
self.output_label.setObjectName("job_output")
- self.output_label.setAccessibleName("Output file")
+ self.output_label.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Output file")
+ )
self._build_completion_summary()
self._build_completion_actions()
self.provider_notice_label = QLabel()
self.provider_notice_label.setObjectName("job_provider_notice")
- self.provider_notice_label.setAccessibleName("Provider notice")
+ self.provider_notice_label.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Provider notice")
+ )
self.elapsed_label = QLabel()
self.elapsed_label.setObjectName("job_elapsed")
self.rate_label = QLabel()
@@ -82,28 +118,30 @@ def __init__(self, parent: QWidget | None = None) -> None:
self.estimate_label.setObjectName("job_estimate")
self.progress_bar = QProgressBar()
self.progress_bar.setObjectName("job_progress")
- self.progress_bar.setAccessibleName("Stage progress")
+ self.progress_bar.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Stage progress")
+ )
self.overall_progress_bar = QProgressBar()
self.overall_progress_bar.setObjectName("job_overall_progress")
- self.overall_progress_bar.setAccessibleName("Overall progress")
+ self.overall_progress_bar.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Overall progress")
+ )
self.stage_progress_label = QLabel()
self.stage_progress_label.setObjectName("job_stage_progress_label")
self.overall_progress_label = QLabel()
self.overall_progress_label.setObjectName("job_overall_progress_label")
- self.cancel_button = QPushButton("Cancel")
+ self.cancel_button = QPushButton(
+ QCoreApplication.translate("PreviewJobDialog", "Cancel")
+ )
self.cancel_button.setObjectName("job_cancel")
- self._assemble_layout()
+
+ def _connect_controls(self) -> None:
self.cancel_button.clicked.connect(self._request_cancel)
self.open_output_button.clicked.connect(self.open_output_requested.emit)
self.open_folder_button.clicked.connect(self.open_folder_requested.emit)
self.close_button.clicked.connect(self._close_completion)
self.installEventFilter(self)
self.cancel_button.installEventFilter(self)
- self._progress_presenter = JobProgressPresenter()
- self._elapsed_timer = QTimer(self)
- self._elapsed_timer.setInterval(250)
- self._elapsed_timer.timeout.connect(self._refresh_metrics)
- self.reset()
def _assemble_layout(self) -> None:
"""Stack stage, detail, job identity, both bars and the metrics row."""
@@ -132,23 +170,60 @@ def _assemble_layout(self) -> None:
def _build_completion_summary(self) -> None:
self.completion_summary = QWidget()
self.completion_summary.setObjectName("job_completion_summary")
- self.completion_summary.setAccessibleName("Render completion summary")
+ self.completion_summary.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Render completion summary")
+ )
summary_layout = QFormLayout(self.completion_summary)
- self.completion_dimensions = self._summary_value("Output dimensions")
- self.completion_frames = self._summary_value("Output frame count")
- self.completion_duration = self._summary_value("Animation duration")
- self.completion_size = self._summary_value("Output file size")
- self.completion_fps = self._summary_value("Output frame rate")
- self.completion_job_time = self._summary_value("Render duration")
- self.completion_cuts = self._summary_value("Cut segmentation")
+ self.completion_dimensions = self._summary_value(
+ QCoreApplication.translate("PreviewJobDialog", "Output dimensions")
+ )
+ self.completion_frames = self._summary_value(
+ QCoreApplication.translate("PreviewJobDialog", "Output frame count")
+ )
+ self.completion_duration = self._summary_value(
+ QCoreApplication.translate("PreviewJobDialog", "Animation duration")
+ )
+ self.completion_size = self._summary_value(
+ QCoreApplication.translate("PreviewJobDialog", "Output file size")
+ )
+ self.completion_fps = self._summary_value(
+ QCoreApplication.translate("PreviewJobDialog", "Output frame rate")
+ )
+ self.completion_job_time = self._summary_value(
+ QCoreApplication.translate("PreviewJobDialog", "Render duration")
+ )
+ self.completion_cuts = self._summary_value(
+ QCoreApplication.translate("PreviewJobDialog", "Cut segmentation")
+ )
for label, value in (
- ("Dimensions", self.completion_dimensions),
- ("Frames", self.completion_frames),
- ("Animation duration", self.completion_duration),
- ("File size", self.completion_size),
- ("Output FPS", self.completion_fps),
- ("Job time", self.completion_job_time),
- ("Cuts", self.completion_cuts),
+ (
+ QCoreApplication.translate("PreviewJobDialog", "Dimensions"),
+ self.completion_dimensions,
+ ),
+ (
+ QCoreApplication.translate("PreviewJobDialog", "Frames"),
+ self.completion_frames,
+ ),
+ (
+ QCoreApplication.translate("PreviewJobDialog", "Animation duration"),
+ self.completion_duration,
+ ),
+ (
+ QCoreApplication.translate("PreviewJobDialog", "File size"),
+ self.completion_size,
+ ),
+ (
+ QCoreApplication.translate("PreviewJobDialog", "Output FPS"),
+ self.completion_fps,
+ ),
+ (
+ QCoreApplication.translate("PreviewJobDialog", "Job time"),
+ self.completion_job_time,
+ ),
+ (
+ QCoreApplication.translate("PreviewJobDialog", "Cuts"),
+ self.completion_cuts,
+ ),
):
summary_layout.addRow(label, value)
@@ -157,26 +232,59 @@ def _build_completion_actions(self) -> None:
self.completion_actions.setObjectName("job_completion_actions")
actions_layout = QHBoxLayout(self.completion_actions)
actions_layout.addStretch(1)
- self.open_output_button = QPushButton("Open output")
+ self.open_output_button = QPushButton(
+ QCoreApplication.translate("PreviewJobDialog", "Open output")
+ )
self.open_output_button.setObjectName("job_open_output")
- self.open_output_button.setAccessibleName("Open output")
- self.open_folder_button = QPushButton("Open folder")
+ self.open_output_button.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Open output")
+ )
+ self.open_folder_button = QPushButton(
+ QCoreApplication.translate("PreviewJobDialog", "Open folder")
+ )
self.open_folder_button.setObjectName("job_open_folder")
- self.open_folder_button.setAccessibleName("Open folder")
- self.close_button = QPushButton("Close")
+ self.open_folder_button.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Open folder")
+ )
+ self.close_button = QPushButton(
+ QCoreApplication.translate("PreviewJobDialog", "Close")
+ )
self.close_button.setObjectName("job_close")
- self.close_button.setAccessibleName("Close")
+ self.close_button.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Close")
+ )
actions_layout.addWidget(self.open_output_button)
actions_layout.addWidget(self.open_folder_button)
actions_layout.addWidget(self.close_button)
- def reset(self, title: str = "Previewing selected frame") -> None:
+ def reset(self, title: str | None = None) -> None:
self._completion_visible = False
self._terminal_close_requested = False
- self.setAccessibleName("Preview job")
- self.setWindowTitle(title)
- self.stage_label.setText("Preparing model")
+ self.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Preview job")
+ )
+ self.setWindowTitle(
+ QCoreApplication.translate("PreviewJobDialog", "Previewing selected frame")
+ if title is None
+ else title
+ )
+ self.stage_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Preparing model")
+ )
self.detail_label.setText("")
+ self._reset_completion_widgets()
+ self._reset_progress_widgets()
+ self._apply_metrics(self._progress_presenter.reset(time.monotonic()))
+ self._elapsed_timer.start()
+ self.cancel_button.setEnabled(True)
+ self.cancel_button.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Cancel")
+ )
+ self._cancel_emitted = False
+ self._model_name = ""
+ self._provider_name = ""
+
+ def _reset_completion_widgets(self) -> None:
self.model_provider_label.clear()
self.model_provider_label.hide()
self.output_label.clear()
@@ -195,6 +303,8 @@ def reset(self, title: str = "Previewing selected frame") -> None:
self.completion_cuts,
):
value.clear()
+
+ def _reset_progress_widgets(self) -> None:
self.stage_progress_label.show()
self.progress_bar.show()
self.overall_progress_label.show()
@@ -205,19 +315,20 @@ def reset(self, title: str = "Previewing selected frame") -> None:
self.cancel_button.show()
self.provider_notice_label.clear()
self.provider_notice_label.hide()
- self.stage_progress_label.setText("Stage progress (indeterminate)")
+ self.stage_progress_label.setText(
+ QCoreApplication.translate(
+ "PreviewJobDialog", "Stage progress (indeterminate)"
+ )
+ )
self.progress_bar.setRange(0, 0)
self.progress_bar.setFormat("")
- self.overall_progress_label.setText("Overall progress (indeterminate)")
+ self.overall_progress_label.setText(
+ QCoreApplication.translate(
+ "PreviewJobDialog", "Overall progress (indeterminate)"
+ )
+ )
self.overall_progress_bar.setRange(0, 0)
self.overall_progress_bar.setFormat("")
- self._apply_metrics(self._progress_presenter.reset(time.monotonic()))
- self._elapsed_timer.start()
- self.cancel_button.setEnabled(True)
- self.cancel_button.setText("Cancel")
- self._cancel_emitted = False
- self._model_name = ""
- self._provider_name = ""
@property
def completion_visible(self) -> bool:
@@ -234,14 +345,17 @@ def _summary_value(self, accessible_name: str) -> QLabel:
def set_job_details(
self, model_id: str, output_filename: str | None = None
) -> None:
- self._model_name = _MODEL_DISPLAY_NAMES.get(model_id, model_id)
+ self._model_name = model_display_name(model_id, model_id)
self._refresh_model_provider()
if output_filename:
- self.output_label.setText(f"Output: {output_filename}")
+ self.output_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Output: %s")
+ % output_filename
+ )
self.output_label.show()
def set_execution_provider(self, provider: str) -> None:
- self._provider_name = _PROVIDER_DISPLAY_NAMES.get(provider, provider)
+ self._provider_name = provider_label(provider, recommended=False, model_id="")
self._refresh_model_provider()
def _refresh_model_provider(self) -> None:
@@ -251,12 +365,15 @@ def _refresh_model_provider(self) -> None:
return
text = self._model_name
if self._provider_name:
- text += f" · {self._provider_name}"
+ text += (
+ QCoreApplication.translate("PreviewJobDialog", " · %s")
+ % self._provider_name
+ )
self.model_provider_label.setText(text)
self.model_provider_label.show()
def set_provider_notice(self, notice: str) -> None:
- self.provider_notice_label.setText(notice)
+ self.provider_notice_label.setText(provider_notice(notice))
self.provider_notice_label.show()
def set_stage(self, stage: str | ProgressStage) -> None:
@@ -264,7 +381,7 @@ def set_stage(self, stage: str | ProgressStage) -> None:
def set_progress(self, event: ProgressEvent) -> None:
self.set_stage(event.stage)
- self.detail_label.setText(event.detail)
+ self.detail_label.setText(progress_detail(event.detail))
self._apply_metrics(
self._progress_presenter.update(
event.overall_completed,
@@ -273,11 +390,17 @@ def set_progress(self, event: ProgressEvent) -> None:
)
)
if event.total is None:
- self.stage_progress_label.setText("Stage progress (indeterminate)")
+ self.stage_progress_label.setText(
+ QCoreApplication.translate(
+ "PreviewJobDialog", "Stage progress (indeterminate)"
+ )
+ )
self.progress_bar.setRange(0, 0)
self.progress_bar.setFormat("")
else:
- self.stage_progress_label.setText("Stage progress")
+ self.stage_progress_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Stage progress")
+ )
self.progress_bar.setRange(0, event.total)
self.progress_bar.setValue(event.completed)
if event.stage is ProgressStage.DOWNLOADING_MODEL:
@@ -285,26 +408,46 @@ def set_progress(self, event: ProgressEvent) -> None:
format_model_download_progress(event.completed, event.total)
)
elif event.stage is ProgressStage.DECODE:
- self.progress_bar.setFormat("%v / %m frames")
+ self.progress_bar.setFormat(
+ QCoreApplication.translate("PreviewJobDialog", "%v / %m frames")
+ )
else:
- self.progress_bar.setFormat("%v / %m")
+ self.progress_bar.setFormat(
+ QCoreApplication.translate("PreviewJobDialog", "%v / %m")
+ )
if event.overall_completed is not None and event.overall_total is not None:
- self.overall_progress_label.setText("Overall progress")
+ self.overall_progress_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Overall progress")
+ )
self.overall_progress_bar.setRange(0, event.overall_total)
self.overall_progress_bar.setValue(event.overall_completed)
- self.overall_progress_bar.setFormat("%v / %m frames")
+ self.overall_progress_bar.setFormat(
+ QCoreApplication.translate("PreviewJobDialog", "%v / %m frames")
+ )
else:
- self.overall_progress_label.setText("Overall progress (indeterminate)")
+ self.overall_progress_label.setText(
+ QCoreApplication.translate(
+ "PreviewJobDialog", "Overall progress (indeterminate)"
+ )
+ )
self.overall_progress_bar.setRange(0, 0)
self.overall_progress_bar.setFormat("")
def set_cancelling(self) -> None:
- self.stage_label.setText("Cancelling…")
- self.detail_label.setText("Waiting for the current safe checkpoint…")
+ self.stage_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Cancelling…")
+ )
+ self.detail_label.setText(
+ QCoreApplication.translate(
+ "PreviewJobDialog", "Waiting for the current safe checkpoint…"
+ )
+ )
self.progress_bar.setRange(0, 0)
self.progress_bar.setFormat("")
self.cancel_button.setEnabled(False)
- self.cancel_button.setText("Cancelling…")
+ self.cancel_button.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Cancelling…")
+ )
def show_completion(self, result: ArtifactResult) -> None:
"""Show the finished render summary and keep the modal open for review."""
@@ -314,21 +457,38 @@ def show_completion(self, result: ArtifactResult) -> None:
if result.execution_provider:
self.set_execution_provider(result.execution_provider)
self._completion_visible = True
- self.setAccessibleName("Render complete")
- self.setWindowTitle("Render complete")
- self.stage_label.setText("Complete")
- self.detail_label.setText("Output is ready")
- self.output_label.setText(f"Output: {output_path.name}")
+ self._populate_completion(result, output_path)
+ self._show_completion_widgets()
+
+ def _populate_completion(self, result: ArtifactResult, output_path: Path) -> None:
+ self.setAccessibleName(
+ QCoreApplication.translate("PreviewJobDialog", "Render complete")
+ )
+ self.setWindowTitle(
+ QCoreApplication.translate("PreviewJobDialog", "Render complete")
+ )
+ self.stage_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Complete")
+ )
+ self.detail_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Output is ready")
+ )
+ self.output_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Output: %s")
+ % output_path.name
+ )
self.output_label.setToolTip(str(output_path))
self.output_label.setAccessibleDescription(str(output_path))
self.output_label.show()
self.completion_dimensions.setText(
- format_source_dimensions(result.width, result.height) or "—"
+ format_source_dimensions(result.width, result.height)
+ or QCoreApplication.translate("PreviewJobDialog", "—")
)
- self.completion_frames.setText(_format_frames(result.frame_count))
+ self.completion_frames.setText(self._format_frames(result.frame_count))
self.completion_duration.setText(_format_milliseconds(result.duration_ms))
self.completion_size.setText(
- format_source_file_size(result.file_size) or "—"
+ format_source_file_size(result.file_size)
+ or QCoreApplication.translate("PreviewJobDialog", "—")
)
fps = (
format_source_frame_rate(Fraction(result.output_fps))
@@ -337,17 +497,19 @@ def show_completion(self, result: ArtifactResult) -> None:
and result.output_fps > 0
else ""
)
- self.completion_fps.setText(fps or "—")
- self.completion_job_time.setText(
- _format_milliseconds(result.job_duration_ms)
+ self.completion_fps.setText(
+ fps or QCoreApplication.translate("PreviewJobDialog", "—")
)
+ self.completion_job_time.setText(_format_milliseconds(result.job_duration_ms))
self.completion_cuts.setText(
- "Reused existing cuts"
+ QCoreApplication.translate("PreviewJobDialog", "Reused existing cuts")
if result.cuts_reused
- else "Fresh segmentation"
+ else QCoreApplication.translate("PreviewJobDialog", "Fresh segmentation")
if result.cuts_reused is False
- else "—"
+ else QCoreApplication.translate("PreviewJobDialog", "—")
)
+
+ def _show_completion_widgets(self) -> None:
self.stage_progress_label.hide()
self.progress_bar.hide()
self.overall_progress_label.hide()
@@ -385,7 +547,10 @@ def _refresh_metrics(self) -> None:
self._apply_metrics(self._progress_presenter.current(time.monotonic()))
def _apply_metrics(self, metrics: JobProgressMetrics) -> None:
- self.elapsed_label.setText(f"Elapsed {metrics.elapsed}")
+ self.elapsed_label.setText(
+ QCoreApplication.translate("PreviewJobDialog", "Elapsed %s")
+ % metrics.elapsed
+ )
self.rate_label.setText(metrics.rate)
self.estimate_label.setText(metrics.estimate)
@@ -406,6 +571,15 @@ def _request_cancel(self) -> None:
self._cancel_emitted = True
self.cancel_requested.emit()
+ def _format_frames(self, frame_count: int | None) -> str:
+ if (
+ isinstance(frame_count, int)
+ and not isinstance(frame_count, bool)
+ and frame_count >= 0
+ ):
+ return self.tr("%n frames", "", frame_count)
+ return QCoreApplication.translate("PreviewJobDialog", "—")
+
@Slot()
def _close_completion(self) -> None:
if not self._completion_visible:
@@ -415,21 +589,10 @@ def _close_completion(self) -> None:
self.done(0)
self._terminal_close_requested = False
-
-def _format_frames(frame_count: int | None) -> str:
- if (
- isinstance(frame_count, int)
- and not isinstance(frame_count, bool)
- and frame_count >= 0
- ):
- return f"{frame_count} frames"
- return "—"
-
-
def _format_milliseconds(value: int | None) -> str:
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return format_source_duration(Fraction(value, 1000))
- return "—"
+ return QCoreApplication.translate("PreviewJobDialog", "—")
_MODEL_DISPLAY_NAMES = {
diff --git a/src/matteloop/ui/preview_controller/runtime.py b/src/matteloop/ui/preview_controller/runtime.py
index 8af8d38..cb51d8f 100644
--- a/src/matteloop/ui/preview_controller/runtime.py
+++ b/src/matteloop/ui/preview_controller/runtime.py
@@ -90,11 +90,7 @@ class ProductionPreviewRuntime:
def __init__(self, *, cache_root: Path | None = None) -> None:
self.catalog = ModelCatalog.load_resource()
- self.cache_root = (
- model_cache_root()
- if cache_root is None
- else cache_root
- )
+ self.cache_root = model_cache_root() if cache_root is None else cache_root
self._context: JobContext | None = None
self._prepared: PreparedSegmentation | None = None
self._prepared_provider = CPU_EXECUTION_PROVIDER
@@ -284,10 +280,7 @@ def rebuild(
context: JobContext,
) -> RenderArtifact:
prepared = self._prepared
- if (
- prepared is None
- or prepared.model_id != request.segmentation.model_id
- ):
+ if prepared is None or prepared.model_id != request.segmentation.model_id:
spec = self.catalog.get(request.segmentation.model_id)
artifact = spec.artifact
if artifact is None:
@@ -308,9 +301,7 @@ def rebuild(
)
from matteloop.ui.render_pipeline import render_prepared
- return render_prepared(
- prepared, request, context, cut_workspace=cut_workspace
- )
+ return render_prepared(prepared, request, context, cut_workspace=cut_workspace)
def close(self) -> None:
self._manager.close()
@@ -344,9 +335,6 @@ def _cached(self, model_id: str, filename: str) -> bool:
return (
bool(filename)
and (
- self.cache_root
- / self.catalog.rembg_version
- / model_id
- / filename
+ self.cache_root / self.catalog.rembg_version / model_id / filename
).is_file()
)
diff --git a/src/matteloop/ui/preview_controller/worker.py b/src/matteloop/ui/preview_controller/worker.py
index 59a90e5..b4a20ef 100644
--- a/src/matteloop/ui/preview_controller/worker.py
+++ b/src/matteloop/ui/preview_controller/worker.py
@@ -137,6 +137,7 @@ def _notify_failure_or_cancel(self, error: BaseException) -> None:
)
)
+
def _qimage_from_rgba(value: ImmutableRgba) -> QImage:
if not isinstance(value, ImmutableRgba):
raise TypeError("preview result did not contain immutable RGBA pixels")
diff --git a/src/matteloop/ui/render_controller.py b/src/matteloop/ui/render_controller.py
index 09bcb9a..43b9d6d 100644
--- a/src/matteloop/ui/render_controller.py
+++ b/src/matteloop/ui/render_controller.py
@@ -1,12 +1,8 @@
"""GUI-thread render orchestration, confirmations, and output actions.
Shutdown note: every widget this controller creates is destroyed in
-``shutdown``. The job dialog is the one that is easy to miss -- it survives
-its job to show the completion summary, and with no dialog parent it is a
-top-level widget kept alive by this controller's reference alone. Leaving it
-to Python's garbage collector meant a QWidget could be destroyed from inside
-a running event loop, which crashed the process with an access violation
-long after the job it belonged to had finished.
+``shutdown``. The job dialog survives its job to show the completion summary,
+so this controller keeps it alive until the event loop closes.
"""
from __future__ import annotations
@@ -47,6 +43,7 @@
ProgressEvent,
)
from matteloop.jobs.workspace import CutWorkspace, WorkspaceSummary
+from matteloop.ui.copy import render_copy
from matteloop.ui.ports import (
ManageWorkspacesRequested,
OpenOutputFolderRequested,
@@ -291,13 +288,16 @@ def _show_preflight(self) -> None:
return
dialog = self._choice_dialog(
"render_preflight_dialog",
- "Preview recommended",
- "Preview this frame before rendering?",
- "A preview lets you verify the cutout before processing the whole video.",
+ render_copy("Preview recommended"),
+ render_copy("Preview this frame before rendering?"),
+ render_copy(
+ "A preview lets you verify the cutout before processing the whole "
+ "video."
+ ),
(
- ("Preview first", "preview"),
- ("Render anyway", "render"),
- ("Cancel", "cancel"),
+ (render_copy("Preview first"), "preview"),
+ (render_copy("Render anyway"), "render"),
+ (render_copy("Cancel"), "cancel"),
),
0,
self._finish_preflight,
@@ -383,13 +383,15 @@ def _show_reuse(self) -> None:
return
dialog = self._choice_dialog(
"render_reuse_dialog",
- "Matching cut set found",
- "A validated cut set matches the current source and settings.",
- "Rebuild reuses the cuts and only reruns framing and encoding.",
+ render_copy("Matching cut set found"),
+ render_copy("A validated cut set matches the current source and settings."),
+ render_copy(
+ "Rebuild reuses the cuts and only reruns framing and encoding."
+ ),
(
- ("Rebuild", "rebuild"),
- ("Regenerate", "regenerate"),
- ("Cancel", "cancel"),
+ (render_copy("Rebuild"), "rebuild"),
+ (render_copy("Regenerate"), "regenerate"),
+ (render_copy("Cancel"), "cancel"),
),
0,
self._finish_reuse,
@@ -493,13 +495,13 @@ def _show_collision(self) -> None:
return
dialog = self._choice_dialog(
"render_collision_dialog",
- "Output already exists",
- f"{self._collision_request.output.path} already exists.",
- "Choose how to handle the existing output.",
+ render_copy("Output already exists"),
+ render_copy("%s already exists.") % self._collision_request.output.path,
+ render_copy("Choose how to handle the existing output."),
(
- ("Replace", "replace"),
- ("Choose another name", "choose"),
- ("Cancel", "cancel"),
+ (render_copy("Replace"), "replace"),
+ (render_copy("Choose another name"), "choose"),
+ (render_copy("Cancel"), "cancel"),
),
2,
self._finish_collision,
@@ -535,18 +537,16 @@ def _choose_output_name(
) -> None:
filename, _filter = QFileDialog.getSaveFileName(
self._dialog_parent,
- "Choose output name",
+ render_copy("Choose output name"),
str(request.output.path),
- "WebP files (*.webp)",
+ render_copy("WebP files (*.webp)"),
)
if not filename:
return
chosen = Path(filename)
if chosen.suffix.casefold() != ".webp":
chosen = (
- chosen.with_suffix(".webp")
- if chosen.suffix
- else Path(f"{chosen}.webp")
+ chosen.with_suffix(".webp") if chosen.suffix else Path(f"{chosen}.webp")
)
try:
selected = replace(
@@ -640,9 +640,7 @@ def progress_sink(event: ProgressEvent) -> None:
)
self._threads[job_id][0].start()
- def _open_dialog(
- self, job_id: str, kind: JobKind, request: RenderRequest
- ) -> None:
+ def _open_dialog(self, job_id: str, kind: JobKind, request: RenderRequest) -> None:
if self._dialog is None:
self._dialog = PreviewJobDialog(self._dialog_parent)
if not self._dialog_cancel_connected:
@@ -651,9 +649,10 @@ def _open_dialog(
self._dialog.open_folder_requested.connect(self._open_output_folder)
self._dialog_cancel_connected = True
rebuilding = kind is JobKind.REBUILD
- self._dialog.reset(
+ title = render_copy(
"Rebuilding from edited cuts" if rebuilding else "Rendering video"
)
+ self._dialog.reset(title)
self._dialog.set_stage("Validation" if rebuilding else ProgressStage.DECODE)
self._dialog.set_job_details(
request.segmentation.model_id, request.output.filename
diff --git a/src/matteloop/ui/result_player.py b/src/matteloop/ui/result_player.py
index 6dcb475..7ed4592 100644
--- a/src/matteloop/ui/result_player.py
+++ b/src/matteloop/ui/result_player.py
@@ -26,7 +26,7 @@
from typing import Protocol
from PIL import Image, UnidentifiedImageError
-from PySide6.QtCore import QObject, QSize, Qt, QTimer, Signal, Slot
+from PySide6.QtCore import QCoreApplication, QObject, QSize, Qt, QTimer, Signal, Slot
from PySide6.QtGui import QImage
from PySide6.QtWidgets import QPushButton, QWidget
@@ -43,11 +43,6 @@
PLAYER_MIN_DISPLAY_SIDE = 64
_PLAY_BUTTON_CLEARANCE_GAP = 8.0
-_PLAY_TEXT = "Play"
-_PLAY_ACCESSIBLE_NAME = "Play the result loop"
-_PAUSE_TEXT = "Pause"
-_PAUSE_ACCESSIBLE_NAME = "Pause the result loop"
-
class _FrameLoadCancelled(Exception):
"""Raised internally to unwind ``_load_player_frames`` once superseded."""
@@ -85,7 +80,7 @@ def __init__(
) -> None:
super().__init__(
parent,
- title="Result",
+ title=QCoreApplication.translate("ResultPlayerCanvas", "Result"),
object_name="result_canvas",
runtime_root=runtime_root,
)
@@ -101,9 +96,13 @@ def __init__(
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._advance)
- self.play_button = QPushButton("Play", self)
+ self.play_button = QPushButton(
+ QCoreApplication.translate("ResultPlayerCanvas", "Play"), self
+ )
self.play_button.setObjectName("result_play")
- self.play_button.setAccessibleName(_PLAY_ACCESSIBLE_NAME)
+ self.play_button.setAccessibleName(
+ QCoreApplication.translate("ResultPlayerCanvas", "Play the result loop")
+ )
self.play_button.setCheckable(True)
self.play_button.hide()
self.play_button.toggled.connect(self._play_toggled)
@@ -213,9 +212,17 @@ def _sync_play_button(self, playing: bool) -> None:
"""
if self.play_button.isChecked() != playing:
self.play_button.setChecked(playing)
- self.play_button.setText(_PAUSE_TEXT if playing else _PLAY_TEXT)
+ self.play_button.setText(
+ QCoreApplication.translate("ResultPlayerCanvas", "Pause")
+ if playing
+ else QCoreApplication.translate("ResultPlayerCanvas", "Play")
+ )
self.play_button.setAccessibleName(
- _PAUSE_ACCESSIBLE_NAME if playing else _PLAY_ACCESSIBLE_NAME
+ QCoreApplication.translate("ResultPlayerCanvas", "Pause the result loop")
+ if playing
+ else QCoreApplication.translate(
+ "ResultPlayerCanvas", "Play the result loop"
+ )
)
@property
@@ -329,7 +336,11 @@ def _current_images(self) -> tuple[QImage, ...]:
def _update_truncation_marker(self, frames: PlayerFrames) -> None:
if frames.cached < frames.frame_count:
self.set_status_marker(
- f"Previewing the first {frames.cached} of {frames.frame_count} frames"
+ QCoreApplication.translate(
+ "ResultPlayerCanvas",
+ "Previewing the first %s of %s frames",
+ )
+ % (frames.cached, frames.frame_count)
)
else:
self.set_status_marker(None)
@@ -412,7 +423,12 @@ def run(self) -> None:
except _FrameLoadCancelled:
pass
except (OSError, UnidentifiedImageError):
- self.failed.emit("Cut frames could not be read", self._generation)
+ self.failed.emit(
+ QCoreApplication.translate(
+ "ResultPlayerCanvas", "Cut frames could not be read"
+ ),
+ self._generation,
+ )
else:
self.succeeded.emit(frames, self._generation)
finally:
diff --git a/src/matteloop/ui/settings_dialog.py b/src/matteloop/ui/settings_dialog.py
index 87db166..da2da15 100644
--- a/src/matteloop/ui/settings_dialog.py
+++ b/src/matteloop/ui/settings_dialog.py
@@ -4,8 +4,9 @@
from pathlib import Path
-from PySide6.QtCore import Qt
+from PySide6.QtCore import QCoreApplication, QSettings, QSignalBlocker, Qt
from PySide6.QtWidgets import (
+ QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
@@ -20,6 +21,12 @@
from matteloop.core.parameters import OutputDirectoryChanged
from matteloop.core.state import JobState
from matteloop.ui.compact_widgets import MiddleElidingLineEdit, compact_field
+from matteloop.ui.i18n import (
+ SUPPORTED_LANGUAGES,
+ persist_language,
+ selected_language,
+ translate_language_name,
+)
from matteloop.ui.ports import StateStore, WindowServices
@@ -31,51 +38,84 @@ def __init__(
store: StateStore,
services: WindowServices,
parent: QWidget | None = None,
+ *,
+ settings: QSettings | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("settings_dialog")
- self.setWindowTitle("Preferences")
- self.setAccessibleName("Preferences")
+ self.setWindowTitle(QCoreApplication.translate("SettingsDialog", "Preferences"))
+ self.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Preferences")
+ )
self.setMinimumWidth(560)
self._store = store
self._services = services
+ self._settings = settings or QSettings()
self._directory: Path | None = None
+ self._build_widgets()
+ self._build_layout()
+ self._connect_controls()
+ self.load()
+
+ def _build_widgets(self) -> None:
self.output_directory_edit = compact_field(MiddleElidingLineEdit())
self.output_directory_edit.setObjectName("output_directory")
- self.output_directory_edit.setAccessibleName("Output directory")
+ self.output_directory_edit.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Output directory")
+ )
self.output_directory_edit.setAccessibleDescription(
- "Directory used for output files"
+ QCoreApplication.translate(
+ "SettingsDialog", "Directory used for output files"
+ )
)
self.output_directory_edit.setProperty("mono", True)
self.output_directory_edit.setReadOnly(True)
-
- self.choose_output_directory_button = QPushButton("Choose…")
- self.choose_output_directory_button.setObjectName(
- "choose_output_directory"
+ self.choose_output_directory_button = QPushButton(
+ QCoreApplication.translate("SettingsDialog", "Choose…")
)
+ self.choose_output_directory_button.setObjectName("choose_output_directory")
self.choose_output_directory_button.setAccessibleName(
- "Choose output directory"
+ QCoreApplication.translate("SettingsDialog", "Choose output directory")
+ )
+ self.clear_output_directory_button = QPushButton(
+ QCoreApplication.translate("SettingsDialog", "Clear")
)
- self.clear_output_directory_button = QPushButton("Clear")
self.clear_output_directory_button.setObjectName("clear_output_directory")
self.clear_output_directory_button.setAccessibleName(
- "Clear output directory"
+ QCoreApplication.translate("SettingsDialog", "Clear output directory")
)
- self.button_box = QDialogButtonBox(
- QDialogButtonBox.StandardButton.Close
+ self.language_selector = QComboBox()
+ self.language_selector.setObjectName("interface_language")
+ self.language_selector.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Interface language")
)
+ for language in SUPPORTED_LANGUAGES:
+ self.language_selector.addItem(translate_language_name(language), language)
+ self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
self.button_box.setObjectName("settings_actions")
- self.button_box.setAccessibleName("Preferences actions")
- self._build_layout()
- self._connect_controls()
- self.load()
+ self.button_box.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Preferences actions")
+ )
def _build_layout(self) -> None:
self.description_label = QLabel()
self.description_label.setObjectName("settings_description")
- self.description_label.setAccessibleName("Preferences description")
+ self.description_label.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Preferences description")
+ )
self.description_label.setProperty("secondary", True)
+ self.language_note_label = QLabel(
+ QCoreApplication.translate(
+ "SettingsDialog",
+ "Interface language changes apply after restarting MatteLoop.",
+ )
+ )
+ self.language_note_label.setObjectName("language_restart_note")
+ self.language_note_label.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Language restart note")
+ )
+ self.language_note_label.setProperty("secondary", True)
directory_row = QWidget()
directory_layout = QHBoxLayout(directory_row)
@@ -91,25 +131,34 @@ def _build_layout(self) -> None:
)
form.setHorizontalSpacing(12)
form.setVerticalSpacing(8)
- label = QLabel("Output directory")
- label.setAccessibleName("Output directory label")
+ label = QLabel(QCoreApplication.translate("SettingsDialog", "Output directory"))
+ label.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Output directory label")
+ )
label.setBuddy(self.output_directory_edit)
form.addRow(label, directory_row)
+ language_label = QLabel(
+ QCoreApplication.translate("SettingsDialog", "Interface language")
+ )
+ language_label.setBuddy(self.language_selector)
+ form.addRow(language_label, self.language_selector)
layout = QVBoxLayout(self)
layout.addWidget(self.description_label)
+ layout.addWidget(self.language_note_label)
layout.addLayout(form)
layout.addWidget(self.button_box)
def _connect_controls(self) -> None:
self.choose_output_directory_button.clicked.connect(self._choose_directory)
self.clear_output_directory_button.clicked.connect(self._clear_directory)
+ self.language_selector.currentIndexChanged.connect(self._language_changed)
self.button_box.rejected.connect(self.reject)
- close_button = self.button_box.button(
- QDialogButtonBox.StandardButton.Close
- )
+ close_button = self.button_box.button(QDialogButtonBox.StandardButton.Close)
if close_button is not None:
- close_button.setAccessibleName("Close preferences")
+ close_button.setAccessibleName(
+ QCoreApplication.translate("SettingsDialog", "Close preferences")
+ )
self.setTabOrder(
self.output_directory_edit,
self.choose_output_directory_button,
@@ -127,6 +176,11 @@ def load(self) -> None:
directory = state.parameters.output_directory
controls_enabled = state.job.phase is JobState.IDLE
self._directory = directory
+ language = selected_language(self._settings)
+ with QSignalBlocker(self.language_selector):
+ self.language_selector.setCurrentIndex(
+ self.language_selector.findData(language)
+ )
self.output_directory_edit.setText(
str(directory) if directory is not None else ""
)
@@ -135,17 +189,26 @@ def load(self) -> None:
self.clear_output_directory_button.setEnabled(
controls_enabled and directory is not None
)
- self.description_label.setText(
- "Choose where rendered output files are saved. "
- "Changes apply immediately."
- if controls_enabled
- else "Output directory controls are disabled while a job is running."
- )
+ if controls_enabled:
+ self.description_label.setText(
+ QCoreApplication.translate(
+ "SettingsDialog", "Choose where rendered output files are saved."
+ )
+ )
+ else:
+ self.description_label.setText(
+ QCoreApplication.translate(
+ "SettingsDialog",
+ "Output directory controls are disabled while a job is running.",
+ )
+ )
def _choose_directory(self) -> None:
current = str(self._directory or Path.home())
selected = QFileDialog.getExistingDirectory(
- self, "Choose output directory", current
+ self,
+ QCoreApplication.translate("SettingsDialog", "Choose output directory"),
+ current,
)
if selected:
self._set_directory(Path(selected))
@@ -158,3 +221,8 @@ def _set_directory(self, directory: Path | None) -> None:
self.output_directory_edit.setText(str(directory) if directory else "")
self.clear_output_directory_button.setEnabled(directory is not None)
self._services.dispatch(OutputDirectoryChanged(directory))
+
+ def _language_changed(self, index: int) -> None:
+ language = self.language_selector.itemData(index)
+ if isinstance(language, str):
+ persist_language(self._settings, language)
diff --git a/src/matteloop/ui/source_presentation.py b/src/matteloop/ui/source_presentation.py
index 5fcbcf3..10c2be6 100644
--- a/src/matteloop/ui/source_presentation.py
+++ b/src/matteloop/ui/source_presentation.py
@@ -9,7 +9,10 @@
from pathlib import Path
from typing import TypedDict
+from PySide6.QtCore import QCoreApplication
+
from matteloop.core.timeline import format_timecode
+from matteloop.ui.i18n import display_locale
_SOURCE_FILENAME_MAX_LENGTH = 40
# The download callback fires per 256 KiB chunk — about 60 times a second at
@@ -97,7 +100,9 @@ def current(self, timestamp: float) -> JobProgressMetrics:
and self._overall_total is not None
):
remaining = self._overall_total - self._overall_completed
- estimate = f"{format_elapsed(remaining / self._rate)} remaining"
+ estimate = QCoreApplication.translate(
+ "SourcePresentation", "%s remaining"
+ ) % format_elapsed(remaining / self._rate)
return JobProgressMetrics(
format_elapsed(elapsed),
format_frame_rate(self._rate),
@@ -142,7 +147,7 @@ def format_source_duration(duration: object) -> str:
"""Return a source duration using the shared millisecond timecode format."""
if not isinstance(duration, Fraction) or duration < 0:
return ""
- return format_timecode(duration)
+ return _localized_timecode(duration)
def format_source_frame_rate(rate: object) -> str:
@@ -151,8 +156,8 @@ def format_source_frame_rate(rate: object) -> str:
return ""
hundredths = _rounded_hundredths(rate)
whole, decimal = divmod(hundredths, 100)
- value = f"{whole}.{decimal:02d}".rstrip("0").rstrip(".")
- return f"{value} fps"
+ value = _localized_decimal(whole, decimal, 2)
+ return QCoreApplication.translate("SourcePresentation", "%s fps") % value
def format_source_file_size(size: object) -> str:
@@ -162,7 +167,7 @@ def format_source_file_size(size: object) -> str:
value = float(size)
for unit in ("B", "KiB", "MiB", "GiB"):
if value < 1024 or unit == "GiB":
- return f"{value:.1f} {unit}"
+ return f"{display_locale().toString(value, 'f', 1)} {unit}"
value /= 1024
return ""
@@ -176,7 +181,9 @@ def format_elapsed(seconds: object) -> str:
or seconds < 0
):
return ""
- return format_timecode(Fraction(math.floor(float(seconds)))).removesuffix(".000")
+ return _localized_timecode(Fraction(math.floor(float(seconds)))).removesuffix(
+ display_locale().decimalPoint() + "000"
+ )
def format_frame_rate(frames_per_second: object) -> str:
@@ -188,7 +195,8 @@ def format_frame_rate(frames_per_second: object) -> str:
or frames_per_second <= 0
):
return ""
- return f"{float(frames_per_second):.1f} fps"
+ value = display_locale().toString(float(frames_per_second), "f", 1)
+ return QCoreApplication.translate("SourcePresentation", "%s fps") % value
class DownloadRateEstimator:
@@ -234,7 +242,11 @@ def format_download_speed(bytes_per_second: object) -> str:
):
return ""
value = format_source_file_size(int(bytes_per_second))
- return f"{value}/s" if value else ""
+ return (
+ QCoreApplication.translate("SourcePresentation", "%s/s") % value
+ if value
+ else ""
+ )
def format_model_download_progress(completed: object, total: object) -> str:
@@ -243,7 +255,10 @@ def format_model_download_progress(completed: object, total: object) -> str:
total_size = format_source_file_size(total)
if not completed_size or not total_size:
return ""
- return f"{completed_size} of {total_size}"
+ return QCoreApplication.translate("SourcePresentation", "%s of %s") % (
+ completed_size,
+ total_size,
+ )
def format_model_download_detail(
@@ -255,13 +270,15 @@ def format_model_download_detail(
"""Return the model download detail shown below the current stage."""
if not isinstance(model_name, str) or not model_name:
return ""
- detail = f"Downloading {model_name}"
+ detail = QCoreApplication.translate("SourcePresentation", "Downloading %s") % (
+ model_name
+ )
progress = format_model_download_progress(completed, total)
if progress:
- detail += f" — {progress}"
+ detail += QCoreApplication.translate("SourcePresentation", " — %s") % progress
speed = format_download_speed(bytes_per_second)
if speed:
- detail += f" · {speed}"
+ detail += QCoreApplication.translate("SourcePresentation", " · %s") % speed
return detail
@@ -271,7 +288,11 @@ def present_source_metadata(
"""Return source-strip text and the unshortened path for accessibility."""
if metadata is None:
return {
- "source_filename": "Reading video…" if loading else "",
+ "source_filename": (
+ QCoreApplication.translate("SourcePresentation", "Reading video…")
+ if loading
+ else ""
+ ),
"source_dimensions": "",
"source_duration": "",
"source_frame_rate": "",
@@ -287,9 +308,7 @@ def present_source_metadata(
"source_dimensions": format_source_dimensions(
getattr(metadata, "width", None), getattr(metadata, "height", None)
),
- "source_duration": format_source_duration(
- getattr(metadata, "duration", None)
- ),
+ "source_duration": format_source_duration(getattr(metadata, "duration", None)),
"source_frame_rate": format_source_frame_rate(rate),
"source_file_size": format_source_file_size(
getattr(metadata, "file_size", None)
@@ -301,3 +320,18 @@ def present_source_metadata(
def _rounded_hundredths(value: Fraction) -> int:
rounded = value * 100 + Fraction(1, 2)
return rounded.numerator // rounded.denominator
+
+
+def _localized_decimal(whole: int, fraction: int, places: int) -> str:
+ value = f"{whole}.{fraction:0{places}d}"
+ decimal_point = display_locale().decimalPoint()
+ return value.replace(".", decimal_point).rstrip("0").rstrip(decimal_point)
+
+
+def _localized_timecode(value: Fraction) -> str:
+ return format_timecode(value).replace(".", display_locale().decimalPoint())
+
+
+def format_localized_timecode(value: Fraction) -> str:
+ """Format a timeline value using the selected interface locale."""
+ return _localized_timecode(value)
diff --git a/src/matteloop/ui/source_strip.py b/src/matteloop/ui/source_strip.py
index 1fb4f3c..554ab9a 100644
--- a/src/matteloop/ui/source_strip.py
+++ b/src/matteloop/ui/source_strip.py
@@ -5,7 +5,7 @@
from pathlib import Path
from typing import Protocol
-from PySide6.QtCore import Qt, QUrl, Signal
+from PySide6.QtCore import QCoreApplication, Qt, QUrl, Signal
from PySide6.QtGui import QDragEnterEvent, QDropEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
@@ -43,15 +43,21 @@ def __init__(self, parent: QWidget | None = None) -> None:
layout.setSpacing(12)
self.filename = QLabel()
self.filename.setObjectName("source_filename")
- self.filename.setAccessibleName("Source video")
+ self.filename.setAccessibleName(
+ QCoreApplication.translate("SourceStrip", "Source video")
+ )
self.filename.setProperty("mono", True)
self.dimensions = self._label("source_dimensions")
self.duration = self._label("source_duration")
self.frame_rate = self._label("source_frame_rate")
self.file_size = self._label("source_file_size")
- self.replace_button = QPushButton("Open Video…")
+ self.replace_button = QPushButton(
+ QCoreApplication.translate("SourceStrip", "Open Video…")
+ )
self.replace_button.setObjectName("replace_video")
- self.replace_button.setAccessibleName("Open Video")
+ self.replace_button.setAccessibleName(
+ QCoreApplication.translate("SourceStrip", "Open Video")
+ )
for widget in (
self.filename,
self.dimensions,
@@ -70,7 +76,6 @@ def _label(self, name: str) -> QLabel:
value.setProperty("mono", True)
return value
-
def set_presented_metadata(self, values: SourcePresentationValues) -> None:
"""Apply presenter-owned source values to the strip widgets."""
self.filename.setText(values.source_filename)
@@ -90,19 +95,29 @@ class SourceDropSurface(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("source_drop_target")
- self.setAccessibleName("Video drop area")
+ self.setAccessibleName(
+ QCoreApplication.translate("SourceDropSurface", "Video drop area")
+ )
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setAcceptDrops(True)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.heading = QLabel("Drop a video here")
+ self.heading = QLabel(
+ QCoreApplication.translate("SourceDropSurface", "Drop a video here")
+ )
self.heading.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.heading.setAccessibleName("Open a video")
- self.button = QPushButton("Open Video…")
+ self.heading.setAccessibleName(
+ QCoreApplication.translate("SourceDropSurface", "Open a video")
+ )
+ self.button = QPushButton(
+ QCoreApplication.translate("SourceDropSurface", "Open Video…")
+ )
self.button.setObjectName("choose_video")
- self.button.setAccessibleName("Open Video")
+ self.button.setAccessibleName(
+ QCoreApplication.translate("SourceDropSurface", "Open Video")
+ )
self.button.setMinimumHeight(44)
layout.addWidget(self.heading)
layout.addWidget(self.button, alignment=Qt.AlignmentFlag.AlignCenter)
diff --git a/src/matteloop/ui/timeline.py b/src/matteloop/ui/timeline.py
index bdddba6..d4a2550 100644
--- a/src/matteloop/ui/timeline.py
+++ b/src/matteloop/ui/timeline.py
@@ -7,7 +7,16 @@
from pathlib import Path
from PIL import Image
-from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThread, Signal, Slot
+from PySide6.QtCore import (
+ QCoreApplication,
+ QObject,
+ QPointF,
+ QRectF,
+ Qt,
+ QThread,
+ Signal,
+ Slot,
+)
from PySide6.QtGui import (
QColor,
QFont,
@@ -37,7 +46,6 @@
StartChanged,
StepFrame,
absolute_frame_number,
- format_timecode,
)
from matteloop.jobs.cache import PixmapCache, ThumbnailCacheKey, ThumbnailDiskCache
from matteloop.jobs.source import SourceRevision, SourceValidationProof, decode_frame
@@ -47,6 +55,7 @@
filmstrip_timestamps,
generate_thumbnail,
)
+from matteloop.ui.source_presentation import format_localized_timecode
from matteloop.ui.theme import (
ACCENT_COLOR,
BACKGROUND_COLOR,
@@ -98,9 +107,7 @@ def run(self) -> None:
if self._cancelled():
return
try:
- result = self._thumbnail_result(
- timestamp, fingerprint, disk_cache
- )
+ result = self._thumbnail_result(timestamp, fingerprint, disk_cache)
except BaseException:
if self._cancelled():
return
@@ -210,7 +217,9 @@ class TimelineWidget(QFrame):
def __init__(self, parent: QFrame | None = None) -> None:
super().__init__(parent)
self.setObjectName("timeline")
- self.setAccessibleName("Video timeline")
+ self.setAccessibleName(
+ QCoreApplication.translate("TimelineWidget", "Video timeline")
+ )
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setMinimumHeight(176)
self.setMouseTracking(True)
@@ -233,21 +242,37 @@ def _build_telemetry(self) -> None:
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 132, 16, 12)
layout.setSpacing(12)
- self.timecode_label = QLabel("0:00.000")
+ self.timecode_label = QLabel(format_localized_timecode(Fraction(0)))
self.timecode_label.setObjectName("timeline_timecode")
- self.frame_label = QLabel("Frame —")
+ self.frame_label = QLabel(
+ QCoreApplication.translate("TimelineWidget", "Frame —")
+ )
self.frame_label.setObjectName("timeline_frame")
- self.range_label = QLabel("IN — OUT —")
+ self.range_label = QLabel(
+ QCoreApplication.translate("TimelineWidget", "IN — OUT —")
+ )
self.range_label.setObjectName("timeline_range")
self.range_status_label = QLabel()
self.range_status_label.setObjectName("timeline_range_status")
self.range_status_label.setProperty("secondary", True)
- self.set_start_button = QPushButton("Set IN")
- self.set_start_button.setAccessibleName("Set export start to playhead")
- self.set_end_button = QPushButton("Set OUT")
- self.set_end_button.setAccessibleName("Set export end to playhead")
- self.reset_range_button = QPushButton("Reset Range")
- self.reset_range_button.setAccessibleName("Reset export range")
+ self.set_start_button = QPushButton(
+ QCoreApplication.translate("TimelineWidget", "Set IN")
+ )
+ self.set_start_button.setAccessibleName(
+ QCoreApplication.translate("TimelineWidget", "Set export start to playhead")
+ )
+ self.set_end_button = QPushButton(
+ QCoreApplication.translate("TimelineWidget", "Set OUT")
+ )
+ self.set_end_button.setAccessibleName(
+ QCoreApplication.translate("TimelineWidget", "Set export end to playhead")
+ )
+ self.reset_range_button = QPushButton(
+ QCoreApplication.translate("TimelineWidget", "Reset Range")
+ )
+ self.reset_range_button.setAccessibleName(
+ QCoreApplication.translate("TimelineWidget", "Reset export range")
+ )
for label in (
self.timecode_label,
self.frame_label,
@@ -262,6 +287,9 @@ def _build_telemetry(self) -> None:
layout.addWidget(self.set_start_button)
layout.addWidget(self.set_end_button)
layout.addWidget(self.reset_range_button)
+ self._connect_telemetry_controls()
+
+ def _connect_telemetry_controls(self) -> None:
self.set_start_button.clicked.connect(
lambda: self.command_requested.emit(SetStartToPlayhead())
)
@@ -333,7 +361,9 @@ def paintEvent(self, event) -> None: # type: ignore[no-untyped-def]
geometry = self._geometry
if geometry is None:
painter.setPen(QColor(DISABLED_COLOR))
- painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "Timeline")
+ painter.drawText(
+ self.rect(), Qt.AlignmentFlag.AlignCenter, self.tr("Timeline")
+ )
return
timeline = _qt_rect(geometry.visual["timeline"])
painter.fillRect(timeline, QColor(CONTROL_COLOR))
@@ -412,9 +442,7 @@ def _paint_range_controls(
self._paint_handle(painter, geometry.visual["end_handle"], "OUT")
painter.fillRect(_qt_rect(geometry.visual["playhead"]), QColor(ACCENT_COLOR))
- def _paint_focus(
- self, painter: QPainter, geometry: InteractionGeometry
- ) -> None:
+ def _paint_focus(self, painter: QPainter, geometry: InteractionGeometry) -> None:
target = geometry.dragged or geometry.focused or "playhead"
focused = geometry.focus.get(target)
if focused is None:
@@ -531,25 +559,46 @@ def _rebuild_geometry(
def _update_telemetry(self) -> None:
presentation = self._presentation
if presentation is None:
- self.timecode_label.setText("0:00.000")
- self.frame_label.setText("Frame —")
- self.range_label.setText("IN — OUT —")
+ self.timecode_label.setText(format_localized_timecode(Fraction(0)))
+ self.frame_label.setText(
+ QCoreApplication.translate("TimelineWidget", "Frame —")
+ )
+ self.range_label.setText(
+ QCoreApplication.translate("TimelineWidget", "IN — OUT —")
+ )
self.range_status_label.clear()
return
state = presentation.state
- self.timecode_label.setText(format_timecode(state.playhead))
+ self.timecode_label.setText(format_localized_timecode(state.playhead))
self.frame_label.setText(
- f"Frame {absolute_frame_number(state.playhead, state.source_fps):06d}"
+ QCoreApplication.translate("TimelineWidget", "Frame %s")
+ % f"{absolute_frame_number(state.playhead, state.source_fps):06d}"
)
self.range_label.setText(
- f"IN {format_timecode(state.start)} OUT {format_timecode(state.end)}"
+ QCoreApplication.translate("TimelineWidget", "IN %s OUT %s")
+ % (
+ format_localized_timecode(state.start),
+ format_localized_timecode(state.end),
+ )
)
outside = not state.start <= state.playhead < state.end
- self.range_status_label.setText("Outside export range" if outside else "")
+ self.range_status_label.setText(
+ QCoreApplication.translate("TimelineWidget", "Outside export range")
+ if outside
+ else ""
+ )
self.setAccessibleDescription(
- f"Preview frame playhead {self.timecode_label.text()}, "
- f"{self.frame_label.text()}; {self.range_label.text()}"
- + ("; Outside export range" if outside else "")
+ QCoreApplication.translate(
+ "TimelineWidget", "Preview frame playhead %s, %s; %s%s"
+ )
+ % (
+ self.timecode_label.text(),
+ self.frame_label.text(),
+ self.range_label.text(),
+ QCoreApplication.translate("TimelineWidget", "; Outside export range")
+ if outside
+ else "",
+ )
)
def _thumbnail_layout_signature(
@@ -660,9 +709,7 @@ def _paint_thumbnail_frame_number(
return
painter.setFont(QFont(MONO_FONT, 8))
painter.setPen(QColor(TEXT_COLOR))
- frame = absolute_frame_number(
- timestamp, self._presentation.state.source_fps
- )
+ frame = absolute_frame_number(timestamp, self._presentation.state.source_fps)
painter.drawText(
tile.adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignLeft, str(frame)
)
@@ -683,10 +730,11 @@ def _paint_ruler(self, painter: QPainter, geometry: InteractionGeometry) -> None
painter.drawText(
QRectF(x - 42, 114, 84, 16),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop,
- format_timecode(timestamp),
+ format_localized_timecode(timestamp),
)
painter.restore()
+
def _qt_rect(rect: RectF) -> QRectF:
return QRectF(rect.x, rect.y, rect.width, rect.height)
@@ -774,11 +822,7 @@ def _thumbnail_edge(
) -> Fraction:
timestamp = timestamps[index]
if left:
- return (
- Fraction(0)
- if index == 0
- else (timestamps[index - 1] + timestamp) / 2
- )
+ return Fraction(0) if index == 0 else (timestamps[index - 1] + timestamp) / 2
return (
duration
if index + 1 == len(timestamps)
diff --git a/src/matteloop/ui/transform_group.py b/src/matteloop/ui/transform_group.py
index 8940a3f..b165d0d 100644
--- a/src/matteloop/ui/transform_group.py
+++ b/src/matteloop/ui/transform_group.py
@@ -7,7 +7,7 @@
from fractions import Fraction
from itertools import groupby
-from PySide6.QtCore import QSignalBlocker, Qt, Signal
+from PySide6.QtCore import QCoreApplication, QSignalBlocker, Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QCheckBox,
@@ -43,6 +43,7 @@
)
from matteloop.ui.source_presentation import (
format_source_dimensions,
+ format_source_duration,
format_source_file_size,
)
@@ -65,6 +66,42 @@
_RESIZE_ERROR = "Set at least one of width or height."
+def _transform_label(value: str) -> str:
+ return {
+ "Free": QCoreApplication.translate("TransformGroup", "Free"),
+ "First frame": QCoreApplication.translate("TransformGroup", "First frame"),
+ "Last frame": QCoreApplication.translate("TransformGroup", "Last frame"),
+ "X": QCoreApplication.translate("TransformGroup", "X"),
+ "Y": QCoreApplication.translate("TransformGroup", "Y"),
+ "Width": QCoreApplication.translate("TransformGroup", "Width"),
+ "Height": QCoreApplication.translate("TransformGroup", "Height"),
+ "Aspect": QCoreApplication.translate("TransformGroup", "Aspect"),
+ "Percent": QCoreApplication.translate("TransformGroup", "Percent"),
+ "Mismatch": QCoreApplication.translate("TransformGroup", "Mismatch"),
+ "Preset": QCoreApplication.translate("TransformGroup", "Preset"),
+ "Keep original aspect ratio": QCoreApplication.translate(
+ "TransformGroup", "Keep original aspect ratio"
+ ),
+ "Stretch to fit": QCoreApplication.translate(
+ "TransformGroup", "Stretch to fit"
+ ),
+ "Center and crop to fit": QCoreApplication.translate(
+ "TransformGroup", "Center and crop to fit"
+ ),
+ "Add transparent padding": QCoreApplication.translate(
+ "TransformGroup", "Add transparent padding"
+ ),
+ "Crop": QCoreApplication.translate("TransformGroup", "Crop"),
+ "Trim": QCoreApplication.translate("TransformGroup", "Trim"),
+ "Resize": QCoreApplication.translate("TransformGroup", "Resize"),
+ "1:1": QCoreApplication.translate("TransformGroup", "1:1"),
+ "2:1": QCoreApplication.translate("TransformGroup", "2:1"),
+ "3:1": QCoreApplication.translate("TransformGroup", "3:1"),
+ "4:3": QCoreApplication.translate("TransformGroup", "4:3"),
+ "16:9": QCoreApplication.translate("TransformGroup", "16:9"),
+ }.get(value, value)
+
+
@dataclass(frozen=True)
class CutFacts:
"""Cut-derived facts the group needs but ParameterState does not hold."""
@@ -92,7 +129,9 @@ def __init__(
) -> None:
super().__init__(parent)
self.setObjectName("transform_group")
- self.setAccessibleName("Transform")
+ self.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Transform")
+ )
self._emit = emit
self._presets = presets
self._transform = TransformSpec()
@@ -170,26 +209,44 @@ def tab_widgets(self) -> tuple[QWidget, ...]:
def _build_trim_widgets(self) -> None:
self.first_frame_spinbox = compact_field(QSpinBox())
self.first_frame_spinbox.setObjectName("transform_first_frame")
- self.first_frame_spinbox.setAccessibleName("First frame")
- self.first_playhead_button = QPushButton("Use Playhead")
+ self.first_frame_spinbox.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "First frame")
+ )
+ self.first_playhead_button = QPushButton(
+ QCoreApplication.translate("TransformGroup", "Use Playhead")
+ )
self.first_playhead_button.setObjectName("transform_first_playhead")
- self.first_playhead_button.setAccessibleName("Use playhead for first frame")
+ self.first_playhead_button.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Use playhead for first frame")
+ )
self.last_frame_spinbox = compact_field(QSpinBox())
self.last_frame_spinbox.setObjectName("transform_last_frame")
- self.last_frame_spinbox.setAccessibleName("Last frame")
- self.last_playhead_button = QPushButton("Use Playhead")
+ self.last_frame_spinbox.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Last frame")
+ )
+ self.last_playhead_button = QPushButton(
+ QCoreApplication.translate("TransformGroup", "Use Playhead")
+ )
self.last_playhead_button.setObjectName("transform_last_playhead")
- self.last_playhead_button.setAccessibleName("Use playhead for last frame")
+ self.last_playhead_button.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Use playhead for last frame")
+ )
def _build_crop_widgets(self) -> None:
- self.crop_edit_checkbox = QCheckBox("Edit crop")
+ self.crop_edit_checkbox = QCheckBox(
+ QCoreApplication.translate("TransformGroup", "Edit crop")
+ )
self.crop_edit_checkbox.setObjectName("transform_crop_edit")
- self.crop_edit_checkbox.setAccessibleName("Edit crop")
+ self.crop_edit_checkbox.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Edit crop")
+ )
self.aspect_combo = compact_field(QComboBox())
self.aspect_combo.setObjectName("transform_aspect")
- self.aspect_combo.setAccessibleName("Crop aspect ratio")
+ self.aspect_combo.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Crop aspect ratio")
+ )
for label, ratio in _ASPECT_PRESETS:
- self.aspect_combo.addItem(label, ratio)
+ self.aspect_combo.addItem(_transform_label(label), ratio)
self.crop_x_spinbox = self._crop_spinbox("x")
self.crop_y_spinbox = self._crop_spinbox("y")
self.crop_width_spinbox = self._crop_spinbox("width")
@@ -198,7 +255,10 @@ def _build_crop_widgets(self) -> None:
def _crop_spinbox(self, name: str) -> QSpinBox:
field = compact_field(QSpinBox())
field.setObjectName(f"transform_crop_{name}")
- field.setAccessibleName(f"Crop {name}")
+ field.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Crop %s")
+ % _transform_label(name)
+ )
field.setMaximum(MAX_FINAL_DIMENSION)
return field
@@ -207,30 +267,38 @@ def _build_resize_widgets(self) -> None:
self.height_spinbox = self._dimension_spinbox("height")
self.percent_spinbox = compact_field(QDoubleSpinBox())
self.percent_spinbox.setObjectName("transform_percent")
- self.percent_spinbox.setAccessibleName("Resize percentage")
+ self.percent_spinbox.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Resize percentage")
+ )
self.percent_spinbox.setRange(0, 1000)
- self.percent_spinbox.setSuffix(" %")
+ self.percent_spinbox.setSuffix(
+ QCoreApplication.translate("TransformGroup", " %")
+ )
self.mismatch_combo = compact_field(QComboBox())
self.mismatch_combo.setObjectName("transform_mismatch")
- self.mismatch_combo.setAccessibleName("Aspect mismatch handling")
+ self.mismatch_combo.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Aspect mismatch handling")
+ )
for label, mode in _MISMATCH_LABELS:
- self.mismatch_combo.addItem(label, mode)
+ self.mismatch_combo.addItem(_transform_label(label), mode)
self.size_preset_combo = compact_field(QComboBox())
self.size_preset_combo.setObjectName("transform_size_preset")
- self.size_preset_combo.setAccessibleName("Output size preset")
+ self.size_preset_combo.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Output size preset")
+ )
self.size_preset_combo.setModel(self._preset_model())
def _dimension_spinbox(self, name: str) -> QSpinBox:
field = compact_field(QSpinBox())
field.setObjectName(f"transform_{name}")
- field.setAccessibleName(name.capitalize())
+ field.setAccessibleName(_transform_label(name.capitalize()))
field.setRange(MIN_FINAL_DIMENSION - 1, MAX_FINAL_DIMENSION)
- field.setSpecialValueText("Auto")
+ field.setSpecialValueText(QCoreApplication.translate("TransformGroup", "Auto"))
return field
def _preset_model(self) -> QStandardItemModel:
model = QStandardItemModel(self.size_preset_combo)
- custom = QStandardItem("Custom")
+ custom = QStandardItem(QCoreApplication.translate("TransformGroup", "Custom"))
custom.setData(None, Qt.ItemDataRole.UserRole)
model.appendRow(custom)
grouped = groupby(self._presets, key=lambda preset: preset.platform)
@@ -247,7 +315,9 @@ def _preset_model(self) -> QStandardItemModel:
def _build_readout_widget(self) -> None:
self.readout_label = QLabel()
self.readout_label.setObjectName("transform_readout")
- self.readout_label.setAccessibleName("Transform summary")
+ self.readout_label.setAccessibleName(
+ QCoreApplication.translate("TransformGroup", "Transform summary")
+ )
self.readout_label.setWordWrap(True)
def _connect_signals(self) -> None:
@@ -283,22 +353,24 @@ def _section_heading(self, text: str, object_name: str) -> QLabel:
siblings (e.g. crop's Width/Height from resize's), matching the
bold, non-interactive heading idiom the disclosure headers already
use (``theme.py``'s ``QToolButton { font-weight: 600; }``)."""
- heading = QLabel(text)
+ heading = QLabel(_transform_label(text))
heading.setObjectName(object_name)
heading.setProperty("heading", True)
- heading.setAccessibleName(text)
+ heading.setAccessibleName(_transform_label(text))
return heading
def _trim_section(self) -> QWidget:
section = QWidget()
layout = QFormLayout(section)
layout.addRow(self._section_heading("Trim", "transform_trim_heading"))
- layout.addRow("First frame", self._trim_row(
- self.first_frame_spinbox, self.first_playhead_button
- ))
- layout.addRow("Last frame", self._trim_row(
- self.last_frame_spinbox, self.last_playhead_button
- ))
+ layout.addRow(
+ _transform_label("First frame"),
+ self._trim_row(self.first_frame_spinbox, self.first_playhead_button),
+ )
+ layout.addRow(
+ _transform_label("Last frame"),
+ self._trim_row(self.last_frame_spinbox, self.last_playhead_button),
+ )
return section
def _trim_row(self, spinbox: QSpinBox, button: QPushButton) -> QWidget:
@@ -314,22 +386,22 @@ def _crop_section(self) -> QWidget:
layout = QFormLayout(section)
layout.addRow(self._section_heading("Crop", "transform_crop_heading"))
layout.addRow(self.crop_edit_checkbox)
- layout.addRow("Aspect", self.aspect_combo)
- layout.addRow("X", self.crop_x_spinbox)
- layout.addRow("Y", self.crop_y_spinbox)
- layout.addRow("Width", self.crop_width_spinbox)
- layout.addRow("Height", self.crop_height_spinbox)
+ layout.addRow(_transform_label("Aspect"), self.aspect_combo)
+ layout.addRow(_transform_label("X"), self.crop_x_spinbox)
+ layout.addRow(_transform_label("Y"), self.crop_y_spinbox)
+ layout.addRow(_transform_label("Width"), self.crop_width_spinbox)
+ layout.addRow(_transform_label("Height"), self.crop_height_spinbox)
return section
def _resize_section(self) -> QWidget:
section = QWidget()
layout = QFormLayout(section)
layout.addRow(self._section_heading("Resize", "transform_resize_heading"))
- layout.addRow("Width", self.width_spinbox)
- layout.addRow("Height", self.height_spinbox)
- layout.addRow("Percent", self.percent_spinbox)
- layout.addRow("Mismatch", self.mismatch_combo)
- layout.addRow("Preset", self.size_preset_combo)
+ layout.addRow(_transform_label("Width"), self.width_spinbox)
+ layout.addRow(_transform_label("Height"), self.height_spinbox)
+ layout.addRow(_transform_label("Percent"), self.percent_spinbox)
+ layout.addRow(_transform_label("Mismatch"), self.mismatch_combo)
+ layout.addRow(_transform_label("Preset"), self.size_preset_combo)
return section
# -- reducer-owned state -> widgets --------------------------------------
@@ -372,7 +444,9 @@ def _sync_resize_widgets(self) -> None:
self._mismatch = resize.mismatch if resize is not None else MismatchMode.KEEP
self.width_spinbox.setValue(self._dimension_display(resize, "width"))
self.height_spinbox.setValue(self._dimension_display(resize, "height"))
- self.mismatch_combo.setCurrentIndex(self.mismatch_combo.findData(self._mismatch))
+ self.mismatch_combo.setCurrentIndex(
+ self.mismatch_combo.findData(self._mismatch)
+ )
self.percent_spinbox.setValue(0)
self.size_preset_combo.setCurrentIndex(0)
@@ -569,15 +643,18 @@ def _readout_text(self, size: tuple[int, int]) -> str:
kept = self._transform.kept_range(facts.frame_count)
delays = self._transform.select_kept(facts.delays_ms)
duration = sum(delays) / 1000
- text = (
- f"{len(kept)} of {facts.frame_count} frames · {duration:.3f} s · "
- f"{format_source_dimensions(*size)} px"
- )
+ kept_count = len(kept)
+ text = self.tr("%n of %1 frames · %2 s · %3 px", "", kept_count)
+ text = text.replace("%1", str(facts.frame_count))
+ text = text.replace("%2", format_source_duration(Fraction(duration)))
+ text = text.replace("%3", format_source_dimensions(*size))
artifact = self._artifact
if artifact is not None and artifact.width and artifact.height:
rendered = format_source_dimensions(artifact.width, artifact.height)
file_size = format_source_file_size(artifact.file_size)
- text += f" · rendered {rendered} px, {file_size}"
+ text += QCoreApplication.translate(
+ "TransformGroup", " · rendered %s px, %s"
+ ) % (rendered, file_size)
return text
def _show_transform_error(self, message: str) -> None:
@@ -589,7 +666,13 @@ def _clear_transform_error(self) -> None:
def _show_resize_error(self) -> None:
for spin in (self.width_spinbox, self.height_spinbox):
- self._set_widget_invalid(spin, True, _RESIZE_ERROR)
+ self._set_widget_invalid(
+ spin,
+ True,
+ QCoreApplication.translate(
+ "TransformGroup", "Set at least one of width or height."
+ ),
+ )
def _clear_resize_error(self) -> None:
for spin in (self.width_spinbox, self.height_spinbox):
diff --git a/src/matteloop/ui/workspace_controller.py b/src/matteloop/ui/workspace_controller.py
index 982e74c..3a1f092 100644
--- a/src/matteloop/ui/workspace_controller.py
+++ b/src/matteloop/ui/workspace_controller.py
@@ -5,7 +5,7 @@
from collections.abc import Callable
from pathlib import Path
-from PySide6.QtCore import QUrl
+from PySide6.QtCore import QCoreApplication, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox, QWidget
@@ -42,9 +42,7 @@ def close(self) -> None:
def _open_selected(self, value: object) -> None:
if isinstance(value, WorkspaceSummary):
- QDesktopServices.openUrl(
- QUrl.fromLocalFile(str(value.workspace.path))
- )
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(value.workspace.path)))
def _delete_selected(self, value: object) -> None:
if not isinstance(value, WorkspaceSummary):
@@ -53,8 +51,10 @@ def _delete_selected(self, value: object) -> None:
if active is not None and active.path == value.workspace.path:
QMessageBox.warning(
self.dialog,
- "Cut set is in use",
- "This cut set is being used by a running job.",
+ QCoreApplication.translate("WorkspacePicker", "Cut set is in use"),
+ QCoreApplication.translate(
+ "WorkspacePicker", "This cut set is being used by a running job."
+ ),
)
return
allow_pinned = value.pinned
@@ -66,7 +66,11 @@ def _delete_selected(self, value: object) -> None:
except AppError as error:
if error.code is not ErrorCode.CUT_WORKSPACE_PINNED or allow_pinned:
QMessageBox.warning(
- self.dialog, "Could not delete cut set", str(error)
+ self.dialog,
+ QCoreApplication.translate(
+ "WorkspacePicker", "Could not delete cut set"
+ ),
+ str(error),
)
return
if not self._confirm_pinned_delete():
@@ -83,8 +87,10 @@ def _delete_selected(self, value: object) -> None:
def _confirm_pinned_delete(self) -> bool:
answer = QMessageBox.question(
self.dialog,
- "Delete pinned cut set?",
- "This set is pinned. Delete it anyway?",
+ QCoreApplication.translate("WorkspacePicker", "Delete pinned cut set?"),
+ QCoreApplication.translate(
+ "WorkspacePicker", "This set is pinned. Delete it anyway?"
+ ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
diff --git a/src/matteloop/ui/workspace_dialog.py b/src/matteloop/ui/workspace_dialog.py
index 6c411f2..cb562e8 100644
--- a/src/matteloop/ui/workspace_dialog.py
+++ b/src/matteloop/ui/workspace_dialog.py
@@ -5,7 +5,7 @@
from dataclasses import replace
from pathlib import Path
-from PySide6.QtCore import QSize, Signal
+from PySide6.QtCore import QCoreApplication, QSize, Signal
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
@@ -41,35 +41,70 @@ class WorkspacePickerDialog(QDialog):
open_requested = Signal(object)
delete_requested = Signal(object)
- def __init__(
- self, catalog: ModelCatalog, parent: QWidget | None = None
- ) -> None:
+ def __init__(self, catalog: ModelCatalog, parent: QWidget | None = None) -> None:
super().__init__(parent)
- self.setObjectName("workspace_picker")
- self.setWindowTitle("Promoted cut sets")
- self.setAccessibleName("Promoted cut sets")
- self.resize(980, 360)
self._catalog = catalog
self._summaries: tuple[WorkspaceSummary, ...] = ()
self._output_directory: Path | None = None
+ self._build_widgets()
+ self._build_layout()
+ self._connect_signals()
+ self._update_actions()
+
+ def _build_widgets(self) -> None:
+ self.setObjectName("workspace_picker")
+ self.setWindowTitle(
+ QCoreApplication.translate("WorkspacePickerDialog", "Promoted cut sets")
+ )
+ self.setAccessibleName(
+ QCoreApplication.translate("WorkspacePickerDialog", "Promoted cut sets")
+ )
+ self.resize(980, 360)
self._message = QLabel()
self._message.setProperty("secondary", True)
self.cut_set_list = QListWidget()
self.cut_set_list.setObjectName("cut_set_list")
- self.cut_set_list.setAccessibleName("Promoted cut sets")
+ self.cut_set_list.setAccessibleName(
+ QCoreApplication.translate("WorkspacePickerDialog", "Promoted cut sets")
+ )
self.cut_set_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
self.cut_set_list.setItemDelegate(AlignedRowDelegate(self.cut_set_list))
self.cut_set_list.setUniformItemSizes(True)
- self.use_button = QPushButton("Use this set")
- self.use_button.setAccessibleName("Use selected cut set")
- self.open_button = QPushButton("Open folder")
- self.open_button.setAccessibleName("Open selected cut folder")
- self.delete_button = QPushButton("Delete set")
- self.delete_button.setAccessibleName("Delete selected cut set")
- self.close_button = QPushButton("Close")
+ self.use_button = QPushButton(
+ QCoreApplication.translate("WorkspacePickerDialog", "Use this set")
+ )
+ self.use_button.setAccessibleName(
+ QCoreApplication.translate("WorkspacePickerDialog", "Use selected cut set")
+ )
+ self.open_button = QPushButton(
+ QCoreApplication.translate("WorkspacePickerDialog", "Open folder")
+ )
+ self.open_button.setAccessibleName(
+ QCoreApplication.translate(
+ "WorkspacePickerDialog", "Open selected cut folder"
+ )
+ )
+ self.delete_button = QPushButton(
+ QCoreApplication.translate("WorkspacePickerDialog", "Delete set")
+ )
+ self.delete_button.setAccessibleName(
+ QCoreApplication.translate(
+ "WorkspacePickerDialog", "Delete selected cut set"
+ )
+ )
+ self.close_button = QPushButton(
+ QCoreApplication.translate("WorkspacePickerDialog", "Close")
+ )
+
+ def _build_layout(self) -> None:
layout = QVBoxLayout(self)
layout.addWidget(
- QLabel("Choose a validated cut set for this output directory.")
+ QLabel(
+ QCoreApplication.translate(
+ "WorkspacePickerDialog",
+ "Choose a validated cut set for this output directory.",
+ )
+ )
)
layout.addWidget(self._message)
layout.addWidget(self.cut_set_list, 1)
@@ -80,17 +115,16 @@ def __init__(
actions.addStretch(1)
actions.addWidget(self.close_button)
layout.addLayout(actions)
+
+ def _connect_signals(self) -> None:
self.cut_set_list.currentItemChanged.connect(
lambda _current, _previous: self._update_actions()
)
- self.cut_set_list.itemDoubleClicked.connect(
- lambda _item: self._use_selected()
- )
+ self.cut_set_list.itemDoubleClicked.connect(lambda _item: self._use_selected())
self.use_button.clicked.connect(self._use_selected)
self.open_button.clicked.connect(self._open_selected)
self.delete_button.clicked.connect(self._delete_selected)
self.close_button.clicked.connect(self.close)
- self._update_actions()
@property
def summaries(self) -> tuple[WorkspaceSummary, ...]:
@@ -111,7 +145,12 @@ def load(self, output_directory: Path) -> None:
)
except (AppError, OSError) as error:
self._summaries = ()
- self._message.setText(f"Could not read cut sets: {error}")
+ self._message.setText(
+ QCoreApplication.translate(
+ "WorkspacePickerDialog", "Could not read cut sets: %s"
+ )
+ % error
+ )
self.cut_set_list.clear()
self._update_actions()
return
@@ -126,11 +165,21 @@ def load(self, output_directory: Path) -> None:
self.cut_set_list.addItem(item)
if self.cut_set_list.count():
self.cut_set_list.setCurrentRow(0)
- self._message.setText(
- f"{len(self._summaries)} promoted set(s) in {output_directory}"
- if self._summaries
- else "No promoted cut sets found."
- )
+ if self._summaries:
+ summary_count = len(self._summaries)
+ self._message.setText(
+ self.tr(
+ "%n promoted cut set(s) in %1",
+ "",
+ summary_count,
+ ).replace("%1", str(output_directory))
+ )
+ else:
+ self._message.setText(
+ QCoreApplication.translate(
+ "WorkspacePickerDialog", "No promoted cut sets found."
+ )
+ )
self._update_actions()
def selected_summary(self) -> WorkspaceSummary | None:
diff --git a/src/matteloop/ui/workspace_presentation.py b/src/matteloop/ui/workspace_presentation.py
index 4e69517..5f4bddd 100644
--- a/src/matteloop/ui/workspace_presentation.py
+++ b/src/matteloop/ui/workspace_presentation.py
@@ -3,10 +3,11 @@
from __future__ import annotations
from collections.abc import Mapping
-from datetime import datetime
from fractions import Fraction
from pathlib import Path
+from PySide6.QtCore import QCoreApplication, QDateTime, QLocale, QObject
+
from matteloop.core.specs import (
AlphaMattingSpec,
CropSpec,
@@ -15,19 +16,59 @@
SamplingSpec,
SegmentationSpec,
)
-from matteloop.core.timeline import format_timecode
from matteloop.jobs.models.catalog import ModelCatalog
from matteloop.jobs.workspace import CutManifest, WorkspaceSummary
from matteloop.ui.aligned_rows import AlignedColumn, AlignedRow, RowStatus
+from matteloop.ui.copy import model_display_name, model_license, model_purpose
+from matteloop.ui.i18n import display_locale
from matteloop.ui.source_presentation import (
+ format_localized_timecode,
format_source_dimensions,
format_source_file_size,
format_source_filename,
)
+class WorkspacePresentation(QObject):
+ """Translation context used by the Qt-free workspace presentation helpers."""
+
+ def format_frames(self, count: int) -> str:
+ return self.tr("%n frames", "", count)
+
+
+_WORKSPACE_TRANSLATOR = WorkspacePresentation()
+
+
def present_workspace(summary: WorkspaceSummary, catalog: ModelCatalog) -> AlignedRow:
"""Map one validated manifest to aligned columns and spoken detail."""
+ values = _workspace_values(summary, catalog)
+ flags = values["flags"]
+ status_token = (
+ RowStatus.EDITED
+ if summary.manifest.edited
+ else RowStatus.PINNED
+ if summary.manifest.pinned
+ else RowStatus.READY
+ )
+ return AlignedRow(
+ "✎" if summary.manifest.edited else ("◆" if summary.manifest.pinned else "✓"),
+ status_token,
+ (
+ AlignedColumn(values["filename"]),
+ AlignedColumn(values["frame_text"], True),
+ AlignedColumn(values["display_name"]),
+ AlignedColumn(values["range_text"]),
+ AlignedColumn(values["dimensions"]),
+ AlignedColumn(values["created_at"]),
+ AlignedColumn(flags),
+ ),
+ _workspace_detail(values),
+ )
+
+
+def _workspace_values(
+ summary: WorkspaceSummary, catalog: ModelCatalog
+) -> dict[str, str]:
manifest = summary.manifest
sampling = _mapping(manifest.cache_key_inputs["sampling"])
start = _fraction(sampling["start"])
@@ -37,50 +78,68 @@ def present_workspace(summary: WorkspaceSummary, catalog: ModelCatalog) -> Align
model = catalog.get(model_id)
flags = ", ".join(
label
- for label, enabled in (("edited", manifest.edited), ("pinned", manifest.pinned))
- if enabled
- ) or "unchanged"
- status_words = f"{flags}; {format_source_file_size(summary.size_bytes)}"
- detail = (
- f"{format_source_filename(manifest.source_path)}; "
- f"{manifest.frame_count} frames; {model.display_name}; "
- f"{format_timecode(start)}–{format_timecode(end)} at {fps} fps; "
- f"{format_source_dimensions(manifest.width, manifest.height)}; "
- f"created {_created_at(manifest.created_at_ns)}; "
- f"purpose: {model.purpose}; licence: {model.license_note}; "
- f"{status_words}"
- )
- glyph = "✎" if manifest.edited else ("◆" if manifest.pinned else "✓")
- status_token = (
- RowStatus.EDITED
- if manifest.edited
- else RowStatus.PINNED
- if manifest.pinned
- else RowStatus.READY
- )
- return AlignedRow(
- glyph,
- status_token,
- (
- AlignedColumn(format_source_filename(manifest.source_path)),
- AlignedColumn(f"{manifest.frame_count} frames", True),
- AlignedColumn(model.display_name),
- AlignedColumn(
- f"{format_timecode(start)}–{format_timecode(end)} · {fps} fps"
+ for label, enabled in (
+ (
+ QCoreApplication.translate("WorkspacePresentation", "edited"),
+ manifest.edited,
),
- AlignedColumn(
- format_source_dimensions(manifest.width, manifest.height)
+ (
+ QCoreApplication.translate("WorkspacePresentation", "pinned"),
+ manifest.pinned,
),
- AlignedColumn(_created_at(manifest.created_at_ns)),
- AlignedColumn(flags),
- ),
- detail,
+ )
+ if enabled
+ ) or QCoreApplication.translate("WorkspacePresentation", "unchanged")
+ display_name = model_display_name(model_id, model.display_name)
+ purpose = model_purpose(model_id, model.purpose)
+ license_note = model_license(model_id, model.license_note)
+ frame_count = manifest.frame_count
+ frame_text = _WORKSPACE_TRANSLATOR.format_frames(frame_count)
+ fps_text = QCoreApplication.translate("WorkspacePresentation", "%s fps") % fps
+ return {
+ "filename": format_source_filename(manifest.source_path),
+ "frame_text": frame_text,
+ "display_name": display_name,
+ "range_text": QCoreApplication.translate("WorkspacePresentation", "%1–%2 · %3")
+ .replace("%1", format_localized_timecode(start))
+ .replace("%2", format_localized_timecode(end))
+ .replace("%3", fps_text),
+ "dimensions": format_source_dimensions(manifest.width, manifest.height),
+ "created_at": _created_at(manifest.created_at_ns),
+ "purpose": purpose,
+ "license_note": license_note,
+ "flags": flags,
+ "size": format_source_file_size(summary.size_bytes),
+ "range": f"{format_localized_timecode(start)}–{format_localized_timecode(end)}",
+ "fps": fps_text,
+ }
+
+
+def _workspace_detail(values: dict[str, str]) -> str:
+ status_words = (
+ QCoreApplication.translate("WorkspacePresentation", "%1; %2")
+ .replace("%1", values["flags"])
+ .replace("%2", values["size"])
+ )
+ return (
+ QCoreApplication.translate(
+ "WorkspacePresentation",
+ "%1; %2; %3; %4 at %5; %6; created %7; purpose: %8; licence: %9; %10",
+ )
+ .replace("%10", status_words)
+ .replace("%1", values["filename"])
+ .replace("%2", values["frame_text"])
+ .replace("%3", values["display_name"])
+ .replace("%4", values["range"])
+ .replace("%5", values["fps"])
+ .replace("%6", values["dimensions"])
+ .replace("%7", values["created_at"])
+ .replace("%8", values["purpose"])
+ .replace("%9", values["license_note"])
)
-def request_for_workspace(
- manifest: CutManifest, base: RenderRequest
-) -> RenderRequest:
+def request_for_workspace(manifest: CutManifest, base: RenderRequest) -> RenderRequest:
"""Use the manifest's exact cut inputs with the current output/framing."""
inputs = manifest.cache_key_inputs
sampling = _mapping(inputs["sampling"])
@@ -147,8 +206,9 @@ def _string(value: object) -> str:
def _created_at(timestamp_ns: int) -> str:
try:
- return datetime.fromtimestamp(timestamp_ns / 1_000_000_000).strftime(
- "%Y-%m-%d %H:%M"
+ timestamp = QDateTime.fromMSecsSinceEpoch(timestamp_ns // 1_000_000)
+ return display_locale().toString(
+ timestamp, QLocale.FormatType.ShortFormat
)
except (OverflowError, OSError, ValueError):
- return "unknown time"
+ return QCoreApplication.translate("WorkspacePresentation", "unknown time")
diff --git a/tests/test_resources.py b/tests/test_resources.py
index 6d102e1..a69f2ae 100644
--- a/tests/test_resources.py
+++ b/tests/test_resources.py
@@ -1,10 +1,12 @@
from __future__ import annotations
import os
+import shutil
import sys
from pathlib import Path
import pytest
+from PySide6.QtCore import QTranslator
import matteloop.resources as resources_module
from matteloop.resources import (
@@ -12,6 +14,9 @@
resource_path,
status_icon_asset,
)
+from matteloop.ui.i18n import application_translator
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.parametrize(
@@ -46,6 +51,18 @@ def test_resource_path_resolves_one_explicit_runtime_copy(tmp_path: Path) -> Non
assert read_resource_bytes("manifest.json", runtime_root=tmp_path) == b"{}"
+def test_qtranslator_loads_catalogue_from_frozen_resource_root(tmp_path: Path) -> None:
+ resource_dir = tmp_path / "resources"
+ resource_dir.mkdir()
+ source = REPOSITORY_ROOT / "resources" / "matteloop_de.qm"
+ destination = resource_dir / source.name
+ shutil.copy2(source, destination)
+
+ translator = QTranslator()
+ assert translator.load(str(destination))
+ assert application_translator("de", runtime_root=tmp_path) is not None
+
+
def test_resource_path_fails_closed_when_resource_is_missing(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="resource not found"):
resource_path("manifest.json", runtime_root=tmp_path)
diff --git a/tests/test_translations.py b/tests/test_translations.py
new file mode 100644
index 0000000..0503565
--- /dev/null
+++ b/tests/test_translations.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+import xml.etree.ElementTree as ET
+from collections import Counter
+from pathlib import Path
+
+import pytest
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+_PLACEHOLDER = re.compile(r"%(?:n|[0-9]+|s)")
+
+
+def _placeholders(text: str) -> Counter[str]:
+ return Counter(_PLACEHOLDER.findall(text))
+
+
+def _message_keys(path: Path) -> set[tuple[str, str, bool]]:
+ root = ET.parse(path).getroot()
+ return {
+ (
+ context.findtext("name") or "",
+ message.findtext("source") or "",
+ message.attrib.get("numerus") == "yes",
+ )
+ for context in root.findall("context")
+ for message in context.findall("message")
+ }
+
+
+def _assert_translations_are_complete(path: Path) -> None:
+ root = ET.parse(path).getroot()
+ messages = list(root.iter("message"))
+ assert messages
+ for message in messages:
+ source = message.findtext("source") or ""
+ translation = message.find("translation")
+ assert translation is not None
+ if message.attrib.get("numerus") == "yes":
+ forms = translation.findall("numerusform")
+ assert forms and all(form.text for form in forms), source
+ translated = [form.text or "" for form in forms]
+ else:
+ assert (translation.text or "").strip(), source
+ translated = [translation.text or ""]
+ for form in translated:
+ assert _placeholders(source) == _placeholders(form), source
+
+
+def test_catalogues_match_lupdate_extraction(tmp_path: Path) -> None:
+ lupdate = shutil.which("pyside6-lupdate")
+ if lupdate is None:
+ pytest.skip("pyside6-lupdate is not on PATH")
+ generated = tuple(
+ tmp_path / name for name in ("matteloop_en.ts", "matteloop_de.ts")
+ )
+ subprocess.run(
+ [
+ lupdate,
+ "-extensions",
+ "py",
+ "-no-obsolete",
+ "src/matteloop",
+ "-ts",
+ *(str(path) for path in generated),
+ ],
+ cwd=REPOSITORY_ROOT,
+ check=True,
+ )
+ for filename, extracted in zip(
+ ("matteloop_en.ts", "matteloop_de.ts"), generated
+ ):
+ checked = REPOSITORY_ROOT / "resources" / filename
+ assert _message_keys(extracted) == _message_keys(checked)
+ _assert_translations_are_complete(checked)
+
+
+def test_compiled_catalogues_match_their_sources(tmp_path: Path) -> None:
+ """A stale .qm ships the wrong words while every .ts review looks correct."""
+ lrelease = shutil.which("pyside6-lrelease")
+ if lrelease is None:
+ pytest.skip("pyside6-lrelease is not on PATH")
+ for name in ("matteloop_en", "matteloop_de"):
+ rebuilt = tmp_path / f"{name}.qm"
+ subprocess.run(
+ [
+ lrelease,
+ str(REPOSITORY_ROOT / "resources" / f"{name}.ts"),
+ "-qm",
+ str(rebuilt),
+ ],
+ check=True,
+ )
+ checked = REPOSITORY_ROOT / "resources" / f"{name}.qm"
+ assert rebuilt.read_bytes() == checked.read_bytes(), name
+
+
+def test_installing_translators_reaches_the_german_catalogue() -> None:
+ """app.py calls this at every GUI start; nothing else covers it."""
+ from PySide6.QtCore import QCoreApplication
+ from PySide6.QtWidgets import QApplication
+
+ from matteloop.ui.i18n import install_translators
+
+ application = QApplication.instance() or QApplication([])
+ assert isinstance(application, QApplication)
+ translators = install_translators(application, "de")
+ try:
+ assert translators
+ assert (
+ QCoreApplication.translate("ActionShelf", "Render Video")
+ == "Video rendern"
+ )
+ finally:
+ for translator in translators:
+ application.removeTranslator(translator)
diff --git a/tests/ui/test_preferences_surface.py b/tests/ui/test_preferences_surface.py
index a0e4d65..a632169 100644
--- a/tests/ui/test_preferences_surface.py
+++ b/tests/ui/test_preferences_surface.py
@@ -52,12 +52,8 @@ def _settings(name: str) -> QSettings:
return settings
-def _ready_store(
- source: Path, output_directory: Path | None = None
-) -> ReducerStore:
- state = AppState(
- parameters=ParameterState(output_directory=output_directory)
- )
+def _ready_store(source: Path, output_directory: Path | None = None) -> ReducerStore:
+ state = AppState(parameters=ParameterState(output_directory=output_directory))
loading = reduce(state, SourceLoadRequested("source", "load"))
ready = reduce(loading, SourceLoaded("source", "load", Metadata(source)))
return ReducerStore(ready)
@@ -98,9 +94,7 @@ def test_preferences_directory_matches_inspector_and_can_fall_back_to_source(
inspector = Inspector(settings)
qtbot.addWidget(preferences)
qtbot.addWidget(inspector)
- monkeypatch.setattr(
- QFileDialog, "getExistingDirectory", lambda *_args: str(chosen)
- )
+ monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *_args: str(chosen))
preferences.choose_output_directory_button.click()
@@ -122,9 +116,7 @@ def test_preferences_clear_works_without_a_loaded_source(qtbot, tmp_path: Path)
override = tmp_path / "exports"
settings = _settings("clear-without-source")
settings.setValue("parameters/output_directory", str(override))
- store = ReducerStore(
- AppState(parameters=ParameterState(output_directory=override))
- )
+ store = ReducerStore(AppState(parameters=ParameterState(output_directory=override)))
controller = SourceController(store, settings=settings)
dialog = SettingsDialog(store, controller)
qtbot.addWidget(dialog)
@@ -165,3 +157,22 @@ def test_preferences_offers_no_cancel_it_cannot_honour(qtbot) -> None:
assert dialog.button_box.button(QDialogButtonBox.StandardButton.Close)
assert dialog.button_box.button(QDialogButtonBox.StandardButton.Cancel) is None
assert dialog.button_box.button(QDialogButtonBox.StandardButton.Ok) is None
+
+
+def test_preferences_persists_language_for_the_next_restart(qtbot) -> None:
+ settings = _settings("language")
+ # Start from English explicitly: without a stored value the dialog opens on
+ # the system language, so on a German machine selecting German is a no-op
+ # and the test would only ever pass on an English host.
+ settings.setValue("ui/language", "en")
+ dialog = SettingsDialog(ReducerStore(AppState()), Services(), settings=settings)
+ qtbot.addWidget(dialog)
+ assert dialog.language_selector.currentData() == "en"
+
+ dialog.language_selector.setCurrentIndex(dialog.language_selector.findData("de"))
+
+ assert settings.value("ui/language") == "de"
+ assert (
+ dialog.language_note_label.text()
+ == "Interface language changes apply after restarting MatteLoop."
+ )