From c3cc13524315654d8595cdb755a1b44367de8936 Mon Sep 17 00:00:00 2001 From: smb Date: Mon, 7 Sep 2026 00:34:43 +0200 Subject: [PATCH 1/2] feat: translate the interface, with German as the second language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MatteLoop was English by accident rather than by design: not one tr() call anywhere, every visible string a bare literal at its point of use. #47 had to translate six German strings that had been sitting in the execution-provider feature — found not by a test or a review but because a screenshot of the running window was rendered for the README and the German was simply visible in the picture. Without a translation layer there is no mechanism that can catch the next one. This wraps every user-visible string with Qt's own system: tr() and QCoreApplication.translate(), .ts sources, compiled .qm catalogues, loaded through QTranslator at startup. No new dependency — PySide6 ships lupdate and lrelease. Log lines, error codes, AppError.technical_detail and ProgressEvent payloads stay canonical English: they are data, not copy, and one of them is serialised as exception text. Number and date formatting follows the selected interface language rather than the system locale, which is the decision the issue left open. On a German machine an English interface used to read "6,042 s" inside otherwise English labels; now the two never disagree. The language is chosen in Preferences, persists immediately, and applies at the next start — including the locale, so words and numbers always change together. The catalogue is generated, never written. tests/test_translations.py runs the documented lupdate command into a temporary directory and compares the extracted messages — sources, contexts and numerus flags — against the checked catalogues, so a hand-edited entry or a plural that lupdate cannot see fails CI. That guard is not decorative: two earlier drafts of this change shipped hand-seeded entries the documented command could not reproduce, and a plural form that rendered "1 frames". Rendering the German interface was itself a review, as it was in #43: it showed "Compute acceleration" clipped to "Berechnungsbeschleun", so the German label is now "Beschleunigung". Also makes the language test independent of the host locale. Without a stored value the dialog opens on the system language, so on a German machine switching to German asserted on a no-op — it could only ever have passed on an English runner. The guardrail baselines for inspector.py and timeline.py rise to 897 and 842 lines. The growth is mechanical: tr() wrapping reflows long literals across several lines. The maintainer approved these two before the work started; no function crossed the 60-line budget, and main_window.py stayed inside its own by extracting its render helpers into main_window_render.py. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + README.md | 4 + docs/building.md | 15 + packaging/pysidedeploy.spec | 2 + pyproject.toml | 4 + resources/matteloop_de.qm | Bin 0 -> 55601 bytes resources/matteloop_de.ts | 2461 +++++++++++++++++ resources/matteloop_en.qm | Bin 0 -> 49643 bytes resources/matteloop_en.ts | 2461 +++++++++++++++++ scripts/guardrails-baseline.json | 16 +- src/matteloop/app.py | 10 +- src/matteloop/ui/action_shelf.py | 32 +- src/matteloop/ui/controller.py | 8 +- src/matteloop/ui/copy.py | 583 ++++ src/matteloop/ui/crop_canvas.py | 22 +- src/matteloop/ui/i18n.py | 131 + src/matteloop/ui/inspector.py | 228 +- src/matteloop/ui/inspector_disclosure.py | 8 +- src/matteloop/ui/main_window.py | 98 +- src/matteloop/ui/main_window_render.py | 101 + src/matteloop/ui/model_manager/_controller.py | 285 +- src/matteloop/ui/model_manager/_dialog.py | 162 +- src/matteloop/ui/model_manager/_entries.py | 41 +- src/matteloop/ui/presenter.py | 7 +- src/matteloop/ui/preview_canvas.py | 23 +- .../ui/preview_controller/controller.py | 22 +- src/matteloop/ui/preview_controller/dialog.py | 363 ++- .../ui/preview_controller/runtime.py | 20 +- src/matteloop/ui/preview_controller/worker.py | 1 + src/matteloop/ui/render_controller.py | 65 +- src/matteloop/ui/result_player.py | 42 +- src/matteloop/ui/settings_dialog.py | 132 +- src/matteloop/ui/source_presentation.py | 66 +- src/matteloop/ui/source_strip.py | 35 +- src/matteloop/ui/timeline.py | 120 +- src/matteloop/ui/transform_group.py | 177 +- src/matteloop/ui/workspace_controller.py | 24 +- src/matteloop/ui/workspace_dialog.py | 103 +- src/matteloop/ui/workspace_presentation.py | 148 +- tests/test_resources.py | 17 + tests/test_translations.py | 77 + tests/ui/test_preferences_surface.py | 35 +- 42 files changed, 7449 insertions(+), 702 deletions(-) create mode 100644 resources/matteloop_de.qm create mode 100644 resources/matteloop_de.ts create mode 100644 resources/matteloop_en.qm create mode 100644 resources/matteloop_en.ts create mode 100644 src/matteloop/ui/copy.py create mode 100644 src/matteloop/ui/i18n.py create mode 100644 src/matteloop/ui/main_window_render.py create mode 100644 tests/test_translations.py 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 0000000000000000000000000000000000000000..983d582cf14f6a2847174c42073e87ccd1ae9be6 GIT binary patch literal 55601 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 + + + + 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 0000000000000000000000000000000000000000..ccc1d1f227dcac5122d44624ec02d310925a1250 GIT binary patch literal 49643 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 + + + + 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..c9fce49 --- /dev/null +++ b/src/matteloop/ui/i18n.py @@ -0,0 +1,131 @@ +"""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) + + translations_enum = getattr(QLibraryInfo.LibraryPath, "Translations") + qt_translations = Path(QLibraryInfo.path(translations_enum)) + 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..2c0e9ae --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,77 @@ +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) 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." + ) From 6c34baf12993306bff11feffccc42d75449aabee Mon Sep 17 00:00:00 2001 From: smb Date: Mon, 7 Sep 2026 00:41:47 +0200 Subject: [PATCH 2/2] fix: load Qt's own catalogue without crashing every GUI start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger: the Gitar review on the pull request. install_translators read QLibraryInfo.LibraryPath.Translations, which does not exist — the member is TranslationsPath — so app.py raised AttributeError before the window appeared. Every launch, on both platforms. The whole suite was green while the application could not start, because nothing called install_translators: it is reached only from _run_gui, and no test runs the real startup path. A wrong enum member is exactly the kind of mistake a type checker cannot see through getattr() and a reading review skims past. So the fix is one name, and the coverage is the point: one test installs the translators for real and asserts a German string comes back, and a second compiles both .ts files with lrelease and compares the result byte for byte against the checked-in .qm. The catalogue tests until now proved the sources were extractable and complete, but never that the compiled files anyone actually ships match them — a stale .qm would have shipped the wrong words while every review of the .ts looked correct. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/i18n.py | 5 +++-- tests/test_translations.py | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/matteloop/ui/i18n.py b/src/matteloop/ui/i18n.py index c9fce49..29ae9fd 100644 --- a/src/matteloop/ui/i18n.py +++ b/src/matteloop/ui/i18n.py @@ -114,8 +114,9 @@ def install_translators( application.installTranslator(app_translator) translators.append(app_translator) - translations_enum = getattr(QLibraryInfo.LibraryPath, "Translations") - qt_translations = Path(QLibraryInfo.path(translations_enum)) + 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)): diff --git a/tests/test_translations.py b/tests/test_translations.py index 2c0e9ae..0503565 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -75,3 +75,44 @@ def test_catalogues_match_lupdate_extraction(tmp_path: Path) -> None: 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)