Explorar o código

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>
Carl hai 1 mes
pai
achega
5db4c75ca0

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
CHANGELOG.md


+ 3 - 19
backend/app/main.py

@@ -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 - 0
backend/app/services/camera.py

@@ -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 - 0
backend/app/services/layer_timelapse.py

@@ -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)

+ 94 - 1
backend/tests/unit/services/test_layer_timelapse.py

@@ -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."""
 

+ 1 - 0
backend/tests/unit/test_layer_timelapse_expected_archive.py

@@ -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,
     )
 
 

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio