diff --git a/src/matteloop/core/crop.py b/src/matteloop/core/crop.py index 8b4d671..3a1a4ef 100644 --- a/src/matteloop/core/crop.py +++ b/src/matteloop/core/crop.py @@ -96,12 +96,19 @@ def oriented_rect_to_source_rect( *, source_width: int, source_height: int, + display_width: int, + display_height: int, rotation: int, pixel_aspect: float, ) -> RectF: """Map oriented crop pixels into the raw coordinates used by geometry.""" transform = _orientation_transform( - source_width, source_height, rotation, pixel_aspect + source_width, + source_height, + display_width, + display_height, + rotation, + pixel_aspect, ) return transform.widget_rect_to_source( RectF(crop.x, crop.y, crop.width, crop.height) @@ -109,14 +116,20 @@ def oriented_rect_to_source_rect( def oriented_point_from_widget( - geometry: InteractionGeometry, point: PointF + geometry: InteractionGeometry, + point: PointF, + *, + display_width: int, + display_height: int, ) -> PointF: """Map a widget point through one crop geometry snapshot to oriented space.""" transform = geometry.transform if not isinstance(transform, MediaTransform): raise ValueError("crop geometry must use a media transform") raw = geometry.widget_to_source(point) - return _orientation_transform_from_media(transform).source_to_widget(raw) + return _orientation_transform_from_media( + transform, display_width=display_width, display_height=display_height + ).source_to_widget(raw) def fit_crop_aspect( @@ -252,24 +265,31 @@ def _resize_crop( def _orientation_transform( - source_width: int, source_height: int, rotation: int, pixel_aspect: float + source_width: int, + source_height: int, + display_width: int, + display_height: int, + rotation: int, + pixel_aspect: float, ) -> MediaTransform: _validate_dimensions(source_width, source_height) + _validate_dimensions(display_width, display_height) return MediaTransform( source_size=SizeF(source_width, source_height), - viewport=SizeF( - source_height if rotation in {90, 270} else source_width, - source_width * pixel_aspect if rotation in {90, 270} else source_height, - ), + viewport=SizeF(display_width, display_height), rotation=rotation, pixel_aspect=pixel_aspect, ) -def _orientation_transform_from_media(transform: MediaTransform) -> MediaTransform: +def _orientation_transform_from_media( + transform: MediaTransform, *, display_width: int, display_height: int +) -> MediaTransform: return _orientation_transform( int(transform.source_size.width), int(transform.source_size.height), + display_width, + display_height, transform.rotation, transform.pixel_aspect, ) diff --git a/src/matteloop/ui/crop_canvas.py b/src/matteloop/ui/crop_canvas.py index 75e915b..1f079e6 100644 --- a/src/matteloop/ui/crop_canvas.py +++ b/src/matteloop/ui/crop_canvas.py @@ -169,8 +169,14 @@ def mousePressEvent(self, event: QMouseEvent) -> None: return self._focused_target = target self._dragged = target - self._drag_crop = self._presentation - self._drag_start = oriented_point_from_widget(geometry, position) + presentation = self._presentation + self._drag_crop = presentation + self._drag_start = oriented_point_from_widget( + geometry, + position, + display_width=presentation.width, + display_height=presentation.height, + ) self.setFocus(Qt.FocusReason.MouseFocusReason) self._rebuild_geometry(focused=target, dragged=target) event.accept() @@ -244,7 +250,10 @@ def _emit_drag(self, position: QPointF) -> None: if geometry is None or presentation is None or self._dragged is None: return current = oriented_point_from_widget( - geometry, PointF(position.x(), position.y()) + geometry, + PointF(position.x(), position.y()), + display_width=presentation.width, + display_height=presentation.height, ) crop = crop_from_drag( presentation.crop, @@ -289,6 +298,8 @@ def _rebuild_geometry( presentation.crop, source_width=presentation.coded_width, source_height=presentation.coded_height, + display_width=presentation.width, + display_height=presentation.height, rotation=presentation.rotation, pixel_aspect=presentation.pixel_aspect, ) diff --git a/tests/core/test_crop.py b/tests/core/test_crop.py index 7851fe8..74e8694 100644 --- a/tests/core/test_crop.py +++ b/tests/core/test_crop.py @@ -2,7 +2,10 @@ from fractions import Fraction +import pytest + from matteloop.core.crop import ( + _orientation_transform, centered_crop_for_aspect, clamp_crop, crop_from_drag, @@ -13,12 +16,14 @@ ) from matteloop.core.geometry import ( CropGeometryState, + MediaTransform, PointF, RectF, SizeF, build_crop_geometry, ) from matteloop.core.specs import CropSpec +from matteloop.jobs.source import _display_dimensions def test_dragging_crop_moves_and_clamps_in_oriented_source_pixels() -> None: @@ -62,10 +67,13 @@ def test_crop_keyboard_nudge_uses_ten_source_pixels_with_shift_step() -> None: def test_oriented_crop_geometry_maps_rotation_and_pixel_aspect_once() -> None: crop = CropSpec(0, 0, 8, 8) + display_width, display_height = _display_dimensions(16, 8, Fraction(2), 270) raw_crop = oriented_rect_to_source_rect( crop, source_width=16, source_height=8, + display_width=display_width, + display_height=display_height, rotation=270, pixel_aspect=2, ) @@ -76,12 +84,129 @@ def test_oriented_crop_geometry_maps_rotation_and_pixel_aspect_once() -> None: rotation=270, pixel_aspect=2, ), - viewport=SizeF(8, 32), + viewport=SizeF(display_width, display_height), dpr=1, ) assert geometry.visual["crop"] == RectF(0, 0, 8, 8) - assert oriented_point_from_widget(geometry, PointF(0, 0)) == PointF(0, 0) + assert oriented_point_from_widget( + geometry, + PointF(0, 0), + display_width=display_width, + display_height=display_height, + ) == PointF(0, 0) + + +@pytest.mark.parametrize("rotation", [0, 90, 180, 270]) +@pytest.mark.parametrize( + ("source_width", "source_height", "pixel_aspect"), + [ + pytest.param(720, 480, Fraction(32, 27), id="below-half"), + pytest.param(640, 480, Fraction(11, 12), id="above-half"), + pytest.param(1926, 1080, Fraction(11, 12), id="exact-half"), + ], +) +def test_oriented_crop_mapping_uses_rounded_decoder_extent( + rotation: int, source_width: int, source_height: int, pixel_aspect: Fraction +) -> None: + """The canvas uses the decoder's rounded extent on both sides of .5. + + Asserting against ``_display_dimensions`` rather than written-out widths + is the point: the canvas and decoder each round the same product, and this + is the assertion that fails if either one drifts from the other. + """ + decoded = _display_dimensions( + source_width, source_height, pixel_aspect, rotation + ) + + transform = _orientation_transform( + source_width, + source_height, + decoded[0], + decoded[1], + rotation, + float(pixel_aspect), + ) + + assert transform.viewport == SizeF(*decoded) + + +@pytest.mark.parametrize("rotation", [0, 90, 180, 270]) +@pytest.mark.parametrize( + "pixel_aspect", + [Fraction(1), Fraction(2), Fraction(3, 2), Fraction(3, 4)], +) +def test_oriented_crop_mapping_round_trips_inside_source_frame( + rotation: int, pixel_aspect: Fraction +) -> None: + source_size = SizeF(16, 8) + crop = CropSpec(4, 0, 4, 8) + display_width, display_height = _display_dimensions( + 16, 8, pixel_aspect, rotation + ) + display_size = SizeF(display_width, display_height) + + mapped = oriented_rect_to_source_rect( + crop, + source_width=16, + source_height=8, + display_width=display_width, + display_height=display_height, + rotation=rotation, + pixel_aspect=float(pixel_aspect), + ) + + assert mapped.x >= 0 + assert mapped.y >= 0 + assert mapped.right <= source_size.width + assert mapped.bottom <= source_size.height + + restored = MediaTransform( + source_size=source_size, + viewport=display_size, + rotation=rotation, + pixel_aspect=float(pixel_aspect), + ).source_rect_to_widget(mapped) + + assert restored.x == pytest.approx(crop.x) + assert restored.y == pytest.approx(crop.y) + assert restored.width == pytest.approx(crop.width) + assert restored.height == pytest.approx(crop.height) + + +@pytest.mark.parametrize("rotation", [0, 90, 180, 270]) +@pytest.mark.parametrize( + "pixel_aspect", + [Fraction(1), Fraction(2), Fraction(3, 2), Fraction(3, 4)], +) +def test_oriented_point_mapping_round_trips_through_display_geometry( + rotation: int, pixel_aspect: Fraction +) -> None: + source_size = SizeF(16, 8) + display_width, display_height = _display_dimensions( + 16, 8, pixel_aspect, rotation + ) + display_size = SizeF(display_width, display_height) + geometry = build_crop_geometry( + state=CropGeometryState( + source_size=source_size, + crop=RectF(0, 0, 8, 8), + rotation=rotation, + pixel_aspect=float(pixel_aspect), + ), + viewport=display_size, + dpr=1, + ) + + restored = oriented_point_from_widget( + geometry, + PointF(2, 3), + display_width=display_width, + display_height=display_height, + ) + + assert restored.x == pytest.approx(2) + assert restored.y == pytest.approx(3) def test_fit_crop_aspect_on_a_corner_keeps_the_opposite_corner_fixed() -> None: @@ -209,3 +334,19 @@ def test_clamp_crop_leaves_a_crop_that_already_fits_unchanged() -> None: crop = CropSpec(5, 5, 50, 50) assert clamp_crop(crop, 100, 100) == crop + + +def test_canvas_extent_uses_the_decoder_for_an_arbitrary_declared_ratio() -> None: + pixel_aspect = Fraction(18333, 20000) + decoded = _display_dimensions(1926, 1080, pixel_aspect, 0) + + transform = _orientation_transform( + 1926, + 1080, + decoded[0], + decoded[1], + 0, + float(pixel_aspect), + ) + + assert transform.viewport == SizeF(*decoded)