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