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.
This commit is contained in:
maziggy
2026-05-03 07:59:02 +02:00
parent d6a3139397
commit abc8e97050
24 changed files with 517 additions and 133 deletions
+8 -1
View File
@@ -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:
+1
View File
@@ -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)")
+11 -2
View File
@@ -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"
+5
View File
@@ -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)
+4
View File
@@ -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,
+17 -4
View File
@@ -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)
+12 -2
View File
@@ -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)
+1
View File
@@ -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,
+24 -3
View File
@@ -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:
@@ -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."""