From abc8e9705019f686fdf9892eaeb71fbe176e703a Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 3 May 2026 07:59:02 +0200 Subject: [PATCH] feat(camera): optional snapshot URL override for external cameras (#1177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 +- backend/app/api/routes/camera.py | 9 +- backend/app/core/database.py | 1 + backend/app/main.py | 13 +- backend/app/models/printer.py | 5 + backend/app/schemas/printer.py | 4 + backend/app/services/external_camera.py | 21 +- backend/app/services/layer_timelapse.py | 14 +- backend/app/services/obico_detection.py | 1 + backend/app/services/plate_detection.py | 27 +- .../unit/services/test_external_camera.py | 141 +++++++++++ .../src/__tests__/pages/SettingsPage.test.tsx | 91 +++++++ frontend/src/api/client.ts | 2 + frontend/src/i18n/locales/de.ts | 3 + frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/fr.ts | 3 + frontend/src/i18n/locales/it.ts | 3 + frontend/src/i18n/locales/ja.ts | 3 + frontend/src/i18n/locales/pt-BR.ts | 3 + frontend/src/i18n/locales/zh-CN.ts | 3 + frontend/src/i18n/locales/zh-TW.ts | 3 + frontend/src/pages/SettingsPage.tsx | 57 ++++- .../{index-CwcBz1oz.js => index-CrzRt--w.js} | 236 +++++++++--------- static/index.html | 2 +- 24 files changed, 517 insertions(+), 133 deletions(-) rename static/assets/{index-CwcBz1oz.js => index-CrzRt--w.js} (78%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f609711d6..1b8ca4886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ All notable changes to Bambuddy will be documented in this file. - **SpoolBuddy SSH update fails with "permission denied for user spoolbuddy" after Bambuddy keypair rotation** (reported during user testing) — Bambuddy's data dir at `/spoolbuddy/ssh/` can get recreated outside the daemon's control (volume remount, container recreate, fresh deploy), at which point `get_or_create_keypair()` generates a new ed25519 keypair. The SpoolBuddy daemon previously only fetched and deployed Bambuddy's public key at registration time (`/devices/register`), so any rotation after a successful registration left the device's `~/.ssh/authorized_keys` pointing at a defunct public half — every "Update" click from the Bambuddy UI then failed with `Connection closed by authenticating user spoolbuddy [preauth]` until the daemon was restarted manually. Worse, every prior successful registration appended a fresh entry to `authorized_keys` without ever pruning the old one, so a typical device accumulated 5+ stale Bambuddy-tagged keys (each one a permanent backdoor for whichever Bambuddy keypair held the matching private half at the time it was deployed). Two-pronged fix: **(1)** the heartbeat response (`HeartbeatResponse`, `routes/spoolbuddy.py:282`) now carries the current `ssh_public_key` alongside the existing `pending_command` / calibration fields, so the daemon's heartbeat picks up a key rotation within one cycle instead of needing a service restart; the same `try/except Exception: pass` pattern as the registration response keeps a missing/unreadable backend key from breaking telemetry. **(2)** `_deploy_ssh_key()` in `daemon/main.py` now syncs rather than appends — it strips every line tagged `bambuddy-spoolbuddy`, writes the current key once, and is a no-op when already in sync (so it doesn't churn the file every heartbeat). User-managed entries (any line not tagged `bambuddy-spoolbuddy`) are preserved untouched. 5 new unit tests in `spoolbuddy/tests/test_deploy_ssh_key.py` (creates-when-missing → mode-600 file with the current key; pile-up-of-stale-keys → only current key remains, no growth; preserves-unrelated-user-keys → user's own SSH access untouched; idempotent-when-in-sync → no mtime change so heartbeat doesn't churn the file; swallows-write-errors → readonly-fs PermissionError doesn't crash the heartbeat loop). 2 new backend integration tests in `test_spoolbuddy.py::TestDeviceEndpoints` — `test_heartbeat_returns_ssh_public_key` (response carries the key on every heartbeat) and `test_heartbeat_ssh_key_failure_does_not_break_heartbeat` (backend key-read failure leaves `ssh_public_key: None` but the heartbeat still 200s). -- **External-camera frames returned as black on go2rtc and other MJPEG sources** ([#1177](https://github.com/maziggy/bambuddy/issues/1177), reported by @nkm8) — `_capture_mjpeg_frame` returned the very first JPEG it found in the stream's bytes (`backend/app/services/external_camera.py:282`), but many MJPEG sources — go2rtc most notably, and several IP cameras — emit a "warm-up" frame on the byte that follows connection accept: usually the last keyframe held in the encoder, which is often black or stale until the encoder catches up to live content. Subsequent frames on the same connection are fine. The reporter saw it across snapshot UX, finish photos in notifications, and timelapse — every code path that opens a fresh capture connection (snapshot endpoint, `[PHOTO-BG]` finish photo, plate-detection CV, Obico ML inference, layer timelapse, Settings → Test). His own observation that go2rtc's `/api/frame.jpeg` (single-frame, internally already warmed) is never black while the first frame off `/api/stream.mjpeg` is, matched the hypothesis exactly. Support-bundle evidence was clean: every black notification frame in his log was 11095 bytes (a pure-black 1280×720 JPEG encodes to ~10–15 KB on standard libjpeg quality settings), while every captured-after-warm-up frame from the same source was 30–45 KB. Fix: read past the first frame and return the second; if the connection closes / times out / hits the 5 MB buffer cap before a second frame ever arrives, fall back to the first so callers still get *something* (degrading slow / single-frame streams to None would regress every code path that relied on pre-fix behaviour). The inner-loop now drains every complete frame already in the buffer before pulling the next chunk so high-FPS sources that pack multiple frames per chunk are handled correctly. The `snapshot` / `rtsp` / `usb` capture paths and the live-view streaming endpoint (`generate_mjpeg_stream`) are untouched. 7 new regression tests in `test_external_camera.py::TestCaptureMjpegFrameWarmupSkip` cover (a) two-frames-in-two-chunks → second returned, (b) two-frames-in-one-chunk → second returned, (c) frame split across chunk boundary → assembled correctly, (d) single-frame stream → first returned via fallback (no None regression), (e) timeout after first frame → first returned via fallback, (f) zero-frame stream → None, (g) non-200 status → None. Latency penalty: at most one frame interval (typically 50 ms – 1 s on a steady stream). +- **External-camera frames returned as black on go2rtc and other MJPEG sources** ([#1177](https://github.com/maziggy/bambuddy/issues/1177), reported by @nkm8) — `_capture_mjpeg_frame` returned the very first JPEG it found in the stream's bytes (`backend/app/services/external_camera.py:282`), but many MJPEG sources — go2rtc most notably, and several IP cameras — emit a "warm-up" frame on the byte that follows connection accept: usually the last keyframe held in the encoder, which is often black or stale until the encoder catches up to live content. Subsequent frames on the same connection are fine. The reporter saw it across snapshot UX, finish photos in notifications, and timelapse — every code path that opens a fresh capture connection (snapshot endpoint, `[PHOTO-BG]` finish photo, plate-detection CV, Obico ML inference, layer timelapse, Settings → Test). His own observation that go2rtc's `/api/frame.jpeg` (single-frame, internally already warmed) is never black while the first frame off `/api/stream.mjpeg` is, matched the hypothesis exactly. Support-bundle evidence was clean: every black notification frame in his log was 11095 bytes (a pure-black 1280×720 JPEG encodes to ~10–15 KB on standard libjpeg quality settings), while every captured-after-warm-up frame from the same source was 30–45 KB. Fix: read past the first frame and return the second; if the connection closes / times out / hits the 5 MB buffer cap before a second frame ever arrives, fall back to the first so callers still get *something* (degrading slow / single-frame streams to None would regress every code path that relied on pre-fix behaviour). The inner-loop now drains every complete frame already in the buffer before pulling the next chunk so high-FPS sources that pack multiple frames per chunk are handled correctly. The `snapshot` / `rtsp` / `usb` capture paths and the live-view streaming endpoint (`generate_mjpeg_stream`) are untouched. 7 new regression tests in `test_external_camera.py::TestCaptureMjpegFrameWarmupSkip` cover (a) two-frames-in-two-chunks → second returned, (b) two-frames-in-one-chunk → second returned, (c) frame split across chunk boundary → assembled correctly, (d) single-frame stream → first returned via fallback (no None regression), (e) timeout after first frame → first returned via fallback, (f) zero-frame stream → None, (g) non-200 status → None. Latency penalty: at most one frame interval (typically 50 ms – 1 s on a steady stream). **Follow-up: optional snapshot URL override** — @nkm8 retested on the daily build and saw the warm-up skip help most of the time but the black-frame symptom still surfaced intermittently on his go2rtc setup, with the same workflow break (notification thumbnails black, snapshot UX black). His own bisect already pointed at the cleanest fix: go2rtc exposes `/api/frame.jpeg` as a dedicated single-frame endpoint that *never* returns the encoder's warm-up keyframe, while `/api/stream.mjpeg` always does on a fresh connection. New optional `external_camera_snapshot_url` column on `printers` (idempotent `ALTER TABLE` migration via `_safe_execute`, plumbed through `PrinterBase` / `PrinterUpdate` / `PrinterResponse` / `from_orm_with_roi` / TypeScript `Printer` + `PrinterCreate`); when set, every single-frame capture path (`/api/v1/printers/{id}/camera/snapshot`, `[SNAPSHOT]` notification thumbnails, `[PHOTO-BG]` finish photo, layer timelapse on every captured layer, Obico ML snapshot, plate-detect / calibrate-plate CV) routes through `_capture_snapshot()` on the override URL via plain HTTP GET, bypassing the warm-up-frame dance entirely. The override is camera-type-agnostic — set it once on the printer config and it applies regardless of whether the live stream is mjpeg / rtsp / usb. Live-view (the `/camera/stream` and `/camera` endpoints powering the in-app viewer) deliberately stays on the configured stream URL — the override only changes single-frame captures, since a 1 fps poll-the-snapshot-endpoint live view would be a regression for everyone who doesn't have this problem. Settings UI (Settings → General → External Cameras) renders a new "Snapshot URL (optional)" input with its own Test button below the live-stream URL row; the input is hidden when `camera_type === 'snapshot'` since the live URL is already a single-frame endpoint and the override would be redundant. SSRF guard on the override is the existing `_sanitize_camera_url("http", "https")` allowlist — link-local / metadata / blocked hosts return None instead of being fetched. Empty-string override is treated as unset (defence in depth — a stale config row that somehow has `""` rather than `NULL` still routes through the live stream rather than firing GET against an empty URL). 5 new backend tests in `test_external_camera.py::TestSnapshotUrlOverride` (override routes to snapshot path; no override → camera-type handler; empty string → camera-type handler; SSRF guard on metadata-target override returns None; override is camera-type-agnostic across rtsp/usb). 3 new frontend tests in `SettingsPage.test.tsx` (input renders for mjpeg/rtsp/usb camera types; hidden for snapshot type; debounced PATCH carries `external_camera_snapshot_url` when the user types). i18n: `settings.cameraSnapshotUrl{,Placeholder,Help}` in en + de fully translated, the other 6 locales (fr/it/ja/pt-BR/zh-CN/zh-TW) seeded with English copies pending native translation. Documented under `bambuddy-wiki/docs/features/camera.md` with the go2rtc example URL as a tip block. - **MakerWorld sidebar entry visible to every user regardless of group permissions** ([#1175](https://github.com/maziggy/bambuddy/issues/1175)) — Backend already enforced `makerworld:view` on every `/makerworld/*` route (`backend/app/api/routes/makerworld.py:145, 157, 242, 406`), the permission was correctly granted to the admin and standard-user role defaults (`permissions.py:298, 364, 454`), and the frontend `Permission` type union already included `'makerworld:view' | 'makerworld:import'` (`client.ts:2498`) — but the sidebar's hand-maintained `navPermissions` map in `Layout.tsx:278` had no entry for `makerworld`, so `isHidden('makerworld')` always returned false and the entry rendered for every authenticated user. Users without the permission saw the entry, clicked, and the page rendered while every API call inside it 403'd. Two-line fix: (1) `Layout.tsx:278` — add `makerworld: 'makerworld:view'` to the map, matching every other sidebar entry's gating shape; (2) `App.tsx:200` — wrap the route in `` for defence in depth, so a user who knows the URL can no longer reach the page directly (matches the existing pattern on `settings`, `groups/new`, `groups/:id/edit` two lines below). 2 new Layout tests pin the contract: with auth enabled and a user lacking `makerworld:view`, the sidebar `` link is absent (other links like `/files` still render); with the permission granted, the link renders. diff --git a/backend/app/api/routes/camera.py b/backend/app/api/routes/camera.py index 7168d4191..3308ea9b9 100644 --- a/backend/app/api/routes/camera.py +++ b/backend/app/api/routes/camera.py @@ -792,7 +792,12 @@ async def camera_snapshot( if printer.external_camera_enabled and printer.external_camera_url: 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: raise HTTPException( 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, use_external=use_external, roi=roi, + external_camera_snapshot_url=printer.external_camera_snapshot_url if printer.external_camera_enabled else None, ) # 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_type=printer.external_camera_type if printer.external_camera_enabled else None, 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: diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 4ebbb8f8c..4787e67b9 100644 --- a/backend/app/core/database.py +++ b/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_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_snapshot_url VARCHAR(500)") # 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)") diff --git a/backend/app/main.py b/backend/app/main.py index 9601ceeef..8fea70109 100644 --- a/backend/app/main.py +++ b/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) 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: logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data)) 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, use_external=printer.external_camera_enabled, roi=roi, + external_camera_snapshot_url=printer.external_camera_snapshot_url, ) # Restore chamber light to original state @@ -2201,6 +2206,7 @@ async def on_print_start(printer_id: int, data: dict): fallback_archive.id, printer.external_camera_url, 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) @@ -2290,6 +2296,7 @@ async def on_print_start(printer_id: int, data: dict): archive.id, printer.external_camera_url, 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) @@ -3332,7 +3339,9 @@ async def on_print_complete(printer_id: int, data: dict): from backend.app.services.external_camera import 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: photos_dir = archive_dir / "photos" diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index 027b2ece0..72e17f0c7 100644 --- a/backend/app/models/printer.py +++ b/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_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # mjpeg, rtsp, snapshot 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 # Plate detection - check if build plate is empty before starting print plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index f5a02a185..ead51dec3 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -18,6 +18,7 @@ class PrinterBase(BaseModel): external_camera_url: str | None = None external_camera_type: str | None = None # "mjpeg", "rtsp", "snapshot", "usb" 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 @@ -50,6 +51,7 @@ class PrinterUpdate(BaseModel): external_camera_url: str | None = None external_camera_type: str | 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 plate_detection_enabled: bool | None = None plate_detection_roi: PlateDetectionROI | None = None @@ -63,6 +65,7 @@ class PrinterResponse(PrinterBase): external_camera_url: str | None = None external_camera_type: str | None = None external_camera_enabled: bool = False + external_camera_snapshot_url: str | None = None # #1177 camera_rotation: int = 0 # 0, 90, 180, 270 degrees plate_detection_enabled: bool = False plate_detection_roi: PlateDetectionROI | None = None @@ -87,6 +90,7 @@ class PrinterResponse(PrinterBase): "external_camera_url": printer.external_camera_url, "external_camera_type": printer.external_camera_type, "external_camera_enabled": printer.external_camera_enabled, + "external_camera_snapshot_url": printer.external_camera_snapshot_url, "camera_rotation": printer.camera_rotation, "is_active": printer.is_active, "nozzle_count": printer.nozzle_count, diff --git a/backend/app/services/external_camera.py b/backend/app/services/external_camera.py index 59836d791..a8d911128 100644 --- a/backend/app/services/external_camera.py +++ b/backend/app/services/external_camera.py @@ -173,17 +173,30 @@ def get_ffmpeg_path() -> str | 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. 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: 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") if camera_type == "mjpeg": return await _capture_mjpeg_frame(url, timeout) diff --git a/backend/app/services/layer_timelapse.py b/backend/app/services/layer_timelapse.py index b9a9d42e2..eaef72937 100644 --- a/backend/app/services/layer_timelapse.py +++ b/backend/app/services/layer_timelapse.py @@ -40,6 +40,7 @@ class TimelapseSession: archive_id: int | None camera_url: str camera_type: str + snapshot_url: str | None = None # Optional single-frame override; #1177 last_layer: int = -1 frame_count: int = 0 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 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: frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg" 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) -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. 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) url: External camera URL 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: 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, camera_url=url, camera_type=cam_type, + snapshot_url=snapshot_url, ) _active_sessions[printer_id] = session logger.info("Started timelapse session for printer %s", printer_id) diff --git a/backend/app/services/obico_detection.py b/backend/app/services/obico_detection.py index e74c05971..c8d372be4 100644 --- a/backend/app/services/obico_detection.py +++ b/backend/app/services/obico_detection.py @@ -197,6 +197,7 @@ class ObicoDetectionService: printer.external_camera_url, printer.external_camera_type, timeout=SNAPSHOT_CAPTURE_TIMEOUT, + snapshot_url=printer.external_camera_snapshot_url, ) return await capture_camera_frame_bytes( ip_address=printer.ip_address, diff --git a/backend/app/services/plate_detection.py b/backend/app/services/plate_detection.py index 00652ffd5..96b180c17 100644 --- a/backend/app/services/plate_detection.py +++ b/backend/app/services/plate_detection.py @@ -588,6 +588,7 @@ async def capture_camera_image( external_camera_url: str | None = None, external_camera_type: str | None = None, use_external: bool = False, + external_camera_snapshot_url: str | None = None, ) -> tuple[bytes | None, str]: """Capture an image from the printer camera. @@ -605,7 +606,11 @@ async def capture_camera_image( try: 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: camera_source = "external" 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, use_external: bool = False, roi: tuple[float, float, float, float] | None = None, + external_camera_snapshot_url: str | None = None, ) -> PlateDetectionResult: """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( - 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: @@ -722,6 +735,7 @@ async def calibrate_plate( external_camera_url: str | None = None, external_camera_type: str | None = None, use_external: bool = False, + external_camera_snapshot_url: str | None = None, ) -> tuple[bool, str, int]: """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 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: diff --git a/backend/tests/unit/services/test_external_camera.py b/backend/tests/unit/services/test_external_camera.py index 40f06a096..5e4b49295 100644 --- a/backend/tests/unit/services/test_external_camera.py +++ b/backend/tests/unit/services/test_external_camera.py @@ -372,6 +372,147 @@ class TestCameraTypeValidation: 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: """Tests for RTSP/RTSPS URL handling.""" diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index 4a0353676..dc96d735b 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/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(); + + 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(); + + // 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 | 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; + return HttpResponse.json({ ...mjpegPrinter, ...receivedBody }); + }), + ); + + render(); + + 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 }, + ); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9fda1dac3..532f3a426 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -152,6 +152,7 @@ export interface Printer { external_camera_url: string | null; external_camera_type: string | null; // "mjpeg", "rtsp", "snapshot" external_camera_enabled: boolean; + external_camera_snapshot_url: string | null; // optional single-frame override (#1177) camera_rotation: number; // 0, 90, 180, 270 degrees plate_detection_enabled: boolean; // Check plate before print plate_detection_roi?: PlateDetectionROI; // ROI for plate detection @@ -351,6 +352,7 @@ export interface PrinterCreate { external_camera_url?: string | null; external_camera_type?: string | null; external_camera_enabled?: boolean; + external_camera_snapshot_url?: string | null; camera_rotation?: number; plate_detection_enabled?: boolean; plate_detection_roi?: PlateDetectionROI; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index a9c69906c..c95a1aa3e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2056,6 +2056,9 @@ export default { cameraTypeRtsp: 'RTSP-Stream', cameraTypeSnapshot: 'HTTP-Snapshot', 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', test: 'Testen', connected: 'Verbunden', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ca62fde77..d4daa6d4e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2059,6 +2059,9 @@ export default { cameraTypeRtsp: 'RTSP Stream', cameraTypeSnapshot: 'HTTP Snapshot', 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', test: 'Test', connected: 'Connected', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index b2edbae90..e0067043a 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2010,6 +2010,9 @@ export default { cameraTypeRtsp: 'Flux RTSP', cameraTypeSnapshot: 'Snapshot HTTP', 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', test: 'Tester', connected: 'Connecté', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 7be36f3c0..93a5b4837 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2009,6 +2009,9 @@ export default { cameraTypeRtsp: 'Stream RTSP', cameraTypeSnapshot: 'Snapshot HTTP', 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', test: 'Test', connected: 'Connesso', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 78e84f47a..22c93fd5d 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2055,6 +2055,9 @@ export default { cameraTypeRtsp: 'RTSPストリーム', cameraTypeSnapshot: 'HTTPスナップショット', 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: '回転', test: 'テスト', connected: '接続済み', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index f0ee8807c..939122264 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2009,6 +2009,9 @@ export default { cameraTypeRtsp: 'Stream RTSP', cameraTypeSnapshot: 'Snapshot HTTP', 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', test: 'Testar', connected: 'Conectado', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 1c9ea8443..ed38d03fa 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2053,6 +2053,9 @@ export default { cameraTypeRtsp: 'RTSP 流', cameraTypeSnapshot: 'HTTP 快照', 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: '旋转', test: '测试', connected: '已连接', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 444101003..75d13138d 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -2053,6 +2053,9 @@ export default { cameraTypeRtsp: 'RTSP 流', cameraTypeSnapshot: 'HTTP 快照', 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: '旋轉', test: '測試', connected: '已連線', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 1c65734a9..7f0f02cc8 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -893,7 +893,7 @@ export function SettingsPage() { }); 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), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printers'] }); @@ -1116,20 +1116,31 @@ export function SettingsPage() { const [localCameraUrls, setLocalCameraUrls] = useState>({}); const cameraUrlSaveTimeoutRef = useRef>>({}); const initializedPrinterUrlsRef = useRef>(new Set()); + const [localSnapshotUrls, setLocalSnapshotUrls] = useState>({}); + const snapshotUrlSaveTimeoutRef = useRef>>({}); + const initializedPrinterSnapshotUrlsRef = useRef>(new Set()); // Initialize local camera URLs from printer data useEffect(() => { if (printers) { const urls: Record = {}; + const snapUrls: Record = {}; printers.forEach(p => { if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) { urls[p.id] = p.external_camera_url; 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) { setLocalCameraUrls(prev => ({ ...prev, ...urls })); } + if (Object.keys(snapUrls).length > 0) { + setLocalSnapshotUrls(prev => ({ ...prev, ...snapUrls })); + } } }, [printers]); @@ -1151,6 +1162,21 @@ export function SettingsPage() { }, 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 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; @@ -1912,6 +1938,35 @@ export function SettingsPage() { )} )} + {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && ( +
+ +
+ 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" + /> + +
+

+ {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.')} +

+
+ )}