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 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 diff --git a/src/matteloop/app.py b/src/matteloop/app.py index b425d14..a3de375 100644 --- a/src/matteloop/app.py +++ b/src/matteloop/app.py @@ -71,6 +71,9 @@ def _run_gui() -> int: provider_options=provider_options, ) controller.set_dialog_parent(window) + 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/core/crop.py b/src/matteloop/core/crop.py index 0c7131e..8b4d671 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( @@ -68,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) @@ -105,6 +119,107 @@ 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, source_width=source_width, source_height=source_height + ) + 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, + *, + source_width: int, + source_height: int, +) -> CropSpec: + width, height = crop.width, crop.height + if target in {"east", "west"}: + 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, 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") + + +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/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..e8d1134 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: @@ -141,6 +143,11 @@ class OutputMaxSizeChanged: value: Decimal +@dataclass(frozen=True, slots=True) +class TransformChanged: + transform: TransformSpec + + ParameterEvent = ( ModelChanged | EdgeModeChanged @@ -153,6 +160,7 @@ class OutputMaxSizeChanged: | OutputDirectoryChanged | OutputFilenameChanged | OutputMaxSizeChanged + | TransformChanged ) @@ -184,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 @@ -318,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/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/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/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/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..a039fc3 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 @@ -70,10 +69,17 @@ 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 +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, @@ -1336,53 +1342,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 ) @@ -1392,7 +1364,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 = ( @@ -1451,13 +1423,14 @@ def _encode_snapshot( None, notes, ) + store_transform(durable, request.transform, notes) assert summary is not None return RenderArtifact( output_path, artifact_fingerprint, durable, manifest, - manifest.frame_count, + len(framed_paths), summary.width, summary.height, summary.file_size, @@ -1640,40 +1613,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, @@ -2903,36 +2842,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", - ) diff --git a/src/matteloop/jobs/transform_stage.py b/src/matteloop/jobs/transform_stage.py new file mode 100644 index 0000000..f050196 --- /dev/null +++ b/src/matteloop/jobs/transform_stage.py @@ -0,0 +1,175 @@ +"""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`` 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 + +import dataclasses +import os +import tempfile +from collections.abc import Callable +from pathlib import Path + +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 +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 + + +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, crop, and resize the kept cut frames and persist them. + + 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. + + 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: + 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 position, index in enumerate(kept): + context.set_frame_context( + position + 1, kept_count, overall=context.overall_progress + ) + context.checkpoint("framing") + framed_paths.append( + _stage_frame( + read_cut, index, position, plan, transform, framed_directory, tracker + ) + ) + context.progress( + "Framing", + position + 1, + total=kept_count, + detail=f"Frame {position + 1} of {kept_count}", + ) + return tuple(framed_paths), transform.select_kept(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: + cut = read_cut(index, tracker) + try: + framed = apply_framing(cut, plan) + tracker.register(framed) + finally: + cut.close() + del cut + try: + 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 + + +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/src/matteloop/jobs/transform_store.py b/src/matteloop/jobs/transform_store.py new file mode 100644 index 0000000..8e831d4 --- /dev/null +++ b/src/matteloop/jobs/transform_store.py @@ -0,0 +1,207 @@ +"""Persist the transform applied to a cut, beside the cut directory (D1). + +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 (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 + +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``; raises ``ValueError`` on any deviation.""" + 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 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() + 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, or remove it for identity. Swallows + ``OSError`` into a note (G4): the job already published its output.""" + if spec.is_identity: + discard_transform(workspace, notes) + 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 (G3); a no-op where unsupported (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, 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 FileNotFoundError: + pass + except OSError as error: + _note(notes, f"could not remove the stored transform: {error}") 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/src/matteloop/ui/controller.py b/src/matteloop/ui/controller.py index 185b585..d261bf2 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,12 @@ 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._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 @@ -229,6 +238,28 @@ 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 _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: + """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 +330,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/crop_canvas.py b/src/matteloop/ui/crop_canvas.py index 099ccc9..2ddc08b 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 @@ -259,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( @@ -273,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/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/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/src/matteloop/ui/preview_canvas.py b/src/matteloop/ui/preview_canvas.py index 854edac..bc4df1a 100644 --- a/src/matteloop/ui/preview_canvas.py +++ b/src/matteloop/ui/preview_canvas.py @@ -182,8 +182,26 @@ 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 _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.property("checkerboard") is True: + if self._should_paint_checkerboard(): painter = QPainter(self) tile = 16 light = QColor(CHECKERBOARD_LIGHT) @@ -203,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( @@ -223,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): @@ -232,15 +270,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/render_controller.py b/src/matteloop/ui/render_controller.py index 5e7ec75..4497e71 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 @@ -88,6 +97,7 @@ class RenderController(QObject): provider_ready = Signal(str) provider_notice = Signal(str) + artifact_ready = Signal(object) def __init__( self, @@ -123,6 +133,8 @@ 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.open_cut_key: Callable[[], str | None] | None = None self._closed = False @property @@ -170,6 +182,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: @@ -233,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." - ) - 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 = 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, ) - 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") @@ -347,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." + 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, ) - rebuild = dialog.addButton("Rebuild", QMessageBox.ButtonRole.AcceptRole) - regenerate = dialog.addButton( - "Regenerate", QMessageBox.ButtonRole.AcceptRole - ) - 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 @@ -401,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: @@ -435,6 +476,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 @@ -449,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 @@ -503,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) @@ -571,6 +587,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)) @@ -637,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 ) @@ -766,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/src/matteloop/ui/render_worker.py b/src/matteloop/ui/render_worker.py index f988372..b2bfc38 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,12 +71,13 @@ def run(self) -> None: artifact = rebuild( self._request, self._rebuild_workspace, self._context ) - 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/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/result_player.py b/src/matteloop/ui/result_player.py new file mode 100644 index 0000000..6dcb475 --- /dev/null +++ b/src/matteloop/ui/result_player.py @@ -0,0 +1,530 @@ +"""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 collections.abc import Callable +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 _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 +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 +_PLAY_BUTTON_CLEARANCE_GAP = 8.0 + +_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.""" + + +def _no_cancellation() -> bool: + return False + + +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_ACCESSIBLE_NAME) + 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). + + 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 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) + self._rebuild_geometry() + self.update() + 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() + 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.""" + 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. + + ``_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 + + def play(self) -> None: + if self._frames is None or not self._playable(): + return + self._playing = True + self._sync_play_button(True) + self._schedule_next() + + def pause(self) -> None: + self._playing = False + self._timer.stop() + 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: + 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)) + + 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 + + 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: + 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, + cancelled: Callable[[], bool] = _no_cancellation, + ) -> 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 + self._cancelled = cancelled + + @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, + self._cancelled, + ) + except _FrameLoadCancelled: + pass + 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, + cancelled: Callable[[], bool] = _no_cancellation, +) -> 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): + if cancelled(): + raise _FrameLoadCancelled + 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/theme.py b/src/matteloop/ui/theme.py index 180b85b..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}; }} @@ -74,6 +75,21 @@ 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; }} + """ + ) + + +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'] {{ @@ -84,6 +100,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}; }} @@ -98,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 diff --git a/src/matteloop/ui/transform_group.py b/src/matteloop/ui/transform_group.py new file mode 100644 index 0000000..8940a3f --- /dev/null +++ b/src/matteloop/ui/transform_group.py @@ -0,0 +1,639 @@ +"""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, + fraction_from_widget_value, +) +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 _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 + )) + 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._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) + 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(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) + 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: + # 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._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 + 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() + 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() + 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_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): + 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/src/matteloop/ui/transform_stage.py b/src/matteloop/ui/transform_stage.py new file mode 100644 index 0000000..0de7240 --- /dev/null +++ b/src/matteloop/ui/transform_stage.py @@ -0,0 +1,630 @@ +"""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 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 + +import threading +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, QTimer, Signal, Slot + +from matteloop.core.crop import clamp_crop +from matteloop.core.geometry import FramingPlan, PixelBounds, union_alpha_bounds +from matteloop.core.parameters import ParameterState, TransformChanged +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: + 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 + ``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 _CutFactsCancelled(Exception): + """Unwinds ``_iter_stored_frames`` once cancelled.""" + + +class _CutFactsWorker(QObject): + """Recompute one ``CutFacts`` off the GUI thread; also resolves the + ``FramingPlan`` the player's frame loader needs (C2) to apply per frame.""" + + succeeded = Signal(object, object, int) + failed = Signal(int) + finished = Signal() + + def __init__( + 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._cancelled + ) + except _CutFactsCancelled: + pass + except Exception: + self.failed.emit(self._generation) + else: + self.succeeded.emit(facts, plan, 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, + cache_budget: int = PLAYER_CACHE_BUDGET_BYTES, + 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._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) + self._current_generation = 0 + 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._source_id: str | None = store.state.source_id + 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._frame_cancel_event: threading.Event | 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 + ) + + 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, wired here.""" + 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) + 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) + + 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. + + Clamps the crop when facts for this exact cut are already known + (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 + 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._plan = 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() + # 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: + return self._session + + @property + def facts(self) -> CutFacts | None: + return self._facts + + # -- Stage C: playhead coupling and crop-edit mode ---------------------- + + 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 + 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: + 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 + if framing_changed: + self._framing = framing + if self._session is not None: + self._schedule_facts() + transform = parameters.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: + 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: + if crop_editing: + self._refresh_crop_edit_presentation(transform) + 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 + 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: + return + presentation = self._crop_edit_presentation(transform) + canvas.set_crop_edit(True, presentation, transform) + + def _schedule_facts(self) -> None: + session = self._session + if session is None: + return + self._join_worker() + generation = next(self._generations) + self._current_generation = 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) + worker.failed.connect(self._facts_failed) + worker.finished.connect(thread.quit) + 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: + """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 + self._worker = None + + @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) + 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: + 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) + 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 + 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 + + # -- 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 + cancel_event = threading.Event() + self._frame_cancel_event = cancel_event + 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, + cancelled=cancel_event.is_set, + ) + 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) + 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 frame loader.""" + 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): + 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, *, 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 -- ``quit()`` + alone cannot interrupt its running slot. Bumping the generation + (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 + worker, self._frame_worker = self._frame_worker, None + self._retire(thread, worker, wait=wait) + + +def _framing_from_parameters(parameters: ParameterState) -> FramingSpec: + return FramingSpec( + parameters.trim, + parameters.alpha_threshold, + parameters.padding, + parameters.stretch_x, + ) + + +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): + 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, + cancelled: Callable[[], bool] = lambda: False, +) -> tuple[CutFacts, FramingPlan]: + manifest = session.manifest + source_size = (manifest.width, manifest.height) + 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( + 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( + session: CutSession, framing: FramingSpec, frame_reader: FrameReader, + cancelled: Callable[[], bool] = lambda: False, +) -> 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, cancelled), framing.alpha_threshold + ) + + +def _iter_stored_frames( + 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 + 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/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/core/test_crop.py b/tests/core/test_crop.py index 70a93c6..7851fe8 100644 --- a/tests/core/test_crop.py +++ b/tests/core/test_crop.py @@ -1,7 +1,12 @@ from __future__ import annotations +from fractions import Fraction + from matteloop.core.crop import ( + centered_crop_for_aspect, + clamp_crop, crop_from_drag, + fit_crop_aspect, nudge_crop, oriented_point_from_widget, oriented_rect_to_source_rect, @@ -77,3 +82,130 @@ 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_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) + + 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) + + +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 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/core/test_specs.py b/tests/core/test_specs.py index d33dab2..805704d 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,130 @@ 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_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) + + 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 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 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()) diff --git a/tests/jobs/test_rebuild.py b/tests/jobs/test_rebuild.py index d66b747..137da4c 100644 --- a/tests/jobs/test_rebuild.py +++ b/tests/jobs/test_rebuild.py @@ -1,20 +1,36 @@ from __future__ import annotations +import os +import stat from dataclasses import replace from decimal import Decimal +from fractions import Fraction +import numpy as np 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, + OutputSpec, + 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, + PillowWebPEncoder, PreparedSegmentation, RenderService, ) +from matteloop.jobs.transform_store import load_transform, transform_sidecar_path from tests.jobs.render_support import ( ExplodingSource, FakeClock, @@ -22,6 +38,7 @@ FakeEncoder, FakeSegmenter, FakeSource, + FakeSourceInfo, binding, job, render_service, @@ -29,6 +46,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 +395,401 @@ 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 + + +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 + + +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 + ) 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..3986336 --- /dev/null +++ b/tests/jobs/test_transform_stage.py @@ -0,0 +1,126 @@ +"""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 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 + + +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 + + +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) diff --git a/tests/jobs/test_transform_store.py b/tests/jobs/test_transform_store.py new file mode 100644 index 0000000..399ba13 --- /dev/null +++ b/tests/jobs/test_transform_store.py @@ -0,0 +1,171 @@ +"""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_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: + """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 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_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) 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..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, @@ -82,6 +83,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 +114,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: @@ -232,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} 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( 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 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_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, 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_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() 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_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 new file mode 100644 index 0000000..80db095 --- /dev/null +++ b/tests/ui/test_result_player.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import threading +from fractions import Fraction +from pathlib import Path + +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 ( + 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.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 ( + 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_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) + 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_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) + 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) + + +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 ----------------------------------------------------------- + + +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"] + + +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 ------------ + + +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_group.py b/tests/ui/test_transform_group.py new file mode 100644 index 0000000..b586350 --- /dev/null +++ b/tests/ui/test_transform_group.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +from dataclasses import replace +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 ( + 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_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] = [] + 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_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()) + + 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)) + + +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 diff --git a/tests/ui/test_transform_stage.py b/tests/ui/test_transform_stage.py new file mode 100644 index 0000000..137ca93 --- /dev/null +++ b/tests/ui/test_transform_stage.py @@ -0,0 +1,847 @@ +"""The controller owning the currently open cut and its derived facts (D5).""" + +from __future__ import annotations + +import threading +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 ( + AlphaThresholdChanged, + GlobalTrimChanged, + 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 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, _DirectFrameReader +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), + ) + 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 + 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 + + +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 + + +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) + 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() + + +# -- 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 ------------------------ + + +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() + + +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() + + +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 ----------- + + +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() + + +# -- 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_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: + """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: + 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() + + +# -- 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() 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() 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()