Files
maziggy 432e956eff fix(camera): rotate every still exactly once, and cover the sources that let ffmpeg write the file
Review follow-ups on applying camera_rotation to finish photos and
layer-timelapse frames.

Rotating the frame popped from _stage22_finish_frames rotated one of its
sources twice. The cache has two kinds of feeder: live grabs, which are raw,
and the #1867 in-print bank, whose bytes come from
_capture_snapshot_for_notification and have already been rotated on the way
in. The consumer cannot tell them apart, so on the finish_state trigger - the
path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180
degree rotation cancelled itself out and the photo was upside-down again,
which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now
happens where each frame is captured, so every entry in the cache carries one
rotation whatever produced it, and the invariant is stated both where the
cache is declared and where it is consumed.

Two finish-photo sources were still writing unrotated files: the built-in
camera's own capture_finish_photo, and the still extracted from a
printer-recorded timelapse - which is the *preferred* source for a built-in
camera print, so a user with a rotation set got a correctly oriented photo or
not depending on which source happened to win. Neither ever holds the frame as
bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file
handles that case and is best-effort - a failed rotate leaves the unrotated
file rather than losing a delivered photo. The archived video itself is the
printer's own file and is not re-encoded, so it still plays at the camera's
native orientation; the CHANGELOG says so rather than leaving it to be
discovered.

apply_camera_rotation logs at debug, not info. It was on a path that runs once
per layer, where a tall print would have put hundreds of lines in the log for
something the surrounding capture already reports at debug.

The moved rotation logic had no test of its own - every existing test patches
it out and asserts the call, so a flipped sign or a dropped expand=True would
have shipped green. test_camera_rotation.py drives the real round trip: a
corner marker pins which way it turns, the dimensions pin that the frame is
not cropped, and an undecodable frame comes back by identity because a capture
path must not lose a frame to a failed rotate.

Tests for the fix itself sit on both sides of the cache. The producer half is
driven directly; the consumer half is a closure nested inside on_print_complete
with nothing able to reach it, so it is pinned by an AST guard - checked
against the source because the alternative is no check at all. Reverting
main.py to the pre-fix shape fails three of the five, the guard among them.

The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit
flagged (B108); they take tmp_path now.
2026-07-31 09:30:06 +02:00

147 lines
5.8 KiB
Python

"""Tests for the shared camera-rotation helpers (#2708).
Every other test of a rotating path patches ``apply_camera_rotation`` out and
asserts the call, which proves the wiring but not the rotation. These drive
the real PIL round trip, so a flipped sign or a dropped ``expand=True`` fails
here rather than shipping.
"""
import io
import logging
import pytest
from PIL import Image
from backend.app.services.camera import apply_camera_rotation, apply_camera_rotation_to_file
logger = logging.getLogger(__name__)
def _jpeg(width: int, height: int, corner: tuple[int, int, int] = (255, 0, 0)) -> bytes:
"""A JPEG with one distinctly coloured pixel block in the top-left corner,
so which way it turned is observable and not just the dimensions."""
img = Image.new("RGB", (width, height), (0, 0, 255))
for x in range(min(8, width)):
for y in range(min(8, height)):
img.putpixel((x, y), corner)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=95)
return buf.getvalue()
def _open(data: bytes) -> Image.Image:
return Image.open(io.BytesIO(data))
def _brightest_corner(img: Image.Image) -> str:
"""Which corner holds the red block, sampled a few pixels in to stay clear
of JPEG ringing at the edges."""
w, h = img.size
probes = {
"top-left": (3, 3),
"top-right": (w - 4, 3),
"bottom-left": (3, h - 4),
"bottom-right": (w - 4, h - 4),
}
return max(probes, key=lambda name: img.getpixel(probes[name])[0] - img.getpixel(probes[name])[2])
class TestApplyCameraRotation:
def test_zero_rotation_returns_the_input_object(self):
"""Not merely equal — identity. apply_camera_rotation_to_file uses this
to decide there is nothing to write back."""
src = _jpeg(64, 32)
assert apply_camera_rotation(src, 0, logger) is src
def test_90_degrees_turns_clockwise(self):
"""camera_rotation is documented as degrees *clockwise*, and PIL's
rotate() is counter-clockwise — the helper negates to compensate. A
lost negation would send the corner to bottom-right instead."""
src = _jpeg(64, 32)
assert _brightest_corner(_open(src)) == "top-left"
out = _open(apply_camera_rotation(src, 90, logger))
assert out.size == (32, 64) # expand=True, so the frame is not cropped
assert _brightest_corner(out) == "top-right"
def test_270_degrees_turns_the_other_way(self):
out = _open(apply_camera_rotation(_jpeg(64, 32), 270, logger))
assert out.size == (32, 64)
assert _brightest_corner(out) == "bottom-left"
def test_180_degrees_keeps_the_dimensions_and_flips_the_corner(self):
out = _open(apply_camera_rotation(_jpeg(64, 32), 180, logger))
assert out.size == (64, 32)
assert _brightest_corner(out) == "bottom-right"
def test_applying_180_twice_is_the_bug_that_was_fixed(self):
"""The regression this guards: two rotations cancel out and the photo
is upside-down again. Kept as a test so the invariant that
_stage22_finish_frames holds exactly one rotation has a stated reason.
"""
src = _jpeg(64, 32)
once = apply_camera_rotation(src, 180, logger)
twice = apply_camera_rotation(once, 180, logger)
assert _brightest_corner(_open(once)) == "bottom-right"
assert _brightest_corner(_open(twice)) == "top-left" # back to the original
def test_undecodable_bytes_return_unchanged(self):
"""A capture path must not lose a frame because the rotate failed —
an unrotated photo beats no photo."""
junk = b"not a jpeg at all"
assert apply_camera_rotation(junk, 90, logger) is junk
def test_a_failed_rotate_is_logged_as_a_warning(self, caplog):
with caplog.at_level(logging.WARNING, logger=__name__):
apply_camera_rotation(b"not a jpeg at all", 90, logger)
assert any("Failed to apply camera rotation" in r.message for r in caplog.records)
def test_a_successful_rotate_does_not_log_at_info(self, caplog):
"""Layer-timelapse calls this once per layer; at INFO a tall print
would bury the log."""
with caplog.at_level(logging.INFO, logger=__name__):
apply_camera_rotation(_jpeg(64, 32), 90, logger)
assert caplog.records == []
class TestApplyCameraRotationToFile:
"""The two finish-photo sources that let ffmpeg write the file and never
hold the bytes: capture_finish_photo and the timelapse last-frame extract."""
@pytest.mark.asyncio
async def test_rotates_in_place(self, tmp_path):
path = tmp_path / "finish.jpg"
path.write_bytes(_jpeg(64, 32))
await apply_camera_rotation_to_file(path, 90, logger)
out = _open(path.read_bytes())
assert out.size == (32, 64)
assert _brightest_corner(out) == "top-right"
@pytest.mark.asyncio
async def test_zero_rotation_leaves_the_file_untouched(self, tmp_path):
path = tmp_path / "finish.jpg"
original = _jpeg(64, 32)
path.write_bytes(original)
await apply_camera_rotation_to_file(path, 0, logger)
assert path.read_bytes() == original
@pytest.mark.asyncio
async def test_a_file_that_cannot_be_rotated_is_left_intact(self, tmp_path):
"""Not truncated, not deleted — the caller's unrotated photo survives."""
path = tmp_path / "finish.jpg"
path.write_bytes(b"not a jpeg at all")
await apply_camera_rotation_to_file(path, 90, logger)
assert path.read_bytes() == b"not a jpeg at all"
@pytest.mark.asyncio
async def test_a_missing_file_does_not_raise(self, tmp_path):
"""Best-effort: this runs after the capture reported success, and must
not turn a delivered photo into a failed one."""
await apply_camera_rotation_to_file(tmp_path / "gone.jpg", 90, logger)