diff --git a/resources/matteloop_de.qm b/resources/matteloop_de.qm
index 9ea45c2..d309ea0 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 8e9e951..ba2f33e 100644
--- a/resources/matteloop_de.ts
+++ b/resources/matteloop_de.ts
@@ -30,7 +30,7 @@
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
@@ -354,7 +354,7 @@
%s — %s download required
- %s — %s Download erforderlich
+ Für %s ist ein Download von %s erforderlich
@@ -1124,7 +1124,7 @@ Freed %s.
Download & Preview
- Herunterladen & Vorschau
+ Modell laden & Vorschau
@@ -1273,13 +1273,13 @@ Freed %s.
Video wird gerendert
-
+
Previewing selected frame
Ausgewähltes Bild wird in der Vorschau angezeigt
-
+
Preparing model
diff --git a/resources/matteloop_en.ts b/resources/matteloop_en.ts
index 53126ae..7270123 100644
--- a/resources/matteloop_en.ts
+++ b/resources/matteloop_en.ts
@@ -30,7 +30,7 @@
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
@@ -1273,13 +1273,13 @@ Freed %s.
Rendering video
-
+
Previewing selected frame
Previewing selected frame
-
+
Preparing model
diff --git a/src/matteloop/jobs/models/download.py b/src/matteloop/jobs/models/download.py
index ca04dd3..e65afb5 100644
--- a/src/matteloop/jobs/models/download.py
+++ b/src/matteloop/jobs/models/download.py
@@ -176,6 +176,11 @@ def _download_locked(
if _entry_exists(bound, target_name, spec.id):
if _verified_file(bound, target_name, artifact, cancelled, spec.id):
_assert_bound_named(bound)
+ # Every path that leaves a usable weight on disk reports the
+ # terminal progress, because a caller reads it as "the weight
+ # is now usable". Skipping it here would make selecting an
+ # already-cached model report the model as missing.
+ progress(artifact.size_bytes, artifact.size_bytes)
return target
_safe_unlink(bound, target_name, spec.id)
_remove_stale_part(bound, part_name, spec.id)
@@ -234,7 +239,7 @@ def _download_locked(
except OSError as error:
raise _disk_error(spec.id, error) from error
digest.update(chunk)
- if known_total is not None:
+ if known_total is not None and completed < known_total:
progress(completed, known_total)
_raise_if_cancelled(cancelled, spec.id)
if completed != artifact.size_bytes:
@@ -267,6 +272,11 @@ def _download_locked(
if _verified_file(bound, target_name, artifact, cancelled, spec.id):
_safe_unlink(bound, part_name, spec.id)
_assert_bound_named(bound)
+ # Report completion on this path too: a caller that
+ # treats the terminal progress as "the weight is now
+ # usable" would otherwise never hear it for a weight
+ # that was already cached and verified.
+ progress(artifact.size_bytes, artifact.size_bytes)
return target
_safe_unlink(bound, target_name, spec.id)
_before_model_promotion(bound)
@@ -292,6 +302,7 @@ def _download_locked(
raise _permission_error(spec.id, error) from error
except OSError as error:
raise _disk_error(spec.id, error) from error
+ progress(artifact.size_bytes, artifact.size_bytes)
return target
except BaseException as error:
try:
diff --git a/src/matteloop/ui/preview_controller/controller.py b/src/matteloop/ui/preview_controller/controller.py
index 92584d4..bd99d85 100644
--- a/src/matteloop/ui/preview_controller/controller.py
+++ b/src/matteloop/ui/preview_controller/controller.py
@@ -17,6 +17,7 @@
JobKind,
JobStageChanged,
JobState,
+ ModelAvailabilityChanged,
ModelPrepared,
PreviewFailed,
PreviewRequested,
@@ -260,6 +261,12 @@ def _notification(self, notification: object) -> None:
if self._store.state.job.job_id != job_id:
return
if isinstance(notification, ProgressEvent):
+ if (
+ notification.stage is ProgressStage.DOWNLOADING_MODEL
+ and notification.total is not None
+ and notification.completed == notification.total
+ ):
+ self._store.dispatch(ModelAvailabilityChanged(True))
if self._store.state.job.phase is not JobState.CANCELLING:
assert self._dialog is not None
self._dialog.set_progress(notification)
diff --git a/tests/jobs/models/test_download.py b/tests/jobs/models/test_download.py
index 0f16655..a746f6b 100644
--- a/tests/jobs/models/test_download.py
+++ b/tests/jobs/models/test_download.py
@@ -131,14 +131,54 @@ def test_download_streams_to_versioned_part_verifies_and_atomically_promotes(
assert response.closed is True
-def test_unknown_content_length_emits_no_byte_progress(tmp_path: Path) -> None:
+def test_download_reports_completion_after_verified_weight_is_promoted(
+ tmp_path: Path,
+) -> None:
+ data = b"verified-model-bytes"
+ target = tmp_path / "2.0.75" / "u2net" / "u2net.onnx"
+ completion_target_states: list[bool] = []
+
+ _download(
+ tmp_path,
+ data,
+ response=FakeResponse(data, headers={"Content-Length": str(len(data))}),
+ progress=lambda done, total: (
+ completion_target_states.append(target.is_file())
+ if done == total
+ else None
+ ),
+ )
+
+ assert completion_target_states == [True]
+
+
+def test_unknown_content_length_reports_post_promotion_completion(
+ tmp_path: Path,
+) -> None:
events: list[tuple[int, int]] = []
_download(
tmp_path, b"abcdef", progress=lambda done, total: events.append((done, total))
)
- assert events == []
+ assert events == [(6, 6)]
+
+
+def test_an_already_cached_weight_still_reports_completion(tmp_path: Path) -> None:
+ """A cached weight takes the early return, and a caller that reads the
+ terminal progress as "usable now" must still hear it there -- otherwise
+ selecting an already-downloaded model reports it as missing.
+ """
+ data = b"abcdef"
+ first: list[tuple[int, int]] = []
+ _download(tmp_path, data, progress=lambda done, total: first.append((done, total)))
+ assert first == [(6, 6)]
+
+ second: list[tuple[int, int]] = []
+
+ _download(tmp_path, data, progress=lambda done, total: second.append((done, total)))
+
+ assert second == [(6, 6)]
@pytest.mark.parametrize("cancel_after_call", [1, 3, 6])
diff --git a/tests/ui/test_preview_controller.py b/tests/ui/test_preview_controller.py
index f6567b0..8ffad95 100644
--- a/tests/ui/test_preview_controller.py
+++ b/tests/ui/test_preview_controller.py
@@ -15,8 +15,10 @@
from matteloop.core.errors import AppError, ErrorCode
from matteloop.core.specs import CropSpec
from matteloop.core.state import (
+ CancelAcknowledged,
CancelRequested,
JobStageChanged,
+ ModelAvailabilityChanged,
ModelPrepared,
PreviewRequested,
PreviewState,
@@ -111,6 +113,42 @@ def close(self) -> None:
return
+class DownloadedModelFailureRuntime(FakePreviewRuntime):
+ def prepare(
+ self, model_id: str, extras: dict[str, object], context: JobContext
+ ) -> PreparedSegmentation:
+ super().prepare(model_id, extras, context)
+ context.progress(
+ ProgressStage.DOWNLOADING_MODEL,
+ 128,
+ total=128,
+ detail="128 / 128 bytes",
+ )
+ raise RuntimeError("segmentation process timed out")
+
+
+class DownloadedModelCancellationRuntime(FakePreviewRuntime):
+ def __init__(self) -> None:
+ super().__init__()
+ self.downloaded = Event()
+
+ def prepare(
+ self, model_id: str, extras: dict[str, object], context: JobContext
+ ) -> PreparedSegmentation:
+ prepared = super().prepare(model_id, extras, context)
+ context.progress(
+ ProgressStage.DOWNLOADING_MODEL,
+ 128,
+ total=128,
+ detail="128 / 128 bytes",
+ )
+ self.downloaded.set()
+ while not context.cancellation.requested:
+ Event().wait(0.01)
+ context.checkpoint("model-preparation")
+ return prepared
+
+
class BlockingPreviewRuntime(FakePreviewRuntime):
def __init__(self) -> None:
super().__init__()
@@ -295,6 +333,69 @@ def test_preview_request_uses_the_selected_oriented_crop(tmp_path: Path, qtbot)
controller.shutdown()
+def test_completed_model_stays_available_when_preview_setup_fails(
+ tmp_path: Path, qtbot, request
+) -> None:
+ path = tmp_path / "source.mp4"
+ path.write_bytes(b"fixture")
+ runtime = DownloadedModelFailureRuntime()
+ store = RecordingStore()
+ store.dispatch(ModelAvailabilityChanged(False))
+ controller = SourceController(
+ store,
+ source_adapter=FakeSourceAdapter(path),
+ preview_runtime=runtime,
+ )
+ request.addfinalizer(controller.shutdown)
+ window = MainWindow(store, controller, _settings())
+ qtbot.addWidget(window)
+ window.show()
+
+ controller.dispatch(VideoDropped(path))
+ qtbot.waitUntil(lambda: store.state.source.value == "ready", timeout=5000)
+ controller.dispatch(PreviewFrameRequested())
+
+ qtbot.waitUntil(lambda: store.state.preview is PreviewState.ERROR, timeout=5000)
+
+ assert store.state.model_available is True
+ assert window.preview_button.text() == "Preview Frame"
+ assert window.inspector.model_status.text() == "● Ready"
+ assert not window.inspector.model_download_notice.isVisible()
+
+
+def test_completed_model_stays_available_when_preview_is_cancelled(
+ tmp_path: Path, qtbot, request
+) -> None:
+ path = tmp_path / "source.mp4"
+ path.write_bytes(b"fixture")
+ runtime = DownloadedModelCancellationRuntime()
+ store = RecordingStore()
+ store.dispatch(ModelAvailabilityChanged(False))
+ preview_controller = PreviewController(store, runtime=runtime)
+ controller = SourceController(
+ store,
+ source_adapter=FakeSourceAdapter(path),
+ preview_controller=preview_controller,
+ )
+ request.addfinalizer(controller.shutdown)
+ window = MainWindow(store, controller, _settings())
+ qtbot.addWidget(window)
+ window.show()
+
+ controller.dispatch(VideoDropped(path))
+ qtbot.waitUntil(lambda: store.state.source.value == "ready", timeout=5000)
+ controller.dispatch(PreviewFrameRequested())
+ qtbot.waitUntil(runtime.downloaded.is_set, timeout=5000)
+ dialog = preview_controller.dialog
+ assert dialog is not None
+ qtbot.mouseClick(dialog.cancel_button, Qt.MouseButton.LeftButton)
+
+ qtbot.waitUntil(lambda: store.state.job.phase.value == "idle", timeout=5000)
+
+ assert store.state.model_available is True
+ assert any(isinstance(event, CancelAcknowledged) for event in store.events)
+
+
@pytest.mark.parametrize("use_escape", [False, True])
def test_cancel_keeps_modal_dialog_open_until_the_safe_checkpoint(
tmp_path: Path, qtbot, use_escape: bool