From aebf05803c6684fb41d0de28b24d42b9cfe244db Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 00:49:25 +0200 Subject: [PATCH 01/45] docs: plan the transform stage on a finished cut Records the implementation plan for issue #25: TransformSpec/ResizeSpec, the four resize mismatch modes, trim, output crop, sidecar persistence, the inspector group and the looping result player. The guardrail ratchet, not the feature, drives the module layout. Every file the issue named for the core work is frozen or nearly so, so the pixel ops, the rebuild staging, the persistence, the inspector group and the player each land in a new module, paid for by mechanical extractions out of render.py and inspector.py. Persistence deviates from the issue: the transform goes into a dot-prefixed sidecar beside the cut directory, not into CutManifest, because the manifest rejects unknown keys and a schema bump would make every touched cut unreadable to the previous release. Section 9 records the engineering review's findings and the eight corrections they produced. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-04-issue-25-transform-stage.md | 1167 +++++++++++++++++ 1 file changed, 1167 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md diff --git a/docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md b/docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md new file mode 100644 index 0000000..4d881d9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md @@ -0,0 +1,1167 @@ +# Transform Stage on a Finished Cut — Implementation Plan (issue #25) + +> Executes the decided spec in `ISSUE-25-SPEC.md` under the hard constraints in +> `CONSTRAINTS.md`. Where the two collide, the constraints win; every such +> override is listed in "Overrides of the spec's file table" at the end. +> This plan contains contracts, decisions and edge cases — not code. A task +> whose implementation is obvious from its contract is meant to be written by +> the implementer, not copied from here. + +**Branch:** `feat/issue-25-transform-stage` (off `main`). Never push to `main`, +never merge, never close the PR. PR text: `Closes #25`. + +**Definition of done for every stage:** `uv run ruff check . && uv run mypy src +&& QT_QPA_PLATFORM=offscreen uv run pytest -q` green, plus +`uv run python scripts/check_guardrails.py` passing with the baseline +untouched, plus `mypy src` reporting exactly the 9 pre-existing errors in the +files named in `CONSTRAINTS.md` §B and no new file in that list. + +--- + +## 1. Intent + +After this lands, a user who has rendered (or picked) a cut can, without +leaving MatteLoop and without re-running the model: + +- shorten the loop to a frame range, +- crop the output to a free or fixed-ratio rectangle drawn on the result + canvas, +- resize to explicit pixel dimensions (or a 7TV preset), choosing what happens + when the aspect ratio does not match (keep / stretch / cover / pad), +- watch the result loop at output fps before deciding, + +and then re-encode through the existing Rebuild path. The stored PNGs are never +modified; the transform is remembered per cut and restored when the cut is +reopened. + +--- + +## 2. Verified facts that shape the design + +Line numbers are from the branch point (`c0b6e7f`). + +| Fact | Evidence | +|---|---| +| A cut directory may contain **only** `manifest.json` plus the canonical frame names; anything else invalidates the set. | `jobs/workspace/_scan.py:60-79` builds `expected_names` and raises `_set_error("... unexpected ...")` on any extra entry. | +| That invalidation is not recoverable in the picker: `list_workspaces` retries with `detect_external_edits`, which rescans and raises again. | `jobs/workspace/_snapshot_ops.py:295-300`, `_cut_ops.py:366-373`. | +| The manifest rejects unknown keys **and** any other schema version, and all three manifest modules are frozen. | `_manifest.py:325-343` (`_exact_keys`), `_manifest.py:160-167` (schema gate); baseline `scripts/guardrails-baseline.json`. | +| Dot-prefixed entries in `cuts_root` are ignored by every scan that touches that directory. | `_snapshot_ops.py:264-265` (`list_workspaces` skips `name.startswith(".")`), `_models.py:53` (`_existing_promoted_path`), `_manifest_io.py:167-170` (recovery matches only `_MARKER_RE`), `_manifest_io.py:255-287` (recovery touches only the target/stage/backup/marker names it derives), `_common.py:85-90` (the three reserved dot-name regexes), `_platform.py:166-173` (`_assert_safe_directory` only opens the directory). `delete_workspace` removes only `workspace.path` (`_snapshot_ops.py:347`). | +| `webp_delays` distributes cumulative rounding — delays are **not** uniform (3 fps → 333/334/333; four frames → 333/334/333/333). | `core/timebase.py:41-46`: `rhu((i+1)·1000/fps) − rhu(i·1000/fps)`. | +| The cut cache key, the render fingerprint and the union fingerprint enumerate their inputs explicitly; none reads a whole `RenderRequest`. `render_fingerprint` hashes only `cut_key`, `framing`, `max_bytes` ("the framing and size settings used by encoding"); nothing in `ui/` or `core/state.py` reads `RenderArtifact.fingerprint` today. `cut_cache_key_inputs` names the cut directory and must not change. | `core/fingerprints.py:187-221`, `:235-246`, `:249-259`; grep `.fingerprint` over `src/matteloop/ui`, `core/state.py`. | +| `_encode_snapshot` bakes `tuple(notes)` into the `RenderArtifact` it returns, so a note appended *after* the call never reaches the artifact. Its parameter for the promoted workspace is named `durable`. | `jobs/render.py:1325`, `:1458-1472`. | +| `main_window` connects `command_requested` for exactly three widgets (timeline, original canvas, inspector); a new canvas signal is not wired automatically. | `ui/main_window.py:193-194`. | +| `CropCanvas.keyPressEvent` emits `CropChanged` directly, not through `_emit_drag`; `PreviewStage` puts the result canvas in cover mode, which crops the displayed pixmap to fill the canvas. | `ui/crop_canvas.py:210`, `ui/preview_canvas.py:219-227`, `:238`. | +| `apply_framing` is also used by the preview path and `errno` by the rollback path; only `tempfile` is exclusive to `_persist_framed_png`. `_map_output_os_error`/`_output_error` have 43 call sites in render.py (2 defs + 43 uses), none elsewhere. | `jobs/render.py:906`, `:920`, `:2008`, `:1647`; grep. | +| `ImageOps.contain`/`fit`/`pad` exist in Pillow but use Python `round()` and float crop boxes and do not premultiply; they cannot reproduce the house rounding or the pixel assertions of AC 4. | Pillow `ImageOps`; `core/webp.py:1226-1236`. | +| The house resize idiom is RGBA → `RGBa` (premultiplied) → `LANCZOS` → RGBA, PNG saved with `optimize=False`. | `core/webp.py:1226-1244` (`_resize_from_sources`). | +| Framing rounds with `_round_positive_fraction` (floor of x + ½). | `core/geometry.py:221-224`, `:1131-1133`. | +| `rebuild()` is 1155–1271; the per-frame framing loop lives in `_encode_snapshot` 1339–1385; the encoder is called at 1408–1416; `manifest.frame_count` is used as the encoded frame count at 1395 and 1460. | `jobs/render.py`. | +| `_persist_framed_png` (1643–1674) and the two output-error helpers (2906–2938) are the only render.py symbols the framing loop needs; the helpers have 43 call sites in render.py and none in tests; `_persist_framed_png` is patched by `tests/jobs/test_render.py:1550-1556` and called at `:2968`. | grep over `src`/`tests`. | +| `core/state.py` (880), `ui/presenter.py` (179) and `ui/main_window.py` (380) are in the baseline and must not grow. `ParameterEvent` is routed by `state.py:410-411` to `parameters.reduce_parameters`, so a new event added to the union in `core/parameters.py` needs no change in `state.py`. | baseline; `core/state.py:361,410-411`. | +| `ui/inspector.py` is at 798/800; `ui/render_controller.py` at 793/800. | `wc -l`. | +| `show_workspace_attention` (`inspector.py:771-773`) has no callers in `src` or `tests`; `_read_bool` (`:775-777`) has one caller (`:601`). | grep. | +| Reading a promoted cut through `read_promoted_cut` rescans and rehashes the whole directory on every call. | `_models.py:240` (`detect_external_edits(self)` per read). | +| The preview and full render must call the same crop/cleanup functions. | `docs/designs/matteloop-desktop-app.md:33`. | +| Every new function must be ≤ 60 lines; every new module ≤ 800 lines. | `scripts/check_guardrails.py`, `CONSTRAINTS.md` §A. | + +--- + +## 3. Architecture decisions + +### D1 — Persistence: a dot-prefixed sidecar **beside** the cut directory, not in it, not in the manifest + +**Chosen.** `cuts_root / f".transform-{cache_key}.json"`, written by the job +layer after a successful publish, read by the UI when a cut is opened. + +**Why not the manifest (spec's first choice).** `_manifest.py:325-343` rejects +any key it does not know and `:160-167` rejects any other `schema_version`. The +three manifest modules are frozen. Even with a read-compat shim in *new* code, +a manifest written with a new key or version is unreadable by the *previous* +release: for cache-key-named directories `validate_cut_set` raises +`CUT_MANIFEST_INVALID`, which `list_workspaces` does not catch +(`_snapshot_ops.py:296-300` only catches `CUT_SET_INVALID`), so one touched cut +would blank the whole picker after a rollback. The rollback plan in the spec +("must stay loadable by the previous code") cannot be met by a manifest field. + +**Why not a sidecar inside the cut directory.** `_scan.py:60-79` computes the +exact expected name set and raises on any extra entry; `list_workspaces` then +retries through `detect_external_edits`, which raises again. A single sidecar +inside a cut directory would make that cut — and, via the uncaught error, +every cut in the output directory — invisible to the picker. This is the +"strand or invalidate user data" outcome the constraints forbid. + +**Why the beside-the-directory sidecar is safe.** Every routine that lists or +recovers `cuts_root` either skips dot-names outright (`_snapshot_ops.py:264`, +`_models.py:53`) or matches one of three reserved patterns +(`_common.py:88-90`: `.stage-`, `.replace-…json`, `.backup-`), none of which +`.transform-<64 hex>.json` matches. Recovery (`_manifest_io.py:255-287`) only +ever removes the stage/backup/marker names it derives from the key. +`delete_workspace` removes only the cut directory, so the sidecar becomes an +orphan of a few hundred bytes; the UI delete path removes it explicitly +(Task B6). Old code never reads the file, so rollback strands nothing. + +Two paths that *see* the file and are harmless: `list_workspaces` counts every +entry (dot-names included) against `MAX_WORKSPACE_ENTRIES * 3 = 30 000` +(`_snapshot_ops.py:262`, `_common.py:76`) — at most one sidecar per cut and at +most 10 000 cuts, so the bound cannot trip. The picker's size column is summed +from the manifest (`_snapshot_ops.py:301-302`), so the sidecar's bytes are not +reported; accepted. `cleanup_scratch` (`_snapshot_ops.py:402`) and +`_bounded_tree_size` enumerate `scratch`, never `cuts`. `stage_cut` +(`_cut_ops.py:67-77`) enumerates a staging directory, never `cuts_root`. + +**Recorded deviation from AC 8.** AC 8 says "restores the last transform from +the manifest". The behaviour AC 8 describes — reopening a cut restores the +last applied transform, and a cut without a stored transform loads as identity +— is satisfied by the sidecar: absent file → identity; unreadable or +mismatched file → identity plus a logged note (degrade, never refuse). The +manifest itself is unchanged, which is what keeps every existing cut readable +by both old and new code. + +**Write discipline.** Temp file in `cuts_root`, fsync, `os.replace` +(guardrail G3 ceiling). Written only when the applied transform is +non-identity; an identity rebuild *removes* the sidecar so "reopen" then means +identity. Failures to write are appended to the artifact `notes`, never raised. +The one call lives **inside** `_encode_snapshot`, after the `if published:` +scratch cleanup and before `assert summary is not None` (render.py:1451-1458): +`store_transform(durable, request.transform, notes)`. Placing it after the +call in `render()`/`rebuild()` would lose the notes (`tuple(notes)` is baked +into the artifact at :1471) and would write the sidecar even when publication +did not happen. Existing tests that assert an empty or dot-free `cuts_root` +(`test_render.py:1526`, `:3006`) are unaffected because they run identity +transforms. + +**Rejected as secondary:** QSettings keyed by cache key — does not travel with +the output directory, leaks on delete, and V1 scope limits QSettings to +primitives. + +### D2 — Pixel operations live in a new `core/transform.py`; the encoder and the player call the same two functions + +`core/webp.py` and `core/geometry.py` are frozen. The new module owns +`resolve_resize` (pure integer arithmetic), `transformed_size` and +`apply_transform` (crop then resize on a Pillow RGBA image). Both the encoder +(via `jobs/transform_stage.py`) and the result player call, per frame, +`geometry.apply_framing(cut, plan)` followed by `transform.apply_transform(framed, +spec)`. That is the function pair the design document's rule at line 33 +refers to. The player additionally downsizes the *result* for display; the +encoder persists it. + +`apply_transform` returns the **same object** for an identity spec (no crop, +no convert, no resize, no copy). Byte identity (AC 1) therefore follows from +the fact that the extracted loop, with an identity spec, executes exactly the +statements it executes today. + +### D3 — `render.py` shrinks by extracting the framing loop plus its helpers into `jobs/transform_stage.py` + +The block `_encode_snapshot` lines 1339–1385 (trim-union guard, plan +construction, framed-directory creation, per-frame read → `apply_framing` → +persist loop, progress reporting) becomes one call into the new module. The +persistence primitive `_persist_framed_png` (1643–1674) moves with it because +it is called only from that loop. The two error helpers it needs +(`_map_output_os_error` 2906–2928, `_output_error` 2931–2938) move to +`jobs/encoding.py` (93 lines, "helpers kept separate from the frozen render +orchestration", already imported by render.py, imports nothing from render.py +— no cycle) and are imported back into render.py under the same names so its +43 call sites are untouched. + +Line accounting for render.py (must end ≤ 2938): + +| Change | Lines | +|---|---| +| Replace 1339–1385 with a plan+stage call | −47 + ~8 | +| Move `_persist_framed_png` (+2 blank) | −34 | +| Move the two error helpers (+2 blank) | −35 | +| Extend the `jobs.encoding` import, add `transform_stage` and `transform_store` imports | +5 (worst case after isort) | +| `store_transform(durable, request.transform, notes)` inside `_encode_snapshot` (D1) | +1 | +| `import tempfile` drops (its only use was :1647). `apply_framing` (:906, :920) and `errno` (:2008) stay. | −1 | +| **Net** | **≈ −103** | + +`jobs/encoding.py` gains `import errno` for `_map_output_os_error`; it already +imports `AppError` and `ErrorCode`. + +The extraction is behaviour-preserving and mechanical: the loop body is moved +verbatim, its free variables become parameters, and the only semantic change is +that the loop iterates the kept range and returns the kept delays. The +`tests/jobs/test_render.py:1550-1556` and `:2968` seams follow the moved +function (they patch/call `transform_stage._persist_framed_png` instead of +`render_module._persist_framed_png`; two one-line test edits). + +The `manifest.frame_count` uses at 1395 and 1460 become `len(framed_paths)` +(same line count). `RenderArtifact.frame_count`/`delays_ms` describe the +encoded output; `requested_timestamps`/`actual_pts` keep describing the cut +grid (unchanged, documented in the dataclass docstring — no field added). + +The trim/union guard and `FramingPlan` construction move too +(`framing_plan(...)` in the stage module), so the player builds its plan with +the same function as the encoder. + +**`render_fingerprint` stays as it is.** Adding the transform to the hash was +proposed and then dropped — see §4.11 for the decision and the evidence that +`RenderArtifact.fingerprint` has no consumer. `cut_cache_key_inputs` and +`union_fingerprint` are likewise untouched: the cut and its union do not depend +on the transform, and changing the cache key would orphan every promoted cut. + +### D4 — The transform is reducer state inside `ParameterState`; the event is a `ParameterEvent` + +`core/state.py` is frozen, so `AppState` gains no field. `ParameterState` +(`core/parameters.py:46-86`, 448 lines, room) gains `transform: TransformSpec` +with an identity default. `TransformChanged` joins the `ParameterEvent` union; +`state.py:410-411` already routes every `ParameterEvent` to +`reduce_parameters`, which gains one branch. The reducer does **not** +invalidate the preview (the single-frame preview is unaffected by a +post-framing transform), so changing a transform does not re-trigger the +"Preview recommended" preflight — Render → "Matching cut set found" → Rebuild +is the same path framing changes use today. + +QSettings persistence is untouched: `preferences.py` writes only its `_KEYS`; +`parameters_from_values` leaves the new field at its default. The transform is +per cut (D1), not a preference. + +### D5 — The UI needs a "current cut" that state cannot hold: a `CutSession` owned by a new controller + +Nothing in `AppState` names a cut workspace, and `ArtifactResult` (frozen +`state.py`) cannot gain one. The `RenderArtifact` returned by the worker does +carry `cut_workspace` and `manifest`. So: + +- `RenderWorker` gains `artifact_ready = Signal(object)` emitted with the raw + `RenderArtifact` before `RenderSucceeded`. +- `RenderController` re-emits it as its own `artifact_ready` signal (+2 lines) + and gains a public `transform_restore: Callable[[CutWorkspace], None] | None` + attribute consulted in `_use_workspace` before the request is built (+3 + lines), so "Use this set" restores the cut's stored transform *before* the + rebuild request is assembled. Total +5 within the 7-line headroom. Fallback if + the count overruns: move `_WorkspaceProbeWorker` (`render_controller.py:56-83`, + no test references) to `ui/workspace_controller.py`. +- A new `ui/transform_stage.py` holds `TransformStageController(QObject)`: it + subscribes to the store, owns the `CutSession`, owns the frame-loading + worker, and drives the `TransformGroup` (Stage B) and the + `ResultPlayerCanvas` (Stage C). It is created by `SourceController` and + attached to the window's widgets by one new method called from `app.py` + (`_run_gui` stays under 60 lines). `main_window.py` is untouched — which + means `attach` must itself connect `canvas.command_requested` to + `store.dispatch`; `main_window.py:193-194` wires only the timeline, the + original canvas and the inspector. + +The session records the *applied* transform on artifact success (the request's +transform, which the encoder just used), so a render that used a transform does +not reset the inspector to identity afterwards. + +### D6 — The inspector gains the Transform group as a separate module and two mechanical removals pay for the wiring + +`ui/inspector.py` has 2 lines of headroom. The group is a self-contained +`QWidget` in `ui/transform_group.py`; the inspector only constructs it, mounts +it in a new `("transform", "Transform", False)` disclosure and lists it in +`tab_widgets()`: + +| Inspector change | Lines | +|---|---| +| `from matteloop.ui.transform_group import TransformGroup` | +1 | +| `_DISCLOSURES` row | +1 | +| construct `self.transform_group` in `__init__` **before** the `_DISCLOSURES` loop (`:144`), passing `self.command_requested.emit` as its emitter | +1 | +| `_section`: `if key == "transform": …addWidget(self.transform_group)` | +2 | +| `tab_widgets()`: the disclosure button and `*self.transform_group.tab_widgets()` | +2 | +| remove `show_workspace_attention` (`:771-773`, no callers in `src`, `tests`, `resources`) | −4 | +| move `_read_bool` (`:775-777`) to `inspector_disclosure.read_bool(settings, name, default)`; `:601` becomes `read_bool(self._settings, …)`; the import line `from matteloop.ui.inspector_disclosure import configure_disclosure, read_bool` is 77 chars (the name `read_bool_setting` would make it 85 — still under ruff's 88, but leave no margin for isort to wrap it into four lines) | −4 | +| **Net** | **−1 → 797** | + +State reaches the group through the existing view path: `crop_view.render_source_editor` +(+1 line) calls `inspector.transform_group.apply(model.parameters, editable)`; +`ParameterPresentation` gains `transform` and `artifact` fields so the readout +can show the last artifact's actual size and file size (`present_parameters` +already receives the whole state; `presenter.py` is not touched). Cut facts +(frame count, framed size, fps, delays) reach the group from the +`TransformStageController` via `set_cut(...)`; the group is enabled only when +both a transform and cut facts are present. + +### D7 — The result player is a `CropCanvas` subclass with two frame caches and one timer + +`crop_canvas.py` (312 lines) is reused, not forked: its `__init__` gains +keyword parameters `title`, `object_name`, `runtime_root` (defaults preserve +today's behaviour) and **both** emit sites — `_emit_drag` (`:245`) and +`keyPressEvent` (`:210`) — route the new rectangle through a +`_constrain(crop, target)` hook (default identity) and emit via a +`_crop_event` factory (default `CropChanged`). `ResultPlayerCanvas` overrides +the hook (aspect lock) and the factory (`TransformChanged` with the new crop). +Missing the keyboard site would make an arrow-key nudge on the result canvas +dispatch a *source* crop change, invalidate the preview and force +re-segmentation on the next render. + +Two display modes on one canvas, chosen by the group's "Edit crop" toggle: +crop-edit mode plays the **framed** frames with the crop overlay and handles; +otherwise it plays the **transformed** frames (what the file will contain). +While a session is loaded the canvas displays in fit mode +(`set_cover_frame(False)`): the overlay geometry from `build_crop_geometry` +assumes a fitted pixmap, and cover mode (`preview_canvas.py:219-227`) would +crop a `pad` result's transparent columns out of view. The stage's cover +setting is restored when the session closes. + +Caching, bounded and reload-free for the common edits: + +- The loader (a `QThread` worker) reads **all** stored frames of the cut + directly by manifest filename (never through `read_promoted_cut`, which + rescans the directory per call — `_models.py:240`), applies `apply_framing` + and `apply_transform` at full resolution, and keeps two display-sized + caches: *framed*, keyed by (cut, framing, display size), and *transformed*, + keyed additionally by (crop, resize). Trim never reloads: playback slices + `kept_range` over the caches and the sliced `webp_delays` — the same numbers + the encoder receives. The framed cache survives crop/resize edits. +- Crop/resize edits arrive per mouse move. The transformed rebuild is + debounced (one restarted single-shot timer, 250 ms) and skipped entirely + while crop-edit mode is on (the canvas shows the framed cache then); it runs + once when crop-edit is toggled off. A generation counter drops stale results. +- Memory is capped by `PLAYER_CACHE_BUDGET_BYTES = 128 MiB` for both caches + together. The display size shrinks (aspect kept) until + `2 × frames × w × h × 4` fits, never below 64 px on the short side; if the + floor still overflows (thousands of frames), the caches hold the first N + stored frames that fit and the canvas shows the status marker "Previewing + the first N of M frames". Degrade, never refuse; `MAX_OUTPUT_FRAMES` is + 100 000 (`timebase.py`), so an unbounded cache is not hypothetical. + +The canvas keeps its public `PreviewCanvas` surface (`set_presented_frame`, +`set_status_marker`, `status_label`, properties) because `main_window._render` +calls them on every state change; while a session is playing it ignores a +repeated placeholder call but yields to a *new* preview image (identity +comparison), pausing playback — Preview Frame still shows its result. + +--- + +## 4. Contracts + +Signatures are the contract. Field lists are complete; defaults are as shown. + +### 4.1 `src/matteloop/core/specs.py` (additions; stays well under 800) + +``` +class MismatchMode(StrEnum): + KEEP = "keep"; STRETCH = "stretch"; COVER = "cover"; PAD = "pad" + +@dataclass(frozen=True) +class ResizeSpec: + width: int | None = None + height: int | None = None + mismatch: MismatchMode = MismatchMode.KEEP + def __post_init__(self) -> None # calls validate() + def validate(self) -> None # ValidationError(INVALID_TRANSFORM, "transform", …) + +@dataclass(frozen=True) +class TransformSpec: + first_frame: int = 0 + last_frame: int | None = None + crop: CropSpec | None = None + resize: ResizeSpec | None = None + def __post_init__(self) -> None # calls validate() + def validate(self) -> None # structural only + @property + def is_identity(self) -> bool # all four fields at their defaults + def kept_range(self, frame_count: int) -> range + def select_kept[T](self, items: tuple[T, ...]) -> tuple[T, ...] + def validate_for(self, frame_count: int, framed_size: tuple[int, int]) -> tuple[int, int] + # bounds against the cut; returns the final (width, height) before auto-fit + +@dataclass(frozen=True) +class SizePreset: + platform: str; label: str; width: int; height: int + +PLATFORM_SIZE_PRESETS: tuple[SizePreset, ...] # ("7TV", "128x128", 128, 128), ("7TV", "256x128", 256, 128), ("7TV", "384x128", 384, 128) +``` + +`RenderRequest` gains `transform: TransformSpec = field(default_factory=TransformSpec)` +(last field, so positional construction in tests keeps working) and +`validate()` calls `self.transform.validate()`. `validate_for_source` is +unchanged: the transform is validated against the *cut*, not the source. + +`MismatchMode` is a `StrEnum` with exactly the spec's four literal values — +the house idiom (`EdgeMode`, `CollisionPolicy`), serialising identically. The +spec's `validate(frame_count, framed_size)` is split into `validate()` / +`validate_for(...)` to mirror `CropSpec.validate()` / `CropSpec.validate_for()` +(`specs.py:119-153`); behaviour is the spec's. + +`core/errors.py` gains `ErrorCode.INVALID_TRANSFORM = "invalid_transform"` +(one line; `tests/core/test_errors.py:138` only asserts a retired code is +absent). + +**Validation rules (`validate`):** +- `first_frame` is a non-bool int ≥ 0; `last_frame` is `None` or a non-bool + int ≥ `first_frame`; `crop` is `None` or a `CropSpec`; `resize` is `None` + or a `ResizeSpec`. +- `ResizeSpec`: at least one of width/height given; each given value is a + non-bool int in `[MIN_FINAL_DIMENSION, MAX_FINAL_DIMENSION]`; `mismatch` is a + `MismatchMode`. + +**Validation rules (`validate_for(frame_count, framed_size)`),** each message +names the violated bound and the actual value, and **every** failure is a +`ValidationError(INVALID_TRANSFORM, "transform", …)` — never the framing +code, which would point the user at the wrong group: +- `0 <= first_frame <= last_frame_or_default < frame_count` ("last_frame 9 + exceeds the last stored frame 7"). +- `crop` fully inside `framed_size` via `CropSpec.validate_for` (re-raised as + `INVALID_TRANSFORM` with the framed size in the message). +- The bounded dimensions are the **final canvas** — `ResizePlan.canvas` when + a resize is set, else the cropped size, else the framed size. `scaled` (the + intermediate of `cover`/`pad`) is bounded only by the allocation budget. + Each axis must lie in `[128, 16383]` ("derived height 64 is below the 128 px + minimum"; "crop height 100 is below the 128 px minimum"). This is the same + check `FramingSpec().validate_final_dimensions` performs, re-raised under the + transform code; the final `(width, height)` is returned. +- Split into `_validate_range`, `_validate_crop`, `_validate_size` so no + function nears 60 lines. + +The `TransformGroup` readout calls `validate_for` with the cut facts and shows +a failing message inline (cf. `inspector.py:356-362`) before Render is ever +clicked; the job repeats the check in `stage_encoder_frames` and raises the +same message. Validation stays in one function. + +### 4.2 `src/matteloop/core/transform.py` (new, Qt-free, ≤ 300 lines) + +``` +@dataclass(frozen=True) +class ResizePlan: + scaled: tuple[int, int] # size after the proportional/non-proportional resample + canvas: tuple[int, int] # final output size + offset: tuple[int, int] # paste offset (pad) — (0, 0) otherwise + crop_box: tuple[int, int, int, int] | None # cover only: box cut from `scaled` + resample: bool # False when scaled == source (no resample at all) + +def resolve_resize(source_size: tuple[int, int], resize: ResizeSpec) -> ResizePlan +def transformed_size(framed_size: tuple[int, int], spec: TransformSpec) -> tuple[int, int] +def apply_transform(image: Image.Image, spec: TransformSpec) -> Image.Image + # identity → returns `image` itself; otherwise crop (if any) then resize (if any). + # Never mutates its input. Always returns mode "RGBA". +``` + +**Exact rounding rules for `resolve_resize`** — `w×h` is the (cropped) source, +`W`/`H` the requested values, `rhu(x)` is `geometry._round_positive_fraction` +(floor of x + ½ on an exact `Fraction`), all ratios exact `Fraction`s: + +| Case | scaled | canvas | placement | +|---|---|---|---| +| only `W` given | `(W, rhu(h·W/w))` | = scaled | none; mismatch ignored | +| only `H` given | `(rhu(w·H/h), H)` | = scaled | none; mismatch ignored | +| both given, `w·H == h·W` | `(W, H)` | `(W, H)` | none; mismatch ignored | +| `keep` | `s = min(W/w, H/h)`; `(rhu(w·s), rhu(h·s))` | = scaled | none — the limiting axis is exactly `W` or `H` | +| `stretch` | `(W, H)` non-proportional | `(W, H)` | none | +| `cover` | `s = max(W/w, H/h)`; `(rhu(w·s), rhu(h·s))` (≥ `(W, H)` on both axes) | `(W, H)` | `crop_box = (l, t, l+W, t+H)` with `l = (scaled_w − W) // 2`, `t = (scaled_h − H) // 2`; the odd pixel goes right/bottom | +| `pad` | `s = min(W/w, H/h)`; `(rhu(w·s), rhu(h·s))` (≤ `(W, H)`) | `(W, H)` transparent `(0,0,0,0)` | `offset = ((W − scaled_w) // 2, (H − scaled_h) // 2)`; the odd pixel goes right/bottom | + +`resample` is `False` exactly when `scaled == (w, h)`; then no `RGBa` +conversion and no `resize` call happen — `pad` pastes the untouched frame and +`keep`/`cover` return it unchanged. When `resample` is `True`, the resample is +`convert("RGBa")` → `resize(scaled, LANCZOS)` → `convert("RGBA")`, i.e. the +`_resize_from_sources` idiom at `webp.py:1226-1236`. `cover` resamples once to +`scaled` and then crops losslessly; it never resamples a source sub-rectangle. + +Checks against AC 4 on a 128×128 frame with 256×128: `keep` → `s = 1` → +128×128, no resample; `stretch` → 256×128 non-uniform; `cover` → `s = 2` → +scaled 256×256, `crop_box = (0, 64, 256, 192)` = the middle half; `pad` → +`s = 1` → scaled 128×128, `offset = (64, 0)` → columns 0–63 and 192–255 fully +transparent, columns 64–191 byte-identical to the frame. AC 5 on a 256×128 +frame with width 256 → `(256, rhu(128·256/256)) = (256, 128)` for every mode. + +The intermediate `scaled` allocation is bounded with +`geometry._validate_allocation_budget(scaled, 4, "transform resample")` (the +same budget framing uses). `transformed_size` is pure arithmetic (crop size, +then `resolve_resize(...).canvas`) and performs no bounds check — the bounds +live in `validate_for` only. Implement `resolve_resize` as one small helper per +mode (`_single_axis`, `_keep`, `_cover`, `_pad`) dispatched from a ≤ 20-line +body, and `apply_transform` as `_crop` / `_resample` / `_place`. + +Pillow's `ImageOps.contain`/`fit`/`pad` were considered [Layer 1] and +rejected: they round with Python `round()` and resample a float crop box, +neither premultiplies, and AC 4's byte-identical `pad` columns and lossless +`cover` crop need exact integer placement. + +### 4.3 `src/matteloop/jobs/transform_stage.py` (new, ≤ 250 lines) + +``` +def framing_plan(source_size: tuple[int, int], union: PixelBounds | None, framing: FramingSpec) -> FramingPlan + # moves render.py:1339-1350 verbatim: raises INVALID_FRAMING when trim is on and union is None + +def stage_encoder_frames( + read_cut: Callable[[int, RgbaOwnershipTracker], Image.Image], + frame_count: int, + plan: FramingPlan, + transform: TransformSpec, + delays: tuple[int, ...], + framed_directory: Path, + tracker: RgbaOwnershipTracker, + context: JobContext, +) -> tuple[tuple[Path, ...], tuple[int, ...]] + # 1. transform.validate_for(frame_count, plan.output_size) — before any file is created + # 2. tracker.include_size(plan.output_size) and the transformed size + # 3. mkdir framed_directory (exist_ok=False; OSError → _map_output_os_error as today) + # 4. for index in transform.kept_range(frame_count): the moved loop body, with + # apply_transform(framed, transform) between apply_framing and persist, + # progress totals = kept count, output names frame-{position:06d}.png (0-based over kept frames) + # 5. return (paths, transform.select_kept(delays)) + +def _persist_framed_png(path: Path, image: Image.Image) -> None # moved verbatim from render.py:1643-1674 +``` + +`render.py` call shape (illustrative, not code): `plan = framing_plan(...)`; +`framed_paths, delays = stage_encoder_frames(partial(self._workspace.read_cut, +private), manifest.frame_count, plan, request.transform, delays, scratch / +"framed-inputs", tracker, context)`. Per-frame `tracker.register(...)` and +`close()`/`del` discipline is kept exactly as today for every intermediate +(cut, framed, transformed). + +The signature alone is ten lines and the moved loop body is ~25; put the +per-frame work (read → frame → transform → persist, with the `try/finally` +pairs) in `_stage_frame(read_cut, index, position, plan, transform, +framed_directory, tracker) -> Path` so `stage_encoder_frames` stays a +≤ 40-line driver. The module is new, so a single 61-line function fails CI. + +`jobs/encoding.py` gains `_map_output_os_error` and `_output_error` moved +verbatim from `render.py:2906-2938` (plus `import errno`); render.py imports +them back by name. `transform_stage.py` imports from `jobs.encoding`, +`core.geometry`, `core.transform`, `core.specs`, `core.rgba`, `jobs.context` +— never from `jobs.render`, so no cycle. + +### 4.4 `src/matteloop/jobs/transform_store.py` (new, Qt-free, ≤ 200 lines) + +``` +TRANSFORM_SIDECAR_SCHEMA = "matteloop-cut-transform" +TRANSFORM_SIDECAR_VERSION = 1 + +def transform_sidecar_path(workspace: CutWorkspace) -> Path # cuts_root / f".transform-{cache_key}.json" +def transform_to_payload(spec: TransformSpec) -> dict[str, object] +def transform_from_payload(payload: object) -> TransformSpec # ValueError on any deviation +def load_transform(workspace: CutWorkspace, notes: list[str] | None = None) -> TransformSpec + # missing file → identity; unreadable/mismatched schema, version or cache_key → identity + note +def store_transform(workspace: CutWorkspace, spec: TransformSpec, notes: list[str]) -> None + # identity → remove the sidecar if present; else temp file + fsync + os.replace; + # any OSError → note appended, nothing raised +def discard_transform(workspace: CutWorkspace) -> None # best effort, never raises +``` + +Payload: `{"schema", "schema_version", "cache_key", "transform": {"first_frame", +"last_frame", "crop": {x,y,width,height} | null, "resize": {width, height, +"mismatch"} | null}}`, canonical JSON (sorted keys, no NaN) like the manifest. +`store_transform` is called from exactly one place, `_encode_snapshot` (D1); +`discard_transform` from `WorkspacePickerController._delete_selected` after +`delete_workspace` succeeds (`workspace_controller.py:63`, `:73`). + +### 4.11 `src/matteloop/core/fingerprints.py` — DROPPED (orchestrator decision, 2026-09-04) + +**Do not touch `core/fingerprints.py` in this ticket.** The maintainer's +standing instruction for this branch is that the rembg pin, the model manifest +and `fingerprints.py` stay untouched, because that file is one of the gates in +the "Bumping rembg" checklist in `CLAUDE.md` and a stray edit there is +expensive to spot. + +Verified independently before dropping it: `render_fingerprint` has exactly one +producer (`render.py:1389`) and its value becomes `RenderArtifact.fingerprint` +(`render.py:1457`, dataclass at `render.py:293-296`), which is in-memory only — +never persisted, never compared against a stored value, and not consumed +anywhere in `ui/` or `core/state.py`. Omitting the transform from the hash +therefore cannot cause a stale artifact to be reused or a cache to be missed. +The change was correctness hygiene with no reachable behaviour, i.e. exactly +what guardrail G1 tells us not to write. + +Task T4 and edge case E34 are struck for the same reason. Record the decision +in the PR description. + +### 4.5 `src/matteloop/core/parameters.py` (additions) + +``` +ParameterState.transform: TransformSpec = field(default_factory=TransformSpec) # last field + +@dataclass(frozen=True, slots=True) +class TransformChanged: + transform: TransformSpec + +ParameterEvent = (... | TransformChanged) +def _reduce_transform(state: AppState, event: TransformChanged) -> AppState + # no-op when equal or not a TransformSpec; replaces parameters.transform; does NOT invalidate the preview +``` + +### 4.6 `src/matteloop/core/crop.py` (addition) + +``` +def fit_crop_aspect(crop: CropSpec, ratio: Fraction, target: str, *, source_width: int, source_height: int) -> CropSpec + # Re-fit `crop` to width:height == ratio after a handle/body move: + # corner handles keep the dragged corner's opposite corner fixed and adjust the axis that grew less; + # edge handles adjust the perpendicular axis around the crop's centre; "crop" (move) is unchanged. + # Result is clamped by clamp_crop and has width, height >= 1; rounding is rhu on exact Fractions. +def centered_crop_for_aspect(ratio: Fraction, *, source_width: int, source_height: int) -> CropSpec + # the largest centred rectangle of that ratio inside the source (used when a preset is chosen) +``` + +### 4.7 `src/matteloop/ui/parameter_presentation.py` (additions) + +`ParameterPresentation` gains `transform: TransformSpec = +field(default_factory=TransformSpec)` and `artifact: ArtifactResult | None = +None` as its last two fields; `present_parameters` fills them from +`state.parameters.transform` and `state.artifact_result`. Defaults keep the +existing keyword construction in `tests/ui/test_parameter_presentation.py` +valid, so the only test that changes is the one asserting the new values. + +### 4.8 `src/matteloop/ui/transform_group.py` (new, ≤ 600 lines) + +``` +@dataclass(frozen=True) +class CutFacts: + cache_key: str + frame_count: int + framed_size: tuple[int, int] + fps: int + delays_ms: tuple[int, ...] + +class TransformGroup(QWidget): + aspect_lock_changed = Signal(object) # Fraction | None + crop_edit_toggled = Signal(bool) + use_playhead_requested = Signal(str) # "first" | "last" + def __init__(self, emit: Callable[[object], None], parent: QWidget | None = None, *, + presets: tuple[SizePreset, ...] = PLATFORM_SIZE_PRESETS) -> None + def apply(self, presentation: ParameterPresentation, editable: bool) -> None + def set_cut(self, facts: CutFacts | None) -> None + def set_playhead_frame(self, index: int | None) -> None + def tab_widgets(self) -> tuple[QWidget, ...] +``` + +Widgets (object names in parentheses; all with accessible names): first/last +frame spinboxes (`transform_first_frame`, `transform_last_frame`) with "Use +playhead" buttons (`transform_first_playhead`, `transform_last_playhead`); +"Edit crop" checkbox (`transform_crop_edit`); aspect preset combo +(`transform_aspect`: Free, 1:1, 2:1, 3:1, 4:3, 16:9); crop x/y/width/height +spinboxes (`transform_crop_x` …); width/height spinboxes +(`transform_width`, `transform_height`) with range +`[MIN_FINAL_DIMENSION − 1, MAX_FINAL_DIMENSION]` and special value text +"Auto" at the minimum; percentage spinbox (`transform_percent`); mismatch +combo (`transform_mismatch`: Keep original aspect ratio / Stretch to fit / +Center and crop to fit / Add transparent padding); size preset combo +(`transform_size_preset`: "Custom" then `presets` grouped by platform via +separator + platform header rows that are not selectable); read-only readout +label (`transform_readout`). + +Behaviour contract: every edit emits `TransformChanged` with a *structurally +valid* spec (the group never emits a `ResizeSpec` with both axes Auto — it +shows the filename-style inline error instead, cf. `inspector.py:356-362`); +choosing a size preset fills width and height and leaves the mismatch combo +untouched (AC 10); the percentage field fills width and height from the cropped +framed size and clears itself when width or height is edited by hand; choosing +a non-Free aspect preset replaces the crop with `centered_crop_for_aspect` and +emits `aspect_lock_changed`; the readout shows "N of M frames · D.DDD s · +W×H px" from facts + transform, and appends "· rendered W'×H', S" from the +last artifact when present (AC 6/10). Playhead buttons are enabled only after +`set_playhead_frame` with a non-`None` index. + +### 4.9 `src/matteloop/ui/transform_stage.py` (new, ≤ 600 lines) + +``` +@dataclass(frozen=True) +class CutSession: + workspace: CutWorkspace + manifest: CutManifest + fps: int # manifest.cache_key_inputs["sampling"]["fps"] — the cut's grid, not the inspector's current fps + +class TransformStageController(QObject): + facts_changed = Signal(object) # CutFacts | None + def __init__(self, store: StateStore, *, frame_reader: FrameReader | None = None, parent: QObject | None = None) -> None + def attach(self, group: TransformGroup, canvas: ResultPlayerCanvas | None) -> None # canvas is None until Stage C + def open_artifact(self, artifact: object) -> None # duck-typed: needs .cut_workspace and .manifest, else ignored + def restore_for(self, workspace: CutWorkspace) -> None # load_transform → clamp crop to framed size → dispatch TransformChanged + def close_session(self) -> None + def shutdown(self) -> None + @property + def session(self) -> CutSession | None + @property + def facts(self) -> CutFacts | None +``` + +`FrameReader` is a small Protocol (`read(workspace, frame: CutFrame) -> +Image.Image`) defaulting to a direct `Image.open` of `workspace.path / +frame.filename`; tests inject fakes. The session holds no `FramingSpec`: facts +follow the **current** `state.parameters` (trim, alpha_threshold, padding, +stretch_x) and are recomputed on a `QThread` worker (`_CutFactsWorker`) when +any of the four changes — union from `manifest.union_metadata` when its +`alpha_threshold` text equals the current threshold (`detect_external_edits` +drops the metadata on edited frames, `_cut_ops.py:381`, so a stale union +cannot survive an edit), otherwise `geometry.union_alpha_bounds` over the +stored frames; framed size from `transform_stage.framing_plan(...)`; delays +from `webp_delays(frame_count, fps)`. The worker follows the +`moveToThread` / `finished → quit → deleteLater` idiom of +`ui/preview_controller/controller.py:192-202`. A generation counter discards +stale results. On facts change the controller clamps +`state.parameters.transform.crop` to the new framed size with `clamp_crop` +and dispatches the clamped spec if it differs. + +`SourceController` gains `transform_stage` (property) and +`attach_transform_stage(group, canvas)`; it connects +`render_controller.artifact_ready → transform_stage.open_artifact` and sets +`render_controller.transform_restore = transform_stage.restore_for`; +`shutdown()` calls `transform_stage.shutdown()`. `attach` connects +`canvas.command_requested → store.dispatch` (nothing else does, D5) and the +group's three signals to the controller. `app.py` calls +`controller.attach_transform_stage(window.inspector.transform_group, +window.result_canvas)` after `set_dialog_parent`. `restore_for` dispatches +synchronously (`ReducerStore.dispatch`, `store.py:21-24`), so a +`_current_request()` built after it already carries the restored transform — +that ordering is E17. + +### 4.10 `src/matteloop/ui/result_player.py` (new, Stage C, ≤ 500 lines) + +``` +PLAYER_CACHE_BUDGET_BYTES = 128 * 1024 * 1024 +PLAYER_MIN_DISPLAY_SIDE = 64 + +@dataclass(frozen=True) +class PlayerFrames: + key: object # (cache_key, framing, crop, resize, display size) identity + framed: tuple[QImage, ...] # stored frames 0..cached-1, framed only, display-scaled + transformed: tuple[QImage, ...] | None # same frames, framed + transformed; None while deferred (crop-edit on) + delays_ms: tuple[int, ...] # full grid from webp_delays; the canvas slices + cached: int # frames held (== frame_count unless the budget truncated) + frame_count: int + +class ResultPlayerCanvas(CropCanvas): + playhead_changed = Signal(int) # stored-cut frame index + def __init__(self, parent=None, *, runtime_root: Path | None = None) -> None # title "Result", object_name "result_canvas" + def set_frames(self, frames: PlayerFrames | None) -> None # None restores the stage's cover mode + def set_kept_range(self, kept: range) -> None # trim: re-slices, never reloads + def set_crop_edit(self, enabled: bool, presentation: CropPresentation | None) -> None + def set_aspect_lock(self, ratio: Fraction | None) -> None + def play(self) -> None; def pause(self) -> None + @property + def playing(self) -> bool + @property + def current_frame(self) -> int | None + play_button: QPushButton # object name "result_play", hidden without frames +``` + +The `CropPresentation` for crop-edit mode is built from the facts: `width = +coded_width = framed_w`, `height = coded_height = framed_h`, `rotation = 0`, +`pixel_aspect = 1.0`, `source_id = cache_key`, `crop = transform.crop or the +full framed rectangle`. + +`_FrameLoadWorker(QObject)` reads stored frames via the session's +`FrameReader`, applies `apply_framing` then `apply_transform`, scales both +results to the budgeted display size with +`Qt.AspectRatioMode.KeepAspectRatio`, and emits one `PlayerFrames`. When only +the transform changed and the framed cache's key still matches, the worker +receives the existing framed images and rebuilds the transformed cache alone. +Runs on a `QThread` owned by `TransformStageController`, which also owns the +250 ms debounce timer (D7). + +--- + +## 5. Edge cases (each mapped to the criterion or test that covers it) + +| # | Edge case | Behaviour | Covered by | +|---|---|---|---| +| E1 | Default `TransformSpec` on rebuild | Encoder receives the same PNG paths, bytes and delays as before; `apply_transform` returns its input object | AC 1 — `test_rebuild.py::test_identity_transform_rebuild_is_byte_identical` | +| E2 | Trim `2..5` on an 8-frame cut | 4 frames, delays = `delays[2:6]` of the full grid (not recomputed — `webp_delays` is non-uniform). At 3 fps the full grid is `333,334,333,333,334,333,333,334`, so the kept sequence is `333,333,334,333`; a recomputed `webp_delays(4, 3)` would be `333,334,333,333` — same sum, different order, so the test must compare the **sequence**, not the sum | AC 2 — `test_rebuild.py::test_trim_keeps_exactly_the_selected_frames_and_their_delays` (fps 3; assert `artifact.delays_ms == full[2:6]` and the encoder's recorded delays) | +| E3 | `last_frame >= frame_count`, or `first_frame > last_frame` | `ValidationError(INVALID_TRANSFORM)` naming the bound; raised in `stage_encoder_frames` before the framed directory exists; no output written; old output untouched | AC 3 — `test_rebuild.py::test_trim_outside_the_cut_is_rejected_before_any_file_is_written` | +| E4 | Trim to a single frame | Still WebP path (`animated = count > 1` in webp.py) — allowed | `test_rebuild.py` trim test parametrised with `first == last` | +| E5 | Four mismatch modes on 1:1 → 256×128 | dims and pixels per §4.2 table | AC 4 — `tests/core/test_transform.py` (4 tests + pad columns byte-equal to the source) | +| E6 | Width only on 2:1 | 256×128 for every mode | AC 5 — `test_transform.py` parametrised over modes | +| E7 | Both axes `None`, or derived dimension < 128, or given dimension > 16383 | `ResizeSpec`/`validate_for` raise `INVALID_TRANSFORM` at validation, never at encode | AC 5 — `tests/core/test_specs.py` | +| E8 | `resize` target equals the cropped size | `resample=False`; no `RGBa` round trip | `test_transform.py::test_resize_to_the_same_size_does_not_resample` (pixel equality on a semi-transparent frame) | +| E9 | `max_bytes` set and the resized output exceeds it | encode succeeds; `EncodeSummary` reports the auto-fit size; artifact width/height smaller than requested; the completion dialog and the readout show them | AC 6 — `test_rebuild.py::test_auto_fit_shrinks_a_resized_output_and_reports_actual_size` | +| E10 | Any number of transforms and rebuilds | Stored PNG sha256s in the manifest unchanged; `validate_cut_set` still passes; no new entries in the cut directory | AC 7 — `test_rebuild.py::test_transforms_never_touch_stored_cut_frames` | +| E11 | Non-identity rebuild then reopen | Sidecar written beside the cut directory; `load_transform` returns the same spec; `list_workspaces` still lists the cut; old-code readers ignore the file | AC 8 — `tests/jobs/test_transform_store.py`, `test_rebuild.py::test_rebuild_records_the_applied_transform_beside_the_cut` | +| E12 | Identity rebuild after a non-identity one | Sidecar removed; reopen → identity | `test_transform_store.py::test_identity_store_removes_the_sidecar` | +| E13 | Sidecar corrupt / wrong cache key / wrong version / unwritable directory | identity + note; job never fails because of the sidecar | `test_transform_store.py` (3 cases) + degrade test with a read-only `cuts_root` skipped on Windows | +| E14 | Framing changed (padding/trim/stretch) after a crop was set | framed size changes → controller clamps the crop and dispatches; a stale crop that still escapes is rejected by `validate_for` with the framed size in the message | `test_specs.py` (validate_for), `tests/ui/test_transform_stage.py::test_facts_change_clamps_the_crop` | +| E15 | Framing trim on with the transform | union computed over **all** stored frames, not the kept ones — the framed size and crop space are independent of the trim | `test_rebuild.py` trim test asserts the framed size equals the untrimmed rebuild's | +| E16 | `cover` with odd overflow / `pad` with odd remainder | extra pixel right/bottom | `test_transform.py::test_cover_and_pad_place_the_odd_pixel_right_and_bottom` (129×128 → 128×128 cover; 128×128 → 257×128 pad) | +| E17 | Reopen via "Use this set" | transform restored **before** the rebuild request is built; the rebuild uses it | `tests/ui/test_render_controller.py::test_use_this_set_restores_the_stored_transform_before_rebuilding` | +| E18 | Render (not rebuild) with a transform set | applied by the shared stage; sidecar written for the new cut; inspector keeps the transform | `test_render.py::test_render_applies_the_request_transform` | +| E19 | Transform edits while a job runs | ignored by the reducer (`can_edit` false) | `tests/core/test_parameters.py` | +| E20 | Transform edits and the preview | preview stays CURRENT (no invalidation); Render skips the preflight | `test_parameters.py::test_transform_change_does_not_invalidate_the_preview` | +| E21 | Fake preset row | appears in the combo under its platform with no other change | AC 10 — `tests/ui/test_transform_group.py` (inject `presets=`) | +| E22 | Preset chosen | width/height filled, mismatch untouched, readout updated | AC 10 | +| E23 | Player reads a frame that fails (`OSError`, `UnidentifiedImageError`) | worker reports; canvas shows "Cut frames could not be read"; no traceback on the GUI thread | `tests/ui/test_result_player.py` | +| E24 | Transform changed while a load is in flight | generation counter drops the stale `PlayerFrames` | `test_transform_stage.py` | +| E25 | Player loop | cycles exactly `kept_range` at the kept delays; Play/Pause toggles; zero segmentation/encoder calls | AC 9 — `test_result_player.py` with `FakeSegmenter`/`FakeEncoder` from `tests/jobs/render_support.py` | +| E26 | New preview image arrives while playing | canvas pauses and shows the preview; Play resumes the loop | `test_result_player.py` | +| E27 | Externally edited cut frames | the player reads current bytes (what the next rebuild uses); nothing else changes | covered by E23 style test with a rewritten PNG | +| E28 | Delete cut set from the picker | sidecar removed with it | `tests/ui/test_workspace_dialog.py` (or the controller test) | +| E29 | Tab order with the new disclosure | `tab_widgets()` includes the group; the existing tab-order test's expected list gains the transform disclosure between "Crop & Cleanup" and "Output" | `test_main_window_state_and_source_drop.py::test_actual_tab_order...` (expected list updated) | +| E30 | Arrow-key nudge on the result canvas in crop-edit mode | emits `TransformChanged` with the nudged (and aspect-constrained) crop; **never** `CropChanged` (`crop_canvas.py:210` routes through the factory) | **regression guard** — `test_result_player.py::test_keyboard_nudge_never_emits_a_source_crop_change` | +| E31 | `pad` result shown in the player | full 256×128 visible including the transparent columns (fit mode, not cover) | `test_result_player.py::test_session_displays_in_fit_mode_and_restores_cover` | +| E32 | Long cut (frames × display size over `PLAYER_CACHE_BUDGET_BYTES`) | display size shrinks to fit; below the 64 px floor only the first N frames are cached and the status marker names N of M; no exception, no refusal | `test_result_player.py::test_cache_budget_shrinks_then_truncates` (budget injected small) | +| E33 | Ten `TransformChanged` crop edits within 250 ms | one transformed rebuild after the timer; zero rebuilds while crop-edit is on; the framed cache is reused (same key) | `test_transform_stage.py::test_crop_edits_are_debounced_and_reuse_the_framed_cache` | +| E35 | Crop-only transform whose crop is 100×100 | `validate_for` raises `INVALID_TRANSFORM` ("crop height 100 is below the 128 px minimum"), not `INVALID_FINAL_DIMENSIONS` | `test_specs.py` | + +--- + +## 6. Task order + +Three stages; each ends mergeable and green. Test first, always: the test is +written, run and seen to fail for the stated reason before the implementation. +Every new function ≤ 60 lines — the split points named in §4 (`_stage_frame`, +`_validate_range/_crop/_size`, one helper per resize mode, `_build_*` rows in +the group) are part of the contract, not a suggestion; re-check +`check_guardrails.py` after each task. + +### Stage A — Core (AC 1–8) + +**A1. Specs and error code.** +- Test first: `tests/core/test_specs.py` — `TransformSpec()` is identity; + `ResizeSpec()` with both axes `None` raises `INVALID_TRANSFORM`; width 127 + and 16384 rejected; `validate_for` rejects `last_frame >= frame_count` + (message contains the bound) and a crop outside the framed size; `validate_for` + returns the final size; `RenderRequest` default carries identity and + `replace(request, transform=...)` validates. Fails: `ImportError`. +- Files: `core/errors.py` (+1), `core/specs.py` (+~150), `core/parameters.py` + (field only, +2). **`core/fingerprints.py` is not touched** (§4.11). +- Budget: specs.py ≈ 650/800; no frozen file. + +**A2. Pixel operations.** +- Test first: `tests/core/test_transform.py` — the four AC 4 cases with pixel + assertions on a synthetic 128×128 frame (left half red, right half blue, + opaque; plus one semi-transparent pixel to catch premultiply drift), AC 5, + E8, E16, identity returns the same object, crop-only output size and pixels, + output mode is RGBA. Fails: `ImportError`. +- Files: `core/transform.py` (new). +- Budget: new module ≤ 300; functions ≤ 60 (split `apply_transform` into + `_crop`, `_resample`, `_place`). + +**A3. Move the two output-error helpers to `jobs/encoding.py`** (mechanical). +- Test first: none needed beyond the existing suite; run `tests/jobs` before + and after — identical results. `ruff` proves nothing else moved. +- Files: `jobs/encoding.py` (+35 plus `import errno`), `jobs/render.py` (−35, + import extended; `import errno` stays — `:2008`). +- Budget: render.py ≤ 2938 — check. + +**A4. Extract the framing loop into `jobs/transform_stage.py`** (mechanical, identity only). +- Test first: `tests/jobs/test_transform_stage.py` — `stage_encoder_frames` + with an identity spec on a 3-frame fake reader writes `frame-00000{0,1,2}.png` + with the framed pixels and returns the delays unchanged; `framing_plan` raises + `INVALID_FRAMING` for trim without union. Fails: `ImportError`. + Then update the two seams in `tests/jobs/test_render.py:1550-1556, 2968` to + `transform_stage` and confirm they still pass. +- Files: `jobs/transform_stage.py` (new; `stage_encoder_frames` + + `_stage_frame`), `jobs/render.py` (−47 +8 for the loop, −34 for + `_persist_framed_png`, import lines, `import tempfile` removed — + `apply_framing` stays, it serves the preview at `:906`/`:920`). +- Budget: render.py must be strictly below 2938 after this task; record the + count in the commit body. `_encode_snapshot` becomes shorter (still counted + as long; the count of long functions must not rise). + +**A5. Apply the transform in the stage.** +- Test first: `tests/jobs/test_rebuild.py` — AC 1 byte identity (rebuild + twice, once with `transform=TransformSpec()` explicitly, once via + `request(...)` default, compare `output.webp` bytes and the encoder's + recorded paths/delays); AC 2 trim with fps 3 (`validate_webp` frame count + and `delays_ms` sum); AC 3 (`pytest.raises(ValidationError)`, output + unchanged, no `framed-inputs` directory left under scratch); crop+resize + dimensions via `validate_webp`; E15 framed size independence; AC 7 sha256 + check via `FilesystemWorkspacePort().validate(...)`. Fails: transform + ignored / attribute errors. +- Files: `jobs/transform_stage.py`, `jobs/render.py` (frame-count substitutions + at 1395/1460 — same line count). +- Budget: no growth in render.py. + +**A6. Auto-fit interplay (AC 6).** +- Test first: `test_rebuild.py` — resize to 512×256 with `max_bytes` small + enough to force a fit on a noisy synthetic cut; assert the artifact's + width/height are below 512×256 and the job succeeded. Use the real + `PillowWebPEncoder` through `FakeEncoder`-style wiring only if the fake cannot + fit; otherwise a `FittingFakeEncoder` that calls `auto_fit_webp`. Fails: + attribute mismatch or no shrink. +- Files: tests only, unless the stage needs `tracker.include_size` for the fit + sizes (then `transform_stage.py`). + +**A7. Sidecar store.** +- Test first: `tests/jobs/test_transform_store.py` — round trip; missing → + identity; corrupt JSON / wrong schema / wrong cache key → identity with a + note; identity store removes; `list_workspaces` still lists the cut and + `validate_cut_set` passes with the sidecar present; `discard_transform` on a + missing file is silent. Fails: `ImportError`. +- Files: `jobs/transform_store.py` (new). + +**A8. Record the applied transform in the job layer.** +- Test first: `test_rebuild.py` — after a rebuild with a trim, + `load_transform(artifact.cut_workspace)` equals the request's transform; + after a subsequent identity rebuild the sidecar is gone; a render with a + transform records it too (E18); an unwritable `cuts_root` (POSIX only) adds a + note and the artifact still publishes. Fails: identity returned. +- Files: `jobs/render.py` (+1 line inside `_encode_snapshot`, between the + `if published:` cleanup and `assert summary is not None` — see D1 for why + not after the call; both `render()` and `rebuild()` pass through here). +- Budget: render.py still below 2938 (A4 bought ≈ 100 lines). + +**A9. Docs.** `docs/v1-scope.md`: add a "Transform" row to the "In scope" +table under "Cuts" with the 2026-09-04 scope-reopen note and "#25". No other +doc changes. + +**Stage A verification.** Full gate; `check_guardrails.py` passes with the +baseline file unchanged (`git diff --exit-code scripts/guardrails-baseline.json`); +`mypy src` reports exactly the 9 pre-existing errors; render.py line count +recorded in the PR description. The branch is mergeable here: the feature is +reachable (any request carries the transform) even though the UI does not yet +expose it, and every existing path is byte-identical. + +### Stage B — Inspector (AC 10) + +**B1. Reducer event.** +- Test first: `tests/core/test_parameters.py` — `TransformChanged` replaces + the transform, leaves `preview` untouched (E20), is ignored while a job runs + (E19) and when unchanged; `parameters_from_values` yields identity; + `persist_parameters` writes no transform key. Fails: `ImportError`. +- Files: `core/parameters.py` (+~16). + +**B2. Request mapping.** +- Test first: `tests/ui/test_parameter_requests.py` — extend the existing + "share every inspector parameter" test with a non-identity transform; + `tests/ui/test_workspace_dialog.py` or a new `test_workspace_presentation.py` + — `request_for_workspace` forwards `base.transform`. Fails: attribute + absent / identity. +- Files: `ui/request_builder.py` (+1), `ui/workspace_presentation.py` (+1). + +**B3. Presentation.** +- Test first: `tests/ui/test_parameter_presentation.py` — `present_parameters` + exposes `transform` and `artifact`. Fails: `AttributeError` (the new fields + have defaults, §4.7, so existing constructions keep working). +- Files: `ui/parameter_presentation.py` (+5). + +**B4. `TransformGroup` widget.** +- Test first: `tests/ui/test_transform_group.py` — AC 10 preset fills + width/height and leaves mismatch; fake preset row appears under a fake + platform header (E21); each control emits a valid `TransformChanged`; both + axes Auto shows the inline error and emits nothing; percentage fills and + clears; aspect preset emits a centred crop and `aspect_lock_changed`; + `apply` with `editable=False` disables everything; `set_cut(None)` disables; + readout wording with and without an artifact; `tab_widgets()` order. Fails: + `ImportError`. +- Files: `ui/transform_group.py` (new), `core/crop.py` (+~50 for + `fit_crop_aspect` / `centered_crop_for_aspect`, tested in + `tests/core/test_crop.py` first). + +**B5. Mount in the inspector; free the lines.** +- Test first: `tests/ui/test_parameter_inspector.py` — the "transform" + disclosure exists with title "Transform" and is collapsed by default; the + group is inside its body; `tab_widgets()` contains the disclosure and the + group's widgets in order. Update the expected list in + `test_actual_tab_order_skips_hidden_widgets...` (E29). Fails: `KeyError`. +- Files: `ui/inspector.py` (net −1 → 797 per D6), `ui/inspector_disclosure.py` + (+~5 for `read_bool`), `ui/crop_view.py` (+1). +- Budget: inspector.py ≤ 800 — record the count in the commit body. If the + count still lands above 800 (an import wrapped by isort, say), the next + mechanical extraction is `_set_provider_options` (`:779-797`, pure combo + bookkeeping) into a module-level helper beside `build_provider_options` — + only then, never speculatively. + +**B6. Session controller and wiring.** +- Test first: `tests/ui/test_transform_stage.py` — `open_artifact` with a + duck-typed artifact lacking a workspace is ignored; with a real cut (rendered + through `tests/jobs/render_support` fakes into `tmp_path`) facts arrive with + the manifest frame count, the framed size from the same `framing_plan`, the + cut's fps and `webp_delays`; `restore_for` dispatches the stored transform + (and identity when none); a padding change re-computes facts and clamps the + crop (E14); `shutdown` joins the worker. `tests/ui/test_render_controller.py` + — `artifact_ready` fires with the worker's artifact; E17 ordering. Picker + delete removes the sidecar (E28). Fails: `ImportError`/no signal. +- Files: `ui/transform_stage.py` (new; `attach` wires + `canvas.command_requested → store.dispatch` when a canvas is given), + `ui/render_worker.py` (+~4), `ui/render_controller.py` (+5, ≤ 800 — record + the count: `artifact_ready` signal, its connect in `_spawn_worker`, the + `transform_restore` attribute, two lines in `_use_workspace` *before* + `_current_request()`), `ui/controller.py` (+~15), + `ui/workspace_controller.py` (+2: `discard_transform` after each successful + `delete_workspace`, `:63` and `:73`), `app.py` (+1; `_run_gui` ≤ 60). + +**Stage B verification.** Full gate + guardrails; `uv run matteloop` shows the +Transform disclosure, enabled after a render, and Render → "Matching cut set +found" → Rebuild produces the transformed file (manual check noted in the PR, +not a gate). + +### Stage C — Result player (AC 9) + +**C1. `CropCanvas` reuse hooks.** +- Test first: `tests/ui/test_crop_canvas.py` — a subclass overriding + `_constrain` and `_crop_event` receives **both** the drag and an arrow-key + nudge (E30; the base class still emits `CropChanged` for both); default + behaviour of the existing tests unchanged; `CropCanvas(title="Result", + object_name="x")` sets those. Fails: `TypeError` on the kwargs. +- Files: `ui/crop_canvas.py` (+~12: kwargs, the two hooks, `:210` and `:245` + routed through them). + +**C2. `ResultPlayerCanvas` and the frame loader.** +- Test first: `tests/ui/test_result_player.py` — with a `PlayerFrames` of 4 + frames and delays `(333, 334, 333, 333)` (= `webp_delays(4, 3)`) and + `set_kept_range(range(1, 4))` the canvas shows frame 1, advances on the + timer (use `qtbot.waitUntil` on `current_frame`), wraps to frame 1, + `pause()` stops advancing, `play()` resumes; a new `set_kept_range` re-slices + without a new `PlayerFrames`; the play button is hidden without frames and + accessible with them; crop-edit mode shows the framed cache with the overlay + and emits `TransformChanged` on drag **and** on an arrow key (E30) with the + aspect lock applied; E26; E31 (fit mode while loaded, cover restored on + `set_frames(None)`); E32 with an injected 1 MiB budget; loader on a real + temp cut produces both caches at display size and AC 9's zero-call + assertion on `FakeSegmenter`/`FakeEncoder`. Fails: `ImportError`. +- Files: `ui/result_player.py` (new), `ui/preview_canvas.py` (`PreviewStage` + constructs `ResultPlayerCanvas`; `set_cover_frame` semantics unchanged), + `ui/transform_stage.py` (owns the loader thread and the debounce timer, + E33; `attach` with a canvas). +- Existing UI tests that assert `result_canvas` object/accessible names, status + label, properties and tab position must pass unchanged. + +**C3. Playhead coupling.** +- Test first: `tests/ui/test_transform_stage.py` — `use_playhead_requested("last")` + with the player at stored index 5 emits `TransformChanged(last_frame=5)`; + buttons disabled when the player has no frames. Fails: no emission. +- Files: `ui/transform_stage.py`. + +**Stage C verification.** Full gate + guardrails; the tab-order test still +passes with the play button hidden in a session-less window; a manual loop +check in `uv run matteloop` noted in the PR. + +--- + +## 7. Verification (per stage, identical commands) + +``` +uv run ruff check . +uv run mypy src # exactly the 9 baseline errors, same files +QT_QPA_PLATFORM=offscreen uv run pytest -q +uv run python scripts/check_guardrails.py +git diff --exit-code scripts/guardrails-baseline.json +wc -l src/matteloop/jobs/render.py src/matteloop/ui/inspector.py src/matteloop/ui/render_controller.py +``` + +The three `wc -l` numbers go into each stage's PR comment. Commits: summary +line, blank line, one to two lines per changed point; `fix:` only with a +`Trigger:` line; third consecutive `fix:` on one file stops and reports. + +--- + +## 8. Overrides of the spec's file table (forced by the line budgets) + +| Spec said | Plan does | Why | +|---|---|---| +| `render.py:1155-1257` gains frame selection, crop, resize | `jobs/transform_stage.py` (new) gains them; render.py loses ≈ 100 lines (D3) | render.py frozen at 2938 | +| `_manifest.py:141-159` persists the transform | `jobs/transform_store.py` sidecar beside the cut directory (D1) | manifest modules frozen; exact-key + version gates would strand data on rollback; in-directory sidecar invalidates the cut | +| `inspector.py:648-669` gains the Transform group | `ui/transform_group.py` (new); inspector mounts it with 6 lines and gives back 8 (D6) | inspector.py at 798/800 | +| `preview_canvas.py:115-200` gains the player | `ui/result_player.py` (new); `PreviewStage` swaps the class (D7) | keep the player reviewable; preview_canvas stays a base | +| crop/resize in `core/webp.py` next to `_resize_from_sources` | `core/transform.py` (new) with the same premultiply idiom (D2) | webp.py frozen | +| `TransformSpec.validate(frame_count, framed_size)` | `validate()` + `validate_for(frame_count, framed_size)` | mirrors `CropSpec`; same behaviour | +| `ResizeSpec.mismatch: Literal[...]` | `MismatchMode(StrEnum)` with the same four values | house idiom; identical serialisation | +| AC 8 "from the manifest" | from the sidecar; recorded deviation | see D1 | +| "Delays are computed for the kept frames only" | the encoder's full-grid delays are **sliced** to the kept range | `webp_delays` is non-uniform (`timebase.py:29-47`); slicing is what "the summed delays of exactly those frames" (AC 2) means | +| Player "transformed lazily at canvas resolution" | full-resolution `apply_framing` + `apply_transform` per stored frame on a worker, cached at display size under a 128 MiB budget, two caches (framed / transformed), transformed rebuild debounced | the design rule at `matteloop-desktop-app.md:33` requires the encoder's functions on the encoder's inputs; the budget keeps memory bounded for any frame count | +| "The player uses the same per-frame delays the encoder writes (… they are uniform)" | same delays, but they are **not** uniform — sliced from the full grid | `timebase.py:41-46` | +| `TransformSpec.validate(...)` "called from `rebuild()`" | called from `stage_encoder_frames`, which both `render()` and `rebuild()` reach through `_encode_snapshot`; the inspector readout calls the same function early for the inline message | one validation function, two callers, one message | + +Everything else in the spec — presets as data, no rotate/flip/thinning, crop +in framed-frame space, centre-only cover, transparent-only padding, byte +identity for the WebP only — is implemented as written. + +--- + +## 9. Review outcome (eng review, 2026-09-04) + +### NOT in scope + +- Rotate, flip, frame thinning, fps change, non-transparent padding, cover + anchors, 7TV upload or limits — the spec's own exclusions (G8). +- A transform for the *source* crop or range — the frames do not exist. +- Growing any frozen module or touching `scripts/guardrails-baseline.json` — + the constraints file forbids it; every budget overrun in §3 has a named + fallback that extracts instead. +- Migrating or reading a transform from the manifest — D1 rejects it with + evidence; the sidecar is the persistence. +- Distribution: no new artifact type; the existing macOS/Windows packaging is + untouched. + +### What already exists (reused, not rebuilt) + +| Sub-problem | Existing | Plan | +|---|---|---| +| Premultiplied LANCZOS resize | `webp.py:1226-1236` | idiom copied exactly into `core/transform.py` (webp.py frozen) | +| Rounding | `geometry._round_positive_fraction` | reused for every derived dimension | +| Crop handles, drag, nudge, clamp | `crop_canvas.py`, `core/crop.py` | subclassed / extended; not forked | +| Frame reading, framing plan | `apply_framing`, `FramingPlan` | called from the shared stage by encoder and player | +| Delays | `webp_delays` | sliced, never recomputed | +| Atomic file write | `_persist_framed_png` pattern | reused for the sidecar (G3 ceiling) | +| Worker thread idiom | `preview_controller/controller.py:192-202` | copied for facts and frame loaders | +| Pillow `ImageOps.contain/fit/pad` | stdlib-adjacent [Layer 1] | rejected (rounding, float box, no premultiply — §4.2) | + +### Failure modes (per new codepath) + +| Path | Realistic failure | Test | Handling | User sees | +|---|---|---|---|---| +| `stage_encoder_frames` | `validate_for` rejects a stale crop after a framing change | E14, E3 | `INVALID_TRANSFORM` before any file | inline message in the group; the job dialog names the bound | +| `apply_transform` | allocation budget exceeded by `cover`'s `scaled` | E7-adjacent (`_validate_allocation_budget`) | `ValidationError` | error dialog, no partial output | +| `store_transform` | read-only `cuts_root` | E13 | note, never raised | rebuild succeeds; note in the completion summary | +| `load_transform` | corrupt / foreign sidecar | E13 | identity + note | cut opens as identity | +| `_FrameLoadWorker` | unreadable PNG | E23 | worker reports | "Cut frames could not be read" | +| `_FrameLoadWorker` | thousands of frames | E32 | budget + truncation | "Previewing the first N of M frames" | +| `_CutFactsWorker` | stale result after a fast framing change | E24 | generation counter | none | +| `ResultPlayerCanvas.keyPressEvent` | source crop dispatched by mistake | E30 | factory hook | none — the regression guard exists for this | +| `_use_workspace` restore | `TransformChanged` ignored because a job is running | E19 | reducer no-op | picker closes; identity used — acceptable, a job is running | + +No path is silent, untested and unhandled: **0 critical gaps**. + +### Worktree parallelization + +| Step | Modules | Depends on | +|---|---|---| +| A1–A2 specs, transform | `core/` | — | +| A3–A6 extraction + stage | `jobs/`, `tests/jobs` | A1–A2 | +| A7–A8 sidecar store | `jobs/` | A4 (placement inside `_encode_snapshot`) | +| B1–B3 reducer, request mapping, presentation | `core/parameters.py`, `ui/` (small files) | A1 | +| B4 `TransformGroup` + `core/crop.py` helpers | `ui/transform_group.py`, `core/crop.py` | A1, B3 | +| B5–B6 inspector mount, controller, wiring | `ui/inspector.py`, `ui/render_controller.py`, `ui/controller.py` | B4, A7 | +| C1–C3 player | `ui/crop_canvas.py`, `ui/result_player.py`, `ui/transform_stage.py` | B6 | + +Lane A: A1 → A2 → A3 → A4 → A5 → A6 → A7 → A8 (sequential, shared `jobs/render.py`). +Lane B: B1 → B2 → B3 → B4 (after A1 only; independent of A3–A8). +Then B5 → B6 (needs both lanes), then C1 → C2 → C3. +Launch A3+ and B1–B4 in parallel worktrees after A1–A2 merge; conflict flag: +both lanes touch `core/parameters.py` (A1 adds the field, B1 the event) — +keep A1's two-line field addition and B1's event in separate commits. + +## Implementation Tasks +Synthesized from this review's findings. Each task derives from a specific +finding above. Run with Claude Code or Codex; checkbox as you ship. + +- [ ] **T1 (P1, human: ~1h / CC: ~5min)** — `jobs/render.py` — Call `store_transform` inside `_encode_snapshot`, not after it + - Surfaced by: Architecture — `tuple(notes)` baked at `render.py:1471`; notes appended later are lost + - Files: `src/matteloop/jobs/render.py`, `tests/jobs/test_rebuild.py` + - Verify: A8's "unwritable cuts_root adds a note" test sees the note on the artifact +- [ ] **T2 (P1, human: ~1h / CC: ~10min)** — `ui/crop_canvas.py` — Route `keyPressEvent` through `_crop_event`/`_constrain` + - Surfaced by: Architecture — `crop_canvas.py:210` emits `CropChanged` directly + - Files: `src/matteloop/ui/crop_canvas.py`, `tests/ui/test_crop_canvas.py`, `tests/ui/test_result_player.py` + - Verify: E30 regression guard +- [ ] **T3 (P1, human: ~2h / CC: ~15min)** — `ui/result_player.py` — Bound the caches (128 MiB), debounce transformed rebuilds, fit mode while loaded + - Surfaced by: Performance — unbounded per-frame caches; reload per mouse move; cover mode crops `pad` output + - Files: `src/matteloop/ui/result_player.py`, `src/matteloop/ui/transform_stage.py` + - Verify: E31, E32, E33 +- [x] ~~**T4** — `core/fingerprints.py` — add the transform to `render_fingerprint`~~ **DROPPED** + - `fingerprints.py` is off-limits on this branch (rembg gate). The fingerprint + is in-memory only and has no consumer, so the omission changes no behaviour. + See §4.11. E34 is struck with it. +- [ ] **T5 (P2, human: ~30min / CC: ~5min)** — `core/specs.py` — Every `validate_for` failure is `INVALID_TRANSFORM`; bounds apply to the final canvas + - Surfaced by: Code quality — crop-only final < 128 would surface as a framing error + - Files: `src/matteloop/core/specs.py`, `tests/core/test_specs.py` + - Verify: E35 +- [ ] **T6 (P2, human: ~30min / CC: ~5min)** — `ui/transform_stage.py` — `attach` connects `canvas.command_requested`; drop `CutSession.framing` + - Surfaced by: Architecture — `main_window.py:193-194` wires three widgets only; E14 needs current framing + - Files: `src/matteloop/ui/transform_stage.py`, `tests/ui/test_transform_stage.py` + - Verify: B6/C2 tests; a drag on the result canvas reaches the store +- [ ] **T7 (P2, human: ~15min / CC: ~2min)** — tests — Compare the delay *sequence* in the AC 2 test + - Surfaced by: Test review — sum equality cannot distinguish slicing from recomputation + - Files: `tests/jobs/test_rebuild.py` + - Verify: E2 +- [ ] **T8 (P2, human: ~15min / CC: ~2min)** — `ui/inspector.py` — Recount after B5 with the import line included (target 797) + - Surfaced by: Code quality — the D6 table omitted the `TransformGroup` import + - Files: `src/matteloop/ui/inspector.py` + - Verify: `wc -l` in the commit body; `check_guardrails.py` + +_No new tasks from Step 0 (scope accepted as-is)._ + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | outside voice abandoned by the coordinator; a Sol review runs on the finished diff | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN) | 17 issues, 0 critical gaps, all folded into §2–§9 | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +- **AUTO-DECIDED (spawned session):** scope accepted as-is despite the >8-file smell (the spec's AC 1–10 and the frozen-module budgets force the module count); every finding applied with its recommended option; no baseline, source or test file touched. +- **VERDICT:** ENG CLEARED — ready to implement. + +NO UNRESOLVED DECISIONS From 5360956605f6658684ab7fd6228353333ce74623 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 00:56:29 +0200 Subject: [PATCH 02/45] feat: add the transform spec contract (issue #25 task A1) Lays the pure-data foundation for the transform stage per docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md. - core/errors.py: new ErrorCode.INVALID_TRANSFORM. - core/specs.py: MismatchMode, ResizeSpec, TransformSpec (identity default, kept_range/select_kept, validate/validate_for split into _validate_range/_validate_crop/_validate_size), SizePreset and PLATFORM_SIZE_PRESETS (7TV 128x128/256x128/384x128). - core/specs.py: RenderRequest gains a transform field (last field, identity default) and validates it. - core/parameters.py: ParameterState gains a transform field with an identity default; no reducer event yet (Stage B). - tests/core/test_specs.py: identity default, ResizeSpec axis/bounds rejection, validate_for range/crop/size failures and success. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/core/errors.py | 1 + src/matteloop/core/parameters.py | 4 +- src/matteloop/core/specs.py | 199 +++++++++++++++++++++++++++++++ tests/core/test_specs.py | 89 +++++++++++++- 4 files changed, 291 insertions(+), 2 deletions(-) diff --git a/src/matteloop/core/errors.py b/src/matteloop/core/errors.py index f14340f..280b758 100644 --- a/src/matteloop/core/errors.py +++ b/src/matteloop/core/errors.py @@ -13,6 +13,7 @@ class ErrorCode(StrEnum): INVALID_CROP = "invalid_crop" INVALID_SEGMENTATION = "invalid_segmentation" INVALID_FRAMING = "invalid_framing" + INVALID_TRANSFORM = "invalid_transform" INVALID_OUTPUT = "invalid_output" INVALID_RENDER_REQUEST = "invalid_render_request" INVALID_FINAL_DIMENSIONS = "invalid_final_dimensions" diff --git a/src/matteloop/core/parameters.py b/src/matteloop/core/parameters.py index c72fcd8..efc23a5 100644 --- a/src/matteloop/core/parameters.py +++ b/src/matteloop/core/parameters.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from decimal import Decimal, InvalidOperation from fractions import Fraction from pathlib import Path @@ -20,6 +20,7 @@ OutputSpec, SamplingSpec, SegmentationSpec, + TransformSpec, is_local_path_syntax, ) @@ -58,6 +59,7 @@ class ParameterState: output_filename: str | None = None max_mib: Decimal = Decimal("0") execution_provider: str = CPU_EXECUTION_PROVIDER + transform: TransformSpec = field(default_factory=TransformSpec) def __post_init__(self) -> None: if self.model_id not in V1_MODEL_IDS: diff --git a/src/matteloop/core/specs.py b/src/matteloop/core/specs.py index 0f37cc5..13746a4 100644 --- a/src/matteloop/core/specs.py +++ b/src/matteloop/core/specs.py @@ -321,6 +321,203 @@ def dimensions_after_padding_and_stretch( return stretched_width, padded_height +class MismatchMode(StrEnum): + """How a resize reconciles a requested size with the source aspect ratio.""" + + KEEP = "keep" + STRETCH = "stretch" + COVER = "cover" + PAD = "pad" + + +@dataclass(frozen=True) +class ResizeSpec: + """A target pixel size and how to reconcile an aspect-ratio mismatch.""" + + width: int | None = None + height: int | None = None + mismatch: MismatchMode = MismatchMode.KEEP + + def __post_init__(self) -> None: + self.validate() + + def validate(self) -> None: + if self.width is None and self.height is None: + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + "resize requires at least one of width or height", + ) + for name, value in (("width", self.width), ("height", self.height)): + if value is None: + continue + if ( + not isinstance(value, int) + or isinstance(value, bool) + or not MIN_FINAL_DIMENSION <= value <= MAX_FINAL_DIMENSION + ): + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + f"resize {name} must be an integer between " + f"{MIN_FINAL_DIMENSION} and {MAX_FINAL_DIMENSION}", + ) + if not isinstance(self.mismatch, MismatchMode): + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + "mismatch must be a MismatchMode", + ) + + +@dataclass(frozen=True) +class TransformSpec: + """A trim, crop, and resize applied to a finished cut, never to stored frames.""" + + first_frame: int = 0 + last_frame: int | None = None + crop: CropSpec | None = None + resize: ResizeSpec | None = None + + def __post_init__(self) -> None: + self.validate() + + def validate(self) -> None: + if ( + not isinstance(self.first_frame, int) + or isinstance(self.first_frame, bool) + or self.first_frame < 0 + ): + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + "first_frame must be a non-negative integer", + ) + if self.last_frame is not None and ( + not isinstance(self.last_frame, int) + or isinstance(self.last_frame, bool) + or self.last_frame < self.first_frame + ): + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + "last_frame must be null or an integer >= first_frame", + ) + if self.crop is not None and not isinstance(self.crop, CropSpec): + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + "crop must be null or a CropSpec", + ) + if self.resize is not None and not isinstance(self.resize, ResizeSpec): + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + "resize must be null or a ResizeSpec", + ) + + @property + def is_identity(self) -> bool: + return ( + self.first_frame == 0 + and self.last_frame is None + and self.crop is None + and self.resize is None + ) + + def kept_range(self, frame_count: int) -> range: + """Return the 0-based indices this transform keeps from *frame_count*.""" + last = frame_count - 1 if self.last_frame is None else self.last_frame + return range(self.first_frame, last + 1) + + def select_kept[T](self, items: tuple[T, ...]) -> tuple[T, ...]: + """Slice *items* (e.g. delays) to the frames this transform keeps.""" + kept = self.kept_range(len(items)) + return tuple(items[index] for index in kept) + + def validate_for( + self, frame_count: int, framed_size: tuple[int, int] + ) -> tuple[int, int]: + """Validate against the cut and return the final (width, height).""" + self._validate_range(frame_count) + cropped_size = self._validate_crop(framed_size) + return self._validate_size(cropped_size) + + def _validate_range(self, frame_count: int) -> None: + last = frame_count - 1 if self.last_frame is None else self.last_frame + if last >= frame_count: + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + f"last_frame {last} exceeds the last stored frame " + f"{frame_count - 1}", + ) + if self.first_frame > last: + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + f"first_frame {self.first_frame} exceeds last_frame {last}", + ) + + def _validate_crop(self, framed_size: tuple[int, int]) -> tuple[int, int]: + if self.crop is None: + return framed_size + try: + self.crop.validate_for(*framed_size) + except ValidationError as error: + width, height = framed_size + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + f"crop must be fully contained by the framed size " + f"{width}x{height}", + ) from error + return (self.crop.width, self.crop.height) + + def _validate_size(self, cropped_size: tuple[int, int]) -> tuple[int, int]: + if self.resize is not None: + from matteloop.core.transform import resolve_resize + + width, height = resolve_resize(cropped_size, self.resize).canvas + noun = "derived" + else: + width, height = cropped_size + noun = "crop" if self.crop is not None else "framed" + for axis, value in (("width", width), ("height", height)): + if value < MIN_FINAL_DIMENSION: + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + f"{noun} {axis} {value} is below the " + f"{MIN_FINAL_DIMENSION} px minimum", + ) + if value > MAX_FINAL_DIMENSION: + raise ValidationError( + ErrorCode.INVALID_TRANSFORM, + "transform", + f"{noun} {axis} {value} exceeds the " + f"{MAX_FINAL_DIMENSION} px maximum", + ) + return width, height + + +@dataclass(frozen=True) +class SizePreset: + """One platform-named fixed output size offered in the resize preset combo.""" + + platform: str + label: str + width: int + height: int + + +PLATFORM_SIZE_PRESETS: tuple[SizePreset, ...] = ( + SizePreset("7TV", "128x128", 128, 128), + SizePreset("7TV", "256x128", 256, 128), + SizePreset("7TV", "384x128", 384, 128), +) + + @dataclass(frozen=True) class OutputSpec: """Output naming, collision choice, and optional final-size limit.""" @@ -406,6 +603,7 @@ class RenderRequest: output: OutputSpec rebuild: bool = False regenerate: bool = False + transform: TransformSpec = field(default_factory=TransformSpec) def __post_init__(self) -> None: self.validate() @@ -426,6 +624,7 @@ def validate(self) -> None: self.segmentation.validate() self.framing.validate() self.output.validate() + self.transform.validate() if not isinstance(self.rebuild, bool) or not isinstance(self.regenerate, bool): raise ValidationError( ErrorCode.INVALID_RENDER_REQUEST, diff --git a/tests/core/test_specs.py b/tests/core/test_specs.py index d33dab2..7c2e292 100644 --- a/tests/core/test_specs.py +++ b/tests/core/test_specs.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from dataclasses import FrozenInstanceError +from dataclasses import FrozenInstanceError, replace from decimal import Decimal from fractions import Fraction from pathlib import Path @@ -12,15 +12,20 @@ from matteloop.core.errors import ErrorCode, ValidationError from matteloop.core.specs import ( MAX_ALPHA_MATTING_ERODE_SIZE, + MAX_FINAL_DIMENSION, + MIN_FINAL_DIMENSION, AlphaMattingSpec, CollisionPolicy, CropSpec, EdgeMode, FramingSpec, + MismatchMode, OutputSpec, RenderRequest, + ResizeSpec, SamplingSpec, SegmentationSpec, + TransformSpec, ) @@ -536,3 +541,85 @@ def test_source_validation_combines_duration_crop_and_final_dimensions( too_long.validate_for_source(128, 128, Fraction(1)) assert exc.value.code is ErrorCode.INVALID_SAMPLING + + +def test_transform_spec_default_is_identity() -> None: + transform = TransformSpec() + + assert transform.is_identity is True + assert transform.first_frame == 0 + assert transform.last_frame is None + assert transform.crop is None + assert transform.resize is None + + +def test_resize_spec_rejects_both_axes_none() -> None: + with pytest.raises(ValidationError) as exc: + ResizeSpec() + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + + +@pytest.mark.parametrize( + "width", [MIN_FINAL_DIMENSION - 1, MAX_FINAL_DIMENSION + 1] +) +def test_resize_spec_rejects_width_outside_final_dimension_bounds( + width: int, +) -> None: + with pytest.raises(ValidationError) as exc: + ResizeSpec(width=width, height=None) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + + +def test_resize_spec_accepts_a_single_axis_with_the_keep_default() -> None: + resize = ResizeSpec(width=256) + + assert resize.height is None + assert resize.mismatch is MismatchMode.KEEP + + +def test_transform_spec_validate_for_rejects_last_frame_past_the_stored_count() -> ( + None +): + transform = TransformSpec(first_frame=0, last_frame=9) + + with pytest.raises(ValidationError) as exc: + transform.validate_for(8, (128, 128)) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + assert "9" in exc.value.technical_detail + assert "7" in exc.value.technical_detail + + +def test_transform_spec_validate_for_rejects_a_crop_outside_the_framed_size() -> None: + transform = TransformSpec(crop=CropSpec(x=0, y=0, width=200, height=200)) + + with pytest.raises(ValidationError) as exc: + transform.validate_for(4, (128, 128)) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + + +def test_transform_spec_validate_for_returns_the_final_canvas_size() -> None: + transform = TransformSpec(crop=CropSpec(x=0, y=0, width=150, height=128)) + + assert transform.validate_for(4, (256, 128)) == (150, 128) + + +def test_render_request_default_transform_is_identity(tmp_path: Path) -> None: + request = valid_render_request(tmp_path) + + assert request.transform == TransformSpec() + + +def test_render_request_replace_validates_a_new_transform(tmp_path: Path) -> None: + request = valid_render_request(tmp_path) + + updated = replace(request, transform=TransformSpec(first_frame=2, last_frame=5)) + assert updated.transform.first_frame == 2 + + with pytest.raises(ValidationError) as exc: + replace(request, transform=TransformSpec(first_frame=5, last_frame=2)) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM From e2938af5b80682ba3148294f674e78a7b2032aeb Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:03:30 +0200 Subject: [PATCH 03/45] feat: add the transform pixel operations module (issue #25 task A2) New core/transform.py per docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md D2/4.2. - resolve_resize: exact-Fraction integer arithmetic for keep/stretch/ cover/pad and the single-axis cases, dispatched from a small body into _single_axis/_keep/_cover/_pad, each returning a ResizePlan. - apply_transform: identity returns the same object untouched; crop then resample then place, split into _crop/_resample/_place so no function nears the 60-line budget. - Resample mirrors core/webp.py:1226-1236 exactly: RGBA -> premultiplied RGBa -> Lanczos resize -> RGBA, skipped entirely when scaled equals the source (resample=False). - The intermediate scaled allocation is bounded via geometry._validate_allocation_budget, same ceiling framing uses. - tests/core/test_transform.py: the four AC 4 mismatch modes on a 128x128 red/blue frame with pixel assertions against a reference built from the same premultiply idiom (catches drift on the semi-transparent probe pixel), AC 5 single-axis, E8 (no resample at matching size), E16 (odd-pixel placement for cover/pad), identity object identity, and crop-only output size/mode. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/core/transform.py | 169 +++++++++++++++++++++++++++++ tests/core/test_transform.py | 187 ++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 src/matteloop/core/transform.py create mode 100644 tests/core/test_transform.py diff --git a/src/matteloop/core/transform.py b/src/matteloop/core/transform.py new file mode 100644 index 0000000..a889d59 --- /dev/null +++ b/src/matteloop/core/transform.py @@ -0,0 +1,169 @@ +"""Crop and resize a finished cut's frames, Qt-free, never touching stored PNGs. + +Both the render/rebuild path and the result player call these two functions +after ``geometry.apply_framing`` so encoder and preview stay pixel-identical +(design decision D2). The resize idiom mirrors ``core/webp.py:1226-1236`` +exactly: convert to premultiplied ``RGBa``, resample with Lanczos, convert +back to straight ``RGBA``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction + +from PIL import Image + +from matteloop.core import geometry +from matteloop.core.specs import CropSpec, MismatchMode, ResizeSpec, TransformSpec + + +@dataclass(frozen=True) +class ResizePlan: + """The concrete pixel operations one ``resolve_resize`` call resolves to.""" + + scaled: tuple[int, int] + canvas: tuple[int, int] + offset: tuple[int, int] + crop_box: tuple[int, int, int, int] | None + resample: bool + + +def resolve_resize(source_size: tuple[int, int], resize: ResizeSpec) -> ResizePlan: + """Resolve *resize* against *source_size* into exact integer pixel ops.""" + width, height = source_size + target_width, target_height = resize.width, resize.height + if target_width is not None and target_height is None: + return _single_axis(width, height, target_width, vertical=False) + if target_height is not None and target_width is None: + return _single_axis(width, height, target_height, vertical=True) + assert target_width is not None and target_height is not None + target = (target_width, target_height) + if width * target_height == height * target_width: + return _plan(target, target, source_size) + if resize.mismatch is MismatchMode.STRETCH: + return _plan(target, target, source_size) + if resize.mismatch is MismatchMode.COVER: + return _cover(width, height, target_width, target_height) + if resize.mismatch is MismatchMode.PAD: + return _pad(width, height, target_width, target_height) + return _keep(width, height, target_width, target_height) + + +def transformed_size( + framed_size: tuple[int, int], spec: TransformSpec +) -> tuple[int, int]: + """Return the final output size for *spec*; pure arithmetic, no bounds check.""" + size = ( + (spec.crop.width, spec.crop.height) if spec.crop is not None else framed_size + ) + if spec.resize is None: + return size + return resolve_resize(size, spec.resize).canvas + + +def apply_transform(image: Image.Image, spec: TransformSpec) -> Image.Image: + """Apply *spec* to *image*. Never mutates its input; always returns RGBA. + + An identity spec returns ``image`` itself so an unmodified cut is never + re-saved (AC 1 byte identity). + """ + if spec.is_identity: + return image + working = _crop(image, spec.crop) + if spec.resize is None: + return working + plan = resolve_resize(working.size, spec.resize) + return _place(_resample(working, plan), plan) + + +def _single_axis(width: int, height: int, target: int, *, vertical: bool) -> ResizePlan: + if vertical: + scale = Fraction(target, height) + scaled = (_rhu(width * scale), target) + else: + scale = Fraction(target, width) + scaled = (target, _rhu(height * scale)) + return _plan(scaled, scaled, (width, height)) + + +def _keep(width: int, height: int, target_width: int, target_height: int) -> ResizePlan: + scale = min(Fraction(target_width, width), Fraction(target_height, height)) + scaled = (_rhu(width * scale), _rhu(height * scale)) + return _plan(scaled, scaled, (width, height)) + + +def _cover( + width: int, height: int, target_width: int, target_height: int +) -> ResizePlan: + scale = max(Fraction(target_width, width), Fraction(target_height, height)) + scaled = (_rhu(width * scale), _rhu(height * scale)) + left = (scaled[0] - target_width) // 2 + top = (scaled[1] - target_height) // 2 + crop_box = (left, top, left + target_width, top + target_height) + target = (target_width, target_height) + return _plan(scaled, target, (width, height), crop_box=crop_box) + + +def _pad( + width: int, height: int, target_width: int, target_height: int +) -> ResizePlan: + scale = min(Fraction(target_width, width), Fraction(target_height, height)) + scaled = (_rhu(width * scale), _rhu(height * scale)) + offset = ((target_width - scaled[0]) // 2, (target_height - scaled[1]) // 2) + target = (target_width, target_height) + return _plan(scaled, target, (width, height), offset=offset) + + +def _plan( + scaled: tuple[int, int], + canvas: tuple[int, int], + source_size: tuple[int, int], + *, + offset: tuple[int, int] = (0, 0), + crop_box: tuple[int, int, int, int] | None = None, +) -> ResizePlan: + resample = scaled != source_size + if resample: + geometry._validate_allocation_budget(scaled, 4, "transform resample") + return ResizePlan( + scaled=scaled, + canvas=canvas, + offset=offset, + crop_box=crop_box, + resample=resample, + ) + + +def _rhu(value: Fraction) -> int: + """Floor of ``value + 1/2`` on an exact Fraction (house rounding).""" + return (2 * value.numerator + value.denominator) // (2 * value.denominator) + + +def _crop(image: Image.Image, crop: CropSpec | None) -> Image.Image: + if crop is None: + return _ensure_rgba(image) + box = (crop.x, crop.y, crop.x + crop.width, crop.y + crop.height) + return _ensure_rgba(image.crop(box)) + + +def _ensure_rgba(image: Image.Image) -> Image.Image: + return image if image.mode == "RGBA" else image.convert("RGBA") + + +def _resample(image: Image.Image, plan: ResizePlan) -> Image.Image: + if not plan.resample: + return image + premultiplied = image.convert("RGBa") + resized = premultiplied.resize(plan.scaled, Image.Resampling.LANCZOS) + return resized.convert("RGBA") + + +def _place(image: Image.Image, plan: ResizePlan) -> Image.Image: + if plan.crop_box is not None: + return image.crop(plan.crop_box) + if plan.canvas != image.size: + canvas = Image.new("RGBA", plan.canvas, (0, 0, 0, 0)) + canvas.paste(image, plan.offset) + return canvas + return image diff --git a/tests/core/test_transform.py b/tests/core/test_transform.py new file mode 100644 index 0000000..bc5d1f8 --- /dev/null +++ b/tests/core/test_transform.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import pytest +from PIL import Image + +from matteloop.core.specs import CropSpec, MismatchMode, ResizeSpec, TransformSpec +from matteloop.core.transform import apply_transform, resolve_resize + + +def _half_red_half_blue(width: int, height: int) -> Image.Image: + """An opaque red/blue split frame with one semi-transparent pixel. + + The semi-transparent pixel exercises the premultiply step: an + implementation that resizes without premultiplying first produces + different RGB values around it than the house idiom does. + """ + image = Image.new("RGBA", (width, height), (0, 0, 0, 255)) + half = width // 2 + for x in range(width): + color = (255, 0, 0, 255) if x < half else (0, 0, 255, 255) + for y in range(height): + image.putpixel((x, y), color) + image.putpixel((1, 1), (255, 0, 0, 40)) + return image + + +def _reference_resample(image: Image.Image, size: tuple[int, int]) -> Image.Image: + """Replicate the house resize idiom verbatim (core/webp.py:1226-1236).""" + premultiplied = image.convert("RGBa") + resized = premultiplied.resize(size, Image.Resampling.LANCZOS) + return resized.convert("RGBA") + + +def test_identity_transform_returns_the_same_object() -> None: + source = _half_red_half_blue(128, 128) + + assert apply_transform(source, TransformSpec()) is source + + +def test_apply_transform_keep_mode_leaves_a_matching_aspect_untouched() -> None: + source = _half_red_half_blue(128, 128) + spec = TransformSpec(resize=ResizeSpec(256, 128, MismatchMode.KEEP)) + + plan = resolve_resize((128, 128), spec.resize) + assert plan.scaled == (128, 128) + assert plan.resample is False + + result = apply_transform(source, spec) + + assert result.size == (128, 128) + assert result.mode == "RGBA" + assert list(result.get_flattened_data()) == list(source.get_flattened_data()) + + +def test_apply_transform_stretch_mode_fills_the_exact_canvas() -> None: + source = _half_red_half_blue(128, 128) + spec = TransformSpec(resize=ResizeSpec(256, 128, MismatchMode.STRETCH)) + + result = apply_transform(source, spec) + + assert result.size == (256, 128) + assert result.mode == "RGBA" + expected = _reference_resample(source, (256, 128)) + assert list(result.get_flattened_data()) == list(expected.get_flattened_data()) + + +def test_apply_transform_cover_mode_scales_then_crops_the_middle() -> None: + source = _half_red_half_blue(128, 128) + spec = TransformSpec(resize=ResizeSpec(256, 128, MismatchMode.COVER)) + + plan = resolve_resize((128, 128), spec.resize) + assert plan.scaled == (256, 256) + assert plan.crop_box == (0, 64, 256, 192) + + result = apply_transform(source, spec) + + assert result.size == (256, 128) + assert result.mode == "RGBA" + reference = _reference_resample(source, (256, 256)).crop((0, 64, 256, 192)) + assert list(result.get_flattened_data()) == list(reference.get_flattened_data()) + + +def test_apply_transform_pad_mode_pastes_untouched_frame_with_transparent_bars() -> ( + None +): + source = _half_red_half_blue(128, 128) + spec = TransformSpec(resize=ResizeSpec(256, 128, MismatchMode.PAD)) + + plan = resolve_resize((128, 128), spec.resize) + assert plan.scaled == (128, 128) + assert plan.resample is False + assert plan.offset == (64, 0) + + result = apply_transform(source, spec) + + assert result.size == (256, 128) + assert result.mode == "RGBA" + for x in range(0, 64): + for y in range(128): + assert result.getpixel((x, y)) == (0, 0, 0, 0) + for x in range(64, 192): + for y in range(128): + assert result.getpixel((x, y)) == source.getpixel((x - 64, y)) + for x in range(192, 256): + for y in range(128): + assert result.getpixel((x, y)) == (0, 0, 0, 0) + + +@pytest.mark.parametrize( + "mismatch", + [MismatchMode.KEEP, MismatchMode.STRETCH, MismatchMode.COVER, MismatchMode.PAD], +) +def test_width_only_resize_yields_the_proportional_height_in_every_mode( + mismatch: MismatchMode, +) -> None: + source = _half_red_half_blue(256, 128) + spec = TransformSpec(resize=ResizeSpec(width=256, mismatch=mismatch)) + + result = apply_transform(source, spec) + + assert result.size == (256, 128) + assert result.mode == "RGBA" + + +def test_resize_to_the_same_size_does_not_resample() -> None: + source = _half_red_half_blue(128, 128) + spec = TransformSpec(resize=ResizeSpec(128, 128, MismatchMode.STRETCH)) + + plan = resolve_resize((128, 128), spec.resize) + assert plan.resample is False + + result = apply_transform(source, spec) + + assert result.size == (128, 128) + assert list(result.get_flattened_data()) == list(source.get_flattened_data()) + + +def test_cover_places_the_extra_odd_pixel_on_the_right() -> None: + source = _half_red_half_blue(129, 128) + spec = TransformSpec(resize=ResizeSpec(128, 128, MismatchMode.COVER)) + + plan = resolve_resize((129, 128), spec.resize) + assert plan.scaled == (129, 128) + assert plan.resample is False + assert plan.crop_box == (0, 0, 128, 128) + + result = apply_transform(source, spec) + + assert result.size == (128, 128) + assert list(result.get_flattened_data()) == list( + source.crop((0, 0, 128, 128)).get_flattened_data() + ) + + +def test_pad_places_the_extra_odd_pixel_on_the_right() -> None: + source = _half_red_half_blue(128, 128) + spec = TransformSpec(resize=ResizeSpec(257, 128, MismatchMode.PAD)) + + plan = resolve_resize((128, 128), spec.resize) + assert plan.scaled == (128, 128) + assert plan.resample is False + assert plan.offset == (64, 0) + + result = apply_transform(source, spec) + + assert result.size == (257, 128) + for x in range(64): + for y in range(128): + assert result.getpixel((x, y)) == (0, 0, 0, 0) + for x in range(64, 192): + for y in range(128): + assert result.getpixel((x, y)) == source.getpixel((x - 64, y)) + for x in range(192, 257): + for y in range(128): + assert result.getpixel((x, y)) == (0, 0, 0, 0) + + +def test_apply_transform_crop_only_returns_the_cropped_region_as_rgba() -> None: + source = _half_red_half_blue(128, 128) + spec = TransformSpec(crop=CropSpec(x=0, y=0, width=64, height=128)) + + result = apply_transform(source, spec) + + assert result.size == (64, 128) + assert result.mode == "RGBA" + expected = source.crop((0, 0, 64, 128)) + assert list(result.get_flattened_data()) == list(expected.get_flattened_data()) From 76acb9463c770d8a4bddd88279c61a36a5c756ee Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:10:41 +0200 Subject: [PATCH 04/45] refactor: move output-error helpers into jobs/encoding.py render.py is frozen at 2938 lines; task A5 needs the room. Moved _map_output_os_error and _output_error verbatim into jobs/encoding.py (no import cycle: encoding.py only needs core/context imports). render.py imports both back under the same names, so all 43 call sites are unchanged. render.py: 2938 -> 2910 lines. tests/jobs: 564 passed, 9 pre-existing failures, 46 skipped - identical before and after the move. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/encoding.py | 36 ++++++++++++++++++++++++++++++ src/matteloop/jobs/render.py | 40 +++++----------------------------- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/matteloop/jobs/encoding.py b/src/matteloop/jobs/encoding.py index c455aa8..70f662c 100644 --- a/src/matteloop/jobs/encoding.py +++ b/src/matteloop/jobs/encoding.py @@ -2,6 +2,7 @@ from __future__ import annotations +import errno from collections.abc import Callable from pathlib import Path @@ -91,3 +92,38 @@ def report_frame(completed: int, total: int) -> None: ) return report_frame, report_attempt + + +def _map_output_os_error(error: OSError, detail: str) -> AppError: + if error.errno in {errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)}: + suffix = "disk quota or free space exhausted" + action = "free-disk-space" + elif error.errno in {errno.EACCES, errno.EPERM}: + suffix = "output location is not writable" + action = "choose-writable-output" + elif error.errno == getattr(errno, "EROFS", -1): + suffix = "output filesystem is read-only" + action = "choose-writable-output" + elif error.errno == errno.EEXIST: + suffix = "output target already exists" + action = "choose-collision-policy" + else: + suffix = f"{type(error).__name__}: {error}" + action = "retry-output" + return AppError( + ErrorCode.INVALID_OUTPUT, + "output", + "error.output.failed", + f"{detail}: {suffix}", + action, + ) + + +def _output_error(detail: str) -> AppError: + return AppError( + ErrorCode.INVALID_OUTPUT, + "output", + "error.output.failed", + detail, + "retry-output", + ) diff --git a/src/matteloop/jobs/render.py b/src/matteloop/jobs/render.py index 8a69c8c..651181f 100644 --- a/src/matteloop/jobs/render.py +++ b/src/matteloop/jobs/render.py @@ -70,7 +70,12 @@ validate_webp, ) from matteloop.jobs.context import JobContext, JobTerminalState -from matteloop.jobs.encoding import auto_fit_progress, auto_fit_webp +from matteloop.jobs.encoding import ( + _map_output_os_error, + _output_error, + auto_fit_progress, + auto_fit_webp, +) from matteloop.jobs.models.cache_fs import BoundDirectoryCloseError, UnsafeCacheError from matteloop.jobs.protocol import PROTOCOL_VERSION, SegmentOptions, SegmentRequest from matteloop.jobs.source import DecodedFrame, SourceInfo, decode_frame, probe_source @@ -2903,36 +2908,3 @@ def _is_sha256(value: object) -> bool: ) -def _map_output_os_error(error: OSError, detail: str) -> AppError: - if error.errno in {errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)}: - suffix = "disk quota or free space exhausted" - action = "free-disk-space" - elif error.errno in {errno.EACCES, errno.EPERM}: - suffix = "output location is not writable" - action = "choose-writable-output" - elif error.errno == getattr(errno, "EROFS", -1): - suffix = "output filesystem is read-only" - action = "choose-writable-output" - elif error.errno == errno.EEXIST: - suffix = "output target already exists" - action = "choose-collision-policy" - else: - suffix = f"{type(error).__name__}: {error}" - action = "retry-output" - return AppError( - ErrorCode.INVALID_OUTPUT, - "output", - "error.output.failed", - f"{detail}: {suffix}", - action, - ) - - -def _output_error(detail: str) -> AppError: - return AppError( - ErrorCode.INVALID_OUTPUT, - "output", - "error.output.failed", - detail, - "retry-output", - ) From 9c73eec352cf5cdf69b1a412e044370645ee9789 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:21:47 +0200 Subject: [PATCH 05/45] refactor: extract the framing loop into jobs/transform_stage.py render.py is frozen at 2938 lines; task A5 needs the room for the transform pixel operations. Moved the trim-union guard, FramingPlan construction, and the per-frame read-frame-persist loop (framing_plan + stage_encoder_frames) plus _persist_framed_png verbatim into a new jobs/transform_stage.py, so the encoder and the future result player call the same functions (design decision D2/D3). stage_encoder_frames takes the TransformSpec parameter the full contract calls for, but this task only implements the identity path: it still iterates every stored frame and returns the delays unchanged. Applying a non-identity transform is task A5. apply_framing stays imported in render.py (still serves the preview at :906/:920); errno stays (rollback path); only the now-unused tempfile import is dropped. Updated the two test seams in tests/jobs/test_render.py to patch/call transform_stage instead of render_module. render.py: 2910 -> 2842 lines (still 11 over-60-line functions). transform_stage.py: 146 lines. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/render.py | 92 +++------------- src/matteloop/jobs/transform_stage.py | 146 ++++++++++++++++++++++++++ tests/jobs/test_render.py | 9 +- tests/jobs/test_transform_stage.py | 87 +++++++++++++++ 4 files changed, 251 insertions(+), 83 deletions(-) create mode 100644 src/matteloop/jobs/transform_stage.py create mode 100644 tests/jobs/test_transform_stage.py diff --git a/src/matteloop/jobs/render.py b/src/matteloop/jobs/render.py index 651181f..575e0e7 100644 --- a/src/matteloop/jobs/render.py +++ b/src/matteloop/jobs/render.py @@ -23,7 +23,6 @@ import os import shutil import stat -import tempfile import time from collections.abc import Callable, Sequence from dataclasses import dataclass, field, replace @@ -79,6 +78,7 @@ from matteloop.jobs.models.cache_fs import BoundDirectoryCloseError, UnsafeCacheError from matteloop.jobs.protocol import PROTOCOL_VERSION, SegmentOptions, SegmentRequest from matteloop.jobs.source import DecodedFrame, SourceInfo, decode_frame, probe_source +from matteloop.jobs.transform_stage import framing_plan, stage_encoder_frames from matteloop.jobs.workspace import ( AdvisoryFileLock, CutFrame, @@ -1341,53 +1341,19 @@ def _encode_snapshot( *, rebuilt: bool, ) -> RenderArtifact: - if request.framing.trim and union is None: - raise ValidationError( - ErrorCode.INVALID_FRAMING, - "framing", - "range-wide alpha union contains no visible pixels at this threshold", - ) - plan = FramingPlan( - (manifest.width, manifest.height), - global_bounds=union if request.framing.trim else None, - padding=request.framing.padding, - stretch_x=request.framing.stretch_x, - ) + plan = framing_plan((manifest.width, manifest.height), union, request.framing) tracker.include_size(plan.output_size) scratch = private.path.parent - framed_directory = scratch / "framed-inputs" - try: - framed_directory.mkdir(exist_ok=False) - except OSError as error: - raise _map_output_os_error( - error, "cannot create framed input directory" - ) from error - framed_paths: list[Path] = [] - for index in range(manifest.frame_count): - context.set_frame_context( - index + 1, - manifest.frame_count, - overall=context.overall_progress, - ) - context.checkpoint("framing") - cut = self._workspace.read_cut(private, index, tracker) - try: - framed = apply_framing(cut, plan) - tracker.register(framed) - finally: - cut.close() - del cut - try: - path = framed_directory / f"frame-{index:06d}.png" - _persist_framed_png(path, framed) - framed_paths.append(path) - finally: - framed.close() - del framed - context.progress( - "Framing", index + 1, total=manifest.frame_count, - detail=f"Frame {index + 1} of {manifest.frame_count}", - ) + framed_paths, delays = stage_encoder_frames( + partial(self._workspace.read_cut, private), + manifest.frame_count, + plan, + request.transform, + delays, + scratch / "framed-inputs", + tracker, + context, + ) candidate = self._output_publisher.candidate_path( request.output.path, context.job_id, scratch ) @@ -1645,40 +1611,6 @@ def _union_bounds( ) -def _persist_framed_png(path: Path, image: Image.Image) -> None: - temporary: Path | None = None - primary: BaseException | None = None - try: - descriptor, raw = tempfile.mkstemp( - prefix=f".{path.name}.", suffix=".tmp", dir=path.parent - ) - os.close(descriptor) - temporary = Path(raw) - image.save(temporary, format="PNG") - with temporary.open("rb+") as encoded: # Windows _commit needs write - os.fsync(encoded.fileno()) - os.replace(temporary, path) - temporary = None - except OSError as error: - wrapped = _map_output_os_error(error, "cannot persist framed PNG") - primary = wrapped - raise wrapped from error - except ValueError as error: - wrapped = _output_error(f"cannot persist framed PNG: {error}") - primary = wrapped - raise wrapped from error - except BaseException as error: - primary = error - raise - finally: - if temporary is not None: - try: - temporary.unlink() - except OSError as error: - if primary is not None: - primary.add_note(f"additional framed-PNG cleanup failure: {error}") - - def _cleanup_stage( workspace: WorkspacePort, staged: CutWorkspace, diff --git a/src/matteloop/jobs/transform_stage.py b/src/matteloop/jobs/transform_stage.py new file mode 100644 index 0000000..8e2d1d5 --- /dev/null +++ b/src/matteloop/jobs/transform_stage.py @@ -0,0 +1,146 @@ +"""Frame a cut and hand it to the encoder — the shared stage the result player +also calls (design decision D2), so encoder and preview stay pixel-identical. + +``stage_encoder_frames`` accepts a ``TransformSpec`` for contract compliance +(``docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md`` section +4.3), but this module (task A4) only implements the identity path: the loop +still enumerates every stored frame and returns the delays unchanged. Slicing +to the transform's kept range and calling ``core.transform.apply_transform`` +is task A5. +""" + +from __future__ import annotations + +import os +import tempfile +from collections.abc import Callable +from pathlib import Path + +from PIL import Image + +from matteloop.core.errors import ErrorCode, ValidationError +from matteloop.core.geometry import FramingPlan, PixelBounds, apply_framing +from matteloop.core.rgba import RgbaOwnershipTracker +from matteloop.core.specs import FramingSpec, TransformSpec +from matteloop.jobs.context import JobContext +from matteloop.jobs.encoding import _map_output_os_error, _output_error + + +def framing_plan( + source_size: tuple[int, int], union: PixelBounds | None, framing: FramingSpec +) -> FramingPlan: + """Build the one immutable ``FramingPlan`` the encoder and the player share.""" + if framing.trim and union is None: + raise ValidationError( + ErrorCode.INVALID_FRAMING, + "framing", + "range-wide alpha union contains no visible pixels at this threshold", + ) + return FramingPlan( + source_size, + global_bounds=union if framing.trim else None, + padding=framing.padding, + stretch_x=framing.stretch_x, + ) + + +def stage_encoder_frames( + read_cut: Callable[[int, RgbaOwnershipTracker], Image.Image], + frame_count: int, + plan: FramingPlan, + transform: TransformSpec, + delays: tuple[int, ...], + framed_directory: Path, + tracker: RgbaOwnershipTracker, + context: JobContext, +) -> tuple[tuple[Path, ...], tuple[int, ...]]: + """Frame every stored cut frame and persist it for the encoder. + + Task A4 moves the loop verbatim; ``transform`` is accepted but not yet + applied (task A5), so the kept range is always the full cut and the + delays returned are the ones passed in, unchanged. + """ + try: + framed_directory.mkdir(exist_ok=False) + except OSError as error: + raise _map_output_os_error( + error, "cannot create framed input directory" + ) from error + framed_paths: list[Path] = [] + for index in range(frame_count): + context.set_frame_context( + index + 1, frame_count, overall=context.overall_progress + ) + context.checkpoint("framing") + framed_paths.append( + _stage_frame( + read_cut, index, index, plan, transform, framed_directory, tracker + ) + ) + context.progress( + "Framing", + index + 1, + total=frame_count, + detail=f"Frame {index + 1} of {frame_count}", + ) + return tuple(framed_paths), delays + + +def _stage_frame( + read_cut: Callable[[int, RgbaOwnershipTracker], Image.Image], + index: int, + position: int, + plan: FramingPlan, + transform: TransformSpec, + framed_directory: Path, + tracker: RgbaOwnershipTracker, +) -> Path: + del transform # applied starting in task A5 + cut = read_cut(index, tracker) + try: + framed = apply_framing(cut, plan) + tracker.register(framed) + finally: + cut.close() + del cut + try: + path = framed_directory / f"frame-{position:06d}.png" + _persist_framed_png(path, framed) + return path + finally: + framed.close() + del framed + + +def _persist_framed_png(path: Path, image: Image.Image) -> None: + temporary: Path | None = None + primary: BaseException | None = None + try: + descriptor, raw = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + os.close(descriptor) + temporary = Path(raw) + image.save(temporary, format="PNG") + with temporary.open("rb+") as encoded: # Windows _commit needs write + os.fsync(encoded.fileno()) + os.replace(temporary, path) + temporary = None + except OSError as error: + wrapped = _map_output_os_error(error, "cannot persist framed PNG") + primary = wrapped + raise wrapped from error + except ValueError as error: + wrapped = _output_error(f"cannot persist framed PNG: {error}") + primary = wrapped + raise wrapped from error + except BaseException as error: + primary = error + raise + finally: + if temporary is not None: + try: + temporary.unlink() + except OSError as error: + if primary is not None: + primary.add_note(f"additional framed-PNG cleanup failure: {error}") diff --git a/tests/jobs/test_render.py b/tests/jobs/test_render.py index 50a9705..5f76ab9 100644 --- a/tests/jobs/test_render.py +++ b/tests/jobs/test_render.py @@ -15,6 +15,7 @@ from PIL import Image import matteloop.jobs.render as render_module +import matteloop.jobs.transform_stage as transform_stage_module import matteloop.jobs.workspace as workspace_module from matteloop.core.errors import AppError, ErrorCode from matteloop.core.specs import ( @@ -1545,13 +1546,15 @@ def encode(self, *args, **kwargs): return summary if boundary == "framing": - actual_persist = render_module._persist_framed_png + actual_persist = transform_stage_module._persist_framed_png def persist_then_cancel(path, image): actual_persist(path, image) context.request_cancel() - monkeypatch.setattr(render_module, "_persist_framed_png", persist_then_cancel) + monkeypatch.setattr( + transform_stage_module, "_persist_framed_png", persist_then_cancel + ) render_request = request(tmp_path) render_request.output.path.write_bytes(b"old-output") @@ -2963,7 +2966,7 @@ def fail_unlink(self: Path, *args, **kwargs): with Image.new("RGBA", (128, 128)) as image: with pytest.raises(AppError) as exc: - render_module._persist_framed_png(output, image) + transform_stage_module._persist_framed_png(output, image) assert exc.value.retry_action == "free-disk-space" assert exc.value.__notes__ == [ diff --git a/tests/jobs/test_transform_stage.py b/tests/jobs/test_transform_stage.py new file mode 100644 index 0000000..99be5b6 --- /dev/null +++ b/tests/jobs/test_transform_stage.py @@ -0,0 +1,87 @@ +"""Behaviour of the shared framing/staging functions used by encoder and player.""" + +from __future__ import annotations + +from decimal import Decimal +from fractions import Fraction +from pathlib import Path + +import pytest +from PIL import Image + +from matteloop.core.errors import ErrorCode, ValidationError +from matteloop.core.geometry import FramingPlan, PixelBounds +from matteloop.core.rgba import RgbaOwnershipTracker +from matteloop.core.specs import FramingSpec, TransformSpec +from matteloop.core.state import JobKind +from matteloop.jobs.transform_stage import framing_plan, stage_encoder_frames +from tests.jobs.render_support import job + + +def _colour_frame(colour: tuple[int, int, int, int]) -> Image.Image: + return Image.new("RGBA", (128, 128), colour) + + +def test_framing_plan_matches_direct_construction_for_the_identity_framing() -> None: + framing = FramingSpec(False, Decimal("2"), 0, Decimal("1")) + plan = framing_plan((200, 180), None, framing) + assert plan == FramingPlan( + (200, 180), global_bounds=None, padding=0, stretch_x=Fraction(1) + ) + + +def test_framing_plan_rejects_trim_without_a_union() -> None: + framing = FramingSpec(True, Decimal("2"), 0, Decimal("1")) + with pytest.raises(ValidationError) as exc: + framing_plan((200, 180), None, framing) + assert exc.value.code is ErrorCode.INVALID_FRAMING + + +def test_framing_plan_accepts_trim_with_a_union() -> None: + framing = FramingSpec(True, Decimal("2"), 0, Decimal("1")) + union = PixelBounds(10, 10, 138, 138) + plan = framing_plan((200, 180), union, framing) + assert plan.global_bounds == union + + +def test_stage_encoder_frames_writes_the_framed_pixels_and_keeps_the_delays( + tmp_path: Path, +) -> None: + colours = [ + (255, 0, 0, 255), + (0, 255, 0, 255), + (0, 0, 255, 255), + ] + frames = [_colour_frame(colour) for colour in colours] + + def read_cut(index: int, tracker: RgbaOwnershipTracker) -> Image.Image: + image = frames[index].copy() + tracker.register(image) + return image + + plan = FramingPlan((128, 128), global_bounds=None, padding=0, stretch_x=Fraction(1)) + delays = (100, 100, 100) + tracker = RgbaOwnershipTracker((128, 128)) + context = job(tmp_path, "job-1", JobKind.RENDER) + + paths, returned_delays = stage_encoder_frames( + read_cut, + 3, + plan, + TransformSpec(), + delays, + tmp_path / "framed-inputs", + tracker, + context, + ) + + assert paths == tuple( + tmp_path / "framed-inputs" / f"frame-{index:06d}.png" for index in range(3) + ) + for path, frame in zip(paths, frames, strict=True): + with Image.open(path) as saved: + saved.load() + assert saved.mode == "RGBA" + assert saved.size == (128, 128) + assert saved.tobytes() == frame.tobytes() + assert returned_delays == delays From 8f50dab174e45293c957b6f5c7a5e724303bedc0 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:23:06 +0200 Subject: [PATCH 06/45] test: cover TransformSpec.validate_for's resize branch _validate_size reaches core.transform through a lazy import to break a cycle; no test exercised it. Added the resize-derived-dimension cases (width-only and height-only, both below the 128 px floor) and the crop-only case, confirming bounds apply to the final canvas, plus one accepted keep-mode resize confirming the (128, 128) result. Co-Authored-By: Claude Opus 5 (1M context) --- tests/core/test_specs.py | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/core/test_specs.py b/tests/core/test_specs.py index 7c2e292..805704d 100644 --- a/tests/core/test_specs.py +++ b/tests/core/test_specs.py @@ -607,6 +607,51 @@ def test_transform_spec_validate_for_returns_the_final_canvas_size() -> None: assert transform.validate_for(4, (256, 128)) == (150, 128) +def test_transform_spec_validate_for_rejects_a_derived_height_too_small() -> None: + transform = TransformSpec(resize=ResizeSpec(width=128, height=None)) + + with pytest.raises(ValidationError) as exc: + transform.validate_for(4, (512, 128)) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + assert "128" in exc.value.technical_detail + assert "32" in exc.value.technical_detail + + +def test_transform_spec_validate_for_rejects_a_derived_width_too_small() -> None: + transform = TransformSpec(resize=ResizeSpec(width=None, height=128)) + + with pytest.raises(ValidationError) as exc: + transform.validate_for(4, (128, 512)) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + assert "128" in exc.value.technical_detail + assert "32" in exc.value.technical_detail + + +def test_transform_spec_validate_for_rejects_a_crop_only_final_canvas_too_small() -> ( + None +): + """Bounds apply to the final canvas, not just the crop rectangle in isolation.""" + transform = TransformSpec(crop=CropSpec(x=0, y=0, width=100, height=100)) + + with pytest.raises(ValidationError) as exc: + transform.validate_for(4, (256, 256)) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + assert "128" in exc.value.technical_detail + + +def test_transform_spec_validate_for_accepts_a_keep_resize_limited_by_short_axis() -> ( + None +): + transform = TransformSpec( + resize=ResizeSpec(width=256, height=128, mismatch=MismatchMode.KEEP) + ) + + assert transform.validate_for(4, (256, 256)) == (128, 128) + + def test_render_request_default_transform_is_identity(tmp_path: Path) -> None: request = valid_render_request(tmp_path) From 7aaa38325acdf3259b0309209d73fa2bee1f3367 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:43:32 +0200 Subject: [PATCH 07/45] feat: apply the transform in the shared encoder stage (issue #25 task A5) stage_encoder_frames now validates TransformSpec against the loaded cut (validate_for, before the framed directory is created) and stages only transform.kept_range(frame_count). The returned delays are transform.select_kept(delays) -- a slice of the full non-uniform webp_delays grid, never a recomputation, since a recomputed grid over the kept count can sum to the same total without matching term-by-term. _stage_frame runs core.transform.apply_transform on each framed image before persisting it; an identity spec returns the same object apply_framing produced, so an unmodified rebuild reaches the encoder with the exact paths and delays it always has (AC 1 byte identity). stage_encoder_frames also registers the transform's final (post-resize, pre-auto-fit) canvas size with the RgbaOwnershipTracker, alongside the existing framed-size registration: once a resize changes the final WebP dimensions, the decoded validation frames webp.py registers during encode no longer match the framed size, so without this they would silently escape the tracker's full-resolution-RGBA matching and undercount ownership_peak/ownership_current. The resize's internal "scaled" intermediate (e.g. cover's pre-crop buffer) is deliberately not threaded through: core/transform.py's apply_transform takes no tracker parameter by contract, that buffer never survives past the call, and it is already bounded independently by _validate_allocation_budget. render.py's two manifest.frame_count uses inside _encode_snapshot become len(framed_paths), since a trim can shrink the encoded frame count below the cut's stored frame count; the file stays at 2842 lines, its over-60-line function count unchanged at 11. Trigger: none (feature work, not a bug fix). Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/render.py | 4 +- src/matteloop/jobs/transform_stage.py | 58 ++++-- tests/jobs/test_rebuild.py | 265 +++++++++++++++++++++++++- 3 files changed, 302 insertions(+), 25 deletions(-) diff --git a/src/matteloop/jobs/render.py b/src/matteloop/jobs/render.py index 575e0e7..8fa18bf 100644 --- a/src/matteloop/jobs/render.py +++ b/src/matteloop/jobs/render.py @@ -1363,7 +1363,7 @@ def _encode_snapshot( published = False publish_error: BaseException | None = None try: - frame_count = manifest.frame_count + frame_count = len(framed_paths) overall = context.overall_progress or (0, frame_count) stage = "Auto-fit" if request.output.max_bytes is not None else "Encode" overall_for_encode = ( @@ -1428,7 +1428,7 @@ def _encode_snapshot( artifact_fingerprint, durable, manifest, - manifest.frame_count, + len(framed_paths), summary.width, summary.height, summary.file_size, diff --git a/src/matteloop/jobs/transform_stage.py b/src/matteloop/jobs/transform_stage.py index 8e2d1d5..a4b1afc 100644 --- a/src/matteloop/jobs/transform_stage.py +++ b/src/matteloop/jobs/transform_stage.py @@ -1,12 +1,15 @@ """Frame a cut and hand it to the encoder — the shared stage the result player also calls (design decision D2), so encoder and preview stay pixel-identical. -``stage_encoder_frames`` accepts a ``TransformSpec`` for contract compliance -(``docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md`` section -4.3), but this module (task A4) only implements the identity path: the loop -still enumerates every stored frame and returns the delays unchanged. Slicing -to the transform's kept range and calling ``core.transform.apply_transform`` -is task A5. +``stage_encoder_frames`` validates and applies the request's ``TransformSpec`` +(task A5): ``validate_for`` runs before the framed directory is created, only +the frames in ``transform.kept_range`` are staged, and the encoder's delays +are *sliced* from the full grid rather than recomputed — ``webp_delays`` +(``core/timebase.py``) distributes rounding non-uniformly, so a recomputed +grid over the kept count would not equal the kept slice of the full grid. +``core.transform.apply_transform`` runs on each frame after ``apply_framing``; +for an identity spec it returns the same object, so an unmodified frame is +never re-saved with different bytes than before this stage existed. """ from __future__ import annotations @@ -22,6 +25,7 @@ from matteloop.core.geometry import FramingPlan, PixelBounds, apply_framing from matteloop.core.rgba import RgbaOwnershipTracker from matteloop.core.specs import FramingSpec, TransformSpec +from matteloop.core.transform import apply_transform from matteloop.jobs.context import JobContext from matteloop.jobs.encoding import _map_output_os_error, _output_error @@ -54,36 +58,41 @@ def stage_encoder_frames( tracker: RgbaOwnershipTracker, context: JobContext, ) -> tuple[tuple[Path, ...], tuple[int, ...]]: - """Frame every stored cut frame and persist it for the encoder. + """Frame, crop, and resize the kept cut frames and persist them. - Task A4 moves the loop verbatim; ``transform`` is accepted but not yet - applied (task A5), so the kept range is always the full cut and the - delays returned are the ones passed in, unchanged. + Validates *transform* against the cut before any file is created. Only + the frames in ``transform.kept_range(frame_count)`` are staged; the + returned delays are ``transform.select_kept(delays)`` — a slice of the + full grid, never a recomputation. """ + final_size = transform.validate_for(frame_count, plan.output_size) + tracker.include_size(final_size) try: framed_directory.mkdir(exist_ok=False) except OSError as error: raise _map_output_os_error( error, "cannot create framed input directory" ) from error + kept = transform.kept_range(frame_count) + kept_count = len(kept) framed_paths: list[Path] = [] - for index in range(frame_count): + for position, index in enumerate(kept): context.set_frame_context( - index + 1, frame_count, overall=context.overall_progress + position + 1, kept_count, overall=context.overall_progress ) context.checkpoint("framing") framed_paths.append( _stage_frame( - read_cut, index, index, plan, transform, framed_directory, tracker + read_cut, index, position, plan, transform, framed_directory, tracker ) ) context.progress( "Framing", - index + 1, - total=frame_count, - detail=f"Frame {index + 1} of {frame_count}", + position + 1, + total=kept_count, + detail=f"Frame {position + 1} of {kept_count}", ) - return tuple(framed_paths), delays + return tuple(framed_paths), transform.select_kept(delays) def _stage_frame( @@ -95,7 +104,6 @@ def _stage_frame( framed_directory: Path, tracker: RgbaOwnershipTracker, ) -> Path: - del transform # applied starting in task A5 cut = read_cut(index, tracker) try: framed = apply_framing(cut, plan) @@ -104,9 +112,17 @@ def _stage_frame( cut.close() del cut try: - path = framed_directory / f"frame-{position:06d}.png" - _persist_framed_png(path, framed) - return path + transformed = apply_transform(framed, transform) + if transformed is not framed: + tracker.register(transformed) + try: + path = framed_directory / f"frame-{position:06d}.png" + _persist_framed_png(path, transformed) + return path + finally: + if transformed is not framed: + transformed.close() + del transformed finally: framed.close() del framed diff --git a/tests/jobs/test_rebuild.py b/tests/jobs/test_rebuild.py index d66b747..69ad89a 100644 --- a/tests/jobs/test_rebuild.py +++ b/tests/jobs/test_rebuild.py @@ -2,13 +2,23 @@ from dataclasses import replace from decimal import Decimal +from fractions import Fraction import pytest from PIL import Image -from matteloop.core.errors import AppError, ErrorCode -from matteloop.core.specs import FramingSpec +from matteloop.core.errors import AppError, ErrorCode, ValidationError +from matteloop.core.specs import ( + CropSpec, + FramingSpec, + MismatchMode, + ResizeSpec, + SamplingSpec, + TransformSpec, +) from matteloop.core.state import JobKind +from matteloop.core.timebase import webp_delays +from matteloop.core.webp import validate_webp from matteloop.jobs.render import ( AtomicOutputPublisher, FilesystemWorkspacePort, @@ -22,6 +32,7 @@ FakeEncoder, FakeSegmenter, FakeSource, + FakeSourceInfo, binding, job, render_service, @@ -29,6 +40,15 @@ ) +class _LongerSource(FakeSource): + """A source whose probed duration covers an 8-frame, 3 fps sampling grid.""" + + def probe(self, path, context): + del path, context + self.probe_calls += 1 + return FakeSourceInfo(duration=Fraction(3)) + + def test_rebuild_never_probes_hashes_decodes_or_segments_source(tmp_path) -> None: workspace = FilesystemWorkspacePort() segmenter = FakeSegmenter() @@ -369,3 +389,244 @@ def encode(self, frame_paths, delays_ms, destination, **kwargs): assert not ( rebuilt_output / ".matteloop-work" / "scratch" / "cross-output-failure" ).exists() + + +def _rebuild_service(workspace: FilesystemWorkspacePort, encoder) -> RenderService: + return RenderService( + source=ExplodingSource(), + segmentation=binding(FakeSegmenter()), + workspace=workspace, + encoder=encoder, + disk_probe=FakeDiskProbe(), + clock=FakeClock(), + output_publisher=AtomicOutputPublisher(), + ) + + +def test_identity_transform_rebuild_is_byte_identical(tmp_path) -> None: + """AC 1: an explicit identity ``TransformSpec`` and the ``RenderRequest`` + default must reach the encoder with the same paths and delays and produce + a byte-identical WebP.""" + workspace = FilesystemWorkspacePort() + seed_request = request(tmp_path) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-identity", JobKind.RENDER) + ) + + explicit_encoder = FakeEncoder() + _rebuild_service(workspace, explicit_encoder).rebuild( + replace( + seed_request, + rebuild=True, + transform=TransformSpec(), + output=replace(seed_request.output, filename="explicit.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-explicit", JobKind.REBUILD), + ) + + default_encoder = FakeEncoder() + _rebuild_service(workspace, default_encoder).rebuild( + replace( + seed_request, + rebuild=True, + output=replace(seed_request.output, filename="default.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-default", JobKind.REBUILD), + ) + + explicit_bytes = (tmp_path / "explicit.webp").read_bytes() + default_bytes = (tmp_path / "default.webp").read_bytes() + assert explicit_bytes == default_bytes + + explicit_paths, explicit_delays, _ = explicit_encoder.calls[0] + default_paths, default_delays, _ = default_encoder.calls[0] + assert [path.name for path in explicit_paths] == [ + path.name for path in default_paths + ] + assert explicit_delays == default_delays + + +def test_trim_keeps_exactly_the_selected_frames_and_their_delays(tmp_path) -> None: + """AC 2 / E2 / T7: the kept delays are a *slice* of the full non-uniform + grid, not a recomputation over the kept count.""" + workspace = FilesystemWorkspacePort() + seed_request = request( + tmp_path, sampling=SamplingSpec(Fraction(0), Fraction(8, 3), 3) + ) + original = render_service(source=_LongerSource(), workspace=workspace).render( + seed_request, job(tmp_path, "seed-trim", JobKind.RENDER) + ) + full_delays = webp_delays(8, 3) + expected = full_delays[2:6] + # The regression this test guards against: a recomputed grid over just + # the kept count would sum to the same total but not match term-by-term. + assert expected != webp_delays(4, 3) + assert sum(expected) == sum(webp_delays(4, 3)) + + rebuild_encoder = FakeEncoder() + artifact = _rebuild_service(workspace, rebuild_encoder).rebuild( + replace( + seed_request, + rebuild=True, + transform=TransformSpec(first_frame=2, last_frame=5), + output=replace(seed_request.output, filename="trimmed.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-trim", JobKind.REBUILD), + ) + + assert artifact.frame_count == 4 + assert artifact.delays_ms == expected + assert rebuild_encoder.calls[0][1] == expected + info = validate_webp( + artifact.output_path, expected_frames=4, expected_duration_ms=sum(expected) + ) + assert info.frames == 4 + assert info.delays_ms == expected + + +@pytest.mark.parametrize( + ("transform", "fragment"), + [ + (TransformSpec(first_frame=8), "first_frame 8 exceeds last_frame 7"), + ( + TransformSpec(last_frame=9), + "last_frame 9 exceeds the last stored frame 7", + ), + ], + ids=["fewer-than-one-frame", "last-frame-beyond-cut"], +) +def test_trim_outside_the_cut_is_rejected_before_any_file_is_written( + tmp_path, transform: TransformSpec, fragment: str +) -> None: + """AC 3 / E3: an out-of-bounds trim raises INVALID_TRANSFORM before the + framed-inputs directory is created, and the old output is untouched.""" + workspace = FilesystemWorkspacePort() + seed_request = request( + tmp_path, sampling=SamplingSpec(Fraction(0), Fraction(8, 3), 3) + ) + original = render_service(source=_LongerSource(), workspace=workspace).render( + seed_request, job(tmp_path, "seed-bad-trim", JobKind.RENDER) + ) + before = original.output_path.read_bytes() + + with pytest.raises(ValidationError) as exc: + _rebuild_service(workspace, FakeEncoder()).rebuild( + replace(seed_request, rebuild=True, transform=transform), + original.cut_workspace, + job( + tmp_path, + f"rebuild-bad-trim-{transform.first_frame}-{transform.last_frame}", + JobKind.REBUILD, + ), + ) + + assert exc.value.code is ErrorCode.INVALID_TRANSFORM + assert fragment in str(exc.value) + assert original.output_path.read_bytes() == before + assert not list(tmp_path.rglob("framed-inputs")) + + +def test_crop_and_resize_change_output_dimensions(tmp_path) -> None: + """Crop then resize (stretch) produces the resolved final canvas size.""" + workspace = FilesystemWorkspacePort() + seed_request = request(tmp_path) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-crop-resize", JobKind.RENDER) + ) + + artifact = _rebuild_service(workspace, FakeEncoder()).rebuild( + replace( + seed_request, + rebuild=True, + transform=TransformSpec( + crop=CropSpec(0, 0, 64, 64), + resize=ResizeSpec(width=256, height=128, mismatch=MismatchMode.STRETCH), + ), + ), + original.cut_workspace, + job(tmp_path, "rebuild-crop-resize", JobKind.REBUILD), + ) + + assert (artifact.width, artifact.height) == (256, 128) + info = validate_webp( + artifact.output_path, + expected_frames=artifact.frame_count, + expected_duration_ms=sum(artifact.delays_ms), + ) + assert (info.width, info.height) == (256, 128) + + +def test_transform_trim_does_not_affect_the_framed_size(tmp_path) -> None: + """E15: the framed size (and the union it is derived from) is computed + over every stored frame, independent of the transform's trim.""" + workspace = FilesystemWorkspacePort() + seed_request = replace( + request(tmp_path), + framing=FramingSpec(True, Decimal("2"), 64, Decimal("1")), + ) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-trim-union", JobKind.RENDER) + ) + + full_range = _rebuild_service(workspace, FakeEncoder()).rebuild( + replace( + seed_request, + rebuild=True, + output=replace(seed_request.output, filename="full.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-full-range", JobKind.REBUILD), + ) + trimmed = _rebuild_service(workspace, FakeEncoder()).rebuild( + replace( + seed_request, + rebuild=True, + transform=TransformSpec(first_frame=0, last_frame=0), + output=replace(seed_request.output, filename="trimmed.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-trimmed-range", JobKind.REBUILD), + ) + + assert (trimmed.width, trimmed.height) == (full_range.width, full_range.height) + + +def test_transforms_never_touch_stored_cut_frames(tmp_path) -> None: + """AC 7 / E10: any number of transforms and rebuilds leave the stored + cut PNGs' sha256 (per the manifest) unchanged.""" + workspace = FilesystemWorkspacePort() + seed_request = request(tmp_path) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-sha", JobKind.RENDER) + ) + before_hashes = tuple( + frame.sha256 for frame in workspace.validate(original.cut_workspace).frames + ) + + transforms = ( + TransformSpec(), + TransformSpec(first_frame=1), + TransformSpec( + crop=CropSpec(0, 0, 64, 64), + resize=ResizeSpec(width=200, height=150, mismatch=MismatchMode.PAD), + ), + ) + for index, transform in enumerate(transforms): + _rebuild_service(workspace, FakeEncoder()).rebuild( + replace( + seed_request, + rebuild=True, + transform=transform, + output=replace(seed_request.output, filename=f"out-{index}.webp"), + ), + original.cut_workspace, + job(tmp_path, f"rebuild-sha-{index}", JobKind.REBUILD), + ) + + after_hashes = tuple( + frame.sha256 for frame in workspace.validate(original.cut_workspace).frames + ) + assert after_hashes == before_hashes From f08ed0e7484915103d11aa993b1150b59c6a86a0 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:44:36 +0200 Subject: [PATCH 08/45] test: cover the auto-fit interplay with a resize transform (issue #25 task A6) Adds test_auto_fit_shrinks_a_resized_output_and_reports_actual_size: a rebuild whose transform resizes to 512x256 with output.max_bytes set low enough that the real PillowWebPEncoder must shrink the lossless WebP below the requested canvas. The stored cut frame is overwritten with random noise first, since the fixture's flat-colour frames would already fit the byte budget at the requested size and never exercise the fit loop. No production code changes: A5's stage_encoder_frames already resolves the transform's final size before encoding, and jobs/encoding.py's auto_fit_webp (via core/webp.py's fit_webp_to_size, frozen) already registers each shrink candidate's size with the RgbaOwnershipTracker via include_size, so the auto-fit path needed no changes of its own to report the smaller actual dimensions (guardrail G4, degrade never refuse). Co-Authored-By: Claude Opus 5 (1M context) --- tests/jobs/test_rebuild.py | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/jobs/test_rebuild.py b/tests/jobs/test_rebuild.py index 69ad89a..4932aec 100644 --- a/tests/jobs/test_rebuild.py +++ b/tests/jobs/test_rebuild.py @@ -4,6 +4,7 @@ from decimal import Decimal from fractions import Fraction +import numpy as np import pytest from PIL import Image @@ -12,6 +13,7 @@ CropSpec, FramingSpec, MismatchMode, + OutputSpec, ResizeSpec, SamplingSpec, TransformSpec, @@ -22,6 +24,7 @@ from matteloop.jobs.render import ( AtomicOutputPublisher, FilesystemWorkspacePort, + PillowWebPEncoder, PreparedSegmentation, RenderService, ) @@ -630,3 +633,51 @@ def test_transforms_never_touch_stored_cut_frames(tmp_path) -> None: frame.sha256 for frame in workspace.validate(original.cut_workspace).frames ) assert after_hashes == before_hashes + + +def test_auto_fit_shrinks_a_resized_output_and_reports_actual_size(tmp_path) -> None: + """AC 6 / E9 (guardrail G4, degrade never refuse): a resize that exceeds + ``max_bytes`` still succeeds; the summary reports the smaller actual + dimensions, not the requested ones.""" + workspace = FilesystemWorkspacePort() + seed_request = request(tmp_path, sampling=SamplingSpec(Fraction(0), Fraction(1), 1)) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-autofit", JobKind.RENDER) + ) + rng = np.random.default_rng(1234) + noise = rng.integers(0, 256, size=(128, 128, 4), dtype=np.uint8) + noise[:, :, 3] = 255 + Image.fromarray(noise, mode="RGBA").save( + original.cut_workspace.path / "frame-000000.png" + ) + + artifact = RenderService( + source=ExplodingSource(), + segmentation=binding(FakeSegmenter()), + workspace=workspace, + encoder=PillowWebPEncoder(), + disk_probe=FakeDiskProbe(), + clock=FakeClock(), + output_publisher=AtomicOutputPublisher(), + ).rebuild( + replace( + seed_request, + rebuild=True, + transform=TransformSpec( + resize=ResizeSpec(width=512, height=256, mismatch=MismatchMode.STRETCH) + ), + output=OutputSpec( + tmp_path, + "fit.webp", + max_bytes=150_000, + collision_policy=seed_request.output.collision_policy, + ), + ), + original.cut_workspace, + job(tmp_path, "rebuild-autofit", JobKind.REBUILD), + ) + + assert artifact.output_path.exists() + assert artifact.file_size <= 150_000 + assert artifact.width <= 512 and artifact.height <= 256 + assert artifact.width < 512 or artifact.height < 256 From c7ec35968bd82230e0a9e0c6c6c4db507b0d0652 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:54:02 +0200 Subject: [PATCH 09/45] feat: add the sidecar that persists a cut's applied transform (issue #25 task A7) jobs/transform_store.py stores a TransformSpec as a dot-prefixed JSON sidecar beside the cut directory (cuts_root/.transform-.json), per design decision D1: the manifest rejects unknown keys and versions and is frozen, and a sidecar inside the cut directory would invalidate the whole cut set for the picker. load_transform degrades to identity plus a note on a missing, corrupt, wrong-schema or foreign-cache-key sidecar (guardrail G4); store_transform removes the sidecar for an identity spec and otherwise writes it via temp-sibling -> fsync -> atomic rename -> best-effort parent fsync (guardrail G3 ceiling), swallowing OSError into a note instead of raising. tests/jobs/test_transform_store.py covers the round trip, the four degrade-to-identity cases, sidecar removal on an identity store, a silent discard_transform on a missing file, and directly asserts the D1 premise: a cut with a sidecar beside it still appears in list_workspaces and still passes validate_cut_set. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/transform_store.py | 220 ++++++++++++++++++++++++++ tests/jobs/test_transform_store.py | 144 +++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/matteloop/jobs/transform_store.py create mode 100644 tests/jobs/test_transform_store.py diff --git a/src/matteloop/jobs/transform_store.py b/src/matteloop/jobs/transform_store.py new file mode 100644 index 0000000..6c5768b --- /dev/null +++ b/src/matteloop/jobs/transform_store.py @@ -0,0 +1,220 @@ +"""Persist the transform applied to a cut, beside the cut directory. + +Design decision D1 (issue #25): the transform cannot live in the manifest +(``_manifest.py`` rejects unknown keys and other schema versions, and the +manifest modules are frozen) and cannot live inside the cut directory either +(``_scan.py`` invalidates any cut with an unexpected entry). It is instead a +dot-prefixed sidecar next to the cut directory, +``cuts_root / f".transform-{cache_key}.json"`` -- a name every scanner and +recovery routine in ``jobs/workspace`` already ignores. + +Reading and writing degrade rather than fail (guardrail G4): a missing, +corrupt, wrong-schema, or foreign-cache-key sidecar is not something a user +can fix from the UI, so ``load_transform`` returns identity plus a note +instead of raising, and ``store_transform`` swallows ``OSError`` the same +way. Writing follows the repository's durability ceiling and nothing more +(guardrail G3): a temp sibling is fsynced, renamed into place atomically, and +the parent directory is fsynced best-effort -- no locks, no journals, no +inode binding. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Final + +from matteloop.core.errors import ValidationError +from matteloop.core.specs import CropSpec, MismatchMode, ResizeSpec, TransformSpec +from matteloop.jobs.workspace import CutWorkspace + +TRANSFORM_SIDECAR_SCHEMA: Final = "matteloop-cut-transform" +TRANSFORM_SIDECAR_VERSION: Final = 1 + + +def transform_sidecar_path(workspace: CutWorkspace) -> Path: + """Return the sidecar path for *workspace* -- never inside the cut itself.""" + return workspace.cuts_root / f".transform-{workspace.cache_key}.json" + + +def transform_to_payload(spec: TransformSpec) -> dict[str, object]: + """Convert *spec* to its JSON-serialisable ``"transform"`` payload.""" + return { + "first_frame": spec.first_frame, + "last_frame": spec.last_frame, + "crop": _crop_to_payload(spec.crop), + "resize": _resize_to_payload(spec.resize), + } + + +def _crop_to_payload(crop: CropSpec | None) -> dict[str, int] | None: + if crop is None: + return None + return {"x": crop.x, "y": crop.y, "width": crop.width, "height": crop.height} + + +def _resize_to_payload(resize: ResizeSpec | None) -> dict[str, object] | None: + if resize is None: + return None + return { + "width": resize.width, + "height": resize.height, + "mismatch": resize.mismatch.value, + } + + +def transform_from_payload(payload: object) -> TransformSpec: + """Rebuild a ``TransformSpec`` from a stored ``"transform"`` payload. + + Raises ``ValueError`` for any structural or type deviation -- the sole + exception ``load_transform`` needs to catch before falling back to + identity. + """ + if not isinstance(payload, dict): + raise ValueError("transform payload must be an object") + try: + return TransformSpec( + first_frame=payload["first_frame"], + last_frame=payload["last_frame"], + crop=_crop_from_payload(payload.get("crop")), + resize=_resize_from_payload(payload.get("resize")), + ) + except (KeyError, TypeError, ValidationError) as error: + raise ValueError(f"invalid transform payload: {error}") from error + + +def _crop_from_payload(payload: object) -> CropSpec | None: + if payload is None: + return None + if not isinstance(payload, dict): + raise ValueError("crop must be an object or null") + return CropSpec( + x=payload["x"], + y=payload["y"], + width=payload["width"], + height=payload["height"], + ) + + +def _resize_from_payload(payload: object) -> ResizeSpec | None: + if payload is None: + return None + if not isinstance(payload, dict): + raise ValueError("resize must be an object or null") + return ResizeSpec( + width=payload["width"], + height=payload["height"], + mismatch=MismatchMode(payload["mismatch"]), + ) + + +def _note(notes: list[str] | None, message: str) -> None: + if notes is not None: + notes.append(message) + + +def load_transform( + workspace: CutWorkspace, notes: list[str] | None = None +) -> TransformSpec: + """Return the transform stored for *workspace*, or identity plus a note. + + A missing sidecar is not a failure -- it means no transform was ever + applied, so it is silent. Anything else wrong with the sidecar (corrupt + JSON, an unrecognised schema, or one written for a different cache key) + degrades to identity with a note rather than raising. + """ + path = transform_sidecar_path(workspace) + try: + raw = path.read_bytes() + except FileNotFoundError: + return TransformSpec() + except OSError as error: + _note(notes, f"stored transform could not be read: {error}") + return TransformSpec() + try: + payload = json.loads(raw) + except ValueError as error: + _note(notes, f"stored transform is corrupt: {error}") + return TransformSpec() + if ( + not isinstance(payload, dict) + or payload.get("schema") != TRANSFORM_SIDECAR_SCHEMA + or payload.get("schema_version") != TRANSFORM_SIDECAR_VERSION + or payload.get("cache_key") != workspace.cache_key + ): + _note(notes, "stored transform does not match this cut; using identity") + return TransformSpec() + try: + return transform_from_payload(payload.get("transform")) + except ValueError as error: + _note(notes, f"stored transform is invalid: {error}") + return TransformSpec() + + +def store_transform( + workspace: CutWorkspace, spec: TransformSpec, notes: list[str] +) -> None: + """Persist *spec* beside the cut directory, or remove it for identity. + + Swallows ``OSError`` and appends a note instead: a corrupt or unwritable + sidecar must never fail the job whose output has already been published + (guardrail G4). + """ + if spec.is_identity: + discard_transform(workspace) + return + payload = { + "schema": TRANSFORM_SIDECAR_SCHEMA, + "schema_version": TRANSFORM_SIDECAR_VERSION, + "cache_key": workspace.cache_key, + "transform": transform_to_payload(spec), + } + encoded = json.dumps(payload, sort_keys=True, allow_nan=False).encode("utf-8") + try: + _write_sidecar(transform_sidecar_path(workspace), encoded) + except OSError as error: + notes.append(f"could not record the applied transform: {error}") + + +def _write_sidecar(path: Path, encoded: bytes) -> None: + """Temp sibling -> fsync -> atomic rename -> best-effort parent fsync.""" + descriptor, raw = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(raw) + try: + os.write(descriptor, encoded) + os.fsync(descriptor) + finally: + os.close(descriptor) + try: + os.replace(temporary, path) + except OSError: + temporary.unlink(missing_ok=True) + raise + _fsync_parent(path) + + +def _fsync_parent(path: Path) -> None: + """Best-effort directory fsync (guardrail G3); a silent no-op where the + platform will not open a directory for reading (Windows).""" + try: + descriptor = os.open(path.parent, os.O_RDONLY) + except OSError: + return + try: + os.fsync(descriptor) + except OSError: + pass + finally: + os.close(descriptor) + + +def discard_transform(workspace: CutWorkspace) -> None: + """Remove the sidecar if present. Best effort -- never raises.""" + try: + transform_sidecar_path(workspace).unlink() + except OSError: + pass diff --git a/tests/jobs/test_transform_store.py b/tests/jobs/test_transform_store.py new file mode 100644 index 0000000..71e3976 --- /dev/null +++ b/tests/jobs/test_transform_store.py @@ -0,0 +1,144 @@ +"""The dot-prefixed sidecar that persists a cut's applied transform (D1).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from matteloop.core.specs import CropSpec, MismatchMode, ResizeSpec, TransformSpec +from matteloop.core.state import JobKind +from matteloop.jobs.render import FilesystemWorkspacePort +from matteloop.jobs.transform_store import ( + TRANSFORM_SIDECAR_SCHEMA, + TRANSFORM_SIDECAR_VERSION, + discard_transform, + load_transform, + store_transform, + transform_sidecar_path, +) +from matteloop.jobs.workspace import list_workspaces, validate_cut_set +from tests.jobs.render_support import job, render_service, request + + +def _seeded_cut(tmp_path: Path): + workspace = FilesystemWorkspacePort() + original = render_service(workspace=workspace).render( + request(tmp_path), job(tmp_path, "seed-transform-store", JobKind.RENDER) + ) + return original.cut_workspace + + +def test_round_trip_through_the_sidecar(tmp_path: Path) -> None: + workspace = _seeded_cut(tmp_path) + spec = TransformSpec( + first_frame=1, + crop=CropSpec(0, 0, 64, 64), + resize=ResizeSpec(width=200, height=150, mismatch=MismatchMode.PAD), + ) + notes: list[str] = [] + + store_transform(workspace, spec, notes) + + assert notes == [] + path = transform_sidecar_path(workspace) + assert path.exists() + assert path.parent == workspace.cuts_root + assert load_transform(workspace) == spec + + +def test_missing_sidecar_is_identity(tmp_path: Path) -> None: + workspace = _seeded_cut(tmp_path) + + assert load_transform(workspace) == TransformSpec() + + +def test_corrupt_json_falls_back_to_identity_with_a_note(tmp_path: Path) -> None: + workspace = _seeded_cut(tmp_path) + transform_sidecar_path(workspace).write_text("{not json", encoding="utf-8") + notes: list[str] = [] + + result = load_transform(workspace, notes) + + assert result == TransformSpec() + assert len(notes) == 1 + assert "corrupt" in notes[0] + + +def test_wrong_schema_version_falls_back_to_identity_with_a_note( + tmp_path: Path, +) -> None: + workspace = _seeded_cut(tmp_path) + payload = { + "schema": TRANSFORM_SIDECAR_SCHEMA, + "schema_version": TRANSFORM_SIDECAR_VERSION + 1, + "cache_key": workspace.cache_key, + "transform": { + "first_frame": 0, + "last_frame": None, + "crop": None, + "resize": None, + }, + } + transform_sidecar_path(workspace).write_text(json.dumps(payload), encoding="utf-8") + notes: list[str] = [] + + result = load_transform(workspace, notes) + + assert result == TransformSpec() + assert len(notes) == 1 + + +def test_wrong_cache_key_falls_back_to_identity_with_a_note(tmp_path: Path) -> None: + workspace = _seeded_cut(tmp_path) + payload = { + "schema": TRANSFORM_SIDECAR_SCHEMA, + "schema_version": TRANSFORM_SIDECAR_VERSION, + "cache_key": "f" * 64, + "transform": { + "first_frame": 1, + "last_frame": None, + "crop": None, + "resize": None, + }, + } + transform_sidecar_path(workspace).write_text(json.dumps(payload), encoding="utf-8") + notes: list[str] = [] + + result = load_transform(workspace, notes) + + assert result == TransformSpec() + assert len(notes) == 1 + + +def test_identity_store_removes_an_existing_sidecar(tmp_path: Path) -> None: + workspace = _seeded_cut(tmp_path) + store_transform(workspace, TransformSpec(first_frame=1), []) + assert transform_sidecar_path(workspace).exists() + + store_transform(workspace, TransformSpec(), []) + + assert not transform_sidecar_path(workspace).exists() + + +def test_discard_transform_on_a_missing_file_is_silent(tmp_path: Path) -> None: + workspace = _seeded_cut(tmp_path) + assert not transform_sidecar_path(workspace).exists() + + discard_transform(workspace) # must not raise + + assert not transform_sidecar_path(workspace).exists() + + +def test_sidecar_beside_the_cut_does_not_break_listing_or_validation( + tmp_path: Path, +) -> None: + """The whole premise of D1: a sidecar next to the cut directory must not + make the cut disappear from the picker or fail cut-set validation.""" + workspace = _seeded_cut(tmp_path) + store_transform(workspace, TransformSpec(first_frame=1), []) + + listing = list_workspaces(tmp_path) + assert [entry.workspace.cache_key for entry in listing.entries] == [ + workspace.cache_key + ] + validate_cut_set(workspace) # must not raise From a3cda2c3797d74ab42e021433a0d6fb655bc0293 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:59:22 +0200 Subject: [PATCH 10/45] feat: record the applied transform when a cut is published (issue #25 task A8) _encode_snapshot now calls store_transform(durable, request.transform, notes) right after the post-publish scratch cleanup and before the RenderArtifact's notes tuple is built. That placement matters: the tuple is baked in a few lines later, so a note appended after the call (e.g. calling store_transform from render()/rebuild() instead) would never reach the artifact, and calling it earlier would write the sidecar even when publication never happened. Both render() and rebuild() reach this single call site through _encode_snapshot, so a first render with a transform records it exactly like a rebuild does. tests/jobs/test_rebuild.py adds: a rebuild's stored transform round-trips through load_transform; a subsequent identity rebuild removes the sidecar; a plain render (not just rebuild) with a transform records it too; and, skipped on Windows because chmod cannot make a directory read-only there, an unwritable cuts_root adds a note to the artifact while the job still publishes. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/render.py | 2 + tests/jobs/test_rebuild.py | 112 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/src/matteloop/jobs/render.py b/src/matteloop/jobs/render.py index 8fa18bf..a039fc3 100644 --- a/src/matteloop/jobs/render.py +++ b/src/matteloop/jobs/render.py @@ -79,6 +79,7 @@ from matteloop.jobs.protocol import PROTOCOL_VERSION, SegmentOptions, SegmentRequest from matteloop.jobs.source import DecodedFrame, SourceInfo, decode_frame, probe_source from matteloop.jobs.transform_stage import framing_plan, stage_encoder_frames +from matteloop.jobs.transform_store import store_transform from matteloop.jobs.workspace import ( AdvisoryFileLock, CutFrame, @@ -1422,6 +1423,7 @@ def _encode_snapshot( None, notes, ) + store_transform(durable, request.transform, notes) assert summary is not None return RenderArtifact( output_path, diff --git a/tests/jobs/test_rebuild.py b/tests/jobs/test_rebuild.py index 4932aec..137da4c 100644 --- a/tests/jobs/test_rebuild.py +++ b/tests/jobs/test_rebuild.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import stat from dataclasses import replace from decimal import Decimal from fractions import Fraction @@ -28,6 +30,7 @@ PreparedSegmentation, RenderService, ) +from matteloop.jobs.transform_store import load_transform, transform_sidecar_path from tests.jobs.render_support import ( ExplodingSource, FakeClock, @@ -681,3 +684,112 @@ def test_auto_fit_shrinks_a_resized_output_and_reports_actual_size(tmp_path) -> assert artifact.file_size <= 150_000 assert artifact.width <= 512 and artifact.height <= 256 assert artifact.width < 512 or artifact.height < 256 + + +def test_rebuild_records_the_applied_transform_beside_the_cut(tmp_path) -> None: + """AC 8 / E11: a rebuild with a non-identity transform writes the sidecar, + and ``load_transform`` returns exactly the request's transform.""" + workspace = FilesystemWorkspacePort() + seed_request = request(tmp_path) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-record-transform", JobKind.RENDER) + ) + transform = TransformSpec(first_frame=0, last_frame=0) + + artifact = _rebuild_service(workspace, FakeEncoder()).rebuild( + replace(seed_request, rebuild=True, transform=transform), + original.cut_workspace, + job(tmp_path, "rebuild-record-transform", JobKind.REBUILD), + ) + + assert load_transform(artifact.cut_workspace) == transform + + +def test_identity_rebuild_after_a_recorded_transform_removes_the_sidecar( + tmp_path, +) -> None: + """E12: an identity rebuild after a non-identity one removes the sidecar, + so reopening the cut afterwards loads as identity.""" + workspace = FilesystemWorkspacePort() + seed_request = request(tmp_path) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-clear-transform", JobKind.RENDER) + ) + non_identity = _rebuild_service(workspace, FakeEncoder()).rebuild( + replace( + seed_request, + rebuild=True, + transform=TransformSpec(first_frame=0, last_frame=0), + output=replace(seed_request.output, filename="non-identity.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-non-identity", JobKind.REBUILD), + ) + assert transform_sidecar_path(non_identity.cut_workspace).exists() + + artifact = _rebuild_service(workspace, FakeEncoder()).rebuild( + replace( + seed_request, + rebuild=True, + output=replace(seed_request.output, filename="identity-again.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-back-to-identity", JobKind.REBUILD), + ) + + assert not transform_sidecar_path(artifact.cut_workspace).exists() + assert load_transform(artifact.cut_workspace) == TransformSpec() + + +def test_render_with_a_transform_records_it_too(tmp_path) -> None: + """E18: a Render (not a Rebuild) with a transform also writes the sidecar, + via the same shared ``_encode_snapshot`` call site.""" + workspace = FilesystemWorkspacePort() + transform = TransformSpec(first_frame=0, last_frame=0) + + artifact = render_service(workspace=workspace).render( + request(tmp_path, transform=transform), + job(tmp_path, "render-with-transform", JobKind.RENDER), + ) + + assert load_transform(artifact.cut_workspace) == transform + + +@pytest.mark.skipif( + os.name == "nt", + reason="chmod cannot make a Windows directory read-only for this process", +) +def test_unwritable_cuts_root_notes_the_failure_and_still_publishes( + tmp_path, +) -> None: + """E13 / T1: an unwritable ``cuts_root`` must not fail the job -- the + output has already been published by the time the sidecar write is + attempted (guardrail G4). The note must reach the artifact, which is + only possible because ``store_transform`` runs inside ``_encode_snapshot`` + before its ``notes`` tuple is baked in.""" + workspace = FilesystemWorkspacePort() + seed_request = request(tmp_path) + original = render_service(workspace=workspace).render( + seed_request, job(tmp_path, "seed-unwritable-cuts-root", JobKind.RENDER) + ) + cuts_root = original.cut_workspace.cuts_root + mode = cuts_root.stat().st_mode + cuts_root.chmod(stat.S_IRUSR | stat.S_IXUSR) + try: + artifact = _rebuild_service(workspace, FakeEncoder()).rebuild( + replace( + seed_request, + rebuild=True, + transform=TransformSpec(first_frame=0, last_frame=0), + output=replace(seed_request.output, filename="unwritable.webp"), + ), + original.cut_workspace, + job(tmp_path, "rebuild-unwritable-cuts-root", JobKind.REBUILD), + ) + finally: + cuts_root.chmod(mode) + + assert artifact.output_path.exists() + assert any( + "could not record the applied transform" in note for note in artifact.notes + ) From 9f45311fcd445e5076f37acd8000dda1b3903f1d Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 01:59:29 +0200 Subject: [PATCH 11/45] docs: add the Transform row to the V1 scope table (issue #25 task A9) The transform stage (trim, crop, resize of a finished cut, re-encoded through Rebuild) landed as Stage A of issue #25's plan. Records the scope-reopen decision next to the existing Cuts row and names the reason persistence went to a sidecar rather than the manifest, matching the "Recorded design deviations" rationale already in this document. Co-Authored-By: Claude Opus 5 (1M context) --- docs/v1-scope.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/v1-scope.md b/docs/v1-scope.md index 1123f2a..d850f79 100644 --- a/docs/v1-scope.md +++ b/docs/v1-scope.md @@ -59,6 +59,7 @@ Nothing else gates V1. When that sentence is true end to end, V1 is done. | Accessibility | Full keyboard reachability, correct tab order, accessible names and values on **standard** widgets, no colour-only status. | | Persistence | `QSettings` primitives, window geometry, output directory. | | Cuts | Post-segmentation cuts are persisted, editable, selectable from a promoted cut-set picker, and reusable through Rebuild. **Scope reopened 2026-08-31:** re-rendering an edited clip otherwise repeats segmentation, including a 927 MiB model and 39 inferences, merely to re-encode. | +| Transform | A finished cut can be trimmed to a frame range, cropped, and resized (explicit dimensions or a 7TV preset, with a keep/stretch/cover/pad mismatch policy) and re-encoded through Rebuild, without touching the stored cut frames or re-running segmentation. **Scope reopened 2026-09-04 (issue #25):** the transform is remembered per cut in a sidecar beside the cut directory, not in the manifest — the manifest modules are frozen and reject unknown keys, and a sidecar written inside the cut directory would invalidate the whole set for the picker. | ### Recorded design deviations From ec124d03d7aeaf846996cf606f33ea7b20fada65 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 02:04:45 +0200 Subject: [PATCH 12/45] refactor: trim transform_store.py's docstrings to the 200-line budget The module contract in the transform-stage plan (section 4.4) caps this file at 200 lines; the initial version landed at 220 because several function docstrings repeated context already carried by the module docstring. Condenses the module docstring and four function docstrings to their essential points -- the guardrail G3/G4 references and the degrade-to-identity behaviour they describe are unchanged -- bringing the file to 197 lines with no change to any function's behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/transform_store.py | 51 ++++++++------------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/src/matteloop/jobs/transform_store.py b/src/matteloop/jobs/transform_store.py index 6c5768b..bfe8a88 100644 --- a/src/matteloop/jobs/transform_store.py +++ b/src/matteloop/jobs/transform_store.py @@ -1,21 +1,13 @@ -"""Persist the transform applied to a cut, beside the cut directory. +"""Persist the transform applied to a cut, beside the cut directory (D1). -Design decision D1 (issue #25): the transform cannot live in the manifest -(``_manifest.py`` rejects unknown keys and other schema versions, and the -manifest modules are frozen) and cannot live inside the cut directory either -(``_scan.py`` invalidates any cut with an unexpected entry). It is instead a -dot-prefixed sidecar next to the cut directory, +The manifest is frozen and rejects unknown keys/versions, and a sidecar +inside the cut directory would invalidate the whole set for the picker +(``_scan.py``), so this is a dot-prefixed sidecar next to it instead -- ``cuts_root / f".transform-{cache_key}.json"`` -- a name every scanner and -recovery routine in ``jobs/workspace`` already ignores. - -Reading and writing degrade rather than fail (guardrail G4): a missing, -corrupt, wrong-schema, or foreign-cache-key sidecar is not something a user -can fix from the UI, so ``load_transform`` returns identity plus a note -instead of raising, and ``store_transform`` swallows ``OSError`` the same -way. Writing follows the repository's durability ceiling and nothing more -(guardrail G3): a temp sibling is fsynced, renamed into place atomically, and -the parent directory is fsynced best-effort -- no locks, no journals, no -inode binding. +recovery routine in ``jobs/workspace`` already ignores. Reading and writing +degrade rather than fail (G4); writing follows the durability ceiling and +nothing more (G3): temp sibling -> fsync -> atomic rename -> best-effort +parent fsync, no locks, no journals, no inode binding. """ from __future__ import annotations @@ -66,12 +58,7 @@ def _resize_to_payload(resize: ResizeSpec | None) -> dict[str, object] | None: def transform_from_payload(payload: object) -> TransformSpec: - """Rebuild a ``TransformSpec`` from a stored ``"transform"`` payload. - - Raises ``ValueError`` for any structural or type deviation -- the sole - exception ``load_transform`` needs to catch before falling back to - identity. - """ + """Rebuild a ``TransformSpec``; raises ``ValueError`` on any deviation.""" if not isinstance(payload, dict): raise ValueError("transform payload must be an object") try: @@ -118,13 +105,8 @@ def _note(notes: list[str] | None, message: str) -> None: def load_transform( workspace: CutWorkspace, notes: list[str] | None = None ) -> TransformSpec: - """Return the transform stored for *workspace*, or identity plus a note. - - A missing sidecar is not a failure -- it means no transform was ever - applied, so it is silent. Anything else wrong with the sidecar (corrupt - JSON, an unrecognised schema, or one written for a different cache key) - degrades to identity with a note rather than raising. - """ + """Return the stored transform, or identity (+ a note if something was + wrong: missing is silent; corrupt, foreign, or mismatched is not).""" path = transform_sidecar_path(workspace) try: raw = path.read_bytes() @@ -156,12 +138,8 @@ def load_transform( def store_transform( workspace: CutWorkspace, spec: TransformSpec, notes: list[str] ) -> None: - """Persist *spec* beside the cut directory, or remove it for identity. - - Swallows ``OSError`` and appends a note instead: a corrupt or unwritable - sidecar must never fail the job whose output has already been published - (guardrail G4). - """ + """Persist *spec* beside the cut, or remove it for identity. Swallows + ``OSError`` into a note (G4): the job already published its output.""" if spec.is_identity: discard_transform(workspace) return @@ -198,8 +176,7 @@ def _write_sidecar(path: Path, encoded: bytes) -> None: def _fsync_parent(path: Path) -> None: - """Best-effort directory fsync (guardrail G3); a silent no-op where the - platform will not open a directory for reading (Windows).""" + """Best-effort directory fsync (G3); a no-op where unsupported (Windows).""" try: descriptor = os.open(path.parent, os.O_RDONLY) except OSError: From 45a1d336d81a2e62e5a436162b672958967918fb Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 02:17:11 +0200 Subject: [PATCH 13/45] feat: reduce TransformChanged in the parameter store (issue #25 task B1) TransformChanged joins ParameterEvent, replacing ParameterState.transform when the value actually differs. It reuses the top-level can_edit gate in reduce_parameters, so an edit while a job runs is a no-op (E19). The reducer never routes through _invalidate/PreviewInvalidated: a post-framing transform edit leaves an existing preview CURRENT (E20), unlike every other parameter change in this module. Confirmed parameters_from_values already yields an identity transform (the field is absent from the QSettings key list) and persist_parameters already writes no transform key; both got regression tests rather than code changes since a cut's transform is not a preference. --- src/matteloop/core/parameters.py | 20 +++++++++++++ tests/core/test_parameters.py | 41 +++++++++++++++++++++++++- tests/ui/test_parameter_persistence.py | 14 ++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/matteloop/core/parameters.py b/src/matteloop/core/parameters.py index efc23a5..e8d1134 100644 --- a/src/matteloop/core/parameters.py +++ b/src/matteloop/core/parameters.py @@ -143,6 +143,11 @@ class OutputMaxSizeChanged: value: Decimal +@dataclass(frozen=True, slots=True) +class TransformChanged: + transform: TransformSpec + + ParameterEvent = ( ModelChanged | EdgeModeChanged @@ -155,6 +160,7 @@ class OutputMaxSizeChanged: | OutputDirectoryChanged | OutputFilenameChanged | OutputMaxSizeChanged + | TransformChanged ) @@ -186,6 +192,8 @@ def reduce_parameters(state: AppState, event: ParameterEvent) -> AppState: return _reduce_output_filename(state, event) if isinstance(event, OutputMaxSizeChanged): return _reduce_output_max_size(state, event) + if isinstance(event, TransformChanged): + return _reduce_transform(state, event) return state @@ -320,6 +328,18 @@ def _reduce_output_max_size( return replace(state, parameters=updated) +def _reduce_transform(state: AppState, event: TransformChanged) -> AppState: + """Replace the transform in place; a transform never stales the preview.""" + if ( + not isinstance(event.transform, TransformSpec) + or event.transform == state.parameters.transform + ): + return state + return replace( + state, parameters=replace(state.parameters, transform=event.transform) + ) + + def _invalidate( state: AppState, parameters: ParameterState, category: str ) -> AppState: diff --git a/tests/core/test_parameters.py b/tests/core/test_parameters.py index bcc2292..8f3752f 100644 --- a/tests/core/test_parameters.py +++ b/tests/core/test_parameters.py @@ -21,15 +21,17 @@ PaddingChanged, ParameterState, StretchChanged, + TransformChanged, parameters_from_values, ) -from matteloop.core.specs import EdgeMode +from matteloop.core.specs import EdgeMode, TransformSpec from matteloop.core.state import ( AppState, PreviewRequested, PreviewResult, PreviewState, PreviewSucceeded, + RenderRequested, SourceLoaded, SourceLoadRequested, reduce, @@ -174,6 +176,43 @@ def test_invalid_parameter_events_leave_reducer_state_unchanged() -> None: assert reduce(state, event) is state +def test_transform_changed_replaces_transform_without_invalidating_preview() -> None: + state = _current() + transform = TransformSpec(first_frame=1, last_frame=3) + + changed = reduce(state, TransformChanged(transform)) + + assert changed.parameters.transform == transform + assert changed.preview is PreviewState.CURRENT + assert changed.stale_category is None + + +def test_transform_changed_is_ignored_when_the_value_is_unchanged() -> None: + state = _current() + + assert reduce(state, TransformChanged(TransformSpec())) is state + + +def test_transform_changed_is_ignored_while_a_job_runs() -> None: + running = reduce(_ready(), RenderRequested("job", "req")) + + assert running.job.phase.value != "idle" + assert ( + reduce(running, TransformChanged(TransformSpec(first_frame=2))) is running + ) + + +def test_parameter_state_default_transform_is_identity() -> None: + assert ParameterState().transform == TransformSpec() + assert ParameterState().transform.is_identity + + +def test_parameters_from_values_yields_an_identity_transform() -> None: + parameters = parameters_from_values({"fps": "60"}) + + assert parameters.transform == TransformSpec() + + def test_invalid_saved_values_fall_back_independently() -> None: parameters = parameters_from_values( { diff --git a/tests/ui/test_parameter_persistence.py b/tests/ui/test_parameter_persistence.py index 3115115..d31c336 100644 --- a/tests/ui/test_parameter_persistence.py +++ b/tests/ui/test_parameter_persistence.py @@ -11,7 +11,7 @@ ProviderOption, ) from matteloop.core.parameters import OutputFilenameChanged, ParameterState -from matteloop.core.specs import EdgeMode +from matteloop.core.specs import CropSpec, EdgeMode, TransformSpec from matteloop.core.state import AppState, SourceState from matteloop.ui.controller import SourceController from matteloop.ui.preferences import load_parameters, persist_parameters @@ -57,6 +57,18 @@ def test_parameter_preferences_round_trip_as_qsettings_primitives() -> None: assert actual == expected +def test_persist_parameters_writes_no_transform_key() -> None: + settings = _settings() + non_identity = ParameterState( + transform=TransformSpec(first_frame=1, crop=CropSpec(0, 0, 4, 4)) + ) + + persist_parameters(settings, non_identity) + + assert settings.value("parameters/transform") is None + assert "transform" not in settings.allKeys() + + def test_unavailable_saved_provider_falls_back_to_the_available_cpu_choice() -> None: settings = _settings() persist_parameters( From 928cbdb1bfa4b1c57e041d9bbeaec5324c46525e Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 02:17:17 +0200 Subject: [PATCH 14/45] feat: forward the transform into preview, render and rebuild requests (issue #25 task B2) _render_request (ui/request_builder.py, shared by preview and render paths) now sets RenderRequest.transform from ParameterState.transform instead of leaving it at the RenderRequest default. request_for_workspace (ui/workspace_presentation.py) forwards base.transform through "Use this set" rebuilds, alongside the framing and output settings it already carries over from the current inspector state. New tests/ui/test_workspace_presentation.py covers request_for_workspace directly with a duck-typed manifest, since no prior test exercised it. --- src/matteloop/ui/request_builder.py | 1 + src/matteloop/ui/workspace_presentation.py | 1 + tests/ui/test_parameter_requests.py | 5 +- tests/ui/test_workspace_presentation.py | 79 ++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/ui/test_workspace_presentation.py diff --git a/src/matteloop/ui/request_builder.py b/src/matteloop/ui/request_builder.py index 190b2d2..1ad5ee2 100644 --- a/src/matteloop/ui/request_builder.py +++ b/src/matteloop/ui/request_builder.py @@ -108,4 +108,5 @@ def _render_request( parameters.max_mib, collision_policy=collision_policy, ), + transform=parameters.transform, ) diff --git a/src/matteloop/ui/workspace_presentation.py b/src/matteloop/ui/workspace_presentation.py index 9b476cd..1ac2689 100644 --- a/src/matteloop/ui/workspace_presentation.py +++ b/src/matteloop/ui/workspace_presentation.py @@ -111,6 +111,7 @@ def request_for_workspace( framing=base.framing, output=base.output, rebuild=True, + transform=base.transform, ) diff --git a/tests/ui/test_parameter_requests.py b/tests/ui/test_parameter_requests.py index 9f39e0b..21c0351 100644 --- a/tests/ui/test_parameter_requests.py +++ b/tests/ui/test_parameter_requests.py @@ -7,7 +7,7 @@ from matteloop.core.execution_providers import CUDA_EXECUTION_PROVIDER from matteloop.core.parameters import ParameterState -from matteloop.core.specs import EdgeMode +from matteloop.core.specs import CropSpec, EdgeMode, TransformSpec from matteloop.core.timeline import TimelineState from matteloop.ui.preview_controller import _preview_inputs, _render_request @@ -22,6 +22,7 @@ class Metadata: def test_preview_and_render_requests_share_every_inspector_parameter() -> None: source = Path("/clips/holiday.mp4") + transform = TransformSpec(first_frame=1, last_frame=3, crop=CropSpec(0, 0, 4, 4)) parameters = ParameterState( model_id="u2net", edge_mode=EdgeMode.DECONTAMINATE_COLORS, @@ -34,6 +35,7 @@ def test_preview_and_render_requests_share_every_inspector_parameter() -> None: output_directory=Path("/exports"), output_filename="holiday-cut.webp", max_mib=Decimal("12.5"), + transform=transform, ) timeline = TimelineState( Fraction(4), Fraction(1, 2), Fraction(7, 2), Fraction(1), fps=120 @@ -52,6 +54,7 @@ def test_preview_and_render_requests_share_every_inspector_parameter() -> None: assert request.framing.stretch_x == parameters.stretch_x assert request.output.path == Path("/exports/holiday-cut.webp") assert request.output.max_bytes == int(Decimal("12.5") * 1024 * 1024) + assert request.transform == transform def timeline_to_sampling(timeline: TimelineState): diff --git a/tests/ui/test_workspace_presentation.py b/tests/ui/test_workspace_presentation.py new file mode 100644 index 0000000..ec856f8 --- /dev/null +++ b/tests/ui/test_workspace_presentation.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path + +from matteloop.core.specs import ( + CollisionPolicy, + CropSpec, + FramingSpec, + OutputSpec, + RenderRequest, + SamplingSpec, + SegmentationSpec, + TransformSpec, +) +from matteloop.ui.workspace_presentation import request_for_workspace + + +@dataclass(frozen=True) +class _FakeManifest: + """Duck-typed stand-in exposing only what request_for_workspace reads.""" + + cache_key_inputs: dict[str, object] + source_path: str + + +def _manifest() -> _FakeManifest: + return _FakeManifest( + cache_key_inputs={ + "sampling": { + "start": {"numerator": 0, "denominator": 1}, + "end": {"numerator": 4, "denominator": 1}, + "fps": 15, + }, + "crop": {"x": 0, "y": 0, "width": 640, "height": 360}, + "model": {"id": "u2net"}, + "edge_settings": { + "mode": "standard", + "alpha_matting": { + "foreground_threshold": 240, + "background_threshold": 10, + "erode_size": 10, + }, + }, + }, + source_path="/clips/holiday.mp4", + ) + + +def _base_request(transform: TransformSpec) -> RenderRequest: + return RenderRequest( + source=Path("/clips/holiday.mp4"), + sampling=SamplingSpec(), + crop=CropSpec(0, 0, 640, 360), + segmentation=SegmentationSpec(), + framing=FramingSpec(), + output=OutputSpec.from_mib( + Path("/exports"), "holiday.webp", Decimal(0), CollisionPolicy.CANCEL + ), + transform=transform, + ) + + +def test_request_for_workspace_forwards_the_base_transform() -> None: + transform = TransformSpec(first_frame=2, last_frame=5) + base = _base_request(transform) + + request = request_for_workspace(_manifest(), base) # type: ignore[arg-type] + + assert request.transform == transform + + +def test_request_for_workspace_defaults_to_identity_when_base_is_identity() -> None: + base = _base_request(TransformSpec()) + + request = request_for_workspace(_manifest(), base) # type: ignore[arg-type] + + assert request.transform == TransformSpec() From 34e96c63be088324b77946cf4d9487c5890c9413 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 02:19:07 +0200 Subject: [PATCH 15/45] feat: expose the transform and last artifact in ParameterPresentation (issue #25 task B3) present_parameters now fills transform from state.parameters.transform and artifact from state.artifact_result. Both fields default (identity transform, no artifact) so every existing keyword construction in tests stays valid without touching presenter.py. These reach the Transform group's readout: the transform for the "N of M frames" line, the artifact for the "rendered W'xH', S" suffix shown after a render or rebuild (AC 6/10). --- src/matteloop/ui/parameter_presentation.py | 10 ++++-- tests/ui/test_parameter_presentation.py | 37 +++++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/matteloop/ui/parameter_presentation.py b/src/matteloop/ui/parameter_presentation.py index 8f86cf1..2fe70ef 100644 --- a/src/matteloop/ui/parameter_presentation.py +++ b/src/matteloop/ui/parameter_presentation.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from decimal import Decimal from fractions import Fraction from pathlib import Path @@ -11,8 +11,8 @@ output_directory_for_source, output_filename_for_source, ) -from matteloop.core.specs import EdgeMode -from matteloop.core.state import AppState +from matteloop.core.specs import EdgeMode, TransformSpec +from matteloop.core.state import AppState, ArtifactResult @dataclass(frozen=True, slots=True) @@ -32,6 +32,8 @@ class ParameterPresentation: end: Fraction | None duration: Fraction | None source_duration: Fraction | None + transform: TransformSpec = field(default_factory=TransformSpec) + artifact: ArtifactResult | None = None def present_parameters(state: AppState) -> ParameterPresentation: @@ -66,6 +68,8 @@ def present_parameters(state: AppState) -> ParameterPresentation: end=timeline.end if timeline is not None else None, duration=timeline.end - timeline.start if timeline is not None else None, source_duration=timeline.duration if timeline is not None else None, + transform=parameters.transform, + artifact=state.artifact_result, ) diff --git a/tests/ui/test_parameter_presentation.py b/tests/ui/test_parameter_presentation.py index 58157e2..f7945f2 100644 --- a/tests/ui/test_parameter_presentation.py +++ b/tests/ui/test_parameter_presentation.py @@ -4,7 +4,17 @@ from fractions import Fraction from pathlib import Path -from matteloop.core.state import AppState, SourceLoaded, SourceLoadRequested, reduce +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import CropSpec, TransformSpec +from matteloop.core.state import ( + AppState, + ArtifactResult, + RenderRequested, + RenderSucceeded, + SourceLoaded, + SourceLoadRequested, + reduce, +) from matteloop.ui.parameter_presentation import present_parameters @@ -28,3 +38,28 @@ def test_parameter_presentation_uses_source_defaults_for_output() -> None: assert presentation.output_filename == "holiday.webp" assert presentation.duration == Fraction(4) assert presentation.fps == 15 + assert presentation.transform == TransformSpec() + assert presentation.artifact is None + + +def test_parameter_presentation_exposes_the_transform_and_last_artifact() -> None: + source = Path("/clips/holiday.mp4") + loading = reduce(AppState(), SourceLoadRequested("source", "load")) + ready = reduce(loading, SourceLoaded("source", "load", Metadata(source))) + transform = TransformSpec(first_frame=1, crop=CropSpec(0, 0, 4, 4)) + with_transform = reduce(ready, TransformChanged(transform)) + running = reduce(with_transform, RenderRequested("job", "req")) + artifact = ArtifactResult( + source_id="source", + request_id="req", + output_path=Path("/exports/holiday.webp"), + width=256, + height=128, + file_size=4096, + ) + state = reduce(running, RenderSucceeded("job", artifact)) + + presentation = present_parameters(state) + + assert presentation.transform == transform + assert presentation.artifact == artifact From ba03c0cf6b4d484398700b3e5357c3bc43ef42de Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 02:37:03 +0200 Subject: [PATCH 16/45] feat: add the TransformGroup inspector widget (issue #25 task B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/crop.py gains fit_crop_aspect and centered_crop_for_aspect, both pure functions over CropSpec/Fraction. fit_crop_aspect keeps the opposite corner fixed on a corner handle (adjusting whichever axis needs the smaller change), recentres the perpendicular axis on an edge handle, and leaves a body move untouched; centered_crop_for_aspect returns the largest centred rectangle of a ratio inside the source. Both round with the same half-up-on-exact-Fraction rule as geometry.py and always return a clamp_crop'd, >=1px result. ui/transform_group.py is a new, self-contained QWidget (not mounted anywhere yet — that is task B5) exposing trim (first/last frame + "Use playhead" buttons), crop (aspect preset combo + x/y/width/height spinboxes), and resize (width/height/percent/mismatch/size-preset) controls per contract section 4.8. Every edit calls the emit callback with a TransformChanged carrying a structurally valid TransformSpec; attempting to leave both resize axes at "Auto" shows an inline error and emits nothing instead. The size-preset combo is built from data (PLATFORM_SIZE_PRESETS by default, injectable for tests) grouped by platform via non-selectable header rows, so a new preset row needs no UI change to appear. The readout calls TransformSpec.validate_for and shows its message inline on failure, matching the job's own validation path. __init__ is split into per-section _build_*/_connect_signals/ _build_layout helpers to stay well under the 60-line function budget. Two MismatchMode lookups go through the enum constructor rather than isinstance, mirroring inspector.py's EdgeMode combo handling: PySide's QVariant boxing degrades a StrEnum to a plain str on round trip, so an isinstance(data, MismatchMode) check after reading combo.currentData() silently never matches. --- src/matteloop/core/crop.py | 82 ++++ src/matteloop/ui/transform_group.py | 600 ++++++++++++++++++++++++++++ tests/core/test_crop.py | 84 ++++ tests/ui/test_transform_group.py | 240 +++++++++++ 4 files changed, 1006 insertions(+) create mode 100644 src/matteloop/ui/transform_group.py create mode 100644 tests/ui/test_transform_group.py diff --git a/src/matteloop/core/crop.py b/src/matteloop/core/crop.py index 0c7131e..920a264 100644 --- a/src/matteloop/core/crop.py +++ b/src/matteloop/core/crop.py @@ -3,6 +3,7 @@ from __future__ import annotations import math +from fractions import Fraction from matteloop.core.geometry import ( InteractionGeometry, @@ -25,6 +26,8 @@ "west", } ) +_CORNER_HANDLES = frozenset({"north_west", "north_east", "south_east", "south_west"}) +_EDGE_HANDLES = frozenset({"north", "south", "east", "west"}) def crop_from_drag( @@ -105,6 +108,85 @@ def oriented_point_from_widget( return _orientation_transform_from_media(transform).source_to_widget(raw) +def fit_crop_aspect( + crop: CropSpec, + ratio: Fraction, + target: str, + *, + source_width: int, + source_height: int, +) -> CropSpec: + """Re-fit *crop* to width:height == *ratio* after a handle/body move. + + Corner handles keep the dragged corner's opposite corner fixed and + adjust whichever axis needs the smaller change to reach the ratio. + Edge handles adjust the perpendicular axis around the crop's centre. + A body move ("crop") is returned unchanged. The result is always + clamped to the source and has both dimensions >= 1. + """ + _validate_dimensions(source_width, source_height) + _validate_ratio(ratio) + if target == "crop": + fitted = crop + elif target in _CORNER_HANDLES: + fitted = _fit_corner(crop, ratio, target) + elif target in _EDGE_HANDLES: + fitted = _fit_edge(crop, ratio, target) + else: + raise ValueError("unknown crop interaction target") + return clamp_crop(fitted, source_width, source_height) + + +def centered_crop_for_aspect( + ratio: Fraction, *, source_width: int, source_height: int +) -> CropSpec: + """Return the largest centred rectangle of *ratio* inside the source.""" + _validate_dimensions(source_width, source_height) + _validate_ratio(ratio) + height_for_full_width = _rhu(Fraction(source_width) / ratio) + if height_for_full_width <= source_height: + width, height = source_width, max(1, height_for_full_width) + else: + width, height = max(1, _rhu(Fraction(source_height) * ratio)), source_height + x = (source_width - width) // 2 + y = (source_height - height) // 2 + return clamp_crop(CropSpec(x, y, width, height), source_width, source_height) + + +def _fit_corner(crop: CropSpec, ratio: Fraction, target: str) -> CropSpec: + width, height = crop.width, crop.height + width_for_height = _rhu(Fraction(height) * ratio) + height_for_width = _rhu(Fraction(width) / ratio) + if abs(width_for_height - width) <= abs(height_for_width - height): + new_width, new_height = max(1, width_for_height), height + else: + new_width, new_height = width, max(1, height_for_width) + x = crop.x + width - new_width if "west" in target else crop.x + y = crop.y + height - new_height if "north" in target else crop.y + return CropSpec(x, y, new_width, new_height) + + +def _fit_edge(crop: CropSpec, ratio: Fraction, target: str) -> CropSpec: + width, height = crop.width, crop.height + if target in {"east", "west"}: + new_height = max(1, _rhu(Fraction(width) / ratio)) + y = crop.y + _rhu(Fraction(height - new_height, 2)) + return CropSpec(crop.x, y, width, new_height) + new_width = max(1, _rhu(Fraction(height) * ratio)) + x = crop.x + _rhu(Fraction(width - new_width, 2)) + return CropSpec(x, crop.y, new_width, height) + + +def _validate_ratio(ratio: Fraction) -> None: + if not isinstance(ratio, Fraction) or ratio <= 0: + raise ValueError("aspect ratio must be a positive Fraction") + + +def _rhu(value: Fraction) -> int: + """Round half up (towards +infinity) on an exact Fraction.""" + return (2 * value.numerator + value.denominator) // (2 * value.denominator) + + def _move_crop( crop: CropSpec, dx: int, dy: int, source_width: int, source_height: int ) -> CropSpec: diff --git a/src/matteloop/ui/transform_group.py b/src/matteloop/ui/transform_group.py new file mode 100644 index 0000000..e5f70fb --- /dev/null +++ b/src/matteloop/ui/transform_group.py @@ -0,0 +1,600 @@ +"""Trim, crop, and resize controls for a finished cut's post-render transform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +from fractions import Fraction +from itertools import groupby + +from PySide6.QtCore import QSignalBlocker, Qt, Signal +from PySide6.QtGui import QStandardItem, QStandardItemModel +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from matteloop.core.crop import centered_crop_for_aspect +from matteloop.core.errors import ValidationError +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import ( + MAX_FINAL_DIMENSION, + MIN_FINAL_DIMENSION, + PLATFORM_SIZE_PRESETS, + CropSpec, + MismatchMode, + ResizeSpec, + SizePreset, + TransformSpec, +) +from matteloop.core.state import ArtifactResult +from matteloop.ui.compact_widgets import compact_field +from matteloop.ui.parameter_presentation import ParameterPresentation +from matteloop.ui.source_presentation import ( + format_source_dimensions, + format_source_file_size, +) + +_ASPECT_PRESETS: tuple[tuple[str, Fraction | None], ...] = ( + ("Free", None), + ("1:1", Fraction(1, 1)), + ("2:1", Fraction(2, 1)), + ("3:1", Fraction(3, 1)), + ("4:3", Fraction(4, 3)), + ("16:9", Fraction(16, 9)), +) + +_MISMATCH_LABELS: tuple[tuple[str, MismatchMode], ...] = ( + ("Keep original aspect ratio", MismatchMode.KEEP), + ("Stretch to fit", MismatchMode.STRETCH), + ("Center and crop to fit", MismatchMode.COVER), + ("Add transparent padding", MismatchMode.PAD), +) + +_RESIZE_ERROR = "Set at least one of width or height." + + +@dataclass(frozen=True) +class CutFacts: + """Cut-derived facts the group needs but ParameterState does not hold.""" + + cache_key: str + frame_count: int + framed_size: tuple[int, int] + fps: int + delays_ms: tuple[int, ...] + + +class TransformGroup(QWidget): + """A self-contained trim/crop/resize editor for the current cut's transform.""" + + aspect_lock_changed = Signal(object) # Fraction | None + crop_edit_toggled = Signal(bool) + use_playhead_requested = Signal(str) # "first" | "last" + + def __init__( + self, + emit: Callable[[object], None], + parent: QWidget | None = None, + *, + presets: tuple[SizePreset, ...] = PLATFORM_SIZE_PRESETS, + ) -> None: + super().__init__(parent) + self.setObjectName("transform_group") + self.setAccessibleName("Transform") + self._emit = emit + self._presets = presets + self._transform = TransformSpec() + self._facts: CutFacts | None = None + self._artifact: ArtifactResult | None = None + self._playhead: int | None = None + self._editable = False + self._syncing = False + self._mismatch = MismatchMode.KEEP + self._build_trim_widgets() + self._build_crop_widgets() + self._build_resize_widgets() + self._build_readout_widget() + self._connect_signals() + self._build_layout() + self._syncing = True + try: + self._configure_ranges() + finally: + self._syncing = False + self._refresh_enabled() + + # -- public surface ---------------------------------------------------- + + def apply(self, presentation: ParameterPresentation, editable: bool) -> None: + """Render reducer-owned transform and artifact values into the widgets.""" + self._syncing = True + try: + self._transform = presentation.transform + self._artifact = presentation.artifact + self._apply_transform_to_widgets() + finally: + self._syncing = False + self._editable = editable + self._refresh_enabled() + self._update_readout() + + def set_cut(self, facts: CutFacts | None) -> None: + """Update the cut this group edits, re-displaying the current transform.""" + self._facts = facts + self._syncing = True + try: + self._configure_ranges() + self._sync_trim_widgets() + self._sync_crop_widgets() + finally: + self._syncing = False + self._refresh_enabled() + self._update_readout() + + def set_playhead_frame(self, index: int | None) -> None: + """Enable the "Use playhead" buttons once a stored-cut index is known.""" + self._playhead = index + self._refresh_enabled() + + def tab_widgets(self) -> tuple[QWidget, ...]: + """Return the group's controls in keyboard navigation order.""" + return ( + self.first_frame_spinbox, + self.first_playhead_button, + self.last_frame_spinbox, + self.last_playhead_button, + self.crop_edit_checkbox, + self.aspect_combo, + *self._crop_field_widgets(), + self.width_spinbox, + self.height_spinbox, + self.percent_spinbox, + self.mismatch_combo, + self.size_preset_combo, + ) + + # -- widget construction ------------------------------------------------- + + def _build_trim_widgets(self) -> None: + self.first_frame_spinbox = compact_field(QSpinBox()) + self.first_frame_spinbox.setObjectName("transform_first_frame") + self.first_frame_spinbox.setAccessibleName("First frame") + self.first_playhead_button = QPushButton("Use Playhead") + self.first_playhead_button.setObjectName("transform_first_playhead") + self.first_playhead_button.setAccessibleName("Use playhead for first frame") + self.last_frame_spinbox = compact_field(QSpinBox()) + self.last_frame_spinbox.setObjectName("transform_last_frame") + self.last_frame_spinbox.setAccessibleName("Last frame") + self.last_playhead_button = QPushButton("Use Playhead") + self.last_playhead_button.setObjectName("transform_last_playhead") + self.last_playhead_button.setAccessibleName("Use playhead for last frame") + + def _build_crop_widgets(self) -> None: + self.crop_edit_checkbox = QCheckBox("Edit crop") + self.crop_edit_checkbox.setObjectName("transform_crop_edit") + self.crop_edit_checkbox.setAccessibleName("Edit crop") + self.aspect_combo = compact_field(QComboBox()) + self.aspect_combo.setObjectName("transform_aspect") + self.aspect_combo.setAccessibleName("Crop aspect ratio") + for label, ratio in _ASPECT_PRESETS: + self.aspect_combo.addItem(label, ratio) + self.crop_x_spinbox = self._crop_spinbox("x") + self.crop_y_spinbox = self._crop_spinbox("y") + self.crop_width_spinbox = self._crop_spinbox("width") + self.crop_height_spinbox = self._crop_spinbox("height") + + def _crop_spinbox(self, name: str) -> QSpinBox: + field = compact_field(QSpinBox()) + field.setObjectName(f"transform_crop_{name}") + field.setAccessibleName(f"Crop {name}") + field.setMaximum(MAX_FINAL_DIMENSION) + return field + + def _build_resize_widgets(self) -> None: + self.width_spinbox = self._dimension_spinbox("width") + self.height_spinbox = self._dimension_spinbox("height") + self.percent_spinbox = compact_field(QDoubleSpinBox()) + self.percent_spinbox.setObjectName("transform_percent") + self.percent_spinbox.setAccessibleName("Resize percentage") + self.percent_spinbox.setRange(0, 1000) + self.percent_spinbox.setSuffix(" %") + self.mismatch_combo = compact_field(QComboBox()) + self.mismatch_combo.setObjectName("transform_mismatch") + self.mismatch_combo.setAccessibleName("Aspect mismatch handling") + for label, mode in _MISMATCH_LABELS: + self.mismatch_combo.addItem(label, mode) + self.size_preset_combo = compact_field(QComboBox()) + self.size_preset_combo.setObjectName("transform_size_preset") + self.size_preset_combo.setAccessibleName("Output size preset") + self.size_preset_combo.setModel(self._preset_model()) + + def _dimension_spinbox(self, name: str) -> QSpinBox: + field = compact_field(QSpinBox()) + field.setObjectName(f"transform_{name}") + field.setAccessibleName(name.capitalize()) + field.setRange(MIN_FINAL_DIMENSION - 1, MAX_FINAL_DIMENSION) + field.setSpecialValueText("Auto") + return field + + def _preset_model(self) -> QStandardItemModel: + model = QStandardItemModel(self.size_preset_combo) + custom = QStandardItem("Custom") + custom.setData(None, Qt.ItemDataRole.UserRole) + model.appendRow(custom) + grouped = groupby(self._presets, key=lambda preset: preset.platform) + for platform, group in grouped: + header = QStandardItem(platform) + header.setFlags(Qt.ItemFlag.NoItemFlags) + model.appendRow(header) + for preset in group: + item = QStandardItem(preset.label) + item.setData(preset, Qt.ItemDataRole.UserRole) + model.appendRow(item) + return model + + def _build_readout_widget(self) -> None: + self.readout_label = QLabel() + self.readout_label.setObjectName("transform_readout") + self.readout_label.setAccessibleName("Transform summary") + self.readout_label.setWordWrap(True) + + def _connect_signals(self) -> None: + self.first_frame_spinbox.valueChanged.connect(self._first_frame_changed) + self.last_frame_spinbox.valueChanged.connect(self._last_frame_changed) + self.first_playhead_button.clicked.connect( + lambda: self.use_playhead_requested.emit("first") + ) + self.last_playhead_button.clicked.connect( + lambda: self.use_playhead_requested.emit("last") + ) + self.crop_edit_checkbox.toggled.connect(self._crop_edit_changed) + self.aspect_combo.currentIndexChanged.connect(self._aspect_changed) + for field in self._crop_field_widgets(): + field.valueChanged.connect(self._crop_field_changed) + self.width_spinbox.valueChanged.connect(self._resize_field_changed) + self.height_spinbox.valueChanged.connect(self._resize_field_changed) + self.percent_spinbox.valueChanged.connect(self._percent_changed) + self.mismatch_combo.currentIndexChanged.connect(self._mismatch_changed) + self.size_preset_combo.currentIndexChanged.connect(self._size_preset_changed) + + def _build_layout(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + layout.addWidget(self._trim_section()) + layout.addWidget(self._crop_section()) + layout.addWidget(self._resize_section()) + layout.addWidget(self.readout_label) + + def _trim_section(self) -> QWidget: + section = QWidget() + layout = QFormLayout(section) + layout.addRow("First frame", self._trim_row( + self.first_frame_spinbox, self.first_playhead_button + )) + layout.addRow("Last frame", self._trim_row( + self.last_frame_spinbox, self.last_playhead_button + )) + return section + + def _trim_row(self, spinbox: QSpinBox, button: QPushButton) -> QWidget: + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.addWidget(spinbox, 1) + row_layout.addWidget(button) + return row + + def _crop_section(self) -> QWidget: + section = QWidget() + layout = QFormLayout(section) + layout.addRow(self.crop_edit_checkbox) + layout.addRow("Aspect", self.aspect_combo) + layout.addRow("X", self.crop_x_spinbox) + layout.addRow("Y", self.crop_y_spinbox) + layout.addRow("Width", self.crop_width_spinbox) + layout.addRow("Height", self.crop_height_spinbox) + return section + + def _resize_section(self) -> QWidget: + section = QWidget() + layout = QFormLayout(section) + layout.addRow("Width", self.width_spinbox) + layout.addRow("Height", self.height_spinbox) + layout.addRow("Percent", self.percent_spinbox) + layout.addRow("Mismatch", self.mismatch_combo) + layout.addRow("Preset", self.size_preset_combo) + return section + + # -- reducer-owned state -> widgets -------------------------------------- + + def _apply_transform_to_widgets(self) -> None: + self._sync_trim_widgets() + self._sync_crop_widgets() + self.aspect_combo.setCurrentIndex(0) + self._sync_resize_widgets() + + def _sync_trim_widgets(self) -> None: + self.first_frame_spinbox.setValue(self._transform.first_frame) + last_default = self._facts.frame_count - 1 if self._facts is not None else 0 + last_frame = self._transform.last_frame + self.last_frame_spinbox.setValue( + last_default if last_frame is None else last_frame + ) + + def _sync_crop_widgets(self) -> None: + self._set_crop_widgets(self._transform.crop or self._default_crop()) + + def _default_crop(self) -> CropSpec: + if self._facts is None: + return CropSpec(0, 0, 1, 1) + return CropSpec(0, 0, *self._facts.framed_size) + + def _set_crop_widgets(self, crop: CropSpec) -> None: + blockers = [QSignalBlocker(field) for field in self._crop_field_widgets()] + try: + self.crop_x_spinbox.setValue(crop.x) + self.crop_y_spinbox.setValue(crop.y) + self.crop_width_spinbox.setValue(crop.width) + self.crop_height_spinbox.setValue(crop.height) + finally: + del blockers + + def _sync_resize_widgets(self) -> None: + resize = self._transform.resize + self._mismatch = resize.mismatch if resize is not None else MismatchMode.KEEP + self.width_spinbox.setValue(self._dimension_display(resize, "width")) + self.height_spinbox.setValue(self._dimension_display(resize, "height")) + self.mismatch_combo.setCurrentIndex(self.mismatch_combo.findData(self._mismatch)) + self.percent_spinbox.setValue(0) + self.size_preset_combo.setCurrentIndex(0) + + def _dimension_display(self, resize: ResizeSpec | None, axis: str) -> int: + spinbox = self.width_spinbox if axis == "width" else self.height_spinbox + value = None if resize is None else getattr(resize, axis) + return spinbox.minimum() if value is None else value + + def _configure_ranges(self) -> None: + if self._facts is None: + self.first_frame_spinbox.setRange(0, 0) + self.last_frame_spinbox.setRange(0, 0) + self.crop_x_spinbox.setRange(0, 0) + self.crop_y_spinbox.setRange(0, 0) + self.crop_width_spinbox.setRange(1, 1) + self.crop_height_spinbox.setRange(1, 1) + return + frame_count = self._facts.frame_count + width, height = self._facts.framed_size + self.first_frame_spinbox.setRange(0, max(0, frame_count - 1)) + self.last_frame_spinbox.setRange(0, max(0, frame_count - 1)) + self.crop_x_spinbox.setRange(0, max(0, width - 1)) + self.crop_y_spinbox.setRange(0, max(0, height - 1)) + self.crop_width_spinbox.setRange(1, width) + self.crop_height_spinbox.setRange(1, height) + + # -- widget edits -> reducer events -------------------------------------- + + def _first_frame_changed(self, value: int) -> None: + if self._syncing: + return + self._update_transform(replace(self._transform, first_frame=value)) + + def _last_frame_changed(self, _value: int) -> None: + if self._syncing: + return + last_frame = self._last_frame_value() + self._update_transform(replace(self._transform, last_frame=last_frame)) + + def _last_frame_value(self) -> int | None: + value = self.last_frame_spinbox.value() + if self._facts is not None and value == self._facts.frame_count - 1: + return None + return value + + def _crop_edit_changed(self, checked: bool) -> None: + if not self._syncing: + self.crop_edit_toggled.emit(checked) + + def _aspect_changed(self, _index: int) -> None: + if self._syncing: + return + ratio = self.aspect_combo.currentData() + if ratio is None: + self.aspect_lock_changed.emit(None) + return + if self._facts is None or not isinstance(ratio, Fraction): + return + width, height = self._facts.framed_size + crop = centered_crop_for_aspect(ratio, source_width=width, source_height=height) + self._set_crop_widgets(crop) + self._update_transform(replace(self._transform, crop=crop)) + self.aspect_lock_changed.emit(ratio) + + def _crop_field_changed(self, _value: int) -> None: + if self._syncing: + return + crop = CropSpec( + self.crop_x_spinbox.value(), + self.crop_y_spinbox.value(), + self.crop_width_spinbox.value(), + self.crop_height_spinbox.value(), + ) + self._update_transform(replace(self._transform, crop=crop)) + + def _resize_field_changed(self, _value: int) -> None: + if self._syncing: + return + with QSignalBlocker(self.percent_spinbox): + self.percent_spinbox.setValue(0) + self._apply_resize_fields() + + def _percent_changed(self, value: float) -> None: + if self._syncing or self._facts is None or value <= 0: + return + cropped_width, cropped_height = self._cropped_size() + fraction = Fraction(value) / 100 + width = _rhu(Fraction(cropped_width) * fraction) + height = _rhu(Fraction(cropped_height) * fraction) + with QSignalBlocker(self.width_spinbox), QSignalBlocker(self.height_spinbox): + self.width_spinbox.setValue(width) + self.height_spinbox.setValue(height) + self._apply_resize_fields() + + def _cropped_size(self) -> tuple[int, int]: + if self._transform.crop is not None: + return (self._transform.crop.width, self._transform.crop.height) + if self._facts is not None: + return self._facts.framed_size + return (1, 1) + + def _mismatch_changed(self, _index: int) -> None: + if self._syncing: + return + try: + # PySide's QVariant boxing degrades a StrEnum to a plain str on + # round trip (cf. inspector.py's EdgeMode combo handling), so the + # value is re-parsed through the enum rather than isinstance-checked. + mode = MismatchMode(self.mismatch_combo.currentData()) + except (TypeError, ValueError): + return + self._mismatch = mode + if self._transform.resize is not None: + resize = replace(self._transform.resize, mismatch=mode) + self._update_transform(replace(self._transform, resize=resize)) + + def _size_preset_changed(self, _index: int) -> None: + if self._syncing: + return + preset = self.size_preset_combo.currentData() + if not isinstance(preset, SizePreset): + return + with QSignalBlocker(self.width_spinbox), QSignalBlocker(self.height_spinbox): + self.width_spinbox.setValue(preset.width) + self.height_spinbox.setValue(preset.height) + self._apply_resize_fields() + + def _apply_resize_fields(self) -> None: + width = self._width_value() + height = self._height_value() + if width is None and height is None: + self._show_resize_error() + return + self._clear_resize_error() + resize = ResizeSpec(width, height, self._mismatch) + self._update_transform(replace(self._transform, resize=resize)) + + def _width_value(self) -> int | None: + value = self.width_spinbox.value() + return None if value == self.width_spinbox.minimum() else value + + def _height_value(self) -> int | None: + value = self.height_spinbox.value() + return None if value == self.height_spinbox.minimum() else value + + def _update_transform(self, transform: TransformSpec) -> None: + if transform == self._transform: + return + self._transform = transform + self._emit(TransformChanged(transform)) + self._update_readout() + + # -- readout and error surfaces ------------------------------------- + + def _update_readout(self) -> None: + if self._facts is None: + self.readout_label.setText("") + self._clear_transform_error() + return + try: + size = self._transform.validate_for( + self._facts.frame_count, self._facts.framed_size + ) + except ValidationError as error: + self._show_transform_error(str(error)) + return + self._clear_transform_error() + self.readout_label.setText(self._readout_text(size)) + + def _readout_text(self, size: tuple[int, int]) -> str: + facts = self._facts + assert facts is not None + kept = self._transform.kept_range(facts.frame_count) + delays = self._transform.select_kept(facts.delays_ms) + duration = sum(delays) / 1000 + text = ( + f"{len(kept)} of {facts.frame_count} frames · {duration:.3f} s · " + f"{format_source_dimensions(*size)} px" + ) + artifact = self._artifact + if artifact is not None and artifact.width and artifact.height: + rendered = format_source_dimensions(artifact.width, artifact.height) + file_size = format_source_file_size(artifact.file_size) + text += f" · rendered {rendered} px, {file_size}" + return text + + def _show_transform_error(self, message: str) -> None: + self.readout_label.setText(message) + self._set_widget_invalid(self.readout_label, True, message) + + def _clear_transform_error(self) -> None: + self._set_widget_invalid(self.readout_label, False, "") + + def _show_resize_error(self) -> None: + for spin in (self.width_spinbox, self.height_spinbox): + self._set_widget_invalid(spin, True, _RESIZE_ERROR) + + def _clear_resize_error(self) -> None: + for spin in (self.width_spinbox, self.height_spinbox): + self._set_widget_invalid(spin, False, "") + + def _set_widget_invalid(self, widget: QWidget, invalid: bool, message: str) -> None: + widget.setProperty("invalid", invalid) + widget.setToolTip(message) + widget.style().unpolish(widget) + widget.style().polish(widget) + + # -- enablement ----------------------------------------------------- + + def _refresh_enabled(self) -> None: + enabled = self._editable and self._facts is not None + for widget in self._core_widgets(): + widget.setEnabled(enabled) + playhead_enabled = enabled and self._playhead is not None + self.first_playhead_button.setEnabled(playhead_enabled) + self.last_playhead_button.setEnabled(playhead_enabled) + + def _core_widgets(self) -> tuple[QWidget, ...]: + return ( + self.first_frame_spinbox, + self.last_frame_spinbox, + self.crop_edit_checkbox, + self.aspect_combo, + *self._crop_field_widgets(), + self.width_spinbox, + self.height_spinbox, + self.percent_spinbox, + self.mismatch_combo, + self.size_preset_combo, + ) + + def _crop_field_widgets(self) -> tuple[QSpinBox, ...]: + return ( + self.crop_x_spinbox, + self.crop_y_spinbox, + self.crop_width_spinbox, + self.crop_height_spinbox, + ) + + +def _rhu(value: Fraction) -> int: + """Round half up (towards +infinity) on an exact Fraction.""" + return (2 * value.numerator + value.denominator) // (2 * value.denominator) diff --git a/tests/core/test_crop.py b/tests/core/test_crop.py index 70a93c6..13c5bdb 100644 --- a/tests/core/test_crop.py +++ b/tests/core/test_crop.py @@ -1,7 +1,11 @@ from __future__ import annotations +from fractions import Fraction + from matteloop.core.crop import ( + centered_crop_for_aspect, crop_from_drag, + fit_crop_aspect, nudge_crop, oriented_point_from_widget, oriented_rect_to_source_rect, @@ -77,3 +81,83 @@ def test_oriented_crop_geometry_maps_rotation_and_pixel_aspect_once() -> None: assert geometry.visual["crop"] == RectF(0, 0, 8, 8) assert oriented_point_from_widget(geometry, PointF(0, 0)) == PointF(0, 0) + + +def test_fit_crop_aspect_on_a_corner_keeps_the_opposite_corner_fixed() -> None: + fitted = fit_crop_aspect( + CropSpec(2, 3, 6, 5), + Fraction(2, 1), + "north_west", + source_width=20, + source_height=20, + ) + + assert fitted == CropSpec(2, 5, 6, 3) + # The south-east corner (the one opposite the dragged north-west handle) + # is exactly where it was before the re-fit. + assert (fitted.x + fitted.width, fitted.y + fitted.height) == (8, 8) + assert fitted.width == 2 * fitted.height + + +def test_fit_crop_aspect_on_a_corner_adjusts_the_axis_needing_less_change() -> None: + fitted = fit_crop_aspect( + CropSpec(0, 0, 8, 4), + Fraction(1, 1), + "south_east", + source_width=20, + source_height=20, + ) + + assert fitted == CropSpec(0, 0, 4, 4) + + +def test_fit_crop_aspect_on_an_edge_recentres_the_perpendicular_axis() -> None: + fitted = fit_crop_aspect( + CropSpec(2, 3, 6, 5), + Fraction(3, 1), + "east", + source_width=20, + source_height=20, + ) + + assert fitted == CropSpec(2, 5, 6, 2) + assert fitted.width == 3 * fitted.height + + +def test_fit_crop_aspect_leaves_a_body_move_unchanged() -> None: + crop = CropSpec(2, 3, 6, 5) + + fitted = fit_crop_aspect( + crop, Fraction(4, 3), "crop", source_width=20, source_height=20 + ) + + assert fitted == crop + + +def test_fit_crop_aspect_clamps_a_result_that_would_leave_the_source() -> None: + fitted = fit_crop_aspect( + CropSpec(0, 0, 6, 5), + Fraction(2, 1), + "south_east", + source_width=8, + source_height=8, + ) + + assert fitted.x + fitted.width <= 8 + assert fitted.y + fitted.height <= 8 + assert fitted.width >= 1 + assert fitted.height >= 1 + + +def test_centered_crop_for_aspect_uses_the_full_width_when_it_fits() -> None: + crop = centered_crop_for_aspect( + Fraction(16, 9), source_width=1920, source_height=1080 + ) + + assert crop == CropSpec(0, 0, 1920, 1080) + + +def test_centered_crop_for_aspect_centres_a_narrower_rectangle() -> None: + crop = centered_crop_for_aspect(Fraction(1, 1), source_width=200, source_height=100) + + assert crop == CropSpec(50, 0, 100, 100) diff --git a/tests/ui/test_transform_group.py b/tests/ui/test_transform_group.py new file mode 100644 index 0000000..2293f59 --- /dev/null +++ b/tests/ui/test_transform_group.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from dataclasses import replace +from fractions import Fraction +from pathlib import Path + +from matteloop.core.crop import centered_crop_for_aspect +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import ( + PLATFORM_SIZE_PRESETS, + MismatchMode, + ResizeSpec, + SizePreset, + TransformSpec, +) +from matteloop.core.state import AppState, ArtifactResult +from matteloop.ui.parameter_presentation import ( + ParameterPresentation, + present_parameters, +) +from matteloop.ui.transform_group import CutFacts, TransformGroup + + +def _presentation( + transform: TransformSpec = TransformSpec(), + artifact: ArtifactResult | None = None, +) -> ParameterPresentation: + base = present_parameters(AppState()) + return replace(base, transform=transform, artifact=artifact) + + +def _facts( + frame_count: int = 10, + framed_size: tuple[int, int] = (256, 128), + fps: int = 15, + delay: int = 67, +) -> CutFacts: + return CutFacts( + cache_key="a" * 64, + frame_count=frame_count, + framed_size=framed_size, + fps=fps, + delays_ms=tuple(delay for _ in range(frame_count)), + ) + + +def _group(qtbot, presets: tuple[SizePreset, ...] | None = None): + emitted: list[object] = [] + kwargs = {} if presets is None else {"presets": presets} + group = TransformGroup(emitted.append, **kwargs) + qtbot.addWidget(group) + return group, emitted + + +def _index_with_data(combo, data: object) -> int: + """Locate a combo row by value equality. + + QComboBox.findData compares opaque Python objects (Fraction, SizePreset) + by identity, not value, so a freshly built lookup key never matches the + object actually stored on the row. + """ + for index in range(combo.count()): + if combo.itemData(index) == data: + return index + raise AssertionError(f"no combo item with data {data!r}") + + +def test_choosing_a_size_preset_fills_dimensions_and_leaves_mismatch(qtbot) -> None: + group, emitted = _group(qtbot) + group.apply(_presentation(), editable=True) + group.set_cut(_facts()) + group.mismatch_combo.setCurrentIndex( + group.mismatch_combo.findData(MismatchMode.STRETCH) + ) + emitted.clear() + preset = next(p for p in PLATFORM_SIZE_PRESETS if p.label == "256x128") + + group.size_preset_combo.setCurrentIndex(group.size_preset_combo.findData(preset)) + + assert group.width_spinbox.value() == 256 + assert group.height_spinbox.value() == 128 + assert group.mismatch_combo.currentData() == MismatchMode.STRETCH + assert emitted + last = emitted[-1] + assert isinstance(last, TransformChanged) + assert last.transform.resize == ResizeSpec(256, 128, MismatchMode.STRETCH) + + +def test_a_fake_preset_row_appears_under_its_own_platform_header(qtbot) -> None: + fake = SizePreset("Acme", "999x999", 999, 999) + group, _emitted = _group(qtbot, presets=(*PLATFORM_SIZE_PRESETS, fake)) + group.apply(_presentation(), editable=True) + group.set_cut(_facts()) + + model = group.size_preset_combo.model() + labels = [model.item(row).text() for row in range(model.rowCount())] + + assert "Acme" in labels + header_row = labels.index("Acme") + assert labels[header_row + 1] == "999x999" + assert group.size_preset_combo.findData(fake) >= 0 + + +def test_editing_first_frame_emits_a_valid_transform_change(qtbot) -> None: + group, emitted = _group(qtbot) + group.apply(_presentation(), editable=True) + group.set_cut(_facts(frame_count=10)) + emitted.clear() + + group.first_frame_spinbox.setValue(2) + + assert emitted == [TransformChanged(TransformSpec(first_frame=2))] + + +def test_clearing_both_resize_axes_to_auto_shows_an_error_and_emits_nothing( + qtbot, +) -> None: + group, emitted = _group(qtbot) + group.apply( + _presentation(transform=TransformSpec(resize=ResizeSpec(width=256))), + editable=True, + ) + group.set_cut(_facts()) + assert group.width_spinbox.value() == 256 + assert group.height_spinbox.value() == group.height_spinbox.minimum() + emitted.clear() + + group.width_spinbox.setValue(group.width_spinbox.minimum()) + + assert emitted == [] + assert group.width_spinbox.property("invalid") is True + assert group.height_spinbox.property("invalid") is True + + +def test_percentage_fills_both_dimensions_and_clears_on_manual_edit(qtbot) -> None: + group, emitted = _group(qtbot) + group.apply(_presentation(), editable=True) + group.set_cut(_facts(framed_size=(640, 360))) + emitted.clear() + + group.percent_spinbox.setValue(50) + + assert group.width_spinbox.value() == 320 + assert group.height_spinbox.value() == 180 + last = emitted[-1] + assert isinstance(last, TransformChanged) + assert last.transform.resize == ResizeSpec(320, 180, MismatchMode.KEEP) + + group.width_spinbox.setValue(300) + + assert group.percent_spinbox.value() == 0 + + +def test_choosing_an_aspect_preset_emits_a_centred_crop_and_locks_it(qtbot) -> None: + group, emitted = _group(qtbot) + locks: list[object] = [] + group.aspect_lock_changed.connect(locks.append) + group.apply(_presentation(), editable=True) + group.set_cut(_facts(framed_size=(200, 100))) + emitted.clear() + + group.aspect_combo.setCurrentIndex( + _index_with_data(group.aspect_combo, Fraction(1, 1)) + ) + + expected = centered_crop_for_aspect( + Fraction(1, 1), source_width=200, source_height=100 + ) + last = emitted[-1] + assert isinstance(last, TransformChanged) + assert last.transform.crop == expected + assert locks == [Fraction(1, 1)] + assert group.crop_width_spinbox.value() == expected.width + assert group.crop_height_spinbox.value() == expected.height + + +def test_apply_with_editable_false_disables_every_control(qtbot) -> None: + group, _emitted = _group(qtbot) + group.set_cut(_facts()) + + group.apply(_presentation(), editable=False) + + for widget in group.tab_widgets(): + assert not widget.isEnabled() + + +def test_set_cut_none_disables_every_control(qtbot) -> None: + group, _emitted = _group(qtbot) + group.apply(_presentation(), editable=True) + group.set_cut(_facts()) + assert group.first_frame_spinbox.isEnabled() + + group.set_cut(None) + + for widget in group.tab_widgets(): + assert not widget.isEnabled() + + +def test_readout_shows_frame_count_duration_and_size(qtbot) -> None: + group, _emitted = _group(qtbot) + group.apply( + _presentation(TransformSpec(first_frame=1, last_frame=3)), editable=True + ) + group.set_cut(_facts(frame_count=10, framed_size=(256, 128), delay=100)) + + text = group.readout_label.text() + + assert "3 of 10 frames" in text + assert "0.300 s" in text + assert "256" in text and "128" in text + assert "rendered" not in text + + +def test_readout_appends_the_rendered_artifact_size(qtbot) -> None: + group, _emitted = _group(qtbot) + artifact = ArtifactResult( + source_id="s", + request_id="r", + output_path=Path("out.webp"), + width=200, + height=100, + file_size=2048, + ) + group.apply(_presentation(artifact=artifact), editable=True) + group.set_cut(_facts()) + + text = group.readout_label.text() + + assert "rendered" in text + assert "200" in text and "100" in text + + +def test_tab_widgets_returns_the_focus_order(qtbot) -> None: + group, _emitted = _group(qtbot) + + widgets = group.tab_widgets() + + assert widgets[0] is group.first_frame_spinbox + assert group.size_preset_combo in widgets + assert len(widgets) == len(set(widgets)) From 8ab8d99db6cc05ec7c0a6baf8d57fb346868bbab Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 02:44:03 +0200 Subject: [PATCH 17/45] feat: mount the Transform group in the inspector (issue #25 task B5) Adds a collapsed-by-default "transform" disclosure between Crop & Cleanup and Output that hosts the TransformGroup widget built in B4; crop_view.render_source_editor now pushes ParameterPresentation into it on every state change. Pays for the 6 new lines with two mechanical removals: dropped show_workspace_attention (verified no callers in src/tests/resources via grep) and moved _read_bool to inspector_disclosure.read_bool, named to keep its import line under isort's wrap threshold. Net effect: inspector.py 798 -> 797 lines. Updated the existing tab-order test to expect the new disclosure button between Crop & Cleanup and Output; it stays collapsed with no cut open, so only the button (not the group's controls) appears in the chain. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/crop_view.py | 1 + src/matteloop/ui/inspector.py | 19 ++++++------ src/matteloop/ui/inspector_disclosure.py | 8 ++++- .../test_main_window_state_and_source_drop.py | 1 + tests/ui/test_parameter_inspector.py | 29 +++++++++++++++++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/matteloop/ui/crop_view.py b/src/matteloop/ui/crop_view.py index 4dc63e9..3e3e59c 100644 --- a/src/matteloop/ui/crop_view.py +++ b/src/matteloop/ui/crop_view.py @@ -22,4 +22,5 @@ def render_source_editor( canvas.apply_presentation(model.crop, active=model.crop_enabled, editable=editable) inspector.apply_crop(model.crop, model.crop_enabled, editable) inspector.apply_parameters(model.parameters, editable) + inspector.transform_group.apply(model.parameters, editable) inspector.set_model_status(model.model_status) diff --git a/src/matteloop/ui/inspector.py b/src/matteloop/ui/inspector.py index df6a9bf..1739393 100644 --- a/src/matteloop/ui/inspector.py +++ b/src/matteloop/ui/inspector.py @@ -61,7 +61,7 @@ compact_field, ) from matteloop.ui.crop_presentation import CropPresentation -from matteloop.ui.inspector_disclosure import configure_disclosure +from matteloop.ui.inspector_disclosure import configure_disclosure, read_bool from matteloop.ui.parameter_presentation import ( ParameterPresentation, decimal_from_widget_value, @@ -69,11 +69,13 @@ ) from matteloop.ui.ports import OutputDirectoryRequested from matteloop.ui.source_presentation import format_source_file_size +from matteloop.ui.transform_group import TransformGroup _DISCLOSURES = ( ("segmentation", "Segmentation", True), ("time_sampling", "Time & Sampling", True), ("crop_cleanup", "Crop & Cleanup", False), + ("transform", "Transform", False), ("output", "Output", True), ("workspace", "Workspace", False), ) @@ -141,6 +143,7 @@ def _build_scrollable_content(self) -> None: self.manage_workspaces = QPushButton("Manage Workspaces…") self.manage_workspaces.setObjectName("manage_workspaces") self.manage_workspaces.setAccessibleName("Manage Workspaces…") + self.transform_group = TransformGroup(self.command_requested.emit) for key, title, default in _DISCLOSURES: section = self._section(key, title, default) content_layout.addWidget(section) @@ -592,13 +595,15 @@ def _section(self, key: str, title: str, default: bool) -> QFrame: if key == "crop_cleanup": body_layout.addWidget(self._cleanup_controls()) body_layout.addWidget(self._crop_controls()) + if key == "transform": + body_layout.addWidget(self.transform_group) if key == "output": body_layout.addWidget(self._output_controls()) if key == "workspace": body_layout.addWidget(self.edited_cut_recovery) body_layout.addWidget(self.rebuild_button) body_layout.addWidget(self.manage_workspaces) - checked = self._read_bool(f"inspector/{key}", default) + checked = read_bool(self._settings, f"inspector/{key}", default) configure_disclosure(button, key, title, checked) body.setVisible(checked) button.toggled.connect(body.setVisible) @@ -737,6 +742,8 @@ def tab_widgets(self) -> tuple[QWidget, ...]: 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.output_filename_edit, @@ -768,14 +775,6 @@ def set_workspace_state(self, attention: bool, open_: bool) -> None: workspace_button.style().unpolish(workspace_button) workspace_button.style().polish(workspace_button) - def show_workspace_attention(self, visible: bool) -> None: - """Compatibility wrapper for older callers.""" - self.set_workspace_state(visible, visible) - - def _read_bool(self, name: str, default: bool) -> bool: - value = self._settings.value(name, default) - return value if type(value) is bool else default - def _set_provider_options(self, model_id: str) -> None: if self._provider_model_id == model_id and self.provider_picker.count(): return diff --git a/src/matteloop/ui/inspector_disclosure.py b/src/matteloop/ui/inspector_disclosure.py index 2723c19..dba0b9f 100644 --- a/src/matteloop/ui/inspector_disclosure.py +++ b/src/matteloop/ui/inspector_disclosure.py @@ -2,10 +2,16 @@ from __future__ import annotations -from PySide6.QtCore import Qt +from PySide6.QtCore import QSettings, Qt from PySide6.QtWidgets import QSizePolicy, QToolButton +def read_bool(settings: QSettings, name: str, default: bool) -> bool: + """Read a settings value expected to be an exact bool, degrading to default.""" + value = settings.value(name, default) + return value if type(value) is bool else default + + def configure_disclosure( button: QToolButton, key: str, title: str, expanded: bool ) -> None: diff --git a/tests/ui/test_main_window_state_and_source_drop.py b/tests/ui/test_main_window_state_and_source_drop.py index b4074b0..e4bbb15 100644 --- a/tests/ui/test_main_window_state_and_source_drop.py +++ b/tests/ui/test_main_window_state_and_source_drop.py @@ -576,6 +576,7 @@ def test_actual_tab_order_skips_hidden_widgets_and_reaches_success_actions( value.manage_models_button, value.inspector.disclosures["time_sampling"][0], value.inspector.disclosures["crop_cleanup"][0], + value.inspector.disclosures["transform"][0], value.inspector.disclosures["output"][0], value.inspector.disclosures["workspace"][0], value.edited_cut_recovery, diff --git a/tests/ui/test_parameter_inspector.py b/tests/ui/test_parameter_inspector.py index 868a158..90ab417 100644 --- a/tests/ui/test_parameter_inspector.py +++ b/tests/ui/test_parameter_inspector.py @@ -82,6 +82,7 @@ def test_inspector_disclosure_titles_preserve_literal_ampersands(qtbot) -> None: ("segmentation", "Segmentation", True), ("time_sampling", "Time & Sampling", True), ("crop_cleanup", "Crop & Cleanup", False), + ("transform", "Transform", False), ("output", "Output", True), ("workspace", "Workspace", False), ], @@ -112,6 +113,34 @@ def test_inspector_disclosures_show_visual_and_spoken_state( ) +def test_transform_group_is_mounted_inside_its_disclosure_body(qtbot) -> None: + inspector = Inspector(_settings()) + qtbot.addWidget(inspector) + + _button, body = inspector.disclosures["transform"] + + assert inspector.transform_group.parentWidget() is body + + +def test_tab_widgets_include_the_transform_disclosure_and_group_in_order( + qtbot, +) -> None: + inspector = Inspector(_settings()) + qtbot.addWidget(inspector) + + widgets = inspector.tab_widgets() + transform_button = inspector.disclosures["transform"][0] + crop_index = widgets.index(inspector.disclosures["crop_cleanup"][0]) + transform_index = widgets.index(transform_button) + output_index = widgets.index(inspector.disclosures["output"][0]) + + assert crop_index < transform_index < output_index + group_widgets = inspector.transform_group.tab_widgets() + assert widgets[transform_index + 1 : transform_index + 1 + len(group_widgets)] == ( + group_widgets + ) + + def test_output_directory_middle_elides_while_preserving_full_path_semantics( qtbot, ) -> None: From 98bd3b516fc86d3a9ce641c51e4005e1a2bcec1d Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 03:06:15 +0200 Subject: [PATCH 18/45] feat: add the cut-session controller and wire it end to end (issue #25 task B6) New ui/transform_stage.py owns the "currently open cut" ParameterState cannot hold: CutSession (workspace/manifest/fps, no cached FramingSpec per T6) and TransformStageController, which recomputes CutFacts on a QThread worker via the same jobs.transform_stage.framing_plan the encoder uses, whenever a framing-relevant parameter changes, and clamps a stale crop against the new framed size (E14). RenderWorker gains an artifact_ready signal emitted with the raw RenderArtifact before RenderSucceeded; RenderController re-emits it and gains a transform_restore hook that "Use this set" calls before building the rebuild request, so a restored transform reaches the request (E17). SourceController builds the controller, wires both, and exposes attach_transform_stage for the window to call. app.py attaches the inspector's Transform group with no result canvas yet (Stage C only); passing the existing PreviewCanvas would be wrong since it lacks the command_requested signal attach() connects. Deleting a cut set from the picker now also discards its transform sidecar (E28). Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/app.py | 1 + src/matteloop/ui/controller.py | 20 ++ src/matteloop/ui/render_controller.py | 5 + src/matteloop/ui/render_worker.py | 2 + src/matteloop/ui/transform_stage.py | 343 +++++++++++++++++++++++ src/matteloop/ui/workspace_controller.py | 3 + tests/ui/test_render_controller.py | 102 ++++++- tests/ui/test_transform_stage.py | 242 ++++++++++++++++ tests/ui/test_workspace_controller.py | 37 +++ 9 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 src/matteloop/ui/transform_stage.py create mode 100644 tests/ui/test_transform_stage.py create mode 100644 tests/ui/test_workspace_controller.py diff --git a/src/matteloop/app.py b/src/matteloop/app.py index b425d14..bb27a83 100644 --- a/src/matteloop/app.py +++ b/src/matteloop/app.py @@ -71,6 +71,7 @@ def _run_gui() -> int: provider_options=provider_options, ) controller.set_dialog_parent(window) + controller.attach_transform_stage(window.inspector.transform_group, None) application.aboutToQuit.connect(controller.shutdown) window.show() return application.exec() diff --git a/src/matteloop/ui/controller.py b/src/matteloop/ui/controller.py index 185b585..acb03c6 100644 --- a/src/matteloop/ui/controller.py +++ b/src/matteloop/ui/controller.py @@ -51,6 +51,7 @@ probe_source, ) from matteloop.paths import model_cache_root +from matteloop.ui.crop_canvas import CropCanvas from matteloop.ui.model_manager import ModelManagerController, ModelRemovalService from matteloop.ui.ports import ( ChooseVideoRequested, @@ -70,6 +71,8 @@ from matteloop.ui.preview_controller import PreviewController, PreviewRuntime from matteloop.ui.render_controller import RenderController from matteloop.ui.timeline import SourceFrameWorker +from matteloop.ui.transform_group import TransformGroup +from matteloop.ui.transform_stage import TransformStageController VIDEO_FILE_FILTER = "Video files (*.mp4 *.mov *.webm *.mkv)" _THREAD_SHUTDOWN_TIMEOUT_MS = 5000 @@ -188,6 +191,11 @@ def __init__( parent=self, ) self._model_manager = self._build_model_manager() + self._transform_stage = TransformStageController(store, parent=self) + self._render_controller.artifact_ready.connect( + self._transform_stage.open_artifact + ) + self._render_controller.transform_restore = self._transform_stage.restore_for self._preview_controller.provider_ready.connect(self._provider_ready) self._render_controller.provider_ready.connect(self._provider_ready) self._working_provider = store.state.parameters.execution_provider @@ -229,6 +237,17 @@ def render_controller(self) -> RenderController: """Expose the render command owner for lifecycle and UI integration tests.""" return self._render_controller + @property + def transform_stage(self) -> TransformStageController: + """Expose the cut-session owner for lifecycle and UI integration tests.""" + return self._transform_stage + + def attach_transform_stage( + self, group: TransformGroup, canvas: CropCanvas | None + ) -> None: + """Wire the inspector's Transform group (and, later, the result canvas).""" + self._transform_stage.attach(group, canvas) + @property def model_options(self) -> tuple[tuple[str, bool], ...]: """Expose runtime model availability for the passive inspector view.""" @@ -299,6 +318,7 @@ def shutdown(self) -> None: for thread, _worker in tuple(self._frame_threads): thread.wait(_THREAD_SHUTDOWN_TIMEOUT_MS) self._model_manager.close() + self._transform_stage.shutdown() self._render_controller.shutdown() self._preview_controller.shutdown() threads = tuple(self._threads.items()) diff --git a/src/matteloop/ui/render_controller.py b/src/matteloop/ui/render_controller.py index 5e7ec75..7fb62b7 100644 --- a/src/matteloop/ui/render_controller.py +++ b/src/matteloop/ui/render_controller.py @@ -88,6 +88,7 @@ class RenderController(QObject): provider_ready = Signal(str) provider_notice = Signal(str) + artifact_ready = Signal(object) def __init__( self, @@ -123,6 +124,7 @@ def __init__( self._probe_request: RenderRequest | None = None self._workspace_picker: WorkspacePickerController | None = None self._active_workspace: CutWorkspace | None = None + self.transform_restore: Callable[[CutWorkspace], None] | None = None self._closed = False @property @@ -435,6 +437,8 @@ def _open_workspace_picker(self) -> None: def _use_workspace(self, value: object) -> None: if not isinstance(value, WorkspaceSummary): return + if self.transform_restore is not None: + self.transform_restore(value.workspace) request = self._current_request() if request is None: return @@ -571,6 +575,7 @@ def _spawn_worker( worker.notification.connect(self._notification) worker.provider_ready.connect(self._provider_ready) worker.provider_notice.connect(self._provider_notice) + worker.artifact_ready.connect(self.artifact_ready.emit) worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) thread.finished.connect(lambda job_id=job_id: self._thread_finished(job_id)) diff --git a/src/matteloop/ui/render_worker.py b/src/matteloop/ui/render_worker.py index f988372..18ac79a 100644 --- a/src/matteloop/ui/render_worker.py +++ b/src/matteloop/ui/render_worker.py @@ -33,6 +33,7 @@ class RenderWorker(QObject): finished = Signal(str) provider_ready = Signal(str) provider_notice = Signal(str) + artifact_ready = Signal(object) def __init__( self, @@ -70,6 +71,7 @@ def run(self) -> None: artifact = rebuild( self._request, self._rebuild_workspace, self._context ) + self.artifact_ready.emit(artifact) if not provider_emitted: self._emit_provider_details() if self._context.terminal_state is JobTerminalState.RUNNING: diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py new file mode 100644 index 0000000..e201f4d --- /dev/null +++ b/src/matteloop/ui/transform_stage.py @@ -0,0 +1,343 @@ +"""Own the "currently open cut" the reducer state cannot hold (D5). + +``ParameterState`` has room for a ``TransformSpec`` (D4) but nothing in +``AppState`` names a cut workspace, and ``core/state.py`` is frozen. This +controller fills that gap: it tracks a ``CutSession`` from the render +worker's raw artifact, recomputes the cut-derived ``CutFacts`` the +``TransformGroup`` readout needs whenever a framing-relevant parameter +changes, and restores a cut's stored transform before "Use this set" +rebuilds it (E17). + +The session deliberately does not cache a ``FramingSpec`` (T6): the current +framing always comes from ``store.state.parameters``, read fresh on every +recomputation, so a padding/trim/stretch edit is what re-derives the framed +size a stale crop is clamped against (E14) -- caching it here would let the +two drift. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass, replace +from decimal import Decimal +from itertools import count +from typing import TYPE_CHECKING, Protocol, cast + +from PIL import Image +from PySide6.QtCore import QObject, QThread, Signal, Slot + +from matteloop.core.crop import clamp_crop +from matteloop.core.geometry import PixelBounds, union_alpha_bounds +from matteloop.core.parameters import ParameterState, TransformChanged +from matteloop.core.specs import FramingSpec +from matteloop.core.state import AppState +from matteloop.core.timebase import webp_delays +from matteloop.jobs.transform_stage import framing_plan +from matteloop.jobs.transform_store import load_transform +from matteloop.jobs.workspace import CutFrame, CutManifest, CutWorkspace +from matteloop.ui.crop_canvas import CropCanvas +from matteloop.ui.ports import StateStore +from matteloop.ui.transform_group import CutFacts, TransformGroup + +if TYPE_CHECKING: + from fractions import Fraction + + +class FrameReader(Protocol): + """Read one stored cut frame directly by manifest filename.""" + + def read(self, workspace: CutWorkspace, frame: CutFrame) -> Image.Image: ... + + +class _DirectFrameReader: + """Open a stored frame straight off disk -- never through + ``CutWorkspace.read_promoted_cut``, which rescans the whole cut per call. + """ + + def read(self, workspace: CutWorkspace, frame: CutFrame) -> Image.Image: + with Image.open(workspace.path / frame.filename) as opened: + opened.load() + return opened.convert("RGBA") + + +@dataclass(frozen=True) +class CutSession: + """The cut currently open in the Transform group -- no framing cached.""" + + workspace: CutWorkspace + manifest: CutManifest + fps: int + + +class _CutFactsWorker(QObject): + """Recompute one ``CutFacts`` off the GUI thread (facts can decode frames).""" + + succeeded = Signal(object, int) + failed = Signal(int) + finished = Signal() + + def __init__( + self, + session: CutSession, + framing: FramingSpec, + frame_reader: FrameReader, + generation: int, + ) -> None: + super().__init__() + self._session = session + self._framing = framing + self._frame_reader = frame_reader + self._generation = generation + + @Slot() + def run(self) -> None: + try: + facts = _compute_facts(self._session, self._framing, self._frame_reader) + except Exception: + self.failed.emit(self._generation) + else: + self.succeeded.emit(facts, self._generation) + finally: + self.finished.emit() + + +class TransformStageController(QObject): + """Track the open cut, its derived facts, and the group/canvas it feeds.""" + + facts_changed = Signal(object) + + def __init__( + self, + store: StateStore, + *, + frame_reader: FrameReader | None = None, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self._store = store + self._frame_reader = frame_reader or _DirectFrameReader() + self._session: CutSession | None = None + self._facts: CutFacts | None = None + self._group: TransformGroup | None = None + self._canvas: CropCanvas | None = None + self._crop_edit_enabled = False + self._aspect_lock: Fraction | None = None + self._generations = count(1) + self._current_generation = 0 + self._thread: QThread | None = None + self._worker: _CutFactsWorker | None = None + self._framing = _framing_from_parameters(store.state.parameters) + self._unsubscribe: Callable[[], None] | None = store.subscribe( + self._state_changed + ) + + def attach(self, group: TransformGroup, canvas: CropCanvas | None) -> None: + """Wire the group's signals and, once it exists, the result canvas. + + ``main_window.py`` only wires ``command_requested`` for the timeline, + the original canvas, and the inspector (D5) -- a fourth widget needs + its own connection, which is what this method is for. + """ + self._group = group + self._canvas = canvas + group.use_playhead_requested.connect(self._use_playhead_requested) + group.crop_edit_toggled.connect(self._crop_edit_toggled) + group.aspect_lock_changed.connect(self._aspect_lock_changed) + if canvas is not None: + canvas.command_requested.connect(self._store.dispatch) + group.set_cut(self._facts) + + def open_artifact(self, artifact: object) -> None: + """Adopt the cut behind a just-finished render/rebuild, if it has one.""" + workspace = getattr(artifact, "cut_workspace", None) + manifest = getattr(artifact, "manifest", None) + if not isinstance(workspace, CutWorkspace) or not isinstance( + manifest, CutManifest + ): + return + self._session = CutSession(workspace, manifest, _fps_from_manifest(manifest)) + self._schedule_facts() + + def restore_for(self, workspace: CutWorkspace) -> None: + """Dispatch the cut's stored transform before a rebuild request is built. + + Clamps the crop when facts for this exact cut are already known + (E14); otherwise dispatches the raw stored spec and lets the facts + recomputation triggered by the rebuild's own ``open_artifact`` clamp + it -- never blocks this call on decoding the cut's frames. + """ + transform = load_transform(workspace) + facts = self._facts + if ( + facts is not None + and facts.cache_key == workspace.cache_key + and transform.crop is not None + ): + clamped = clamp_crop(transform.crop, *facts.framed_size) + if clamped != transform.crop: + transform = replace(transform, crop=clamped) + self._store.dispatch(TransformChanged(transform)) + + def close_session(self) -> None: + self._session = None + self._current_generation = next(self._generations) + self._set_facts(None) + + def shutdown(self) -> None: + if self._unsubscribe is not None: + unsubscribe, self._unsubscribe = self._unsubscribe, None + unsubscribe() + self.close_session() + self._join_worker() + + @property + def session(self) -> CutSession | None: + return self._session + + @property + def facts(self) -> CutFacts | None: + return self._facts + + # -- Stage C seams: state is recorded here, consumed by the player ----- + + def _use_playhead_requested(self, _edge: str) -> None: + """No result player yet (Stage C); nothing to answer with.""" + + def _crop_edit_toggled(self, enabled: bool) -> None: + self._crop_edit_enabled = enabled + + def _aspect_lock_changed(self, ratio: object) -> None: + self._aspect_lock = cast("Fraction | None", ratio) + + # -- facts recomputation ------------------------------------------- + + def _state_changed(self, state: AppState) -> None: + parameters = state.parameters + framing = _framing_from_parameters(parameters) + if framing != self._framing: + self._framing = framing + if self._session is not None: + self._schedule_facts() + + def _schedule_facts(self) -> None: + session = self._session + if session is None: + return + self._join_worker() + generation = next(self._generations) + self._current_generation = generation + worker = _CutFactsWorker(session, self._framing, self._frame_reader, generation) + thread = QThread(self) + worker.moveToThread(thread) + worker.succeeded.connect(self._facts_ready) + worker.failed.connect(self._facts_failed) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.started.connect(worker.run) + self._thread = thread + self._worker = worker + thread.start() + + @Slot(object, int) + def _facts_ready(self, facts: object, generation: int) -> None: + if generation != self._current_generation or not isinstance(facts, CutFacts): + return + self._clamp_current_crop(facts) + self._set_facts(facts) + + @Slot(int) + def _facts_failed(self, generation: int) -> None: + if generation == self._current_generation: + self._set_facts(None) + + def _clamp_current_crop(self, facts: CutFacts) -> None: + transform = self._store.state.parameters.transform + crop = transform.crop + if crop is None: + return + clamped = clamp_crop(crop, *facts.framed_size) + if clamped != crop: + self._store.dispatch(TransformChanged(replace(transform, crop=clamped))) + + def _set_facts(self, facts: CutFacts | None) -> None: + self._facts = facts + if self._group is not None: + self._group.set_cut(facts) + self.facts_changed.emit(facts) + + def _join_worker(self) -> None: + thread = self._thread + self._thread = None + self._worker = None + if thread is None: + return + try: + thread.quit() + thread.wait(5000) + except RuntimeError: + pass + + +def _framing_from_parameters(parameters: ParameterState) -> FramingSpec: + return FramingSpec( + parameters.trim, + parameters.alpha_threshold, + parameters.padding, + parameters.stretch_x, + ) + + +def _fps_from_manifest(manifest: CutManifest) -> int: + sampling = manifest.cache_key_inputs["sampling"] + if not isinstance(sampling, Mapping): + raise ValueError("manifest sampling inputs are not a mapping") + fps = sampling["fps"] + if type(fps) is not int: + raise ValueError("manifest fps is not an integer") + return fps + + +def _compute_facts( + session: CutSession, framing: FramingSpec, frame_reader: FrameReader +) -> CutFacts: + manifest = session.manifest + source_size = (manifest.width, manifest.height) + union = _resolve_union(session, framing, frame_reader) if framing.trim else None + plan = framing_plan(source_size, union, framing) + delays = webp_delays(manifest.frame_count, session.fps) + return CutFacts( + cache_key=manifest.cache_key, + frame_count=manifest.frame_count, + framed_size=plan.output_size, + fps=session.fps, + delays_ms=delays, + ) + + +def _resolve_union( + session: CutSession, framing: FramingSpec, frame_reader: FrameReader +) -> PixelBounds: + metadata = session.manifest.union_metadata + if metadata is not None: + try: + matches = Decimal(metadata.alpha_threshold) == framing.alpha_threshold + except ArithmeticError: + matches = False + if matches: + left, top, right, bottom = metadata.bounds + return PixelBounds(left, top, right, bottom) + return union_alpha_bounds( + _iter_stored_frames(session, frame_reader), framing.alpha_threshold + ) + + +def _iter_stored_frames( + session: CutSession, frame_reader: FrameReader +) -> Iterator[Image.Image]: + for frame in session.manifest.frames: + image = frame_reader.read(session.workspace, frame) + try: + yield image + finally: + image.close() diff --git a/src/matteloop/ui/workspace_controller.py b/src/matteloop/ui/workspace_controller.py index 5868cda..982e74c 100644 --- a/src/matteloop/ui/workspace_controller.py +++ b/src/matteloop/ui/workspace_controller.py @@ -12,6 +12,7 @@ from matteloop.core.errors import AppError, ErrorCode from matteloop.core.specs import RenderRequest from matteloop.jobs.models.catalog import ModelCatalog +from matteloop.jobs.transform_store import discard_transform from matteloop.jobs.workspace import CutWorkspace, WorkspaceSummary, delete_workspace from matteloop.ui.workspace_dialog import WorkspacePickerDialog @@ -61,6 +62,7 @@ def _delete_selected(self, value: object) -> None: return try: delete_workspace(value.workspace, allow_pinned=allow_pinned) + discard_transform(value.workspace) except AppError as error: if error.code is not ErrorCode.CUT_WORKSPACE_PINNED or allow_pinned: QMessageBox.warning( @@ -71,6 +73,7 @@ def _delete_selected(self, value: object) -> None: return try: delete_workspace(value.workspace, allow_pinned=True) + discard_transform(value.workspace) except AppError: return request = self._request_factory() diff --git a/tests/ui/test_render_controller.py b/tests/ui/test_render_controller.py index 750b4d4..6d344ac 100644 --- a/tests/ui/test_render_controller.py +++ b/tests/ui/test_render_controller.py @@ -10,7 +10,8 @@ from PySide6.QtGui import QDesktopServices, QImage from matteloop.core.crop_state import CropChanged -from matteloop.core.specs import CropSpec +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import CropSpec, TransformSpec from matteloop.core.state import ( AppState, ArtifactState, @@ -30,7 +31,7 @@ from matteloop.core.webp import validate_webp from matteloop.jobs.context import CancellationState, JobContext, ProgressEvent from matteloop.jobs.render import ImmutableRgba, PreparedSegmentation, RenderArtifact -from matteloop.jobs.workspace import CutWorkspace, WorkspaceLifecycle +from matteloop.jobs.workspace import CutWorkspace, WorkspaceLifecycle, WorkspaceSummary from matteloop.ui.controller import SourceController from matteloop.ui.ports import ( OpenOutputFolderRequested, @@ -215,6 +216,46 @@ def find_matching_workspace(self, request, context): return self.workspace +class RebuildRuntime(FakeRenderRuntime): + """Answers ``rebuild`` the way a matched-cut path requires.""" + + def rebuild(self, request, workspace, context): + del workspace, context + self.render_requests.append(request) + return type("Artifact", (), {"output_path": request.output.path})() + + +@dataclass(frozen=True) +class _RebuildManifest: + """Duck-typed stand-in exposing only what request_for_workspace reads.""" + + cache_key_inputs: dict[str, object] + source_path: str + + +def _rebuild_manifest(source: Path) -> _RebuildManifest: + return _RebuildManifest( + cache_key_inputs={ + "sampling": { + "start": {"numerator": 0, "denominator": 1}, + "end": {"numerator": 2, "denominator": 1}, + "fps": 15, + }, + "crop": {"x": 0, "y": 0, "width": 128, "height": 128}, + "model": {"id": "u2net"}, + "edge_settings": { + "mode": "standard", + "alpha_matting": { + "foreground_threshold": 240, + "background_threshold": 10, + "erode_size": 10, + }, + }, + }, + source_path=str(source), + ) + + class RecordingStore(ReducerStore): def __init__(self, state) -> None: super().__init__(state) @@ -561,6 +602,63 @@ def test_matching_cut_set_offers_three_choices_with_rebuild_default( controller.shutdown() +def test_artifact_ready_fires_with_the_workers_raw_artifact(tmp_path, qtbot) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"fixture") + runtime = FakeRenderRuntime() + store = RecordingStore(_current_state(source)) + controller = SourceController(store, preview_runtime=runtime) + received: list[object] = [] + controller.render_controller.artifact_ready.connect(received.append) + + controller.dispatch(RenderVideoRequested()) + + qtbot.waitUntil(lambda: store.state.artifact is ArtifactState.VALID, timeout=5000) + assert len(received) == 1 + assert received[0].output_path == runtime.render_requests[0].output.path + controller.shutdown() + + +def test_use_this_set_restores_the_stored_transform_before_rebuilding( + tmp_path, qtbot +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"fixture") + cuts_root = tmp_path / ".matteloop-work" / "cuts" + workspace = CutWorkspace( + tmp_path, + tmp_path / ".matteloop-work", + cuts_root, + tmp_path / ".matteloop-work" / "scratch", + "a" * 64, + cuts_root / "source-aaaaaaaa", + WorkspaceLifecycle.PROMOTED, + None, + "source-aaaaaaaa", + ) + manifest = _rebuild_manifest(source) + runtime = RebuildRuntime() + store = RecordingStore(_current_state(source)) + controller = SourceController(store, preview_runtime=runtime) + restored: list[CutWorkspace] = [] + restored_transform = TransformSpec(first_frame=2) + + def restore(target: CutWorkspace) -> None: + restored.append(target) + store.dispatch(TransformChanged(restored_transform)) + + controller.render_controller.transform_restore = restore + + controller.render_controller._use_workspace( # noqa: SLF001 + WorkspaceSummary(workspace, manifest, 0) + ) + + qtbot.waitUntil(lambda: len(runtime.render_requests) == 1, timeout=5000) + assert restored == [workspace] + assert runtime.render_requests[0].transform == restored_transform + controller.shutdown() + + def test_output_actions_open_artifact_and_its_folder( tmp_path, qtbot, monkeypatch ) -> None: diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py new file mode 100644 index 0000000..c952990 --- /dev/null +++ b/tests/ui/test_transform_stage.py @@ -0,0 +1,242 @@ +"""The controller owning the currently open cut and its derived facts (D5).""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from fractions import Fraction +from pathlib import Path + +from matteloop.core.crop_state import CropChanged +from matteloop.core.parameters import PaddingChanged, TransformChanged +from matteloop.core.specs import CropSpec, FramingSpec, TransformSpec +from matteloop.core.state import ( + AppState, + JobKind, + SourceLoaded, + SourceLoadRequested, + reduce, +) +from matteloop.core.timebase import webp_delays +from matteloop.jobs.render import FilesystemWorkspacePort +from matteloop.jobs.transform_stage import framing_plan +from matteloop.jobs.transform_store import store_transform +from matteloop.jobs.workspace import CutWorkspace +from matteloop.ui.crop_canvas import CropCanvas +from matteloop.ui.store import ReducerStore +from matteloop.ui.transform_group import CutFacts, TransformGroup +from matteloop.ui.transform_stage import TransformStageController +from tests.jobs.render_support import job, render_service, request + + +@dataclass(frozen=True) +class _Metadata: + path: Path + width: int = 128 + height: int = 128 + duration: Fraction = Fraction(2) + average_rate: Fraction = Fraction(30) + + +def _ready_state(path: Path) -> AppState: + """A minimal READY state: ``TransformChanged`` is gated on ``can_edit``, + which requires a loaded source (``core/state.py::capabilities``).""" + loading = reduce(AppState(), SourceLoadRequested("source", "load")) + return reduce(loading, SourceLoaded("source", "load", _Metadata(path))) + + +class _FakeStore: + """A ``StateStore`` double that records dispatches without reducing them.""" + + def __init__(self, state: AppState) -> None: + self._state = state + self.dispatched: list[object] = [] + self._listeners: list[Callable[[AppState], None]] = [] + + @property + def state(self) -> AppState: + return self._state + + def dispatch(self, event: object) -> None: + self.dispatched.append(event) + + def subscribe(self, listener: Callable[[AppState], None]) -> Callable[[], None]: + self._listeners.append(listener) + return lambda: self._listeners.remove(listener) + + +def _seed_cut(tmp_path: Path, job_id: str): + return render_service(workspace=FilesystemWorkspacePort()).render( + request(tmp_path), job(tmp_path, job_id, JobKind.RENDER) + ) + + +def test_open_artifact_without_a_cut_workspace_is_ignored(qtbot) -> None: + store = _FakeStore(AppState()) + controller = TransformStageController(store) + events: list[object] = [] + controller.facts_changed.connect(events.append) + + controller.open_artifact(object()) + + assert controller.session is None + assert events == [] + controller.shutdown() + + +def test_open_artifact_computes_facts_matching_the_shared_framing_stage( + tmp_path, qtbot +) -> None: + artifact = _seed_cut(tmp_path, "seed-facts") + manifest = artifact.manifest + store = _FakeStore(AppState()) + controller = TransformStageController(store) + + controller.open_artifact(artifact) + + qtbot.waitUntil(lambda: controller.facts is not None, timeout=5000) + facts = controller.facts + assert facts is not None + sampling = manifest.cache_key_inputs["sampling"] + fps = sampling["fps"] + assert facts.cache_key == manifest.cache_key + assert facts.frame_count == manifest.frame_count + assert facts.fps == fps + assert facts.delays_ms == webp_delays(manifest.frame_count, fps) + plan = framing_plan((manifest.width, manifest.height), None, FramingSpec()) + assert facts.framed_size == plan.output_size + controller.shutdown() + + +def test_restore_for_dispatches_identity_then_the_stored_transform( + tmp_path, qtbot +) -> None: + workspace = _seed_cut(tmp_path, "seed-restore").cut_workspace + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + + controller.restore_for(workspace) + assert store.state.parameters.transform == TransformSpec() + + spec = TransformSpec(first_frame=1) + store_transform(workspace, spec, []) + + controller.restore_for(workspace) + assert store.state.parameters.transform == spec + controller.shutdown() + + +def test_padding_change_recomputes_facts_from_the_shared_framing_stage( + tmp_path, qtbot +) -> None: + artifact = _seed_cut(tmp_path, "seed-padding") + manifest = artifact.manifest + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: controller.facts is not None, timeout=5000) + original_size = controller.facts.framed_size + + store.dispatch(PaddingChanged(10)) + + qtbot.waitUntil( + lambda: ( + controller.facts is not None + and controller.facts.framed_size != original_size + ), + timeout=5000, + ) + parameters = store.state.parameters + expected = framing_plan( + (manifest.width, manifest.height), + None, + FramingSpec(False, parameters.alpha_threshold, 10, parameters.stretch_x), + ) + assert controller.facts.framed_size == expected.output_size + controller.shutdown() + + +def test_facts_ready_clamps_a_crop_left_over_from_a_larger_framed_size( + tmp_path, qtbot +) -> None: + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + oversized_crop = CropSpec(0, 0, 200, 200) + store.dispatch(TransformChanged(TransformSpec(crop=oversized_crop))) + assert store.state.parameters.transform.crop == oversized_crop + + smaller_facts = CutFacts( + cache_key="a" * 64, + frame_count=4, + framed_size=(128, 128), + fps=15, + delays_ms=(67, 67, 67, 67), + ) + controller._facts_ready(smaller_facts, controller._current_generation) # noqa: SLF001 + + clamped = store.state.parameters.transform.crop + assert clamped is not None + assert clamped.width <= 128 + assert clamped.height <= 128 + assert clamped != oversized_crop + assert controller.facts == smaller_facts + controller.shutdown() + + +def test_shutdown_joins_the_facts_worker_and_clears_the_session( + tmp_path, qtbot +) -> None: + artifact = _seed_cut(tmp_path, "seed-shutdown") + store = _FakeStore(AppState()) + controller = TransformStageController(store) + controller.open_artifact(artifact) + + controller.shutdown() + + assert controller.session is None + assert controller.facts is None + assert controller._thread is None # noqa: SLF001 + + +def test_attach_wires_the_canvas_command_requested_to_the_store(qtbot) -> None: + store = _FakeStore(AppState()) + controller = TransformStageController(store) + canvas = CropCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + + controller.attach(group, canvas) + canvas.command_requested.emit(CropChanged(CropSpec(0, 0, 10, 10))) + + assert store.dispatched == [CropChanged(CropSpec(0, 0, 10, 10))] + controller.shutdown() + + +def test_attach_without_a_canvas_leaves_the_group_disabled_until_a_cut_opens( + qtbot, +) -> None: + store = _FakeStore(AppState()) + controller = TransformStageController(store) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + + controller.attach(group, None) + + assert group._facts is None # noqa: SLF001 + controller.shutdown() + + +def test_open_artifact_ignored_leaves_an_existing_session_untouched( + tmp_path, qtbot +) -> None: + artifact = _seed_cut(tmp_path, "seed-keep") + store = _FakeStore(AppState()) + controller = TransformStageController(store) + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: controller.session is not None, timeout=5000) + + controller.open_artifact(object()) + + assert isinstance(controller.session.workspace, CutWorkspace) + controller.shutdown() diff --git a/tests/ui/test_workspace_controller.py b/tests/ui/test_workspace_controller.py new file mode 100644 index 0000000..ba1705b --- /dev/null +++ b/tests/ui/test_workspace_controller.py @@ -0,0 +1,37 @@ +"""Picker lifecycle and safe actions for promoted cut sets.""" + +from __future__ import annotations + +from matteloop.core.specs import TransformSpec +from matteloop.core.state import JobKind +from matteloop.jobs.render import FilesystemWorkspacePort +from matteloop.jobs.transform_store import store_transform, transform_sidecar_path +from matteloop.jobs.workspace import WorkspaceSummary +from matteloop.ui.workspace_controller import WorkspacePickerController +from tests.jobs.render_support import job, render_service, request + + +def test_delete_selected_removes_the_transform_sidecar_with_the_cut( + tmp_path, qtbot +) -> None: + artifact = render_service(workspace=FilesystemWorkspacePort()).render( + request(tmp_path), job(tmp_path, "seed-delete", JobKind.RENDER) + ) + workspace = artifact.cut_workspace + store_transform(workspace, TransformSpec(first_frame=1), []) + sidecar = transform_sidecar_path(workspace) + assert sidecar.exists() + + controller = WorkspacePickerController( + dialog_parent=None, + request_factory=lambda: request(tmp_path), + active_workspace=lambda: None, + ) + qtbot.addWidget(controller.dialog) + + controller._delete_selected( # noqa: SLF001 + WorkspaceSummary(workspace, artifact.manifest, 0) + ) + + assert not workspace.path.exists() + assert not sidecar.exists() From e7039933ecc610369694e07028ec2a02f6d13e2d Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 03:34:18 +0200 Subject: [PATCH 19/45] feat: reuse CropCanvas via constrain/crop-event hooks (issue #25 task C1) CropCanvas gains title/object_name/runtime_root constructor keywords (default unchanged: "Original"/"original_canvas") so a subclass can drive a different titled canvas without forking the class. Both emit sites -- the drag path (_emit_drag) and keyPressEvent -- now route the candidate crop through two overridable hooks before dispatch: _constrain (identity by default) re-fits the rectangle, and _crop_event (CropChanged by default) builds the command to emit. keyPressEvent previously emitted CropChanged directly, bypassing any hook a subclass could reuse; a result canvas built on CropCanvas would have dispatched a source crop change on an arrow-key nudge and forced re-segmentation on the next render (E30). Tests cover the default behaviour, a subclass overriding both hooks for drag and keyboard, and the constructor keyword overrides. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/crop_canvas.py | 39 +++++++++++++++++--- tests/ui/test_crop_canvas.py | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/matteloop/ui/crop_canvas.py b/src/matteloop/ui/crop_canvas.py index 099ccc9..435efc8 100644 --- a/src/matteloop/ui/crop_canvas.py +++ b/src/matteloop/ui/crop_canvas.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from PySide6.QtCore import QPointF, QRectF, Qt, Signal from PySide6.QtGui import QColor, QKeyEvent, QMouseEvent, QPainter, QPen from PySide6.QtWidgets import QWidget @@ -23,6 +25,7 @@ SizeF, build_crop_geometry, ) +from matteloop.core.specs import CropSpec from matteloop.ui.crop_presentation import CropPresentation from matteloop.ui.preview_canvas import PreviewCanvas from matteloop.ui.theme import ACCENT_COLOR, CANVAS_COLOR, TEXT_COLOR @@ -44,8 +47,15 @@ class CropCanvas(PreviewCanvas): command_requested = Signal(object) - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__("Original", "original_canvas", parent) + def __init__( + self, + parent: QWidget | None = None, + *, + title: str = "Original", + object_name: str = "original_canvas", + runtime_root: Path | None = None, + ) -> None: + super().__init__(title, object_name, parent, runtime_root=runtime_root) self.layout().setContentsMargins(0, 0, 0, 0) # type: ignore[union-attr] self.status_label.hide() self.setMouseTracking(True) @@ -206,8 +216,9 @@ def keyPressEvent(self, event: QKeyEvent) -> None: except ValueError: super().keyPressEvent(event) return + crop = self._constrain(crop, self._focused_target) if crop != self._presentation.crop: - self.command_requested.emit(CropChanged(crop)) + self.command_requested.emit(self._crop_event(crop)) self._announce_crop() event.accept() @@ -241,8 +252,28 @@ def _emit_drag(self, position: QPointF) -> None: source_width=presentation.width, source_height=presentation.height, ) + crop = self._constrain(crop, self._dragged) if self._presentation is None or crop != self._presentation.crop: - self.command_requested.emit(CropChanged(crop)) + self.command_requested.emit(self._crop_event(crop)) + + def _constrain(self, crop: CropSpec, target: str) -> CropSpec: + """Re-fit a candidate crop before it is compared/emitted. + + Identity by default; ``ResultPlayerCanvas`` overrides this to apply + an aspect lock. + """ + return crop + + def _crop_event(self, crop: CropSpec) -> object: + """Build the command to dispatch for a changed crop. + + Default: ``CropChanged`` (the source crop). ``ResultPlayerCanvas`` + overrides this to dispatch ``TransformChanged`` instead -- routing + both the drag site and ``keyPressEvent`` through this hook is what + keeps an arrow-key nudge on the result canvas from dispatching a + *source* crop change (edge case E30). + """ + return CropChanged(crop) def _rebuild_geometry( self, *, focused: str | None = None, dragged: str | None = None diff --git a/tests/ui/test_crop_canvas.py b/tests/ui/test_crop_canvas.py index 47073b9..d7af6d4 100644 --- a/tests/ui/test_crop_canvas.py +++ b/tests/ui/test_crop_canvas.py @@ -187,6 +187,69 @@ def test_crop_canvas_drag_from_the_media_gutter_emits_crop_changed(qtbot) -> Non assert any(isinstance(event, CropChanged) for event in events) +def test_crop_canvas_accepts_title_and_object_name_overrides(qtbot) -> None: + canvas = CropCanvas(title="Result", object_name="x") + qtbot.addWidget(canvas) + + assert canvas.objectName() == "x" + assert canvas.accessibleName() == "Background-removed result" + + +class _ConstrainedCanvas(CropCanvas): + """A subclass exercising the reuse hooks a real result canvas overrides.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.events: list[object] = [] + + def _constrain(self, crop, target): + return CropSpec(crop.x, crop.y, crop.width, crop.width) + + def _crop_event(self, crop): + event = ("constrained", crop) + self.events.append(event) + return event + + +def test_crop_canvas_drag_routes_through_the_constrain_and_event_hooks(qtbot) -> None: + canvas = _ConstrainedCanvas() + qtbot.addWidget(canvas) + canvas.resize(200, 100) + canvas.set_frame(QImage(100, 50, QImage.Format.Format_RGBA8888)) + canvas.apply_presentation(_presentation(), active=True, editable=True) + canvas.show() + + qtbot.mousePress(canvas, Qt.MouseButton.LeftButton, pos=QPoint(60, 80)) + qtbot.mouseMove(canvas, QPoint(80, 90)) + qtbot.mouseRelease(canvas, Qt.MouseButton.LeftButton, pos=QPoint(80, 90)) + + assert canvas.events, "the drag must reach the overridden hooks" + kind, crop = canvas.events[-1] + assert kind == "constrained" + assert crop.width == crop.height + + +def test_crop_canvas_keyboard_nudge_routes_through_the_constrain_and_event_hooks( + qtbot, +) -> None: + canvas = _ConstrainedCanvas() + qtbot.addWidget(canvas) + canvas.resize(200, 100) + canvas.set_frame(QImage(100, 50, QImage.Format.Format_RGBA8888)) + canvas.apply_presentation(_presentation(), active=True, editable=True) + canvas.show() + canvas.activateWindow() + canvas.setFocus() + qtbot.waitUntil(canvas.hasFocus, timeout=1000) + + qtbot.keyClick(canvas, Qt.Key.Key_Left) + + assert canvas.events, "the keyboard nudge must reach the overridden hooks" + kind, crop = canvas.events[-1] + assert kind == "constrained" + assert crop.width == crop.height + + def test_crop_canvas_uses_the_clamped_inset_for_a_tiny_canvas(qtbot) -> None: canvas = CropCanvas() qtbot.addWidget(canvas) From a7614d3c296f6dd089cab95340794159e4f4de4f Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 03:35:21 +0200 Subject: [PATCH 20/45] feat: add the looping result player and its bounded frame cache (issue #25 task C2) ResultPlayerCanvas (new ui/result_player.py) subclasses CropCanvas: a QTimer loop steps through PlayerFrames.transformed at the cut's per-frame delays (webp_delays, non-uniform), wraps at the kept range, and shows the framed cache with the crop overlay instead while "Edit crop" is on. It overrides the C1 hooks so a drag or arrow-key nudge dispatches TransformChanged with the crop -- not the source CropChanged. A repeated preview-image call is ignored by identity; a genuinely new one pauses playback (Preview Frame still wins). PreviewStage now constructs this canvas for result_canvas; set_cover_frame keeps its existing meaning, and the player forces fit mode while a session is loaded so a `pad` result's transparent columns are not cropped away, then restores whatever cover mode was set before. Both frame caches (framed, transformed) are built by FrameLoadWorker on a worker thread, calling the same apply_framing/apply_transform the encoder calls, on the same stored frames -- never through read_promoted_cut, which rescans the whole cut per call. Display size shrinks first to keep both caches under PLAYER_CACHE_BUDGET_BYTES (128 MiB); once a 64 px floor is hit, the cache instead holds the first N stored frames and the canvas shows "Previewing the first N of M frames". TransformStageController now owns that worker's thread, a generation counter that drops a stale result, and a 250 ms single-shot debounce timer: a crop or resize edit while "Edit crop" is on triggers no rebuild at all (the canvas is already showing the framed cache); toggling it off schedules one debounced reload. Facts recomputation now also resolves and keeps the FramingPlan the loader needs to call apply_framing per frame. app.py wires the real result_canvas into attach_transform_stage instead of the None placeholder B6 left behind, now that the widget exists. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/app.py | 4 +- src/matteloop/ui/preview_canvas.py | 5 +- src/matteloop/ui/result_player.py | 426 ++++++++++++++++++++++++++++ src/matteloop/ui/transform_stage.py | 187 +++++++++++- tests/ui/test_result_player.py | 294 +++++++++++++++++++ tests/ui/test_transform_stage.py | 91 +++++- 6 files changed, 985 insertions(+), 22 deletions(-) create mode 100644 src/matteloop/ui/result_player.py create mode 100644 tests/ui/test_result_player.py diff --git a/src/matteloop/app.py b/src/matteloop/app.py index bb27a83..a3de375 100644 --- a/src/matteloop/app.py +++ b/src/matteloop/app.py @@ -71,7 +71,9 @@ def _run_gui() -> int: provider_options=provider_options, ) controller.set_dialog_parent(window) - controller.attach_transform_stage(window.inspector.transform_group, None) + controller.attach_transform_stage( + window.inspector.transform_group, window.result_canvas + ) application.aboutToQuit.connect(controller.shutdown) window.show() return application.exec() diff --git a/src/matteloop/ui/preview_canvas.py b/src/matteloop/ui/preview_canvas.py index 854edac..ce9e8b1 100644 --- a/src/matteloop/ui/preview_canvas.py +++ b/src/matteloop/ui/preview_canvas.py @@ -232,15 +232,14 @@ def __init__( ) -> None: super().__init__(parent) from matteloop.ui.crop_canvas import CropCanvas + from matteloop.ui.result_player import ResultPlayerCanvas self.setObjectName("preview_stage") layout = QHBoxLayout(self) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(8) self.original_canvas = CropCanvas() - self.result_canvas = PreviewCanvas( - "Result", "result_canvas", runtime_root=runtime_root - ) + self.result_canvas = ResultPlayerCanvas(runtime_root=runtime_root) self.original_canvas.set_cover_frame(False) self.result_canvas.set_cover_frame(True) self.original_canvas.setText("Original") diff --git a/src/matteloop/ui/result_player.py b/src/matteloop/ui/result_player.py new file mode 100644 index 0000000..ed85797 --- /dev/null +++ b/src/matteloop/ui/result_player.py @@ -0,0 +1,426 @@ +"""Looping player for a finished cut's transformed frames (Stage C, D7). + +Reuses ``CropCanvas``'s crop-editing machinery -- its two ``_constrain`` / +``_crop_event`` hooks (task C1) -- for the "Edit crop" overlay, so a drag or +an arrow-key nudge on this canvas dispatches ``TransformChanged`` and never +the source ``CropChanged`` a plain ``CropCanvas`` emits (edge case E30). + +Playback never calls segmentation or the encoder (AC 9): both frame caches +are plain Pillow images produced by ``core.geometry.apply_framing`` and +``core.transform.apply_transform`` -- the same two functions the encoder +calls, on the encoder's inputs (design rule, ``matteloop-desktop-app.md:33``) +-- run on a worker thread owned by ``TransformStageController`` and handed to +this canvas as display-scaled ``QImage``s. Memory is bounded by +``PLAYER_CACHE_BUDGET_BYTES``: the display size shrinks first: only once the +64 px floor is hit does the cache truncate to the first N stored frames +(E32), which this canvas surfaces as a status marker. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace +from fractions import Fraction +from pathlib import Path +from typing import Protocol + +from PIL import Image, UnidentifiedImageError +from PySide6.QtCore import QObject, QSize, Qt, QTimer, Signal, Slot +from PySide6.QtGui import QImage +from PySide6.QtWidgets import QPushButton, QWidget + +from matteloop.core.crop import fit_crop_aspect +from matteloop.core.geometry import FramingPlan, apply_framing +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import CropSpec, TransformSpec +from matteloop.core.transform import apply_transform +from matteloop.jobs.workspace import CutFrame, CutManifest, CutWorkspace +from matteloop.ui.crop_canvas import CropCanvas +from matteloop.ui.crop_presentation import CropPresentation + +PLAYER_CACHE_BUDGET_BYTES = 128 * 1024 * 1024 +PLAYER_MIN_DISPLAY_SIDE = 64 + + +class FrameReader(Protocol): + """Read one stored cut frame directly by manifest filename.""" + + def read(self, workspace: CutWorkspace, frame: CutFrame) -> Image.Image: ... + + +@dataclass(frozen=True) +class PlayerFrames: + """Two display-scaled frame caches sharing one identity key.""" + + key: object + framed: tuple[QImage, ...] + transformed: tuple[QImage, ...] | None + delays_ms: tuple[int, ...] + cached: int + frame_count: int + + +class ResultPlayerCanvas(CropCanvas): + """Loop a cut's framed/transformed frames; edit its output crop in place.""" + + playhead_changed = Signal(int) + + def __init__( + self, parent: QWidget | None = None, *, runtime_root: Path | None = None + ) -> None: + super().__init__( + parent, + title="Result", + object_name="result_canvas", + runtime_root=runtime_root, + ) + self._frames: PlayerFrames | None = None + self._kept: range | None = None + self._crop_edit = False + self._aspect_lock: Fraction | None = None + self._transform = TransformSpec() + self._playhead_index: int | None = None + self._playing = False + self._cover_before_session: bool | None = None + self._last_preview_image: QImage | None = None + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.timeout.connect(self._advance) + self.play_button = QPushButton("Play", self) + self.play_button.setObjectName("result_play") + self.play_button.setAccessibleName("Play the result loop") + self.play_button.setCheckable(True) + self.play_button.hide() + self.play_button.toggled.connect(self._play_toggled) + layout = self.layout() + assert layout is not None + layout.addWidget(self.play_button) + layout.setAlignment( + self.play_button, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight + ) + + # -- public surface ---------------------------------------------------- + + def set_frames(self, frames: PlayerFrames | None) -> None: + """Replace the cached frames; ``None`` closes the session (E31).""" + self.pause() + had_session = self._frames is not None + if frames is not None and not had_session: + self._cover_before_session = self._cover_frame + self.set_cover_frame(False) + elif frames is None and had_session and self._cover_before_session is not None: + self.set_cover_frame(self._cover_before_session) + self._cover_before_session = None + self._frames = frames + self._kept = None + if frames is None: + self.play_button.hide() + self._playhead_index = None + self.set_status_marker(None) + self.set_frame(None) + return + self.play_button.show() + playable = self._playable() + self._playhead_index = playable.start if playable else None + self._update_truncation_marker(frames) + self._show_current_frame() + + def set_kept_range(self, kept: range) -> None: + """Re-slice playback to *kept* without reloading either cache.""" + self._kept = kept + if self._frames is None: + return + playable = self._playable() + if self._playhead_index is None or self._playhead_index not in playable: + self._playhead_index = playable.start if playable else None + self._show_current_frame() + + def set_crop_edit( + self, + enabled: bool, + presentation: CropPresentation | None, + transform: TransformSpec | None = None, + ) -> None: + """Toggle between the crop overlay (framed frames) and the result loop.""" + self._crop_edit = enabled + self._transform = transform if transform is not None else TransformSpec() + self.apply_presentation(presentation, active=enabled, editable=enabled) + self._show_current_frame() + + def set_aspect_lock(self, ratio: Fraction | None) -> None: + self._aspect_lock = ratio + + def play(self) -> None: + if self._frames is None or not self._playable(): + return + self._playing = True + if not self.play_button.isChecked(): + self.play_button.setChecked(True) + self._schedule_next() + + def pause(self) -> None: + self._playing = False + self._timer.stop() + if self.play_button.isChecked(): + self.play_button.setChecked(False) + + @property + def playing(self) -> bool: + return self._playing + + @property + def current_frame(self) -> int | None: + """The stored-cut frame index currently displayed, if any.""" + return self._playhead_index + + def set_presented_frame(self, image: object, placeholder: str) -> None: + """Yield to a genuinely new preview image; ignore a repeated one (E26).""" + if ( + self._frames is not None + and isinstance(image, QImage) + and not image.isNull() + ): + if image is self._last_preview_image: + return + self._last_preview_image = image + self.pause() + super().set_presented_frame(image, placeholder) + return + self._last_preview_image = image if isinstance(image, QImage) else None + super().set_presented_frame(image, placeholder) + + # -- CropCanvas reuse hooks (C1) ----------------------------------------- + + def _constrain(self, crop: CropSpec, target: str) -> CropSpec: + if self._aspect_lock is None or self._presentation is None: + return crop + return fit_crop_aspect( + crop, + self._aspect_lock, + target, + source_width=self._presentation.width, + source_height=self._presentation.height, + ) + + def _crop_event(self, crop: CropSpec) -> object: + return TransformChanged(replace(self._transform, crop=crop)) + + # -- playback internals --------------------------------------------- + + def _play_toggled(self, checked: bool) -> None: + if checked: + self.play() + else: + self.pause() + + def _playable(self) -> range: + frames = self._frames + if frames is None: + return range(0) + cached = frames.cached + kept = self._kept + start = 0 if kept is None else max(kept.start, 0) + stop = cached if kept is None else min(kept.stop, cached) + return range(start, stop) if start < stop else range(0) + + def _current_images(self) -> tuple[QImage, ...]: + frames = self._frames + assert frames is not None + if self._crop_edit or frames.transformed is None: + return frames.framed + return frames.transformed + + def _update_truncation_marker(self, frames: PlayerFrames) -> None: + if frames.cached < frames.frame_count: + self.set_status_marker( + f"Previewing the first {frames.cached} of {frames.frame_count} frames" + ) + else: + self.set_status_marker(None) + + def _show_current_frame(self) -> None: + index = self._playhead_index + if self._frames is None or index is None: + self.set_frame(None) + return + images = self._current_images() + if 0 <= index < len(images): + self.set_frame(images[index]) + self.playhead_changed.emit(index) + + def _schedule_next(self) -> None: + frames = self._frames + index = self._playhead_index + if not self._playing or frames is None or index is None: + return + delay = frames.delays_ms[index] if index < len(frames.delays_ms) else 100 + self._timer.start(max(1, delay)) + + def _advance(self) -> None: + playable = self._playable() + if not playable or self._playhead_index is None: + self.pause() + return + next_index = self._playhead_index + 1 + if next_index >= playable.stop: + next_index = playable.start + self._playhead_index = next_index + self._show_current_frame() + self._schedule_next() + + +class FrameLoadWorker(QObject): + """Build one ``PlayerFrames`` off the GUI thread (decoding is not free).""" + + succeeded = Signal(object, int) + failed = Signal(str, int) + finished = Signal() + + def __init__( + self, + workspace: CutWorkspace, + manifest: CutManifest, + frame_reader: FrameReader, + plan: FramingPlan, + transform: TransformSpec, + delays: tuple[int, ...], + generation: int, + *, + budget: int = PLAYER_CACHE_BUDGET_BYTES, + ) -> None: + super().__init__() + self._workspace = workspace + self._manifest = manifest + self._frame_reader = frame_reader + self._plan = plan + self._transform = transform + self._delays = delays + self._generation = generation + self._budget = budget + + @Slot() + def run(self) -> None: + try: + frames = _load_player_frames( + self._workspace, + self._manifest, + self._frame_reader, + self._plan, + self._transform, + self._delays, + self._budget, + ) + except (OSError, UnidentifiedImageError): + self.failed.emit("Cut frames could not be read", self._generation) + else: + self.succeeded.emit(frames, self._generation) + finally: + self.finished.emit() + + +def _load_player_frames( + workspace: CutWorkspace, + manifest: CutManifest, + frame_reader: FrameReader, + plan: FramingPlan, + transform: TransformSpec, + delays: tuple[int, ...], + budget: int, +) -> PlayerFrames: + frame_count = manifest.frame_count + display_size, cached = _fit_budget(frame_count, plan.output_size, budget) + key = ( + manifest.cache_key, + plan.output_size, + transform.crop, + transform.resize, + display_size, + ) + framed_images: list[QImage] = [] + transformed_images: list[QImage] = [] + for index in range(cached): + framed_qimage, transformed_qimage = _load_one_frame( + workspace, + manifest.frames[index], + frame_reader, + plan, + transform, + display_size, + ) + framed_images.append(framed_qimage) + transformed_images.append(transformed_qimage) + return PlayerFrames( + key=key, + framed=tuple(framed_images), + transformed=tuple(transformed_images), + delays_ms=delays, + cached=cached, + frame_count=frame_count, + ) + + +def _load_one_frame( + workspace: CutWorkspace, + frame: CutFrame, + frame_reader: FrameReader, + plan: FramingPlan, + transform: TransformSpec, + display_size: tuple[int, int], +) -> tuple[QImage, QImage]: + cut = frame_reader.read(workspace, frame) + try: + framed = apply_framing(cut, plan) + finally: + cut.close() + try: + framed_qimage = _scaled_qimage(framed, display_size) + transformed = apply_transform(framed, transform) + try: + transformed_qimage = _scaled_qimage(transformed, display_size) + finally: + if transformed is not framed: + transformed.close() + finally: + framed.close() + return framed_qimage, transformed_qimage + + +def _fit_budget( + frame_count: int, base_size: tuple[int, int], budget: int +) -> tuple[tuple[int, int], int]: + """Shrink the display size (aspect kept) to fit *budget*; below the + minimum short side, truncate the cached frame count instead (E32).""" + width, height = base_size + if frame_count <= 0 or width <= 0 or height <= 0: + return (max(1, width), max(1, height)), 0 + per_frame_limit = budget / (2 * frame_count * 4) + scale = min(1.0, math.sqrt(per_frame_limit / (width * height))) + display_width = max(1, math.floor(width * scale)) + display_height = max(1, math.floor(height * scale)) + if min(display_width, display_height) >= PLAYER_MIN_DISPLAY_SIDE: + return (display_width, display_height), frame_count + if width <= height: + display_width = PLAYER_MIN_DISPLAY_SIDE + display_height = max(1, round(height * display_width / width)) + else: + display_height = PLAYER_MIN_DISPLAY_SIDE + display_width = max(1, round(width * display_height / height)) + per_frame_bytes = 2 * display_width * display_height * 4 + cached = max(1, min(frame_count, budget // per_frame_bytes)) + return (display_width, display_height), cached + + +def _scaled_qimage(image: Image.Image, display_size: tuple[int, int]) -> QImage: + return _qimage_from_pillow(image).scaled( + QSize(*display_size), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + +def _qimage_from_pillow(image: Image.Image) -> QImage: + rgba = image.convert("RGBA") + raw = rgba.tobytes() + return QImage( + raw, rgba.width, rgba.height, rgba.width * 4, QImage.Format.Format_RGBA8888 + ).copy() diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index e201f4d..9a5d4ab 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -24,19 +24,26 @@ from typing import TYPE_CHECKING, Protocol, cast from PIL import Image -from PySide6.QtCore import QObject, QThread, Signal, Slot +from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot from matteloop.core.crop import clamp_crop -from matteloop.core.geometry import PixelBounds, union_alpha_bounds +from matteloop.core.geometry import FramingPlan, PixelBounds, union_alpha_bounds from matteloop.core.parameters import ParameterState, TransformChanged -from matteloop.core.specs import FramingSpec +from matteloop.core.specs import CropSpec, FramingSpec, TransformSpec from matteloop.core.state import AppState from matteloop.core.timebase import webp_delays from matteloop.jobs.transform_stage import framing_plan from matteloop.jobs.transform_store import load_transform from matteloop.jobs.workspace import CutFrame, CutManifest, CutWorkspace from matteloop.ui.crop_canvas import CropCanvas +from matteloop.ui.crop_presentation import CropPresentation from matteloop.ui.ports import StateStore +from matteloop.ui.result_player import ( + PLAYER_CACHE_BUDGET_BYTES, + FrameLoadWorker, + PlayerFrames, + ResultPlayerCanvas, +) from matteloop.ui.transform_group import CutFacts, TransformGroup if TYPE_CHECKING: @@ -70,9 +77,14 @@ class CutSession: class _CutFactsWorker(QObject): - """Recompute one ``CutFacts`` off the GUI thread (facts can decode frames).""" + """Recompute one ``CutFacts`` off the GUI thread (facts can decode frames). - succeeded = Signal(object, int) + Also resolves the ``FramingPlan`` behind that ``CutFacts.framed_size`` -- + the player's frame loader needs the whole plan (task C2), not just its + output size, to call ``apply_framing`` per stored frame. + """ + + succeeded = Signal(object, object, int) failed = Signal(int) finished = Signal() @@ -92,11 +104,13 @@ def __init__( @Slot() def run(self) -> None: try: - facts = _compute_facts(self._session, self._framing, self._frame_reader) + facts, plan = _compute_facts( + self._session, self._framing, self._frame_reader + ) except Exception: self.failed.emit(self._generation) else: - self.succeeded.emit(facts, self._generation) + self.succeeded.emit(facts, plan, self._generation) finally: self.finished.emit() @@ -111,6 +125,7 @@ def __init__( store: StateStore, *, frame_reader: FrameReader | None = None, + cache_budget: int = PLAYER_CACHE_BUDGET_BYTES, parent: QObject | None = None, ) -> None: super().__init__(parent) @@ -118,8 +133,10 @@ def __init__( self._frame_reader = frame_reader or _DirectFrameReader() self._session: CutSession | None = None self._facts: CutFacts | None = None + self._plan: FramingPlan | None = None self._group: TransformGroup | None = None self._canvas: CropCanvas | None = None + self._player_canvas: ResultPlayerCanvas | None = None self._crop_edit_enabled = False self._aspect_lock: Fraction | None = None self._generations = count(1) @@ -127,6 +144,17 @@ def __init__( self._thread: QThread | None = None self._worker: _CutFactsWorker | None = None self._framing = _framing_from_parameters(store.state.parameters) + self._last_transform: TransformSpec = store.state.parameters.transform + self._player_frames: PlayerFrames | None = None + self._frame_generations = count(1) + self._frame_generation = 0 + self._frame_thread: QThread | None = None + self._frame_worker: FrameLoadWorker | None = None + self._cache_budget = cache_budget + self._debounce_timer = QTimer(self) + self._debounce_timer.setSingleShot(True) + self._debounce_timer.setInterval(250) + self._debounce_timer.timeout.connect(self._start_frame_load) self._unsubscribe: Callable[[], None] | None = store.subscribe( self._state_changed ) @@ -140,12 +168,14 @@ def attach(self, group: TransformGroup, canvas: CropCanvas | None) -> None: """ self._group = group self._canvas = canvas + self._player_canvas = canvas if isinstance(canvas, ResultPlayerCanvas) else None group.use_playhead_requested.connect(self._use_playhead_requested) group.crop_edit_toggled.connect(self._crop_edit_toggled) group.aspect_lock_changed.connect(self._aspect_lock_changed) if canvas is not None: canvas.command_requested.connect(self._store.dispatch) group.set_cut(self._facts) + self._sync_player_frames(immediate=True) def open_artifact(self, artifact: object) -> None: """Adopt the cut behind a just-finished render/rebuild, if it has one.""" @@ -180,6 +210,7 @@ def restore_for(self, workspace: CutWorkspace) -> None: def close_session(self) -> None: self._session = None + self._plan = None self._current_generation = next(self._generations) self._set_facts(None) @@ -189,6 +220,7 @@ def shutdown(self) -> None: unsubscribe() self.close_session() self._join_worker() + self._cancel_frame_load() @property def session(self) -> CutSession | None: @@ -198,26 +230,67 @@ def session(self) -> CutSession | None: def facts(self) -> CutFacts | None: return self._facts - # -- Stage C seams: state is recorded here, consumed by the player ----- + # -- Stage C: crop-edit mode --------------------------------------- def _use_playhead_requested(self, _edge: str) -> None: - """No result player yet (Stage C); nothing to answer with.""" + """Playhead coupling lands in task C3; nothing to answer with yet.""" def _crop_edit_toggled(self, enabled: bool) -> None: + was_enabled = self._crop_edit_enabled self._crop_edit_enabled = enabled + canvas = self._player_canvas + if canvas is None: + return + transform = self._store.state.parameters.transform + presentation = self._crop_edit_presentation(transform) if enabled else None + canvas.set_crop_edit(enabled, presentation, transform) + if was_enabled and not enabled: + self._sync_player_frames(immediate=False) + + def _crop_edit_presentation( + self, transform: TransformSpec + ) -> CropPresentation | None: + facts = self._facts + if facts is None: + return None + width, height = facts.framed_size + crop = transform.crop or CropSpec(0, 0, width, height) + return CropPresentation( + source_id=facts.cache_key, + width=width, + height=height, + coded_width=width, + coded_height=height, + rotation=0, + pixel_aspect=1.0, + crop=crop, + ) def _aspect_lock_changed(self, ratio: object) -> None: self._aspect_lock = cast("Fraction | None", ratio) + if self._player_canvas is not None: + self._player_canvas.set_aspect_lock(self._aspect_lock) # -- facts recomputation ------------------------------------------- def _state_changed(self, state: AppState) -> None: parameters = state.parameters framing = _framing_from_parameters(parameters) - if framing != self._framing: + framing_changed = framing != self._framing + if framing_changed: self._framing = framing if self._session is not None: self._schedule_facts() + transform = parameters.transform + transform_changed = transform != self._last_transform + self._last_transform = transform + facts = self._facts + if self._player_canvas is not None and facts is not None: + kept = transform.kept_range(facts.frame_count) + self._player_canvas.set_kept_range(kept) + crop_editing = self._crop_edit_enabled + if transform_changed and not framing_changed and not crop_editing: + self._sync_player_frames(immediate=False) def _schedule_facts(self) -> None: session = self._session @@ -239,10 +312,15 @@ def _schedule_facts(self) -> None: self._worker = worker thread.start() - @Slot(object, int) - def _facts_ready(self, facts: object, generation: int) -> None: - if generation != self._current_generation or not isinstance(facts, CutFacts): + @Slot(object, object, int) + def _facts_ready(self, facts: object, plan: object, generation: int) -> None: + if ( + generation != self._current_generation + or not isinstance(facts, CutFacts) + or not isinstance(plan, FramingPlan) + ): return + self._plan = plan self._clamp_current_crop(facts) self._set_facts(facts) @@ -265,6 +343,7 @@ def _set_facts(self, facts: CutFacts | None) -> None: if self._group is not None: self._group.set_cut(facts) self.facts_changed.emit(facts) + self._sync_player_frames(immediate=True) def _join_worker(self) -> None: thread = self._thread @@ -278,6 +357,83 @@ def _join_worker(self) -> None: except RuntimeError: pass + # -- Stage C: the player's frame loader ------------------------------- + + def _sync_player_frames(self, *, immediate: bool) -> None: + canvas = self._player_canvas + if canvas is None: + return + if self._session is None or self._facts is None or self._plan is None: + self._cancel_frame_load() + self._player_frames = None + canvas.set_frames(None) + return + if immediate: + self._start_frame_load() + else: + self._debounce_timer.start() + + def _start_frame_load(self) -> None: + canvas = self._player_canvas + session = self._session + facts = self._facts + plan = self._plan + if canvas is None or session is None or facts is None or plan is None: + return + self._cancel_frame_load() + generation = next(self._frame_generations) + self._frame_generation = generation + transform = self._store.state.parameters.transform + worker = FrameLoadWorker( + session.workspace, + session.manifest, + self._frame_reader, + plan, + transform, + facts.delays_ms, + generation, + budget=self._cache_budget, + ) + thread = QThread(self) + worker.moveToThread(thread) + worker.succeeded.connect(self._frame_load_succeeded) + worker.failed.connect(self._frame_load_failed) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.started.connect(worker.run) + self._frame_thread = thread + self._frame_worker = worker + thread.start() + + @Slot(object, int) + def _frame_load_succeeded(self, frames: object, generation: int) -> None: + if generation != self._frame_generation or not isinstance(frames, PlayerFrames): + return + self._player_frames = frames + if self._player_canvas is not None: + self._player_canvas.set_frames(frames) + + @Slot(str, int) + def _frame_load_failed(self, message: str, generation: int) -> None: + if generation != self._frame_generation: + return + if self._player_canvas is not None: + self._player_canvas.set_status_marker(message) + + def _cancel_frame_load(self) -> None: + self._debounce_timer.stop() + thread = self._frame_thread + self._frame_thread = None + self._frame_worker = None + if thread is None: + return + try: + thread.quit() + thread.wait(5000) + except RuntimeError: + pass + def _framing_from_parameters(parameters: ParameterState) -> FramingSpec: return FramingSpec( @@ -300,19 +456,20 @@ def _fps_from_manifest(manifest: CutManifest) -> int: def _compute_facts( session: CutSession, framing: FramingSpec, frame_reader: FrameReader -) -> CutFacts: +) -> tuple[CutFacts, FramingPlan]: manifest = session.manifest source_size = (manifest.width, manifest.height) union = _resolve_union(session, framing, frame_reader) if framing.trim else None plan = framing_plan(source_size, union, framing) delays = webp_delays(manifest.frame_count, session.fps) - return CutFacts( + facts = CutFacts( cache_key=manifest.cache_key, frame_count=manifest.frame_count, framed_size=plan.output_size, fps=session.fps, delays_ms=delays, ) + return facts, plan def _resolve_union( diff --git a/tests/ui/test_result_player.py b/tests/ui/test_result_player.py new file mode 100644 index 0000000..5b2389f --- /dev/null +++ b/tests/ui/test_result_player.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +from fractions import Fraction +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QImage + +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import CropSpec, FramingSpec, TransformSpec +from matteloop.core.state import ( + AppState, + JobKind, + SourceLoaded, + SourceLoadRequested, + reduce, +) +from matteloop.jobs.render import FilesystemWorkspacePort +from matteloop.jobs.transform_stage import framing_plan +from matteloop.ui.crop_presentation import CropPresentation +from matteloop.ui.result_player import ( + FrameLoadWorker, + PlayerFrames, + ResultPlayerCanvas, + _fit_budget, +) +from matteloop.ui.store import ReducerStore +from matteloop.ui.transform_group import TransformGroup +from matteloop.ui.transform_stage import TransformStageController +from tests.jobs.render_support import ( + FakeEncoder, + FakeSegmenter, + job, + render_service, + request, +) + + +def _presentation(crop: CropSpec) -> CropPresentation: + return CropPresentation( + source_id="cut", + width=128, + height=128, + coded_width=128, + coded_height=128, + rotation=0, + pixel_aspect=1.0, + crop=crop, + ) + + +def _player_frames( + frame_count: int, delays: tuple[int, ...], *, cached: int | None = None +) -> PlayerFrames: + stored = cached if cached is not None else frame_count + framed = tuple(QImage(4, 4, QImage.Format.Format_RGBA8888) for _ in range(stored)) + transformed = tuple( + QImage(4, 4, QImage.Format.Format_RGBA8888) for _ in range(stored) + ) + return PlayerFrames( + key=("cut", (4, 4), None, None, (4, 4)), + framed=framed, + transformed=transformed, + delays_ms=delays, + cached=stored, + frame_count=frame_count, + ) + + +def _ready_state(path: Path) -> AppState: + loading = reduce(AppState(), SourceLoadRequested("source", "load")) + + class _Metadata: + def __init__(self, path: Path) -> None: + self.path = path + self.width = 128 + self.height = 128 + self.duration = Fraction(2) + self.average_rate = Fraction(30) + + return reduce(loading, SourceLoaded("source", "load", _Metadata(path))) + + +def _seed_cut(tmp_path: Path, job_id: str = "seed", *, segmenter=None, encoder=None): + service = render_service( + segmenter=segmenter, + encoder=encoder, + workspace=FilesystemWorkspacePort(), + ) + return service.render(request(tmp_path), job(tmp_path, job_id, JobKind.RENDER)) + + +class _ExplodingReader: + def read(self, workspace: object, frame: object) -> QImage: + del workspace, frame + raise OSError("boom") + + +# -- canvas playback ----------------------------------------------------- + + +def test_canvas_shows_the_kept_range_start_and_wraps_on_the_timer(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + frames = _player_frames(4, (10, 10, 10, 10)) + + canvas.set_frames(frames) + canvas.set_kept_range(range(1, 4)) + + assert canvas.current_frame == 1 + canvas.play() + qtbot.waitUntil(lambda: canvas.current_frame == 2, timeout=1000) + qtbot.waitUntil(lambda: canvas.current_frame == 3, timeout=1000) + qtbot.waitUntil(lambda: canvas.current_frame == 1, timeout=1000) + canvas.pause() + frozen = canvas.current_frame + qtbot.wait(60) + assert canvas.current_frame == frozen + + +def test_new_kept_range_re_slices_without_a_new_playerframes(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + frames = _player_frames(4, (100, 100, 100, 100)) + canvas.set_frames(frames) + canvas.set_kept_range(range(0, 4)) + assert canvas.current_frame == 0 + + canvas.set_kept_range(range(2, 4)) + + assert canvas.current_frame == 2 + assert canvas._frames is frames # noqa: SLF001 + + +def test_play_button_hidden_without_frames_and_shown_with_them(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.show() + assert canvas.play_button.isHidden() + + canvas.set_frames(_player_frames(2, (100, 100))) + assert not canvas.play_button.isHidden() + + canvas.set_frames(None) + assert canvas.play_button.isHidden() + + +def test_session_displays_in_fit_mode_and_restores_cover(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.set_cover_frame(True) + + canvas.set_frames(_player_frames(2, (100, 100))) + assert canvas._cover_frame is False # noqa: SLF001 + + canvas.set_frames(None) + assert canvas._cover_frame is True # noqa: SLF001 + + +def test_new_preview_image_pauses_and_a_repeated_call_is_ignored(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.set_frames(_player_frames(3, (500, 500, 500))) + canvas.play() + assert canvas.playing + + preview = QImage(2, 2, QImage.Format.Format_RGBA8888) + canvas.set_presented_frame(preview, "placeholder") + assert not canvas.playing + + canvas.play() + assert canvas.playing + canvas.set_presented_frame(preview, "placeholder") + assert canvas.playing + + +def test_set_frames_shows_the_truncation_marker_when_cache_was_truncated(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + frames = _player_frames(10, tuple([100] * 4), cached=4) + + canvas.set_frames(frames) + + assert canvas.status_label.text() == "Previewing the first 4 of 10 frames" + assert not canvas.status_label.isHidden() + + canvas.set_frames(_player_frames(4, (100, 100, 100, 100))) + assert canvas.status_label.text() == "" + + +def test_keyboard_nudge_never_emits_a_source_crop_change(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.resize(200, 200) + canvas.set_frame(QImage(128, 128, QImage.Format.Format_RGBA8888)) + presentation = _presentation(CropSpec(10, 10, 40, 20)) + canvas.set_crop_edit(True, presentation, TransformSpec(first_frame=2)) + canvas.show() + canvas.activateWindow() + canvas.setFocus() + qtbot.waitUntil(canvas.hasFocus, timeout=1000) + events: list[object] = [] + canvas.command_requested.connect(events.append) + + qtbot.keyClick(canvas, Qt.Key.Key_Left) + + assert events, "the nudge should dispatch a command" + assert all(isinstance(event, TransformChanged) for event in events) + assert events[-1].transform.first_frame == 2 + assert events[-1].transform.crop == CropSpec(9, 10, 40, 20) + + +def test_aspect_lock_constrains_the_crop_before_dispatch(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + presentation = _presentation(CropSpec(0, 0, 40, 40)) + canvas.set_crop_edit(True, presentation, TransformSpec()) + canvas.set_aspect_lock(Fraction(2, 1)) + + constrained = canvas._constrain( # noqa: SLF001 + CropSpec(0, 0, 40, 60), "south_east" + ) + + assert Fraction(constrained.width, constrained.height) == Fraction(2, 1) + + +# -- budget math ----------------------------------------------------------- + + +def test_fit_budget_shrinks_the_display_size_before_truncating_frames() -> None: + display_size, cached = _fit_budget(10, (1000, 1000), 10_000_000) + assert cached == 10 + assert display_size[0] < 1000 + + +def test_fit_budget_truncates_frames_once_the_floor_is_hit() -> None: + display_size, cached = _fit_budget(1000, (1000, 1000), 200_000) + assert display_size == (64, 64) + assert 1 <= cached < 1000 + + +# -- worker failure (E23) --------------------------------------------------- + + +def test_frame_load_worker_reports_unreadable_frames(tmp_path) -> None: + artifact = _seed_cut(tmp_path, "worker-fail") + manifest = artifact.manifest + plan = framing_plan((manifest.width, manifest.height), None, FramingSpec()) + worker = FrameLoadWorker( + artifact.cut_workspace, + manifest, + _ExplodingReader(), + plan, + TransformSpec(), + (100,) * manifest.frame_count, + 1, + ) + failures: list[str] = [] + worker.failed.connect(lambda message, _generation: failures.append(message)) + worker.run() + + assert failures == ["Cut frames could not be read"] + + +# -- AC 9: zero segmentation, zero encoder calls during playback ------------ + + +def test_playback_never_calls_segmentation_or_the_encoder(tmp_path, qtbot) -> None: + segmenter = FakeSegmenter() + encoder = FakeEncoder() + artifact = _seed_cut(tmp_path, "ac9", segmenter=segmenter, encoder=encoder) + + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + + segment_calls = len(segmenter.calls) + encode_calls = len(encoder.calls) + + canvas.play() + for _ in range(6): + canvas._advance() # noqa: SLF001 + canvas.pause() + + assert len(segmenter.calls) == segment_calls + assert len(encoder.calls) == encode_calls + controller.shutdown() diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index c952990..e1920c5 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -21,11 +21,12 @@ from matteloop.jobs.render import FilesystemWorkspacePort from matteloop.jobs.transform_stage import framing_plan from matteloop.jobs.transform_store import store_transform -from matteloop.jobs.workspace import CutWorkspace +from matteloop.jobs.workspace import CutFrame, CutWorkspace from matteloop.ui.crop_canvas import CropCanvas +from matteloop.ui.result_player import PlayerFrames, ResultPlayerCanvas from matteloop.ui.store import ReducerStore from matteloop.ui.transform_group import CutFacts, TransformGroup -from matteloop.ui.transform_stage import TransformStageController +from matteloop.ui.transform_stage import TransformStageController, _DirectFrameReader from tests.jobs.render_support import job, render_service, request @@ -172,7 +173,10 @@ def test_facts_ready_clamps_a_crop_left_over_from_a_larger_framed_size( fps=15, delays_ms=(67, 67, 67, 67), ) - controller._facts_ready(smaller_facts, controller._current_generation) # noqa: SLF001 + plan = framing_plan((128, 128), None, FramingSpec()) + controller._facts_ready( # noqa: SLF001 + smaller_facts, plan, controller._current_generation + ) clamped = store.state.parameters.transform.crop assert clamped is not None @@ -240,3 +244,84 @@ def test_open_artifact_ignored_leaves_an_existing_session_untouched( assert isinstance(controller.session.workspace, CutWorkspace) controller.shutdown() + + +# -- E24: a stale frame-load generation is discarded ------------------------ + + +def test_frame_load_succeeded_ignores_a_stale_generation(qtbot) -> None: + store = _FakeStore(AppState()) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + controller._frame_generation = 5 # noqa: SLF001 + + stale = PlayerFrames( + key="stale", + framed=(), + transformed=(), + delays_ms=(), + cached=0, + frame_count=0, + ) + controller._frame_load_succeeded(stale, 3) # noqa: SLF001 + + assert canvas._frames is None # noqa: SLF001 + controller.shutdown() + + +# -- E33: crop-edit drags are debounced and skipped while editing ----------- + + +class _CountingReader: + """Wrap ``_DirectFrameReader`` to count how many stored frames were read.""" + + def __init__(self) -> None: + self.calls = 0 + self._inner = _DirectFrameReader() + + def read(self, workspace: CutWorkspace, frame: CutFrame): + self.calls += 1 + return self._inner.read(workspace, frame) + + +def test_crop_edits_are_debounced_and_reuse_the_framed_cache(tmp_path, qtbot) -> None: + artifact = _seed_cut(tmp_path, "debounce") + reader = _CountingReader() + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store, frame_reader=reader) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + frame_count = artifact.manifest.frame_count + initial_reads = reader.calls + assert initial_reads == frame_count + first_key = canvas._frames.key # noqa: SLF001 + + controller._crop_edit_toggled(True) # noqa: SLF001 + for index in range(10): + crop = CropSpec(0, 0, 100, 100 - index) + store.dispatch(TransformChanged(TransformSpec(crop=crop))) + qtbot.wait(60) + assert reader.calls == initial_reads, "no rebuild while crop-edit is on" + + controller._crop_edit_toggled(False) # noqa: SLF001 + qtbot.waitUntil( + lambda: reader.calls == initial_reads + frame_count, timeout=2000 + ) + + reloaded_key = canvas._frames.key # noqa: SLF001 + assert (reloaded_key[0], reloaded_key[1], reloaded_key[4]) == ( + first_key[0], + first_key[1], + first_key[4], + ), "the framed cache's identity (cache_key, framing, display size) is unchanged" + controller.shutdown() From c24371100ff0a9f4dd1ea941a82cdeacc5caf7a9 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 03:36:09 +0200 Subject: [PATCH 21/45] feat: resolve "Use playhead" against the player's current frame (issue #25 task C3) TransformStageController._use_playhead_requested read the player's current_frame -- the stored-cut index ResultPlayerCanvas is actually displaying -- and dispatches TransformChanged with that index as first_frame or last_frame, replacing the Stage-C-not-built-yet stub left by task B6/C2. Without a player attached, or before any frame has loaded, the request is a no-op: TransformGroup already disables both "Use playhead" buttons until set_playhead_frame reports a real index. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 20 ++++++++++++++--- tests/ui/test_transform_stage.py | 34 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index 9a5d4ab..2f56257 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -230,10 +230,24 @@ def session(self) -> CutSession | None: def facts(self) -> CutFacts | None: return self._facts - # -- Stage C: crop-edit mode --------------------------------------- + # -- Stage C: playhead coupling and crop-edit mode ---------------------- - def _use_playhead_requested(self, _edge: str) -> None: - """Playhead coupling lands in task C3; nothing to answer with yet.""" + def _use_playhead_requested(self, edge: str) -> None: + """Resolve "Use playhead" against the player's current stored frame.""" + canvas = self._player_canvas + if canvas is None: + return + index = canvas.current_frame + if index is None: + return + transform = self._store.state.parameters.transform + updated = ( + replace(transform, first_frame=index) + if edge == "first" + else replace(transform, last_frame=index) + ) + if updated != transform: + self._store.dispatch(TransformChanged(updated)) def _crop_edit_toggled(self, enabled: bool) -> None: was_enabled = self._crop_edit_enabled diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index e1920c5..d5c86d3 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -246,6 +246,40 @@ def test_open_artifact_ignored_leaves_an_existing_session_untouched( controller.shutdown() +# -- C3: playhead coupling -------------------------------------------------- + + +def test_use_playhead_requested_dispatches_the_players_current_frame(qtbot) -> None: + store = _FakeStore(AppState()) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + canvas._playhead_index = 5 # noqa: SLF001 + + controller._use_playhead_requested("last") # noqa: SLF001 + + assert store.dispatched == [TransformChanged(TransformSpec(last_frame=5))] + controller.shutdown() + + +def test_use_playhead_requested_without_a_player_frame_does_nothing(qtbot) -> None: + store = _FakeStore(AppState()) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + + controller._use_playhead_requested("first") # noqa: SLF001 + + assert store.dispatched == [] + controller.shutdown() + + # -- E24: a stale frame-load generation is discarded ------------------------ From d5b5cfe4208c99176be11fda519724e61b477d78 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 03:37:18 +0200 Subject: [PATCH 22/45] feat: enable "Use playhead" once the player reports a frame (issue #25 task C3) attach() now connects ResultPlayerCanvas.playhead_changed to TransformGroup.set_playhead_frame. Without this, TransformGroup._playhead stayed None forever and both "Use playhead" buttons stayed disabled regardless of playback, because nothing ever told the group which stored frame the player was actually showing. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 2 ++ tests/ui/test_transform_stage.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index 2f56257..8325e5b 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -174,6 +174,8 @@ def attach(self, group: TransformGroup, canvas: CropCanvas | None) -> None: group.aspect_lock_changed.connect(self._aspect_lock_changed) if canvas is not None: canvas.command_requested.connect(self._store.dispatch) + if self._player_canvas is not None: + self._player_canvas.playhead_changed.connect(group.set_playhead_frame) group.set_cut(self._facts) self._sync_player_frames(immediate=True) diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index d5c86d3..f647056 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -359,3 +359,20 @@ def test_crop_edits_are_debounced_and_reuse_the_framed_cache(tmp_path, qtbot) -> first_key[4], ), "the framed cache's identity (cache_key, framing, display size) is unchanged" controller.shutdown() + + +def test_attach_wires_the_players_playhead_to_the_groups_use_playhead_buttons( + qtbot, +) -> None: + store = _FakeStore(AppState()) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + + controller.attach(group, canvas) + canvas.playhead_changed.emit(7) + + assert group._playhead == 7 # noqa: SLF001 + controller.shutdown() From 5eca57a1794d70f29d25198ab5b838f02aa362f7 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 03:59:48 +0200 Subject: [PATCH 23/45] fix: reset the transform when a new source loads SourceLoadRequested carried parameters.transform forward unchanged, so a crop/trim set for one clip silently applied to the next clip's preview and render, or raised INVALID_TRANSFORM naming a crop from a different source. Reset only the transform field on the existing replace() call so state.py's line count stays at 880 while fps/model/framing survive the source swap untouched. Trigger: test test_loading_a_new_source_resets_the_transform_but_keeps_other_parameters (tests/core/test_state.py), which failed with AssertionError: TransformSpec(first_frame=2, ...) == TransformSpec(first_frame=0, ...) before the fix. --- src/matteloop/core/state.py | 2 +- tests/core/test_state.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/matteloop/core/state.py b/src/matteloop/core/state.py index 2987a2f..4d88266 100644 --- a/src/matteloop/core/state.py +++ b/src/matteloop/core/state.py @@ -388,7 +388,7 @@ def reduce(state: AppState, event: Event) -> AppState: return AppState( source=SourceState.LOADING, source_id=event.source_id, source_request_id=event.request_id, - parameters=state.parameters, + parameters=replace(state.parameters, transform=specs.TransformSpec()), model_available=state.model_available, model_supports_render=state.model_supports_render, focus_target=FocusTarget.NONE, diff --git a/tests/core/test_state.py b/tests/core/test_state.py index e2b1478..d4d286c 100644 --- a/tests/core/test_state.py +++ b/tests/core/test_state.py @@ -4,6 +4,8 @@ import pytest +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import CropSpec, TransformSpec from matteloop.core.state import ( AppState, ArtifactResult, @@ -827,3 +829,24 @@ def test_state_and_nested_job_are_immutable() -> None: state.source = SourceState.EMPTY # type: ignore[misc] with pytest.raises(FrozenInstanceError): state.job.stage = "Encode" # type: ignore[misc] + + +def test_loading_a_new_source_resets_the_transform_but_keeps_other_parameters() -> None: + non_identity_transform = TransformSpec( + first_frame=2, last_frame=5, crop=CropSpec(0, 0, 64, 64) + ) + with_transform = reduce( + ready_state(), TransformChanged(non_identity_transform) + ) + assert with_transform.parameters.transform == non_identity_transform + + loading_next_source = reduce( + with_transform, + SourceLoadRequested(source_id="replacement", request_id="load-2"), + ) + + assert loading_next_source.parameters.transform == TransformSpec() + assert loading_next_source.parameters.fps == with_transform.parameters.fps + assert loading_next_source.parameters.model_id == with_transform.parameters.model_id + assert loading_next_source.parameters.trim == with_transform.parameters.trim + assert loading_next_source.parameters.padding == with_transform.parameters.padding From f209753fdd45b99e67f258115545725227f0fc57 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 04:05:08 +0200 Subject: [PATCH 24/45] fix: stop resetting the aspect lock and let trim edits degrade Two independent defects in transform_group.py's trim/crop widgets: - _apply_transform_to_widgets unconditionally reset aspect_combo to "Free" on every apply(), which runs on every dispatch. Picking 16:9 round-tripped back to "Free" under _syncing, and choosing "Free" afterwards changed no combo index so it never re-emitted the unlock. The aspect choice is UI-only, not derived from TransformSpec, so apply() must not touch it at all. - _first_frame_changed/_last_frame_changed built the new TransformSpec from only the edited endpoint, so moving first_frame past the current last_frame (or last_frame below first_frame, including via "Use playhead") raised ValidationError straight out of the Qt slot. Both handlers now pull the opposite endpoint along when the edit would otherwise invalidate it, updating that spinbox under _syncing so it doesn't re-enter the handler. Trigger: tests test_aspect_lock_survives_an_apply_round_trip_and_free_actually_unlocks, test_raising_first_frame_above_last_adjusts_last_instead_of_raising, and test_lowering_last_frame_below_first_adjusts_first_instead_of_raising (tests/ui/test_transform_group.py). The first failed on `assert group.aspect_combo.currentData() == Fraction(16, 9)`; the other two raised `matteloop.core.errors.ValidationError: last_frame must be null or an integer >= first_frame` uncaught out of the Qt event loop. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_group.py | 28 +++++++++++-- tests/ui/test_transform_group.py | 65 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/matteloop/ui/transform_group.py b/src/matteloop/ui/transform_group.py index e5f70fb..1816fa7 100644 --- a/src/matteloop/ui/transform_group.py +++ b/src/matteloop/ui/transform_group.py @@ -318,9 +318,10 @@ def _resize_section(self) -> QWidget: # -- reducer-owned state -> widgets -------------------------------------- def _apply_transform_to_widgets(self) -> None: + # The aspect lock is a UI-only choice, not derived from TransformSpec, + # so a reducer round trip must never touch aspect_combo's selection. self._sync_trim_widgets() self._sync_crop_widgets() - self.aspect_combo.setCurrentIndex(0) self._sync_resize_widgets() def _sync_trim_widgets(self) -> None: @@ -386,13 +387,34 @@ def _configure_ranges(self) -> None: def _first_frame_changed(self, value: int) -> None: if self._syncing: return - self._update_transform(replace(self._transform, first_frame=value)) + last_frame = self._transform.last_frame + if last_frame is not None and value > last_frame: + last_frame = value + self._set_spinbox_silently(self.last_frame_spinbox, value) + self._update_transform( + replace(self._transform, first_frame=value, last_frame=last_frame) + ) def _last_frame_changed(self, _value: int) -> None: if self._syncing: return last_frame = self._last_frame_value() - self._update_transform(replace(self._transform, last_frame=last_frame)) + first_frame = self._transform.first_frame + if last_frame is not None and last_frame < first_frame: + first_frame = last_frame + self._set_spinbox_silently(self.first_frame_spinbox, first_frame) + self._update_transform( + replace(self._transform, first_frame=first_frame, last_frame=last_frame) + ) + + def _set_spinbox_silently(self, spinbox: QSpinBox, value: int) -> None: + """Move *spinbox* to keep first/last coupled without re-entering a slot.""" + was_syncing = self._syncing + self._syncing = True + try: + spinbox.setValue(value) + finally: + self._syncing = was_syncing def _last_frame_value(self) -> int | None: value = self.last_frame_spinbox.value() diff --git a/tests/ui/test_transform_group.py b/tests/ui/test_transform_group.py index 2293f59..58a4247 100644 --- a/tests/ui/test_transform_group.py +++ b/tests/ui/test_transform_group.py @@ -174,6 +174,71 @@ def test_choosing_an_aspect_preset_emits_a_centred_crop_and_locks_it(qtbot) -> N assert group.crop_height_spinbox.value() == expected.height +def test_aspect_lock_survives_an_apply_round_trip_and_free_actually_unlocks( + qtbot, +) -> None: + group, emitted = _group(qtbot) + locks: list[object] = [] + group.aspect_lock_changed.connect(locks.append) + group.apply(_presentation(), editable=True) + group.set_cut(_facts(framed_size=(1920, 1080))) + emitted.clear() + + group.aspect_combo.setCurrentIndex( + _index_with_data(group.aspect_combo, Fraction(16, 9)) + ) + last = emitted[-1] + assert isinstance(last, TransformChanged) + + # A dispatched TransformChanged always comes back through apply() once the + # reducer accepts it; the aspect choice must not be reset by that round trip. + group.apply(_presentation(transform=last.transform), editable=True) + + assert group.aspect_combo.currentData() == Fraction(16, 9) + + locks.clear() + group.aspect_combo.setCurrentIndex(_index_with_data(group.aspect_combo, None)) + + assert locks == [None] + + +def test_raising_first_frame_above_last_adjusts_last_instead_of_raising( + qtbot, +) -> None: + group, emitted = _group(qtbot) + group.apply( + _presentation(transform=TransformSpec(first_frame=0, last_frame=3)), + editable=True, + ) + group.set_cut(_facts(frame_count=10)) + emitted.clear() + + group.first_frame_spinbox.setValue(4) + + last = emitted[-1] + assert isinstance(last, TransformChanged) + assert last.transform.first_frame == 4 + assert last.transform.last_frame is None or last.transform.last_frame >= 4 + + +def test_lowering_last_frame_below_first_adjusts_first_instead_of_raising( + qtbot, +) -> None: + group, emitted = _group(qtbot) + group.apply( + _presentation(transform=TransformSpec(first_frame=5)), editable=True + ) + group.set_cut(_facts(frame_count=10)) + emitted.clear() + + group.last_frame_spinbox.setValue(2) + + last = emitted[-1] + assert isinstance(last, TransformChanged) + assert last.transform.last_frame == 2 + assert last.transform.first_frame <= 2 + + def test_apply_with_editable_false_disables_every_control(qtbot) -> None: group, _emitted = _group(qtbot) group.set_cut(_facts()) From a296a3bf884ea946f5f9508a5f7cca90ca9bfe1e Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 04:05:17 +0200 Subject: [PATCH 25/45] fix: bound an aspect-locked edge fit before constructing the crop _fit_edge centred the perpendicular axis by adding an unclamped signed offset to the existing origin, so an aspect-locked crop pinned to the top or left edge could get a negative x/y as soon as an east/west (or north/south) handle drag demanded a taller/wider box. CropSpec rejects negative coordinates in its own constructor, so the ValidationError escaped before fit_crop_aspect's later clamp_crop call ever ran. _fit_edge now takes the source dimensions and computes a bounded, ratio-preserving origin (_bounded_centered_origin) so the CropSpec it builds is always valid on the first construction. Trigger: test test_fit_crop_aspect_on_an_edge_at_the_top_clamps_instead_of_raising (tests/core/test_crop.py), which raised matteloop.core.errors.ValidationError: crop origin must be non-negative and dimensions must be positive from CropSpec(x=0, y=-3, width=6, height=12). Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/core/crop.py | 34 ++++++++++++++++++++++++++++------ tests/core/test_crop.py | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/matteloop/core/crop.py b/src/matteloop/core/crop.py index 920a264..34daac3 100644 --- a/src/matteloop/core/crop.py +++ b/src/matteloop/core/crop.py @@ -131,7 +131,9 @@ def fit_crop_aspect( elif target in _CORNER_HANDLES: fitted = _fit_corner(crop, ratio, target) elif target in _EDGE_HANDLES: - fitted = _fit_edge(crop, ratio, target) + fitted = _fit_edge( + crop, ratio, target, source_width=source_width, source_height=source_height + ) else: raise ValueError("unknown crop interaction target") return clamp_crop(fitted, source_width, source_height) @@ -166,17 +168,37 @@ def _fit_corner(crop: CropSpec, ratio: Fraction, target: str) -> CropSpec: return CropSpec(x, y, new_width, new_height) -def _fit_edge(crop: CropSpec, ratio: Fraction, target: str) -> CropSpec: +def _fit_edge( + crop: CropSpec, + ratio: Fraction, + target: str, + *, + source_width: int, + source_height: int, +) -> CropSpec: width, height = crop.width, crop.height if target in {"east", "west"}: - new_height = max(1, _rhu(Fraction(width) / ratio)) - y = crop.y + _rhu(Fraction(height - new_height, 2)) + new_height = max(1, min(source_height, _rhu(Fraction(width) / ratio))) + y = _bounded_centered_origin(crop.y, height, new_height, source_height) return CropSpec(crop.x, y, width, new_height) - new_width = max(1, _rhu(Fraction(height) * ratio)) - x = crop.x + _rhu(Fraction(width - new_width, 2)) + new_width = max(1, min(source_width, _rhu(Fraction(height) * ratio))) + x = _bounded_centered_origin(crop.x, width, new_width, source_width) return CropSpec(x, crop.y, new_width, height) +def _bounded_centered_origin( + origin: int, old_size: int, new_size: int, extent: int +) -> int: + """Re-centre a resized edge on its old midpoint, clamped inside *extent*. + + Growing the perpendicular axis around the centre can otherwise place the + new origin outside [0, extent - new_size] and CropSpec would reject it + before `fit_crop_aspect`'s later clamp_crop ever runs. + """ + proposed = origin + _rhu(Fraction(old_size - new_size, 2)) + return min(max(proposed, 0), max(0, extent - new_size)) + + def _validate_ratio(ratio: Fraction) -> None: if not isinstance(ratio, Fraction) or ratio <= 0: raise ValueError("aspect ratio must be a positive Fraction") diff --git a/tests/core/test_crop.py b/tests/core/test_crop.py index 13c5bdb..4ea4d4c 100644 --- a/tests/core/test_crop.py +++ b/tests/core/test_crop.py @@ -124,6 +124,25 @@ def test_fit_crop_aspect_on_an_edge_recentres_the_perpendicular_axis() -> None: assert fitted.width == 3 * fitted.height +def test_fit_crop_aspect_on_an_edge_at_the_top_clamps_instead_of_raising() -> None: + # Growing the width of a crop pinned to the top edge (y=0) demands a much + # taller box under this ratio; naive centring would push y negative and + # CropSpec's own validation would raise before clamp_crop ever runs. + fitted = fit_crop_aspect( + CropSpec(0, 0, 6, 5), + Fraction(1, 2), + "east", + source_width=20, + source_height=20, + ) + + assert fitted.x >= 0 + assert fitted.y >= 0 + assert fitted.x + fitted.width <= 20 + assert fitted.y + fitted.height <= 20 + assert fitted.width == 6 + + def test_fit_crop_aspect_leaves_a_body_move_unchanged() -> None: crop = CropSpec(2, 3, 6, 5) From 87e71927e3b55b7c1d4933b590f40b49347c8e02 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 04:05:25 +0200 Subject: [PATCH 26/45] fix: report a failed sidecar removal instead of swallowing it discard_transform caught every OSError, including a real removal failure, and dropped it -- the docstring's "best effort, never raises" covered a merely-missing file but silently extended to cover a failed unlink too. When a non-identity transform is followed by an identity rebuild, a swallowed unlink failure leaves the stale sidecar in place, so reopening the cut restores a transform the last render did not apply, with no note telling the user. discard_transform now accepts the same notes list store_transform already threads through the non-identity write path, keeps a missing file silent, and appends a note for any other OSError. The job still never fails on this path. Trigger: test test_store_transform_notes_a_failed_sidecar_removal_but_still_completes (tests/jobs/test_transform_store.py), which failed with `assert 0 == 1` -- notes stayed empty after a monkeypatched unlink raised PermissionError, since Windows can't reliably deny unlink permission on files it owns. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/transform_store.py | 18 ++++++++++++++---- tests/jobs/test_transform_store.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/matteloop/jobs/transform_store.py b/src/matteloop/jobs/transform_store.py index bfe8a88..8e831d4 100644 --- a/src/matteloop/jobs/transform_store.py +++ b/src/matteloop/jobs/transform_store.py @@ -141,7 +141,7 @@ def store_transform( """Persist *spec* beside the cut, or remove it for identity. Swallows ``OSError`` into a note (G4): the job already published its output.""" if spec.is_identity: - discard_transform(workspace) + discard_transform(workspace, notes) return payload = { "schema": TRANSFORM_SIDECAR_SCHEMA, @@ -189,9 +189,19 @@ def _fsync_parent(path: Path) -> None: os.close(descriptor) -def discard_transform(workspace: CutWorkspace) -> None: - """Remove the sidecar if present. Best effort -- never raises.""" +def discard_transform( + workspace: CutWorkspace, notes: list[str] | None = None +) -> None: + """Remove the sidecar if present. Best effort -- never raises. + + A missing sidecar is the common, silent case. Any other removal + failure is routed through *notes* (G4): the job already published its + output, but leaving a stale sidecar in place would silently restore a + transform the last render did not apply. + """ try: transform_sidecar_path(workspace).unlink() - except OSError: + except FileNotFoundError: pass + except OSError as error: + _note(notes, f"could not remove the stored transform: {error}") diff --git a/tests/jobs/test_transform_store.py b/tests/jobs/test_transform_store.py index 71e3976..399ba13 100644 --- a/tests/jobs/test_transform_store.py +++ b/tests/jobs/test_transform_store.py @@ -129,6 +129,33 @@ def test_discard_transform_on_a_missing_file_is_silent(tmp_path: Path) -> None: assert not transform_sidecar_path(workspace).exists() +def test_store_transform_notes_a_failed_sidecar_removal_but_still_completes( + tmp_path: Path, monkeypatch +) -> None: + """A non-identity transform followed by an identity rebuild removes the + old sidecar; if the unlink itself fails, the stale sidecar would silently + restore a transform the last render did not apply. The job must still + complete (G4), but the failure must not vanish -- it belongs in notes.""" + workspace = _seeded_cut(tmp_path) + store_transform(workspace, TransformSpec(first_frame=1), []) + sidecar = transform_sidecar_path(workspace) + assert sidecar.exists() + real_unlink = Path.unlink + + def failing_unlink(self: Path, *args: object, **kwargs: object) -> None: + if self == sidecar: + raise PermissionError("simulated removal failure") + real_unlink(self, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(Path, "unlink", failing_unlink) + notes: list[str] = [] + + store_transform(workspace, TransformSpec(), notes) + + assert len(notes) == 1 + assert "transform" in notes[0] + + def test_sidecar_beside_the_cut_does_not_break_listing_or_validation( tmp_path: Path, ) -> None: From c5e1d3ae32242bc94c638cabe014fbb7f8cc4904 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 04:14:16 +0200 Subject: [PATCH 27/45] fix: clamp an oversized stored crop before encoding a transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stage_encoder_frames validated transform.crop as-is against the current framed size. A crop stored while the framed size was larger — a padding change, or a cut set promoted from a different video — raised INVALID_TRANSFORM and hard-failed the render with no UI-visible cause, because the equivalent UI-side clamp in restore_for only runs once cache facts have arrived (after a prior successful render). Clamp transform.crop with core.crop.clamp_crop against plan.output_size immediately before validate_for, mirroring the UI-side clamp so encoded output matches what the UI later shows. This covers rebuild, plain render, and trim paths that a restore_for-side fix cannot reach without changing render_controller.py's signature. Trigger: test test_stage_encoder_frames_clamps_a_crop_stored_against_a_larger_frame — failed with "crop must be fully contained by the framed size 128x128" before the fix. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/jobs/transform_stage.py | 13 +++++++++ tests/jobs/test_transform_stage.py | 41 ++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/matteloop/jobs/transform_stage.py b/src/matteloop/jobs/transform_stage.py index a4b1afc..f050196 100644 --- a/src/matteloop/jobs/transform_stage.py +++ b/src/matteloop/jobs/transform_stage.py @@ -14,6 +14,7 @@ from __future__ import annotations +import dataclasses import os import tempfile from collections.abc import Callable @@ -21,6 +22,7 @@ from PIL import Image +from matteloop.core.crop import clamp_crop from matteloop.core.errors import ErrorCode, ValidationError from matteloop.core.geometry import FramingPlan, PixelBounds, apply_framing from matteloop.core.rgba import RgbaOwnershipTracker @@ -64,7 +66,18 @@ def stage_encoder_frames( the frames in ``transform.kept_range(frame_count)`` are staged; the returned delays are ``transform.select_kept(delays)`` — a slice of the full grid, never a recomputation. + + A crop stored while the framed size was larger — more padding, or a cut + set promoted from a different video — is clamped to the current framed + size here rather than rejected: this is the same ``clamp_crop`` the UI + applies once cache facts arrive, so the encoded output matches what the + UI later shows, and it covers rebuild, plain render, and trim paths that + a UI-side clamp alone cannot reach. """ + if transform.crop is not None: + transform = dataclasses.replace( + transform, crop=clamp_crop(transform.crop, *plan.output_size) + ) final_size = transform.validate_for(frame_count, plan.output_size) tracker.include_size(final_size) try: diff --git a/tests/jobs/test_transform_stage.py b/tests/jobs/test_transform_stage.py index 99be5b6..3986336 100644 --- a/tests/jobs/test_transform_stage.py +++ b/tests/jobs/test_transform_stage.py @@ -12,7 +12,7 @@ from matteloop.core.errors import ErrorCode, ValidationError from matteloop.core.geometry import FramingPlan, PixelBounds from matteloop.core.rgba import RgbaOwnershipTracker -from matteloop.core.specs import FramingSpec, TransformSpec +from matteloop.core.specs import CropSpec, FramingSpec, TransformSpec from matteloop.core.state import JobKind from matteloop.jobs.transform_stage import framing_plan, stage_encoder_frames from tests.jobs.render_support import job @@ -85,3 +85,42 @@ def read_cut(index: int, tracker: RgbaOwnershipTracker) -> Image.Image: assert saved.size == (128, 128) assert saved.tobytes() == frame.tobytes() assert returned_delays == delays + + +def test_stage_encoder_frames_clamps_a_crop_stored_against_a_larger_frame( + tmp_path: Path, +) -> None: + """A crop saved while the framed size was bigger (e.g. more padding, or a + different cut set entirely) must rebuild against the *current* framed + size instead of raising INVALID_TRANSFORM -- the UI-side clamp in + ``restore_for`` only ever runs once cache facts have arrived, so a crop + can reach the encoder unclamped on a rebuild, a plain render after a + padding change, or a cut-set switch. + """ + frames = [_colour_frame((255, 0, 0, 255))] + + def read_cut(index: int, tracker: RgbaOwnershipTracker) -> Image.Image: + image = frames[index].copy() + tracker.register(image) + return image + + plan = FramingPlan((128, 128), global_bounds=None, padding=0, stretch_x=Fraction(1)) + tracker = RgbaOwnershipTracker((128, 128)) + context = job(tmp_path, "job-1", JobKind.RENDER) + oversized_crop = CropSpec(0, 0, 200, 200) + transform = TransformSpec(crop=oversized_crop) + + paths, _ = stage_encoder_frames( + read_cut, + 1, + plan, + transform, + (100,), + tmp_path / "framed-inputs", + tracker, + context, + ) + + with Image.open(paths[0]) as saved: + saved.load() + assert saved.size == (128, 128) From 7fd1834b9fc178118bfeadfc58c5a0f5463613fd Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 04:36:57 +0200 Subject: [PATCH 28/45] fix: keep the crop-edit overlay live and stop two cancellation races The crop-edit presentation was refreshed only once, at toggle time: `_state_changed` never re-applied it, so `_presentation` (and the geometry painted from it) went stale after the first nudge or drag. `_state_changed` now re-applies the presentation whenever the transform changes while editing, without the frame-cache reload that path deliberately skips. Superseding a frame load called `thread.quit(); thread.wait(5000)` against a worker with no cancellation check, so `quit()` could not interrupt it and the GUI thread blocked for the rest of the in-flight load on every ordinary crop edit. The worker now checks a `threading.Event` between frames and unwinds early; the blocking wait is dropped on supersede and kept only in `shutdown()`. `_cancel_frame_load` also now bumps `_frame_generation` itself, so a load already in flight when a session closes can no longer resurrect frames for it. A render cancelled between encode and commit still emitted `artifact_ready` before the cancellation checkpoint could raise, opening a cut session for an artifact whose `RenderSucceeded` never arrives. The emit now happens after the checkpoint/commit call. Trigger: test test_state_changed_refreshes_the_crop_edit_presentation -- failed with presentation.crop staying (0, 0, 128, 128) after a TransformChanged dispatch instead of tracking the new crop. Trigger: test test_superseding_a_frame_load_does_not_block_the_gui_thread -- failed, elapsed 5.0s (the full thread.wait timeout) instead of returning immediately. Trigger: test test_a_render_cancelled_after_encode_never_publishes_an_artifact -- failed with the artifact published despite the render being cancelled before commit. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/render_worker.py | 6 +- src/matteloop/ui/result_player.py | 17 +++ src/matteloop/ui/transform_stage.py | 50 ++++++++- tests/ui/test_render_worker.py | 86 +++++++++++++++ tests/ui/test_result_player.py | 50 ++++++++- tests/ui/test_transform_stage.py | 156 ++++++++++++++++++++++++++++ 6 files changed, 356 insertions(+), 9 deletions(-) create mode 100644 tests/ui/test_render_worker.py diff --git a/src/matteloop/ui/render_worker.py b/src/matteloop/ui/render_worker.py index 18ac79a..b2bfc38 100644 --- a/src/matteloop/ui/render_worker.py +++ b/src/matteloop/ui/render_worker.py @@ -71,13 +71,13 @@ def run(self) -> None: artifact = rebuild( self._request, self._rebuild_workspace, self._context ) - self.artifact_ready.emit(artifact) - if not provider_emitted: - self._emit_provider_details() if self._context.terminal_state is JobTerminalState.RUNNING: self._context.commit_if_not_cancelled(lambda: None) elif self._context.terminal_state is JobTerminalState.CANCEL_PENDING: self._context.checkpoint("render") + self.artifact_ready.emit(artifact) + if not provider_emitted: + self._emit_provider_details() output_path = artifact.output_path if not isinstance(output_path, Path): raise TypeError("render result did not contain an output path") diff --git a/src/matteloop/ui/result_player.py b/src/matteloop/ui/result_player.py index ed85797..5ec106a 100644 --- a/src/matteloop/ui/result_player.py +++ b/src/matteloop/ui/result_player.py @@ -19,6 +19,7 @@ from __future__ import annotations import math +from collections.abc import Callable from dataclasses import dataclass, replace from fractions import Fraction from pathlib import Path @@ -42,6 +43,14 @@ PLAYER_MIN_DISPLAY_SIDE = 64 +class _FrameLoadCancelled(Exception): + """Raised internally to unwind ``_load_player_frames`` once superseded.""" + + +def _no_cancellation() -> bool: + return False + + class FrameReader(Protocol): """Read one stored cut frame directly by manifest filename.""" @@ -287,6 +296,7 @@ def __init__( generation: int, *, budget: int = PLAYER_CACHE_BUDGET_BYTES, + cancelled: Callable[[], bool] = _no_cancellation, ) -> None: super().__init__() self._workspace = workspace @@ -297,6 +307,7 @@ def __init__( self._delays = delays self._generation = generation self._budget = budget + self._cancelled = cancelled @Slot() def run(self) -> None: @@ -309,7 +320,10 @@ def run(self) -> None: self._transform, self._delays, self._budget, + self._cancelled, ) + except _FrameLoadCancelled: + pass except (OSError, UnidentifiedImageError): self.failed.emit("Cut frames could not be read", self._generation) else: @@ -326,6 +340,7 @@ def _load_player_frames( transform: TransformSpec, delays: tuple[int, ...], budget: int, + cancelled: Callable[[], bool] = _no_cancellation, ) -> PlayerFrames: frame_count = manifest.frame_count display_size, cached = _fit_budget(frame_count, plan.output_size, budget) @@ -339,6 +354,8 @@ def _load_player_frames( framed_images: list[QImage] = [] transformed_images: list[QImage] = [] for index in range(cached): + if cancelled(): + raise _FrameLoadCancelled framed_qimage, transformed_qimage = _load_one_frame( workspace, manifest.frames[index], diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index 8325e5b..b22569d 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -17,6 +17,7 @@ from __future__ import annotations +import threading from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass, replace from decimal import Decimal @@ -150,6 +151,7 @@ def __init__( self._frame_generation = 0 self._frame_thread: QThread | None = None self._frame_worker: FrameLoadWorker | None = None + self._frame_cancel_event: threading.Event | None = None self._cache_budget = cache_budget self._debounce_timer = QTimer(self) self._debounce_timer.setSingleShot(True) @@ -220,9 +222,12 @@ def shutdown(self) -> None: if self._unsubscribe is not None: unsubscribe, self._unsubscribe = self._unsubscribe, None unsubscribe() + # Join the frame loader before close_session(): it also cancels the + # frame load (via _sync_player_frames), but without waiting, which + # would otherwise clear _frame_thread and leave nothing here to join. + self._cancel_frame_load(wait=True) self.close_session() self._join_worker() - self._cancel_frame_load() @property def session(self) -> CutSession | None: @@ -305,8 +310,24 @@ def _state_changed(self, state: AppState) -> None: kept = transform.kept_range(facts.frame_count) self._player_canvas.set_kept_range(kept) crop_editing = self._crop_edit_enabled - if transform_changed and not framing_changed and not crop_editing: - self._sync_player_frames(immediate=False) + if transform_changed and not framing_changed: + if crop_editing: + self._refresh_crop_edit_presentation(transform) + else: + self._sync_player_frames(immediate=False) + + def _refresh_crop_edit_presentation(self, transform: TransformSpec) -> None: + """Re-apply the crop-edit presentation for *transform* without the + frame reload ``_sync_player_frames`` would trigger (E33): the canvas + overlay and its focus/drag geometry only ever refresh inside + ``apply_presentation``, so a stale presentation between commands is + what stalls a drag mid-gesture and repeats a nudge's first pixel. + """ + canvas = self._player_canvas + if canvas is None: + return + presentation = self._crop_edit_presentation(transform) + canvas.set_crop_edit(True, presentation, transform) def _schedule_facts(self) -> None: session = self._session @@ -399,6 +420,8 @@ def _start_frame_load(self) -> None: self._cancel_frame_load() generation = next(self._frame_generations) self._frame_generation = generation + cancel_event = threading.Event() + self._frame_cancel_event = cancel_event transform = self._store.state.parameters.transform worker = FrameLoadWorker( session.workspace, @@ -409,6 +432,7 @@ def _start_frame_load(self) -> None: facts.delays_ms, generation, budget=self._cache_budget, + cancelled=cancel_event.is_set, ) thread = QThread(self) worker.moveToThread(thread) @@ -437,8 +461,23 @@ def _frame_load_failed(self, message: str, generation: int) -> None: if self._player_canvas is not None: self._player_canvas.set_status_marker(message) - def _cancel_frame_load(self) -> None: + def _cancel_frame_load(self, *, wait: bool = False) -> None: + """Stop the in-flight frame load, if any, without blocking on it. + + The worker checks ``_frame_cancel_event`` between frames (E23), so + setting it here lets a superseded load stop itself; ``thread.quit()`` + alone cannot interrupt its running slot. Bumping the generation + (E24) means a load that finishes anyway -- it slipped past the + cancellation check, or was already queued on the Qt event loop -- + cannot resurrect frames for a session this call is closing. + Only ``shutdown()`` passes ``wait=True``: the object is going away + and must not leave a thread running past it. + """ self._debounce_timer.stop() + self._frame_generation = next(self._frame_generations) + if self._frame_cancel_event is not None: + self._frame_cancel_event.set() + self._frame_cancel_event = None thread = self._frame_thread self._frame_thread = None self._frame_worker = None @@ -446,7 +485,8 @@ def _cancel_frame_load(self) -> None: return try: thread.quit() - thread.wait(5000) + if wait: + thread.wait(5000) except RuntimeError: pass diff --git a/tests/ui/test_render_worker.py b/tests/ui/test_render_worker.py new file mode 100644 index 0000000..98fc3ff --- /dev/null +++ b/tests/ui/test_render_worker.py @@ -0,0 +1,86 @@ +"""RenderWorker's ordering of artifact publication against cancellation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from matteloop.core.state import CancelAcknowledged, JobKind, RenderSucceeded +from matteloop.jobs.context import JobContext +from matteloop.ui.render_worker import RenderWorker +from tests.jobs.render_support import job, request + + +@dataclass +class _FakeArtifact: + output_path: Path + + +class _CancelsBeforeReturningRuntime: + """A render runtime that cancels the job after encoding, before commit -- + the window ``RenderFailed``/``CancelAcknowledged`` exists for. + """ + + def __init__(self, context: JobContext, output_path: Path) -> None: + self._context = context + self._output_path = output_path + + def render(self, request: object, context: JobContext) -> _FakeArtifact: + context.request_cancel() + return _FakeArtifact(self._output_path) + + +class _SucceedsRuntime: + """An uncancelled render runtime, for the happy path this must not break.""" + + def __init__(self, output_path: Path) -> None: + self._output_path = output_path + + def render(self, request: object, context: JobContext) -> _FakeArtifact: + return _FakeArtifact(self._output_path) + + +def test_an_uncancelled_render_still_publishes_its_artifact(tmp_path) -> None: + context = job(tmp_path, "job-1", JobKind.RENDER) + runtime = _SucceedsRuntime(tmp_path / "output.webp") + worker = RenderWorker( + "job-1", "source-1", "request-1", request(tmp_path), runtime, context + ) + artifacts_published: list[object] = [] + notifications: list[object] = [] + worker.artifact_ready.connect(artifacts_published.append) + worker.notification.connect(notifications.append) + + worker.run() + + assert len(artifacts_published) == 1 + assert any(isinstance(event, RenderSucceeded) for event in notifications) + + +def test_a_render_cancelled_after_encode_never_publishes_an_artifact( + tmp_path, +) -> None: + """``artifact_ready`` opens a cut session in the Transform stage (task + C-adjacent wiring). Emitting it before the cancellation checkpoint lets a + render cancelled between encode and commit open a session for an + artifact whose ``RenderSucceeded`` never arrives -- the UI loops an + unpublished, uncommitted result. + """ + context = job(tmp_path, "job-1", JobKind.RENDER) + runtime = _CancelsBeforeReturningRuntime(context, tmp_path / "output.webp") + worker = RenderWorker( + "job-1", "source-1", "request-1", request(tmp_path), runtime, context + ) + artifacts_published: list[object] = [] + notifications: list[object] = [] + worker.artifact_ready.connect(artifacts_published.append) + worker.notification.connect(notifications.append) + + worker.run() + + assert artifacts_published == [], ( + "a cancelled render must not publish its artifact to the Transform " + "stage" + ) + assert any(isinstance(event, CancelAcknowledged) for event in notifications) + assert not any(isinstance(event, RenderSucceeded) for event in notifications) diff --git a/tests/ui/test_result_player.py b/tests/ui/test_result_player.py index 5b2389f..5978aad 100644 --- a/tests/ui/test_result_player.py +++ b/tests/ui/test_result_player.py @@ -1,5 +1,6 @@ from __future__ import annotations +import threading from fractions import Fraction from pathlib import Path @@ -26,7 +27,7 @@ ) from matteloop.ui.store import ReducerStore from matteloop.ui.transform_group import TransformGroup -from matteloop.ui.transform_stage import TransformStageController +from matteloop.ui.transform_stage import TransformStageController, _DirectFrameReader from tests.jobs.render_support import ( FakeEncoder, FakeSegmenter, @@ -262,6 +263,53 @@ def test_frame_load_worker_reports_unreadable_frames(tmp_path) -> None: assert failures == ["Cut frames could not be read"] +class _CancelAfterFirstReadReader: + """Read normally, but signal cancellation once the first frame is read -- + simulating a supersede that lands while the worker is mid-load.""" + + def __init__(self, cancel_after: threading.Event) -> None: + self._cancel_after = cancel_after + self.calls = 0 + self._inner = _DirectFrameReader() + + def read(self, workspace: object, frame: object) -> QImage: + self.calls += 1 + image = self._inner.read(workspace, frame) + self._cancel_after.set() + return image + + +def test_frame_load_worker_stops_early_once_cancelled(tmp_path) -> None: + """A superseded load must stop reading further stored frames instead of + running to completion -- otherwise cancelling it cannot be fast, only + delayed until the whole in-flight load finishes. + """ + artifact = _seed_cut(tmp_path, "worker-cancel") + manifest = artifact.manifest + assert manifest.frame_count >= 2, "need a second frame that must never load" + plan = framing_plan((manifest.width, manifest.height), None, FramingSpec()) + cancelled = threading.Event() + reader = _CancelAfterFirstReadReader(cancelled) + worker = FrameLoadWorker( + artifact.cut_workspace, + manifest, + reader, + plan, + TransformSpec(), + (100,) * manifest.frame_count, + 1, + cancelled=cancelled.is_set, + ) + outcomes: list[str] = [] + worker.succeeded.connect(lambda *_a: outcomes.append("succeeded")) + worker.failed.connect(lambda *_a: outcomes.append("failed")) + + worker.run() + + assert reader.calls == 1, "the worker must stop before reading the next frame" + assert outcomes == [], "a cancelled load must not report success or failure" + + # -- AC 9: zero segmentation, zero encoder calls during playback ------------ diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index f647056..77e2a46 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -2,11 +2,15 @@ from __future__ import annotations +import threading +import time from collections.abc import Callable from dataclasses import dataclass from fractions import Fraction from pathlib import Path +from PySide6.QtCore import Qt + from matteloop.core.crop_state import CropChanged from matteloop.core.parameters import PaddingChanged, TransformChanged from matteloop.core.specs import CropSpec, FramingSpec, TransformSpec @@ -307,6 +311,89 @@ def test_frame_load_succeeded_ignores_a_stale_generation(qtbot) -> None: controller.shutdown() +def test_cancelling_a_frame_load_bumps_the_generation_so_a_queued_result_is_stale( + tmp_path, qtbot +) -> None: + """``_cancel_frame_load`` used to leave ``_frame_generation`` untouched, so + a ``succeeded`` from the worker it just cancelled -- already queued on the + Qt event loop, or slipping past the cancellation check by a hair -- still + matched and reopened a session that ``close_session`` had just closed. + """ + artifact = _seed_cut(tmp_path, "cancel-bumps-generation") + store = _FakeStore(AppState()) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + in_flight_generation = controller._frame_generation # noqa: SLF001 + + controller.close_session() + + stale = PlayerFrames( + key="stale", framed=(), transformed=(), delays_ms=(), cached=0, frame_count=0 + ) + controller._frame_load_succeeded( # noqa: SLF001 + stale, in_flight_generation + ) + + assert canvas._frames is None, ( # noqa: SLF001 + "a load in flight when the session closed must not resurrect frames" + ) + controller.shutdown() + + +class _BlockingReader: + """Read every frame, but block until released -- so a load superseded + mid-flight would hang the caller if the supersede itself waited on it. + """ + + def __init__(self) -> None: + self.first_read_started = threading.Event() + self.release = threading.Event() + self._inner = _DirectFrameReader() + + def read(self, workspace: CutWorkspace, frame: CutFrame): + self.first_read_started.set() + self.release.wait(5) + return self._inner.read(workspace, frame) + + +def test_superseding_a_frame_load_does_not_block_the_gui_thread( + tmp_path, qtbot +) -> None: + """``_cancel_frame_load`` did ``thread.quit(); thread.wait(5000)`` on + every supersede, but the worker checked no cancellation token, so + ``quit()`` could not interrupt its running slot -- the caller (the GUI + thread, on every ordinary crop edit) blocked for the rest of the + in-flight load. + """ + artifact = _seed_cut(tmp_path, "supersede-non-blocking") + reader = _BlockingReader() + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store, frame_reader=reader) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: controller.facts is not None, timeout=5000) + qtbot.waitUntil(reader.first_read_started.is_set, timeout=5000) + + started = time.monotonic() + controller._start_frame_load() # noqa: SLF001 -- supersedes the blocked load + elapsed = time.monotonic() - started + + assert elapsed < 1.0, "superseding a load must not wait on the in-flight worker" + reader.release.set() + controller.shutdown() + + # -- E33: crop-edit drags are debounced and skipped while editing ----------- @@ -361,6 +448,75 @@ def test_crop_edits_are_debounced_and_reuse_the_framed_cache(tmp_path, qtbot) -> controller.shutdown() +# -- crop-edit presentation must stay live across the whole gesture -------- + + +def test_state_changed_refreshes_the_crop_edit_presentation(tmp_path, qtbot) -> None: + """`apply_presentation` in ``result_player.py`` is reached only via + ``set_crop_edit``, called only from ``_crop_edit_toggled`` -- so once + editing is on, ``canvas._presentation`` stayed at its toggle-time crop + forever. That is what made every nudge after the first a no-op (nudge_crop + kept starting from the same stale crop) and made a drag rectangle never + move on screen: the canvas painted `_geometry`, which is only rebuilt + inside `apply_presentation`. + """ + artifact = _seed_cut(tmp_path, "presentation-refresh") + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + controller._crop_edit_toggled(True) # noqa: SLF001 + assert canvas._presentation is not None # noqa: SLF001 + assert canvas._presentation.crop == CropSpec(0, 0, 128, 128) # noqa: SLF001 + + new_crop = CropSpec(10, 10, 50, 50) + store.dispatch(TransformChanged(TransformSpec(crop=new_crop))) + + assert canvas._presentation is not None # noqa: SLF001 + assert canvas._presentation.crop == new_crop, ( # noqa: SLF001 + "the canvas's crop-edit presentation must track every transform " + "change while editing, not just the crop it had when editing began" + ) + controller.shutdown() + + +def test_repeated_keyboard_nudges_are_cumulative_while_crop_editing( + tmp_path, qtbot +) -> None: + """End-to-end version of the same defect: five arrow presses must move + the crop five source pixels, not emit the same one-pixel move five times. + """ + artifact = _seed_cut(tmp_path, "cumulative-nudge") + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + store.dispatch(TransformChanged(TransformSpec(crop=CropSpec(50, 50, 20, 20)))) + controller._crop_edit_toggled(True) # noqa: SLF001 + canvas.resize(200, 200) + canvas.show() + canvas.activateWindow() + canvas.setFocus() + qtbot.waitUntil(canvas.hasFocus, timeout=1000) + + for _ in range(5): + qtbot.keyClick(canvas, Qt.Key.Key_Left) + + final_crop = store.state.parameters.transform.crop + assert final_crop == CropSpec(45, 50, 20, 20) + controller.shutdown() + + def test_attach_wires_the_players_playhead_to_the_groups_use_playhead_buttons( qtbot, ) -> None: From 32b2bed35543bf42622d039aa9a3bd2d126504fa Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 11:03:40 +0200 Subject: [PATCH 29/45] fix: clear worker/thread references before deleteLater frees them _schedule_facts() and _start_frame_load() kept self._thread/_worker and self._frame_thread/_frame_worker pointing at QThreads wired with thread.finished.connect(thread.deleteLater). When the event loop spun (e.g. a qtbot.waitUntil) while a worker was finishing, deleteLater destroyed the C++ QThread but the wrapper attribute still pointed at it, so shutdown()'s _join_worker() (and _cancel_frame_load()) called quit()/wait() on freed memory. Add _facts_thread_finished and _frame_thread_finished, each connected to its thread's finished signal ahead of the deleteLater connection (Qt runs slots on one signal in connection order), clearing the matching pair of attributes so the join methods find nothing left to touch. Each checks sender() against the current attribute so a superseded thread's queued finished signal can't clear a newer one that a later _schedule_facts()/_start_frame_load() call has already started. Trigger: CI run 33830681783 (test (ui) job, Fatal Python error: Segmentation fault, exit 139, on test_open_artifact_ignored_leaves_an_existing_session_untouched); locally reproduces as a hang on the second consecutive run of tests/ui/test_transform_stage.py. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index b22569d..bdba9e0 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -343,12 +343,28 @@ def _schedule_facts(self) -> None: worker.failed.connect(self._facts_failed) worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) + thread.finished.connect(self._facts_thread_finished) thread.finished.connect(thread.deleteLater) thread.started.connect(worker.run) self._thread = thread self._worker = worker thread.start() + @Slot() + def _facts_thread_finished(self) -> None: + """Drop the dangling references once the facts worker's thread ends. + + Connected ahead of ``thread.deleteLater`` (Qt invokes slots on one + signal in connection order), so by the time ``deleteLater`` frees + the C++ ``QThread`` object, ``_join_worker`` already has nothing to + find and never calls ``quit()``/``wait()`` on it. The ``sender()`` + check guards against a superseded thread's queued ``finished`` + clearing a newer thread that ``_schedule_facts`` has since started. + """ + if self.sender() is self._thread: + self._thread = None + self._worker = None + @Slot(object, object, int) def _facts_ready(self, facts: object, plan: object, generation: int) -> None: if ( @@ -440,12 +456,24 @@ def _start_frame_load(self) -> None: worker.failed.connect(self._frame_load_failed) worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) + thread.finished.connect(self._frame_thread_finished) thread.finished.connect(thread.deleteLater) thread.started.connect(worker.run) self._frame_thread = thread self._frame_worker = worker thread.start() + @Slot() + def _frame_thread_finished(self) -> None: + """Mirror ``_facts_thread_finished`` for the player's frame loader: + clear the dangling references before ``deleteLater`` (connected + right after) frees the ``QThread`` out from under + ``_cancel_frame_load``. + """ + if self.sender() is self._frame_thread: + self._frame_thread = None + self._frame_worker = None + @Slot(object, int) def _frame_load_succeeded(self, frames: object, generation: int) -> None: if generation != self._frame_generation or not isinstance(frames, PlayerFrames): From d87a2402d1d283ab117aa544733162303e874780 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 11:40:22 +0200 Subject: [PATCH 30/45] fix: make the cut-facts worker cancellable so shutdown() cannot wait it out open_artifact() sets the session synchronously and only schedules _CutFactsWorker afterward, so shutdown() can land while _resolve_union is still decoding stored frames for the trim union. QThread.quit() cannot interrupt a running slot, so the worker kept decoding regardless of shutdown() giving up on it. Gives _CutFactsWorker the same cooperative-cancellation idiom FrameLoadWorker already has (9f8de12): a threading.Event created per generation in _schedule_facts, a cancelled predicate threaded through _compute_facts/_resolve_union into _iter_stored_frames (union_alpha_bounds is frozen and takes no hook, so the check lives in the frame-reading loop around it instead), and a private _CutFactsCancelled exception to unwind that loop. _join_worker gains the same wait: bool parameter as _cancel_frame_load: shutdown() blocks up to 5s for the in-flight frame to finish and the loop to notice cancellation, never longer; a superseding _schedule_facts does not wait at all, so it cannot block the GUI thread. A cancelled worker emits only finished, never succeeded/failed, so no stale facts reach the controller. Third consecutive fix: commit touching ui/transform_stage.py (after 9f8de12, b851ca2); the maintainer has been told and directed this one to proceed because its trigger is an observed CI crash, not speculation. Trigger: CI run 33856558458, job "test (ui)", segmentation fault (exit 139) in tests/ui/test_transform_stage.py after 62 passing tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 56 +++++++++++------- tests/ui/test_transform_stage.py | 89 ++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 21 deletions(-) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index bdba9e0..38e9cb5 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -77,12 +77,14 @@ class CutSession: fps: int -class _CutFactsWorker(QObject): - """Recompute one ``CutFacts`` off the GUI thread (facts can decode frames). +class _CutFactsCancelled(Exception): + """Unwinds ``_iter_stored_frames`` once cancelled.""" + - Also resolves the ``FramingPlan`` behind that ``CutFacts.framed_size`` -- - the player's frame loader needs the whole plan (task C2), not just its - output size, to call ``apply_framing`` per stored frame. +class _CutFactsWorker(QObject): + """Recompute one ``CutFacts`` off the GUI thread; also resolves the + ``FramingPlan`` the player's frame loader needs (task C2) to call + ``apply_framing`` per stored frame. """ succeeded = Signal(object, object, int) @@ -90,24 +92,24 @@ class _CutFactsWorker(QObject): finished = Signal() def __init__( - self, - session: CutSession, - framing: FramingSpec, - frame_reader: FrameReader, - generation: int, + self, session: CutSession, framing: FramingSpec, frame_reader: FrameReader, + generation: int, cancelled: Callable[[], bool] = lambda: False, ) -> None: super().__init__() self._session = session self._framing = framing self._frame_reader = frame_reader self._generation = generation + self._cancelled = cancelled @Slot() def run(self) -> None: try: facts, plan = _compute_facts( - self._session, self._framing, self._frame_reader + self._session, self._framing, self._frame_reader, self._cancelled ) + except _CutFactsCancelled: + pass except Exception: self.failed.emit(self._generation) else: @@ -144,6 +146,7 @@ def __init__( self._current_generation = 0 self._thread: QThread | None = None self._worker: _CutFactsWorker | None = None + self._facts_cancel_event: threading.Event | None = None self._framing = _framing_from_parameters(store.state.parameters) self._last_transform: TransformSpec = store.state.parameters.transform self._player_frames: PlayerFrames | None = None @@ -227,7 +230,7 @@ def shutdown(self) -> None: # would otherwise clear _frame_thread and leave nothing here to join. self._cancel_frame_load(wait=True) self.close_session() - self._join_worker() + self._join_worker(wait=True) @property def session(self) -> CutSession | None: @@ -336,7 +339,10 @@ def _schedule_facts(self) -> None: self._join_worker() generation = next(self._generations) self._current_generation = generation - worker = _CutFactsWorker(session, self._framing, self._frame_reader, generation) + cancel = self._facts_cancel_event = threading.Event() + worker = _CutFactsWorker( + session, self._framing, self._frame_reader, generation, cancel.is_set + ) thread = QThread(self) worker.moveToThread(thread) worker.succeeded.connect(self._facts_ready) @@ -398,7 +404,10 @@ def _set_facts(self, facts: CutFacts | None) -> None: self.facts_changed.emit(facts) self._sync_player_frames(immediate=True) - def _join_worker(self) -> None: + def _join_worker(self, *, wait: bool = False) -> None: + if self._facts_cancel_event is not None: + self._facts_cancel_event.set() + self._facts_cancel_event = None thread = self._thread self._thread = None self._worker = None @@ -406,7 +415,8 @@ def _join_worker(self) -> None: return try: thread.quit() - thread.wait(5000) + if wait: + thread.wait(5000) except RuntimeError: pass @@ -539,11 +549,13 @@ def _fps_from_manifest(manifest: CutManifest) -> int: def _compute_facts( - session: CutSession, framing: FramingSpec, frame_reader: FrameReader + session: CutSession, framing: FramingSpec, frame_reader: FrameReader, + cancelled: Callable[[], bool] = lambda: False, ) -> tuple[CutFacts, FramingPlan]: manifest = session.manifest source_size = (manifest.width, manifest.height) - union = _resolve_union(session, framing, frame_reader) if framing.trim else None + trim = framing.trim + union = _resolve_union(session, framing, frame_reader, cancelled) if trim else None plan = framing_plan(source_size, union, framing) delays = webp_delays(manifest.frame_count, session.fps) facts = CutFacts( @@ -557,7 +569,8 @@ def _compute_facts( def _resolve_union( - session: CutSession, framing: FramingSpec, frame_reader: FrameReader + session: CutSession, framing: FramingSpec, frame_reader: FrameReader, + cancelled: Callable[[], bool] = lambda: False, ) -> PixelBounds: metadata = session.manifest.union_metadata if metadata is not None: @@ -569,14 +582,17 @@ def _resolve_union( left, top, right, bottom = metadata.bounds return PixelBounds(left, top, right, bottom) return union_alpha_bounds( - _iter_stored_frames(session, frame_reader), framing.alpha_threshold + _iter_stored_frames(session, frame_reader, cancelled), framing.alpha_threshold ) def _iter_stored_frames( - session: CutSession, frame_reader: FrameReader + session: CutSession, frame_reader: FrameReader, + cancelled: Callable[[], bool] = lambda: False, ) -> Iterator[Image.Image]: for frame in session.manifest.frames: + if cancelled(): + raise _CutFactsCancelled image = frame_reader.read(session.workspace, frame) try: yield image diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index 77e2a46..d2ef4e8 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -6,13 +6,20 @@ import time from collections.abc import Callable from dataclasses import dataclass +from decimal import Decimal from fractions import Fraction from pathlib import Path +from PIL import Image from PySide6.QtCore import Qt from matteloop.core.crop_state import CropChanged -from matteloop.core.parameters import PaddingChanged, TransformChanged +from matteloop.core.parameters import ( + AlphaThresholdChanged, + GlobalTrimChanged, + PaddingChanged, + TransformChanged, +) from matteloop.core.specs import CropSpec, FramingSpec, TransformSpec from matteloop.core.state import ( AppState, @@ -206,6 +213,86 @@ def test_shutdown_joins_the_facts_worker_and_clears_the_session( assert controller._thread is None # noqa: SLF001 +class _StallFirstReadReader: + """Read the first stored frame normally but hold it open until told to + proceed -- this is what proves the facts worker is genuinely mid-decode, + not merely fast, by the time ``shutdown()`` lands on it. + """ + + def __init__(self) -> None: + self.started = threading.Event() + self.release = threading.Event() + self.calls = 0 + self._inner = _DirectFrameReader() + + def read(self, workspace: CutWorkspace, frame: CutFrame) -> Image.Image: + self.calls += 1 + if self.calls == 1: + self.started.set() + self.release.wait(5) + return self._inner.read(workspace, frame) + + +def test_shutdown_cancels_a_live_facts_computation_instead_of_waiting_for_it( + tmp_path, qtbot +) -> None: + """Regression pin for CI run 33856558458 (job "test (ui)", segfault, exit + 139, reproduced 3/3 on macOS): ``open_artifact`` sets ``self._session`` + synchronously and only schedules the facts worker afterwards, so a + ``qtbot.waitUntil(lambda: controller.session is not None)`` in the caller + can return while the worker is still decoding frames for the trim union. + ``QThread.quit()`` cannot interrupt a running slot, so before this fix + ``shutdown()`` waited out the *whole* decode; on Linux/offscreen CI the + worker thread segfaulted mid-decode instead of finishing it. + + This can only pin the *behaviour* -- that a cancelled worker stops + between frames and delivers no facts -- not the segfault itself: it does + not reproduce on Windows, so this test cannot prove or disprove it. CI is + the only real verifier of the crash fix. + + Deterministic rather than timing-dependent: the reader blocks the first + read on a real ``threading.Event`` and only releases it once the + controller's cancel token is observed set, so the assertions depend on + that ordering, not on wall-clock guesses. + """ + artifact = _seed_cut(tmp_path, "seed-facts-cancel") + assert artifact.manifest.frame_count >= 2, "need a frame that must never decode" + + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + reader = _StallFirstReadReader() + controller = TransformStageController(store, frame_reader=reader) + store.dispatch(GlobalTrimChanged(True)) + # A mismatched threshold: the render's cached union metadata must be + # skipped so _resolve_union actually decodes the stored frames. + store.dispatch(AlphaThresholdChanged(Decimal("50"))) + + controller.open_artifact(artifact) + assert reader.started.wait(5), "the facts worker never reached the first frame" + cancel_event = controller._facts_cancel_event # noqa: SLF001 + assert cancel_event is not None + worker = controller._worker # noqa: SLF001 + assert worker is not None + outcomes: list[str] = [] + worker.succeeded.connect(lambda *_a: outcomes.append("succeeded")) + worker.failed.connect(lambda *_a: outcomes.append("failed")) + + def _release_once_cancelled() -> None: + cancel_event.wait(5) + reader.release.set() + + releaser = threading.Thread(target=_release_once_cancelled) + releaser.start() + + controller.shutdown() + releaser.join(timeout=5) + + assert not releaser.is_alive(), "the releaser thread must not hang" + assert reader.calls == 1, "must not decode the second frame once cancelled" + qtbot.wait(50) # pump the event loop so a queued signal would land here + assert outcomes == [], "a cancelled worker must not report succeeded or failed" + assert controller.facts is None + + def test_attach_wires_the_canvas_command_requested_to_the_store(qtbot) -> None: store = _FakeStore(AppState()) controller = TransformStageController(store) From 1623b08c27d897ab7b43281a874de2abc1803392 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 13:05:55 +0200 Subject: [PATCH 31/45] fix: keep a superseded worker/thread referenced until it genuinely finishes A core-dump backtrace and a controlled devbox experiment (appending every (thread, worker) pair to a never-cleared list drops the SIGSEGV rate from 9-10/20 to 1/20, and every remaining failure is a deadlock, not a segfault) established the root cause: _join_worker/_cancel_frame_load dropped the controller's only Python reference to a superseded worker at supersede time (wait=False), before its QThread had necessarily started. Python then frees the worker, and the thread activates `started` on an already-collected receiver. Add TransformStageController._retiring, a list of (QThread, QObject) pairs. _retire() -- now the shared tail of _join_worker and _cancel_frame_load -- quits the superseded thread and, on a non-waiting supersede, appends the pair to _retiring instead of dropping it; it also prunes retirees whose thread has genuinely finished via _thread_is_finished(), which treats a RuntimeError from isFinished() (the C++ QThread already collected) as proof the thread is done. shutdown() waits out the current threads and every retiree, each bounded at 5s, then clears the list -- a deadlock is one of the three observed failure modes, so no wait is unbounded. Kept _facts_thread_finished/_frame_thread_finished (from b851ca2): they null the *current* thread's references the instant its own `finished` fires, which is always safe (the thread has already stopped by then) and orthogonal to the supersede-time bug above -- without them, the next _retire() call could read a stale self._thread whose C++ object a prior deleteLater had already collected. G1 note: this is the fourth consecutive fix: commit touching this file, past the third-consecutive stop -- proceeding because the cause is now measured (core dump + controlled experiment), not guessed, and the maintainer of this session has been told. Trigger: CI run 33856558458 (job "test (ui)", SIGSEGV); devbox measurement of tests/ui/test_transform_stage.py alone, 9-10/20 runs failing (SIGSEGV, SIGABRT, or deadlock) before this change. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 124 ++++++++++++++-------------- tests/ui/test_transform_stage.py | 78 +++++++++++++++++ 2 files changed, 140 insertions(+), 62 deletions(-) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index 38e9cb5..bb77d90 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -2,17 +2,14 @@ ``ParameterState`` has room for a ``TransformSpec`` (D4) but nothing in ``AppState`` names a cut workspace, and ``core/state.py`` is frozen. This -controller fills that gap: it tracks a ``CutSession`` from the render -worker's raw artifact, recomputes the cut-derived ``CutFacts`` the -``TransformGroup`` readout needs whenever a framing-relevant parameter -changes, and restores a cut's stored transform before "Use this set" -rebuilds it (E17). - -The session deliberately does not cache a ``FramingSpec`` (T6): the current -framing always comes from ``store.state.parameters``, read fresh on every -recomputation, so a padding/trim/stretch edit is what re-derives the framed -size a stale crop is clamped against (E14) -- caching it here would let the -two drift. +controller tracks a ``CutSession`` from the render worker's raw artifact, +recomputes the cut-derived ``CutFacts`` the ``TransformGroup`` readout needs +whenever a framing-relevant parameter changes, and restores a cut's stored +transform before "Use this set" rebuilds it (E17). + +It deliberately does not cache a ``FramingSpec`` (T6): framing always comes +from ``store.state.parameters``, read fresh each recomputation, so an edit +re-derives the framed size a stale crop is clamped against (E14). """ from __future__ import annotations @@ -59,8 +56,7 @@ def read(self, workspace: CutWorkspace, frame: CutFrame) -> Image.Image: ... class _DirectFrameReader: """Open a stored frame straight off disk -- never through - ``CutWorkspace.read_promoted_cut``, which rescans the whole cut per call. - """ + ``read_promoted_cut``, which rescans the whole cut per call.""" def read(self, workspace: CutWorkspace, frame: CutFrame) -> Image.Image: with Image.open(workspace.path / frame.filename) as opened: @@ -83,9 +79,7 @@ class _CutFactsCancelled(Exception): class _CutFactsWorker(QObject): """Recompute one ``CutFacts`` off the GUI thread; also resolves the - ``FramingPlan`` the player's frame loader needs (task C2) to call - ``apply_framing`` per stored frame. - """ + ``FramingPlan`` the player's frame loader needs (C2) to apply per frame.""" succeeded = Signal(object, object, int) failed = Signal(int) @@ -147,6 +141,9 @@ def __init__( self._thread: QThread | None = None self._worker: _CutFactsWorker | None = None self._facts_cancel_event: threading.Event | None = None + # Superseded pairs stay referenced until genuinely finished -- see + # _join_worker / _cancel_frame_load. + self._retiring: list[tuple[QThread, QObject]] = [] self._framing = _framing_from_parameters(store.state.parameters) self._last_transform: TransformSpec = store.state.parameters.transform self._player_frames: PlayerFrames | None = None @@ -169,8 +166,7 @@ def attach(self, group: TransformGroup, canvas: CropCanvas | None) -> None: ``main_window.py`` only wires ``command_requested`` for the timeline, the original canvas, and the inspector (D5) -- a fourth widget needs - its own connection, which is what this method is for. - """ + its own connection, wired here.""" self._group = group self._canvas = canvas self._player_canvas = canvas if isinstance(canvas, ResultPlayerCanvas) else None @@ -196,12 +192,11 @@ def open_artifact(self, artifact: object) -> None: self._schedule_facts() def restore_for(self, workspace: CutWorkspace) -> None: - """Dispatch the cut's stored transform before a rebuild request is built. + """Dispatch the cut's stored transform before a rebuild request. Clamps the crop when facts for this exact cut are already known - (E14); otherwise dispatches the raw stored spec and lets the facts - recomputation triggered by the rebuild's own ``open_artifact`` clamp - it -- never blocks this call on decoding the cut's frames. + (E14); otherwise the rebuild's own ``open_artifact`` clamps it -- + this call never blocks on decoding the cut's frames. """ transform = load_transform(workspace) facts = self._facts @@ -225,12 +220,17 @@ def shutdown(self) -> None: if self._unsubscribe is not None: unsubscribe, self._unsubscribe = self._unsubscribe, None unsubscribe() - # Join the frame loader before close_session(): it also cancels the - # frame load (via _sync_player_frames), but without waiting, which - # would otherwise clear _frame_thread and leave nothing here to join. + # Join the frame loader before close_session() clears _frame_thread + # via _sync_player_frames's own (non-waiting) cancel. self._cancel_frame_load(wait=True) self.close_session() self._join_worker(wait=True) + for thread, _worker in self._retiring: + try: + thread.wait(5000) # bounded: a deadlocked retiree can't hang shutdown + except RuntimeError: + pass + self._retiring.clear() @property def session(self) -> CutSession | None: @@ -320,11 +320,10 @@ def _state_changed(self, state: AppState) -> None: self._sync_player_frames(immediate=False) def _refresh_crop_edit_presentation(self, transform: TransformSpec) -> None: - """Re-apply the crop-edit presentation for *transform* without the + """Re-apply the crop-edit presentation for *transform*, skipping the frame reload ``_sync_player_frames`` would trigger (E33): the canvas - overlay and its focus/drag geometry only ever refresh inside - ``apply_presentation``, so a stale presentation between commands is - what stalls a drag mid-gesture and repeats a nudge's first pixel. + overlay only refreshes inside ``apply_presentation``, so a stale + presentation stalls a drag and repeats a nudge's first pixel. """ canvas = self._player_canvas if canvas is None: @@ -358,14 +357,8 @@ def _schedule_facts(self) -> None: @Slot() def _facts_thread_finished(self) -> None: - """Drop the dangling references once the facts worker's thread ends. - - Connected ahead of ``thread.deleteLater`` (Qt invokes slots on one - signal in connection order), so by the time ``deleteLater`` frees - the C++ ``QThread`` object, ``_join_worker`` already has nothing to - find and never calls ``quit()``/``wait()`` on it. The ``sender()`` - check guards against a superseded thread's queued ``finished`` - clearing a newer thread that ``_schedule_facts`` has since started. + """Null the current-thread refs once genuinely finished -- always + safe by then, unlike a supersede (see ``_retire``). """ if self.sender() is self._thread: self._thread = None @@ -405,18 +398,29 @@ def _set_facts(self, facts: CutFacts | None) -> None: self._sync_player_frames(immediate=True) def _join_worker(self, *, wait: bool = False) -> None: + """Detach the current facts worker/thread -- see ``_retire``.""" if self._facts_cancel_event is not None: self._facts_cancel_event.set() self._facts_cancel_event = None - thread = self._thread - self._thread = None - self._worker = None + thread, self._thread = self._thread, None + worker, self._worker = self._worker, None + self._retire(thread, worker, wait=wait) + + def _retire( + self, thread: QThread | None, worker: QObject | None, *, wait: bool + ) -> None: + """Quit *thread*; wait bounded if *wait*, else retire the pair (a + drop before it starts/finishes risks the SIGSEGV this guards + against). Also prunes retirees that have finished.""" + self._retiring = [p for p in self._retiring if not _thread_is_finished(p[0])] if thread is None: return try: thread.quit() if wait: thread.wait(5000) + elif worker is not None: + self._retiring.append((thread, worker)) except RuntimeError: pass @@ -475,11 +479,7 @@ def _start_frame_load(self) -> None: @Slot() def _frame_thread_finished(self) -> None: - """Mirror ``_facts_thread_finished`` for the player's frame loader: - clear the dangling references before ``deleteLater`` (connected - right after) frees the ``QThread`` out from under - ``_cancel_frame_load``. - """ + """Mirror ``_facts_thread_finished`` for the frame loader.""" if self.sender() is self._frame_thread: self._frame_thread = None self._frame_worker = None @@ -503,30 +503,20 @@ def _cancel_frame_load(self, *, wait: bool = False) -> None: """Stop the in-flight frame load, if any, without blocking on it. The worker checks ``_frame_cancel_event`` between frames (E23), so - setting it here lets a superseded load stop itself; ``thread.quit()`` + setting it here lets a superseded load stop itself -- ``quit()`` alone cannot interrupt its running slot. Bumping the generation - (E24) means a load that finishes anyway -- it slipped past the - cancellation check, or was already queued on the Qt event loop -- - cannot resurrect frames for a session this call is closing. - Only ``shutdown()`` passes ``wait=True``: the object is going away - and must not leave a thread running past it. + (E24) means a load that slips past cancellation cannot resurrect + frames for a session this call is closing. Only ``shutdown()`` + passes ``wait=True``; otherwise the pair is retired -- see ``_retire``. """ self._debounce_timer.stop() self._frame_generation = next(self._frame_generations) if self._frame_cancel_event is not None: self._frame_cancel_event.set() self._frame_cancel_event = None - thread = self._frame_thread - self._frame_thread = None - self._frame_worker = None - if thread is None: - return - try: - thread.quit() - if wait: - thread.wait(5000) - except RuntimeError: - pass + thread, self._frame_thread = self._frame_thread, None + worker, self._frame_worker = self._frame_worker, None + self._retire(thread, worker, wait=wait) def _framing_from_parameters(parameters: ParameterState) -> FramingSpec: @@ -538,6 +528,16 @@ def _framing_from_parameters(parameters: ParameterState) -> FramingSpec: ) +def _thread_is_finished(thread: QThread) -> bool: + """A ``RuntimeError`` means the QThread's C++ object was already + collected -- itself proof the thread is done. + """ + try: + return thread.isFinished() + except RuntimeError: + return True + + def _fps_from_manifest(manifest: CutManifest) -> int: sampling = manifest.cache_key_inputs["sampling"] if not isinstance(sampling, Mapping): diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index d2ef4e8..9b88768 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -293,6 +293,84 @@ def _release_once_cancelled() -> None: assert controller.facts is None +class _StallEveryReadReader: + """Read every stored frame, but block on every call until released -- + this is what lets a supersede be provoked while the superseded worker + is genuinely mid-decode, deterministically rather than by timing. + """ + + def __init__(self) -> None: + self.started = threading.Event() + self.release = threading.Event() + self._inner = _DirectFrameReader() + + def read(self, workspace: CutWorkspace, frame: CutFrame) -> Image.Image: + self.started.set() + self.release.wait(5) + return self._inner.read(workspace, frame) + + +def test_superseding_the_facts_worker_retires_it_until_its_thread_finishes( + tmp_path, qtbot +) -> None: + """Pins the SIGSEGV fix (CI run 33856558458; devbox measurement 9-10/20 + failures with a core dump inside ``QThread::started`` emission): a + superseded worker/thread pair must stay referenced by the controller + until the thread genuinely finishes, never dropped at supersede time -- + dropping it before the thread has started (or finished) is what let Qt + activate ``started`` on an already-collected receiver. + + Deterministic: the reader blocks every read on a real + ``threading.Event``, so the assertions depend on that ordering, not on + wall-clock guesses. + """ + artifact = _seed_cut(tmp_path, "seed-retire") + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + reader = _StallEveryReadReader() + controller = TransformStageController(store, frame_reader=reader) + # A mismatched threshold: the render's cached union metadata must be + # skipped so _resolve_union actually decodes the stored frames. + store.dispatch(GlobalTrimChanged(True)) + store.dispatch(AlphaThresholdChanged(Decimal("50"))) + + controller.open_artifact(artifact) + assert reader.started.wait(5), "the facts worker never reached the first frame" + retiring_thread = controller._thread # noqa: SLF001 + retiring_worker = controller._worker # noqa: SLF001 + assert retiring_thread is not None + assert retiring_worker is not None + + controller._schedule_facts() # noqa: SLF001 -- supersede while still blocked + + assert controller._thread is not retiring_thread # noqa: SLF001 + assert not retiring_thread.isFinished(), ( + "a superseded thread must stay referenced until it genuinely finishes" + ) + assert (retiring_thread, retiring_worker) in controller._retiring # noqa: SLF001 + + # A plain busy-wait, not qtbot.wait/waitUntil: spinning the Qt event + # loop here could process the retired thread's queued `finished` -> + # `deleteLater`, and isFinished() on an already-deleted QThread raises + # RuntimeError -- exactly the class of access this fix guards against + # in production code (see `_thread_is_finished`). Polling without + # spinning the loop keeps the C++ object alive so this check stays + # safe while still being a real completion condition, not a fixed sleep. + reader.release.set() + deadline = time.monotonic() + 5 + while not retiring_thread.isFinished() and time.monotonic() < deadline: + time.sleep(0.01) + assert retiring_thread.isFinished(), "the superseded thread never finished" + assert (retiring_thread, retiring_worker) in controller._retiring, ( # noqa: SLF001 + "a finished retiree must stay put until something prunes the list" + ) + + controller.shutdown() + + assert controller._retiring == [] # noqa: SLF001 + assert controller._thread is None # noqa: SLF001 + assert controller._worker is None # noqa: SLF001 + + def test_attach_wires_the_canvas_command_requested_to_the_store(qtbot) -> None: store = _FakeStore(AppState()) controller = TransformStageController(store) From 7af24dbb66a8abb893eb7ceb96721f42a0e61070 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 13:22:52 +0200 Subject: [PATCH 32/45] fix: destroy transform-stage workers on the main thread, not their own Removed both `worker.finished.connect(worker.deleteLater)` connections in `_schedule_facts` and `_start_frame_load`. Each worker lives on its own QThread, so that connection queued its deferred delete onto the worker thread's own event loop -- destroying the QObject there needs the GIL for shiboken's Python-override lookup, while the main thread can simultaneously hold the GIL wanting a Qt-internal mutex the same destructor takes. Lock inversion, deadlock. Worker lifetime is now driven entirely by Python refcounting on the main thread: `_facts_thread_finished` / `_frame_thread_finished` null the active `self._worker`/`self._frame_worker` slot from a thread.finished slot (always main-thread, per the existing comment), and superseded workers already sit in `self._retiring` until pruned or cleared by `shutdown()`, likewise main-thread-only. `thread.deleteLater` connections are untouched -- the QThread control object was created on the main thread, so its own deferred delete was never the problem. G1: this is the fifth consecutive fix: commit on this file, past the third-consecutive stop -- proceeding because each cause (the earlier SIGSEGV class, now this deadlock) was independently measured rather than guessed, and the maintainer has been informed. Trigger: gdb backtrace of a hung process: main thread blocked in QLabel::setPixmap -> ~QWidgetTextControl -> QObject::~QObject -> QBasicMutex::lockInternal while holding the GIL; worker thread blocked in QObject::~QObject -> Sbk_GetPyOverride -> PyGILState_Ensure waiting on the GIL. Devbox measurement: tests/ui/test_transform_stage.py went 1/25 -> 0/25 failures, full `pytest -q tests/ui` 0/20, after removing both deleteLater connections. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index bb77d90..0ccf421 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -347,7 +347,6 @@ def _schedule_facts(self) -> None: worker.succeeded.connect(self._facts_ready) worker.failed.connect(self._facts_failed) worker.finished.connect(thread.quit) - worker.finished.connect(worker.deleteLater) thread.finished.connect(self._facts_thread_finished) thread.finished.connect(thread.deleteLater) thread.started.connect(worker.run) @@ -469,7 +468,6 @@ def _start_frame_load(self) -> None: worker.succeeded.connect(self._frame_load_succeeded) worker.failed.connect(self._frame_load_failed) worker.finished.connect(thread.quit) - worker.finished.connect(worker.deleteLater) thread.finished.connect(self._frame_thread_finished) thread.finished.connect(thread.deleteLater) thread.started.connect(worker.run) From 3830bd74acafb00d6443a8ce3d7589a498153321 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 14:26:13 +0200 Subject: [PATCH 33/45] fix: light the result loop's checkerboard without requiring a Preview Render can open the transform stage without a Preview ever having run (render_controller.artifact_ready -> ui/controller.py:195), but present() derives result_checkerboard only from state.preview (ui/presenter.py:87-89), which nothing in the transform stage touches. The player then looped a transparent cut-out over flat grey -- the one thing this tool exists to show clearly. presenter.py and main_window.py are frozen (guardrails-baseline.json), so the fix moves into PreviewCanvas, which already decides how it paints itself: its checkerboard condition is now an overridable _should_paint_checkerboard() predicate, and ResultPlayerCanvas overrides it to also light up whenever a player session is holding frames (self._frames is not None), independent of PreviewState. Every other canvas keeps following the `checkerboard` property exactly as before. Also fixes the Play button reading "Play" while the loop plays: the theme has no QPushButton:checked rule (only QToolButton:checked), so on a flat style the checked state alone is easy to miss. play()/pause() now update the button's text ("Play"/"Pause") and accessible name together, the same way set_cancelling() already does elsewhere in this app, through one small _sync_play_button() helper. This file's history is already deep in fix: commits for the worker/ thread-teardown crash work (dd70069, c550de5, 248b60e, 642c1b0); these two are distinct, observed UI defects the maintainer found on an offscreen visual pass, not more of that hardening. Trigger: maintainer's offscreen visual pass on PR #27 (2026-09-04). Repro for the checkerboard defect: load a video, press Render without ever pressing Preview, and the result loop plays its transparent cut-out over flat grey. present(AppState()) returns result_checkerboard=False even once the transform stage is open; forcing the property true confirmed the painting mechanism itself was correct and only the trigger was missing. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/preview_canvas.py | 10 +++++++- src/matteloop/ui/result_player.py | 38 ++++++++++++++++++++++++++---- tests/ui/test_preview_canvas.py | 18 ++++++++++++++ tests/ui/test_result_player.py | 37 +++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 tests/ui/test_preview_canvas.py diff --git a/src/matteloop/ui/preview_canvas.py b/src/matteloop/ui/preview_canvas.py index ce9e8b1..61aebcf 100644 --- a/src/matteloop/ui/preview_canvas.py +++ b/src/matteloop/ui/preview_canvas.py @@ -182,8 +182,16 @@ def resizeEvent(self, event) -> None: # type: ignore[no-untyped-def] super().resizeEvent(event) self._update_pixmap() + def _should_paint_checkerboard(self) -> bool: + """Whether this canvas paints a checkerboard behind its frame. + + Subclasses override to add cases the ``checkerboard`` property alone + cannot express (e.g. a player actively holding frames to show). + """ + return self.property("checkerboard") is True + def paintEvent(self, event) -> None: # type: ignore[no-untyped-def] - if self.property("checkerboard") is True: + if self._should_paint_checkerboard(): painter = QPainter(self) tile = 16 light = QColor(CHECKERBOARD_LIGHT) diff --git a/src/matteloop/ui/result_player.py b/src/matteloop/ui/result_player.py index 5ec106a..5aebbd6 100644 --- a/src/matteloop/ui/result_player.py +++ b/src/matteloop/ui/result_player.py @@ -42,6 +42,11 @@ PLAYER_CACHE_BUDGET_BYTES = 128 * 1024 * 1024 PLAYER_MIN_DISPLAY_SIDE = 64 +_PLAY_TEXT = "Play" +_PLAY_ACCESSIBLE_NAME = "Play the result loop" +_PAUSE_TEXT = "Pause" +_PAUSE_ACCESSIBLE_NAME = "Pause the result loop" + class _FrameLoadCancelled(Exception): """Raised internally to unwind ``_load_player_frames`` once superseded.""" @@ -97,7 +102,7 @@ def __init__( self._timer.timeout.connect(self._advance) self.play_button = QPushButton("Play", self) self.play_button.setObjectName("result_play") - self.play_button.setAccessibleName("Play the result loop") + self.play_button.setAccessibleName(_PLAY_ACCESSIBLE_NAME) self.play_button.setCheckable(True) self.play_button.hide() self.play_button.toggled.connect(self._play_toggled) @@ -163,15 +168,27 @@ def play(self) -> None: if self._frames is None or not self._playable(): return self._playing = True - if not self.play_button.isChecked(): - self.play_button.setChecked(True) + self._sync_play_button(True) self._schedule_next() def pause(self) -> None: self._playing = False self._timer.stop() - if self.play_button.isChecked(): - self.play_button.setChecked(False) + self._sync_play_button(False) + + def _sync_play_button(self, playing: bool) -> None: + """Keep the button's checked state, text, and accessible name in step. + + Text is the reliable signal that the loop is running: the theme has + no ``QPushButton:checked`` rule, so the checked visual alone is easy + to miss on a flat style. + """ + if self.play_button.isChecked() != playing: + self.play_button.setChecked(playing) + self.play_button.setText(_PAUSE_TEXT if playing else _PLAY_TEXT) + self.play_button.setAccessibleName( + _PAUSE_ACCESSIBLE_NAME if playing else _PLAY_ACCESSIBLE_NAME + ) @property def playing(self) -> bool: @@ -214,6 +231,17 @@ def _constrain(self, crop: CropSpec, target: str) -> CropSpec: def _crop_event(self, crop: CropSpec) -> object: return TransformChanged(replace(self._transform, crop=crop)) + def _should_paint_checkerboard(self) -> bool: + """Show the checkerboard whenever a player session is holding frames. + + A Render can open the transform stage without a Preview ever having + run, so ``checkerboard`` (a proxy for ``PreviewState``) can be false + while the loop plays a transparent cut-out over flat grey. The player + itself always knows whether it has frames to show, independent of + preview state. + """ + return super()._should_paint_checkerboard() or self._frames is not None + # -- playback internals --------------------------------------------- def _play_toggled(self, checked: bool) -> None: diff --git a/tests/ui/test_preview_canvas.py b/tests/ui/test_preview_canvas.py new file mode 100644 index 0000000..71abb21 --- /dev/null +++ b/tests/ui/test_preview_canvas.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from matteloop.ui.preview_canvas import PreviewCanvas + + +def test_checkerboard_predicate_follows_the_property_alone(qtbot) -> None: + """A plain PreviewCanvas has no frame session to hold, so its checkerboard + predicate must keep following the ``checkerboard`` property exactly as it + did before ``ResultPlayerCanvas`` gained an overridable reason of its + own (see test_result_player.py's player-holding-frames case).""" + canvas = PreviewCanvas("Original", "original_canvas") + qtbot.addWidget(canvas) + + canvas.setProperty("checkerboard", False) + assert canvas._should_paint_checkerboard() is False # noqa: SLF001 + + canvas.setProperty("checkerboard", True) + assert canvas._should_paint_checkerboard() is True # noqa: SLF001 diff --git a/tests/ui/test_result_player.py b/tests/ui/test_result_player.py index 5978aad..abdd903 100644 --- a/tests/ui/test_result_player.py +++ b/tests/ui/test_result_player.py @@ -146,6 +146,43 @@ def test_play_button_hidden_without_frames_and_shown_with_them(qtbot) -> None: assert canvas.play_button.isHidden() +def test_checkerboard_paints_whenever_the_player_holds_frames(qtbot) -> None: + """A Render can open the transform stage without a Preview ever having + run, leaving ``checkerboard`` (a PreviewState proxy) false while the + player shows a transparent cut-out. The canvas must not rely on that + property once it is actually holding frames to show.""" + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.setProperty("checkerboard", False) + assert canvas._should_paint_checkerboard() is False # noqa: SLF001 + + canvas.set_frames(_player_frames(2, (100, 100))) + + assert canvas._should_paint_checkerboard() is True # noqa: SLF001 + + canvas.set_frames(None) + + assert canvas._should_paint_checkerboard() is False # noqa: SLF001 + + +def test_play_button_text_and_accessible_name_track_playback_state(qtbot) -> None: + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.set_frames(_player_frames(3, (500, 500, 500))) + assert canvas.play_button.text() == "Play" + assert canvas.play_button.accessibleName() == "Play the result loop" + + canvas.play() + + assert canvas.play_button.text() == "Pause" + assert canvas.play_button.accessibleName() == "Pause the result loop" + + canvas.pause() + + assert canvas.play_button.text() == "Play" + assert canvas.play_button.accessibleName() == "Play the result loop" + + def test_session_displays_in_fit_mode_and_restores_cover(qtbot) -> None: canvas = ResultPlayerCanvas() qtbot.addWidget(canvas) From 25877096a9e43d5d645a420e940ab87949616366 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 14:26:23 +0200 Subject: [PATCH 34/45] fix: give the Transform panel's three sections visible headings _build_layout stacked _trim_section, _crop_section, and _resize_section with no visible titles of their own -- the inspector only supplies the outer "Transform" disclosure title. Inside it, crop's and resize's Width/Height field pairs are visually identical, so nothing tells a user which pair belongs to which operation; only the trim rows ("First frame"/"Last frame") read unambiguously. Each section now opens with a bold QLabel heading ("Trim", "Crop", "Resize") added as a full-width QFormLayout row, the same pattern already used for crop_edit_checkbox. Styling reuses the codebase's existing property-selector idiom (QLabel[secondary='true'] etc.) and the same font-weight: 600 the disclosure headers already use for QToolButton, rather than inventing a new visual language. A plain QLabel takes no focus, so tab_widgets() and its focus-order test are unchanged; a new test asserts that explicitly. Trigger: maintainer's offscreen visual pass on PR #27 (2026-09-04) -- the Transform panel showed two identical unlabelled Width/Height pairs with nothing distinguishing crop from resize (ui/transform_group.py: 269-277 built three unlabelled section widgets). Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/theme.py | 1 + src/matteloop/ui/transform_group.py | 14 ++++++++++++++ tests/ui/test_transform_group.py | 26 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/matteloop/ui/theme.py b/src/matteloop/ui/theme.py index 180b85b..78ce731 100644 --- a/src/matteloop/ui/theme.py +++ b/src/matteloop/ui/theme.py @@ -84,6 +84,7 @@ def install_theme( QPushButton[primaryAction='true'] {{ background: {ACCENT_COLOR}; color: {PRIMARY_ACTION_TEXT_COLOR}; border-color: {ACCENT_COLOR}; }} QLabel[secondary='true'] {{ color: {SECONDARY_COLOR}; }} + QLabel[heading='true'] {{ font-weight: 600; }} QLabel[mono='true'], QAbstractSpinBox[mono='true'], QLineEdit[mono='true'] {{ font-family: '{MONO_FONT}', {_MONO_FALLBACKS}; }} diff --git a/src/matteloop/ui/transform_group.py b/src/matteloop/ui/transform_group.py index 1816fa7..2309407 100644 --- a/src/matteloop/ui/transform_group.py +++ b/src/matteloop/ui/transform_group.py @@ -275,9 +275,21 @@ def _build_layout(self) -> None: layout.addWidget(self._resize_section()) layout.addWidget(self.readout_label) + def _section_heading(self, text: str, object_name: str) -> QLabel: + """A section title distinguishing this group's fields from its + siblings (e.g. crop's Width/Height from resize's), matching the + bold, non-interactive heading idiom the disclosure headers already + use (``theme.py``'s ``QToolButton { font-weight: 600; }``).""" + heading = QLabel(text) + heading.setObjectName(object_name) + heading.setProperty("heading", True) + heading.setAccessibleName(text) + return heading + def _trim_section(self) -> QWidget: section = QWidget() layout = QFormLayout(section) + layout.addRow(self._section_heading("Trim", "transform_trim_heading")) layout.addRow("First frame", self._trim_row( self.first_frame_spinbox, self.first_playhead_button )) @@ -297,6 +309,7 @@ def _trim_row(self, spinbox: QSpinBox, button: QPushButton) -> QWidget: def _crop_section(self) -> QWidget: section = QWidget() layout = QFormLayout(section) + layout.addRow(self._section_heading("Crop", "transform_crop_heading")) layout.addRow(self.crop_edit_checkbox) layout.addRow("Aspect", self.aspect_combo) layout.addRow("X", self.crop_x_spinbox) @@ -308,6 +321,7 @@ def _crop_section(self) -> QWidget: def _resize_section(self) -> QWidget: section = QWidget() layout = QFormLayout(section) + layout.addRow(self._section_heading("Resize", "transform_resize_heading")) layout.addRow("Width", self.width_spinbox) layout.addRow("Height", self.height_spinbox) layout.addRow("Percent", self.percent_spinbox) diff --git a/tests/ui/test_transform_group.py b/tests/ui/test_transform_group.py index 58a4247..5c24db5 100644 --- a/tests/ui/test_transform_group.py +++ b/tests/ui/test_transform_group.py @@ -4,6 +4,8 @@ from fractions import Fraction from pathlib import Path +from PySide6.QtWidgets import QLabel + from matteloop.core.crop import centered_crop_for_aspect from matteloop.core.parameters import TransformChanged from matteloop.core.specs import ( @@ -303,3 +305,27 @@ def test_tab_widgets_returns_the_focus_order(qtbot) -> None: assert widgets[0] is group.first_frame_spinbox assert group.size_preset_combo in widgets assert len(widgets) == len(set(widgets)) + + +def test_crop_and_resize_sections_have_distinguishing_headings(qtbot) -> None: + """Without a heading, crop's and resize's identical Width/Height field + pairs are indistinguishable (only the trim rows read unambiguously).""" + group, _emitted = _group(qtbot) + + trim_heading = group.findChild(QLabel, "transform_trim_heading") + crop_heading = group.findChild(QLabel, "transform_crop_heading") + resize_heading = group.findChild(QLabel, "transform_resize_heading") + + assert trim_heading is not None and trim_heading.text() == "Trim" + assert crop_heading is not None and crop_heading.text() == "Crop" + assert resize_heading is not None and resize_heading.text() == "Resize" + # Each heading sits in its own section, ahead of that section's Width + # field, not attached to a sibling section. + assert crop_heading.parentWidget() is group.crop_width_spinbox.parentWidget() + assert resize_heading.parentWidget() is group.width_spinbox.parentWidget() + assert crop_heading.parentWidget() is not resize_heading.parentWidget() + # A plain heading label is not a tab stop and must not shift focus order. + assert trim_heading not in group.tab_widgets() + assert crop_heading not in group.tab_widgets() + assert resize_heading not in group.tab_widgets() + assert group.tab_widgets()[0] is group.first_frame_spinbox From b04c1819bf690be0d9b7c879c5719a8b35468949 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 17:03:10 +0200 Subject: [PATCH 35/45] fix: keep the result loop honest about trim and a shrinking frame Trigger: manual field test of the transform stage on Windows, 2026-09-04 (reported in PR #27). Two separate defects in this file, both reproduced offscreen against a real cut. Trim was applied and then destroyed. _state_changed compared the whole TransformSpec, so a trim-only edit scheduled a debounced frame reload; that reload calls ResultPlayerCanvas.set_frames, which cleared the kept range set_kept_range had applied moments earlier. Measured: playable = range(1, 2) right after the dispatch, range(0, 2) once the reload landed, so the loop showed the trim for about 250 ms and then reverted to the full cut. The reload was also pure waste, since apply_transform only crops and resizes and the rebuilt cache was byte-identical to the one it replaced. It is now gated on a crop or resize change, which is the contract the plan states twice: "Trim never reloads: playback slices kept_range over the caches". This also explains why the trim appeared to stick only while "Edit crop" was ticked: that branch deliberately skips the reload (E33), so nothing wiped the range. The crop overlay kept a stale framed size. _facts_ready clamped the stored crop and stored the new CutFacts, but the only overlay refresh ran from _state_changed on the clamp's own TransformChanged, when self._facts still held the old value -- and a framing change needing no clamp dispatched nothing at all. Measured with padding 40 -> 0: the overlay stayed on a 500x300 source after the facts moved to 420x220, so a full-frame crop drew as an 84% x 73% sub-rect and _emit_drag let the crop be dragged back out past the real framed size. The overlay is now re-presented against the facts just stored. Tests: test_trim_only_edit_does_not_reload_frames_or_lose_the_kept_range and test_crop_edit_overlay_follows_a_framed_size_change. Neither behaviour had coverage -- the existing tests exercise set_kept_range in isolation, never followed by a set_frames, and the crop-edit debounce only for crop edits. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 17 +++++- tests/ui/test_transform_stage.py | 83 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index 0ccf421..7091c56 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -306,7 +306,12 @@ def _state_changed(self, state: AppState) -> None: if self._session is not None: self._schedule_facts() transform = parameters.transform - transform_changed = transform != self._last_transform + last_transform = self._last_transform + transform_changed = transform != last_transform + reload_relevant_changed = ( + transform.crop != last_transform.crop + or transform.resize != last_transform.resize + ) self._last_transform = transform facts = self._facts if self._player_canvas is not None and facts is not None: @@ -316,7 +321,7 @@ def _state_changed(self, state: AppState) -> None: if transform_changed and not framing_changed: if crop_editing: self._refresh_crop_edit_presentation(transform) - else: + elif reload_relevant_changed: self._sync_player_frames(immediate=False) def _refresh_crop_edit_presentation(self, transform: TransformSpec) -> None: @@ -374,6 +379,14 @@ def _facts_ready(self, facts: object, plan: object, generation: int) -> None: self._plan = plan self._clamp_current_crop(facts) self._set_facts(facts) + if self._crop_edit_enabled: + # The clamp above may have already dispatched a TransformChanged + # that reached _state_changed while self._facts still held the + # OLD facts (_set_facts had not run yet) -- and a framing change + # that needs no clamp at all triggers no dispatch whatsoever. + # Either way, the overlay must be re-presented against the facts + # just stored, not whatever was live when editing began. + self._refresh_crop_edit_presentation(self._store.state.parameters.transform) @Slot(int) def _facts_failed(self, generation: int) -> None: diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index 9b88768..183af02 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -559,6 +559,47 @@ def test_superseding_a_frame_load_does_not_block_the_gui_thread( controller.shutdown() +def test_trim_only_edit_does_not_reload_frames_or_lose_the_kept_range( + tmp_path, qtbot +) -> None: + """A trim-only edit (first_frame/last_frame) was applied to the result + loop via ``set_kept_range`` and then wiped by ``_state_changed`` + scheduling a needless ``_sync_player_frames`` reload -- ``set_frames`` + resets ``canvas._kept`` to ``None`` (the full range) once that reload's + result lands, even though ``apply_transform`` only crops/resizes and the + rebuilt cache would be byte-identical to the one it replaced. + """ + artifact = _seed_cut(tmp_path, "trim-only") + reader = _CountingReader() + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store, frame_reader=reader) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + frame_count = artifact.manifest.frame_count + assert frame_count >= 2, "need room to trim off the first frame" + initial_reads = reader.calls + + store.dispatch(TransformChanged(TransformSpec(first_frame=1))) + + assert canvas._kept == range(1, frame_count) # noqa: SLF001 + assert canvas.current_frame == 1 + + qtbot.wait(500) # long enough for the 250ms debounce to fire if scheduled + + assert reader.calls == initial_reads, "a trim-only edit must never reload the cache" + assert canvas._kept == range(1, frame_count), ( # noqa: SLF001 + "the trim must survive even if something still reloads the cache" + ) + assert canvas.current_frame == 1 + controller.shutdown() + + # -- E33: crop-edit drags are debounced and skipped while editing ----------- @@ -650,6 +691,48 @@ def test_state_changed_refreshes_the_crop_edit_presentation(tmp_path, qtbot) -> controller.shutdown() +def test_crop_edit_overlay_follows_a_framed_size_change(tmp_path, qtbot) -> None: + """``_facts_ready`` clamps the stored crop and stores the new + ``CutFacts``, but the only overlay refresh path, + ``_refresh_crop_edit_presentation``, otherwise only ran from + ``_state_changed`` reacting to the clamp's own ``TransformChanged`` -- + at which point ``self._facts`` was still the OLD facts. A framing change + that does not need to clamp anything (crop already fits) never refreshed + the overlay at all, leaving it framed against a stale source size. + """ + artifact = _seed_cut(tmp_path, "framed-size-change") + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + original_size = controller.facts.framed_size + + controller._crop_edit_toggled(True) # noqa: SLF001 + assert canvas._presentation is not None # noqa: SLF001 + assert (canvas._presentation.width, canvas._presentation.height) == original_size # noqa: SLF001 + + store.dispatch(PaddingChanged(10)) + qtbot.waitUntil( + lambda: controller.facts is not None + and controller.facts.framed_size != original_size, + timeout=5000, + ) + new_size = controller.facts.framed_size + + assert canvas._presentation is not None # noqa: SLF001 + assert (canvas._presentation.width, canvas._presentation.height) == new_size, ( # noqa: SLF001 + "the crop-edit overlay must follow the new framed size, not the one " + "it had when editing began" + ) + assert canvas._presentation.crop == CropSpec(0, 0, *new_size) # noqa: SLF001 + controller.shutdown() + + def test_repeated_keyboard_nudges_are_cumulative_while_crop_editing( tmp_path, qtbot ) -> None: From 26803d32570c747b698df8c86da59de780a1d9ca Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 17:03:10 +0200 Subject: [PATCH 36/45] fix: pause the result loop only when the session actually closes Trigger: manual field test of the transform stage on Windows, 2026-09-04 (reported in PR #27). With the loop playing, unticking "Edit crop" showed the correctly cropped result and stopped playback at the same moment, because both arrive from the same set_frames call. The button fell back to "Play". set_frames called pause() unconditionally. Only the frames-is-None branch needs it, so that the timer stops when the session closes; on a cache swap while the session stays open there is nothing to stop, and nothing in src/ ever calls play() again -- only the user can. The two branches are now split so a swap leaves playback running. The kept range was reset on every call for the same reason and is now only cleared when a session begins, so a trim applied by set_kept_range survives a crop or resize reload. That composes with the reload gate in the transform stage rather than papering over it: trim no longer triggers this path at all, and this keeps the range intact on the crop/resize path that legitimately does. The deliberate pause in set_presented_frame is untouched -- a new Preview image still wins over the loop (E26). Tests: test_frame_cache_swap_while_playing_does_not_stop_the_loop and test_frame_cache_swap_preserves_an_already_applied_kept_range. The only existing playback test covers the E26 pause, not a cache swap. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/result_player.py | 30 ++++++++++++++++-------- tests/ui/test_result_player.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/matteloop/ui/result_player.py b/src/matteloop/ui/result_player.py index 5aebbd6..a1af0b6 100644 --- a/src/matteloop/ui/result_player.py +++ b/src/matteloop/ui/result_player.py @@ -116,23 +116,33 @@ def __init__( # -- public surface ---------------------------------------------------- def set_frames(self, frames: PlayerFrames | None) -> None: - """Replace the cached frames; ``None`` closes the session (E31).""" - self.pause() + """Replace the cached frames; ``None`` closes the session (E31). + + A crop/resize edit rebuilds the cache while the session stays open; + pausing unconditionally here silently stopped a loop that was + already playing, since nothing ever resumes it but the Play button. + Only closing the session (``frames is None``) forces a pause. The + kept range is preserved across such a swap too -- resetting it would + drop a trim ``set_kept_range`` just applied moments earlier. + """ had_session = self._frames is not None - if frames is not None and not had_session: - self._cover_before_session = self._cover_frame - self.set_cover_frame(False) - elif frames is None and had_session and self._cover_before_session is not None: - self.set_cover_frame(self._cover_before_session) - self._cover_before_session = None - self._frames = frames - self._kept = None if frames is None: + self.pause() + if had_session and self._cover_before_session is not None: + self.set_cover_frame(self._cover_before_session) + self._cover_before_session = None + self._frames = None + self._kept = None self.play_button.hide() self._playhead_index = None self.set_status_marker(None) self.set_frame(None) return + if not had_session: + self._cover_before_session = self._cover_frame + self.set_cover_frame(False) + self._kept = None + self._frames = frames self.play_button.show() playable = self._playable() self._playhead_index = playable.start if playable else None diff --git a/tests/ui/test_result_player.py b/tests/ui/test_result_player.py index abdd903..e067fa0 100644 --- a/tests/ui/test_result_player.py +++ b/tests/ui/test_result_player.py @@ -133,6 +133,44 @@ def test_new_kept_range_re_slices_without_a_new_playerframes(qtbot) -> None: assert canvas._frames is frames # noqa: SLF001 +def test_frame_cache_swap_while_playing_does_not_stop_the_loop(qtbot) -> None: + """``set_frames`` paused unconditionally on every call, so a cache swap + while a session stays open (e.g. toggling "Edit crop" off, which + rebuilds the transformed cache) silently stopped a loop that was + already playing -- nothing then resumed it, since ``play()`` is only + ever called from the Play/Pause button toggle. + """ + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.set_frames(_player_frames(4, (500, 500, 500, 500))) + canvas.play() + assert canvas.playing + + canvas.set_frames(_player_frames(4, (500, 500, 500, 500))) + + assert canvas.playing, "a cache swap while the session stays open must not pause" + assert canvas.play_button.text() == "Pause" + + +def test_frame_cache_swap_preserves_an_already_applied_kept_range(qtbot) -> None: + """A cache swap must not drop a trim already applied via + ``set_kept_range`` -- ``set_frames`` used to reset ``self._kept`` to + ``None`` (the full range) on every call, including one that only swaps + in a rebuilt cache for the same open session. + """ + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.set_frames(_player_frames(4, (100, 100, 100, 100))) + canvas.set_kept_range(range(1, 4)) + + canvas.set_frames(_player_frames(4, (100, 100, 100, 100))) + + assert canvas._kept == range(1, 4), ( # noqa: SLF001 + "a frame-cache swap must preserve an already-applied kept range" + ) + assert canvas.current_frame == 1 + + def test_play_button_hidden_without_frames_and_shown_with_them(qtbot) -> None: canvas = ResultPlayerCanvas() qtbot.addWidget(canvas) From cc9ec01a8573e51c496d830d4956f7a2f7ea833c Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 17:03:10 +0200 Subject: [PATCH 37/45] fix: slide a stored crop inward instead of collapsing it to a refusal Trigger: manual field test of the transform stage on Windows, 2026-09-04 (reported in PR #27), while working through the padding hard case. Measured directly: stored: CropSpec(x=330, y=160, width=170, height=140) valid at (500, 300) padding 40 -> 0, framed frame becomes (420, 220) clamped: CropSpec(x=330, y=160, width=90, height=60) ValidationError: crop width 90 is below the 128 px minimum clamp_crop pinned the origin first and trimmed width and height to whatever was left, so a crop stored near the right or bottom edge while the framed frame was larger collapsed below MIN_FINAL_DIMENSION and the render was refused. The user cannot see or fix that from the UI: the crop still reads as valid in the panel, and the framed frame shrank underneath it when they changed padding. docs/engineering-guardrails.md asks for a degrade there. Shrinking to fit first and clamping the origin into what remains slides a crop that still fits, preserving the region the user chose -- the case above now yields CropSpec(250, 80, 170, 140). A crop genuinely larger than the frame on an axis is still shrunk on that axis, and a crop at the origin behaves exactly as before, which is what the existing staging and controller tests pin down. Tests: four cases in tests/core/test_crop.py covering the off-origin slide, oversize in one axis, oversize in both, and a crop that already fits. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/core/crop.py | 21 ++++++++++++++++----- tests/core/test_crop.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/matteloop/core/crop.py b/src/matteloop/core/crop.py index 34daac3..8b4d671 100644 --- a/src/matteloop/core/crop.py +++ b/src/matteloop/core/crop.py @@ -71,12 +71,23 @@ def nudge_crop( def clamp_crop(crop: CropSpec, source_width: int, source_height: int) -> CropSpec: - """Clamp an otherwise valid crop to a non-empty source rectangle.""" + """Clamp an otherwise valid crop to fit inside a source rectangle. + + A crop that still fits at its stored size (e.g. after the framed frame + shrank underneath it -- a padding change or a cut-set switch) is *slid* + inward to stay in bounds; its width/height are preserved. Only a crop + genuinely larger than the frame in an axis is shrunk on that axis. Doing + it in this order -- shrink to fit, then clamp the origin into what + remains -- naturally slides an origin that was fine before the frame + shrank, instead of trimming a corner down to nothing (issue #25: a crop + near the right/bottom edge collapsing below MIN_FINAL_DIMENSION and the + render being refused for a precondition the user cannot see or fix). + """ _validate_dimensions(source_width, source_height) - left = min(max(crop.x, 0), source_width - 1) - top = min(max(crop.y, 0), source_height - 1) - width = min(max(crop.width, 1), source_width - left) - height = min(max(crop.height, 1), source_height - top) + width = min(max(crop.width, 1), source_width) + height = min(max(crop.height, 1), source_height) + left = min(max(crop.x, 0), source_width - width) + top = min(max(crop.y, 0), source_height - height) return CropSpec(left, top, width, height) diff --git a/tests/core/test_crop.py b/tests/core/test_crop.py index 4ea4d4c..7851fe8 100644 --- a/tests/core/test_crop.py +++ b/tests/core/test_crop.py @@ -4,6 +4,7 @@ from matteloop.core.crop import ( centered_crop_for_aspect, + clamp_crop, crop_from_drag, fit_crop_aspect, nudge_crop, @@ -180,3 +181,31 @@ def test_centered_crop_for_aspect_centres_a_narrower_rectangle() -> None: crop = centered_crop_for_aspect(Fraction(1, 1), source_width=200, source_height=100) assert crop == CropSpec(50, 0, 100, 100) + + +def test_clamp_crop_slides_an_off_origin_crop_inward_instead_of_shrinking_it() -> None: + # Stored while the framed frame was 500x300 (e.g. more padding); the + # frame then shrank to 420x220 underneath it. The rectangle still fits + # at its original size -- only its origin needs to move -- so this must + # not collapse below MIN_FINAL_DIMENSION (128) and force a render refusal. + clamped = clamp_crop(CropSpec(330, 160, 170, 140), 420, 220) + + assert clamped == CropSpec(250, 80, 170, 140) + + +def test_clamp_crop_shrinks_a_crop_wider_than_the_new_frame() -> None: + clamped = clamp_crop(CropSpec(10, 10, 300, 50), 100, 100) + + assert clamped == CropSpec(0, 10, 100, 50) + + +def test_clamp_crop_shrinks_a_crop_oversized_in_both_axes() -> None: + clamped = clamp_crop(CropSpec(10, 10, 300, 300), 100, 100) + + assert clamped == CropSpec(0, 0, 100, 100) + + +def test_clamp_crop_leaves_a_crop_that_already_fits_unchanged() -> None: + crop = CropSpec(5, 5, 50, 50) + + assert clamp_crop(crop, 100, 100) == crop From 31a93f20dd0f073511e3f3fa61c269e5ca101497 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 17:03:10 +0200 Subject: [PATCH 38/45] fix: give the spin box arrows a target you can actually hit Trigger: manual field test of the transform stage on Windows, 2026-09-04 (reported in PR #27): the up/down arrows of the Trim spin boxes are hard to click, and often miss. Measured with the real theme installed, a QSpinBox at 280x32: up-button: x=265 y=3 w=14 h=14 down-button: x=265 y=17 w=14 h=14 14 x 14 against WCAG's 24 x 24 minimum, and the pair previously extended two pixels past the widget's own width. The rule for QComboBox, QAbstractSpinBox and QLineEdit gives them a border and padding, which hands sub-control geometry to the stylesheet, but no ::up-button / ::down-button rule ever followed -- while the combo box gets exactly that treatment on the next line. Adding the missing pair brings the arrows to 24 x 16 each, splitting the 32 px content height, with the right edge landing on the widget width instead of past it. Verified that the edit field still ends before the wider buttons begin, and that the geometry does not shift when the focus rule thickens the border to two pixels. This affects every spin box in the application; it is felt in Trim because that is where single frames get clicked through rather than typed. Test: test_spinbox_arrow_buttons_meet_minimum_target_size. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/theme.py | 7 +++++ tests/ui/test_presentation_contract.py | 43 +++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/matteloop/ui/theme.py b/src/matteloop/ui/theme.py index 78ce731..f861230 100644 --- a/src/matteloop/ui/theme.py +++ b/src/matteloop/ui/theme.py @@ -74,6 +74,13 @@ def install_theme( min-height: 32px; padding: 0 8px; selection-background-color: {ACCENT_COLOR}; }} QComboBox::drop-down {{ border: 0; width: 28px; }} + QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {{ + width: 24px; border: 0; background: transparent; + subcontrol-origin: border; subcontrol-position: top right; + }} + QAbstractSpinBox::up-button {{ height: 16px; top: 0px; }} + QAbstractSpinBox::down-button {{ height: 16px; bottom: 0px; + subcontrol-position: bottom right; }} QLabel#result_canvas[status='stale'] {{ color: {WARNING_COLOR}; }} QLabel#result_canvas[status='error'] {{ color: {ERROR_COLOR}; }} QLabel#result_canvas[checkerboard='true'] {{ diff --git a/tests/ui/test_presentation_contract.py b/tests/ui/test_presentation_contract.py index 397a24a..9baaec9 100644 --- a/tests/ui/test_presentation_contract.py +++ b/tests/ui/test_presentation_contract.py @@ -5,6 +5,7 @@ import pytest from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication, QSpinBox, QStyle, QStyleOptionSpinBox from matteloop.core.crop_state import CropChanged, CropToggleChanged, ResetCrop from matteloop.core.errors import AppError, ErrorCode @@ -32,7 +33,7 @@ from matteloop.ui.crop_presentation import CropPresentation from matteloop.ui.inspector import Inspector from matteloop.ui.presenter import present -from matteloop.ui.theme import load_packaged_fonts +from matteloop.ui.theme import install_theme, load_packaged_fonts _SOURCE_ERROR_CASES = [ (ErrorCode.SOURCE_NOT_LOCAL, "Open a video stored on this Mac."), @@ -190,6 +191,46 @@ def test_missing_packaged_fonts_fall_back_honestly(tmp_path: Path) -> None: assert not load_packaged_fonts(runtime_root=tmp_path) +def test_spinbox_arrow_buttons_meet_minimum_target_size(qtbot) -> None: + application = QApplication.instance() + assert application is not None + original_style_sheet = application.styleSheet() + original_font = application.font() + try: + install_theme(application) + spinbox = QSpinBox() + qtbot.addWidget(spinbox) + spinbox.resize(280, 32) + spinbox.show() + + option = QStyleOptionSpinBox() + spinbox.initStyleOption(option) + style = spinbox.style() + up_rect = style.subControlRect( + QStyle.ComplexControl.CC_SpinBox, + option, + QStyle.SubControl.SC_SpinBoxUp, + spinbox, + ) + down_rect = style.subControlRect( + QStyle.ComplexControl.CC_SpinBox, + option, + QStyle.SubControl.SC_SpinBoxDown, + spinbox, + ) + + # WCAG's minimum target size is 24x24; the arrows are a stacked pair + # splitting the control's height, so 24px wide is the achievable bar. + assert up_rect.width() >= 24 + assert down_rect.width() >= 24 + assert up_rect.height() + down_rect.height() >= 24 + assert up_rect.x() + up_rect.width() <= spinbox.width() + assert down_rect.x() + down_rect.width() <= spinbox.width() + finally: + application.setStyleSheet(original_style_sheet) + application.setFont(original_font) + + def test_inspector_uses_defaults_for_malformed_disclosure_settings(qtbot) -> None: settings = QSettings( QSettings.IniFormat, From 12a1d051d7455407c61632217709b1c1d192755f Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 17:34:40 +0200 Subject: [PATCH 39/45] fix: keep the play button off the bottom-right crop handle Trigger: manual field test of the transform stage on Windows, 2026-09-04 (reported in PR #27). The play button is a plain layout child aligned to the bottom-right, and build_crop_geometry centres the media in the same viewport with the same 16 px inset, so both corners land on (W-16, H-16). Whenever the framed aspect is close to the canvas aspect, the bottom-right handle ends up inside the button. Measured with the real theme: canvas framed handle px2 (of 64) 24x24 pointer target (of 576) 430x500 420x220 0 0 430x500 256x256 32 228 380x600 220x420 32 246 380x600 300x470 32 288 A QPushButton accepts the press and does not propagate it, so CropCanvas.mousePressEvent is never entered for those pixels even though hit_test would have returned south_east: starting a drag on the covered half is impossible, and the button paints over it, so the dead half is invisible too. Dragging through the button once a drag has started was always fine -- Qt's implicit mouse grab keeps the moves on the canvas. PreviewCanvas gains a _reserved_bottom_space hook returning 0.0, so the source canvas is unaffected; _rebuild_geometry subtracts it from the viewport it hands to build_crop_geometry, and _update_pixmap composes the media into a widget-sized pixmap when the reservation is non-zero, since QLabel's own AlignCenter cannot express an asymmetric bottom margin. ResultPlayerCanvas reserves the button's height plus a gap -- and _MEDIA_INSET on top, because pointer hit targets are expanded against the raw viewport (deliberately, so a crop flush with the canvas edge stays draggable) and would otherwise still reach past the button's top edge. The reservation applies only while crop-edit is on. The handles it protects exist only then, and reserving the space for the whole session would cost about 13% of the result preview's height for nothing -- the area this stage exists to show. Both edges of the toggle recompute the geometry; leaving crop-edit passes presentation=None, which apply_presentation handles by clearing _geometry directly rather than through _rebuild_geometry. Tests: test_play_button_does_not_cover_the_bottom_right_crop_handle pins the overlap at zero for a 430x500 canvas with a 256x256 frame, and test_media_fills_the_full_viewport_when_crop_edit_is_off pins the other half of the contract so the reservation cannot quietly become permanent again. All five measured rows are now 0 of 64 and 0 of 576. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/crop_canvas.py | 6 +- src/matteloop/ui/preview_canvas.py | 34 +++++++- src/matteloop/ui/result_player.py | 53 +++++++++++- tests/ui/test_result_player.py | 132 ++++++++++++++++++++++++++++- 4 files changed, 218 insertions(+), 7 deletions(-) diff --git a/src/matteloop/ui/crop_canvas.py b/src/matteloop/ui/crop_canvas.py index 435efc8..2ddc08b 100644 --- a/src/matteloop/ui/crop_canvas.py +++ b/src/matteloop/ui/crop_canvas.py @@ -290,6 +290,7 @@ def _rebuild_geometry( rotation=presentation.rotation, pixel_aspect=presentation.pixel_aspect, ) + reserved_bottom = max(0.0, self._reserved_bottom_space()) self._geometry = build_crop_geometry( state=CropGeometryState( source_size=SizeF( @@ -304,7 +305,10 @@ def _rebuild_geometry( else (self._focused_target if self.hasFocus() else None), dragged=dragged if dragged is not None else self._dragged, ), - viewport=SizeF(max(1, self.width()), max(1, self.height())), + viewport=SizeF( + max(1, self.width()), + max(1.0, self.height() - reserved_bottom), + ), dpr=float(self.devicePixelRatioF()), ) transform = self._geometry.transform diff --git a/src/matteloop/ui/preview_canvas.py b/src/matteloop/ui/preview_canvas.py index 61aebcf..bc4df1a 100644 --- a/src/matteloop/ui/preview_canvas.py +++ b/src/matteloop/ui/preview_canvas.py @@ -190,6 +190,16 @@ def _should_paint_checkerboard(self) -> bool: """ return self.property("checkerboard") is True + def _reserved_bottom_space(self) -> float: + """Widget-space height, in px, kept clear at the bottom of the media. + + Zero by default -- the media fills the full inset viewport. A canvas + that overlays a control at the bottom (``ResultPlayerCanvas``'s play + button) overrides this so the media, and any crop geometry built + against it, never sits under that control (#25). + """ + return 0.0 + def paintEvent(self, event) -> None: # type: ignore[no-untyped-def] if self._should_paint_checkerboard(): painter = QPainter(self) @@ -211,8 +221,12 @@ def _update_pixmap(self) -> None: if self._frame is None: return margins = self.layout().contentsMargins() # type: ignore[union-attr] + reserved = max(0, round(self._reserved_bottom_space())) content = self.contentsRect().adjusted( - margins.left(), margins.top(), -margins.right(), -margins.bottom() + margins.left(), + margins.top(), + -margins.right(), + -margins.bottom() - reserved, ) size = QSize(max(1, content.width()), max(1, content.height())) pixmap = QPixmap.fromImage(self._frame).scaled( @@ -231,7 +245,23 @@ def _update_pixmap(self) -> None: size.width(), size.height(), ) - self.setPixmap(pixmap) + if reserved <= 0: + self.setPixmap(pixmap) + return + # Reserved space is asymmetric (bottom-only), so the media can no + # longer rely on QLabel's own AlignCenter: that centres within the + # full widget, which would centre the shrunk pixmap over the reserved + # strip too instead of leaving it clear. Compose it into a + # widget-sized transparent pixmap at the position it belongs, so + # QLabel has nothing left to re-centre. + positioned = QPixmap(self.size()) + positioned.fill(Qt.GlobalColor.transparent) + x = content.x() + max(0, (content.width() - pixmap.width()) // 2) + y = content.y() + max(0, (content.height() - pixmap.height()) // 2) + painter = QPainter(positioned) + painter.drawPixmap(x, y, pixmap) + painter.end() + self.setPixmap(positioned) class PreviewStage(QFrame): diff --git a/src/matteloop/ui/result_player.py b/src/matteloop/ui/result_player.py index a1af0b6..6dcb475 100644 --- a/src/matteloop/ui/result_player.py +++ b/src/matteloop/ui/result_player.py @@ -31,7 +31,7 @@ from PySide6.QtWidgets import QPushButton, QWidget from matteloop.core.crop import fit_crop_aspect -from matteloop.core.geometry import FramingPlan, apply_framing +from matteloop.core.geometry import _MEDIA_INSET, FramingPlan, apply_framing from matteloop.core.parameters import TransformChanged from matteloop.core.specs import CropSpec, TransformSpec from matteloop.core.transform import apply_transform @@ -41,6 +41,7 @@ PLAYER_CACHE_BUDGET_BYTES = 128 * 1024 * 1024 PLAYER_MIN_DISPLAY_SIDE = 64 +_PLAY_BUTTON_CLEARANCE_GAP = 8.0 _PLAY_TEXT = "Play" _PLAY_ACCESSIBLE_NAME = "Play the result loop" @@ -137,6 +138,8 @@ def set_frames(self, frames: PlayerFrames | None) -> None: self._playhead_index = None self.set_status_marker(None) self.set_frame(None) + self._rebuild_geometry() + self.update() return if not had_session: self._cover_before_session = self._cover_frame @@ -144,10 +147,12 @@ def set_frames(self, frames: PlayerFrames | None) -> None: self._kept = None self._frames = frames self.play_button.show() + self._rebuild_geometry() playable = self._playable() self._playhead_index = playable.start if playable else None self._update_truncation_marker(frames) self._show_current_frame() + self.update() def set_kept_range(self, kept: range) -> None: """Re-slice playback to *kept* without reloading either cache.""" @@ -165,11 +170,24 @@ def set_crop_edit( presentation: CropPresentation | None, transform: TransformSpec | None = None, ) -> None: - """Toggle between the crop overlay (framed frames) and the result loop.""" + """Toggle between the crop overlay (framed frames) and the result loop. + + ``_reserved_bottom_space`` depends on ``_crop_edit`` (#25: the + reservation only buys anything while the crop handles exist to be + covered), so every toggle can change it and both the crop geometry + and the displayed media need recomputing either way -- not just when + entering, where ``apply_presentation`` already calls + ``_rebuild_geometry`` for the fresh presentation. Leaving passes + ``presentation=None``, which ``apply_presentation`` handles by + clearing ``_geometry`` directly rather than through + ``_rebuild_geometry``, so it is re-run explicitly here on both edges. + """ self._crop_edit = enabled self._transform = transform if transform is not None else TransformSpec() self.apply_presentation(presentation, active=enabled, editable=enabled) + self._rebuild_geometry() self._show_current_frame() + self.update() def set_aspect_lock(self, ratio: Fraction | None) -> None: self._aspect_lock = ratio @@ -252,6 +270,37 @@ def _should_paint_checkerboard(self) -> bool: """ return super()._should_paint_checkerboard() or self._frames is not None + def _reserved_bottom_space(self) -> float: + """Keep the play button's footprint clear of the crop handles (#25) + while crop-edit is active: the button is a plain layout child + aligned to the bottom-right corner, so without this the crop + geometry built against the media is centred straight through it, and + a drag starting on the covered half of the bottom-right handle never + reaches ``mousePressEvent`` -- the button consumes the click first. + + Gated on ``_crop_edit``, not just button visibility: the handles + this protects only exist while the crop overlay is active + (``CropCanvas.paintEvent`` returns early otherwise, and there is + nothing to hit-test), so outside crop-edit the reservation would + only shrink the visible loop for nothing. + + The button sits ``_MEDIA_INSET`` above the widget's bottom edge (the + layout margin ``_rebuild_geometry`` applies), on top of its own + height. Pointer/touch hit targets are expanded right up to whatever + viewport they are given (by design -- it is what keeps a crop + dragged flush with the true canvas edge reachable), so reserving + only the button's height would still let an expanded hit target + reach past the button's top edge; folding the same inset in here + keeps that expansion inside the button's actual top edge too. + """ + if not self._crop_edit or not self.play_button.isVisible(): + return 0.0 + return ( + _MEDIA_INSET + + self.play_button.sizeHint().height() + + _PLAY_BUTTON_CLEARANCE_GAP + ) + # -- playback internals --------------------------------------------- def _play_toggled(self, checked: bool) -> None: diff --git a/tests/ui/test_result_player.py b/tests/ui/test_result_player.py index e067fa0..80db095 100644 --- a/tests/ui/test_result_player.py +++ b/tests/ui/test_result_player.py @@ -4,9 +4,18 @@ from fractions import Fraction from pathlib import Path -from PySide6.QtCore import Qt +import pytest +from PySide6.QtCore import QRect, Qt from PySide6.QtGui import QImage - +from PySide6.QtWidgets import QApplication + +from matteloop.core.geometry import ( + _MEDIA_INSET, + CropGeometryState, + RectF, + SizeF, + build_crop_geometry, +) from matteloop.core.parameters import TransformChanged from matteloop.core.specs import CropSpec, FramingSpec, TransformSpec from matteloop.core.state import ( @@ -26,6 +35,7 @@ _fit_budget, ) from matteloop.ui.store import ReducerStore +from matteloop.ui.theme import install_theme from matteloop.ui.transform_group import TransformGroup from matteloop.ui.transform_stage import TransformStageController, _DirectFrameReader from tests.jobs.render_support import ( @@ -300,6 +310,124 @@ def test_aspect_lock_constrains_the_crop_before_dispatch(qtbot) -> None: assert Fraction(constrained.width, constrained.height) == Fraction(2, 1) +def _button_overlaps(button: QRect, rect: object) -> bool: + """Whether *button* (canvas-local ``QRect``) intersects a geometry ``RectF``.""" + return not ( + button.x() + button.width() <= rect.left # type: ignore[attr-defined] + or button.x() >= rect.right # type: ignore[attr-defined] + or button.y() + button.height() <= rect.top # type: ignore[attr-defined] + or button.y() >= rect.bottom # type: ignore[attr-defined] + ) + + +def test_play_button_does_not_cover_the_bottom_right_crop_handle(qtbot) -> None: + """Regression for #25: the play button used to share the same + bottom-right corner as the crop overlay's ``south_east`` handle -- a + ``QPushButton`` accepts the press and never lets it reach + ``mousePressEvent``, so a drag starting on the covered half of the + handle was impossible, and the button painted over it besides. + """ + application = QApplication.instance() + assert application is not None + original_style_sheet = application.styleSheet() + try: + install_theme(application) # the real button size, not Qt's bare default + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.resize(430, 500) + canvas.show() + qtbot.wait(10) + + canvas.set_frame(QImage(256, 256, QImage.Format.Format_RGBA8888)) + canvas.set_frames(_player_frames(2, (100, 100))) + presentation = CropPresentation( + source_id="cut", + width=256, + height=256, + coded_width=256, + coded_height=256, + rotation=0, + pixel_aspect=1.0, + crop=CropSpec(0, 0, 256, 256), + ) + canvas.set_crop_edit(True, presentation, TransformSpec()) + qtbot.wait(10) + + assert canvas.play_button.isVisible() + geometry = canvas._geometry # noqa: SLF001 + assert geometry is not None + button = canvas.play_button.geometry() + + assert not _button_overlaps(button, geometry.visual["south_east"]) + assert not _button_overlaps(button, geometry.pointer_hit["south_east"]) + finally: + application.setStyleSheet(original_style_sheet) + + +def test_media_fills_the_full_viewport_when_crop_edit_is_off(qtbot) -> None: + """The other half of #25's contract: the reservation only buys anything + while the crop overlay is active (its handles are the only thing a + covered corner could break), so once crop-edit is turned back off it + must not cost any visible preview area -- this is the stage that exists + to show the loop. Enters crop-edit first (so the layout margin this + canvas always carries is the real ``_MEDIA_INSET``, not the ``0`` a + presentation-less canvas never applies) and then leaves it again, the + same round trip the "Edit crop" toggle drives in the app. + """ + application = QApplication.instance() + assert application is not None + original_style_sheet = application.styleSheet() + try: + install_theme(application) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + canvas.resize(430, 500) + canvas.show() + qtbot.wait(10) + + canvas.set_frame(QImage(256, 256, QImage.Format.Format_RGBA8888)) + canvas.set_frames(_player_frames(2, (100, 100))) + presentation = CropPresentation( + source_id="cut", + width=256, + height=256, + coded_width=256, + coded_height=256, + rotation=0, + pixel_aspect=1.0, + crop=CropSpec(0, 0, 256, 256), + ) + canvas.set_crop_edit(True, presentation, TransformSpec()) + qtbot.wait(10) + assert canvas._reserved_bottom_space() > 0.0 # noqa: SLF001 -- sanity + + canvas.set_crop_edit(False, None, TransformSpec()) + qtbot.wait(10) + + assert canvas.play_button.isVisible() + assert canvas._reserved_bottom_space() == 0.0 # noqa: SLF001 + + pixmap = canvas.pixmap() + assert pixmap is not None and not pixmap.isNull() + + # Full, unreserved inset viewport: what the media occupies with no + # bottom reservation at all -- only ``_MEDIA_INSET`` on every side. + expected = build_crop_geometry( + state=CropGeometryState( + source_size=SizeF(256, 256), + crop=RectF(0, 0, 256, 256), + inset=_MEDIA_INSET, + ), + viewport=SizeF(canvas.width(), canvas.height()), + dpr=float(canvas.devicePixelRatioF()), + ) + content = expected.transform.content_rect + assert pixmap.width() == pytest.approx(content.width, abs=1.0) + assert pixmap.height() == pytest.approx(content.height, abs=1.0) + finally: + application.setStyleSheet(original_style_sheet) + + # -- budget math ----------------------------------------------------------- From 31379bb7082243e494d86d6a5d36029922c3a14b Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 18:12:15 +0200 Subject: [PATCH 40/45] fix: split the stylesheet by role so install_theme fits its budget again Trigger: `uv run python scripts/check_guardrails.py` fails after 4da87d2: G6 src/matteloop/ui/theme.py: 1 function(s) exceed the 60-line budget - extract named helpers. Adding the spin box arrow rules pushed install_theme from 60 to 68 lines. The sheet grows every time a control needs a rule while the installer does not, so the sheet is what has to move out. It is now three role-named helpers -- surfaces, controls, status -- that install_theme concatenates, none of them near the budget, and each an obvious home for the next rule. Reported by an independent Codex review of the branch. The check is part of CI and is not one of the three commands named in CLAUDE.md's verification block, which is why the earlier commit passed ruff, mypy and pytest and still would have failed the build. The stylesheet text is unchanged apart from the whitespace at the two seams where it is now concatenated. Verified after the split: the arrow buttons still measure 24 x 16, and the presentation-contract, result-player and minimum-layout suites stay green. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/theme.py | 51 +++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/matteloop/ui/theme.py b/src/matteloop/ui/theme.py index f861230..ad55f61 100644 --- a/src/matteloop/ui/theme.py +++ b/src/matteloop/ui/theme.py @@ -40,16 +40,9 @@ _MONO_FALLBACKS = "'SF Mono', Menlo, Consolas, 'DejaVu Sans Mono'" -def install_theme( - application: QApplication, *, runtime_root: Path | None = None -) -> bool: - """Install the approved palette and return whether every bundled font loaded.""" - loaded = load_packaged_fonts(runtime_root=runtime_root) - base_font = QFont(UI_FONT, 10) - if not loaded: - base_font.setStyleHint(QFont.StyleHint.SansSerif) - application.setFont(base_font) - application.setStyleSheet( +def _surface_stylesheet() -> str: + """Backgrounds and separators of the fixed window furniture.""" + return ( f""" QWidget {{ background: {BACKGROUND_COLOR}; color: {TEXT_COLOR}; font-family: '{UI_FONT}', {_UI_FALLBACKS}; font-size: 10pt; }} @@ -59,6 +52,14 @@ def install_theme( QWidget#inspector {{ background: {INSPECTOR_COLOR}; border-left: 1px solid {DIVIDER_COLOR}; }} QWidget#inspector_content {{ background: {INSPECTOR_COLOR}; }} + """ + ) + + +def _control_stylesheet() -> str: + """Buttons, text entries and their focus and disabled states.""" + return ( + f""" QPushButton {{ background: {CONTROL_COLOR}; border: 1px solid {DIVIDER_COLOR}; border-radius: 4px; min-height: 40px; padding: 0 12px; }} QPushButton:hover {{ border-color: {HOVER_COLOR}; }} @@ -81,6 +82,14 @@ def install_theme( QAbstractSpinBox::up-button {{ height: 16px; top: 0px; }} QAbstractSpinBox::down-button {{ height: 16px; bottom: 0px; subcontrol-position: bottom right; }} + """ + ) + + +def _status_stylesheet() -> str: + """Status colours, the mono role, tool buttons and scroll areas.""" + return ( + f""" QLabel#result_canvas[status='stale'] {{ color: {WARNING_COLOR}; }} QLabel#result_canvas[status='error'] {{ color: {ERROR_COLOR}; }} QLabel#result_canvas[checkerboard='true'] {{ @@ -106,6 +115,28 @@ def install_theme( QScrollArea {{ border: 0; background: {INSPECTOR_COLOR}; }} """ ) + + +def _stylesheet() -> str: + """Return the palette stylesheet applied by :func:`install_theme`. + + Split by role so no single function carries the whole sheet: it grows + every time a control needs a rule, and G6 caps what one function may + hold. + """ + return _surface_stylesheet() + _control_stylesheet() + _status_stylesheet() + + +def install_theme( + application: QApplication, *, runtime_root: Path | None = None +) -> bool: + """Install the approved palette and return whether every bundled font loaded.""" + loaded = load_packaged_fonts(runtime_root=runtime_root) + base_font = QFont(UI_FONT, 10) + if not loaded: + base_font.setStyleHint(QFont.StyleHint.SansSerif) + application.setFont(base_font) + application.setStyleSheet(_stylesheet()) return loaded From 866a022a3f07a68fc575662dce329a6e478fc69e Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 21:01:58 +0200 Subject: [PATCH 41/45] fix: scroll the inspector instead of editing the field under the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger: manual field test on Windows, 2026-09-04. Scrolling the inspector changed the value of whatever combo or spin box the pointer passed over — silently, and the changed value went into the next render. The session that found this also ended up at 11 fps, a 0.4 % alpha threshold and an edge mode that cannot run (#40), none of them chosen on purpose. Qt hands the field focus before it delivers the wheel event. ``QApplication::notify`` calls ``giveFocusAccordingToFocusPolicy`` on the widget under the pointer, and both ``QComboBox`` and ``QAbstractSpinBox`` are constructed with ``Qt::WheelFocus``, so the field is focused by the time any filter or handler sees the event. Lowering the policy to ``StrongFocus`` stops that; the filter then refuses the wheel unless the field already holds focus, leaving the event unaccepted so it reaches the scroll area exactly as it would from a plain label. Both halves are needed — measured, each alone still edits the value and still does not scroll. Contract: the wheel over the inspector scrolls the inspector. A field merely passed over is never edited and never focused. The wheel edits a field only when that field already holds focus from a click or Tab, and then the panel does not move. One consequence, stated because it is a real edge: scrolling until the still-focused field returns under the pointer will edit it again. Refusing the wheel outright is a one-line change if that turns out to annoy. Two earlier attempts are folded into this commit rather than left standing, because both were built on a wrong diagnosis and their messages asserted behaviour that was never true. The first ignored the event and swallowed it, which stopped the value changing in an offscreen test but made every field a dead zone in the running app. The second forwarded the event to the scroll area's viewport by hand, which measured correctly offscreen and changed nothing on screen. Neither could work: the focus grab happens before either of them runs. The harness gap that hid all of this is closed too. The old tests used ``QApplication.sendEvent``, and Qt short-circuits synthesized wheel events — "Synthesized events shouldn't propagate" — skipping both the focus grab and the parent walk, so they measured a path real input never takes. The tests now use ``QTest.wheelEvent``, which enters through ``QWindowSystemInterface`` like a platform event, and one of them deliberately exercises an unwrapped spin box to prove the suite can still see the original defect. Tests: four in tests/ui/test_compact_widgets.py pinning both directions of the contract, a tab-order guard, the unwrapped-control fidelity check, and test_every_inspector_field_refuses_focus_from_the_wheel in tests/ui/test_parameter_inspector.py, which walks the real inspector and fails if a future field skips ``compact_field``. Against the previous implementation: 3 failed, 4 passed. Verified at the screen after the change: scrolling moves the panel, values stay put, and a clicked-into field still steps. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/compact_widgets.py | 47 ++++++++- tests/ui/test_compact_widgets.py | 142 +++++++++++++++++++++++++++ tests/ui/test_parameter_inspector.py | 18 ++++ 3 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/ui/test_compact_widgets.py diff --git a/src/matteloop/ui/compact_widgets.py b/src/matteloop/ui/compact_widgets.py index 703fec4..a077d2f 100644 --- a/src/matteloop/ui/compact_widgets.py +++ b/src/matteloop/ui/compact_widgets.py @@ -2,9 +2,10 @@ from __future__ import annotations -from PySide6.QtCore import Qt +from PySide6.QtCore import QEvent, QObject, Qt from PySide6.QtGui import QFontMetrics, QPaintEvent, QResizeEvent from PySide6.QtWidgets import ( + QAbstractSpinBox, QComboBox, QLineEdit, QSizePolicy, @@ -15,9 +16,51 @@ ) +class _IgnoreUnfocusedWheel(QObject): + """Let the wheel scroll past a combo/spin box unless it holds focus. + + The wheel edits a field only after the user has focused it by clicking or + tabbing; an unfocused field leaves the event unaccepted so it propagates + to the enclosing scroll area exactly as it would from a plain label. + + This filter is only half of the mechanism and cannot work alone. Qt gives + focus *before* it delivers a wheel event: ``QApplication::notify`` calls + ``giveFocusAccordingToFocusPolicy`` on the widget under the pointer, and + any enabled ``Qt::WheelFocus`` widget there receives focus on the spot. + ``QComboBox`` and ``QAbstractSpinBox`` are both constructed with + ``WheelFocus``, so a ``hasFocus()`` check seen from this filter is already + true and the field edits anyway. ``compact_field`` therefore lowers the + policy to ``Qt::StrongFocus`` (click and Tab, never the wheel), which is + what keeps the check meaningful. + + Returning ``True`` is likewise load-bearing: it stops the widget's own + ``wheelEvent`` from running and re-accepting the event. Propagation is + unaffected, because ``QApplication::notify`` continues up the parent + chain whenever the event is left unaccepted, regardless of the filter's + return value. + """ + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if ( + event.type() == QEvent.Type.Wheel + and isinstance(watched, QWidget) + and not watched.hasFocus() + ): + event.ignore() + return True + return super().eventFilter(watched, event) + + def compact_field[WidgetT: QWidget](widget: WidgetT) -> WidgetT: - """Allow a standard field to use the width its enclosing form provides.""" + """Allow a standard field to use the width its enclosing form provides. + + Combo and spin boxes additionally give up the wheel while unfocused; see + ``_IgnoreUnfocusedWheel`` for why the focus policy must change with it. + """ widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + if isinstance(widget, (QComboBox, QAbstractSpinBox)): + widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + widget.installEventFilter(_IgnoreUnfocusedWheel(widget)) return widget diff --git a/tests/ui/test_compact_widgets.py b/tests/ui/test_compact_widgets.py new file mode 100644 index 0000000..c0e1774 --- /dev/null +++ b/tests/ui/test_compact_widgets.py @@ -0,0 +1,142 @@ +"""Behaviour of the shared inspector field wrapper in compact_widgets. + +Wheel events are delivered with ``QTest.wheelEvent(QWindow, ...)``, which +enters through ``QWindowSystemInterface`` like a platform event: the event is +spontaneous, ``QWidgetWindow`` picks the child under the pointer, and +``QApplication::notify`` runs its focus-on-wheel and parent-propagation logic. +``QApplication.sendEvent`` reproduces none of that (notify short-circuits +synthesized wheel events), which is how two earlier fixes shipped green and +failed at the screen. +""" + +from __future__ import annotations + +from PySide6.QtCore import QPoint, Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import ( + QComboBox, + QLabel, + QLineEdit, + QScrollArea, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from matteloop.ui.compact_widgets import compact_field + +_ONE_NOTCH_DOWN = QPoint(0, -120) + + +class _Form: + """A scrollable form with one field under test and a focus sink above it.""" + + def __init__(self, qtbot, field: QWidget) -> None: + self.area = QScrollArea() + qtbot.addWidget(self.area) + self.area.setWidgetResizable(True) + self.area.setFocusPolicy(Qt.FocusPolicy.NoFocus) + content = QWidget() + layout = QVBoxLayout(content) + self.sink = QLineEdit() + layout.addWidget(self.sink) + self.field = field + layout.addWidget(field) + for index in range(40): + layout.addWidget(QLabel(f"filler {index}")) + self.area.setWidget(content) + self.area.resize(300, 200) + self.area.show() + self.area.activateWindow() + assert QTest.qWaitForWindowActive(self.area) + self.sink.setFocus() + qtbot.waitUntil(self.sink.hasFocus, timeout=1000) + assert not field.hasFocus() + + def wheel_over_field(self) -> None: + position = self.field.mapTo(self.area, self.field.rect().center()) + QTest.wheelEvent(self.area.windowHandle(), position, _ONE_NOTCH_DOWN) + + def scrolled(self) -> int: + return self.area.verticalScrollBar().value() + + +def _spinbox() -> QSpinBox: + spinbox = QSpinBox() + spinbox.setRange(0, 10) + spinbox.setValue(5) + return spinbox + + +def _combobox() -> QComboBox: + combo = QComboBox() + combo.addItems(["a", "b", "c", "d", "e"]) + combo.setCurrentIndex(2) + return combo + + +def test_wheel_over_unfocused_spinbox_scrolls_the_form_and_leaves_it_alone( + qtbot, +) -> None: + form = _Form(qtbot, compact_field(_spinbox())) + + form.wheel_over_field() + + assert form.field.value() == 5 + assert not form.field.hasFocus() + assert form.sink.hasFocus() + assert form.scrolled() > 0 + + +def test_wheel_over_unfocused_combobox_scrolls_the_form_and_leaves_it_alone( + qtbot, +) -> None: + form = _Form(qtbot, compact_field(_combobox())) + + form.wheel_over_field() + + assert form.field.currentIndex() == 2 + assert not form.field.hasFocus() + assert form.sink.hasFocus() + assert form.scrolled() > 0 + + +def test_wheel_over_focused_spinbox_edits_it_instead_of_scrolling(qtbot) -> None: + form = _Form(qtbot, compact_field(_spinbox())) + form.field.setFocus() + qtbot.waitUntil(form.field.hasFocus, timeout=1000) + + form.wheel_over_field() + + assert form.field.value() == 4 + assert form.scrolled() == 0 + + +def test_wheel_over_focused_combobox_edits_it_instead_of_scrolling(qtbot) -> None: + form = _Form(qtbot, compact_field(_combobox())) + form.field.setFocus() + qtbot.waitUntil(form.field.hasFocus, timeout=1000) + + form.wheel_over_field() + + assert form.field.currentIndex() == 3 + assert form.scrolled() == 0 + + +def test_tab_still_reaches_a_compact_field(qtbot) -> None: + form = _Form(qtbot, compact_field(_spinbox())) + + QTest.keyClick(form.sink, Qt.Key.Key_Tab) + + assert form.field.hasFocus() + + +def test_wheel_over_an_unwrapped_spinbox_focuses_and_edits_it(qtbot) -> None: + """The defect compact_field removes, kept as proof the harness can see it.""" + form = _Form(qtbot, _spinbox()) + + form.wheel_over_field() + + assert form.field.hasFocus() + assert form.field.value() == 4 + assert form.scrolled() == 0 diff --git a/tests/ui/test_parameter_inspector.py b/tests/ui/test_parameter_inspector.py index 90ab417..2958b7e 100644 --- a/tests/ui/test_parameter_inspector.py +++ b/tests/ui/test_parameter_inspector.py @@ -2,6 +2,7 @@ import pytest from PySide6.QtCore import QSettings, Qt +from PySide6.QtWidgets import QAbstractSpinBox, QComboBox from matteloop.core.execution_providers import ( COREML_EXECUTION_PROVIDER, @@ -261,3 +262,20 @@ def test_inspector_explains_invalid_output_filename_without_emitting_a_command( assert commands == [] assert inspector.output_filename_edit.property("invalid") is True assert "single" in inspector.output_filename_edit.toolTip() + + +def test_every_inspector_field_refuses_focus_from_the_wheel(qtbot) -> None: + """Every combo and spin box in the inspector, Transform panel included, + goes through compact_field: focus comes from a click or Tab only, so a + wheel tick over an unfocused field scrolls the inspector instead of + focusing and editing it.""" + inspector = Inspector(_settings()) + qtbot.addWidget(inspector) + + fields = [ + *inspector.findChildren(QComboBox), + *inspector.findChildren(QAbstractSpinBox), + ] + + assert len(fields) >= 20 + assert {field.focusPolicy() for field in fields} == {Qt.FocusPolicy.StrongFocus} From 51fd6d69573e75c8905b5abc451a69b2d3e21eff Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 21:01:58 +0200 Subject: [PATCH 42/45] fix: derive the resize percentage from the decimal the user sees Trigger: reported by an independent Codex review of this branch and confirmed by computation: 12.85% of 1000 px -> 128 (current) 129 (decimal-correct) DIFFERS 2.35%, 0.05% -> identical _percent_changed built Fraction(value) / 100 from the float a QDoubleSpinBox hands out, which preserves the binary approximation rather than the decimal shown in the field, so half-up rounding lands on the wrong side at exact half-pixel boundaries. Only such boundaries are affected, which is why it survived until someone computed it, but the displayed percentage and the computed pixel size disagreeing is precisely the quiet kind of wrong this repository's guardrails care about. The fix reuses fraction_from_widget_value, the decimal-safe helper the inspector already applies to the start, end and duration spin boxes, rather than introducing a second conversion for the same problem. Tests: test_percent_rounds_from_the_displayed_decimal_not_the_binary_float pins 12.85% of 1000 px to 129 and failed before the change with assert 128 == 129; a second case pins a value that must not move. The second test uses a 10000 px dimension because the width spin box floors at MIN_FINAL_DIMENSION, which would otherwise clamp the result and hide the assertion. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_group.py | 7 ++++-- tests/ui/test_transform_group.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/matteloop/ui/transform_group.py b/src/matteloop/ui/transform_group.py index 2309407..8940a3f 100644 --- a/src/matteloop/ui/transform_group.py +++ b/src/matteloop/ui/transform_group.py @@ -37,7 +37,10 @@ ) from matteloop.core.state import ArtifactResult from matteloop.ui.compact_widgets import compact_field -from matteloop.ui.parameter_presentation import ParameterPresentation +from matteloop.ui.parameter_presentation import ( + ParameterPresentation, + fraction_from_widget_value, +) from matteloop.ui.source_presentation import ( format_source_dimensions, format_source_file_size, @@ -477,7 +480,7 @@ def _percent_changed(self, value: float) -> None: if self._syncing or self._facts is None or value <= 0: return cropped_width, cropped_height = self._cropped_size() - fraction = Fraction(value) / 100 + fraction = fraction_from_widget_value(value) / 100 width = _rhu(Fraction(cropped_width) * fraction) height = _rhu(Fraction(cropped_height) * fraction) with QSignalBlocker(self.width_spinbox), QSignalBlocker(self.height_spinbox): diff --git a/tests/ui/test_transform_group.py b/tests/ui/test_transform_group.py index 5c24db5..b586350 100644 --- a/tests/ui/test_transform_group.py +++ b/tests/ui/test_transform_group.py @@ -153,6 +153,45 @@ def test_percentage_fills_both_dimensions_and_clears_on_manual_edit(qtbot) -> No assert group.percent_spinbox.value() == 0 +def test_percent_rounds_from_the_displayed_decimal_not_the_binary_float( + qtbot, +) -> None: + """12.85% of 1000 px is a half-pixel case: Fraction(12.85) preserves the + binary float's approximation (128.49999...) and rounds down to 128, + while Fraction("12.85") preserves the decimal the spin box actually + shows and rounds half-up to 129, matching what the user typed.""" + group, emitted = _group(qtbot) + group.apply(_presentation(), editable=True) + group.set_cut(_facts(framed_size=(1000, 1000))) + emitted.clear() + + group.percent_spinbox.setValue(12.85) + + assert group.width_spinbox.value() == 129 + assert group.height_spinbox.value() == 129 + last = emitted[-1] + assert isinstance(last, TransformChanged) + assert last.transform.resize == ResizeSpec(129, 129, MismatchMode.KEEP) + + +def test_percent_rounding_unaffected_by_the_decimal_fix_stays_the_same( + qtbot, +) -> None: + """2.35% of 10000 px is not a half-pixel case (235 exactly, whether the + fraction comes from the binary float or the decimal text), so the fix + must not perturb it. (10000 px, rather than the defect report's 1000 px, + keeps the result above the width spinbox's 128 px "Auto" floor.)""" + group, emitted = _group(qtbot) + group.apply(_presentation(), editable=True) + group.set_cut(_facts(framed_size=(10000, 10000))) + emitted.clear() + + group.percent_spinbox.setValue(2.35) + + assert group.width_spinbox.value() == 235 + assert group.height_spinbox.value() == 235 + + def test_choosing_an_aspect_preset_emits_a_centred_crop_and_locks_it(qtbot) -> None: group, emitted = _group(qtbot) locks: list[object] = [] From cfe63390b46c864ded7b35e2757e7c175977e478 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 21:01:58 +0200 Subject: [PATCH 43/45] fix: close the cut session when the source is replaced Trigger: manual field test on Windows, 2026-09-04, following a Codex finding. With a cut open for a 440 x 444 source and a transform set, loading a 1920 x 1080 source left the previous session alive: the Transform controls stayed enabled although the new source had no cut, Crop still read 440 x 444, and ticking "Edit crop" brought the previous video's frames back onto the canvas. Measured offscreen before the fix: after dispatching a load of another source, the frame reader was called twice more against the old cut -- assert 6 == 4 -- which is the field report's "the old preview reappears", reproduced. The controller subscribes to the whole application state but read only the parameters, so it could not tell "the user reset the transform" from "the user replaced the file" and processed the second as an edit. It now records the source identity it is working against and, when that changes, runs the existing close_session() teardown -- the same one shutdown() uses, which nulls the session and plan, bumps the facts generation so an in-flight worker result is discarded, and clears the player frames. No second cancellation path was written. The framing and transform baselines are re-derived afterwards so the next real edit still diffs correctly. Same-source state changes never enter the branch, so the crop-edit debounce (E33) and the two fixes in 9a3e102 and da4940f are untouched. Two things deliberately left alone. The source identity is a per-load UUID rather than the file, so reopening the same file also closes the session; that is harmless because the reducer rebuilds the state from scratch anyway, and the accompanying test therefore pins the controller's contract rather than a user gesture. And the crop-edit flag and overlay survive the teardown: with no frames on the canvas nothing wrong is visible, and the next facts re-present the overlay, so there was no defect to fix there. Tests: test_loading_a_different_source_closes_the_open_cut_session and test_reloading_the_same_source_leaves_the_open_cut_session_intact. Worth recording for whoever picks this file up next: this is now the third identity check guarding one invariant -- restore_for compares cache keys, _facts_ready compares generations, and _state_changed now compares the source id. If a fourth is ever needed, the honest move is to let CutSession carry the source it was opened for and validate once, rather than adding another comparison. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/transform_stage.py | 19 +++++++++ tests/ui/test_transform_stage.py | 65 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py index 7091c56..0de7240 100644 --- a/src/matteloop/ui/transform_stage.py +++ b/src/matteloop/ui/transform_stage.py @@ -146,6 +146,7 @@ def __init__( self._retiring: list[tuple[QThread, QObject]] = [] self._framing = _framing_from_parameters(store.state.parameters) self._last_transform: TransformSpec = store.state.parameters.transform + self._source_id: str | None = store.state.source_id self._player_frames: PlayerFrames | None = None self._frame_generations = count(1) self._frame_generation = 0 @@ -298,6 +299,10 @@ def _aspect_lock_changed(self, ratio: object) -> None: # -- facts recomputation ------------------------------------------- def _state_changed(self, state: AppState) -> None: + if state.source_id != self._source_id: + self._source_id = state.source_id + self._source_changed(state.parameters) + return parameters = state.parameters framing = _framing_from_parameters(parameters) framing_changed = framing != self._framing @@ -324,6 +329,20 @@ def _state_changed(self, state: AppState) -> None: elif reload_relevant_changed: self._sync_player_frames(immediate=False) + def _source_changed(self, parameters: ParameterState) -> None: + """Close the open cut: it belongs to a file that is no longer loaded. + + ``SourceLoadRequested`` resets the transform, which the edit path in + ``_state_changed`` would otherwise read as an ordinary edit and answer + by reloading the previous cut's frames -- and the retained facts would + keep the Transform controls live on the previous cut's framed size, + which ``clamp_crop`` cannot catch while the stale crop still fits + inside the new source. + """ + self.close_session() + self._framing = _framing_from_parameters(parameters) + self._last_transform = parameters.transform + def _refresh_crop_edit_presentation(self, transform: TransformSpec) -> None: """Re-apply the crop-edit presentation for *transform*, skipping the frame reload ``_sync_player_frames`` would trigger (E33): the canvas diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py index 183af02..137ca93 100644 --- a/tests/ui/test_transform_stage.py +++ b/tests/ui/test_transform_stage.py @@ -780,3 +780,68 @@ def test_attach_wires_the_players_playhead_to_the_groups_use_playhead_buttons( assert group._playhead == 7 # noqa: SLF001 controller.shutdown() + + +# -- a cut session must not outlive the source it belongs to --------------- + + +def test_loading_a_different_source_closes_the_open_cut_session( + tmp_path, qtbot +) -> None: + """Trigger: a cut open for a 440x444 clip, a transform set, then a + 1920x1080 file loaded. ``SourceLoadRequested`` resets the transform but + left ``_session``/``_facts`` in place, so the reset read as an ordinary + edit: it reloaded frames from the previous cut, the Transform controls + stayed live on the previous cut's framed size, and a render started in + that state would have cut a 440x444 corner out of the new video -- + ``clamp_crop`` only intervenes when the crop no longer fits. + """ + artifact = _seed_cut(tmp_path, "source-swap") + reader = _CountingReader() + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store, frame_reader=reader) + canvas = ResultPlayerCanvas() + qtbot.addWidget(canvas) + group = TransformGroup(lambda _event: None) + qtbot.addWidget(group) + controller.attach(group, canvas) + + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: canvas.current_frame is not None, timeout=5000) + frame_count = artifact.manifest.frame_count + store.dispatch(TransformChanged(TransformSpec(crop=CropSpec(0, 0, 64, 64)))) + qtbot.waitUntil(lambda: reader.calls >= 2 * frame_count, timeout=5000) + reads_before = reader.calls + + store.dispatch(SourceLoadRequested("other-source", "load-2")) + + assert controller.session is None + assert controller.facts is None + assert canvas.current_frame is None + qtbot.wait(500) # longer than the 250ms crop-edit debounce + assert reader.calls == reads_before, ( + "no frame may be read from a cut whose source is no longer loaded" + ) + controller.shutdown() + + +def test_reloading_the_same_source_leaves_the_open_cut_session_intact( + tmp_path, qtbot +) -> None: + """The source identity decides, not the event: a state change that keeps + ``source_id`` must leave a healthy session alone. (The shell mints a fresh + id per file dialog, so re-opening the same file does close the session.) + """ + artifact = _seed_cut(tmp_path, "same-source") + store = ReducerStore(_ready_state(tmp_path / "source.mp4")) + controller = TransformStageController(store) + + controller.open_artifact(artifact) + qtbot.waitUntil(lambda: controller.facts is not None, timeout=5000) + facts = controller.facts + + store.dispatch(SourceLoadRequested("source", "load-2")) + + assert controller.session is not None + assert controller.facts == facts + controller.shutdown() From 6882f59c90f0103ffe82319452b6e65b1ba338d6 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 22:01:26 +0200 Subject: [PATCH 44/45] fix: destroy the job dialog on shutdown instead of leaving it to the GC Trigger: the intermittent access violation in tests/ui, so far filed under "a known PyAV crash in roughly 3 of 25 runs". It is not PyAV. Measured on this branch with tests/ui/test_render_controller.py run repeatedly: unmodified 1 crash / 9 runs with an unrelated change to the test file 8 crashes / 8 runs with only this teardown added 0 crashes / 6 runs teardown removed again (control) 2 crashes / 4 runs shutdown() destroyed every widget this controller owns except the job dialog. That one deliberately outlives its job to show the completion summary, and with no dialog parent it is a top-level QWidget held alive by this controller's reference alone. Nothing ever closed or deleted it, so it was left to Python's garbage collector -- meaning a QWidget could be destroyed from inside a running event loop, at whichever unrelated moment the next collection happened to land. That is exactly the shape of the reports: an access violation on the main thread inside app.exec, surfacing in a different test that had already passed, and moving from run to run. Anything that shifts allocation timing moves the detonation, which is why adding a single unused import to a test file took it from 11% to 100%. Diagnosed while working on the transform-restore fix that follows, whose larger test module made the crash reliable enough to bisect. The controlled runs above were done in both directions rather than trusting one green suite: a single passing run is no signal for a fault that depends on collection timing. Not addressed here, and worth its own look: three times during that work a pytest run wedged indefinitely instead of crashing, once on an otherwise unmodified tree. It did not recur in the runs above, which is not the same as being gone, and it is plausibly the other face of the same shutdown-ordering area. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/render_controller.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/matteloop/ui/render_controller.py b/src/matteloop/ui/render_controller.py index 7fb62b7..6bc0744 100644 --- a/src/matteloop/ui/render_controller.py +++ b/src/matteloop/ui/render_controller.py @@ -1,4 +1,13 @@ -"""GUI-thread render orchestration, confirmations, and output actions.""" +"""GUI-thread render orchestration, confirmations, and output actions. + +Shutdown note: every widget this controller creates is destroyed in +``shutdown``. The job dialog is the one that is easy to miss -- it survives +its job to show the completion summary, and with no dialog parent it is a +top-level widget kept alive by this controller's reference alone. Leaving it +to Python's garbage collector meant a QWidget could be destroyed from inside +a running event loop, which crashed the process with an access violation +long after the job it belonged to had finished. +""" from __future__ import annotations @@ -172,6 +181,11 @@ def dispatch(self, command: object) -> None: def shutdown(self) -> None: self._closed = True + if self._dialog is not None: + # Destroyed here, not by a later GC pass -- see the module docstring. + dialog, self._dialog = self._dialog, None + dialog.close() + dialog.deleteLater() if self._preflight_dialog is not None: self._preflight_dialog.close() if self._collision_dialog is not None: From 42b71b80f9bf4f690300bf11c4a35bfd1ec28439 Mon Sep 17 00:00:00 2001 From: Sensitron Date: Fri, 4 Sep 2026 22:02:30 +0200 Subject: [PATCH 45/45] fix: restore a cut's stored transform when it is rebuilt from the reuse prompt Trigger: manual field test on Windows, 2026-09-04, following a Codex finding. A transform was set and rendered, the application restarted, the same video loaded, Render pressed, and the "Matching cut set found" prompt answered with Rebuild: before restart 384 x 128 21 frames 647 KiB after restart 440 x 444 37 frames 3194 KiB The trim, crop and resize were dropped, and the sidecar holding them was then gone from the cuts directory: the rebuild ran with an identity transform, and store_transform removes the file for an identity result. The settings were not merely ignored, they were destroyed, so a second attempt could not recover them either. _finish_reuse rebuilt with the request captured in _request_render, before the probe had looked at the cut, so on a cold start it carried identity. The manual workspace picker was unaffected only because _use_workspace restores first and then re-derives the request from the store. The reuse path now does the same through _transform_for_rebuild, and nothing else about the request changes -- it keeps the cut inputs the probe matched. The exception is the cut that is already open: what the inspector holds for it is an unsaved edit, and restoring over that would be the same loss in the other direction. The controller asks the transform stage which cut it has open rather than tracking a copy of that rule, so the two cannot disagree; every other cut restores, which means keeping a live edit can never write it into an unrelated cut's sidecar. Restoring pushed the module past the 800-line G6 budget. Rather than raising the baseline, the three confirmation prompts -- preflight, reuse, collision -- now share one builder: they differed only in wording and in what they do with the answer. Object names, labels, order, default and escape buttons are unchanged, which the existing dialog tests still assert. A few smaller repetitions went with it, leaving the module under budget. Tests in tests/ui/test_rebuild_transform_restore.py, kept out of test_render_controller.py so that file stays as it was: a cold start restores the sidecar, an open cut keeps its unsaved edits, a different cut restores its own sidecar rather than the open cut's edits, and a cut without a sidecar stays identity. Each was verified to fail with its defect reinstated. Left deliberately: the manual picker still restores unconditionally, so picking a set while that same cut is open with unsaved edits discards them. "Use this set" is an explicit act on a named cut, unlike answering a prompt, so the two are not obviously the same case. Routing it through the same helper is a one-line change if that judgement goes the other way. Co-Authored-By: Claude Opus 5 (1M context) --- src/matteloop/ui/controller.py | 12 ++ src/matteloop/ui/render_controller.py | 228 ++++++++++----------- tests/ui/test_rebuild_transform_restore.py | 220 ++++++++++++++++++++ 3 files changed, 339 insertions(+), 121 deletions(-) create mode 100644 tests/ui/test_rebuild_transform_restore.py diff --git a/src/matteloop/ui/controller.py b/src/matteloop/ui/controller.py index acb03c6..d261bf2 100644 --- a/src/matteloop/ui/controller.py +++ b/src/matteloop/ui/controller.py @@ -196,6 +196,7 @@ def __init__( self._transform_stage.open_artifact ) self._render_controller.transform_restore = self._transform_stage.restore_for + self._render_controller.open_cut_key = self._open_cut_key self._preview_controller.provider_ready.connect(self._provider_ready) self._render_controller.provider_ready.connect(self._provider_ready) self._working_provider = store.state.parameters.execution_provider @@ -242,6 +243,17 @@ def transform_stage(self) -> TransformStageController: """Expose the cut-session owner for lifecycle and UI integration tests.""" return self._transform_stage + def _open_cut_key(self) -> str | None: + """Name the cut the Transform group currently edits, if any. + + The rebuild path asks this before restoring a matched cut's stored + transform, so that an unsaved edit to the cut on screen wins. The + stage owns the session -- it opens one per artifact and closes it + when the source is reloaded -- so it is asked rather than mirrored. + """ + session = self._transform_stage.session + return None if session is None else session.workspace.cache_key + def attach_transform_stage( self, group: TransformGroup, canvas: CropCanvas | None ) -> None: diff --git a/src/matteloop/ui/render_controller.py b/src/matteloop/ui/render_controller.py index 6bc0744..4497e71 100644 --- a/src/matteloop/ui/render_controller.py +++ b/src/matteloop/ui/render_controller.py @@ -134,6 +134,7 @@ def __init__( self._workspace_picker: WorkspacePickerController | None = None self._active_workspace: CutWorkspace | None = None self.transform_restore: Callable[[CutWorkspace], None] | None = None + self.open_cut_key: Callable[[], str | None] | None = None self._closed = False @property @@ -249,42 +250,60 @@ def _request_render(self) -> None: return self._probe_for_reuse(request) + def _choice_dialog( + self, + name: str, + title: str, + text: str, + informative: str, + buttons: tuple[tuple[str, str], ...], + default: int, + chosen: Callable[[str], None], + closed: Callable[[], None], + ) -> QMessageBox: + """Build one three-choice confirmation, unopened. + + The preflight, reuse and collision prompts differ only in wording and + in what they do with the answer, so they share this: *buttons* pairs + each label with the choice *chosen* receives, in display order; the + last is always the escape button, and *default* indexes the + preselected one. The caller stores the dialog before opening it -- + *closed* runs while it is still recorded. + """ + dialog = QMessageBox(self._dialog_parent) + dialog.setObjectName(name) + dialog.setWindowTitle(title) + dialog.setText(text) + dialog.setInformativeText(informative) + role = QMessageBox.ButtonRole.AcceptRole + answers = {dialog.addButton(label, role): choice for label, choice in buttons} + widgets = tuple(answers) + dialog.setDefaultButton(widgets[default]) + dialog.setEscapeButton(widgets[-1]) + dialog.buttonClicked.connect(lambda button: chosen(answers[button])) + dialog.finished.connect(lambda _result: closed()) + return dialog + def _show_preflight(self) -> None: if self._preflight_dialog is not None: return - dialog = QMessageBox(self._dialog_parent) - dialog.setObjectName("render_preflight_dialog") - dialog.setWindowTitle("Preview recommended") - dialog.setText("Preview this frame before rendering?") - dialog.setInformativeText( - "A preview lets you verify the cutout before processing the whole video." + dialog = self._choice_dialog( + "render_preflight_dialog", + "Preview recommended", + "Preview this frame before rendering?", + "A preview lets you verify the cutout before processing the whole video.", + ( + ("Preview first", "preview"), + ("Render anyway", "render"), + ("Cancel", "cancel"), + ), + 0, + self._finish_preflight, + self._preflight_closed, ) - preview = dialog.addButton( - "Preview first", QMessageBox.ButtonRole.AcceptRole - ) - render = dialog.addButton( - "Render anyway", QMessageBox.ButtonRole.AcceptRole - ) - cancel = dialog.addButton("Cancel", QMessageBox.ButtonRole.AcceptRole) - dialog.setDefaultButton(preview) - dialog.setEscapeButton(cancel) - dialog.buttonClicked.connect( - lambda button: self._preflight_selected(button, preview, render, cancel) - ) - dialog.finished.connect(lambda _result: self._preflight_closed()) self._preflight_dialog = dialog dialog.open() - def _preflight_selected( - self, button: object, preview: object, render: object, cancel: object - ) -> None: - if button is preview: - self._finish_preflight("preview") - elif button is render: - self._finish_preflight("render") - elif button is cancel: - self._finish_preflight("cancel") - def _preflight_closed(self) -> None: if self._preflight_dialog is not None: self._finish_preflight("cancel") @@ -363,43 +382,23 @@ def _resolve_collision( def _show_reuse(self) -> None: if self._reuse_dialog is not None or self._reuse_request is None: return - dialog = QMessageBox(self._dialog_parent) - dialog.setObjectName("render_reuse_dialog") - dialog.setWindowTitle("Matching cut set found") - dialog.setText("A validated cut set matches the current source and settings.") - dialog.setInformativeText( - "Rebuild reuses the cuts and only reruns framing and encoding." - ) - rebuild = dialog.addButton("Rebuild", QMessageBox.ButtonRole.AcceptRole) - regenerate = dialog.addButton( - "Regenerate", QMessageBox.ButtonRole.AcceptRole + dialog = self._choice_dialog( + "render_reuse_dialog", + "Matching cut set found", + "A validated cut set matches the current source and settings.", + "Rebuild reuses the cuts and only reruns framing and encoding.", + ( + ("Rebuild", "rebuild"), + ("Regenerate", "regenerate"), + ("Cancel", "cancel"), + ), + 0, + self._finish_reuse, + self._reuse_closed, ) - cancel = dialog.addButton("Cancel", QMessageBox.ButtonRole.AcceptRole) - dialog.setDefaultButton(rebuild) - dialog.setEscapeButton(cancel) - dialog.buttonClicked.connect( - lambda button: self._reuse_selected( - button, rebuild, regenerate, cancel - ) - ) - dialog.finished.connect(lambda _result: self._reuse_closed()) self._reuse_dialog = dialog dialog.open() - def _reuse_selected( - self, - button: object, - rebuild: object, - regenerate: object, - cancel: object, - ) -> None: - if button is rebuild: - self._finish_reuse("rebuild") - elif button is regenerate: - self._finish_reuse("regenerate") - elif button is cancel: - self._finish_reuse("cancel") - def _reuse_closed(self) -> None: if self._reuse_dialog is not None: self._reuse_dialog = None @@ -417,10 +416,36 @@ def _finish_reuse(self, choice: str) -> None: self._reuse_workspace = None dialog.close() if choice == "rebuild" and workspace is not None: - self._resolve_collision(replace(request, rebuild=True), workspace) + self._resolve_collision( + self._transform_for_rebuild(replace(request, rebuild=True), workspace), + workspace, + ) elif choice == "regenerate": self._resolve_collision(replace(request, regenerate=True), None) + def _transform_for_rebuild( + self, request: RenderRequest, workspace: CutWorkspace + ) -> RenderRequest: + """Restore the matched cut's stored transform before it is rebuilt. + + This request predates the probe, so on a cold start it carries + identity: the rebuild dropped the stored trim/crop/resize, and + ``store_transform`` then deleted the sidecar holding them because an + identity result records nothing. Restoring mirrors ``_use_workspace`` + -- dispatch, then read the transform back off the store. + + The cut already open is the exception: what the inspector holds for + it is an unsaved edit, and restoring over that is the same loss in + the other direction. ``open_cut_key`` asks the Transform stage, which + owns that session and closes it when the source is reloaded, rather + than tracking a second copy here. Every other cut restores, so + keeping that edit can never rewrite a different cut's sidecar. + """ + open_key = self.open_cut_key() if self.open_cut_key is not None else None + if self.transform_restore is not None and open_key != workspace.cache_key: + self.transform_restore(workspace) + return replace(request, transform=self._store.state.parameters.transform) + def _current_request(self) -> RenderRequest | None: state = self._store.state if state.source is not SourceState.READY or state.source_value is None: @@ -467,43 +492,23 @@ def _use_workspace(self, value: object) -> None: def _show_collision(self) -> None: if self._collision_dialog is not None or self._collision_request is None: return - dialog = QMessageBox(self._dialog_parent) - dialog.setObjectName("render_collision_dialog") - dialog.setWindowTitle("Output already exists") - dialog.setText(f"{self._collision_request.output.path} already exists.") - dialog.setInformativeText("Choose how to handle the existing output.") - replace_button = dialog.addButton( - "Replace", QMessageBox.ButtonRole.AcceptRole - ) - choose_button = dialog.addButton( - "Choose another name", QMessageBox.ButtonRole.AcceptRole - ) - cancel_button = dialog.addButton("Cancel", QMessageBox.ButtonRole.AcceptRole) - dialog.setDefaultButton(cancel_button) - dialog.setEscapeButton(cancel_button) - dialog.buttonClicked.connect( - lambda button: self._collision_selected( - button, replace_button, choose_button, cancel_button - ) + dialog = self._choice_dialog( + "render_collision_dialog", + "Output already exists", + f"{self._collision_request.output.path} already exists.", + "Choose how to handle the existing output.", + ( + ("Replace", "replace"), + ("Choose another name", "choose"), + ("Cancel", "cancel"), + ), + 2, + self._finish_collision, + self._collision_closed, ) - dialog.finished.connect(lambda _result: self._collision_closed()) self._collision_dialog = dialog dialog.open() - def _collision_selected( - self, - button: object, - replace_button: object, - choose_button: object, - cancel: object, - ) -> None: - if button is replace_button: - self._finish_collision("replace") - elif button is choose_button: - self._finish_collision("choose") - elif button is cancel: - self._finish_collision("cancel") - def _collision_closed(self) -> None: if self._collision_dialog is not None: self._collision_dialog = None @@ -521,15 +526,8 @@ def _finish_collision(self, choice: str) -> None: self._collision_workspace = None dialog.close() if choice == "replace": - self._start( - replace( - request, - output=replace( - request.output, collision_policy=CollisionPolicy.REPLACE - ), - ), - workspace, - ) + output = replace(request.output, collision_policy=CollisionPolicy.REPLACE) + self._start(replace(request, output=output), workspace) elif choice == "choose": self._choose_output_name(request, workspace) @@ -656,14 +654,11 @@ def _open_dialog( self._dialog.open_output_requested.connect(self._open_output) self._dialog.open_folder_requested.connect(self._open_output_folder) self._dialog_cancel_connected = True + rebuilding = kind is JobKind.REBUILD self._dialog.reset( - "Rebuilding from edited cuts" - if kind is JobKind.REBUILD - else "Rendering video" - ) - self._dialog.stage_label.setText( - "Validation" if kind is JobKind.REBUILD else "Decode" + "Rebuilding from edited cuts" if rebuilding else "Rendering video" ) + self._dialog.stage_label.setText("Validation" if rebuilding else "Decode") self._dialog.set_job_details( request.segmentation.model_id, request.output.filename ) @@ -785,16 +780,7 @@ def _open_output_folder(self) -> None: def _normalise_progress(event: ProgressEvent) -> ProgressEvent: if event.stage != "render-cut": return event - return ProgressEvent( - event.job_id, - "Decode", - event.completed, - event.total, - event.detail, - event.overall_completed, - event.overall_total, - event.overall_indeterminate, - ) + return replace(event, stage="Decode") def _path_value(value: object) -> Path | None: diff --git a/tests/ui/test_rebuild_transform_restore.py b/tests/ui/test_rebuild_transform_restore.py new file mode 100644 index 0000000..d00bc00 --- /dev/null +++ b/tests/ui/test_rebuild_transform_restore.py @@ -0,0 +1,220 @@ +"""Restoring a matched cut's stored transform when Rebuild is chosen. + +The reuse request is assembled before the workspace probe ever looks at the +cut, so on a cold start it carries an identity transform. Rebuilding with it +dropped the stored trim/crop/resize and then deleted the sidecar holding +them, because ``store_transform`` records nothing for an identity result. + +These live in their own module rather than in ``test_render_controller.py``: +adding to that module's import and collection scope reproducibly turns a +latent shutdown crash there (``Windows fatal exception: access violation``, +present at HEAD at roughly 1 run in 8) into a near-certain one. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QImage + +from matteloop.core.parameters import TransformChanged +from matteloop.core.specs import CropSpec, TransformSpec +from matteloop.core.state import ( + AppState, + PreviewRequested, + PreviewResult, + PreviewSucceeded, + SourceLoaded, + SourceLoadRequested, + reduce, +) +from matteloop.jobs.transform_store import store_transform, transform_sidecar_path +from matteloop.jobs.workspace import CutWorkspace, WorkspaceLifecycle +from matteloop.ui.controller import SourceController +from matteloop.ui.ports import RenderVideoRequested +from matteloop.ui.preview_controller import PreviewRuntime +from matteloop.ui.store import ReducerStore + + +@dataclass(frozen=True) +class _Metadata: + path: Path + width: int = 128 + height: int = 128 + duration: Fraction = Fraction(2) + average_rate: Fraction = Fraction(30) + + +class _MatchedCutRuntime(PreviewRuntime): + """Match one promoted cut set and answer the Rebuild the dialog offers. + + Only the rebuild path is reached: ``RenderWorker`` skips model + preparation for a rebuild, so ``prepare``, ``preview`` and ``render`` + stand as assertions that nothing strayed off it. + """ + + default_model_id = "birefnet-portrait" + + def __init__(self, workspace: CutWorkspace) -> None: + self.workspace = workspace + self.rebuild_requests: list[object] = [] + + def prepare(self, model_id, extras, context): + raise AssertionError("a rebuild must not prepare a model session") + + def preview(self, request, playhead, context): + raise AssertionError("these tests never preview") + + def render(self, request, context): + raise AssertionError("these tests always rebuild") + + def find_matching_workspace(self, request, context): + del request, context + return self.workspace + + def rebuild(self, request, workspace, context): + del workspace, context + self.rebuild_requests.append(request) + return type("Artifact", (), {"output_path": request.output.path})() + + def close(self) -> None: + return + + +def _previewed_state(path: Path) -> AppState: + """READY with a current preview: ``can_render`` is on and Render goes + straight to the workspace probe instead of the preflight prompt.""" + loading = reduce(AppState(), SourceLoadRequested("source", "load")) + ready = reduce(loading, SourceLoaded("source", "load", _Metadata(path))) + running = reduce(ready, PreviewRequested("preview", "preview-request")) + return reduce( + running, + PreviewSucceeded( + "preview", PreviewResult("source", "preview-request", QImage()) + ), + ) + + +def _promoted_cut(tmp_path: Path, key: str) -> CutWorkspace: + """A promoted cut set as a restarted application would find it. + + The transform sidecar is addressed by ``cuts_root`` and ``cache_key`` + alone, so no cut frames or manifest are needed to store and restore one. + """ + cuts_root = tmp_path / ".matteloop-work" / "cuts" + cuts_root.mkdir(parents=True, exist_ok=True) + return CutWorkspace( + tmp_path, + tmp_path / ".matteloop-work", + cuts_root, + tmp_path / ".matteloop-work" / "scratch", + key * 64, + cuts_root / f"source-{key * 8}", + WorkspaceLifecycle.PROMOTED, + None, + f"source-{key * 8}", + ) + + +def _controller(tmp_path: Path, runtime: _MatchedCutRuntime): + source = tmp_path / "source.mp4" + source.write_bytes(b"fixture") + store = ReducerStore(_previewed_state(source)) + return store, SourceController(store, preview_runtime=runtime) + + +def _rebuild_from_reuse_dialog(controller: SourceController, qtbot) -> None: + """Answer the "Matching cut set found" prompt with Rebuild.""" + qtbot.waitUntil( + lambda: controller.render_controller.reuse_dialog is not None, + timeout=5000, + ) + dialog = controller.render_controller.reuse_dialog + assert dialog is not None + assert dialog.buttons()[0].text() == "Rebuild" + qtbot.mouseClick(dialog.buttons()[0], Qt.MouseButton.LeftButton) + + +def test_rebuilding_a_matched_cut_restores_its_stored_transform( + tmp_path, qtbot +) -> None: + """A cold start holds no transform, so the sidecar is the only record of + the trim/crop/resize the last render applied. + """ + workspace = _promoted_cut(tmp_path, "a") + stored = TransformSpec(first_frame=1, crop=CropSpec(8, 8, 64, 64)) + store_transform(workspace, stored, []) + runtime = _MatchedCutRuntime(workspace) + _store, controller = _controller(tmp_path, runtime) + + controller.dispatch(RenderVideoRequested()) + _rebuild_from_reuse_dialog(controller, qtbot) + + qtbot.waitUntil(lambda: len(runtime.rebuild_requests) == 1, timeout=5000) + assert runtime.rebuild_requests[0].transform == stored + controller.shutdown() + + +def test_rebuilding_the_open_cut_keeps_unsaved_inspector_edits( + tmp_path, qtbot +) -> None: + """The stored transform must never overwrite an edit the user has made to + the cut currently on screen -- the same loss in the other direction. + """ + workspace = _promoted_cut(tmp_path, "a") + store_transform(workspace, TransformSpec(first_frame=1), []) + runtime = _MatchedCutRuntime(workspace) + store, controller = _controller(tmp_path, runtime) + controller.render_controller.open_cut_key = lambda: workspace.cache_key + live = TransformSpec(crop=CropSpec(4, 4, 32, 32)) + store.dispatch(TransformChanged(live)) + + controller.dispatch(RenderVideoRequested()) + _rebuild_from_reuse_dialog(controller, qtbot) + + qtbot.waitUntil(lambda: len(runtime.rebuild_requests) == 1, timeout=5000) + assert runtime.rebuild_requests[0].transform == live + controller.shutdown() + + +def test_rebuilding_a_different_cut_restores_that_cuts_stored_transform( + tmp_path, qtbot +) -> None: + """Keeping live edits is scoped to the open cut: a match on some other + cut set restores that set's sidecar, so the rebuild cannot overwrite it + with a transform belonging to the cut on screen. + """ + opened = _promoted_cut(tmp_path, "a") + matched = _promoted_cut(tmp_path, "b") + stored = TransformSpec(first_frame=2) + store_transform(matched, stored, []) + runtime = _MatchedCutRuntime(matched) + store, controller = _controller(tmp_path, runtime) + controller.render_controller.open_cut_key = lambda: opened.cache_key + store.dispatch(TransformChanged(TransformSpec(crop=CropSpec(4, 4, 32, 32)))) + + controller.dispatch(RenderVideoRequested()) + _rebuild_from_reuse_dialog(controller, qtbot) + + qtbot.waitUntil(lambda: len(runtime.rebuild_requests) == 1, timeout=5000) + assert runtime.rebuild_requests[0].transform == stored + controller.shutdown() + + +def test_rebuilding_a_cut_without_a_stored_transform_stays_identity( + tmp_path, qtbot +) -> None: + workspace = _promoted_cut(tmp_path, "a") + assert not transform_sidecar_path(workspace).exists() + runtime = _MatchedCutRuntime(workspace) + _store, controller = _controller(tmp_path, runtime) + + controller.dispatch(RenderVideoRequested()) + _rebuild_from_reuse_dialog(controller, qtbot) + + qtbot.waitUntil(lambda: len(runtime.rebuild_requests) == 1, timeout=5000) + assert runtime.rebuild_requests[0].transform == TransformSpec() + controller.shutdown()