Sfoglia il codice sorgente

fix(camera): snapshots and the camera test use the live view, the janitor kills only our ffmpeg

- The one-shot USB capture registers its ffmpeg like the Bambu one, so
  the orphan janitor no longer kills finish photos, timelapse frames and
  snapshots mid-capture; a timed-out capture is reaped.
- The janitor treats a v4l2 ffmpeg as ours only with our command shape
  and Bambuddy or init as its parent, so other software's USB camera
  processes are left alone.
- /camera/snapshot answers from the live view while one is running and
  asks the caller to retry while it starts (both camera kinds), except
  for an external camera with its own snapshot URL. The camera wall and
  the camera page retry a failed snapshot once.
- The external camera test reports the watched camera working from the
  live frame instead of failing against the viewer.
- The test toast shows "Camera connected (640x480)".
maziggy 5 giorni fa
parent
commit
7cea7acf0a
100 ha cambiato i file con 608 aggiunte e 69 eliminazioni
  1. 0 8
      .basedpyright/baseline.json
  2. 3 0
      CHANGELOG.md
  3. 97 27
      backend/app/api/routes/camera.py
  4. 25 17
      backend/app/services/external_camera.py
  5. 169 0
      backend/tests/integration/test_camera_live_view_snapshots.py
  6. 64 0
      backend/tests/unit/services/test_external_camera.py
  7. 39 0
      backend/tests/unit/test_camera_usb_stream_cleanup.py
  8. 54 0
      frontend/src/__tests__/components/CameraTile.test.tsx
  9. 51 0
      frontend/src/__tests__/pages/CameraPage.test.tsx
  10. 27 0
      frontend/src/__tests__/utils/cameraTestMessage.test.ts
  11. 3 1
      frontend/src/api/client.ts
  12. 11 0
      frontend/src/components/CameraTile.tsx
  13. 1 0
      frontend/src/i18n/locales/de.ts
  14. 1 0
      frontend/src/i18n/locales/en.ts
  15. 1 0
      frontend/src/i18n/locales/es.ts
  16. 3 2
      frontend/src/i18n/locales/fr.ts
  17. 1 0
      frontend/src/i18n/locales/it.ts
  18. 1 0
      frontend/src/i18n/locales/ja.ts
  19. 1 0
      frontend/src/i18n/locales/ko.ts
  20. 1 0
      frontend/src/i18n/locales/nl.ts
  21. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  22. 1 0
      frontend/src/i18n/locales/ru.ts
  23. 1 0
      frontend/src/i18n/locales/sv.ts
  24. 1 0
      frontend/src/i18n/locales/tr.ts
  25. 1 0
      frontend/src/i18n/locales/uk.ts
  26. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  27. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  28. 20 0
      frontend/src/pages/CameraPage.tsx
  29. 2 9
      frontend/src/pages/SettingsPage.tsx
  30. 23 0
      frontend/src/utils/cameraTestMessage.ts
  31. 0 0
      static/assets/AdditionalSection-BU39HOr8.js
  32. 0 0
      static/assets/AmsUnitCard-CtdDquzt.js
  33. 0 0
      static/assets/ArchivesPage-C5KXxo7n.js
  34. 0 0
      static/assets/AssignToAmsModal-BygOl_UZ.js
  35. 0 0
      static/assets/CamWallPage-Bms7JXS8.js
  36. 0 0
      static/assets/CameraDiagnoseModal-BQIzWG8X.js
  37. 0 0
      static/assets/CameraPage-CUs9LkoT.js
  38. 0 0
      static/assets/CameraPage-HyQWkg1t.js
  39. 0 0
      static/assets/CameraWall-AZmzq7EY.js
  40. 0 0
      static/assets/CameraWall-BmDzsGlH.js
  41. 0 0
      static/assets/ColumnConfigModal-DsK0osub.js
  42. 0 0
      static/assets/ConfigureAmsSlotModal-oe6xrCTF.js
  43. 0 0
      static/assets/ConnectAuthorizePage-Dut3uCe-.js
  44. 0 0
      static/assets/ContextMenu-C-4wD-h2.js
  45. 0 0
      static/assets/ExternalLinkPage-meheGXum.js
  46. 0 1
      static/assets/FileManagerPage-CG5bpcYC.js
  47. 0 0
      static/assets/FileUploadModal-BGGIXPtJ.js
  48. 0 0
      static/assets/FinancePage-Mlu_ytfd.js
  49. 0 0
      static/assets/GCodeViewerPage-DRsZvCTI.js
  50. 0 0
      static/assets/GroupEditPage-CvVOBcqi.js
  51. 0 0
      static/assets/ImagePreviewModal-Dtxp3z5l.js
  52. 0 0
      static/assets/InventoryPage-CbqVp3pR.js
  53. 0 0
      static/assets/LibraryTrashPage-2k3LLDve.js
  54. 0 0
      static/assets/LoginPage-DBuvfLti.js
  55. 0 0
      static/assets/MaintenancePage-ZPvtEJg6.js
  56. 0 0
      static/assets/ModelSourcesPage-DFPuxvhT.js
  57. 0 0
      static/assets/NotificationsPage-CSsHaEQn.js
  58. 0 1
      static/assets/PdfPreviewModal-CEFoRV2b.js
  59. 0 0
      static/assets/PhotoGalleryModal-BV_YQi7Y.js
  60. 0 0
      static/assets/PrinterLocationsPage-BAkJuYZ_.js
  61. 0 0
      static/assets/PrintersPage-CP5bhff7.js
  62. 0 0
      static/assets/ProfilesPage-DzSckPYv.js
  63. 0 0
      static/assets/ProjectDetailPage-Cr9UVt4L.js
  64. 0 0
      static/assets/ProjectsPage-GKjDzBqs.js
  65. 0 0
      static/assets/QueuePage-BDs8Ms_B.js
  66. 0 0
      static/assets/RichTextEditor-CYhjn2ia.js
  67. 0 0
      static/assets/SettingsPage-Cx0B8bP7.js
  68. 0 0
      static/assets/SettingsPage-CymzMUkk.js
  69. 0 0
      static/assets/SetupPage-1krIBNGW.js
  70. 0 0
      static/assets/SliceModal-DzQ5139B.js
  71. 0 0
      static/assets/SpoolBuddyAmsPage-C9vEk5Qf.js
  72. 0 0
      static/assets/SpoolBuddyCalibrationPage-BMlQjr05.js
  73. 0 0
      static/assets/SpoolBuddyDashboard-DY8j1ej5.js
  74. 0 0
      static/assets/SpoolBuddyInventoryPage-PKM-5azp.js
  75. 1 1
      static/assets/SpoolBuddySettingsPage-BKoxGhMK.js
  76. 0 0
      static/assets/SpoolBuddyWriteTagPage-D26vcneW.js
  77. 0 0
      static/assets/SpreadsheetPreviewModal-DYDftzgP.js
  78. 0 0
      static/assets/StatsPage-C3gKUAAZ.js
  79. 0 0
      static/assets/StreamOverlayPage-B7kJGd8l.js
  80. 0 0
      static/assets/SystemInfoPage-BNzfOHf6.js
  81. 0 0
      static/assets/de-v4wturyq.js
  82. 0 0
      static/assets/es-Dfyemg5k.js
  83. 0 0
      static/assets/fr-CLzbefHt.js
  84. 0 0
      static/assets/index-BgUt0B-1.js
  85. 1 1
      static/assets/inventorySearch-E6zT5oX-.js
  86. 0 0
      static/assets/it-B0byA_N9.js
  87. 0 0
      static/assets/ja-BTZHAsRJ.js
  88. 0 0
      static/assets/ko-BfKlSgbi.js
  89. 0 0
      static/assets/locationSensorDefaults-DIf8OLbs.js
  90. 0 0
      static/assets/nl-CSXajMH8.js
  91. 0 0
      static/assets/pdf-XuIYMZcz.js
  92. 0 0
      static/assets/pt-BR-B24lUZX8.js
  93. 0 0
      static/assets/ru-De7xKLI_.js
  94. 0 0
      static/assets/sv-PPQpMSzn.js
  95. 0 0
      static/assets/tr-DFLBj0MJ.js
  96. 0 0
      static/assets/uk-C5tEnNwu.js
  97. 0 0
      static/assets/utils-D_v7vV4r.js
  98. 0 0
      static/assets/zh-CN-wUz7eVCj.js
  99. 0 0
      static/assets/zh-TW-CsWnLERm.js
  100. 1 1
      static/index.html

+ 0 - 8
.basedpyright/baseline.json

@@ -2717,14 +2717,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportPossiblyUnboundVariable",
-                "range": {
-                    "startColumn": 11,
-                    "endColumn": 18,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportOptionalSubscript",
                 "range": {

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Camera snapshots and the camera test no longer fail while someone watches the live view** — A USB camera can be read by one program at a time, so while a viewer was watching, every snapshot Home Assistant or the camera wall fetched from an external camera failed, and the external camera's **Test** button failed too. Snapshots now return the live view's frame, and the Test button reports the camera working from that frame. While the live view is still starting, a snapshot asks the caller to try again a moment later instead of taking the camera from the viewer (the camera wall and the camera page now retry once on their own); this now applies to the printer's own camera as well. An external camera with its own snapshot URL is still fetched from that URL.
+- **USB camera photos are no longer killed mid-capture** — A cleanup job that runs every minute stops leftover camera processes, and it did not know about the one-shot USB capture behind finish photos, timelapse frames and snapshots, so it sometimes killed one in progress. It also stopped any other program's `ffmpeg` reading a USB camera on the same machine; it now only stops Bambuddy's own.
+- **The camera Test button shows the resolution properly** — It read "Camera connected640x480"; it now reads "Camera connected (640x480)".
 - **Admins can remove a locked-out user's 2FA from Edit user** — The wiki described this, but Edit user had no control for it, so a user who lost their authenticator stayed locked out unless an admin called the API by hand. **Settings → Users → Edit user** now has **Disable 2FA**, which asks for the admin's own password (admins who sign in through LDAP or SSO usually have none and are not asked) and says whether the user had any 2FA to remove.
 - **The AMS drying button knows a printer cannot dry from the first page load** — After a page load and every 30-second refresh, the printer card didn't know the drying status or why drying wasn't possible, so a printer that cannot dry showed an enabled Start button until the next live update (the printer still refused the start). The page load and the live updates now describe AMS units and external spools in exactly the same way. A slot the firmware reports as only its number now also counts as empty from the first load, and the AMS temperature no longer switches between "24°C" and "24.0°C".
 - **The filament override picker keeps both external spools of a dual-nozzle printer without an AMS apart** — On an H2D or X2D feeding only from its external spools, identical spools on the left and right merged into one entry that was offered for both nozzles. The picker now uses the same dual-nozzle check as the print scheduler, so each external spool is offered only for the nozzle it feeds.

+ 97 - 27
backend/app/api/routes/camera.py

@@ -4,6 +4,7 @@ import asyncio
 import contextlib
 import logging
 import os
+import re
 import subprocess
 import sys
 import time
@@ -1529,6 +1530,31 @@ async def _snapshot_response(printer_id: int, printer: Printer) -> Response:
     import tempfile
     from pathlib import Path
 
+    headers = {
+        "Cache-Control": "no-cache, no-store, must-revalidate",
+        "Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
+    }
+
+    # A viewer is watching: both camera kinds allow one reader (Bambu firmware
+    # one connection, a USB camera one V4L2 handle), so a capture of our own
+    # would fail or knock the viewer off. Answer with the live frame, and while
+    # the view is still starting, ask the caller to come back (#1348) — Home
+    # Assistant and the camera wall poll this. Not for an external camera with
+    # its own snapshot URL (#1177): that endpoint has no reader to compete with
+    # and gives the full-quality frame the live view does not.
+    has_snapshot_url = bool(
+        printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_snapshot_url
+    )
+    defer, live_frame = (False, None) if has_snapshot_url else live_frame_for_capture(printer_id)
+    if defer:
+        if live_frame:
+            return Response(content=live_frame, media_type="image/jpeg", headers=headers)
+        raise HTTPException(
+            status_code=503,
+            detail="The camera is in use by the live view, which is still starting. Try again in a moment.",
+            headers={"Retry-After": "2"},
+        )
+
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
         from backend.app.services.external_camera import capture_frame
@@ -1553,21 +1579,6 @@ async def _snapshot_response(printer_id: int, printer: Printer) -> Response:
             },
         )
 
-    # Reuse the fan-out broadcaster's buffered frame when a viewer is already
-    # watching — avoids opening a second concurrent RTSP socket on printers
-    # that allow only one camera connection (e.g. X2D firmware 01.01.00.00;
-    # see #1271). Buffered frame is <1s old while a viewer is connected.
-    buffered = try_get_active_buffered_frame(printer_id)
-    if buffered:
-        return Response(
-            content=buffered,
-            media_type="image/jpeg",
-            headers={
-                "Cache-Control": "no-cache, no-store, must-revalidate",
-                "Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
-            },
-        )
-
     # Create temporary file for the snapshot (0600 so only the app user can read it)
     fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
     os.close(fd)
@@ -1762,9 +1773,34 @@ async def test_external_camera(
         raise HTTPException(status_code=422, detail="url and camera_type are required")
 
     # Verify printer exists (for authorization)
-    await get_printer_or_404(printer_id, db)
+    printer = await get_printer_or_404(printer_id, db)
+
+    from backend.app.services.external_camera import jpeg_resolution, test_connection
 
-    from backend.app.services.external_camera import test_connection
+    # Testing the camera a viewer is watching: it allows one reader, so the
+    # test would only report a failure it caused. The live frame shows the
+    # camera works.
+    is_watched_camera = (
+        printer.external_camera_enabled
+        and target_url == printer.external_camera_url
+        and target_type == (printer.external_camera_type or "")
+    )
+    if is_watched_camera:
+        defer, live_frame = live_frame_for_capture(printer_id)
+        if defer:
+            if live_frame:
+                return {
+                    "success": True,
+                    "resolution": jpeg_resolution(live_frame),
+                    "coalesced": True,
+                    "live_view": True,
+                }
+            return {
+                "success": False,
+                "error": "The camera is in use by the live view, which is still starting. Try again in a moment.",
+                "coalesced": False,
+                "live_view": True,
+            }
 
     return await test_connection(target_url, target_type)
 
@@ -2162,19 +2198,53 @@ async def delete_reference(
     return {"success": True, "message": "Reference deleted"}
 
 
-def _scan_bambu_ffmpeg_pids() -> list[int]:
-    """Scan /proc for ffmpeg processes that are ours.
+_USB_DEVICE_ARG = re.compile(rb"^/dev/video\d{1,2}$")
+
+
+def _read_ppid(pid: int) -> int | None:
+    """The parent pid of ``pid`` from /proc, or None when it cannot be read."""
+    try:
+        with open(f"/proc/{pid}/stat", "rb") as f:
+            stat = f.read()
+        # The command name in field 2 may contain spaces; the fields after it don't.
+        return int(stat[stat.rindex(b")") + 1 :].split()[1])
+    except (OSError, ValueError, IndexError):
+        return None
+
+
+def _is_our_usb_command(argv: list[bytes]) -> bool:
+    """The shape of both USB camera commands in services/external_camera:
+    ``ffmpeg -f v4l2 ... -i /dev/videoN ... -`` (output to our pipe)."""
+    if len(argv) < 5 or argv[1:3] != [b"-f", b"v4l2"] or argv[-1] != b"-":
+        return False
+    return any(arg == b"-i" and _USB_DEVICE_ARG.match(argv[i + 1]) for i, arg in enumerate(argv[:-1]))
+
+
+def _is_bambuddy_ffmpeg(argv: list[bytes], ppid: int | None, own_pid: int) -> bool:
+    """Whether an ffmpeg process is one of ours.
 
-    Two shapes are matched, both unambiguously Bambuddy's:
     - Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
-    - External USB (V4L2): an ffmpeg spawned with ``-f v4l2`` is our USB camera
-      stream (#2675). Only orphans are killed — the caller excludes PIDs still in
-      ``_active_streams``, so a live USB stream (now registered there) is spared.
+    - External USB (V4L2): our command shape, run by this process or orphaned
+      to init by an earlier run. Other software reads USB cameras with ffmpeg
+      too (motion, webcam servers on a bare-metal install), and a bare
+      ``v4l2`` match killed those.
+    """
+    joined = b"\x00".join(argv)
+    if b"rtsp://bblp:" in joined or b"rtsps://bblp:" in joined:
+        return True
+    return _is_our_usb_command(argv) and ppid is not None and ppid in (own_pid, 1)
+
+
+def _scan_bambu_ffmpeg_pids() -> list[int]:
+    """Scan /proc for ffmpeg processes that are ours (see ``_is_bambuddy_ffmpeg``).
 
-    This catches orphans that survive app restarts and are not in any tracking dict.
+    Only orphans are killed — the caller excludes PIDs still in
+    ``_active_streams`` and ``_active_capture_pids``. This catches orphans that
+    survive app restarts and are not in any tracking dict.
     """
     import os
 
+    own_pid = os.getpid()
     pids = []
     try:
         for entry in os.listdir("/proc"):
@@ -2185,9 +2255,9 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
                     cmdline = f.read()
                 if b"ffmpeg" not in cmdline:
                     continue
-                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct), plus
-                # the `-f v4l2` input flag our USB camera command always carries.
-                if b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline or b"v4l2" in cmdline:
+                argv = cmdline.rstrip(b"\x00").split(b"\x00")
+                ppid = _read_ppid(int(entry)) if b"v4l2" in cmdline else None
+                if _is_bambuddy_ffmpeg(argv, ppid, own_pid):
                     pids.append(int(entry))
             except (OSError, PermissionError, ValueError):
                 continue

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

@@ -511,6 +511,9 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         "-",
     ]
 
+    from backend.app.services.camera import _active_capture_pids
+
+    process = None
     try:
         logger.debug("Running USB capture: %s", " ".join(cmd))
         process = await asyncio.create_subprocess_exec(
@@ -518,6 +521,9 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
+        # Registered like the Bambu one-shot capture, or the orphan janitor
+        # (routes/camera.cleanup_orphaned_streams) kills it mid-capture.
+        _active_capture_pids.add(process.pid)
 
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
 
@@ -535,10 +541,14 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         logger.warning("USB frame capture timed out after %ss", timeout)
         if process:
             process.kill()
+            await process.wait()
         return None
     except OSError as e:
         logger.error("USB frame capture failed: %s", e)
         return None
+    finally:
+        if process is not None:
+            _active_capture_pids.discard(process.pid)
 
 
 # Redirects a camera URL may take before the fetch gives up.
@@ -880,6 +890,20 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
     return None
 
 
+def jpeg_resolution(frame: bytes) -> str | None:
+    """``"WIDTHxHEIGHT"`` from a JPEG's SOF marker, or None when there is none.
+
+    The SOF marker (FF C0/C1/C2) is followed by length, precision, height, width.
+    """
+    for marker in (b"\xff\xc0", b"\xff\xc1", b"\xff\xc2"):
+        idx = frame.find(marker)
+        if idx != -1 and idx + 9 <= len(frame):
+            height = (frame[idx + 5] << 8) | frame[idx + 6]
+            width = (frame[idx + 7] << 8) | frame[idx + 8]
+            return f"{width}x{height}"
+    return None
+
+
 async def test_connection(url: str, camera_type: str) -> dict:
     """Test camera connection.
 
@@ -906,23 +930,7 @@ async def test_connection(url: str, camera_type: str) -> dict:
         logger.info("Capture result: %s bytes%s", len(frame) if frame else 0, " (coalesced)" if coalesced else "")
 
         if frame:
-            # Try to get resolution from JPEG header
-            resolution = None
-            try:
-                # Simple JPEG dimension extraction
-                # SOF0 marker is FF C0, followed by length, precision, height, width
-                sof_markers = [b"\xff\xc0", b"\xff\xc1", b"\xff\xc2"]
-                for marker in sof_markers:
-                    idx = frame.find(marker)
-                    if idx != -1 and idx + 9 <= len(frame):
-                        height = (frame[idx + 5] << 8) | frame[idx + 6]
-                        width = (frame[idx + 7] << 8) | frame[idx + 8]
-                        resolution = f"{width}x{height}"
-                        break
-            except (IndexError, ValueError):
-                pass  # Resolution detection is optional; fall back to default
-
-            return {"success": True, "resolution": resolution, "coalesced": coalesced}
+            return {"success": True, "resolution": jpeg_resolution(frame), "coalesced": coalesced}
         else:
             return {"success": False, "error": "Failed to capture frame from camera", "coalesced": coalesced}
 

+ 169 - 0
backend/tests/integration/test_camera_live_view_snapshots.py

@@ -0,0 +1,169 @@
+"""Snapshots and the external camera test stand down for a live view (SP-C2).
+
+A USB camera allows one V4L2 handle, so an external snapshot taken while a
+viewer was watching failed with 503 on every Home Assistant or camera-wall
+poll, and the Test button failed the same way. Finish photos and timelapse
+frames already reuse the live frame (``live_frame_for_capture``); the
+snapshot route and the Test button now do too.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+# 640x480 baseline JPEG header: SOI, then SOF0 with height 480 and width 640.
+LIVE_JPEG = b"\xff\xd8\xff\xc0\x00\x11\x08\x01\xe0\x02\x80\x03" + b"\x00" * 120 + b"\xff\xd9"
+
+USB = {"external_camera_enabled": True, "external_camera_url": "/dev/video0", "external_camera_type": "usb"}
+
+
+def _live_view(printer_id: int, frame: bytes | None):
+    streams = {f"{printer_id}-fanout": MagicMock()}
+    frames = {printer_id: frame} if frame is not None else {}
+    return (
+        patch("backend.app.api.routes.camera._active_streams", streams),
+        patch("backend.app.api.routes.camera._last_frames", frames),
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_external_snapshot_uses_the_live_frame(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory(**USB)
+    streams, frames = _live_view(printer.id, LIVE_JPEG)
+    with (
+        streams,
+        frames,
+        patch("backend.app.services.external_camera.capture_frame", new_callable=AsyncMock) as capture,
+    ):
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+    assert response.status_code == 200
+    assert response.content == LIVE_JPEG
+    capture.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_external_snapshot_waits_for_a_starting_live_view(async_client: AsyncClient, printer_factory):
+    """No frame yet: opening the camera now would knock the viewer off (#1348)."""
+    printer = await printer_factory(**USB)
+    streams, frames = _live_view(printer.id, None)
+    with (
+        streams,
+        frames,
+        patch("backend.app.services.external_camera.capture_frame", new_callable=AsyncMock) as capture,
+    ):
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+    assert response.status_code == 503
+    assert response.headers.get("retry-after") == "2"
+    capture.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_bambu_snapshot_waits_for_a_starting_live_view(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory()
+    streams, frames = _live_view(printer.id, None)
+    with (
+        streams,
+        frames,
+        patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as capture,
+    ):
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+    assert response.status_code == 503
+    capture.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_testing_the_watched_camera_reports_it_working(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory(**USB)
+    streams, frames = _live_view(printer.id, LIVE_JPEG)
+    with (
+        streams,
+        frames,
+        patch("backend.app.services.external_camera.test_connection", new_callable=AsyncMock) as probe,
+    ):
+        response = await async_client.post(
+            f"/api/v1/printers/{printer.id}/camera/external/test",
+            json={"url": "/dev/video0", "camera_type": "usb"},
+        )
+
+    assert response.status_code == 200
+    assert response.json() == {"success": True, "resolution": "640x480", "coalesced": True, "live_view": True}
+    probe.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_testing_the_watched_camera_while_the_view_starts(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory(**USB)
+    streams, frames = _live_view(printer.id, None)
+    with (
+        streams,
+        frames,
+        patch("backend.app.services.external_camera.test_connection", new_callable=AsyncMock) as probe,
+    ):
+        response = await async_client.post(
+            f"/api/v1/printers/{printer.id}/camera/external/test",
+            json={"url": "/dev/video0", "camera_type": "usb"},
+        )
+
+    body = response.json()
+    assert body["success"] is False
+    assert body["live_view"] is True
+    probe.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_testing_another_camera_still_opens_it(async_client: AsyncClient, printer_factory):
+    """The form may hold a device that is not the one being watched."""
+    printer = await printer_factory(**USB)
+    streams, frames = _live_view(printer.id, LIVE_JPEG)
+    with (
+        streams,
+        frames,
+        patch(
+            "backend.app.services.external_camera.test_connection",
+            new_callable=AsyncMock,
+            return_value={"success": True, "resolution": "1280x720", "coalesced": False},
+        ) as probe,
+    ):
+        response = await async_client.post(
+            f"/api/v1/printers/{printer.id}/camera/external/test",
+            json={"url": "/dev/video2", "camera_type": "usb"},
+        )
+
+    assert response.json()["resolution"] == "1280x720"
+    probe.assert_awaited_once_with("/dev/video2", "usb")
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_dedicated_snapshot_url_is_still_used_during_a_live_view(async_client: AsyncClient, printer_factory):
+    """#1177: an HTTP snapshot endpoint has no reader to compete with, and gives
+    the full-quality frame the live view does not."""
+    printer = await printer_factory(
+        external_camera_enabled=True,
+        external_camera_url="rtsp://10.0.0.9/live",
+        external_camera_type="rtsp",
+        external_camera_snapshot_url="http://10.0.0.9/snapshot.jpg",
+    )
+    streams, frames = _live_view(printer.id, None)
+    with (
+        streams,
+        frames,
+        patch(
+            "backend.app.services.external_camera.capture_frame", new_callable=AsyncMock, return_value=LIVE_JPEG
+        ) as capture,
+    ):
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+    assert response.status_code == 200
+    capture.assert_awaited_once()
+    assert capture.await_args.kwargs["snapshot_url"] == "http://10.0.0.9/snapshot.jpg"

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

@@ -707,3 +707,67 @@ class TestSnapshotTranscode:
         with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(html)):
             out = await ec._capture_snapshot("http://192.168.50.50/snapshot", 10)
         assert out is None
+
+
+class TestUsbOneShotIsSparedByTheJanitor:
+    """The janitor kills unregistered USB ffmpeg every 60 s; the one-shot
+    capture never registered itself, so a finish photo or timelapse frame
+    in flight could be killed under it (SP-C6)."""
+
+    @pytest.mark.asyncio
+    async def test_pid_is_registered_while_capturing(self):
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from backend.app.services import external_camera
+        from backend.app.services.camera import _active_capture_pids
+
+        seen: list[bool] = []
+        process = MagicMock()
+        process.pid = 51515
+        process.returncode = 0
+
+        async def communicate():
+            seen.append(51515 in _active_capture_pids)
+            return b"\xff\xd8" + b"x" * 200, b""
+
+        process.communicate = communicate
+        with (
+            patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch.object(external_camera, "_safe_usb_device_path", return_value="/dev/video0"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=process)),
+        ):
+            frame = await external_camera._capture_usb_frame("/dev/video0", timeout=5)
+
+        assert frame is not None
+        assert seen == [True]
+        assert 51515 not in _active_capture_pids
+
+    @pytest.mark.asyncio
+    async def test_a_timed_out_capture_is_killed_reaped_and_unregistered(self):
+        import asyncio
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from backend.app.services import external_camera
+        from backend.app.services.camera import _active_capture_pids
+
+        process = MagicMock()
+        process.pid = 51516
+        process.returncode = None
+        process.kill = MagicMock()
+        process.wait = AsyncMock(return_value=-9)
+
+        async def communicate():
+            await asyncio.sleep(10)
+
+        process.communicate = communicate
+        with (
+            patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch.object(external_camera, "_safe_usb_device_path", return_value="/dev/video0"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=process)),
+        ):
+            frame = await external_camera._capture_usb_frame("/dev/video0", timeout=0.05)
+
+        assert frame is None
+        process.kill.assert_called_once()
+        process.wait.assert_awaited()
+        assert 51516 not in _active_capture_pids

+ 39 - 0
backend/tests/unit/test_camera_usb_stream_cleanup.py

@@ -191,6 +191,7 @@ async def test_cleanup_janitor_reaps_stale_external_usb_stream(monkeypatch):
 def test_scan_matches_v4l2_ffmpeg(monkeypatch):
     cmdline = b"ffmpeg\x00-f\x00v4l2\x00-i\x00/dev/video0\x00-f\x00mjpeg\x00-\x00"
     monkeypatch.setattr("os.listdir", lambda _p: ["52020"])
+    monkeypatch.setattr(camera, "_read_ppid", lambda _pid: 1)
     with patch("builtins.open", mock_open(read_data=cmdline)):
         assert 52020 in camera._scan_bambu_ffmpeg_pids()
 
@@ -201,3 +202,41 @@ def test_scan_ignores_unrelated_ffmpeg(monkeypatch):
     monkeypatch.setattr("os.listdir", lambda _p: ["52021"])
     with patch("builtins.open", mock_open(read_data=cmdline)):
         assert camera._scan_bambu_ffmpeg_pids() == []
+
+
+# ---------------------------------------------------------------------------
+# 5. Only our own USB ffmpeg: another program's camera process is left alone
+# ---------------------------------------------------------------------------
+
+OWN_PID = 4242
+ONE_SHOT = [b"/usr/bin/ffmpeg", b"-f", b"v4l2", b"-i", b"/dev/video0", b"-frames:v", b"1", b"-f", b"image2pipe", b"-"]
+STREAM = [b"ffmpeg", b"-f", b"v4l2", b"-framerate", b"10", b"-i", b"/dev/video2", b"-f", b"mjpeg", b"-"]
+
+
+def test_our_one_shot_and_stream_commands_are_ours():
+    assert camera._is_bambuddy_ffmpeg(ONE_SHOT, OWN_PID, OWN_PID)
+    assert camera._is_bambuddy_ffmpeg(STREAM, OWN_PID, OWN_PID)
+
+
+def test_an_orphan_from_an_earlier_run_is_ours():
+    """Reparented to init once the Bambuddy that spawned it died."""
+    assert camera._is_bambuddy_ffmpeg(STREAM, 1, OWN_PID)
+
+
+def test_another_programs_usb_ffmpeg_is_not_ours():
+    """motion, an OctoPrint webcam or a recording on a bare-metal install."""
+    assert not camera._is_bambuddy_ffmpeg(STREAM, 777, OWN_PID)
+
+
+def test_a_different_v4l2_command_shape_is_not_ours():
+    recording = [b"ffmpeg", b"-f", b"v4l2", b"-input_format", b"h264", b"-i", b"/dev/video0", b"/srv/cam.mkv"]
+    assert not camera._is_bambuddy_ffmpeg(recording, OWN_PID, OWN_PID)
+
+
+def test_bambu_rtsp_is_ours_whatever_the_parent():
+    rtsp = [b"ffmpeg", b"-rtsp_transport", b"tcp", b"-i", b"rtsps://bblp:code@10.0.0.5:322/streaming/live/1", b"-"]
+    assert camera._is_bambuddy_ffmpeg(rtsp, 777, OWN_PID)
+
+
+def test_unknown_parent_is_not_ours_for_usb():
+    assert not camera._is_bambuddy_ffmpeg(STREAM, None, OWN_PID)

+ 54 - 0
frontend/src/__tests__/components/CameraTile.test.tsx

@@ -465,4 +465,58 @@ describe('CameraTile', () => {
 
     expect(screen.queryByAltText('X1C-Lab')).toBeNull();
   });
+
+  describe('a failed snapshot', () => {
+    // The server answers 503 while a live view of the printer is starting:
+    // it will not take the camera from that viewer. One failure is retried.
+    async function snapshotTile() {
+      render(
+        <CameraTile printerId={7} printerName="P1S-Garage" mode="snapshot" snapshotIntervalMs={2000} connected />,
+      );
+      await flushMicrotasks();
+    }
+    const fail = async () =>
+      act(async () => {
+        screen.getByAltText('P1S-Garage').dispatchEvent(new Event('error'));
+      });
+    const tick = async () =>
+      act(async () => {
+        vi.advanceTimersByTime(2100);
+      });
+
+    it('is tried again at the next refresh', async () => {
+      await snapshotTile();
+      const first = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
+      await fail();
+      expect(screen.queryByText('No signal')).toBeNull();
+
+      await tick();
+
+      const retried = screen.getByAltText('P1S-Garage') as HTMLImageElement;
+      expect(retried.src).toContain('/api/v1/printers/7/camera/snapshot');
+      expect(retried.src).not.toBe(first);
+    });
+
+    it('shows no signal when the retry fails too', async () => {
+      await snapshotTile();
+      await fail();
+      await tick();
+      await fail();
+
+      expect(screen.getByText('No signal')).toBeInTheDocument();
+    });
+
+    it('gets a fresh retry after a snapshot loads', async () => {
+      await snapshotTile();
+      await fail();
+      await tick();
+      await act(async () => {
+        screen.getByAltText('P1S-Garage').dispatchEvent(new Event('load'));
+      });
+      await tick();
+      await fail();
+
+      expect(screen.queryByText('No signal')).toBeNull();
+    });
+  });
 });

+ 51 - 0
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -150,6 +150,57 @@ describe('CameraPage', () => {
       return document.querySelector('img') as HTMLImageElement;
     }
 
+    async function snapshotImg() {
+      const stream = await streamImg();
+      // The mode buttons stay disabled until the stream shows a frame.
+      await act(async () => {
+        stream.dispatchEvent(new Event('load'));
+      });
+      fireEvent.click(screen.getByText('Snapshot'));
+      await waitFor(() => {
+        expect(document.querySelector('img')?.getAttribute('src') || '').toContain('/camera/snapshot');
+      });
+      return document.querySelector('img') as HTMLImageElement;
+    }
+
+    it('retries a failed snapshot once, a moment later', async () => {
+      // The server answers 503 while the live view it is switching away from
+      // still holds the camera.
+      renderCameraPage(1);
+      const img = await snapshotImg();
+      const first = img.getAttribute('src');
+
+      await act(async () => {
+        img.dispatchEvent(new Event('error'));
+      });
+      expect(screen.queryByText('Camera unavailable')).toBeNull();
+
+      await waitFor(
+        () => {
+          const src = document.querySelector('img')?.getAttribute('src') || '';
+          expect(src).toContain('/camera/snapshot');
+          expect(src).not.toBe(first);
+        },
+        { timeout: 3500 },
+      );
+    });
+
+    it('shows the camera unavailable when the retry fails too', async () => {
+      renderCameraPage(1);
+      const img = await snapshotImg();
+      const first = img.getAttribute('src');
+      await act(async () => {
+        img.dispatchEvent(new Event('error'));
+      });
+      await waitFor(() => expect(document.querySelector('img')?.getAttribute('src')).not.toBe(first), { timeout: 3500 });
+
+      await act(async () => {
+        (document.querySelector('img') as HTMLImageElement).dispatchEvent(new Event('error'));
+      });
+
+      expect(screen.getByText('Camera unavailable')).toBeInTheDocument();
+    });
+
     it('closes the current stream on leaving after the stream was restarted', async () => {
       const { unmount } = renderCameraPage(1);
       const first = await streamImg();

+ 27 - 0
frontend/src/__tests__/utils/cameraTestMessage.test.ts

@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+import i18n from '../../i18n';
+import { cameraTestSuccessMessage } from '../../utils/cameraTestMessage';
+
+const t = i18n.getFixedT('en');
+
+describe('cameraTestSuccessMessage', () => {
+  it('puts the resolution in brackets after the word', () => {
+    expect(cameraTestSuccessMessage({ resolution: '640x480' }, t)).toBe('Camera connected (640x480)');
+  });
+
+  it('leaves it out when the frame had none', () => {
+    expect(cameraTestSuccessMessage({}, t)).toBe('Camera connected');
+  });
+
+  it('names a shared capture', () => {
+    expect(cameraTestSuccessMessage({ resolution: '640x480', coalesced: true }, t)).toBe(
+      'Camera connected (640x480) (shared with a capture already running)',
+    );
+  });
+
+  it('names the live view, which wins over a shared capture', () => {
+    expect(cameraTestSuccessMessage({ resolution: '640x480', coalesced: true, live_view: true }, t)).toBe(
+      'Camera works (640x480) (frame from the live view, which is using the camera)',
+    );
+  });
+});

+ 3 - 1
frontend/src/api/client.ts

@@ -5137,9 +5137,11 @@ export const api = {
     // running (Obico polling, a snapshot) rather than a connection this test
     // opened — a single-reader camera is shared rather than opened twice, so
     // the result is real but says nothing about reaching the camera just now.
+    // `live_view` is true when the tested camera is the one a viewer is
+    // watching: the answer comes from the live frame, not a test of its own.
     // In the body: the URL may carry the camera login, and a query string
     // ends up in the server's access log.
-    request<{ success: boolean; error?: string; resolution?: string; coalesced?: boolean }>(
+    request<{ success: boolean; error?: string; resolution?: string; coalesced?: boolean; live_view?: boolean }>(
       `/printers/${printerId}/camera/external/test`,
       { method: 'POST', body: JSON.stringify({ url, camera_type: cameraType }) }
     ),

+ 11 - 0
frontend/src/components/CameraTile.tsx

@@ -102,6 +102,11 @@ export function CameraTile({
   // 'renewed': the current img is a renewal not yet showing a frame; 'retried':
   // it is the one retry of a renewal that failed.
   const renewalRef = useRef<'none' | 'renewed' | 'retried'>('none');
+  // A failed snapshot is retried once at the next refresh: the server answers
+  // 503 while a live view of this printer is still starting (it will not take
+  // the camera from that viewer). Not more: each failed camera img re-mints the
+  // page's stream token.
+  const snapshotRetriedRef = useRef(false);
   const renewalTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const firstFrameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
@@ -123,6 +128,7 @@ export function CameraTile({
       }).catch(() => {});
     }
     setErrored(false);
+    snapshotRetriedRef.current = false;
     setBust((b) => b + 1);
   }, [mode, printerId, viewerId]);
 
@@ -275,10 +281,15 @@ export function CameraTile({
           onLoad={() => {
             clearFirstFrameTimer();
             renewalRef.current = 'none';
+            snapshotRetriedRef.current = false;
           }}
           onError={(e) => {
             // An img with an empty or missing src is closed, not failed.
             if (!e.currentTarget.getAttribute('src')) return;
+            if (mode === 'snapshot' && !snapshotRetriedRef.current) {
+              snapshotRetriedRef.current = true;
+              return;
+            }
             streamFailed();
           }}
         />

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

@@ -2701,6 +2701,7 @@ export default {
       testFailed: 'Test fehlgeschlagen',
       cameraConnected: 'Kamera verbunden{{resolution}}',
       cameraConnectedCoalesced: 'Kamera verbunden{{resolution}} (geteilt mit einer bereits laufenden Aufnahme)',
+      cameraConnectedLiveView: 'Kamera funktioniert{{resolution}} (Bild aus der Live-Ansicht, die die Kamera gerade nutzt)',
     },
     testConnection: 'Verbindung testen',
     catalog: {

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

@@ -2723,6 +2723,7 @@ export default {
       testFailed: 'Test failed',
       cameraConnected: 'Camera connected{{resolution}}',
       cameraConnectedCoalesced: 'Camera connected{{resolution}} (shared with a capture already running)',
+      cameraConnectedLiveView: 'Camera works{{resolution}} (frame from the live view, which is using the camera)',
     },
     testConnection: 'Test Connection',
     catalog: {

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -2703,6 +2703,7 @@ export default {
       testFailed: 'La prueba falló',
       cameraConnected: 'Cámara conectada{{resolution}}',
       cameraConnectedCoalesced: 'Cámara conectada{{resolution}} (compartida con una captura ya en curso)',
+      cameraConnectedLiveView: 'La cámara funciona{{resolution}} (imagen de la vista en directo, que está usando la cámara)',
     },
     testConnection: 'Probar conexión',
     catalog: {

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

@@ -2654,8 +2654,9 @@ export default {
       passwordChanged: 'Mot de passe modifié',
       connectionFailed: 'Échec connexion',
       testFailed: 'Échec test',
-      cameraConnected: 'Caméra connectée {{resolution}}',
-      cameraConnectedCoalesced: 'Caméra connectée {{resolution}} (partagée avec une capture déjà en cours)',
+      cameraConnected: 'Caméra connectée{{resolution}}',
+      cameraConnectedCoalesced: 'Caméra connectée{{resolution}} (partagée avec une capture déjà en cours)',
+      cameraConnectedLiveView: 'La caméra fonctionne{{resolution}} (image de la vue en direct, qui utilise la caméra)',
     },
     testConnection: 'Tester la connexion',
     catalog: {

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

@@ -2656,6 +2656,7 @@ export default {
       testFailed: 'Test fallito',
       cameraConnected: 'Camera connessa{{resolution}}',
       cameraConnectedCoalesced: 'Camera connessa{{resolution}} (condivisa con un\'acquisizione già in corso)',
+      cameraConnectedLiveView: 'La camera funziona{{resolution}} (fotogramma dalla vista live, che sta usando la camera)',
     },
     testConnection: 'Testa connessione',
     catalog: {

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

@@ -2700,6 +2700,7 @@ export default {
       testFailed: 'テスト通知の送信に失敗しました',
       cameraConnected: 'カメラ接続{{resolution}}',
       cameraConnectedCoalesced: 'カメラ接続{{resolution}}(実行中のキャプチャと共有)',
+      cameraConnectedLiveView: 'カメラは動作しています{{resolution}}(カメラを使用中のライブビューの映像)',
     },
     testConnection: '接続テスト',
     catalog: {

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -2564,6 +2564,7 @@ export default {
       testFailed: '테스트 실패',
       cameraConnected: '카메라 연결됨{{resolution}}',
       cameraConnectedCoalesced: '카메라 연결됨{{resolution}} (이미 진행 중인 캡처와 공유됨)',
+      cameraConnectedLiveView: '카메라가 작동합니다{{resolution}} (카메라를 사용 중인 라이브 뷰의 프레임)',
       passwordNeedsUppercase: '비밀번호에 대문자가 최소 1개 포함되어야 합니다',
       passwordNeedsLowercase: '비밀번호에 소문자가 최소 1개 포함되어야 합니다',
       passwordNeedsDigit: '비밀번호에 숫자가 최소 1개 포함되어야 합니다',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -2721,6 +2721,7 @@ export default {
       testFailed: 'Test mislukt',
       cameraConnected: 'Camera verbonden{{resolution}}',
       cameraConnectedCoalesced: 'Camera verbonden{{resolution}} (gedeeld met een opname die al draait)',
+      cameraConnectedLiveView: 'Camera werkt{{resolution}} (beeld uit de liveweergave, die de camera gebruikt)',
     },
     testConnection: 'Verbinding testen',
     catalog: {

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

@@ -2656,6 +2656,7 @@ export default {
       testFailed: 'Falha no teste',
       cameraConnected: 'Câmera conectada{{resolution}}',
       cameraConnectedCoalesced: 'Câmera conectada{{resolution}} (compartilhada com uma captura já em andamento)',
+      cameraConnectedLiveView: 'A câmera funciona{{resolution}} (quadro da visualização ao vivo, que está usando a câmera)',
     },
     testConnection: 'Testar Conexão',
     catalog: {

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -2572,6 +2572,7 @@ export default {
       testFailed: "Проверка завершилась ошибкой",
       cameraConnected: "Камера подключена{{resolution}}",
       cameraConnectedCoalesced: "Камера подключена{{resolution}} (используется уже выполняющийся захват)",
+      cameraConnectedLiveView: "Камера работает{{resolution}} (кадр из просмотра в реальном времени, который использует камеру)",
     },
     testConnection: "Проверить подключение",
     catalog: {

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -2720,6 +2720,7 @@ export default {
       testFailed: 'Test misslyckades',
       cameraConnected: 'Kamera ansluten{{resolution}}',
       cameraConnectedCoalesced: 'Kamera ansluten{{resolution}} (delad med en pågående inspelning)',
+      cameraConnectedLiveView: 'Kameran fungerar{{resolution}} (bild från livevyn, som använder kameran)',
     },
     testConnection: 'Testa anslutning',
     catalog: {

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -2704,6 +2704,7 @@ export default {
       testFailed: 'Test başarısız',
       cameraConnected: 'Kamera bağlandı{{resolution}}',
       cameraConnectedCoalesced: 'Kamera bağlandı{{resolution}} (hâlihazırda süren bir yakalamayla paylaşıldı)',
+      cameraConnectedLiveView: 'Kamera çalışıyor{{resolution}} (kamerayı kullanan canlı görüntüden kare)',
     },
     testConnection: 'Bağlantıyı Test Et',
     catalog: {

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -2718,6 +2718,7 @@ export default {
       testFailed: "Тест не вдалося",
       cameraConnected: "Камера підключена{{resolution}}",
       cameraConnectedCoalesced: "Камера підключена{{resolution}} (спільно з уже виконуваним захопленням)",
+      cameraConnectedLiveView: "Камера працює{{resolution}} (кадр із перегляду наживо, який використовує камеру)",
     },
     testConnection: "Тестове підключення",
     catalog: {

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

@@ -2701,6 +2701,7 @@ export default {
       testFailed: '测试失败',
       cameraConnected: '摄像头已连接{{resolution}}',
       cameraConnectedCoalesced: '摄像头已连接{{resolution}}(与正在进行的抓取共享)',
+      cameraConnectedLiveView: '摄像头工作正常{{resolution}}(画面来自正在使用摄像头的实时画面)',
     },
     testConnection: '测试连接',
     catalog: {

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

@@ -2701,6 +2701,7 @@ export default {
       testFailed: '測試失敗',
       cameraConnected: '攝影機已連線{{resolution}}',
       cameraConnectedCoalesced: '攝影機已連線{{resolution}}(與進行中的擷取共用)',
+      cameraConnectedLiveView: '攝影機運作正常{{resolution}}(畫面來自正在使用攝影機的即時畫面)',
     },
     testConnection: '測試連線',
     catalog: {

+ 20 - 0
frontend/src/pages/CameraPage.tsx

@@ -14,6 +14,8 @@ import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
+// The server's Retry-After for a snapshot while a live view starts.
+const SNAPSHOT_RETRY_MS = 2000;
 const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
 const MAX_RECONNECT_DELAY = 30000; // 30 seconds
 const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
@@ -74,6 +76,11 @@ export function CameraPage() {
   // that's about to deliver frames — the churn that stranded the P1S camera
   // black for ~20 min (#2521).
   const stallStrikesRef = useRef(0);
+  // A failed snapshot is retried once, a moment later: the server answers 503
+  // while a live view of this printer still holds the camera, e.g. the one
+  // this page just switched away from.
+  const snapshotRetriedRef = useRef(false);
+  const snapshotRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
   // Fetch printer info for the title
   const { data: printer } = useQuery({
@@ -230,6 +237,9 @@ export function CameraPage() {
       if (stallCheckIntervalRef.current) {
         clearInterval(stallCheckIntervalRef.current);
       }
+      if (snapshotRetryTimerRef.current) {
+        clearTimeout(snapshotRetryTimerRef.current);
+      }
     };
   }, []);
 
@@ -336,12 +346,20 @@ export function CameraPage() {
     // Only auto-reconnect for live stream mode
     if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
       attemptReconnect();
+    } else if (streamMode === 'snapshot' && !snapshotRetriedRef.current) {
+      snapshotRetriedRef.current = true;
+      if (snapshotRetryTimerRef.current) clearTimeout(snapshotRetryTimerRef.current);
+      snapshotRetryTimerRef.current = setTimeout(() => {
+        snapshotRetryTimerRef.current = null;
+        setImageKey(Date.now());
+      }, SNAPSHOT_RETRY_MS);
     } else {
       setStreamError(true);
     }
   };
 
   const handleStreamLoad = () => {
+    snapshotRetriedRef.current = false;
     setStreamLoading(false);
     setStreamError(false);
     // Reset reconnect attempts on successful connection
@@ -399,6 +417,7 @@ export function CameraPage() {
     setStreamError(false);
     // Reset reconnect state on mode switch
     setReconnectAttempts(0);
+    snapshotRetriedRef.current = false;
     setIsReconnecting(false);
     // Reset zoom on mode switch
     setZoomLevel(1);
@@ -428,6 +447,7 @@ export function CameraPage() {
 
   const refresh = () => {
     if (transitioning) return;
+    snapshotRetriedRef.current = false;
     setTransitioning(true);
     setStreamLoading(true);
     setStreamError(false);

+ 2 - 9
frontend/src/pages/SettingsPage.tsx

@@ -54,6 +54,7 @@ import { LDAPSettings } from '../components/LDAPSettings';
 import { PrinterAccessSettings } from '../components/PrinterAccessSettings';
 import { TwoFactorSettings } from '../components/TwoFactorSettings';
 import { AdminDisableTwoFactor } from '../components/AdminDisableTwoFactor';
+import { cameraTestSuccessMessage } from '../utils/cameraTestMessage';
 import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { SecurityStatusCard } from '../components/SecurityStatusCard';
 import { APIBrowser } from '../components/APIBrowser';
@@ -1510,15 +1511,7 @@ export function SettingsPage() {
       const result = await api.testExternalCamera(printerId, url, cameraType);
       setExtCameraTestResults(prev => ({ ...prev, [printerId]: result }));
       if (result.success) {
-        // A shared capture means the frame is real but was not fetched over a
-        // connection this test opened, so say so rather than implying the
-        // camera was just reached.
-        showToast(
-          result.coalesced
-            ? t('settings.toast.cameraConnectedCoalesced', { resolution: result.resolution || '' })
-            : t('settings.toast.cameraConnected', { resolution: result.resolution || '' }),
-          'success'
-        );
+        showToast(cameraTestSuccessMessage(result, t), 'success');
       } else {
         showToast(result.error || t('settings.toast.connectionFailed'), 'error');
       }

+ 23 - 0
frontend/src/utils/cameraTestMessage.ts

@@ -0,0 +1,23 @@
+import type { TFunction } from 'i18next';
+
+interface CameraTestResult {
+  resolution?: string;
+  coalesced?: boolean;
+  live_view?: boolean;
+}
+
+/**
+ * The toast for a successful external camera test.
+ *
+ * A frame from the live view or from a capture already running is real, but
+ * it was not fetched over a connection this test opened, so the message says
+ * where it came from rather than implying the camera was just reached.
+ * The strings put the resolution straight after the word, so it carries its
+ * own leading space and brackets.
+ */
+export function cameraTestSuccessMessage(result: CameraTestResult, t: TFunction): string {
+  const resolution = result.resolution ? ` (${result.resolution})` : '';
+  if (result.live_view) return t('settings.toast.cameraConnectedLiveView', { resolution });
+  if (result.coalesced) return t('settings.toast.cameraConnectedCoalesced', { resolution });
+  return t('settings.toast.cameraConnected', { resolution });
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-BU39HOr8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-CtdDquzt.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-C5KXxo7n.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-BygOl_UZ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-Bms7JXS8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-BQIzWG8X.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-CUs9LkoT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-HyQWkg1t.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-AZmzq7EY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-BmDzsGlH.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-DsK0osub.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-oe6xrCTF.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-Dut3uCe-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-C-4wD-h2.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-meheGXum.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-CG5bpcYC.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-BGGIXPtJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-Mlu_ytfd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-DRsZvCTI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-CvVOBcqi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-Dtxp3z5l.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-CbqVp3pR.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-2k3LLDve.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-DBuvfLti.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-ZPvtEJg6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-DFPuxvhT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-CSsHaEQn.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-CEFoRV2b.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-BV_YQi7Y.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-BAkJuYZ_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-CP5bhff7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-DzSckPYv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-Cr9UVt4L.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-GKjDzBqs.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-BDs8Ms_B.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-CYhjn2ia.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-Cx0B8bP7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-CymzMUkk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-1krIBNGW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-DzQ5139B.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-C9vEk5Qf.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BMlQjr05.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-DY8j1ej5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-PKM-5azp.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BKoxGhMK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-D26vcneW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DYDftzgP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-C3gKUAAZ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-B7kJGd8l.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-BNzfOHf6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/de-v4wturyq.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/es-Dfyemg5k.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/fr-CLzbefHt.js


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


+ 1 - 1
static/assets/inventorySearch-DCsx3h-k.js → static/assets/inventorySearch-E6zT5oX-.js

@@ -1 +1 @@
-import{mt as e}from"./index-Kt2kuLbm.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{mt as e}from"./index-BgUt0B-1.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ja-BTZHAsRJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ko-BfKlSgbi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-DIf8OLbs.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/nl-CSXajMH8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-XuIYMZcz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pt-BR-B24lUZX8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ru-De7xKLI_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/sv-PPQpMSzn.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/tr-DFLBj0MJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/uk-C5tEnNwu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/utils-D_v7vV4r.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-CN-wUz7eVCj.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-TW-CsWnLERm.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Kt2kuLbm.js"></script>
+    <script type="module" crossorigin src="/assets/index-BgUt0B-1.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-1U8CP3-A.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DNxj-7q3.js">

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