Browse Source

feat(camera): optional snapshot URL override for external cameras (#1177)

  go2rtc and several IP cameras still emit a warm-up / black frame on every
  fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
  slipped through intermittently for @nkm8's setup. His own bisect named
  the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
  single-frame endpoint that never returns the encoder's stale keyframe.

  Adds an optional external_camera_snapshot_url column on printers. When
  set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
  notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
  Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
  on the override URL via plain HTTP GET, bypassing the warm-up dance.

  Live view stays on the configured stream URL — only single-frame
  captures use the override. Override is camera-type-agnostic. SSRF guard
  applies (existing _sanitize_camera_url allowlist). Empty string treated
  as unset.

  Settings UI: new "Snapshot URL (optional)" input + Test button under
  External Cameras, hidden for camera_type=snapshot since the live URL is
  already a single-frame source. en + de fully translated; 6 other locales
  seeded with English copy.

  5 backend tests pin the routing contract; 3 frontend tests pin the
  input + debounced PATCH. Documented in
  bambuddy-wiki/docs/features/camera.md with the go2rtc example.
maziggy 3 weeks ago
parent
commit
abc8e97050

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 8 - 1
backend/app/api/routes/camera.py

@@ -792,7 +792,12 @@ async def camera_snapshot(
     if printer.external_camera_enabled and printer.external_camera_url:
     if printer.external_camera_enabled and printer.external_camera_url:
         from backend.app.services.external_camera import capture_frame
         from backend.app.services.external_camera import capture_frame
 
 
-        frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)
+        frame_data = await capture_frame(
+            printer.external_camera_url,
+            printer.external_camera_type,
+            timeout=15,
+            snapshot_url=printer.external_camera_snapshot_url,
+        )
         if not frame_data:
         if not frame_data:
             raise HTTPException(
             raise HTTPException(
                 status_code=503,
                 status_code=503,
@@ -1040,6 +1045,7 @@ async def check_plate_empty(
         external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
         external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
         use_external=use_external,
         use_external=use_external,
         roi=roi,
         roi=roi,
+        external_camera_snapshot_url=printer.external_camera_snapshot_url if printer.external_camera_enabled else None,
     )
     )
 
 
     # Get reference count for the response
     # Get reference count for the response
@@ -1124,6 +1130,7 @@ async def calibrate_plate_detection(
         external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,
         external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,
         external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
         external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
         use_external=use_external,
         use_external=use_external,
+        external_camera_snapshot_url=printer.external_camera_snapshot_url if printer.external_camera_enabled else None,
     )
     )
 
 
     if light_warning and success:
     if light_warning and success:

+ 1 - 0
backend/app/core/database.py

@@ -905,6 +905,7 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)")
     await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)")
     await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0")
+    await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_snapshot_url VARCHAR(500)")
 
 
     # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
     # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)")

+ 11 - 2
backend/app/main.py

@@ -1346,7 +1346,11 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
             logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id)
             logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id)
             from backend.app.services.external_camera import capture_frame
             from backend.app.services.external_camera import capture_frame
 
 
-            frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
+            frame_data = await capture_frame(
+                printer.external_camera_url,
+                printer.external_camera_type or "mjpeg",
+                snapshot_url=printer.external_camera_snapshot_url,
+            )
             if frame_data and len(frame_data) <= 2_500_000:
             if frame_data and len(frame_data) <= 2_500_000:
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
                 return _apply_camera_rotation(frame_data, printer, logger)
                 return _apply_camera_rotation(frame_data, printer, logger)
@@ -1616,6 +1620,7 @@ async def on_print_start(printer_id: int, data: dict):
                     external_camera_type=printer.external_camera_type,
                     external_camera_type=printer.external_camera_type,
                     use_external=printer.external_camera_enabled,
                     use_external=printer.external_camera_enabled,
                     roi=roi,
                     roi=roi,
+                    external_camera_snapshot_url=printer.external_camera_snapshot_url,
                 )
                 )
 
 
                 # Restore chamber light to original state
                 # Restore chamber light to original state
@@ -2201,6 +2206,7 @@ async def on_print_start(printer_id: int, data: dict):
                         fallback_archive.id,
                         fallback_archive.id,
                         printer.external_camera_url,
                         printer.external_camera_url,
                         printer.external_camera_type or "mjpeg",
                         printer.external_camera_type or "mjpeg",
+                        snapshot_url=printer.external_camera_snapshot_url,
                     )
                     )
                     logger.info("Started layer timelapse for printer %s, archive %s", printer_id, fallback_archive.id)
                     logger.info("Started layer timelapse for printer %s, archive %s", printer_id, fallback_archive.id)
 
 
@@ -2290,6 +2296,7 @@ async def on_print_start(printer_id: int, data: dict):
                         archive.id,
                         archive.id,
                         printer.external_camera_url,
                         printer.external_camera_url,
                         printer.external_camera_type or "mjpeg",
                         printer.external_camera_type or "mjpeg",
+                        snapshot_url=printer.external_camera_snapshot_url,
                     )
                     )
                     logger.info("Started layer timelapse for printer %s, archive %s", printer_id, archive.id)
                     logger.info("Started layer timelapse for printer %s, archive %s", printer_id, archive.id)
 
 
@@ -3332,7 +3339,9 @@ async def on_print_complete(printer_id: int, data: dict):
                                 from backend.app.services.external_camera import capture_frame
                                 from backend.app.services.external_camera import capture_frame
 
 
                                 frame_data = await capture_frame(
                                 frame_data = await capture_frame(
-                                    printer.external_camera_url, printer.external_camera_type or "mjpeg"
+                                    printer.external_camera_url,
+                                    printer.external_camera_type or "mjpeg",
+                                    snapshot_url=printer.external_camera_snapshot_url,
                                 )
                                 )
                                 if frame_data:
                                 if frame_data:
                                     photos_dir = archive_dir / "photos"
                                     photos_dir = archive_dir / "photos"

+ 5 - 0
backend/app/models/printer.py

@@ -28,6 +28,11 @@ class Printer(Base):
     external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True)  # mjpeg, rtsp, snapshot
     external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True)  # mjpeg, rtsp, snapshot
     external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Optional single-frame snapshot URL — when set, used for snapshot / finish-photo
+    # / timelapse / plate-detect captures instead of opening the live stream and
+    # skipping a warm-up frame. Bypasses MJPEG warm-up issues on sources that
+    # expose a dedicated frame endpoint (e.g. go2rtc's /api/frame.jpeg). #1177.
+    external_camera_snapshot_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     # Plate detection - check if build plate is empty before starting print
     # Plate detection - check if build plate is empty before starting print
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)

+ 4 - 0
backend/app/schemas/printer.py

@@ -18,6 +18,7 @@ class PrinterBase(BaseModel):
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None  # "mjpeg", "rtsp", "snapshot", "usb"
     external_camera_type: str | None = None  # "mjpeg", "rtsp", "snapshot", "usb"
     external_camera_enabled: bool = False
     external_camera_enabled: bool = False
+    external_camera_snapshot_url: str | None = None  # Optional single-frame override; #1177
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
 
 
 
 
@@ -50,6 +51,7 @@ class PrinterUpdate(BaseModel):
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool | None = None
     external_camera_enabled: bool | None = None
+    external_camera_snapshot_url: str | None = None  # #1177
     camera_rotation: int | None = None  # 0, 90, 180, 270 degrees
     camera_rotation: int | None = None  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool | None = None
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
@@ -63,6 +65,7 @@ class PrinterResponse(PrinterBase):
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool = False
     external_camera_enabled: bool = False
+    external_camera_snapshot_url: str | None = None  # #1177
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool = False
     plate_detection_enabled: bool = False
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
@@ -87,6 +90,7 @@ class PrinterResponse(PrinterBase):
             "external_camera_url": printer.external_camera_url,
             "external_camera_url": printer.external_camera_url,
             "external_camera_type": printer.external_camera_type,
             "external_camera_type": printer.external_camera_type,
             "external_camera_enabled": printer.external_camera_enabled,
             "external_camera_enabled": printer.external_camera_enabled,
+            "external_camera_snapshot_url": printer.external_camera_snapshot_url,
             "camera_rotation": printer.camera_rotation,
             "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
             "nozzle_count": printer.nozzle_count,

+ 17 - 4
backend/app/services/external_camera.py

@@ -173,17 +173,30 @@ def get_ffmpeg_path() -> str | None:
     return None
     return None
 
 
 
 
-async def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes | None:
+async def capture_frame(
+    url: str,
+    camera_type: str,
+    timeout: int = 15,
+    snapshot_url: str | None = None,
+) -> bytes | None:
     """Capture single frame from external camera.
     """Capture single frame from external camera.
 
 
     Args:
     Args:
-        url: Camera URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path)
-        camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
-        timeout: Connection timeout in seconds
+        url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
+        camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
+        timeout: Connection timeout in seconds.
+        snapshot_url: Optional override for single-frame capture. When set, fetched
+            via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
+            handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
+            `/api/frame.jpeg` reliably returns a clean image while the MJPEG stream's
+            first frame is often the encoder's stale keyframe). #1177.
 
 
     Returns:
     Returns:
         JPEG bytes or None on failure
         JPEG bytes or None on failure
     """
     """
+    if snapshot_url:
+        logger.debug("capture_frame using snapshot override url=%s...", snapshot_url[:50])
+        return await _capture_snapshot(snapshot_url, timeout)
     logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
     logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
     if camera_type == "mjpeg":
     if camera_type == "mjpeg":
         return await _capture_mjpeg_frame(url, timeout)
         return await _capture_mjpeg_frame(url, timeout)

+ 12 - 2
backend/app/services/layer_timelapse.py

@@ -40,6 +40,7 @@ class TimelapseSession:
     archive_id: int | None
     archive_id: int | None
     camera_url: str
     camera_url: str
     camera_type: str
     camera_type: str
+    snapshot_url: str | None = None  # Optional single-frame override; #1177
     last_layer: int = -1
     last_layer: int = -1
     frame_count: int = 0
     frame_count: int = 0
     session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
     session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
@@ -66,7 +67,7 @@ class TimelapseSession:
         self.last_layer = layer_num
         self.last_layer = layer_num
 
 
         try:
         try:
-            frame_data = await capture_frame(self.camera_url, self.camera_type)
+            frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
             if frame_data:
             if frame_data:
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)
@@ -180,7 +181,13 @@ class TimelapseSession:
             logger.warning("Failed to cleanup timelapse frames: %s", e)
             logger.warning("Failed to cleanup timelapse frames: %s", e)
 
 
 
 
-def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: str) -> TimelapseSession:
+def start_session(
+    printer_id: int,
+    archive_id: int | None,
+    url: str,
+    cam_type: str,
+    snapshot_url: str | None = None,
+) -> TimelapseSession:
     """Start new timelapse session for a printer.
     """Start new timelapse session for a printer.
 
 
     Args:
     Args:
@@ -188,6 +195,8 @@ def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: s
         archive_id: Associated print archive ID (optional)
         archive_id: Associated print archive ID (optional)
         url: External camera URL
         url: External camera URL
         cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
         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.
 
 
     Returns:
     Returns:
         The new TimelapseSession
         The new TimelapseSession
@@ -200,6 +209,7 @@ def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: s
         archive_id=archive_id,
         archive_id=archive_id,
         camera_url=url,
         camera_url=url,
         camera_type=cam_type,
         camera_type=cam_type,
+        snapshot_url=snapshot_url,
     )
     )
     _active_sessions[printer_id] = session
     _active_sessions[printer_id] = session
     logger.info("Started timelapse session for printer %s", printer_id)
     logger.info("Started timelapse session for printer %s", printer_id)

+ 1 - 0
backend/app/services/obico_detection.py

@@ -197,6 +197,7 @@ class ObicoDetectionService:
                 printer.external_camera_url,
                 printer.external_camera_url,
                 printer.external_camera_type,
                 printer.external_camera_type,
                 timeout=SNAPSHOT_CAPTURE_TIMEOUT,
                 timeout=SNAPSHOT_CAPTURE_TIMEOUT,
+                snapshot_url=printer.external_camera_snapshot_url,
             )
             )
         return await capture_camera_frame_bytes(
         return await capture_camera_frame_bytes(
             ip_address=printer.ip_address,
             ip_address=printer.ip_address,

+ 24 - 3
backend/app/services/plate_detection.py

@@ -588,6 +588,7 @@ async def capture_camera_image(
     external_camera_url: str | None = None,
     external_camera_url: str | None = None,
     external_camera_type: str | None = None,
     external_camera_type: str | None = None,
     use_external: bool = False,
     use_external: bool = False,
+    external_camera_snapshot_url: str | None = None,
 ) -> tuple[bytes | None, str]:
 ) -> tuple[bytes | None, str]:
     """Capture an image from the printer camera.
     """Capture an image from the printer camera.
 
 
@@ -605,7 +606,11 @@ async def capture_camera_image(
         try:
         try:
             from backend.app.services.external_camera import capture_frame
             from backend.app.services.external_camera import capture_frame
 
 
-            image_data = await capture_frame(external_camera_url, external_camera_type)
+            image_data = await capture_frame(
+                external_camera_url,
+                external_camera_type,
+                snapshot_url=external_camera_snapshot_url,
+            )
             if image_data:
             if image_data:
                 camera_source = "external"
                 camera_source = "external"
                 logger.debug("Captured frame from external camera for printer %s", printer_id)
                 logger.debug("Captured frame from external camera for printer %s", printer_id)
@@ -665,6 +670,7 @@ async def check_plate_empty(
     external_camera_type: str | None = None,
     external_camera_type: str | None = None,
     use_external: bool = False,
     use_external: bool = False,
     roi: tuple[float, float, float, float] | None = None,
     roi: tuple[float, float, float, float] | None = None,
+    external_camera_snapshot_url: str | None = None,
 ) -> PlateDetectionResult:
 ) -> PlateDetectionResult:
     """Check if the build plate is empty for a printer.
     """Check if the build plate is empty for a printer.
 
 
@@ -692,7 +698,14 @@ async def check_plate_empty(
         )
         )
 
 
     image_data, camera_source = await capture_camera_image(
     image_data, camera_source = await capture_camera_image(
-        printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external
+        printer_id,
+        ip_address,
+        access_code,
+        model,
+        external_camera_url,
+        external_camera_type,
+        use_external,
+        external_camera_snapshot_url=external_camera_snapshot_url,
     )
     )
 
 
     if image_data is None:
     if image_data is None:
@@ -722,6 +735,7 @@ async def calibrate_plate(
     external_camera_url: str | None = None,
     external_camera_url: str | None = None,
     external_camera_type: str | None = None,
     external_camera_type: str | None = None,
     use_external: bool = False,
     use_external: bool = False,
+    external_camera_snapshot_url: str | None = None,
 ) -> tuple[bool, str, int]:
 ) -> tuple[bool, str, int]:
     """Calibrate plate detection by capturing a reference image of the empty plate.
     """Calibrate plate detection by capturing a reference image of the empty plate.
 
 
@@ -742,7 +756,14 @@ async def calibrate_plate(
         return False, "OpenCV not available - plate detection disabled", -1
         return False, "OpenCV not available - plate detection disabled", -1
 
 
     image_data, camera_source = await capture_camera_image(
     image_data, camera_source = await capture_camera_image(
-        printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external
+        printer_id,
+        ip_address,
+        access_code,
+        model,
+        external_camera_url,
+        external_camera_type,
+        use_external,
+        external_camera_snapshot_url=external_camera_snapshot_url,
     )
     )
 
 
     if image_data is None:
     if image_data is None:

+ 141 - 0
backend/tests/unit/services/test_external_camera.py

@@ -372,6 +372,147 @@ class TestCameraTypeValidation:
             assert result is None
             assert result is None
 
 
 
 
+class TestSnapshotUrlOverride:
+    """#1177 follow-up. When ``external_camera_snapshot_url`` is set on the
+    printer, every single-frame capture (notification thumbnail, finish photo,
+    timelapse, plate-detect) must route through the plain HTTP-GET path on the
+    snapshot URL instead of opening the live stream and skipping a warm-up
+    frame. Sources that expose a dedicated frame endpoint (e.g. go2rtc's
+    ``/api/frame.jpeg``) reliably return a clean image — the warm-up dance is
+    only required for sources that don't, and bypassing it removes the
+    inconsistency the reporter still saw after the warm-up fix landed."""
+
+    @pytest.mark.asyncio
+    async def test_snapshot_override_routes_to_snapshot_path(self):
+        from unittest.mock import AsyncMock
+
+        with (
+            patch(
+                "backend.app.services.external_camera._capture_snapshot",
+                new=AsyncMock(return_value=b"\xff\xd8snapshot\xff\xd9"),
+            ) as mocked_snapshot,
+            patch(
+                "backend.app.services.external_camera._capture_mjpeg_frame",
+                new=AsyncMock(return_value=b"should-not-be-called"),
+            ) as mocked_mjpeg,
+        ):
+            from backend.app.services.external_camera import capture_frame
+
+            result = await capture_frame(
+                "http://192.168.1.61:1984/api/stream.mjpeg",
+                "mjpeg",
+                snapshot_url="http://192.168.1.61:1984/api/frame.jpeg",
+            )
+
+        assert result == b"\xff\xd8snapshot\xff\xd9"
+        mocked_snapshot.assert_awaited_once()
+        # First positional arg is the snapshot URL; the live-stream URL is ignored.
+        assert mocked_snapshot.await_args.args[0] == "http://192.168.1.61:1984/api/frame.jpeg"
+        mocked_mjpeg.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_no_snapshot_override_routes_to_camera_type_handler(self):
+        from unittest.mock import AsyncMock
+
+        with (
+            patch(
+                "backend.app.services.external_camera._capture_snapshot",
+                new=AsyncMock(return_value=b"should-not-be-called"),
+            ) as mocked_snapshot,
+            patch(
+                "backend.app.services.external_camera._capture_mjpeg_frame",
+                new=AsyncMock(return_value=b"\xff\xd8live\xff\xd9"),
+            ) as mocked_mjpeg,
+        ):
+            from backend.app.services.external_camera import capture_frame
+
+            result = await capture_frame("http://192.168.1.61:1984/api/stream.mjpeg", "mjpeg")
+
+        assert result == b"\xff\xd8live\xff\xd9"
+        mocked_mjpeg.assert_awaited_once()
+        mocked_snapshot.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_empty_string_snapshot_url_treated_as_unset(self):
+        """Falsy snapshot_url (empty string from a cleared input) must NOT
+        hijack the live-stream path — the form-cleared input becomes ``None``
+        in the DB, but a defence-in-depth empty-string guard means a stale
+        config row still uses the live stream rather than firing GET ''."""
+        from unittest.mock import AsyncMock
+
+        with (
+            patch(
+                "backend.app.services.external_camera._capture_snapshot",
+                new=AsyncMock(return_value=b"should-not-be-called"),
+            ) as mocked_snapshot,
+            patch(
+                "backend.app.services.external_camera._capture_mjpeg_frame",
+                new=AsyncMock(return_value=b"\xff\xd8live\xff\xd9"),
+            ) as mocked_mjpeg,
+        ):
+            from backend.app.services.external_camera import capture_frame
+
+            result = await capture_frame(
+                "http://192.168.1.61:1984/api/stream.mjpeg",
+                "mjpeg",
+                snapshot_url="",
+            )
+
+        assert result == b"\xff\xd8live\xff\xd9"
+        mocked_mjpeg.assert_awaited_once()
+        mocked_snapshot.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_snapshot_override_honours_ssrf_guard(self):
+        """The override goes through ``_capture_snapshot`` which already
+        sanitises the URL — link-local / metadata / blocked-host targets
+        return None instead of being fetched."""
+        from backend.app.services.external_camera import capture_frame
+
+        result = await capture_frame(
+            "http://192.168.1.61:1984/api/stream.mjpeg",
+            "mjpeg",
+            snapshot_url="http://169.254.169.254/latest/meta-data/",
+        )
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_snapshot_override_works_for_rtsp_and_usb_camera_types(self):
+        """The override is camera-type agnostic: a user with an RTSP or USB
+        stream paired with a separate HTTP snapshot endpoint (e.g. go2rtc
+        feeding a USB cam, exposing both /api/stream.mjpeg and
+        /api/frame.jpeg) gets clean snapshots without spinning up ffmpeg."""
+        from unittest.mock import AsyncMock
+
+        for camera_type in ("rtsp", "usb"):
+            with (
+                patch(
+                    "backend.app.services.external_camera._capture_snapshot",
+                    new=AsyncMock(return_value=b"\xff\xd8snap\xff\xd9"),
+                ) as mocked_snapshot,
+                patch(
+                    "backend.app.services.external_camera._capture_rtsp_frame",
+                    new=AsyncMock(return_value=b"should-not-be-called"),
+                ) as mocked_rtsp,
+                patch(
+                    "backend.app.services.external_camera._capture_usb_frame",
+                    new=AsyncMock(return_value=b"should-not-be-called"),
+                ) as mocked_usb,
+            ):
+                from backend.app.services.external_camera import capture_frame
+
+                result = await capture_frame(
+                    "rtsp://printer/stream" if camera_type == "rtsp" else "/dev/video0",
+                    camera_type,
+                    snapshot_url="http://192.168.1.61:1984/api/frame.jpeg",
+                )
+
+            assert result == b"\xff\xd8snap\xff\xd9", f"camera_type={camera_type}"
+            mocked_snapshot.assert_awaited_once()
+            mocked_rtsp.assert_not_awaited()
+            mocked_usb.assert_not_awaited()
+
+
 class TestRtspUrlHandling:
 class TestRtspUrlHandling:
     """Tests for RTSP/RTSPS URL handling."""
     """Tests for RTSP/RTSPS URL handling."""
 
 

+ 91 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -717,4 +717,95 @@ describe('SettingsPage', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('external camera snapshot URL override (#1177)', () => {
+    /**
+     * The snapshot URL input only appears for stream camera types where the
+     * MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
+     * snapshot sources don't need an override since their stream URL is
+     * already a single-frame endpoint.
+     */
+    const mjpegPrinter = {
+      id: 7,
+      name: 'go2rtc Cam',
+      serial_number: 'TEST123',
+      ip_address: '192.168.1.100',
+      access_code: 'XXXX',
+      model: 'P1S',
+      location: null,
+      nozzle_count: 1,
+      is_active: true,
+      auto_archive: true,
+      external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
+      external_camera_type: 'mjpeg',
+      external_camera_enabled: true,
+      external_camera_snapshot_url: null,
+      camera_rotation: 0,
+      plate_detection_enabled: false,
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    };
+
+    it('renders the snapshot URL input when camera_type is mjpeg', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
+      );
+
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
+      });
+    });
+
+    it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () =>
+          HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
+        ),
+      );
+
+      render(<SettingsPage />);
+
+      // Wait for the live-stream URL placeholder to render so we know the
+      // camera section finished mounting before asserting absence of the
+      // snapshot input below.
+      await waitFor(() => {
+        expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
+      });
+      expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
+    });
+
+    it('PATCHes the printer with external_camera_snapshot_url when the user types into the input', async () => {
+      let receivedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
+        http.patch('/api/v1/printers/7', async ({ request }) => {
+          receivedBody = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
+        }),
+      );
+
+      render(<SettingsPage />);
+
+      const input = await waitFor(() =>
+        screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
+      );
+
+      const user = userEvent.setup();
+      await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
+
+      // Save is debounced by 800ms; assert the PATCH eventually fires with
+      // the typed snapshot URL.
+      await waitFor(
+        () => {
+          expect(receivedBody).not.toBeNull();
+          expect(receivedBody!.external_camera_snapshot_url).toBe(
+            'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+          );
+        },
+        { timeout: 3000 },
+      );
+    });
+  });
 });
 });

+ 2 - 0
frontend/src/api/client.ts

@@ -152,6 +152,7 @@ export interface Printer {
   external_camera_url: string | null;
   external_camera_url: string | null;
   external_camera_type: string | null;  // "mjpeg", "rtsp", "snapshot"
   external_camera_type: string | null;  // "mjpeg", "rtsp", "snapshot"
   external_camera_enabled: boolean;
   external_camera_enabled: boolean;
+  external_camera_snapshot_url: string | null;  // optional single-frame override (#1177)
   camera_rotation: number;  // 0, 90, 180, 270 degrees
   camera_rotation: number;  // 0, 90, 180, 270 degrees
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
@@ -351,6 +352,7 @@ export interface PrinterCreate {
   external_camera_url?: string | null;
   external_camera_url?: string | null;
   external_camera_type?: string | null;
   external_camera_type?: string | null;
   external_camera_enabled?: boolean;
   external_camera_enabled?: boolean;
+  external_camera_snapshot_url?: string | null;
   camera_rotation?: number;
   camera_rotation?: number;
   plate_detection_enabled?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
   plate_detection_roi?: PlateDetectionROI;

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -2056,6 +2056,9 @@ export default {
     cameraTypeRtsp: 'RTSP-Stream',
     cameraTypeRtsp: 'RTSP-Stream',
     cameraTypeSnapshot: 'HTTP-Snapshot',
     cameraTypeSnapshot: 'HTTP-Snapshot',
     cameraTypeUsb: 'USB-Kamera (V4L2)',
     cameraTypeUsb: 'USB-Kamera (V4L2)',
+    cameraSnapshotUrl: 'Snapshot-URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'URL für Einzelbildaufnahmen — wird für Benachrichtigungs-Vorschaubilder, Abschlussfotos, Zeitraffer und Plattenerkennung verwendet. Leer lassen, um Bilder aus dem oben konfigurierten Live-Stream zu verwenden. Nützlich für go2rtc (/api/frame.jpeg) und IP-Kameras mit dediziertem Snapshot-Endpunkt.',
     cameraRotation: 'Drehung',
     cameraRotation: 'Drehung',
     test: 'Testen',
     test: 'Testen',
     connected: 'Verbunden',
     connected: 'Verbunden',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -2059,6 +2059,9 @@ export default {
     cameraTypeRtsp: 'RTSP Stream',
     cameraTypeRtsp: 'RTSP Stream',
     cameraTypeSnapshot: 'HTTP Snapshot',
     cameraTypeSnapshot: 'HTTP Snapshot',
     cameraTypeUsb: 'USB Camera (V4L2)',
     cameraTypeUsb: 'USB Camera (V4L2)',
+    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotation',
     cameraRotation: 'Rotation',
     test: 'Test',
     test: 'Test',
     connected: 'Connected',
     connected: 'Connected',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -2010,6 +2010,9 @@ export default {
     cameraTypeRtsp: 'Flux RTSP',
     cameraTypeRtsp: 'Flux RTSP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeUsb: 'Caméra USB (V4L2)',
     cameraTypeUsb: 'Caméra USB (V4L2)',
+    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotation',
     cameraRotation: 'Rotation',
     test: 'Tester',
     test: 'Tester',
     connected: 'Connecté',
     connected: 'Connecté',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -2009,6 +2009,9 @@ export default {
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeUsb: 'Fotocamera USB (V4L2)',
     cameraTypeUsb: 'Fotocamera USB (V4L2)',
+    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotazione',
     cameraRotation: 'Rotazione',
     test: 'Test',
     test: 'Test',
     connected: 'Connesso',
     connected: 'Connesso',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -2055,6 +2055,9 @@ export default {
     cameraTypeRtsp: 'RTSPストリーム',
     cameraTypeRtsp: 'RTSPストリーム',
     cameraTypeSnapshot: 'HTTPスナップショット',
     cameraTypeSnapshot: 'HTTPスナップショット',
     cameraTypeUsb: 'USBカメラ (V4L2)',
     cameraTypeUsb: 'USBカメラ (V4L2)',
+    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: '回転',
     cameraRotation: '回転',
     test: 'テスト',
     test: 'テスト',
     connected: '接続済み',
     connected: '接続済み',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2009,6 +2009,9 @@ export default {
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeUsb: 'Câmera USB (V4L2)',
     cameraTypeUsb: 'Câmera USB (V4L2)',
+    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotação',
     cameraRotation: 'Rotação',
     test: 'Testar',
     test: 'Testar',
     connected: 'Conectado',
     connected: 'Conectado',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2053,6 +2053,9 @@ export default {
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeUsb: 'USB 摄像头 (V4L2)',
     cameraTypeUsb: 'USB 摄像头 (V4L2)',
+    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: '旋转',
     cameraRotation: '旋转',
     test: '测试',
     test: '测试',
     connected: '已连接',
     connected: '已连接',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2053,6 +2053,9 @@ export default {
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeUsb: 'USB 攝影機 (V4L2)',
     cameraTypeUsb: 'USB 攝影機 (V4L2)',
+    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: '旋轉',
     cameraRotation: '旋轉',
     test: '測試',
     test: '測試',
     connected: '已連線',
     connected: '已連線',

+ 56 - 1
frontend/src/pages/SettingsPage.tsx

@@ -893,7 +893,7 @@ export function SettingsPage() {
   });
   });
 
 
   const updatePrinterMutation = useMutation({
   const updatePrinterMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> }) =>
+    mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; external_camera_snapshot_url: string | null; camera_rotation: number }> }) =>
       api.updatePrinter(id, data),
       api.updatePrinter(id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
       queryClient.invalidateQueries({ queryKey: ['printers'] });
@@ -1116,20 +1116,31 @@ export function SettingsPage() {
   const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
   const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
   const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
   const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
   const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
   const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
+  const [localSnapshotUrls, setLocalSnapshotUrls] = useState<Record<number, string>>({});
+  const snapshotUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
+  const initializedPrinterSnapshotUrlsRef = useRef<Set<number>>(new Set());
 
 
   // Initialize local camera URLs from printer data
   // Initialize local camera URLs from printer data
   useEffect(() => {
   useEffect(() => {
     if (printers) {
     if (printers) {
       const urls: Record<number, string> = {};
       const urls: Record<number, string> = {};
+      const snapUrls: Record<number, string> = {};
       printers.forEach(p => {
       printers.forEach(p => {
         if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
         if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
           urls[p.id] = p.external_camera_url;
           urls[p.id] = p.external_camera_url;
           initializedPrinterUrlsRef.current.add(p.id);
           initializedPrinterUrlsRef.current.add(p.id);
         }
         }
+        if (p.external_camera_snapshot_url && !initializedPrinterSnapshotUrlsRef.current.has(p.id)) {
+          snapUrls[p.id] = p.external_camera_snapshot_url;
+          initializedPrinterSnapshotUrlsRef.current.add(p.id);
+        }
       });
       });
       if (Object.keys(urls).length > 0) {
       if (Object.keys(urls).length > 0) {
         setLocalCameraUrls(prev => ({ ...prev, ...urls }));
         setLocalCameraUrls(prev => ({ ...prev, ...urls }));
       }
       }
+      if (Object.keys(snapUrls).length > 0) {
+        setLocalSnapshotUrls(prev => ({ ...prev, ...snapUrls }));
+      }
     }
     }
   }, [printers]);
   }, [printers]);
 
 
@@ -1151,6 +1162,21 @@ export function SettingsPage() {
     }, 800);
     }, 800);
   };
   };
 
 
+  const handleSnapshotUrlChange = (printerId: number, url: string) => {
+    setLocalSnapshotUrls(prev => ({ ...prev, [printerId]: url }));
+
+    if (snapshotUrlSaveTimeoutRef.current[printerId]) {
+      clearTimeout(snapshotUrlSaveTimeoutRef.current[printerId]);
+    }
+
+    snapshotUrlSaveTimeoutRef.current[printerId] = setTimeout(() => {
+      updatePrinterMutation.mutate({
+        id: printerId,
+        data: { external_camera_snapshot_url: url || null }
+      });
+    }, 800);
+  };
+
   const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {
   const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {
     const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};
     const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};
     if (updates.type !== undefined) data.external_camera_type = updates.type || null;
     if (updates.type !== undefined) data.external_camera_type = updates.type || null;
@@ -1912,6 +1938,35 @@ export function SettingsPage() {
                                 )}
                                 )}
                               </div>
                               </div>
                             )}
                             )}
+                            {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
+                              <div className="space-y-1">
+                                <label className="text-xs text-bambu-gray">{t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}</label>
+                                <div className="flex gap-2">
+                                  <input
+                                    type="text"
+                                    placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
+                                    value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
+                                    onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
+                                    className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                                  />
+                                  <Button
+                                    size="sm"
+                                    variant="secondary"
+                                    onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
+                                    disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
+                                  >
+                                    {extCameraTestLoading[printer.id] ? (
+                                      <Loader2 className="w-4 h-4 animate-spin" />
+                                    ) : (
+                                      t('settings.test')
+                                    )}
+                                  </Button>
+                                </div>
+                                <p className="text-xs text-bambu-gray opacity-75">
+                                  {t('settings.cameraSnapshotUrlHelp', 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.')}
+                                </p>
+                              </div>
+                            )}
                             <div className="flex items-center gap-2">
                             <div className="flex items-center gap-2">
                               <label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
                               <label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
                               <select
                               <select

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CrzRt--w.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="./assets/index-CwcBz1oz.js"></script>
+    <script type="module" crossorigin src="./assets/index-CrzRt--w.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-Cw7zekS6.css">
     <link rel="stylesheet" crossorigin href="./assets/index-Cw7zekS6.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff