Skip to content

Transform stage on a finished cut: trim, output crop, resize, and a looping result preview - #27

Merged
sensitron merged 45 commits into
mainfrom
feat/issue-25-transform-stage
Sep 4, 2026
Merged

sensitron merged 45 commits into
mainfrom
feat/issue-25-transform-stage

Conversation

@sensitron

Copy link
Copy Markdown
Collaborator

Closes #25.

A finished cut can now be trimmed to a frame range, cropped for output, and
resized — with the four ezgif-style mismatch modes — and re-encoded through
Rebuild without re-running segmentation. The result canvas loops the
transformed frames so the loop can be judged before rendering.

The stored PNGs are never touched. The transform is a spec applied at encode
time, so every step is reversible until the user changes it again.

Acceptance criteria

All eleven are met. Each is pinned by a named test.

# Criterion Test
1 Identity transform → byte-identical WebP tests/jobs/test_rebuild.py::test_identity_transform_rebuild_is_byte_identical
2 Trim keeps 4 frames and their delays ::test_trim_keeps_exactly_the_selected_frames_and_their_delays
3 Out-of-bounds trim raises, writes nothing ::test_trim_outside_the_cut_is_rejected_before_any_file_is_written
4 keep/stretch/cover/pad pixel behaviour tests/core/test_transform.py
5 Single-axis resize; derived dimensions bounded at validation tests/core/test_specs.py
6 Auto-fit still succeeds and reports actual size ::test_auto_fit_shrinks_a_resized_output_and_reports_actual_size
7 Stored PNGs unchanged (sha256 via the manifest) ::test_transforms_never_touch_stored_cut_frames
8 Reopening restores the transform; absent → identity ::test_rebuild_records_the_applied_transform_beside_the_cut + the corrupt/wrong-key/wrong-schema fallbacks
9 Player triggers zero segmentation and zero encoder calls tests/ui/test_result_player.py::test_playback_never_calls_segmentation_or_the_encoder
10 Preset fills dimensions, leaves mismatch; presets are data tests/ui/test_transform_group.py::test_choosing_a_size_preset_fills_dimensions_and_leaves_mismatch, ::test_a_fake_preset_row_appears_under_its_own_platform_header
11 ruff + mypy + pytest green see Verification

AC 4 was additionally checked by hand against the running code: on a 128×128
frame resized to 256×128, pad produces exactly 64 fully transparent columns
on each side (alpha 0 at columns 0 and 63, 255 at 64 and 191, 0 at 192), and
cover shows source rows 32–95 — the middle half.

Decisions taken without the maintainer

The issue was implemented as written except where the repository's own gates
made that impossible. Each deviation and why:

1. The guardrail ratchet, not the feature, dictated the module layout.
scripts/check_guardrails.py fails CI when any module recorded in
guardrails-baseline.json gains a single line. Every file the issue named for
the core work is frozen or nearly so: core/webp.py (1672), jobs/render.py
(2938), all three workspace/_manifest*.py, core/geometry.py, and
ui/inspector.py at 798 against an 800 budget. The baseline was not
updated
— the guardrails require the maintainer's agreement for that, and
updating it silently is named in that document as the failure it exists to
prevent. Instead the new code lives in new modules, paid for by two mechanical
extractions out of render.py:

  • _map_output_os_error / _output_error → jobs/encoding.py
  • the framing loop and _persist_framed_png → jobs/transform_stage.py

Both were diffed byte-for-byte against git show main:src/matteloop/jobs/render.py
and are verbatim. render.py went 2938 → 2844, its long-function count
unchanged at 11. inspector.py went 798 → 797, paid for by deleting the
caller-less show_workspace_attention and moving _read_bool into
inspector_disclosure.py.

2. Persistence uses a sidecar, not CutManifest — a deviation from AC 8's
wording.
The issue's own rollback plan anticipated this. CutManifest
rejects unknown keys (_manifest.py:325-343) and other schema versions
(:160-167), and all three manifest modules are frozen. A new key or a version
bump would make every touched cut unreadable to the previous release, and
list_workspaces only catches CUT_SET_INVALID, so one touched cut would
blank the entire picker after a rollback. A sidecar inside the cut directory
is also wrong: _scan.py:60-79 raises on any entry that is not
manifest.json or frame-*.png.

So: cuts_root/.transform-<cache_key>.json, beside the cut directory. Every
enumerator there skips dot-prefixed names (_models.py:53,
_snapshot_ops.py:264), the recovery marker regex does not match it, and
delete_workspace removes only workspace.path. The behaviour AC 8 describes
— reopening restores the transform, absent means identity — holds, and
test_sidecar_beside_the_cut_does_not_break_listing_or_validation asserts the
premise directly. Never migrate or rename weight or cut directories; the
namespace is the integrity boundary.

3. core/fingerprints.py was left untouched. A review proposed adding the
transform to render_fingerprint. It was dropped: that file is one of the
gates in the "Bumping rembg" checklist, and RenderArtifact.fingerprint turned
out to have exactly one producer and no consumer — it is in-memory only, never
persisted, never compared. The change was hygiene with no reachable behaviour.

4. Delays are sliced, not recomputed. The issue says "delays are computed
for the kept frames only". webp_delays is non-uniform, so recomputing is not
the same operation: webp_delays(8,3)[2:6] is (333,333,334,333) while
webp_delays(4,3) is (333,334,333,333) — equal sums, different order. The
test asserts the sequence and the inequality, so a future refactor that
recomputes fails loudly.

5. Test files. New tests live in behaviour-named modules
(test_transform.py, test_transform_stage.py, test_transform_store.py,
test_result_player.py, test_transform_group.py) rather than only in the
four the issue's plan listed. Guardrail G7 checks test module names.

Review findings

Two independent reviews ran against the finished diff: an Opus pass and
/codex:review --model gpt-5.6-sol. They agreed on three P1 defects and
disagreed on a fourth; per the repo's conflict rule, Fable arbitrated with only
the disputed code in front of it.

Fixed — all with a failing test first, each commit carrying a Trigger: line:

  • A transform set for one clip silently carried into the next source's render
    — either a wrongly trimmed and cropped output from a Transform group the user
    never opened, or an abort naming a crop belonging to a different clip.
  • A restored transform could hard-fail a rebuild with no visible cause.
    This was the disputed finding: Opus verified two clamp paths as correct, but
    both run only after cut facts arrive — i.e. after the rebuild that already
    failed. Codex was right. It is a guardrail G4 violation and a silent one: on
    failure the dialog simply closes, and the raw crop stays in the store, so the
    next plain Render fails identically while every Transform control is disabled.
    Fixed by clamping in stage_encoder_frames, which covers every entry path.
  • The crop overlay froze after the first gesture — repeated arrow nudges emitted
    the same crop, and a second drag jumped back to the original.
  • The crop aspect lock could never be cleared once set.
  • A trim edit crossing its own endpoint raised ValidationError out of a Qt slot.
  • An aspect-locked crop at a frame edge built an invalid CropSpec on an
    ordinary drag.
  • Superseding a frame load blocked the GUI thread for up to 5 seconds on the
    ordinary edit path, and a cancelled load's queued result could resurrect a
    closed cut's frames.
  • A render cancelled between encode and commit still opened a cut session for
    an unpublished result.
  • A failed sidecar removal was swallowed, so a later reopen restored a transform
    the last render had not applied.

Not fixed — recorded for the maintainer:

  • transform_store.py: on ENOSPC the temp file leaks into cuts_root, where
    nothing collects dot-prefixed names. Never observed; fixing it speculatively
    is the pattern G1 forbids.
  • _snapshot_ops.py:316 delete_workspace does not remove the sidecar; only
    workspace_controller pairs discard_transform by caller discipline. Any
    future caller would orphan one.
  • result_player.py: PlayerFrames.key is computed but never compared and
    _player_frames is never read, so every transform edit re-decodes the cut
    even though the framed cache does not depend on the transform. The test
    named ..._reuse_the_framed_cache asserts the opposite of its name.
    Worth
    correcting, but it is a performance defect behind a 250 ms debounce, not a
    correctness one.
  • The player's status marker and the presenter's share one slot and overwrite
    each other, so the truncation notice can be wiped.
  • Pre-existing and out of scope, but it is what makes the above class of
    failure invisible: state.artifact_error is read by no UI module, so a
    failed render's message reaches only the log.

Verification

uv run ruff check .                      clean
uv run mypy src                          9 errors — the pre-existing baseline
QT_QPA_PLATFORM=offscreen uv run pytest -q
uv run python scripts/check_guardrails.py  passes, baseline file unmodified

This is a Windows workstation, and main is not clean here: before any of
this work, mypy reported 9 errors (POSIX/Windows platform artefacts in
_platform.py, segmentation_host.py, cache_fs.py, _fs_helpers.py,
_publication.py) and pytest had 28–29 failures (symlink and junction tests
that need Administrator, media-stack build configuration, release packaging,
shell-open dialogs). The suite is also already flaky by exactly one test,
test_preview_controller.py::test_preview_request_uses_the_selected_oriented_crop,
which passes in isolation.

So the honest claim is no regression, not a clean sweep:

  • mypy: still exactly 9, same five files, no new file.
  • pytest: 1490 → 1616 passing (126 new tests); failures diffed
    set-against-set with the pre-work baseline — no new failure.

One test is skipped rather than faked: the unwritable-cuts_root case cannot
run on Windows, because chmod will not make a directory read-only for its
owner on NTFS.

Not verified on screen

The result player was never seen running. Offscreen tests show that the
timer advances the frame index, wraps at the kept range, that Play/Pause
toggles, that the caches stay inside their budget and truncate with a marker,
and that playback calls neither segmentation nor the encoder. They cannot show
what it looks like. Unverified: the loop's on-screen appearance, checkerboard
and pixmap scaling, the play button's placement, and dragging the crop overlay
with a real mouse.

Worth clicking through before merging:

  1. Render a clip, then open the Transform group and scrub the trim spinboxes —
    does the loop actually shorten on screen?
  2. Drag the crop overlay on the result canvas repeatedly, and nudge it with the
    arrow keys — the frozen-overlay bug is fixed but only ever seen in tests.
  3. Pick a 7TV preset, Rebuild, and confirm the written file matches the readout.
  4. Load a second video after setting a transform — it must reset to identity.
  5. Restore a saved cut whose crop was stored under a different padding — it
    should now clamp and rebuild instead of failing silently.

Process notes

  • One commit, 34ef0b3, was not written test-first: the agent wrote the
    CropCanvas hooks before their tests and disclosed it. The tests exist and
    pass, but the red-then-green order was not observed there.
  • No fix: commit is the third consecutive one on the same file; the
    transform_stage.py findings were deliberately bundled into one commit to
    stay inside guardrail G1.
  • The plan is committed as
    docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md, including the
    engineering review's findings and the eight corrections they produced.

🤖 Generated with Claude Code

@smb

smb commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

The test (ui) failure: _join_worker touches a QThread that deleteLater already destroyed

CI run 33830681783 dies with Fatal Python error: Segmentation fault, exit 139, after 62 green tests. The faulthandler frames:

src/matteloop/ui/transform_stage.py, line 393 in _join_worker      (thread.wait(5000))
src/matteloop/ui/transform_stage.py, line 230 in shutdown
tests/ui/test_transform_stage.py, line 250 in test_open_artifact_ignored_leaves_an_existing_session_untouched

Reproduced locally on macOS at 9f8de12: three runs of tests/ui/test_transform_stage.py, three segfaults, same test, same frames (one run reports line 388 instead of 393; faulthandler attributes the fault to the nearest bytecode line, the object is gone either way). So it is deterministic, not a runner hiccup.

Mechanism

_start_worker (around :340-346) creates QThread(self), moves the worker onto it and wires thread.finished.connect(thread.deleteLater) while the controller keeps the Python reference in self._thread. The failing test then does qtbot.waitUntil(lambda: controller.session is not None), which spins the event loop until the load finished; in that spin finished fires and deleteLater destroys the C++ QThread. self._thread still points at the wrapper. shutdown() runs _cancel_frame_load(wait=True), close_session(), then _join_worker(), which calls quit()/wait() on the deleted object. The except RuntimeError only helps when shiboken noticed the deletion; after a deleteLater from inside the event loop it usually has not, and the call reads freed memory.

The previous test in the file passes because it never starts a worker. The frame loader uses the same wiring (:437-443, joined at :487-489), so the same fault is available there once a test or a user finishes a frame load and then closes.

Fix options, smallest first

  1. Drop thread.finished.connect(thread.deleteLater) for both threads. They are parented to the controller (QThread(self)), so the controller's own destruction deletes them, and _join_worker keeps a live object to quit()/wait() on. One line removed per thread.
  2. Or keep deleteLater and clear the reference when the thread finishes: thread.finished.connect(self._on_worker_finished) with a slot that sets self._thread = None (and self._worker = None), so _join_worker finds nothing to join. Two small slots.

Either way the test as written is a good regression test for this: it is the only one that finishes a load and then shuts down.

Not pushed to this branch; this is a finding, the fix is yours.

@sensitron

Copy link
Copy Markdown
Collaborator Author

Confirmed, fixed in b851ca2, and thank you — the analysis was exactly right, including the mechanism.

Why my overnight run missed it: on this Windows workstation the same defect
manifests as a hang, not a segfault. tests/ui/test_transform_stage.py passes
on the first run (18 passed, 7.3 s) and then blocks on the second consecutive
run of the same file. My verification ran each file once per suite pass, so a
Windows hang inside a single pass never surfaced. That is a gap in how I
verified, not in the code you diagnosed.

Took your option 2, not the shorter option 1. Both stop the crash, but the
threads are parented to the controller, so without deleteLater every finished
QThread would stay alive as a child until the controller dies — and a user
editing a transform starts one per edit, so they would accumulate. Option 2
removes the dangling reference, which is the actual bug, and still lets them be
collected. Applied to both workers; the frame loader at :437-443 had identical
wiring.

One deviation from your literal suggestion: the clearing slots check
self.sender() is self._thread (respectively _frame_thread) before clearing.
Without that guard, a superseded thread's queued finished — which can arrive
after _schedule_facts / _start_frame_load has already assigned a newer
thread to the same attribute — would wipe the reference to the thread that is
actually running. Supersede is the ordinary edit path here, so that race is
reachable rather than theoretical.

test_open_artifact_ignored_leaves_an_existing_session_untouched is untouched
and remains the regression guard, as you noted. Verified with five consecutive
clean runs of the file (6.6-6.7 s each) plus the full suite: 1616 passed, 28
failures, all matching the pre-existing baseline on this machine.

I also swept src/matteloop/ui/ for the same pattern. transform_stage.py was
the only outlier: model_manager.py:537, controller.py:434,
render_controller.py:328 and :750, preview_controller/controller.py:309
and timeline.py:643 all already clear their reference in a finished-connected
slot. So the house idiom was right and this module simply missed it.

@sensitron

Copy link
Copy Markdown
Collaborator Author

The test (ui) segfault: two causes found and fixed, one left that is not this feature's

Your diagnosis got me to the right file but the wrong thread, and it took a core dump to see that. Rebased onto current main (cac89ba). Measurements below are all from a Linux devbox (Debian 13, QT_QPA_PLATFORM=offscreen), because the crash does not reproduce on Windows at all — locally it only ever showed as an occasional hang.

Why the first fix did nothing

b851ca2 cleared the dangling QThread reference as you suggested. Measured over 20 runs of tests/ui/test_transform_stage.py:

revision failures
9f8de12 (before any fix) 9 / 20
b851ca2 (the deleteLater fix) 10 / 20

No effect, because the fault is not on the main thread. faulthandler's Current thread has no Python frame while the main thread merely sits in _join_worker's wait() — a main-thread use-after-free would fault on the main thread. I also tried disconnecting the widgets in shutdown(); that made it 20 / 20. Three guesses, three misses, so I stopped guessing and took a core dump.

Cause 1 — the worker dies while its thread is starting

QThread::started(QThread::QPrivateSignal)   libQt6Core
QMetaObject::activate                       libQt6Core
<SIGSEGV>

The crash is inside the emission of started, before any Python slot runs: Qt walks the connection list to invoke worker.run and the receiver is already collected. _schedule_facts begins with _join_worker() (wait=False), which sets self._worker = None and does not wait — so superseding a worker drops its last Python reference while its thread may still be starting. Same shape for the frame loader.

Proof before fixing: appending every (thread, worker) pair to a never-cleared list took it from 9-10/20 to 1/25, and every remaining failure was a deadlock rather than a segfault.

Fixed in f1b0455 by retiring superseded pairs into self._retiring and pruning them only once thread.isFinished(), with shutdown() waiting each out bounded at 5s.

Cause 2 — the worker is destroyed on its own thread

The one remaining failure was a genuine deadlock. gdb on the hung process:

  • main thread: holds the GIL, in QLabel::setPixmap → ~QWidgetTextControl → QObject::~QObject → QBasicMutex::lockInternal()
  • worker thread: in QObject::~QObject → shiboken Sbk_GetPyOverride → GilState::acquire() → PyGILState_Ensure

Lock inversion. worker.finished.connect(worker.deleteLater) posts the deferred delete to the worker's own event loop, and destroying it there needs the GIL for shiboken's override lookup while the main thread holds the GIL and wants the Qt mutex that destructor holds.

Fixed in 2a190c0 by removing both worker.deleteLater connections. Worker lifetime is now plain Python refcounting, and every site that drops a reference runs on the main thread. The thread.deleteLater connections are untouched — a QThread object lives on the thread that created it, so those were never the hazard.

What is left, and why I did not touch it

tests/ui still fails roughly 3 in 25 — but it is a different crash in a different subsystem:

jobs/source.py:434 in _input_container
contextlib.py:141 in __enter__
jobs/source.py:295 in decode_frame
jobs/render.py:452 in decode
...
tests/ui/test_render_controller.py:196 in render

PyAV opening a container. This is the crash ci.yml already documents in its Windows job — "tests/ui/test_render_controller crashes the interpreter with an access violation raised while a worker thread closes the PyAV container in jobs/source.py".

Scoped it three ways, 25 runs each of pytest -q tests/ui:

tree failures
origin/main 0 / 25
this branch 3 / 25
this branch, ignoring test_transform_stage.py, test_result_player.py, test_transform_group.py 0 / 25

So this branch's own tests are what expose it — main stays green only because nothing there puts the process under this much Qt-thread pressure. I have not tried to fix it: jobs/source.py is frozen by the ratchet, the fragility predates this work, and quietening our tests to hide it seemed worse than reporting it. Your call whether it belongs in this PR, a follow-up, or the same bucket as the Windows note already in ci.yml.

Process note

ui/transform_stage.py now carries five consecutive fix: commits, well past G1's third-consecutive hard stop. Each one is recorded in its own commit body rather than quietly omitted. The first three were the guesses above; the last two each came with a backtrace and a before/after measurement. If you would rather see this as a smaller history, say so and I will squash the crash work into one commit.

The result player is still not verified on screen — the checklist for that is in the PR description.

@smb

smb commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

The visual pass you listed as not done

Ran the widgets offscreen with synthetic cut frames (a translucent blob on transparency, 320x240, twelve frames) and grabbed them, rather than clicking through a real render. That covers appearance, not the end-to-end flow, so items 3 and 5 on your list are still open. What I can report:

Works, seen rather than inferred. The loop advances and the blob moves; set_kept_range re-slices without a reload and the shown frame jumps into the kept range; the crop overlay draws over the framed frame with its handles and the dimmed surround, and the media gutter from #14 is there; the readout reads 12 of 12 frames · 0.804 s · 320 × 240 px.

One defect: the result loop has no checkerboard unless a preview ran first

present() derives result_checkerboard only from state.preview (ui/presenter.py:87-89): CURRENT, STALE, or RUNNING with a result. Nothing in the transform stage touches the preview state, and capabilities().can_render (core/state.py:743) does not require a preview at all. So:

  1. Load a video, press Render without ever pressing Preview.
  2. render_controller.artifact_ready opens the transform stage (ui/controller.py:195).
  3. The player loops the cut with state.preview still NONE, so main_window.py:280 leaves the property false and the transparent cut-out plays on flat grey.

Verified rather than reasoned: present(AppState()) returns result_checkerboard=False, and grabbing the same frames with the property forced true produces the checkerboard correctly, so the mechanism is fine and only the trigger is missing. In a background-removal tool this is the one thing the loop exists to show — you cannot tell transparent from a light background while judging the cut.

It is not a regression: before this PR the result canvas only ever showed a preview result, so preview state was the right proxy. The player breaks that assumption. The fix is presumably to drive the property from "the result canvas is showing something with alpha" rather than from the preview state, but where that belongs is your call.

Two notes

  • The Play button keeps the label "Play" while playing (ui/result_player.py:98-104). It is checkable, but the theme has no QPushButton:checked rule (only QToolButton:checked, theme.py:97), so on a flat style the pressed state is easy to miss and the button reads "Play" while the loop runs. set_cancelling() elsewhere in this app changes button text for exactly this reason.
  • The Transform panel shows "Width" and "Height" twice with nothing between the crop and resize sections (transform_group.py:269-277 adds three unlabelled section widgets). The inspector supplies the outer "Transform" title, but inside it a user sees two identical field pairs; the trim rows are the only ones that read unambiguously.

Screenshots are local, not attached; happy to paste any of them if useful.

sensitron added a commit that referenced this pull request Sep 4, 2026
Render can open the transform stage without a Preview ever having run
(render_controller.artifact_ready -> ui/controller.py:195), but
present() derives result_checkerboard only from state.preview
(ui/presenter.py:87-89), which nothing in the transform stage touches.
The player then looped a transparent cut-out over flat grey -- the one
thing this tool exists to show clearly. presenter.py and main_window.py
are frozen (guardrails-baseline.json), so the fix moves into
PreviewCanvas, which already decides how it paints itself: its
checkerboard condition is now an overridable _should_paint_checkerboard()
predicate, and ResultPlayerCanvas overrides it to also light up whenever
a player session is holding frames (self._frames is not None),
independent of PreviewState. Every other canvas keeps following the
`checkerboard` property exactly as before.

Also fixes the Play button reading "Play" while the loop plays: the
theme has no QPushButton:checked rule (only QToolButton:checked), so on
a flat style the checked state alone is easy to miss. play()/pause()
now update the button's text ("Play"/"Pause") and accessible name
together, the same way set_cancelling() already does elsewhere in this
app, through one small _sync_play_button() helper.

This file's history is already deep in fix: commits for the worker/
thread-teardown crash work (dd70069, c550de5, 248b60e, 642c1b0); these
two are distinct, observed UI defects the maintainer found on an
offscreen visual pass, not more of that hardening.

Trigger: maintainer's offscreen visual pass on PR #27 (2026-09-04).
Repro for the checkerboard defect: load a video, press Render without
ever pressing Preview, and the result loop plays its transparent
cut-out over flat grey. present(AppState()) returns
result_checkerboard=False even once the transform stage is open;
forcing the property true confirmed the painting mechanism itself was
correct and only the trigger was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sensitron added a commit that referenced this pull request Sep 4, 2026
_build_layout stacked _trim_section, _crop_section, and _resize_section
with no visible titles of their own -- the inspector only supplies the
outer "Transform" disclosure title. Inside it, crop's and resize's
Width/Height field pairs are visually identical, so nothing tells a
user which pair belongs to which operation; only the trim rows
("First frame"/"Last frame") read unambiguously.

Each section now opens with a bold QLabel heading ("Trim", "Crop",
"Resize") added as a full-width QFormLayout row, the same pattern
already used for crop_edit_checkbox. Styling reuses the codebase's
existing property-selector idiom (QLabel[secondary='true'] etc.) and
the same font-weight: 600 the disclosure headers already use for
QToolButton, rather than inventing a new visual language. A plain
QLabel takes no focus, so tab_widgets() and its focus-order test are
unchanged; a new test asserts that explicitly.

Trigger: maintainer's offscreen visual pass on PR #27 (2026-09-04) --
the Transform panel showed two identical unlabelled Width/Height pairs
with nothing distinguishing crop from resize (ui/transform_group.py:
269-277 built three unlabelled section widgets).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sensitron

Copy link
Copy Markdown
Collaborator Author

All three fixed — d9bdaf8 (checkerboard + Play button) and 5472239 (section headings). Thanks for doing the pass I could not; the checkerboard one would not have fallen out of any assertion I wrote.

The checkerboard defect is the one that mattered, and your framing of it was right: not a regression, because preview state was a fair proxy until the player broke that assumption. Both files the trigger lives in — presenter.py (179) and main_window.py (380) — are frozen by the ratchet, so the fix went where the decision belongs anyway: the canvas now decides how it paints itself. PreviewCanvas gained an overridable _should_paint_checkerboard(), and ResultPlayerCanvas overrides it to also fire whenever it is holding frames. The property mechanism is untouched for every other state, and presenter.py and main_window.py are still at exactly 179 and 380.

Guarded two ways: test_checkerboard_paints_whenever_the_player_holds_frames, and a new tests/ui/test_preview_canvas.py::test_checkerboard_predicate_follows_the_property_alone so an unrelated canvas keeps following the property exactly as before.

Play button now changes both text and accessible name between "Play" and "Pause" via _sync_play_button(), rather than relying on a :checked rule — you are right that text is the reliable signal here, and it matches set_cancelling().

Section headings: "Trim" / "Crop" / "Resize" as bold QLabel rows, using a new QLabel[heading='true'] QSS rule that reuses the existing property-selector idiom and the disclosure header's font-weight: 600. The test also asserts the headings stay out of tab_widgets() so focus order is unchanged.

On your offer of screenshots: yes please, if it is cheap — specifically the result loop with the checkerboard now lit, since that is the one I still cannot see from here.

Still open from the original list, both needing a real render rather than an offscreen grab: item 3 (7TV preset → Rebuild → file matches the readout) and item 5 (restore a cut whose crop was stored under different padding — should clamp now rather than fail silently).

On the history: the offer stands to squash the five crash-related fix: commits on transform_stage.py into one. These two UI commits are separate and observed, so I would leave them as they are. Say the word either way.

@sensitron

sensitron commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

Field test on Windows 11, 2026-09-04

Ran the transform stage by hand on a real 1920x1080 / 30 fps source with birefnet-portrait,
covering what the offscreen suite cannot see. The feature works: trim, output crop and
resize all produced the correct output file, the rebuild path reused the cut set as designed,
and the process exited cleanly with no fault report. What follows are the things that only
show up in front of a screen.

1. A trim edit is applied to the loop and then destroyed 250 ms later (P1)

TransformStageController._state_changed gates the frame reload on the whole TransformSpec
(src/matteloop/ui/transform_stage.py:316-320), and first_frame/last_frame are part of it.
The reload lands in ResultPlayerCanvas.set_frames, which clears the kept range
(src/matteloop/ui/result_player.py:129, self._kept = None). Measured against a real cut:

after the trim dispatch : playable = range(1, 2)   <- correct
after the reload lands  : playable = range(0, 2)   <- silently reverted

This contradicts the plan's contract, stated twice:
docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md:305 — "Trim never reloads:
playback slices kept_range over the caches" — and the signature comment at :719.

The reload is also pure waste: apply_transform only crops and resizes, so the rebuilt cache
is byte-for-byte what it replaced.

Repro: with "Edit crop" off, change First frame or Last frame. The loop shows the trim for
about 250 ms, then reverts to the full cut. With "Edit crop" on it sticks, because that
branch deliberately skips the reload (E33) — which is why, from the outside, the checkbox looks
like it is what "applies" the trim.

No test covers the sequence. tests/ui/test_result_player.py:122 exercises set_kept_range in
isolation and never follows it with a set_frames.

2. The result loop stops on every cache swap (P2)

set_frames calls self.pause() unconditionally (src/matteloop/ui/result_player.py:120).
Only the frames is None branch (session close) needs it, and nothing resumes playback —
play() is never called anywhere in src/.

Repro: start the loop, untick "Edit crop". The correctly cropped result appears and the loop
stops, because both arrive from the same set_frames call. Not covered by a test; the only
playback test asserts the deliberate set_presented_frame pause (E26).

3. Crop clamping shrinks but never shifts, so a stored crop can refuse a render (P2)

clamp_crop (src/matteloop/core/crop.py:73-80) keeps the origin and trims width/height. A
crop stored near the right or bottom edge while the framed frame was larger therefore collapses
below MIN_FINAL_DIMENSION:

stored:  CropSpec(x=330, y=160, width=170, height=140)   valid at framed (500, 300)
padding 40 -> 0, framed becomes (420, 220)
clamped: CropSpec(x=330, y=160, width=90, height=60)
         ValidationError: crop width 90 is below the 128 px minimum

The render aborts on a precondition the user cannot see or fix from the UI: the crop still reads
as valid in the panel, and the framed frame shrank underneath it. docs/engineering-guardrails.md
asks for a degrade here. Sliding the rectangle inward before trimming would keep it one.

The crop-at-origin case — the one most likely to be tried by hand — clamps correctly and is
covered by tests/jobs/test_transform_stage.py:90 and tests/ui/test_transform_stage.py:171.

4. The crop overlay keeps a stale framed size while "Edit crop" is on

_facts_ready (src/matteloop/ui/transform_stage.py:367-381) clamps the crop and stores the new
facts, but the overlay refresh runs from _state_changed on the clamp's own TransformChanged,
at which point self._facts is still the old value:

overlay after enable (padding 40) : source (500, 300)
-- facts (420, 220) arrive --
overlay now                       : source (500, 300), crop CropSpec(0, 0, 420, 220)

A full-frame crop then draws as an 84% x 73% sub-rect, and because _emit_drag bounds the drag
by presentation.width/height (src/matteloop/ui/crop_canvas.py:246-252), the user can drag the
crop back out past the real framed size. Toggling "Edit crop" off and on repairs it.

5. The Play/Pause button covers half the bottom-right crop handle and eats the press

The button is a layout child aligned bottom-right inside the same 16 px media inset that
build_crop_geometry uses, so both the content's corner and the button's corner are pinned to
(W-16, H-16). The crop geometry does not know the button exists. Whenever the framed aspect is
close to the canvas aspect, the corner handle lands inside the button:

canvas framed handle px2 covered 24x24 pointer target covered
430x500 420x220 0 / 64 0 / 576
430x500 256x256 32 / 64 228 / 576
380x600 220x420 32 / 64 246 / 576

A QPushButton accepts the press and does not propagate it, so CropCanvas.mousePressEvent is
never entered for those pixels — hit_test would have returned south_east. Starting a drag on
the covered half is impossible; dragging through the button once started is fine. The button is
never hidden in crop-edit mode (set_crop_edit does not touch it).

6. Spinbox arrows are 14 x 17 px, app-wide

Measured with the real theme installed:

up-button:   x=268 y=0  w=14 h=17
down-button: x=268 y=17 w=14 h=17     (spinbox 280 x 34)

src/matteloop/ui/theme.py:72-76 styles QAbstractSpinBox with a border and padding: 0 8px,
which hands sub-control geometry to the stylesheet, but defines no ::up-button / ::down-button
rule — while QComboBox::drop-down right below it does get width: 28px. The buttons also extend
to x=282 on a 280 px box, so the outer 2 px are clipped. WCAG's minimum target is 24 x 24.

This affects every spinbox in the app. It is felt in Trim because that is where single frames get
clicked through rather than typed.

7. Nothing in the UI says how a transform becomes a file

There is no copy anywhere stating that Trim/Crop/Resize are staged and that "Render Video" writes
them out — no dirty marker, no badge, no change in button text. can_render
(src/matteloop/core/state.py:743) is not conditioned on the transform being valid either, so the
button stays enabled with an invalid resize. As a first-time user I asked twice how to save.

The cost is equally invisible: a transform-only change takes the rebuild path and skips
segmentation entirely, but the user only learns that from the "Matching cut set found" dialog
after pressing. Fear of a multi-minute re-render discourages exactly the experimentation this
stage is for.

8. "Edit crop" is an unlabelled view switch

The checkbox does two things: it enables the crop handles, and it silently switches the canvas to
the untransformed frames (src/matteloop/ui/result_player.py:263-268). The second is undocumented
in the UI, so unticking it reads as "apply". Suggestion, not a demand: replace it with a visible
two-state control over the result surface ("Edit" | "Result") so the current view is always named.
An "Apply" button would be the wrong fix — next to "Render Video" it would make it less clear
which one writes the file.

9. The expensive edit is the one that does not ask, and there is no undo

The timeline range (SamplingSpec.start/end, the IN/OUT handles) is in the cut cache key
(src/matteloop/core/fingerprints.py:207-221); the Transform panel's Trim, which operates on frame
indices of the finished cut, deliberately is not. Two adjacent edits, opposite costs: seconds of
encode versus a full decode and re-segmentation.

The confirmation is on the wrong one. When a matching cut set exists, the user gets the "Matching
cut set found" dialog with Rebuild preselected. When the range changed,
find_matching_cut_workspace returns None and the full render starts with no dialog at all.
There is no undo. This happened to me during the test: an accidental nudge of the range started a
full re-render, and nothing asked or warned.

(Correction to an earlier version of this comment: I wrote that two controls are labelled "Trim".
They are not — the timeline is labelled IN/OUT and Start/End, and "Trim" appears only in the
Transform panel. The cost asymmetry above is unaffected.)

@sensitron

Copy link
Copy Markdown
Collaborator Author

Independent review by Codex (gpt-5.6-sol), branch diff against main, 2026-09-04

Run as the second opinion this repository asks for before a merge. Reproduced here unchanged; I
have not acted on any of it beyond the first item. These are findings for the maintainer to weigh,
not a to-do list I intend to work through.

One further finding from the same run is already handled and is therefore not repeated below: the
spin box arrow rules I added grew install_theme past the 60-line G6 budget, so
scripts/check_guardrails.py failed and would have blocked CI. Fixed in ed4ee69 by splitting the
stylesheet into three role-named helpers. Worth noting for everyone working here: that check is not
one of the three commands named in CLAUDE.md's verification block, so ruff, mypy and pytest all
passed while the build would have failed.

The remaining four are in code I did not touch.

  • [P1] Close the cut session when the source changes — src/matteloop/ui/transform_stage.py:300-303
    When another video is loaded, SourceLoadRequested resets the transform, but this subscriber
    retains _session, _facts, and _plan; it can interpret the reset as an edit and reload frames
    from the previous cut. Once the new source is ready, the old cut facts also keep the Transform
    controls enabled, so edits based on stale dimensions are attached to the new source request.
    Track source identity here and close/cancel the session when it changes.

  • [P1] Restore saved transforms on automatic cut reuse — src/matteloop/ui/render_controller.py:440-441
    The restore callback is invoked only for the manual workspace picker. The common Render → matching
    cut set → Rebuild path goes through _finish_reuse and uses the request captured before reading
    the sidecar; after an app restart that request has an identity transform, so the rebuild silently
    omits the remembered trim/crop/resize and then removes the sidecar as an identity result. Restore
    the matched workspace before finalizing that rebuild request, while preserving unsaved edits when
    the same cut is already open.

  • [P2] Persist the normalized transform that was encoded — src/matteloop/jobs/render.py:1426
    For an out-of-bounds stored crop, stage_encoder_frames clamps a local copy before encoding, but
    request.transform remains unchanged here. The sidecar therefore records geometry that was not
    applied to the WebP; reopening after the framed size changes can restore a different crop from the
    one last rendered. Persist the normalized transform used by the staging step.

  • [P2] Parse percentage values without binary float drift — src/matteloop/ui/transform_group.py:480
    QDoubleSpinBox supplies a binary float, and Fraction(value) preserves that approximation rather
    than the displayed decimal. This changes half-up results — for example, 12.85% of 1000 pixels is
    represented just below 128.5 and rounds to 128 instead of 129. Convert through str(value) or the
    existing decimal-safe helper before deriving dimensions.

One connection worth flagging, since it spans the two reviews: the P2 sidecar finding above and
da61a22 from my field-test fixes touch the same clamp. That commit changes clamp_crop to slide a
still-fitting crop inward rather than shrinking it into a refusal, so the difference between the
crop recorded in the sidecar and the crop actually encoded becomes larger and more visible than it
was, not smaller. Whatever is decided for the sidecar should be decided with that in mind.

sensitron added a commit that referenced this pull request Sep 4, 2026
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27). Two separate defects in this file, both reproduced
offscreen against a real cut.

Trim was applied and then destroyed. _state_changed compared the whole
TransformSpec, so a trim-only edit scheduled a debounced frame reload; that
reload calls ResultPlayerCanvas.set_frames, which cleared the kept range
set_kept_range had applied moments earlier. Measured: playable = range(1, 2)
right after the dispatch, range(0, 2) once the reload landed, so the loop
showed the trim for about 250 ms and then reverted to the full cut. The
reload was also pure waste, since apply_transform only crops and resizes and
the rebuilt cache was byte-identical to the one it replaced. It is now gated
on a crop or resize change, which is the contract the plan states twice:
"Trim never reloads: playback slices kept_range over the caches".

This also explains why the trim appeared to stick only while "Edit crop" was
ticked: that branch deliberately skips the reload (E33), so nothing wiped the
range.

The crop overlay kept a stale framed size. _facts_ready clamped the stored
crop and stored the new CutFacts, but the only overlay refresh ran from
_state_changed on the clamp's own TransformChanged, when self._facts still
held the old value -- and a framing change needing no clamp dispatched
nothing at all. Measured with padding 40 -> 0: the overlay stayed on a
500x300 source after the facts moved to 420x220, so a full-frame crop drew as
an 84% x 73% sub-rect and _emit_drag let the crop be dragged back out past
the real framed size. The overlay is now re-presented against the facts just
stored.

Tests: test_trim_only_edit_does_not_reload_frames_or_lose_the_kept_range and
test_crop_edit_overlay_follows_a_framed_size_change. Neither behaviour had
coverage -- the existing tests exercise set_kept_range in isolation, never
followed by a set_frames, and the crop-edit debounce only for crop edits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sensitron added a commit that referenced this pull request Sep 4, 2026
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27). With the loop playing, unticking "Edit crop" showed the
correctly cropped result and stopped playback at the same moment, because
both arrive from the same set_frames call. The button fell back to "Play".

set_frames called pause() unconditionally. Only the frames-is-None branch
needs it, so that the timer stops when the session closes; on a cache swap
while the session stays open there is nothing to stop, and nothing in src/
ever calls play() again -- only the user can. The two branches are now split
so a swap leaves playback running.

The kept range was reset on every call for the same reason and is now only
cleared when a session begins, so a trim applied by set_kept_range survives a
crop or resize reload. That composes with the reload gate in the transform
stage rather than papering over it: trim no longer triggers this path at all,
and this keeps the range intact on the crop/resize path that legitimately
does.

The deliberate pause in set_presented_frame is untouched -- a new Preview
image still wins over the loop (E26).

Tests: test_frame_cache_swap_while_playing_does_not_stop_the_loop and
test_frame_cache_swap_preserves_an_already_applied_kept_range. The only
existing playback test covers the E26 pause, not a cache swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sensitron added a commit that referenced this pull request Sep 4, 2026
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27), while working through the padding hard case. Measured
directly:

    stored:  CropSpec(x=330, y=160, width=170, height=140)  valid at (500, 300)
    padding 40 -> 0, framed frame becomes (420, 220)
    clamped: CropSpec(x=330, y=160, width=90, height=60)
    ValidationError: crop width 90 is below the 128 px minimum

clamp_crop pinned the origin first and trimmed width and height to whatever
was left, so a crop stored near the right or bottom edge while the framed
frame was larger collapsed below MIN_FINAL_DIMENSION and the render was
refused. The user cannot see or fix that from the UI: the crop still reads as
valid in the panel, and the framed frame shrank underneath it when they
changed padding. docs/engineering-guardrails.md asks for a degrade there.

Shrinking to fit first and clamping the origin into what remains slides a
crop that still fits, preserving the region the user chose -- the case above
now yields CropSpec(250, 80, 170, 140). A crop genuinely larger than the
frame on an axis is still shrunk on that axis, and a crop at the origin
behaves exactly as before, which is what the existing staging and controller
tests pin down.

Tests: four cases in tests/core/test_crop.py covering the off-origin slide,
oversize in one axis, oversize in both, and a crop that already fits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sensitron added a commit that referenced this pull request Sep 4, 2026
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27): the up/down arrows of the Trim spin boxes are hard to
click, and often miss. Measured with the real theme installed, a QSpinBox at
280x32:

    up-button:   x=265 y=3  w=14 h=14
    down-button: x=265 y=17 w=14 h=14

14 x 14 against WCAG's 24 x 24 minimum, and the pair previously extended two
pixels past the widget's own width.

The rule for QComboBox, QAbstractSpinBox and QLineEdit gives them a border and
padding, which hands sub-control geometry to the stylesheet, but no
::up-button / ::down-button rule ever followed -- while the combo box gets
exactly that treatment on the next line. Adding the missing pair brings the
arrows to 24 x 16 each, splitting the 32 px content height, with the right
edge landing on the widget width instead of past it. Verified that the edit
field still ends before the wider buttons begin, and that the geometry does
not shift when the focus rule thickens the border to two pixels.

This affects every spin box in the application; it is felt in Trim because
that is where single frames get clicked through rather than typed.

Test: test_spinbox_arrow_buttons_meet_minimum_target_size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sensitron added a commit that referenced this pull request Sep 4, 2026
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27). The play button is a plain layout child aligned to the
bottom-right, and build_crop_geometry centres the media in the same viewport
with the same 16 px inset, so both corners land on (W-16, H-16). Whenever the
framed aspect is close to the canvas aspect, the bottom-right handle ends up
inside the button. Measured with the real theme:

    canvas   framed    handle px2 (of 64)   24x24 pointer target (of 576)
    430x500  420x220    0                     0
    430x500  256x256   32                   228
    380x600  220x420   32                   246
    380x600  300x470   32                   288

A QPushButton accepts the press and does not propagate it, so
CropCanvas.mousePressEvent is never entered for those pixels even though
hit_test would have returned south_east: starting a drag on the covered half
is impossible, and the button paints over it, so the dead half is invisible
too. Dragging through the button once a drag has started was always fine --
Qt's implicit mouse grab keeps the moves on the canvas.

PreviewCanvas gains a _reserved_bottom_space hook returning 0.0, so the
source canvas is unaffected; _rebuild_geometry subtracts it from the viewport
it hands to build_crop_geometry, and _update_pixmap composes the media into a
widget-sized pixmap when the reservation is non-zero, since QLabel's own
AlignCenter cannot express an asymmetric bottom margin. ResultPlayerCanvas
reserves the button's height plus a gap -- and _MEDIA_INSET on top, because
pointer hit targets are expanded against the raw viewport (deliberately, so a
crop flush with the canvas edge stays draggable) and would otherwise still
reach past the button's top edge.

The reservation applies only while crop-edit is on. The handles it protects
exist only then, and reserving the space for the whole session would cost
about 13% of the result preview's height for nothing -- the area this stage
exists to show. Both edges of the toggle recompute the geometry; leaving
crop-edit passes presentation=None, which apply_presentation handles by
clearing _geometry directly rather than through _rebuild_geometry.

Tests: test_play_button_does_not_cover_the_bottom_right_crop_handle pins the
overlap at zero for a 430x500 canvas with a 256x256 frame, and
test_media_fills_the_full_viewport_when_crop_edit_is_off pins the other half
of the contract so the reservation cannot quietly become permanent again.
All five measured rows are now 0 of 64 and 0 of 576.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sensitron

Copy link
Copy Markdown
Collaborator Author

Hands-on verification of the Codex findings, Windows 11, app at ed4ee69

I ran the four findings from the review above against the running application rather than the code.
Two are confirmed with measurements, one is confirmed by arithmetic, and one does not reproduce
through the UI. One further defect turned up while testing.

[P1] Restore saved transforms on automatic cut reuse — CONFIRMED, and it destroys the record

Source diesofcringe.mp4, 440 x 444, IN/OUT untouched so the cut key would still match after a
restart (the timeline range is not persisted — preferences.py:41-65 stores fps, padding and the
output directory, but not start/end, so any other range cannot be reproduced by hand).

before restart, transform applied   384 x 128    21 frames     647 KiB
after restart, Render -> "Matching cut set found" -> Rebuild
                                    440 x 444    37 frames    3194 KiB

The rebuild reused the cut set and dropped the trim, the crop and the resize entirely. The
Transform panel showed defaults (Last frame 36, X 0 / Y 2) once the cut set was matched, not the
stored values.

The second half of the finding holds too. cuts/ now contains exactly one sidecar, and it belongs
to a different video from an earlier session:

.transform-e962789535….json   crop 633x550 @ (0,64), frames 15-16, resize 384x128 stretch

There is no .transform-*.json for the diesofcringe cut set any more, although the 19:15 render
demonstrably applied a transform and store_transform runs after every successful publish. It was
written and then removed by the identity rebuild.

So the user-visible behaviour is: settings are made once, the application stores them, and the most
natural action after a restart silently discards them while writing a 3.2 MiB file at the wrong
size. For a 7TV emote under a 7 MB limit that is a file one might upload without noticing.

[P1] Close the cut session when the source changes — CONFIRMED

With a cut open for diesofcringe.mp4 (440 x 444) and a transform set, loading a different source
(1920 x 1080) leaves the previous session fully alive:

  • the Transform controls stay enabled although the new source has no cut yet;
  • Crop still reads Width 440 / Height 444, i.e. the old source's framed size;
  • with "Edit crop" ticked, the canvas shows the previous video's frames again — the overlay is
    drawn against a cut that belongs to a file no longer loaded.

One consequence beyond the report: clamp_crop only intervenes when the crop no longer fits. A
440 x 444 crop fits comfortably inside 1920 x 1080, so it would be neither clamped nor rejected — a
render started in this state would silently cut a 440 x 444 corner out of the new video. No error,
no warning, wrong output. I did not render to confirm that last step.

[P2] Percentage rounding — CONFIRMED

12.85% of 1000 px   ->  128 (binary float, current)   vs   129 (decimal)   DIFFERS
2.35%, 0.05%        ->  identical

Only exact half-pixel cases are affected, so it is rare, but it is real.

[P2] Persist the normalized transform that was encoded — DOES NOT REPRODUCE through the UI

Padding 40, crop dragged over the whole padded frame (2000 x 1160), rebuild; then padding 0 and a
second rebuild:

testa.webp   2000 x 1160   19 frames     (padding 40, before the crop)
testb.webp   2000 x 1160   19 frames     (crop over the full frame)
testc.webp   1920 x 1080   19 frames     (padding 0, crop clamped)

sidecar after the last rebuild:
"crop": {"x": 0, "y": 0, "width": 1920, "height": 1080}

The sidecar records the clamped geometry, matching what was encoded — not the 2000 x 1160 that was
set. _clamp_current_crop in the transform-stage controller normalizes the stored crop as soon as
the new cut facts arrive, so the request handed to the job is already clamped. The job-level path
the finding points at is genuinely unguarded, but from the UI it is not reachable; it would need a
caller that does not pre-clamp, or a render started before the facts worker has recomputed.

Incidentally, testa and testb are byte-identical (same sha256), which is a nice practical
confirmation of acceptance criterion 1: a crop over the whole frame really is a no-op.

New: the mouse wheel changes values while scrolling the inspector

Scrolling the inspector with the wheel changes whatever field the pointer happens to pass over —
Aspect, the crop numbers, any spin box. QComboBox and QAbstractSpinBox accept wheel events
without focus by default, and nothing in src/matteloop/ui/ filters them: inspector.py:121 only
sets the scroll area's own focus policy, and there is no wheelEvent override anywhere.

It is worse here than in an ordinary form, because a value changed in passing is not obviously
wrong on screen and is carried into the next render. The usual remedy is to ignore wheel events on
these widgets unless they have focus.

sensitron and others added 7 commits September 4, 2026 23:18
Records the implementation plan for issue #25: TransformSpec/ResizeSpec,
the four resize mismatch modes, trim, output crop, sidecar persistence,
the inspector group and the looping result player.

The guardrail ratchet, not the feature, drives the module layout. Every
file the issue named for the core work is frozen or nearly so, so the
pixel ops, the rebuild staging, the persistence, the inspector group and
the player each land in a new module, paid for by mechanical extractions
out of render.py and inspector.py.

Persistence deviates from the issue: the transform goes into a
dot-prefixed sidecar beside the cut directory, not into CutManifest,
because the manifest rejects unknown keys and a schema bump would make
every touched cut unreadable to the previous release.

Section 9 records the engineering review's findings and the eight
corrections they produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lays the pure-data foundation for the transform stage per
docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md.

- core/errors.py: new ErrorCode.INVALID_TRANSFORM.
- core/specs.py: MismatchMode, ResizeSpec, TransformSpec (identity
  default, kept_range/select_kept, validate/validate_for split into
  _validate_range/_validate_crop/_validate_size), SizePreset and
  PLATFORM_SIZE_PRESETS (7TV 128x128/256x128/384x128).
- core/specs.py: RenderRequest gains a transform field (last field,
  identity default) and validates it.
- core/parameters.py: ParameterState gains a transform field with an
  identity default; no reducer event yet (Stage B).
- tests/core/test_specs.py: identity default, ResizeSpec axis/bounds
  rejection, validate_for range/crop/size failures and success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New core/transform.py per docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md D2/4.2.

- resolve_resize: exact-Fraction integer arithmetic for keep/stretch/
  cover/pad and the single-axis cases, dispatched from a small body
  into _single_axis/_keep/_cover/_pad, each returning a ResizePlan.
- apply_transform: identity returns the same object untouched; crop
  then resample then place, split into _crop/_resample/_place so no
  function nears the 60-line budget.
- Resample mirrors core/webp.py:1226-1236 exactly: RGBA -> premultiplied
  RGBa -> Lanczos resize -> RGBA, skipped entirely when scaled equals
  the source (resample=False).
- The intermediate scaled allocation is bounded via
  geometry._validate_allocation_budget, same ceiling framing uses.
- tests/core/test_transform.py: the four AC 4 mismatch modes on a
  128x128 red/blue frame with pixel assertions against a reference
  built from the same premultiply idiom (catches drift on the
  semi-transparent probe pixel), AC 5 single-axis, E8 (no resample at
  matching size), E16 (odd-pixel placement for cover/pad), identity
  object identity, and crop-only output size/mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
render.py is frozen at 2938 lines; task A5 needs the room. Moved
_map_output_os_error and _output_error verbatim into jobs/encoding.py
(no import cycle: encoding.py only needs core/context imports).

render.py imports both back under the same names, so all 43 call
sites are unchanged. render.py: 2938 -> 2910 lines.

tests/jobs: 564 passed, 9 pre-existing failures, 46 skipped -
identical before and after the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
render.py is frozen at 2938 lines; task A5 needs the room for the
transform pixel operations. Moved the trim-union guard, FramingPlan
construction, and the per-frame read-frame-persist loop (framing_plan
+ stage_encoder_frames) plus _persist_framed_png verbatim into a new
jobs/transform_stage.py, so the encoder and the future result player
call the same functions (design decision D2/D3).

stage_encoder_frames takes the TransformSpec parameter the full
contract calls for, but this task only implements the identity path:
it still iterates every stored frame and returns the delays
unchanged. Applying a non-identity transform is task A5.

apply_framing stays imported in render.py (still serves the preview
at :906/:920); errno stays (rollback path); only the now-unused
tempfile import is dropped. Updated the two test seams in
tests/jobs/test_render.py to patch/call transform_stage instead of
render_module.

render.py: 2910 -> 2842 lines (still 11 over-60-line functions).
transform_stage.py: 146 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_validate_size reaches core.transform through a lazy import to break
a cycle; no test exercised it. Added the resize-derived-dimension
cases (width-only and height-only, both below the 128 px floor) and
the crop-only case, confirming bounds apply to the final canvas, plus
one accepted keep-mode resize confirming the (128, 128) result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… A5)

stage_encoder_frames now validates TransformSpec against the loaded cut
(validate_for, before the framed directory is created) and stages only
transform.kept_range(frame_count). The returned delays are
transform.select_kept(delays) -- a slice of the full non-uniform
webp_delays grid, never a recomputation, since a recomputed grid over
the kept count can sum to the same total without matching term-by-term.

_stage_frame runs core.transform.apply_transform on each framed image
before persisting it; an identity spec returns the same object apply_framing
produced, so an unmodified rebuild reaches the encoder with the exact
paths and delays it always has (AC 1 byte identity).

stage_encoder_frames also registers the transform's final (post-resize,
pre-auto-fit) canvas size with the RgbaOwnershipTracker, alongside the
existing framed-size registration: once a resize changes the final
WebP dimensions, the decoded validation frames webp.py registers during
encode no longer match the framed size, so without this they would
silently escape the tracker's full-resolution-RGBA matching and undercount
ownership_peak/ownership_current. The resize's internal "scaled"
intermediate (e.g. cover's pre-crop buffer) is deliberately not threaded
through: core/transform.py's apply_transform takes no tracker parameter
by contract, that buffer never survives past the call, and it is already
bounded independently by _validate_allocation_budget.

render.py's two manifest.frame_count uses inside _encode_snapshot become
len(framed_paths), since a trim can shrink the encoded frame count below
the cut's stored frame count; the file stays at 2842 lines, its
over-60-line function count unchanged at 11.

Trigger: none (feature work, not a bug fix).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sensitron and others added 21 commits September 4, 2026 23:18
_fit_edge centred the perpendicular axis by adding an unclamped signed
offset to the existing origin, so an aspect-locked crop pinned to the
top or left edge could get a negative x/y as soon as an east/west (or
north/south) handle drag demanded a taller/wider box. CropSpec rejects
negative coordinates in its own constructor, so the ValidationError
escaped before fit_crop_aspect's later clamp_crop call ever ran.
_fit_edge now takes the source dimensions and computes a bounded,
ratio-preserving origin (_bounded_centered_origin) so the CropSpec it
builds is always valid on the first construction.

Trigger: test test_fit_crop_aspect_on_an_edge_at_the_top_clamps_instead_of_raising (tests/core/test_crop.py), which raised matteloop.core.errors.ValidationError: crop origin must be non-negative and dimensions must be positive from CropSpec(x=0, y=-3, width=6, height=12).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
discard_transform caught every OSError, including a real removal
failure, and dropped it -- the docstring's "best effort, never raises"
covered a merely-missing file but silently extended to cover a failed
unlink too. When a non-identity transform is followed by an identity
rebuild, a swallowed unlink failure leaves the stale sidecar in place,
so reopening the cut restores a transform the last render did not
apply, with no note telling the user. discard_transform now accepts
the same notes list store_transform already threads through the
non-identity write path, keeps a missing file silent, and appends a
note for any other OSError. The job still never fails on this path.

Trigger: test test_store_transform_notes_a_failed_sidecar_removal_but_still_completes (tests/jobs/test_transform_store.py), which failed with `assert 0 == 1` -- notes stayed empty after a monkeypatched unlink raised PermissionError, since Windows can't reliably deny unlink permission on files it owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stage_encoder_frames validated transform.crop as-is against the current
framed size. A crop stored while the framed size was larger — a padding
change, or a cut set promoted from a different video — raised
INVALID_TRANSFORM and hard-failed the render with no UI-visible cause,
because the equivalent UI-side clamp in restore_for only runs once
cache facts have arrived (after a prior successful render).

Clamp transform.crop with core.crop.clamp_crop against plan.output_size
immediately before validate_for, mirroring the UI-side clamp so encoded
output matches what the UI later shows. This covers rebuild, plain
render, and trim paths that a restore_for-side fix cannot reach without
changing render_controller.py's signature.

Trigger: test test_stage_encoder_frames_clamps_a_crop_stored_against_a_larger_frame — failed with "crop must be fully contained by the framed size 128x128" before the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The crop-edit presentation was refreshed only once, at toggle time:
`_state_changed` never re-applied it, so `_presentation` (and the
geometry painted from it) went stale after the first nudge or drag.
`_state_changed` now re-applies the presentation whenever the
transform changes while editing, without the frame-cache reload that
path deliberately skips.

Superseding a frame load called `thread.quit(); thread.wait(5000)`
against a worker with no cancellation check, so `quit()` could not
interrupt it and the GUI thread blocked for the rest of the in-flight
load on every ordinary crop edit. The worker now checks a
`threading.Event` between frames and unwinds early; the blocking wait
is dropped on supersede and kept only in `shutdown()`. `_cancel_frame_load`
also now bumps `_frame_generation` itself, so a load already in flight
when a session closes can no longer resurrect frames for it.

A render cancelled between encode and commit still emitted
`artifact_ready` before the cancellation checkpoint could raise,
opening a cut session for an artifact whose `RenderSucceeded` never
arrives. The emit now happens after the checkpoint/commit call.

Trigger: test test_state_changed_refreshes_the_crop_edit_presentation -- failed with presentation.crop staying (0, 0, 128, 128) after a TransformChanged dispatch instead of tracking the new crop.
Trigger: test test_superseding_a_frame_load_does_not_block_the_gui_thread -- failed, elapsed 5.0s (the full thread.wait timeout) instead of returning immediately.
Trigger: test test_a_render_cancelled_after_encode_never_publishes_an_artifact -- failed with the artifact published despite the render being cancelled before commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_schedule_facts() and _start_frame_load() kept self._thread/_worker and
self._frame_thread/_frame_worker pointing at QThreads wired with
thread.finished.connect(thread.deleteLater). When the event loop spun
(e.g. a qtbot.waitUntil) while a worker was finishing, deleteLater
destroyed the C++ QThread but the wrapper attribute still pointed at
it, so shutdown()'s _join_worker() (and _cancel_frame_load()) called
quit()/wait() on freed memory.

Add _facts_thread_finished and _frame_thread_finished, each connected
to its thread's finished signal ahead of the deleteLater connection
(Qt runs slots on one signal in connection order), clearing the
matching pair of attributes so the join methods find nothing left to
touch. Each checks sender() against the current attribute so a
superseded thread's queued finished signal can't clear a newer one
that a later _schedule_facts()/_start_frame_load() call has already
started.

Trigger: CI run 33830681783 (test (ui) job, Fatal Python error:
Segmentation fault, exit 139, on
test_open_artifact_ignored_leaves_an_existing_session_untouched);
locally reproduces as a hang on the second consecutive run of
tests/ui/test_transform_stage.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it out

open_artifact() sets the session synchronously and only schedules
_CutFactsWorker afterward, so shutdown() can land while _resolve_union is
still decoding stored frames for the trim union. QThread.quit() cannot
interrupt a running slot, so the worker kept decoding regardless of
shutdown() giving up on it.

Gives _CutFactsWorker the same cooperative-cancellation idiom
FrameLoadWorker already has (9f8de12): a threading.Event created per
generation in _schedule_facts, a cancelled predicate threaded through
_compute_facts/_resolve_union into _iter_stored_frames (union_alpha_bounds
is frozen and takes no hook, so the check lives in the frame-reading loop
around it instead), and a private _CutFactsCancelled exception to unwind
that loop. _join_worker gains the same wait: bool parameter as
_cancel_frame_load: shutdown() blocks up to 5s for the in-flight frame to
finish and the loop to notice cancellation, never longer; a superseding
_schedule_facts does not wait at all, so it cannot block the GUI thread.
A cancelled worker emits only finished, never succeeded/failed, so no
stale facts reach the controller.

Third consecutive fix: commit touching ui/transform_stage.py (after
9f8de12, b851ca2); the maintainer has been told and directed this one to
proceed because its trigger is an observed CI crash, not speculation.

Trigger: CI run 33856558458, job "test (ui)", segmentation fault (exit
139) in tests/ui/test_transform_stage.py after 62 passing tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nishes

A core-dump backtrace and a controlled devbox experiment (appending every
(thread, worker) pair to a never-cleared list drops the SIGSEGV rate from
9-10/20 to 1/20, and every remaining failure is a deadlock, not a
segfault) established the root cause: _join_worker/_cancel_frame_load
dropped the controller's only Python reference to a superseded worker at
supersede time (wait=False), before its QThread had necessarily started.
Python then frees the worker, and the thread activates `started` on an
already-collected receiver.

Add TransformStageController._retiring, a list of (QThread, QObject)
pairs. _retire() -- now the shared tail of _join_worker and
_cancel_frame_load -- quits the superseded thread and, on a non-waiting
supersede, appends the pair to _retiring instead of dropping it; it also
prunes retirees whose thread has genuinely finished via
_thread_is_finished(), which treats a RuntimeError from isFinished() (the
C++ QThread already collected) as proof the thread is done. shutdown()
waits out the current threads and every retiree, each bounded at 5s, then
clears the list -- a deadlock is one of the three observed failure modes,
so no wait is unbounded.

Kept _facts_thread_finished/_frame_thread_finished (from b851ca2): they
null the *current* thread's references the instant its own `finished`
fires, which is always safe (the thread has already stopped by then) and
orthogonal to the supersede-time bug above -- without them, the next
_retire() call could read a stale self._thread whose C++ object a prior
deleteLater had already collected.

G1 note: this is the fourth consecutive fix: commit touching this file,
past the third-consecutive stop -- proceeding because the cause is now
measured (core dump + controlled experiment), not guessed, and the
maintainer of this session has been told.

Trigger: CI run 33856558458 (job "test (ui)", SIGSEGV); devbox measurement
of tests/ui/test_transform_stage.py alone, 9-10/20 runs failing (SIGSEGV,
SIGABRT, or deadlock) before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removed both `worker.finished.connect(worker.deleteLater)` connections
in `_schedule_facts` and `_start_frame_load`. Each worker lives on its
own QThread, so that connection queued its deferred delete onto the
worker thread's own event loop -- destroying the QObject there needs
the GIL for shiboken's Python-override lookup, while the main thread
can simultaneously hold the GIL wanting a Qt-internal mutex the same
destructor takes. Lock inversion, deadlock.

Worker lifetime is now driven entirely by Python refcounting on the
main thread: `_facts_thread_finished` / `_frame_thread_finished` null
the active `self._worker`/`self._frame_worker` slot from a
thread.finished slot (always main-thread, per the existing comment),
and superseded workers already sit in `self._retiring` until pruned or
cleared by `shutdown()`, likewise main-thread-only. `thread.deleteLater`
connections are untouched -- the QThread control object was created on
the main thread, so its own deferred delete was never the problem.

G1: this is the fifth consecutive fix: commit on this file, past the
third-consecutive stop -- proceeding because each cause (the earlier
SIGSEGV class, now this deadlock) was independently measured rather
than guessed, and the maintainer has been informed.

Trigger: gdb backtrace of a hung process: main thread blocked in
QLabel::setPixmap -> ~QWidgetTextControl -> QObject::~QObject ->
QBasicMutex::lockInternal while holding the GIL; worker thread blocked
in QObject::~QObject -> Sbk_GetPyOverride -> PyGILState_Ensure waiting
on the GIL. Devbox measurement: tests/ui/test_transform_stage.py went
1/25 -> 0/25 failures, full `pytest -q tests/ui` 0/20, after removing
both deleteLater connections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Render can open the transform stage without a Preview ever having run
(render_controller.artifact_ready -> ui/controller.py:195), but
present() derives result_checkerboard only from state.preview
(ui/presenter.py:87-89), which nothing in the transform stage touches.
The player then looped a transparent cut-out over flat grey -- the one
thing this tool exists to show clearly. presenter.py and main_window.py
are frozen (guardrails-baseline.json), so the fix moves into
PreviewCanvas, which already decides how it paints itself: its
checkerboard condition is now an overridable _should_paint_checkerboard()
predicate, and ResultPlayerCanvas overrides it to also light up whenever
a player session is holding frames (self._frames is not None),
independent of PreviewState. Every other canvas keeps following the
`checkerboard` property exactly as before.

Also fixes the Play button reading "Play" while the loop plays: the
theme has no QPushButton:checked rule (only QToolButton:checked), so on
a flat style the checked state alone is easy to miss. play()/pause()
now update the button's text ("Play"/"Pause") and accessible name
together, the same way set_cancelling() already does elsewhere in this
app, through one small _sync_play_button() helper.

This file's history is already deep in fix: commits for the worker/
thread-teardown crash work (dd70069, c550de5, 248b60e, 642c1b0); these
two are distinct, observed UI defects the maintainer found on an
offscreen visual pass, not more of that hardening.

Trigger: maintainer's offscreen visual pass on PR #27 (2026-09-04).
Repro for the checkerboard defect: load a video, press Render without
ever pressing Preview, and the result loop plays its transparent
cut-out over flat grey. present(AppState()) returns
result_checkerboard=False even once the transform stage is open;
forcing the property true confirmed the painting mechanism itself was
correct and only the trigger was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_build_layout stacked _trim_section, _crop_section, and _resize_section
with no visible titles of their own -- the inspector only supplies the
outer "Transform" disclosure title. Inside it, crop's and resize's
Width/Height field pairs are visually identical, so nothing tells a
user which pair belongs to which operation; only the trim rows
("First frame"/"Last frame") read unambiguously.

Each section now opens with a bold QLabel heading ("Trim", "Crop",
"Resize") added as a full-width QFormLayout row, the same pattern
already used for crop_edit_checkbox. Styling reuses the codebase's
existing property-selector idiom (QLabel[secondary='true'] etc.) and
the same font-weight: 600 the disclosure headers already use for
QToolButton, rather than inventing a new visual language. A plain
QLabel takes no focus, so tab_widgets() and its focus-order test are
unchanged; a new test asserts that explicitly.

Trigger: maintainer's offscreen visual pass on PR #27 (2026-09-04) --
the Transform panel showed two identical unlabelled Width/Height pairs
with nothing distinguishing crop from resize (ui/transform_group.py:
269-277 built three unlabelled section widgets).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27). Two separate defects in this file, both reproduced
offscreen against a real cut.

Trim was applied and then destroyed. _state_changed compared the whole
TransformSpec, so a trim-only edit scheduled a debounced frame reload; that
reload calls ResultPlayerCanvas.set_frames, which cleared the kept range
set_kept_range had applied moments earlier. Measured: playable = range(1, 2)
right after the dispatch, range(0, 2) once the reload landed, so the loop
showed the trim for about 250 ms and then reverted to the full cut. The
reload was also pure waste, since apply_transform only crops and resizes and
the rebuilt cache was byte-identical to the one it replaced. It is now gated
on a crop or resize change, which is the contract the plan states twice:
"Trim never reloads: playback slices kept_range over the caches".

This also explains why the trim appeared to stick only while "Edit crop" was
ticked: that branch deliberately skips the reload (E33), so nothing wiped the
range.

The crop overlay kept a stale framed size. _facts_ready clamped the stored
crop and stored the new CutFacts, but the only overlay refresh ran from
_state_changed on the clamp's own TransformChanged, when self._facts still
held the old value -- and a framing change needing no clamp dispatched
nothing at all. Measured with padding 40 -> 0: the overlay stayed on a
500x300 source after the facts moved to 420x220, so a full-frame crop drew as
an 84% x 73% sub-rect and _emit_drag let the crop be dragged back out past
the real framed size. The overlay is now re-presented against the facts just
stored.

Tests: test_trim_only_edit_does_not_reload_frames_or_lose_the_kept_range and
test_crop_edit_overlay_follows_a_framed_size_change. Neither behaviour had
coverage -- the existing tests exercise set_kept_range in isolation, never
followed by a set_frames, and the crop-edit debounce only for crop edits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27). With the loop playing, unticking "Edit crop" showed the
correctly cropped result and stopped playback at the same moment, because
both arrive from the same set_frames call. The button fell back to "Play".

set_frames called pause() unconditionally. Only the frames-is-None branch
needs it, so that the timer stops when the session closes; on a cache swap
while the session stays open there is nothing to stop, and nothing in src/
ever calls play() again -- only the user can. The two branches are now split
so a swap leaves playback running.

The kept range was reset on every call for the same reason and is now only
cleared when a session begins, so a trim applied by set_kept_range survives a
crop or resize reload. That composes with the reload gate in the transform
stage rather than papering over it: trim no longer triggers this path at all,
and this keeps the range intact on the crop/resize path that legitimately
does.

The deliberate pause in set_presented_frame is untouched -- a new Preview
image still wins over the loop (E26).

Tests: test_frame_cache_swap_while_playing_does_not_stop_the_loop and
test_frame_cache_swap_preserves_an_already_applied_kept_range. The only
existing playback test covers the E26 pause, not a cache swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27), while working through the padding hard case. Measured
directly:

    stored:  CropSpec(x=330, y=160, width=170, height=140)  valid at (500, 300)
    padding 40 -> 0, framed frame becomes (420, 220)
    clamped: CropSpec(x=330, y=160, width=90, height=60)
    ValidationError: crop width 90 is below the 128 px minimum

clamp_crop pinned the origin first and trimmed width and height to whatever
was left, so a crop stored near the right or bottom edge while the framed
frame was larger collapsed below MIN_FINAL_DIMENSION and the render was
refused. The user cannot see or fix that from the UI: the crop still reads as
valid in the panel, and the framed frame shrank underneath it when they
changed padding. docs/engineering-guardrails.md asks for a degrade there.

Shrinking to fit first and clamping the origin into what remains slides a
crop that still fits, preserving the region the user chose -- the case above
now yields CropSpec(250, 80, 170, 140). A crop genuinely larger than the
frame on an axis is still shrunk on that axis, and a crop at the origin
behaves exactly as before, which is what the existing staging and controller
tests pin down.

Tests: four cases in tests/core/test_crop.py covering the off-origin slide,
oversize in one axis, oversize in both, and a crop that already fits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27): the up/down arrows of the Trim spin boxes are hard to
click, and often miss. Measured with the real theme installed, a QSpinBox at
280x32:

    up-button:   x=265 y=3  w=14 h=14
    down-button: x=265 y=17 w=14 h=14

14 x 14 against WCAG's 24 x 24 minimum, and the pair previously extended two
pixels past the widget's own width.

The rule for QComboBox, QAbstractSpinBox and QLineEdit gives them a border and
padding, which hands sub-control geometry to the stylesheet, but no
::up-button / ::down-button rule ever followed -- while the combo box gets
exactly that treatment on the next line. Adding the missing pair brings the
arrows to 24 x 16 each, splitting the 32 px content height, with the right
edge landing on the widget width instead of past it. Verified that the edit
field still ends before the wider buttons begin, and that the geometry does
not shift when the focus rule thickens the border to two pixels.

This affects every spin box in the application; it is felt in Trim because
that is where single frames get clicked through rather than typed.

Test: test_spinbox_arrow_buttons_meet_minimum_target_size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: manual field test of the transform stage on Windows, 2026-09-04
(reported in PR #27). The play button is a plain layout child aligned to the
bottom-right, and build_crop_geometry centres the media in the same viewport
with the same 16 px inset, so both corners land on (W-16, H-16). Whenever the
framed aspect is close to the canvas aspect, the bottom-right handle ends up
inside the button. Measured with the real theme:

    canvas   framed    handle px2 (of 64)   24x24 pointer target (of 576)
    430x500  420x220    0                     0
    430x500  256x256   32                   228
    380x600  220x420   32                   246
    380x600  300x470   32                   288

A QPushButton accepts the press and does not propagate it, so
CropCanvas.mousePressEvent is never entered for those pixels even though
hit_test would have returned south_east: starting a drag on the covered half
is impossible, and the button paints over it, so the dead half is invisible
too. Dragging through the button once a drag has started was always fine --
Qt's implicit mouse grab keeps the moves on the canvas.

PreviewCanvas gains a _reserved_bottom_space hook returning 0.0, so the
source canvas is unaffected; _rebuild_geometry subtracts it from the viewport
it hands to build_crop_geometry, and _update_pixmap composes the media into a
widget-sized pixmap when the reservation is non-zero, since QLabel's own
AlignCenter cannot express an asymmetric bottom margin. ResultPlayerCanvas
reserves the button's height plus a gap -- and _MEDIA_INSET on top, because
pointer hit targets are expanded against the raw viewport (deliberately, so a
crop flush with the canvas edge stays draggable) and would otherwise still
reach past the button's top edge.

The reservation applies only while crop-edit is on. The handles it protects
exist only then, and reserving the space for the whole session would cost
about 13% of the result preview's height for nothing -- the area this stage
exists to show. Both edges of the toggle recompute the geometry; leaving
crop-edit passes presentation=None, which apply_presentation handles by
clearing _geometry directly rather than through _rebuild_geometry.

Tests: test_play_button_does_not_cover_the_bottom_right_crop_handle pins the
overlap at zero for a 430x500 canvas with a 256x256 frame, and
test_media_fills_the_full_viewport_when_crop_edit_is_off pins the other half
of the contract so the reservation cannot quietly become permanent again.
All five measured rows are now 0 of 64 and 0 of 576.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: `uv run python scripts/check_guardrails.py` fails after 4da87d2:

    G6 src/matteloop/ui/theme.py: 1 function(s) exceed the 60-line budget
    - extract named helpers.

Adding the spin box arrow rules pushed install_theme from 60 to 68 lines. The
sheet grows every time a control needs a rule while the installer does not, so
the sheet is what has to move out. It is now three role-named helpers --
surfaces, controls, status -- that install_theme concatenates, none of them
near the budget, and each an obvious home for the next rule.

Reported by an independent Codex review of the branch. The check is part of CI
and is not one of the three commands named in CLAUDE.md's verification block,
which is why the earlier commit passed ruff, mypy and pytest and still would
have failed the build.

The stylesheet text is unchanged apart from the whitespace at the two seams
where it is now concatenated. Verified after the split: the arrow buttons still
measure 24 x 16, and the presentation-contract, result-player and
minimum-layout suites stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: manual field test on Windows, 2026-09-04. Scrolling the inspector
changed the value of whatever combo or spin box the pointer passed over —
silently, and the changed value went into the next render. The session that
found this also ended up at 11 fps, a 0.4 % alpha threshold and an edge mode
that cannot run (#40), none of them chosen on purpose.

Qt hands the field focus before it delivers the wheel event.
``QApplication::notify`` calls ``giveFocusAccordingToFocusPolicy`` on the
widget under the pointer, and both ``QComboBox`` and ``QAbstractSpinBox`` are
constructed with ``Qt::WheelFocus``, so the field is focused by the time any
filter or handler sees the event. Lowering the policy to ``StrongFocus``
stops that; the filter then refuses the wheel unless the field already holds
focus, leaving the event unaccepted so it reaches the scroll area exactly as
it would from a plain label. Both halves are needed — measured, each alone
still edits the value and still does not scroll.

Contract: the wheel over the inspector scrolls the inspector. A field merely
passed over is never edited and never focused. The wheel edits a field only
when that field already holds focus from a click or Tab, and then the panel
does not move. One consequence, stated because it is a real edge: scrolling
until the still-focused field returns under the pointer will edit it again.
Refusing the wheel outright is a one-line change if that turns out to annoy.

Two earlier attempts are folded into this commit rather than left standing,
because both were built on a wrong diagnosis and their messages asserted
behaviour that was never true. The first ignored the event and swallowed it,
which stopped the value changing in an offscreen test but made every field a
dead zone in the running app. The second forwarded the event to the scroll
area's viewport by hand, which measured correctly offscreen and changed
nothing on screen. Neither could work: the focus grab happens before either
of them runs.

The harness gap that hid all of this is closed too. The old tests used
``QApplication.sendEvent``, and Qt short-circuits synthesized wheel events —
"Synthesized events shouldn't propagate" — skipping both the focus grab and
the parent walk, so they measured a path real input never takes. The tests
now use ``QTest.wheelEvent``, which enters through ``QWindowSystemInterface``
like a platform event, and one of them deliberately exercises an unwrapped
spin box to prove the suite can still see the original defect.

Tests: four in tests/ui/test_compact_widgets.py pinning both directions of
the contract, a tab-order guard, the unwrapped-control fidelity check, and
test_every_inspector_field_refuses_focus_from_the_wheel in
tests/ui/test_parameter_inspector.py, which walks the real inspector and
fails if a future field skips ``compact_field``. Against the previous
implementation: 3 failed, 4 passed.

Verified at the screen after the change: scrolling moves the panel, values
stay put, and a clicked-into field still steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: reported by an independent Codex review of this branch and confirmed
by computation:

    12.85% of 1000 px  ->  128 (current)   129 (decimal-correct)   DIFFERS
    2.35%, 0.05%       ->  identical

_percent_changed built Fraction(value) / 100 from the float a QDoubleSpinBox
hands out, which preserves the binary approximation rather than the decimal
shown in the field, so half-up rounding lands on the wrong side at exact
half-pixel boundaries. Only such boundaries are affected, which is why it
survived until someone computed it, but the displayed percentage and the
computed pixel size disagreeing is precisely the quiet kind of wrong this
repository's guardrails care about.

The fix reuses fraction_from_widget_value, the decimal-safe helper the
inspector already applies to the start, end and duration spin boxes, rather
than introducing a second conversion for the same problem.

Tests: test_percent_rounds_from_the_displayed_decimal_not_the_binary_float
pins 12.85% of 1000 px to 129 and failed before the change with
assert 128 == 129; a second case pins a value that must not move. The second
test uses a 10000 px dimension because the width spin box floors at
MIN_FINAL_DIMENSION, which would otherwise clamp the result and hide the
assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: manual field test on Windows, 2026-09-04, following a Codex finding.
With a cut open for a 440 x 444 source and a transform set, loading a
1920 x 1080 source left the previous session alive: the Transform controls
stayed enabled although the new source had no cut, Crop still read
440 x 444, and ticking "Edit crop" brought the previous video's frames back
onto the canvas.

Measured offscreen before the fix: after dispatching a load of another
source, the frame reader was called twice more against the old cut --
assert 6 == 4 -- which is the field report's "the old preview reappears",
reproduced.

The controller subscribes to the whole application state but read only the
parameters, so it could not tell "the user reset the transform" from "the
user replaced the file" and processed the second as an edit. It now records
the source identity it is working against and, when that changes, runs the
existing close_session() teardown -- the same one shutdown() uses, which
nulls the session and plan, bumps the facts generation so an in-flight
worker result is discarded, and clears the player frames. No second
cancellation path was written. The framing and transform baselines are
re-derived afterwards so the next real edit still diffs correctly.

Same-source state changes never enter the branch, so the crop-edit debounce
(E33) and the two fixes in 9a3e102 and da4940f are untouched.

Two things deliberately left alone. The source identity is a per-load UUID
rather than the file, so reopening the same file also closes the session;
that is harmless because the reducer rebuilds the state from scratch anyway,
and the accompanying test therefore pins the controller's contract rather
than a user gesture. And the crop-edit flag and overlay survive the
teardown: with no frames on the canvas nothing wrong is visible, and the
next facts re-present the overlay, so there was no defect to fix there.

Tests: test_loading_a_different_source_closes_the_open_cut_session and
test_reloading_the_same_source_leaves_the_open_cut_session_intact.

Worth recording for whoever picks this file up next: this is now the third
identity check guarding one invariant -- restore_for compares cache keys,
_facts_ready compares generations, and _state_changed now compares the
source id. If a fourth is ever needed, the honest move is to let CutSession
carry the source it was opened for and validate once, rather than adding
another comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trigger: the intermittent access violation in tests/ui, so far filed under
"a known PyAV crash in roughly 3 of 25 runs". It is not PyAV. Measured on
this branch with tests/ui/test_render_controller.py run repeatedly:

    unmodified                                    1 crash / 9 runs
    with an unrelated change to the test file     8 crashes / 8 runs
    with only this teardown added                 0 crashes / 6 runs
    teardown removed again (control)              2 crashes / 4 runs

shutdown() destroyed every widget this controller owns except the job
dialog. That one deliberately outlives its job to show the completion
summary, and with no dialog parent it is a top-level QWidget held alive by
this controller's reference alone. Nothing ever closed or deleted it, so it
was left to Python's garbage collector -- meaning a QWidget could be
destroyed from inside a running event loop, at whichever unrelated moment
the next collection happened to land.

That is exactly the shape of the reports: an access violation on the main
thread inside app.exec, surfacing in a different test that had already
passed, and moving from run to run. Anything that shifts allocation timing
moves the detonation, which is why adding a single unused import to a test
file took it from 11% to 100%.

Diagnosed while working on the transform-restore fix that follows, whose
larger test module made the crash reliable enough to bisect. The controlled
runs above were done in both directions rather than trusting one green
suite: a single passing run is no signal for a fault that depends on
collection timing.

Not addressed here, and worth its own look: three times during that work a
pytest run wedged indefinitely instead of crashing, once on an otherwise
unmodified tree. It did not recur in the runs above, which is not the same
as being gone, and it is plausibly the other face of the same
shutdown-ordering area.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…se prompt

Trigger: manual field test on Windows, 2026-09-04, following a Codex finding.
A transform was set and rendered, the application restarted, the same video
loaded, Render pressed, and the "Matching cut set found" prompt answered with
Rebuild:

    before restart   384 x 128    21 frames     647 KiB
    after restart    440 x 444    37 frames    3194 KiB

The trim, crop and resize were dropped, and the sidecar holding them was then
gone from the cuts directory: the rebuild ran with an identity transform, and
store_transform removes the file for an identity result. The settings were not
merely ignored, they were destroyed, so a second attempt could not recover
them either.

_finish_reuse rebuilt with the request captured in _request_render, before the
probe had looked at the cut, so on a cold start it carried identity. The
manual workspace picker was unaffected only because _use_workspace restores
first and then re-derives the request from the store. The reuse path now does
the same through _transform_for_rebuild, and nothing else about the request
changes -- it keeps the cut inputs the probe matched.

The exception is the cut that is already open: what the inspector holds for it
is an unsaved edit, and restoring over that would be the same loss in the
other direction. The controller asks the transform stage which cut it has open
rather than tracking a copy of that rule, so the two cannot disagree; every
other cut restores, which means keeping a live edit can never write it into an
unrelated cut's sidecar.

Restoring pushed the module past the 800-line G6 budget. Rather than raising
the baseline, the three confirmation prompts -- preflight, reuse, collision --
now share one builder: they differed only in wording and in what they do with
the answer. Object names, labels, order, default and escape buttons are
unchanged, which the existing dialog tests still assert. A few smaller
repetitions went with it, leaving the module under budget.

Tests in tests/ui/test_rebuild_transform_restore.py, kept out of
test_render_controller.py so that file stays as it was: a cold start restores
the sidecar, an open cut keeps its unsaved edits, a different cut restores its
own sidecar rather than the open cut's edits, and a cut without a sidecar
stays identity. Each was verified to fail with its defect reinstated.

Left deliberately: the manual picker still restores unconditionally, so
picking a set while that same cut is open with unsaved edits discards them.
"Use this set" is an explicit act on a named cut, unlike answering a prompt,
so the two are not obviously the same case. Routing it through the same helper
is a one-line change if that judgement goes the other way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sensitron
sensitron force-pushed the feat/issue-25-transform-stage branch from f22e1a6 to 42b71b8 Compare September 4, 2026 21:22
@sensitron

Copy link
Copy Markdown
Collaborator Author

Closing note: what changed since the review comments above, and why the history was rewritten

The branch has been rebased onto current main (picking up #38) and force-pushed. Eleven commits
now sit on top of it, all from the field test and the two reviews above. CI is green, including the
windows and test (ui) jobs.

The three mouse-wheel commits are one commit

e0ceff4 and 1578461 are gone; 866a022 replaces both. That is deliberate, and the reason is
worth recording rather than hiding: both earlier commits were built on a wrong diagnosis, and their
messages asserted behaviour that was never true.

The real cause is that Qt gives the field focus before it delivers the wheel event.
QApplication::notify calls giveFocusAccordingToFocusPolicy on the widget under the pointer, and
QComboBox and QAbstractSpinBox are constructed with Qt::WheelFocus. By the time any event
filter runs, the field is already focused — so a filter conditioned on "not focused" never engages,
and no amount of ignoring or forwarding helps. Lowering the policy to StrongFocus is the half that
was missing. Measured: each half alone still edits the value and still does not scroll.

Two process notes attached to that:

  • The offscreen tests could not see any of it. They used QApplication.sendEvent, and Qt
    short-circuits synthesized wheel events ("Synthesized events shouldn't propagate"), skipping both
    the focus grab and the parent walk — so they measured a path real input never takes. The tests now
    use QTest.wheelEvent, which enters through QWindowSystemInterface, and one deliberately
    exercises an unwrapped spin box so the suite can still see the original defect.
  • Leaving three fix: commits on one file would also have tripped the guardrail in CLAUDE.md.
    Squashing was the maintainer's call, made explicitly.

The intermittent tests/ui crash was not PyAV

6882f59 fixes it. RenderController.shutdown() destroyed every widget it owns except the job
dialog, which outlives its job to show the completion summary and, with no dialog parent, was left
to Python's garbage collector — so a QWidget could be destroyed from inside a running event loop,
at whichever unrelated moment the next collection landed. Measured in both directions: 1 crash in 9
runs before, 0 in 6 after, 2 in 4 with the teardown removed again as a control. Adding a single
unused import to a test file moved it from 11% to 100%, which is what made it bisectable.

This one is older than this branch and affects main too.

Status of the Codex findings from the comment above

Finding Outcome
Restore saved transforms on automatic cut reuse Confirmed at the screen (440×444/37 frames instead of 384×128/21, sidecar deleted). Fixed in 42b71b8.
Close the cut session when the source changes Confirmed at the screen; the old cut's frames reappear on a new source. Fixed in cfe6339.
Percentage parsed from a binary float Confirmed by computation (12.85% of 1000 px → 128, should be 129). Fixed in 51fd6d6.
Persist the normalized transform that was encoded Not reproducible through the UI. _clamp_current_crop normalises the stored crop when new cut facts arrive, so the request reaching the job is already clamped; the sidecar recorded 1920×1080 matching the encoded output, not the 2000×1160 that was set. The job-level path is still unguarded for a caller that does not pre-clamp.

Deliberately not in this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transform stage on a finished cut: trim, output crop, resize, and a looping result preview

2 participants