Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
aebf058
docs: plan the transform stage on a finished cut
sensitron Sep 3, 2026
5360956
feat: add the transform spec contract (issue #25 task A1)
sensitron Sep 3, 2026
e2938af
feat: add the transform pixel operations module (issue #25 task A2)
sensitron Sep 3, 2026
76acb94
refactor: move output-error helpers into jobs/encoding.py
sensitron Sep 3, 2026
9c73eec
refactor: extract the framing loop into jobs/transform_stage.py
sensitron Sep 3, 2026
8f50dab
test: cover TransformSpec.validate_for's resize branch
sensitron Sep 3, 2026
7aaa383
feat: apply the transform in the shared encoder stage (issue #25 task…
sensitron Sep 3, 2026
f08ed0e
test: cover the auto-fit interplay with a resize transform (issue #25…
sensitron Sep 3, 2026
c7ec359
feat: add the sidecar that persists a cut's applied transform (issue …
sensitron Sep 3, 2026
a3cda2c
feat: record the applied transform when a cut is published (issue #25…
sensitron Sep 3, 2026
9f45311
docs: add the Transform row to the V1 scope table (issue #25 task A9)
sensitron Sep 3, 2026
ec124d0
refactor: trim transform_store.py's docstrings to the 200-line budget
sensitron Sep 4, 2026
45a1d33
feat: reduce TransformChanged in the parameter store (issue #25 task B1)
sensitron Sep 4, 2026
928cbdb
feat: forward the transform into preview, render and rebuild requests…
sensitron Sep 4, 2026
34e96c6
feat: expose the transform and last artifact in ParameterPresentation…
sensitron Sep 4, 2026
ba03c0c
feat: add the TransformGroup inspector widget (issue #25 task B4)
sensitron Sep 4, 2026
8ab8d99
feat: mount the Transform group in the inspector (issue #25 task B5)
sensitron Sep 4, 2026
98bd3b5
feat: add the cut-session controller and wire it end to end (issue #2…
sensitron Sep 4, 2026
e703993
feat: reuse CropCanvas via constrain/crop-event hooks (issue #25 task…
sensitron Sep 4, 2026
a7614d3
feat: add the looping result player and its bounded frame cache (issu…
sensitron Sep 4, 2026
c243711
feat: resolve "Use playhead" against the player's current frame (issu…
sensitron Sep 4, 2026
d5b5cfe
feat: enable "Use playhead" once the player reports a frame (issue #2…
sensitron Sep 4, 2026
5eca57a
fix: reset the transform when a new source loads
sensitron Sep 4, 2026
f209753
fix: stop resetting the aspect lock and let trim edits degrade
sensitron Sep 4, 2026
a296a3b
fix: bound an aspect-locked edge fit before constructing the crop
sensitron Sep 4, 2026
87e7192
fix: report a failed sidecar removal instead of swallowing it
sensitron Sep 4, 2026
c5e1d3a
fix: clamp an oversized stored crop before encoding a transform
sensitron Sep 4, 2026
7fd1834
fix: keep the crop-edit overlay live and stop two cancellation races
sensitron Sep 4, 2026
32b2bed
fix: clear worker/thread references before deleteLater frees them
sensitron Sep 4, 2026
d87a240
fix: make the cut-facts worker cancellable so shutdown() cannot wait …
sensitron Sep 4, 2026
1623b08
fix: keep a superseded worker/thread referenced until it genuinely fi…
sensitron Sep 4, 2026
7af24db
fix: destroy transform-stage workers on the main thread, not their own
sensitron Sep 4, 2026
3830bd7
fix: light the result loop's checkerboard without requiring a Preview
sensitron Sep 4, 2026
2587709
fix: give the Transform panel's three sections visible headings
sensitron Sep 4, 2026
b04c181
fix: keep the result loop honest about trim and a shrinking frame
sensitron Sep 4, 2026
26803d3
fix: pause the result loop only when the session actually closes
sensitron Sep 4, 2026
cc9ec01
fix: slide a stored crop inward instead of collapsing it to a refusal
sensitron Sep 4, 2026
31a93f2
fix: give the spin box arrows a target you can actually hit
sensitron Sep 4, 2026
12a1d05
fix: keep the play button off the bottom-right crop handle
sensitron Sep 4, 2026
31379bb
fix: split the stylesheet by role so install_theme fits its budget again
sensitron Sep 4, 2026
866a022
fix: scroll the inspector instead of editing the field under the pointer
sensitron Sep 4, 2026
51fd6d6
fix: derive the resize percentage from the decimal the user sees
sensitron Sep 4, 2026
cfe6339
fix: close the cut session when the source is replaced
sensitron Sep 4, 2026
6882f59
fix: destroy the job dialog on shutdown instead of leaving it to the GC
sensitron Sep 4, 2026
42b71b8
fix: restore a cut's stored transform when it is rebuilt from the reu…
sensitron Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,167 changes: 1,167 additions & 0 deletions docs/superpowers/plans/2026-09-04-issue-25-transform-stage.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/v1-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Nothing else gates V1. When that sentence is true end to end, V1 is done.
| Accessibility | Full keyboard reachability, correct tab order, accessible names and values on **standard** widgets, no colour-only status. |
| Persistence | `QSettings` primitives, window geometry, output directory. |
| Cuts | Post-segmentation cuts are persisted, editable, selectable from a promoted cut-set picker, and reusable through Rebuild. **Scope reopened 2026-08-31:** re-rendering an edited clip otherwise repeats segmentation, including a 927 MiB model and 39 inferences, merely to re-encode. |
| Transform | A finished cut can be trimmed to a frame range, cropped, and resized (explicit dimensions or a 7TV preset, with a keep/stretch/cover/pad mismatch policy) and re-encoded through Rebuild, without touching the stored cut frames or re-running segmentation. **Scope reopened 2026-09-04 (issue #25):** the transform is remembered per cut in a sidecar beside the cut directory, not in the manifest — the manifest modules are frozen and reject unknown keys, and a sidecar written inside the cut directory would invalidate the whole set for the picker. |

### Recorded design deviations

Expand Down
3 changes: 3 additions & 0 deletions src/matteloop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def _run_gui() -> int:
provider_options=provider_options,
)
controller.set_dialog_parent(window)
controller.attach_transform_stage(
window.inspector.transform_group, window.result_canvas
)
application.aboutToQuit.connect(controller.shutdown)
window.show()
return application.exec()
Expand Down
125 changes: 120 additions & 5 deletions src/matteloop/core/crop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import math
from fractions import Fraction

from matteloop.core.geometry import (
InteractionGeometry,
Expand All @@ -25,6 +26,8 @@
"west",
}
)
_CORNER_HANDLES = frozenset({"north_west", "north_east", "south_east", "south_west"})
_EDGE_HANDLES = frozenset({"north", "south", "east", "west"})


def crop_from_drag(
Expand Down Expand Up @@ -68,12 +71,23 @@ def nudge_crop(


def clamp_crop(crop: CropSpec, source_width: int, source_height: int) -> CropSpec:
"""Clamp an otherwise valid crop to a non-empty source rectangle."""
"""Clamp an otherwise valid crop to fit inside a source rectangle.

A crop that still fits at its stored size (e.g. after the framed frame
shrank underneath it -- a padding change or a cut-set switch) is *slid*
inward to stay in bounds; its width/height are preserved. Only a crop
genuinely larger than the frame in an axis is shrunk on that axis. Doing
it in this order -- shrink to fit, then clamp the origin into what
remains -- naturally slides an origin that was fine before the frame
shrank, instead of trimming a corner down to nothing (issue #25: a crop
near the right/bottom edge collapsing below MIN_FINAL_DIMENSION and the
render being refused for a precondition the user cannot see or fix).
"""
_validate_dimensions(source_width, source_height)
left = min(max(crop.x, 0), source_width - 1)
top = min(max(crop.y, 0), source_height - 1)
width = min(max(crop.width, 1), source_width - left)
height = min(max(crop.height, 1), source_height - top)
width = min(max(crop.width, 1), source_width)
height = min(max(crop.height, 1), source_height)
left = min(max(crop.x, 0), source_width - width)
top = min(max(crop.y, 0), source_height - height)
return CropSpec(left, top, width, height)


Expand Down Expand Up @@ -105,6 +119,107 @@ def oriented_point_from_widget(
return _orientation_transform_from_media(transform).source_to_widget(raw)


def fit_crop_aspect(
crop: CropSpec,
ratio: Fraction,
target: str,
*,
source_width: int,
source_height: int,
) -> CropSpec:
"""Re-fit *crop* to width:height == *ratio* after a handle/body move.

Corner handles keep the dragged corner's opposite corner fixed and
adjust whichever axis needs the smaller change to reach the ratio.
Edge handles adjust the perpendicular axis around the crop's centre.
A body move ("crop") is returned unchanged. The result is always
clamped to the source and has both dimensions >= 1.
"""
_validate_dimensions(source_width, source_height)
_validate_ratio(ratio)
if target == "crop":
fitted = crop
elif target in _CORNER_HANDLES:
fitted = _fit_corner(crop, ratio, target)
elif target in _EDGE_HANDLES:
fitted = _fit_edge(
crop, ratio, target, source_width=source_width, source_height=source_height
)
else:
raise ValueError("unknown crop interaction target")
return clamp_crop(fitted, source_width, source_height)


def centered_crop_for_aspect(
ratio: Fraction, *, source_width: int, source_height: int
) -> CropSpec:
"""Return the largest centred rectangle of *ratio* inside the source."""
_validate_dimensions(source_width, source_height)
_validate_ratio(ratio)
height_for_full_width = _rhu(Fraction(source_width) / ratio)
if height_for_full_width <= source_height:
width, height = source_width, max(1, height_for_full_width)
else:
width, height = max(1, _rhu(Fraction(source_height) * ratio)), source_height
x = (source_width - width) // 2
y = (source_height - height) // 2
return clamp_crop(CropSpec(x, y, width, height), source_width, source_height)


def _fit_corner(crop: CropSpec, ratio: Fraction, target: str) -> CropSpec:
width, height = crop.width, crop.height
width_for_height = _rhu(Fraction(height) * ratio)
height_for_width = _rhu(Fraction(width) / ratio)
if abs(width_for_height - width) <= abs(height_for_width - height):
new_width, new_height = max(1, width_for_height), height
else:
new_width, new_height = width, max(1, height_for_width)
x = crop.x + width - new_width if "west" in target else crop.x
y = crop.y + height - new_height if "north" in target else crop.y
return CropSpec(x, y, new_width, new_height)


def _fit_edge(
crop: CropSpec,
ratio: Fraction,
target: str,
*,
source_width: int,
source_height: int,
) -> CropSpec:
width, height = crop.width, crop.height
if target in {"east", "west"}:
new_height = max(1, min(source_height, _rhu(Fraction(width) / ratio)))
y = _bounded_centered_origin(crop.y, height, new_height, source_height)
return CropSpec(crop.x, y, width, new_height)
new_width = max(1, min(source_width, _rhu(Fraction(height) * ratio)))
x = _bounded_centered_origin(crop.x, width, new_width, source_width)
return CropSpec(x, crop.y, new_width, height)


def _bounded_centered_origin(
origin: int, old_size: int, new_size: int, extent: int
) -> int:
"""Re-centre a resized edge on its old midpoint, clamped inside *extent*.

Growing the perpendicular axis around the centre can otherwise place the
new origin outside [0, extent - new_size] and CropSpec would reject it
before `fit_crop_aspect`'s later clamp_crop ever runs.
"""
proposed = origin + _rhu(Fraction(old_size - new_size, 2))
return min(max(proposed, 0), max(0, extent - new_size))


def _validate_ratio(ratio: Fraction) -> None:
if not isinstance(ratio, Fraction) or ratio <= 0:
raise ValueError("aspect ratio must be a positive Fraction")


def _rhu(value: Fraction) -> int:
"""Round half up (towards +infinity) on an exact Fraction."""
return (2 * value.numerator + value.denominator) // (2 * value.denominator)


def _move_crop(
crop: CropSpec, dx: int, dy: int, source_width: int, source_height: int
) -> CropSpec:
Expand Down
1 change: 1 addition & 0 deletions src/matteloop/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ErrorCode(StrEnum):
INVALID_CROP = "invalid_crop"
INVALID_SEGMENTATION = "invalid_segmentation"
INVALID_FRAMING = "invalid_framing"
INVALID_TRANSFORM = "invalid_transform"
INVALID_OUTPUT = "invalid_output"
INVALID_RENDER_REQUEST = "invalid_render_request"
INVALID_FINAL_DIMENSIONS = "invalid_final_dimensions"
Expand Down
24 changes: 23 additions & 1 deletion src/matteloop/core/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass, replace
from dataclasses import dataclass, field, replace
from decimal import Decimal, InvalidOperation
from fractions import Fraction
from pathlib import Path
Expand All @@ -20,6 +20,7 @@
OutputSpec,
SamplingSpec,
SegmentationSpec,
TransformSpec,
is_local_path_syntax,
)

Expand Down Expand Up @@ -58,6 +59,7 @@ class ParameterState:
output_filename: str | None = None
max_mib: Decimal = Decimal("0")
execution_provider: str = CPU_EXECUTION_PROVIDER
transform: TransformSpec = field(default_factory=TransformSpec)

def __post_init__(self) -> None:
if self.model_id not in V1_MODEL_IDS:
Expand Down Expand Up @@ -141,6 +143,11 @@ class OutputMaxSizeChanged:
value: Decimal


@dataclass(frozen=True, slots=True)
class TransformChanged:
transform: TransformSpec


ParameterEvent = (
ModelChanged
| EdgeModeChanged
Expand All @@ -153,6 +160,7 @@ class OutputMaxSizeChanged:
| OutputDirectoryChanged
| OutputFilenameChanged
| OutputMaxSizeChanged
| TransformChanged
)


Expand Down Expand Up @@ -184,6 +192,8 @@ def reduce_parameters(state: AppState, event: ParameterEvent) -> AppState:
return _reduce_output_filename(state, event)
if isinstance(event, OutputMaxSizeChanged):
return _reduce_output_max_size(state, event)
if isinstance(event, TransformChanged):
return _reduce_transform(state, event)
return state


Expand Down Expand Up @@ -318,6 +328,18 @@ def _reduce_output_max_size(
return replace(state, parameters=updated)


def _reduce_transform(state: AppState, event: TransformChanged) -> AppState:
"""Replace the transform in place; a transform never stales the preview."""
if (
not isinstance(event.transform, TransformSpec)
or event.transform == state.parameters.transform
):
return state
return replace(
state, parameters=replace(state.parameters, transform=event.transform)
)


def _invalidate(
state: AppState, parameters: ParameterState, category: str
) -> AppState:
Expand Down
Loading
Loading