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." + )