Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified resources/matteloop_de.qm
Binary file not shown.
125 changes: 70 additions & 55 deletions resources/matteloop_de.ts

Large diffs are not rendered by default.

Binary file modified resources/matteloop_en.qm
Binary file not shown.
125 changes: 70 additions & 55 deletions resources/matteloop_en.ts

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions src/matteloop/core/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -161,6 +166,7 @@ class TransformChanged:
| OutputDirectoryChanged
| OutputFilenameChanged
| OutputMaxSizeChanged
| ParametersReset
| TransformChanged
)

Expand All @@ -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):
Expand Down Expand Up @@ -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,
)
Comment on lines +238 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Reset uses CROP_CLEANUP stale reason despite model/edge change

_reduce_parameters_reset always invalidates with PreviewInvalidationReason.CROP_CLEANUP, even when the reset changed model_id or edge_mode (which their own reducers classify as SEGMENTATION) or fps (SAMPLING). The category is purely presentational (it only drives the "preview again" status wording via presenter.py), so this is cosmetic rather than a caching bug, but the message shown after a reset that swapped the model will describe a crop/cleanup change. Consider choosing the most significant reason based on what actually changed.

Was this helpful? React with 👍 / 👎



def _reduce_model(state: AppState, event: ModelChanged) -> AppState:
from matteloop.core.state import PreviewInvalidated, reduce
from matteloop.core.tokens import PreviewInvalidationReason
Expand Down
20 changes: 10 additions & 10 deletions src/matteloop/ui/inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Reset button visual placement and tab order disagree

The reset button is added to _cleanup_controls (line 770), so it renders visually inside the "Crop & Cleanup" disclosure right after Horizontal stretch. But in tab_widgets it is appended dead last (line 858), after the entire Workspace section. A keyboard/screen-reader user tabbing through Crop & Cleanup jumps from the stretch spinbox straight to the Transform disclosure and only reaches "Reset to defaults" after Workspace, far from where it is shown. Given the inspector's strong tab-order/accessibility conventions, move the button to its visual position in the tab route (right after self.stretch_spinbox) or place it visually where the tab route expects it.

Was this helpful? React with 👍 / 👎

return controls

def _output_controls(self) -> QWidget:
Expand Down Expand Up @@ -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, ...]:
Expand Down
30 changes: 30 additions & 0 deletions src/matteloop/ui/inspector_reset.py
Original file line number Diff line number Diff line change
@@ -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.",
)
Comment on lines +22 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Reset scope text omits preserved crop/transform

The action's description states only "Compute acceleration, output directory, and output filename are not affected.", yet _reduce_parameters_reset also leaves transform (first_frame, crop) and top-level crop/crop_enabled untouched. A user reading the tooltip would reasonably infer crop and transform ARE reset, which contradicts the stated goal of an honest scope description. Consider mentioning that crop and transform framing are preserved (they have their own reset controls), or otherwise reconcile the text with what is actually reset.

Was this helpful? React with 👍 / 👎

button.setToolTip(scope)
button.setAccessibleDescription(scope)
button.clicked.connect(lambda: emit(ParametersReset()))
return button
74 changes: 72 additions & 2 deletions tests/core/test_parameters.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
95 changes: 93 additions & 2 deletions tests/ui/test_parameter_inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@
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,
ROW_DATA_ROLE,
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:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading