diff --git a/resources/matteloop_de.qm b/resources/matteloop_de.qm index 6cf020b..fcceead 100644 Binary files a/resources/matteloop_de.qm and b/resources/matteloop_de.qm differ diff --git a/resources/matteloop_de.ts b/resources/matteloop_de.ts index 0c6615e..fc739e7 100644 --- a/resources/matteloop_de.ts +++ b/resources/matteloop_de.ts @@ -118,13 +118,13 @@ - + Edge treatment Kantenbehandlung - + Output FPS Ausgabe-FPS @@ -158,7 +158,7 @@ - + Horizontal stretch Horizontale Streckung @@ -178,206 +178,221 @@ 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… - + + Reset to defaults + Auf Standardwerte zurücksetzen + + + + Reset inspector parameters + Inspector-Parameter zurücksetzen + + + + Compute acceleration, output directory, and output filename are not affected. + Rechenbeschleunigung, Ausgabeordner und Ausgabedateiname bleiben unverändert. + + + Segmentation model Segmentierungsmodell - + cached locally lokal zwischengespeichert - + not cached yet noch nicht zwischengespeichert - + Model status Modellstatus - + Model download Modelldownload - + 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 - + Clear Zurücksetzen - + Clear output directory Ausgabeordner zurücksetzen - + 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 Maximale Dateigröße - + Unlimited Unbegrenzt - + MiB MiB - + Unlimited means no maximum file size. Unbegrenzt bedeutet: Es gibt keine maximale Dateigröße. - + %s — %s download required %s — %s Download erforderlich - + 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 @@ -1927,27 +1942,27 @@ Freed %s. 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 @@ -2057,17 +2072,17 @@ Freed %s. SourceStrip - + Source video Quellvideo - + Open Video… Video öffnen… - + Open Video Video öffnen diff --git a/resources/matteloop_en.qm b/resources/matteloop_en.qm index c0b3841..7cd26e3 100644 Binary files a/resources/matteloop_en.qm and b/resources/matteloop_en.qm differ diff --git a/resources/matteloop_en.ts b/resources/matteloop_en.ts index d5ad699..e8c3ef2 100644 --- a/resources/matteloop_en.ts +++ b/resources/matteloop_en.ts @@ -118,13 +118,13 @@ - + Edge treatment Edge treatment - + Output FPS Output FPS @@ -158,7 +158,7 @@ - + Horizontal stretch Horizontal stretch @@ -178,206 +178,221 @@ 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… - + + Reset to defaults + Reset to defaults + + + + Reset inspector parameters + Reset inspector parameters + + + + Compute acceleration, output directory, and output filename are not affected. + Compute acceleration, output directory, and output filename are not affected. + + + Segmentation model Segmentation model - + cached locally cached locally - + not cached yet not cached yet - + Model status Model status - + Model download Model download - + 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 - + Clear Clear - + Clear output directory Clear 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 Maximum file size - + Unlimited Unlimited - + MiB MiB - + Unlimited means no maximum file size. Unlimited means no maximum file size. - + %s — %s download required %s — %s download required - + 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 @@ -1927,27 +1942,27 @@ Freed %s. 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 @@ -2057,17 +2072,17 @@ Freed %s. SourceStrip - + Source video Source video - + Open Video… Open Video… - + Open Video Open Video diff --git a/src/matteloop/core/parameters.py b/src/matteloop/core/parameters.py index e70265c..a9a1e12 100644 --- a/src/matteloop/core/parameters.py +++ b/src/matteloop/core/parameters.py @@ -144,6 +144,11 @@ class OutputMaxSizeChanged: value: Decimal +@dataclass(frozen=True, slots=True) +class ParametersReset: + pass + + @dataclass(frozen=True, slots=True) class TransformChanged: transform: TransformSpec @@ -161,6 +166,7 @@ class TransformChanged: | OutputDirectoryChanged | OutputFilenameChanged | OutputMaxSizeChanged + | ParametersReset | TransformChanged ) @@ -174,6 +180,8 @@ def reduce_parameters(state: AppState, event: ParameterEvent) -> AppState: return state elif not capabilities(state).can_edit: return state + if isinstance(event, ParametersReset): + return _reduce_parameters_reset(state) if isinstance(event, ModelChanged): return _reduce_model(state, event) if isinstance(event, EdgeModeChanged): @@ -201,6 +209,39 @@ def reduce_parameters(state: AppState, event: ParameterEvent) -> AppState: return state +def _reduce_parameters_reset(state: AppState) -> AppState: + from matteloop.core.tokens import PreviewInvalidationReason + + defaults = ParameterState() + parameters = replace( + state.parameters, + model_id=defaults.model_id, + edge_mode=defaults.edge_mode, + fps=defaults.fps, + trim=defaults.trim, + alpha_threshold=defaults.alpha_threshold, + padding=defaults.padding, + stretch_x=defaults.stretch_x, + max_mib=defaults.max_mib, + ) + timeline = state.timeline + if timeline is not None and timeline.fps != defaults.fps: + try: + timeline = timeline.set_fps(defaults.fps) + except ValueError: + return state + if parameters == state.parameters and timeline == state.timeline: + return state + model_available = state.model_available + if parameters.model_id != state.parameters.model_id: + model_available = False + return _invalidate( + replace(state, timeline=timeline, model_available=model_available), + parameters, + PreviewInvalidationReason.CROP_CLEANUP, + ) + + def _reduce_model(state: AppState, event: ModelChanged) -> AppState: from matteloop.core.state import PreviewInvalidated, reduce from matteloop.core.tokens import PreviewInvalidationReason diff --git a/src/matteloop/ui/inspector.py b/src/matteloop/ui/inspector.py index 8ed6e08..f8425f6 100644 --- a/src/matteloop/ui/inspector.py +++ b/src/matteloop/ui/inspector.py @@ -65,6 +65,7 @@ ) from matteloop.ui.crop_presentation import CropPresentation from matteloop.ui.inspector_disclosure import configure_disclosure, read_bool +from matteloop.ui.inspector_reset import build_reset_button from matteloop.ui.parameter_presentation import ( ParameterPresentation, decimal_from_widget_value, @@ -170,6 +171,7 @@ def _build_parameter_controls(self) -> None: self._build_sampling_parameter_controls() self._build_cleanup_parameter_controls() self._build_output_parameter_controls() + self.reset_parameters_button = build_reset_button(self.command_requested.emit) self._connect_parameter_controls() def _build_segmentation_parameter_controls(self) -> None: @@ -506,6 +508,7 @@ def apply_parameters( self.clear_output_directory_button, self.output_filename_edit, self.max_size_spinbox, + self.reset_parameters_button, ): widget.setEnabled(available) self.fps_warning.setVisible(available and presentation.fps > 60) @@ -764,6 +767,7 @@ def _cleanup_controls(self) -> QWidget: layout.addRow(self._form_label("Alpha threshold"), self.alpha_threshold_spinbox) layout.addRow(self._form_label("Padding"), self.padding_spinbox) layout.addRow(self._form_label("Horizontal stretch"), self.stretch_spinbox) + layout.addRow(self._form_label(""), self.reset_parameters_button) return controls def _output_controls(self) -> QWidget: @@ -825,37 +829,33 @@ def parameter_tab_widgets(self) -> tuple[QWidget, ...]: self.clear_output_directory_button, self.output_filename_edit, self.max_size_spinbox, + self.reset_parameters_button, ) def tab_widgets(self) -> tuple[QWidget, ...]: """Return the full inspector tab route in consequence order.""" return ( self.disclosures["segmentation"][0], - self.model_picker, - self.edge_picker, + self.model_picker, self.edge_picker, self.manage_models, self.disclosures["time_sampling"][0], - self.fps_spinbox, - self.start_spinbox, - self.end_spinbox, + self.fps_spinbox, self.start_spinbox, self.end_spinbox, self.duration_spinbox, self.disclosures["crop_cleanup"][0], *self.crop_tab_widgets(), - self.trim_checkbox, - self.alpha_threshold_spinbox, - self.padding_spinbox, + self.trim_checkbox, self.alpha_threshold_spinbox, self.padding_spinbox, self.stretch_spinbox, self.disclosures["transform"][0], *self.transform_group.tab_widgets(), self.disclosures["output"][0], - self.output_directory_button, - self.clear_output_directory_button, + self.output_directory_button, self.clear_output_directory_button, self.output_filename_edit, self.max_size_spinbox, self.disclosures["workspace"][0], self.edited_cut_recovery, self.rebuild_button, self.manage_workspaces, + self.reset_parameters_button, ) def crop_tab_widgets(self) -> tuple[QWidget, ...]: diff --git a/src/matteloop/ui/inspector_reset.py b/src/matteloop/ui/inspector_reset.py new file mode 100644 index 0000000..9aefd43 --- /dev/null +++ b/src/matteloop/ui/inspector_reset.py @@ -0,0 +1,30 @@ +"""Inspector action for restoring creative parameter defaults.""" + +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtCore import QCoreApplication +from PySide6.QtWidgets import QPushButton + +from matteloop.core.parameters import ParametersReset + + +def build_reset_button(emit: Callable[[object], None]) -> QPushButton: + """Build the reset action with an honest description of its scope.""" + button = QPushButton( + QCoreApplication.translate("Inspector", "Reset to defaults") + ) + button.setObjectName("reset_parameters") + button.setAccessibleName( + QCoreApplication.translate("Inspector", "Reset inspector parameters") + ) + scope = QCoreApplication.translate( + "Inspector", + "Compute acceleration, output directory, and output filename are not " + "affected.", + ) + button.setToolTip(scope) + button.setAccessibleDescription(scope) + button.clicked.connect(lambda: emit(ParametersReset())) + return button diff --git a/tests/core/test_parameters.py b/tests/core/test_parameters.py index 522789b..30642b9 100644 --- a/tests/core/test_parameters.py +++ b/tests/core/test_parameters.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from decimal import Decimal from fractions import Fraction from pathlib import Path @@ -19,12 +19,13 @@ OutputFpsChanged, OutputMaxSizeChanged, PaddingChanged, + ParametersReset, ParameterState, StretchChanged, TransformChanged, parameters_from_values, ) -from matteloop.core.specs import EdgeMode, TransformSpec +from matteloop.core.specs import CropSpec, EdgeMode, TransformSpec from matteloop.core.state import ( AppState, PreviewInvalidationReason, @@ -163,6 +164,75 @@ def test_output_fps_changes_sampling_and_stales_the_current_preview() -> None: assert changed.stale_category is PreviewInvalidationReason.SAMPLING +def test_reset_restores_inspector_parameters_and_preserves_other_state() -> None: + current = _current() + assert current.timeline is not None + parameters = ParameterState( + model_id="u2net", + edge_mode=EdgeMode.DECONTAMINATE_COLORS, + execution_provider="CUDAExecutionProvider", + fps=90, + trim=True, + alpha_threshold=Decimal("0.4"), + padding=1, + stretch_x=Decimal("1.2"), + output_directory=Path("exports"), + output_filename="chosen.webp", + max_mib=Decimal("12.5"), + transform=TransformSpec(first_frame=1, crop=CropSpec(4, 5, 80, 70)), + ) + timeline = replace( + current.timeline, + start=Fraction(1, 2), + end=Fraction(7, 2), + playhead=Fraction(1), + fps=90, + ) + state = replace( + current, + parameters=parameters, + timeline=timeline, + crop=CropSpec(8, 9, 100, 90), + crop_enabled=False, + ) + + reset = reduce(state, ParametersReset()) + defaults = ParameterState() + + for name in ( + "model_id", + "edge_mode", + "fps", + "trim", + "alpha_threshold", + "padding", + "stretch_x", + "max_mib", + ): + assert getattr(reset.parameters, name) == getattr(defaults, name) + assert reset.parameters.execution_provider == "CUDAExecutionProvider" + assert reset.parameters.output_directory == Path("exports") + assert reset.parameters.output_filename == "chosen.webp" + assert reset.parameters.transform == parameters.transform + assert reset.source_id == state.source_id + assert reset.source_value is state.source_value + assert reset.crop == state.crop + assert reset.crop_enabled is state.crop_enabled + assert reset.timeline is not None + assert reset.timeline.start == state.timeline.start + assert reset.timeline.end == state.timeline.end + assert reset.timeline.playhead == state.timeline.playhead + assert reset.timeline.fps == defaults.fps + assert reset.preview is PreviewState.STALE + assert reset.stale_category is PreviewInvalidationReason.CROP_CLEANUP + + +def test_reset_parameters_is_ignored_while_a_job_runs() -> None: + running = reduce(_ready(), RenderRequested("job", "request")) + + assert reduce(running, ParametersReset()) is running + + def test_output_parameter_changes_keep_a_current_preview_current() -> None: state = _current() diff --git a/tests/ui/test_parameter_inspector.py b/tests/ui/test_parameter_inspector.py index db97d70..fcfe090 100644 --- a/tests/ui/test_parameter_inspector.py +++ b/tests/ui/test_parameter_inspector.py @@ -8,8 +8,9 @@ from PySide6.QtCore import QSettings, Qt from PySide6.QtWidgets import QAbstractSpinBox, QComboBox -from matteloop.core.parameters import ParameterState -from matteloop.core.state import AppState +from matteloop.core.parameters import ParametersReset, ParameterState +from matteloop.core.specs import CropSpec, EdgeMode, TransformSpec +from matteloop.core.state import AppState, SourceState from matteloop.core.timeline import TimelineState from matteloop.ui.aligned_rows import ( ACCESSIBLE_DESCRIPTION_ROLE, @@ -17,8 +18,10 @@ STATUS_ROLE, RowStatus, ) +from matteloop.ui.controller import SourceController from matteloop.ui.inspector import Inspector from matteloop.ui.parameter_presentation import present_parameters +from matteloop.ui.store import ReducerStore def _settings() -> QSettings: @@ -268,6 +271,94 @@ def test_inspector_emits_edge_mode_from_the_standard_combo(qtbot) -> None: assert [type(command).__name__ for command in commands] == ["EdgeModeChanged"] +def test_inspector_exposes_reset_action_and_explains_what_it_preserves(qtbot) -> None: + inspector = Inspector(_settings()) + qtbot.addWidget(inspector) + commands: list[object] = [] + inspector.command_requested.connect(commands.append) + + assert inspector.reset_parameters_button.text() == "Reset to defaults" + assert inspector.reset_parameters_button.accessibleName() == ( + "Reset inspector parameters" + ) + assert inspector.reset_parameters_button.toolTip() == ( + "Compute acceleration, output directory, and output filename are not affected." + ) + + inspector.reset_parameters_button.click() + + assert commands == [ParametersReset()] + + +def test_inspector_reset_updates_widgets_without_disturbing_other_editors( + qtbot, +) -> None: + settings = _settings() + parameters = ParameterState( + model_id="u2net", + edge_mode=EdgeMode.DECONTAMINATE_COLORS, + execution_provider="CUDAExecutionProvider", + fps=90, + trim=True, + alpha_threshold=Decimal("0.4"), + padding=1, + stretch_x=Decimal("1.2"), + output_directory=Path("exports"), + output_filename="chosen.webp", + max_mib=Decimal("12.5"), + transform=TransformSpec(first_frame=1, crop=CropSpec(4, 5, 80, 70)), + ) + timeline = TimelineState( + Fraction(4), Fraction(1, 2), Fraction(7, 2), Fraction(1), fps=90 + ) + state = AppState( + source=SourceState.READY, + source_id="source", + source_value=object(), + timeline=timeline, + parameters=parameters, + crop=CropSpec(8, 9, 100, 90), + crop_enabled=False, + ) + store = ReducerStore(state) + controller = SourceController.__new__(SourceController) + controller._store = store + controller._settings = settings + controller._working_provider = parameters.execution_provider + controller._failed_provider = None + controller._closed = False + inspector = Inspector(settings) + qtbot.addWidget(inspector) + + def render(updated: AppState) -> None: + inspector.apply_parameters(present_parameters(updated), editable=True) + + store.subscribe(render) + inspector.command_requested.connect(controller.dispatch) + inspector.apply_parameters(present_parameters(state), editable=True) + inspector.reset_parameters_button.click() + + defaults = ParameterState() + assert inspector.model_picker.currentData() == defaults.model_id + assert inspector.edge_picker.currentData() == defaults.edge_mode.value + assert inspector.fps_spinbox.value() == defaults.fps + assert inspector.trim_checkbox.isChecked() is defaults.trim + assert inspector.alpha_threshold_spinbox.value() == float(defaults.alpha_threshold) + assert inspector.padding_spinbox.value() == defaults.padding + assert inspector.stretch_spinbox.value() == float(defaults.stretch_x) + assert inspector.max_size_spinbox.value() == float(defaults.max_mib) + assert store.state.parameters.execution_provider == parameters.execution_provider + assert store.state.parameters.output_directory == parameters.output_directory + assert store.state.parameters.output_filename == parameters.output_filename + assert store.state.parameters.transform == parameters.transform + assert store.state.crop == state.crop + assert store.state.crop_enabled is state.crop_enabled + assert store.state.timeline is not None + assert store.state.timeline.start == timeline.start + assert store.state.timeline.end == timeline.end + assert store.state.timeline.playhead == timeline.playhead + + def test_inspector_exposes_and_emits_output_directory_clear(qtbot) -> None: inspector = Inspector(_settings()) qtbot.addWidget(inspector) diff --git a/tests/ui/test_parameter_persistence.py b/tests/ui/test_parameter_persistence.py index d31c336..ab173ee 100644 --- a/tests/ui/test_parameter_persistence.py +++ b/tests/ui/test_parameter_persistence.py @@ -10,7 +10,11 @@ CUDA_EXECUTION_PROVIDER, ProviderOption, ) -from matteloop.core.parameters import OutputFilenameChanged, ParameterState +from matteloop.core.parameters import ( + OutputFilenameChanged, + ParametersReset, + ParameterState, +) from matteloop.core.specs import CropSpec, EdgeMode, TransformSpec from matteloop.core.state import AppState, SourceState from matteloop.ui.controller import SourceController @@ -103,3 +107,54 @@ def test_failed_provider_is_not_reintroduced_by_a_later_parameter_save() -> None controller._dispatch_parameter(OutputFilenameChanged("next.webp")) assert settings.value("parameters/execution_provider") == CPU_EXECUTION_PROVIDER + + +def test_reset_persists_inspector_defaults_without_overwriting_excluded_choices( +) -> None: + settings = _settings() + saved = ParameterState( + model_id="u2net", + edge_mode=EdgeMode.DECONTAMINATE_COLORS, + execution_provider=CUDA_EXECUTION_PROVIDER, + fps=90, + trim=True, + alpha_threshold=Decimal("0.4"), + padding=1, + stretch_x=Decimal("1.2"), + output_directory=Path("exports"), + output_filename="chosen.webp", + max_mib=Decimal("12.5"), + ) + persist_parameters(settings, saved) + store = ReducerStore( + AppState( + source=SourceState.READY, + source_id="source", + source_value=object(), + parameters=saved, + ) + ) + controller = SourceController.__new__(SourceController) + controller._store = store + controller._settings = settings + controller._working_provider = CUDA_EXECUTION_PROVIDER + controller._failed_provider = None + + controller._dispatch_parameter(ParametersReset()) + actual = load_parameters(settings) + defaults = ParameterState() + + for name in ( + "model_id", + "edge_mode", + "fps", + "trim", + "alpha_threshold", + "padding", + "stretch_x", + "max_mib", + ): + assert getattr(actual, name) == getattr(defaults, name) + assert actual.execution_provider == saved.execution_provider + assert actual.output_directory == saved.output_directory + assert actual.output_filename == saved.output_filename