fix(camera): apply camera_rotation to layer-timelapse frames too

Same gap as the finish-photo fix: camera_rotation was only ever wired
into the notification-snapshot path, so a layer-timelapse video came
out upside-down whenever a rotation was configured - every frame
(fresh or reused from the live view's buffer) was written to disk raw.

Extracts the rotation logic out of main.py into a shared
apply_camera_rotation(image_data, rotation, logger) in services/camera.py
(taking the rotation value directly rather than a printer object, so
both main.py's printer-shaped callers and layer_timelapse's plain int
field can use it). main.py's _apply_camera_rotation becomes a thin
compatibility wrapper so its existing call sites are unchanged.

Threads a `rotation` field through TimelapseSession/start_session,
set from printer.camera_rotation in _maybe_start_layer_timelapse, and
applies it (via asyncio.to_thread, since PIL rotation is CPU-bound) in
capture_layer before each frame is written - after #2707's
live_frame_for_capture() resolves the frame, regardless of whether it
came fresh or from the live view's buffer.

Rebased onto #2707's landed implementation: capture_layer now calls
live_frame_for_capture() instead of the older is_stream_active/
try_get_active_buffered_frame pair this was originally written
against, so the layer-timelapse tests are updated to match, and the
now-redundant TestCaptureLayerAvoidsCompetingWithLiveViewer class
(superseded by #2707's own test_external_camera_live_frame_reuse.py)
is dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carl
2026-07-30 17:34:13 +01:00
co-authored by Claude Sonnet 5
parent f475f8e015
commit 5db4c75ca0
6 changed files with 136 additions and 20 deletions
+3 -19
View File
@@ -1052,6 +1052,7 @@ def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> b
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
rotation=getattr(printer, "camera_rotation", 0),
)
logging.getLogger(__name__).info("Started layer timelapse for printer %s, archive %s", printer_id, archive_id)
return True
@@ -2321,26 +2322,9 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:
"""Apply camera rotation to snapshot image if configured."""
rotation = getattr(printer, "camera_rotation", 0)
if not rotation or rotation == 0:
return image_data
from backend.app.services.camera import apply_camera_rotation
try:
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(image_data))
# PIL rotate is counter-clockwise, so negate for clockwise rotation
img = img.rotate(-rotation, expand=True)
buf = BytesIO()
img.save(buf, format="JPEG", quality=90)
rotated = buf.getvalue()
logger.info("[SNAPSHOT] Applied %d° rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
return rotated
except Exception as e:
logger.warning("[SNAPSHOT] Failed to apply rotation: %s", e)
return image_data
return apply_camera_rotation(image_data, getattr(printer, "camera_rotation", 0), logger)
async def _send_print_start_notification(
+29
View File
@@ -836,6 +836,35 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
return False
def apply_camera_rotation(image_data: bytes, rotation: int, logger: logging.Logger) -> bytes:
"""Apply a camera_rotation value (degrees clockwise) to a captured JPEG.
Shared by every capture path that saves a still image (notification
snapshots, finish photos, layer-timelapse frames) - previously only
wired into the notification-snapshot path, which left finish photos
and timelapse videos upside-down whenever camera_rotation was set.
"""
if not rotation:
return image_data
try:
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(image_data))
# PIL rotate is counter-clockwise, so negate for clockwise rotation
img = img.rotate(-rotation, expand=True)
buf = BytesIO()
img.save(buf, format="JPEG", quality=90)
rotated = buf.getvalue()
logger.info("Applied %d° camera rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
return rotated
except Exception as e:
logger.warning("Failed to apply camera rotation: %s", e)
return image_data
async def capture_finish_photo(
printer_id: int,
ip_address: str,
+8
View File
@@ -11,6 +11,7 @@ from datetime import datetime
from pathlib import Path
from backend.app.core.config import settings
from backend.app.services.camera import apply_camera_rotation
from backend.app.services.external_camera import capture_frame
logger = logging.getLogger(__name__)
@@ -41,6 +42,7 @@ class TimelapseSession:
camera_url: str
camera_type: str
snapshot_url: str | None = None # Optional single-frame override; #1177
rotation: int = 0 # Printer's configured camera_rotation, degrees clockwise
last_layer: int = -1
frame_count: int = 0
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
@@ -88,6 +90,8 @@ class TimelapseSession:
else:
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
if frame_data:
if self.rotation:
frame_data = await asyncio.to_thread(apply_camera_rotation, frame_data, self.rotation, logger)
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
await asyncio.to_thread(frame_path.write_bytes, frame_data)
self.frame_count += 1
@@ -206,6 +210,7 @@ def start_session(
url: str,
cam_type: str,
snapshot_url: str | None = None,
rotation: int = 0,
) -> TimelapseSession:
"""Start new timelapse session for a printer.
@@ -216,6 +221,8 @@ def start_session(
cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
snapshot_url: Optional single-frame URL override; when set, layer captures
fetch from it directly instead of opening the live stream. #1177.
rotation: Printer's configured camera_rotation (degrees clockwise),
applied to every captured frame before it's saved.
Returns:
The new TimelapseSession
@@ -229,6 +236,7 @@ def start_session(
camera_url=url,
camera_type=cam_type,
snapshot_url=snapshot_url,
rotation=rotation,
)
_active_sessions[printer_id] = session
logger.info("Started timelapse session for printer %s", printer_id)
@@ -6,7 +6,7 @@ These tests cover session management and pure logic functions.
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
@@ -246,6 +246,99 @@ class TestLayerChangeLogic:
assert session.frame_count == 0 # But frame count not incremented
class TestCaptureLayerAppliesRotation:
"""camera_rotation was previously only wired into the notification-
snapshot path, so a layer-timelapse video came out upside-down whenever
the printer had a rotation configured. capture_layer now applies it to
every captured frame, whether fresh or reused from the live view's
buffer, before writing to disk."""
@pytest.mark.asyncio
async def test_rotates_fresh_capture_when_configured(self):
from backend.app.services.layer_timelapse import TimelapseSession
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
mock_settings.base_dir = Path("/tmp/test")
with patch.object(Path, "mkdir"):
session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=180)
with (
patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)),
patch(
"backend.app.services.layer_timelapse.capture_frame",
new_callable=AsyncMock,
return_value=b"\xff\xd8unrotated\xff\xd9",
),
patch(
"backend.app.services.layer_timelapse.apply_camera_rotation",
return_value=b"\xff\xd8rotated\xff\xd9",
) as mock_rotate,
patch.object(Path, "write_bytes") as mock_write,
):
result = await session.capture_layer(1)
assert result is True
mock_rotate.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9", 180, ANY)
mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9")
@pytest.mark.asyncio
async def test_rotates_buffered_frame_when_configured(self):
from backend.app.services.layer_timelapse import TimelapseSession
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
mock_settings.base_dir = Path("/tmp/test")
with patch.object(Path, "mkdir"):
session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=90)
with (
patch(
"backend.app.api.routes.camera.live_frame_for_capture",
return_value=(True, b"\xff\xd8buffered\xff\xd9"),
),
patch(
"backend.app.services.layer_timelapse.apply_camera_rotation",
return_value=b"\xff\xd8rotated\xff\xd9",
) as mock_rotate,
patch.object(Path, "write_bytes") as mock_write,
):
result = await session.capture_layer(1)
assert result is True
mock_rotate.assert_called_once_with(b"\xff\xd8buffered\xff\xd9", 90, ANY)
mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9")
@pytest.mark.asyncio
async def test_skips_rotation_when_not_configured(self):
"""Default rotation=0 - no-op, and must not even call apply_camera_rotation
(avoids the PIL decode/re-encode round trip for the common case)."""
from backend.app.services.layer_timelapse import TimelapseSession
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
mock_settings.base_dir = Path("/tmp/test")
with patch.object(Path, "mkdir"):
session = TimelapseSession(1, 100, "/dev/video1", "usb")
assert session.rotation == 0
with (
patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)),
patch(
"backend.app.services.layer_timelapse.capture_frame",
new_callable=AsyncMock,
return_value=b"\xff\xd8unrotated\xff\xd9",
),
patch("backend.app.services.layer_timelapse.apply_camera_rotation") as mock_rotate,
patch.object(Path, "write_bytes") as mock_write,
):
result = await session.capture_layer(1)
assert result is True
mock_rotate.assert_not_called()
mock_write.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9")
class TestOnLayerChange:
"""Tests for the on_layer_change callback."""
@@ -60,6 +60,7 @@ def test_starts_timelapse_when_external_camera_enabled():
"http://camera.local:5000/snapshot.jpg",
"snapshot",
snapshot_url="http://camera.local:5000/snapshot.jpg",
rotation=0,
)