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.
10 changes: 5 additions & 5 deletions resources/matteloop_de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
<translation>Original-Videobild</translation>
</message>
<message>
<location filename="../src/matteloop/ui/crop_canvas.py" line="333"/>
<location filename="../src/matteloop/ui/crop_canvas.py" line="344"/>
<source>Crop bounds: x %s, y %s, width %s, height %s source pixels; source %s × %s</source>
<translation>Zuschnittgrenzen: x %s, y %s, Breite %s, Höhe %s Quellpixel; Quelle %s × %s</translation>
</message>
Expand Down Expand Up @@ -354,7 +354,7 @@
<message>
<location filename="../src/matteloop/ui/inspector.py" line="430"/>
<source>%s — %s download required</source>
<translation>%s — %s Download erforderlich</translation>
<translation>Für %s ist ein Download von %s erforderlich</translation>
</message>
<message>
<location filename="../src/matteloop/ui/inspector.py" line="465"/>
Expand Down Expand Up @@ -1124,7 +1124,7 @@ Freed %s.</source>
<message>
<location filename="../src/matteloop/ui/copy.py" line="367"/>
<source>Download &amp; Preview</source>
<translation>Herunterladen &amp; Vorschau</translation>
<translation>Modell laden &amp; Vorschau</translation>
</message>
<message>
<location filename="../src/matteloop/ui/copy.py" line="370"/>
Expand Down Expand Up @@ -1273,13 +1273,13 @@ Freed %s.</source>
<translation>Video wird gerendert</translation>
</message>
<message>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="305"/>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="312"/>
<location filename="../src/matteloop/ui/preview_controller/dialog.py" line="339"/>
<source>Previewing selected frame</source>
<translation>Ausgewähltes Bild wird in der Vorschau angezeigt</translation>
</message>
<message>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="311"/>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="318"/>
<location filename="../src/matteloop/ui/preview_controller/dialog.py" line="53"/>
<location filename="../src/matteloop/ui/preview_controller/dialog.py" line="344"/>
<source>Preparing model</source>
Expand Down
6 changes: 3 additions & 3 deletions resources/matteloop_en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
<translation>Original video frame</translation>
</message>
<message>
<location filename="../src/matteloop/ui/crop_canvas.py" line="333"/>
<location filename="../src/matteloop/ui/crop_canvas.py" line="344"/>
<source>Crop bounds: x %s, y %s, width %s, height %s source pixels; source %s × %s</source>
<translation>Crop bounds: x %s, y %s, width %s, height %s source pixels; source %s × %s</translation>
</message>
Expand Down Expand Up @@ -1273,13 +1273,13 @@ Freed %s.</translation>
<translation>Rendering video</translation>
</message>
<message>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="305"/>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="312"/>
<location filename="../src/matteloop/ui/preview_controller/dialog.py" line="339"/>
<source>Previewing selected frame</source>
<translation>Previewing selected frame</translation>
</message>
<message>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="311"/>
<location filename="../src/matteloop/ui/preview_controller/controller.py" line="318"/>
<location filename="../src/matteloop/ui/preview_controller/dialog.py" line="53"/>
<location filename="../src/matteloop/ui/preview_controller/dialog.py" line="344"/>
<source>Preparing model</source>
Expand Down
13 changes: 12 additions & 1 deletion src/matteloop/jobs/models/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Comment thread
gitar-bot[bot] marked this conversation as resolved.
return target
except BaseException as error:
try:
Expand Down
7 changes: 7 additions & 0 deletions src/matteloop/ui/preview_controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
JobKind,
JobStageChanged,
JobState,
ModelAvailabilityChanged,
ModelPrepared,
PreviewFailed,
PreviewRequested,
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 42 additions & 2 deletions tests/jobs/models/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
101 changes: 101 additions & 0 deletions tests/ui/test_preview_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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__()
Expand Down Expand Up @@ -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
Expand Down