Explorar o código

fix(camera): Cam Wall tiles and the stream overlay recover from an ended stream without leaking connections

A live stream the server ends fires no img event, so a Cam Wall tile kept
its last frame until reload. Live tiles now reconnect every minute while
the page is visible, closing the old connection first (a detached img
keeps it open). A renewal with no frame within 15 s, or one that errors,
is retried once after 5 s, then the tile shows No signal. Tiles leaving
live mode or unmounting close their stream too. The stream overlay's
minute renewal closes the old connection first and no longer remounts on
a token change.

A camera stream's access re-check that cannot be read is retried once
(timed from when it began) before the stream ends, instead of ending a
valid stream on one database hiccup (SECURITY.md documents the bound).
External camera streams are counted per printer, so one viewer leaving
neither marks the camera inactive nor clears frame state for the others.
maziggy hai 1 semana
pai
achega
91cf89f300
Modificáronse 69 ficheiros con 818 adicións e 39 borrados
  1. 1 0
      CHANGELOG.md
  2. 7 0
      SECURITY.md
  3. 46 11
      backend/app/api/routes/camera.py
  4. 4 4
      backend/app/core/auth.py
  5. 150 0
      backend/tests/integration/test_camera_stream_recheck.py
  6. 1 1
      backend/tests/unit/test_forget_printer.py
  7. 310 0
      frontend/src/__tests__/components/CameraTile.test.tsx
  8. 26 0
      frontend/src/__tests__/components/UpdatedStreamOverlay.test.tsx
  9. 45 0
      frontend/src/__tests__/hooks/useOverlayCameraRecovery.test.ts
  10. 67 2
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  11. 115 3
      frontend/src/components/CameraTile.tsx
  12. 9 3
      frontend/src/components/UpdatedStreamOverlay.tsx
  13. 30 7
      frontend/src/hooks/useOverlayCameraRecovery.ts
  14. 4 3
      frontend/src/pages/StreamOverlayPage.tsx
  15. 0 0
      static/assets/AdditionalSection-CwKyz5Sr.js
  16. 0 0
      static/assets/AmsUnitCard-PjejW833.js
  17. 0 0
      static/assets/ArchivesPage-C3I6CLzl.js
  18. 0 0
      static/assets/AssignToAmsModal-4K9FiRqa.js
  19. 0 0
      static/assets/CamWallPage-CTto3-N6.js
  20. 0 0
      static/assets/CameraDiagnoseModal-BTvBiyQp.js
  21. 0 0
      static/assets/CameraPage-UKhKNqXC.js
  22. 0 0
      static/assets/CameraWall-AqjsdxyX.js
  23. 0 0
      static/assets/CameraWall-D5tCGkia.js
  24. 0 0
      static/assets/ColumnConfigModal-B9r_qSfw.js
  25. 0 0
      static/assets/ConfigureAmsSlotModal-C_VBRQMP.js
  26. 0 0
      static/assets/ConnectAuthorizePage-CReM4XzB.js
  27. 0 0
      static/assets/ContextMenu-zUhzBJbh.js
  28. 0 0
      static/assets/ExternalLinkPage-DWwYnu_s.js
  29. 0 1
      static/assets/FileManagerPage-ZiIjQrno.js
  30. 0 0
      static/assets/FileUploadModal-qMznQ8WM.js
  31. 0 0
      static/assets/FinancePage-BXvYiead.js
  32. 0 0
      static/assets/GCodeViewerPage-DQ2HxpM1.js
  33. 0 0
      static/assets/GroupEditPage-BuovOh9S.js
  34. 0 0
      static/assets/ImagePreviewModal-ORqThLGa.js
  35. 0 0
      static/assets/InventoryPage-kqHgA82w.js
  36. 0 0
      static/assets/LibraryTrashPage-CrODOFaZ.js
  37. 0 0
      static/assets/LoginPage-zHETLQiS.js
  38. 0 0
      static/assets/MaintenancePage-Dgt4Pgei.js
  39. 0 0
      static/assets/ModelSourcesPage-6A50mNkc.js
  40. 0 0
      static/assets/NotificationsPage-DpgYlXHO.js
  41. 0 1
      static/assets/PdfPreviewModal-Dvn8HsoM.js
  42. 0 0
      static/assets/PhotoGalleryModal-Dl_7Fygo.js
  43. 0 0
      static/assets/PrinterLocationsPage-DFGexhU4.js
  44. 0 0
      static/assets/PrintersPage-XKILhdfY.js
  45. 0 0
      static/assets/ProfilesPage-DcBh0XMZ.js
  46. 0 0
      static/assets/ProjectDetailPage-C96Jdbwk.js
  47. 0 0
      static/assets/ProjectsPage-BY6S3QTg.js
  48. 0 0
      static/assets/QueuePage-D_CbSM6z.js
  49. 0 0
      static/assets/RichTextEditor-BaxNds12.js
  50. 0 0
      static/assets/SettingsPage-CU1C2FWa.js
  51. 0 0
      static/assets/SetupPage-QENmlDhA.js
  52. 0 0
      static/assets/SliceModal-Csi9p3y8.js
  53. 0 0
      static/assets/SpoolBuddyAmsPage-BC-XvSCU.js
  54. 0 0
      static/assets/SpoolBuddyCalibrationPage-CKdaok-0.js
  55. 0 0
      static/assets/SpoolBuddyDashboard-BukI3SG2.js
  56. 0 0
      static/assets/SpoolBuddyInventoryPage-B0J-5BHE.js
  57. 1 1
      static/assets/SpoolBuddySettingsPage-DJ3N9L6r.js
  58. 0 0
      static/assets/SpoolBuddyWriteTagPage-HIkTlfK2.js
  59. 0 0
      static/assets/SpreadsheetPreviewModal-DKnoQiXA.js
  60. 0 0
      static/assets/StatsPage-XzkFxZom.js
  61. 0 0
      static/assets/StreamOverlayPage-B_FjRZW1.js
  62. 0 0
      static/assets/StreamOverlayPage-CVb7u673.js
  63. 0 0
      static/assets/SystemInfoPage-D0zovPHr.js
  64. 0 0
      static/assets/index-hIcpEngp.js
  65. 1 1
      static/assets/inventorySearch-C_rSVtx3.js
  66. 0 0
      static/assets/locationSensorDefaults-1CLlneMk.js
  67. 0 0
      static/assets/pdf-CGhCyg8M.js
  68. 0 0
      static/assets/utils-Cv5N-dzn.js
  69. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ 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
+- **Cam Wall tiles no longer freeze on their last frame** — When the server ended a live stream (the camera dropped, or a brief database hiccup during the stream's 30-second access check), browsers report nothing, so a Cam Wall tile kept showing its last frame until the page was reloaded. Live tiles now reconnect their stream every minute while the page is visible, so a camera that comes back shows a picture again within a minute. If the camera stays down, the tile shows "No signal" (a printer's built-in camera keeps its last picture until Bambuddy stops retrying it). A tile that leaves live mode (scrolled away, paused) now closes its stream too, instead of leaving the connection open. The streaming overlay, which already renewed its camera every minute, now closes the old connection first: a browser source left running for hours piled up one open camera connection per minute. A single failed access check no longer ends a stream either: it is checked again after 5 seconds, and only a second failure in a row ends it.
 - **SQLite no longer gives a deleted user's, API key's, printer's or archive's number to a new one** — SQLite reuses the highest freed id, so whatever still referred to a deleted user, API key, printer, print archive or camera token could end up referring to the next one created. Earlier fixes cleaned up after each such case; these tables now never reuse an id at all. Existing SQLite databases are converted once at start (the rows, columns and indexes are kept), and ids still referred to (a deleted printer's print history, for example) are skipped. PostgreSQL never reused ids itself; a SQLite backup restored on PostgreSQL (including one restored before this release), or a PostgreSQL backup restored on SQLite, now keeps those ids unused too.
 - **Taking away live updates or camera access through a group now ends them** — Only creating a dashboard's live connection or a camera link checked the permission for it. When an admin removed that permission from a group, removed a user from the group that granted it, or turned off an API key's status access, a dashboard already open kept receiving live updates and could reconnect for up to an hour, and an open camera view kept running, its link opened new views and snapshots, and a Cam Wall or Home Assistant camera token kept working until it expired. Those connections and views are now closed and the links refused once the permission is gone; the dashboard falls back to refreshing on its own as it does for users without live updates.
 - **An open camera view stops once the viewer may no longer watch** — A live camera stream checked its link once, when it started, and then ran for as long as the tab stayed open. After a password change or reset, removing someone's two-factor authentication, deactivating a user, a backup restore, disabling or expiring an API key, revoking a Cam Wall or overlay token, a group change that takes the printer away, or turning authentication on, a view already open kept showing live video. Streams now check every 30 seconds that whoever opened them may still watch that printer, and end if not. A view that is still allowed keeps running, including past the hour its link is valid for.

+ 7 - 0
SECURITY.md

@@ -123,6 +123,13 @@ line; CI fails otherwise. (We use a standalone marker rather than
 `# noqa: ...` because ruff reserves the latter syntax for its own
 error codes.)
 
+One bounded exception: an open camera stream re-checks its viewer 30
+seconds after the last check that allowed it. Once a check is due, no
+frame goes out until it has run. If it cannot be read (it raises),
+frames continue for at most 5 seconds from when it began; then a second
+check runs (at once, if the first took longer), and the stream ends
+unless that one allows it.
+
 ### 3. No hardcoded fallback secrets
 
 Production secrets (JWT signing keys, encryption keys, OAuth client

+ 46 - 11
backend/app/api/routes/camera.py

@@ -110,8 +110,10 @@ _last_frame_times: dict[int, float] = {}
 # Track stream start times for each printer
 _stream_start_times: dict[int, float] = {}
 
-# Track active external camera streams by printer ID
-_active_external_streams: set[int] = set()
+# Open external camera streams per printer ID: a count, so one viewer's stream
+# ending (a Cam Wall tile renews every minute) leaves the printer active for
+# the others while theirs still run.
+_active_external_streams: dict[int, int] = {}
 
 # Track ALL spawned ffmpeg PIDs (persists even if _active_streams entries are removed)
 # Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
@@ -960,17 +962,43 @@ async def create_stream_token(
 # runs for hours on a token checked once, and a session ending, a key disabled
 # or a Cam Wall token revoked must stop it.
 STREAM_RECHECK_SECONDS = 30.0
+# A re-check that could not be read (the database briefly unavailable) is
+# tried once more after this; only a second failure in a row ends the stream.
+STREAM_RECHECK_RETRY_SECONDS = 5.0
+
+
+async def _opener_allowed(opener: StreamOpener, printer_id: int) -> bool | None:
+    """The stream re-check; None when it could not be read. Unknown is never
+    allowed: _while_allowed retries once, then ends the stream (SECURITY.md section 2)."""
+    try:
+        return await stream_opener_still_allowed(opener, printer_id)
+    except Exception:  # could not be read; never treated as allowed
+        return None
 
 
 async def _while_allowed(frames, opener: StreamOpener, printer_id: int):
     """Pass the stream's chunks on until its opener may no longer watch."""
     next_check = time.monotonic() + STREAM_RECHECK_SECONDS
+    failed = False
     async with contextlib.aclosing(frames) as chunks:
         async for chunk in chunks:
             if time.monotonic() >= next_check:
-                if not await stream_opener_still_allowed(opener, printer_id):
+                started = time.monotonic()
+                allowed = await _opener_allowed(opener, printer_id)
+                if allowed is None and not failed:
+                    # Could not be read: one retry, timed from when this check
+                    # began. Due already (it failed slowly, a pool timeout)?
+                    # Then now, with no frame in between.
+                    failed = True
+                    next_check = started + STREAM_RECHECK_RETRY_SECONDS
+                    if time.monotonic() < next_check:
+                        yield chunk
+                        continue
+                    allowed = await _opener_allowed(opener, printer_id)
+                if not allowed:
                     logger.info("Camera stream for printer %s ended: its opener may no longer watch", printer_id)
                     return
+                failed = False
                 next_check = time.monotonic() + STREAM_RECHECK_SECONDS
             yield chunk
 
@@ -1042,7 +1070,6 @@ async def camera_stream(
         _disconnect_events[stream_id] = stop_event
         # Track stream start
         _stream_start_times[printer_id] = time.time()
-        _active_external_streams.add(printer_id)
 
         # Mutable holder so the wrapper's finally can unregister whatever process
         # is currently registered (the RTSP path may respawn across reconnects).
@@ -1077,6 +1104,9 @@ async def camera_stream(
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             try:
+                # Counted here, not in the handler: a body that never starts (the
+                # client left first) never runs the finally that uncounts it.
+                _active_external_streams[printer_id] = _active_external_streams.get(printer_id, 0) + 1
                 # Chamber light while the viewer watches (#1655).
                 async with (
                     camera_light(printer, wait=False),
@@ -1114,12 +1144,17 @@ async def camera_stream(
                 _active_streams.pop(stream_id, None)
                 _disconnect_events.pop(stream_id, None)
                 _stream_last_frame_times.pop(stream_id, None)
-                _active_external_streams.discard(printer_id)
-                # Now that this path publishes a buffered frame, it has to
-                # retract it too — ownership-checked, so a concurrent viewer of
-                # the same printer keeps its own. Also clears the per-printer
-                # timings this path used to leave behind.
-                _release_printer_frame_state(printer_id)
+                remaining = _active_external_streams.get(printer_id, 0) - 1
+                if remaining > 0:
+                    _active_external_streams[printer_id] = remaining
+                else:
+                    _active_external_streams.pop(printer_id, None)
+                    # Now that this path publishes a buffered frame, it has to
+                    # retract it too, and the per-printer timings it used to
+                    # leave behind. Only with the printer's last external stream:
+                    # is_stream_active() cannot see an MJPEG or snapshot camera's
+                    # other viewers, whose stall detection needs these.
+                    _release_printer_frame_state(printer_id)
                 logger.info("External camera stream ended for printer %s", printer_id)
 
         return StreamingResponse(
@@ -1281,7 +1316,7 @@ async def forget_printer(printer_id: int) -> None:
     _last_frames.pop(printer_id, None)
     _last_frame_times.pop(printer_id, None)
     _stream_start_times.pop(printer_id, None)
-    _active_external_streams.discard(printer_id)
+    _active_external_streams.pop(printer_id, None)
 
 
 async def _stop_printer_streams(printer_id: int) -> int:

+ 4 - 4
backend/app/core/auth.py

@@ -1239,8 +1239,8 @@ async def verify_camera_stream_token(token: str) -> PrinterScope | None:
 
 
 async def stream_opener_still_allowed(opener: StreamOpener, printer_id: int) -> bool:
-    """Whether a stream's opener may still watch this printer (False also when
-    it can't be read).
+    """Whether a stream's opener may still watch this printer. Raises when it
+    can't be read: the stream decides how long it may run unchecked.
 
     A user's sessions must not have ended since the stream started (password
     change or reset, 2FA removal, restore), and they must still exist and be
@@ -1288,9 +1288,9 @@ async def stream_opener_still_allowed(opener: StreamOpener, printer_id: int) ->
             else:
                 return False
             return scope.allows(printer_id)
-    except Exception:  # SEC-AUTH-EXC: a failed re-check ends the stream (fail-closed)
+    except Exception:  # SEC-AUTH-EXC: logged and re-raised, never an answer
         logger.warning("Camera stream re-check failed", exc_info=True)
-        return False
+        raise
 
 
 async def verify_camwall_token(token: str) -> PrinterScope | None:

+ 150 - 0
backend/tests/integration/test_camera_stream_recheck.py

@@ -60,6 +60,7 @@ def _frames(monkeypatch):
             await asyncio.sleep(0)
 
     monkeypatch.setattr(camera, "STREAM_RECHECK_SECONDS", 0.0)
+    monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.0)
     with patch("backend.app.services.external_camera.generate_mjpeg_stream", frames):
         yield
 
@@ -249,3 +250,152 @@ async def test_the_cam_wall_and_overlay_feeds_refuse_a_token_whose_owner_lost_ca
     await _take_camera_view(async_client, group, headers)
 
     assert await verify(created.plaintext) is None
+
+
+def _session_failing(times: int):
+    """The re-check's database session, failing its first `times` uses."""
+    real = auth.async_session
+    calls = {"n": 0}
+
+    def session():
+        calls["n"] += 1
+        if calls["n"] <= times:
+            raise RuntimeError("database briefly unavailable")
+        return real()
+
+    return patch.object(auth, "async_session", session)
+
+
+async def test_one_failed_re_check_does_not_end_a_valid_stream(admin, printer):
+    """A database hiccup froze every open camera view: browsers fire no event
+    when an MJPEG stream ends, so a Cam Wall tile kept its last frame."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+
+    with _session_failing(1):
+        assert not await _ended(stream)
+        assert not await _ended(stream)
+        assert not await _ended(stream)
+
+
+async def test_a_re_check_failing_twice_in_a_row_ends_the_stream(admin, printer):
+    """Still fail-closed: an opener that cannot be checked does not keep watching."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+
+    with _session_failing(1000):
+        ended = [await _ended(stream) for _ in range(3)]
+
+    assert True in ended
+
+
+async def test_a_definite_no_ends_the_stream_without_a_retry(admin, printer, db_session, monkeypatch):
+    monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 3600.0)
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+
+    await db_session.execute(text("UPDATE users SET is_active = 0 WHERE id = :id"), {"id": admin})
+    await db_session.commit()
+
+    assert await _ended(stream)
+
+
+async def test_a_re_check_that_cannot_be_read_raises(admin):
+    """Fail-closed in auth code (SECURITY.md section 2): no permissive value from
+    a catch-all; the stream decides how long it may run unchecked."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    opener = await _opener(token)
+
+    with _session_failing(1), pytest.raises(RuntimeError):
+        await auth.stream_opener_still_allowed(opener, 1)
+
+
+def _session_failing_slowly(seconds: float):
+    """The re-check's database session, failing every use after `seconds` (a pool timeout)."""
+
+    class _Slow:
+        async def __aenter__(self):
+            await asyncio.sleep(seconds)
+            raise RuntimeError("pool timeout")
+
+        async def __aexit__(self, *_exc):
+            return False
+
+    return patch.object(auth, "async_session", lambda: _Slow())
+
+
+async def test_a_slow_failing_re_check_sends_no_frame_past_the_retry_deadline(admin, printer, monkeypatch):
+    """SECURITY.md: no frame more than about 35 s after the last check that allowed
+    the stream. The retry deadline counts from when the failed check started."""
+    monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.01)
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+
+    with _session_failing_slowly(0.05):
+        assert await _ended(stream)
+
+
+async def _status(printer_id: int) -> bool:
+    return (await camera.camera_status(printer_id, None))["active"]
+
+
+async def test_one_viewer_leaving_keeps_a_shared_external_camera_active(admin, printer):
+    """A Cam Wall tile renews its stream every minute; the printer's other open
+    viewer must not read the camera as stopped and reconnect."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    tile = await _open(printer, await _opener(token))
+    viewer = await _open(printer, await _opener(token))
+
+    await tile.aclose()
+    assert await _status(printer)
+
+    await viewer.aclose()
+    assert not await _status(printer)
+
+
+async def test_a_re_check_failing_again_at_the_retry_ends_the_stream_after_frames_meanwhile(
+    admin, printer, monkeypatch
+):
+    """The usual production path: the check fails fast, frames go on until the
+    retry is due, and the retry failing too ends the stream (never deferred again)."""
+    monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.2)
+    token = await auth.create_camera_stream_token(username="camadmin")
+    stream = await _open(printer, await _opener(token))
+    loop = asyncio.get_running_loop()
+    start = loop.time()
+    frames = 0
+
+    with _session_failing(1000), pytest.raises(StopAsyncIteration):
+        while loop.time() - start < 2:
+            await asyncio.wait_for(anext(stream), 1)
+            frames += 1
+
+    assert frames > 1  # it waited for the retry rather than ending at once
+
+
+async def test_a_stream_whose_body_never_starts_leaves_the_camera_inactive(admin, printer):
+    """The client went away before the first chunk: no finally runs, so the
+    count must only go up once the stream really runs."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=await _opener(token))
+
+    await response.body_iterator.aclose()
+
+    assert not await _status(printer)
+
+
+async def test_one_viewer_leaving_keeps_the_printers_frame_state_for_the_others(admin, printer):
+    """Without it /camera/status has no stream_uptime, so the remaining viewer can
+    never read the camera as stalled and would not reconnect if it stops."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    tile = await _open(printer, await _opener(token))
+    viewer = await _open(printer, await _opener(token))
+
+    await tile.aclose()
+    status = await camera.camera_status(printer, None)
+    assert status["stream_uptime"] is not None
+    assert printer in camera._last_frames
+
+    await viewer.aclose()
+    assert (await camera.camera_status(printer, None))["stream_uptime"] is None
+    assert printer not in camera._last_frames

+ 1 - 1
backend/tests/unit/test_forget_printer.py

@@ -169,7 +169,7 @@ async def test_forget_printer_clears_the_services():
     camera._last_frames[GONE] = b"jpg"
     camera._last_frame_times[GONE] = 1.0
     camera._stream_start_times[GONE] = 1.0
-    camera._active_external_streams.add(GONE)
+    camera._active_external_streams[GONE] = 1
     from backend.app.api.routes import printers
 
     printers._cover_cache[GONE] = {("Cube", "front"): b"png"}

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

@@ -1,7 +1,9 @@
+import { StrictMode } from 'react';
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { act, screen } from '@testing-library/react';
 import { render } from '../utils';
 import { CameraTile } from '../../components/CameraTile';
+import { setStreamToken } from '../../api/client';
 
 // The shared render() util mounts AuthProvider, which fires an async
 // /auth/me probe on mount. Each test absorbs that settle with a single
@@ -123,4 +125,312 @@ describe('CameraTile', () => {
     );
     expect(stopCalls.length).toBeGreaterThan(0);
   });
+  // A server-ended MJPEG stream fires no img event (load once in Chromium, per
+  // frame in Firefox, nothing at the end), so a live tile renews its stream.
+  it('renews a live stream every minute without stopping it', async () => {
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const initial = (screen.getByAltText('X1C-Lab') as HTMLImageElement).src;
+
+    await act(async () => {
+      vi.advanceTimersByTime(59_000);
+    });
+    expect((screen.getByAltText('X1C-Lab') as HTMLImageElement).src).toBe(initial);
+
+    await act(async () => {
+      vi.advanceTimersByTime(2_000);
+    });
+    const renewed = (screen.getByAltText('X1C-Lab') as HTMLImageElement).src;
+    expect(renewed).toContain('/api/v1/printers/42/camera/stream');
+    expect(renewed).not.toBe(initial);
+    // /camera/stop would end the stream the other viewers of this printer share.
+    expect(vi.mocked(global.fetch).mock.calls.filter(([url]) => String(url).includes('/camera/stop'))).toHaveLength(0);
+  });
+
+  it('closes the old stream before opening the renewed one', async () => {
+    // A detached img keeps its MJPEG connection open (measured in Chromium):
+    // the browser's per-host pool fills and a USB camera stays held.
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const old = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+
+    await act(async () => {
+      vi.advanceTimersByTime(61_000);
+    });
+
+    expect(old.getAttribute('src')).toBeNull();
+    expect(screen.getByAltText('X1C-Lab')).not.toBe(old);
+  });
+
+  it('takes the error of a cleared img for no failure', async () => {
+    // Browsers fire error on an img whose src is set to '' (the renewal's close).
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+
+    await act(async () => {
+      img.setAttribute('src', '');
+      img.dispatchEvent(new Event('error'));
+    });
+
+    expect(screen.getByAltText('X1C-Lab')).toBeTruthy();
+  });
+
+  it('waits a moment between closing the old stream and opening the new one', async () => {
+    // A USB camera serves one reader: the old ffmpeg must let go of it first.
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const old = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+
+    await act(async () => {
+      vi.advanceTimersByTime(60_100);
+    });
+    expect(old.getAttribute('src')).toBeNull();
+    expect(screen.getByAltText('X1C-Lab')).toBe(old);
+
+    await act(async () => {
+      vi.advanceTimersByTime(200);
+    });
+    expect(screen.getByAltText('X1C-Lab')).not.toBe(old);
+  });
+
+  it('does not renew while the page is hidden', async () => {
+    // The stream token is not refreshed in a background tab; a renewal there
+    // would ask with an expired one.
+    const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const old = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+    const src = old.src;
+
+    await act(async () => {
+      vi.advanceTimersByTime(125_000);
+    });
+
+    expect(screen.getByAltText('X1C-Lab')).toBe(old);
+    expect(old.src).toBe(src);
+    hidden.mockRestore();
+  });
+
+  async function renewedThenFailed() {
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    await act(async () => {
+      vi.advanceTimersByTime(60_500);
+    });
+    const renewed = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+    await act(async () => {
+      renewed.dispatchEvent(new Event('error'));
+    });
+    return renewed;
+  }
+
+  it('retries a renewal that failed once, after a few seconds', async () => {
+    const renewed = await renewedThenFailed();
+    expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
+
+    await act(async () => {
+      vi.advanceTimersByTime(5_500);
+    });
+
+    const retried = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+    expect(retried).not.toBe(renewed);
+    expect(retried.src).toContain('/api/v1/printers/42/camera/stream');
+  });
+
+  it('keeps the stream closed while it waits, whatever re-renders the tile', async () => {
+    // A failed renewal makes the app mint a new stream token; the re-render it
+    // causes must not reconnect the img before the retry is due.
+    const { rerender } = render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    await act(async () => {
+      vi.advanceTimersByTime(60_500);
+    });
+    await act(async () => {
+      screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error'));
+    });
+
+    await act(async () => {
+      setStreamToken('fresh-token');
+      rerender(
+        <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected progress={1} />,
+      );
+    });
+
+    expect(screen.getByAltText('X1C-Lab').getAttribute('src')).toBeNull();
+    setStreamToken(null);
+  });
+
+  it('shows no signal when the retry fails too', async () => {
+    await renewedThenFailed();
+    await act(async () => {
+      vi.advanceTimersByTime(5_500);
+    });
+
+    await act(async () => {
+      screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error'));
+    });
+
+    expect(screen.queryByAltText('X1C-Lab')).toBeNull();
+  });
+
+  it('treats a renewed stream that showed a frame like any other', async () => {
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    await act(async () => {
+      vi.advanceTimersByTime(60_500);
+    });
+    const renewed = screen.getByAltText('X1C-Lab');
+    await act(async () => {
+      renewed.dispatchEvent(new Event('load'));
+    });
+
+    await act(async () => {
+      renewed.dispatchEvent(new Event('error'));
+    });
+
+    expect(screen.queryByAltText('X1C-Lab')).toBeNull();
+  });
+
+  it('closes the live stream when the tile leaves live mode', async () => {
+    const { rerender } = render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const live = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+
+    await act(async () => {
+      rerender(<CameraTile printerId={42} printerName="X1C-Lab" mode="paused" snapshotIntervalMs={5000} connected />);
+    });
+    await flushMicrotasks();
+
+    expect(live.getAttribute('src')).toBeNull();
+  });
+
+  it('closes the live stream when the tile goes away', async () => {
+    const { unmount } = render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const live = screen.getByAltText('X1C-Lab') as HTMLImageElement;
+
+    unmount();
+    await flushMicrotasks();
+
+    expect(live.getAttribute('src')).toBeNull();
+  });
+
+  // A snapshot camera that is down keeps its stream open without a frame: the
+  // img fires neither load nor error, and the tile would stay blank.
+  async function renewedWithoutFrame() {
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    await act(async () => {
+      vi.advanceTimersByTime(60_500);
+    });
+    return screen.getByAltText('X1C-Lab') as HTMLImageElement;
+  }
+
+  it('retries a renewal that shows no frame within 15 seconds', async () => {
+    const renewed = await renewedWithoutFrame();
+
+    await act(async () => {
+      vi.advanceTimersByTime(15_000);
+    });
+    expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
+
+    await act(async () => {
+      vi.advanceTimersByTime(5_500);
+    });
+    expect(screen.getByAltText('X1C-Lab')).not.toBe(renewed);
+  });
+
+  it('shows no signal when the retry shows no frame either', async () => {
+    await renewedWithoutFrame();
+
+    await act(async () => {
+      vi.advanceTimersByTime(20_500);
+    });
+    await act(async () => {
+      vi.advanceTimersByTime(15_500);
+    });
+
+    expect(screen.queryByAltText('X1C-Lab')).toBeNull();
+  });
+
+  it('keeps a renewal that showed a frame', async () => {
+    const renewed = await renewedWithoutFrame();
+    await act(async () => {
+      renewed.dispatchEvent(new Event('load'));
+    });
+
+    await act(async () => {
+      vi.advanceTimersByTime(30_000);
+    });
+
+    expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
+  });
+
+  it('keeps its stream under StrictMode', async () => {
+    // React 19 runs a ref's cleanup once on mount in development; that must
+    // not close the stream of the img that stays.
+    render(
+      <StrictMode>
+        <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />
+      </StrictMode>,
+    );
+    await flushMicrotasks();
+
+    expect(screen.getByAltText('X1C-Lab').getAttribute('src')).toContain('/api/v1/printers/42/camera/stream');
+  });
+
+  it('does not count the time a hidden page cannot load the renewal', async () => {
+    // A lazy img does not load in a background tab; the tab hidden right after
+    // a renewal must not end a working camera on no signal.
+    const renewed = await renewedWithoutFrame();
+    const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
+
+    await act(async () => {
+      vi.advanceTimersByTime(45_000);
+    });
+    hidden.mockRestore();
+
+    expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
+  });
+
+  it('leaves a tile showing no signal alone', async () => {
+    // Its retry would fail again, and every failed camera image makes the app
+    // mint a new stream token and reconnect every camera on the page.
+    render(
+      <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    await act(async () => {
+      screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error'));
+    });
+
+    await act(async () => {
+      vi.advanceTimersByTime(121_000);
+    });
+
+    expect(screen.queryByAltText('X1C-Lab')).toBeNull();
+  });
 });

+ 26 - 0
frontend/src/__tests__/components/UpdatedStreamOverlay.test.tsx

@@ -36,3 +36,29 @@ describe('Version 2 orientation', () => {
     expect(view.container.firstElementChild).toHaveAttribute('data-layout', layout);
   });
 });
+
+describe('Camera stream renewal', () => {
+  it('renders the camera without a source while its stream is closed, and hands over the img', () => {
+    const imgRef = { current: null as HTMLImageElement | null };
+    const view = render(
+      <UpdatedStreamOverlay {...props} camera={{ url: undefined, rotation: 0, onError: () => {}, imgRef }} />,
+    );
+
+    const img = view.container.querySelector('.updated-overlay__camera img');
+    expect(img).not.toBeNull();
+    expect(img?.getAttribute('src')).toBeNull();
+    expect(imgRef.current).toBe(img);
+  });
+  it('switches the stream on the same img when its URL changes (a new stream token)', () => {
+    // A replaced img would keep the old connection open.
+    const camera = { rotation: 0, onError: () => {} };
+    const view = render(<UpdatedStreamOverlay {...props} camera={{ ...camera, url: '/s?t=1&token=a' }} />);
+    const img = view.container.querySelector('.updated-overlay__camera img');
+
+    view.rerender(<UpdatedStreamOverlay {...props} camera={{ ...camera, url: '/s?t=1&token=b' }} />);
+
+    const after = view.container.querySelector('.updated-overlay__camera img');
+    expect(after).toBe(img);
+    expect(after?.getAttribute('src')).toBe('/s?t=1&token=b');
+  });
+});

+ 45 - 0
frontend/src/__tests__/hooks/useOverlayCameraRecovery.test.ts

@@ -74,4 +74,49 @@ describe('useOverlayCameraRecovery', () => {
     unmount();
     expect(vi.getTimerCount()).toBe(0);
   });
+  // A detached img keeps its MJPEG connection open (measured in Chromium): the
+  // old stream is closed before the key changes, and a moment before the new
+  // one opens (a USB camera serves one reader).
+  function withImg(result: { current: ReturnType<typeof useOverlayCameraRecovery> }) {
+    const img = document.createElement('img');
+    img.setAttribute('src', '/api/v1/printers/1/camera/stream?t=1');
+    result.current.imgRef.current = img;
+    return img;
+  }
+
+  it('closes the old stream shortly before opening the renewed one', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(true));
+    const initial = result.current.imageKey;
+    const img = withImg(result);
+
+    act(() => vi.advanceTimersByTime(59_800));
+    expect(img.getAttribute('src')).toBeNull();
+    expect(result.current.closing).toBe(true);
+    expect(result.current.imageKey).toBe(initial);
+
+    act(() => vi.advanceTimersByTime(200));
+    expect(result.current.closing).toBe(false);
+    expect(result.current.imageKey).toBe(initial + 1);
+  });
+
+  it('closes a failed stream until its retry', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(true));
+    const img = withImg(result);
+
+    act(() => result.current.handleStreamError({ currentTarget: img } as never));
+
+    expect(img.getAttribute('src')).toBeNull();
+    expect(result.current.closing).toBe(true);
+  });
+
+  it('takes the error of a closed img for no failure', () => {
+    const { result } = renderHook(() => useOverlayCameraRecovery(true));
+    const initial = result.current.imageKey;
+    const closed = document.createElement('img');
+
+    act(() => result.current.handleStreamError({ currentTarget: closed } as never));
+    act(() => vi.advanceTimersByTime(3_000));
+
+    expect(result.current.imageKey).toBe(initial);
+  });
 });

+ 67 - 2
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -12,6 +12,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { AuthProvider } from '../../contexts/AuthContext';
+import { setStreamToken } from '../../api/client';
 
 const mockPrinter = {
   id: 1,
@@ -58,7 +59,11 @@ function renderOverlayPage(printerId: number, queryParams = '') {
     },
   });
 
-  return rtlRender(
+  return rtlRender(overlayTree(printerId, queryParams, queryClient));
+}
+
+function overlayTree(printerId: number, queryParams: string, queryClient: QueryClient) {
+  return (
     <QueryClientProvider client={queryClient}>
       <MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
         <AuthProvider>
@@ -195,7 +200,8 @@ describe('StreamOverlayPage', () => {
       if (typeof renewal !== 'function') throw new Error('Missing camera renewal timer');
       act(() => renewal());
       const renewed = screen.getByAltText('Camera stream');
-      expect(renewed).not.toBe(image);
+      // Classic remounts the img; version 2 switches the src of the same one.
+      if (!artwork) expect(renewed).not.toBe(image);
       expect(renewed.getAttribute('src')).not.toBe(source);
       expect(renewed.getAttribute('src')).toContain('token=overlay-test');
       expect(renewed.getAttribute('src')).toContain('fps=7');
@@ -206,6 +212,65 @@ describe('StreamOverlayPage', () => {
     }
   });
 
+  // A detached img keeps its MJPEG connection open (measured in Chromium, which
+  // OBS browser sources run on): the old stream is closed before it is replaced.
+  it.each(['', '&artwork=2'])('closes the old camera stream before it is replaced (%s)', async (artwork) => {
+    server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json(mockStatusIdle)));
+    const timers = vi.spyOn(globalThis, 'setTimeout');
+    const view = renderOverlayPage(1, `?token=overlay-test${artwork}`);
+    try {
+      const image = await screen.findByAltText('Camera stream');
+      const close = timers.mock.calls.find(([, delay]) => delay === 59_800)?.[0];
+      const renewal = timers.mock.calls.find(([, delay]) => delay === 60_000)?.[0];
+      if (typeof close !== 'function' || typeof renewal !== 'function') throw new Error('Missing renewal timers');
+
+      const source = image.getAttribute('src');
+      // Both in one batch: React never renders the closed state in between.
+      act(() => {
+        close();
+        renewal();
+      });
+
+      const renewed = screen.getByAltText('Camera stream');
+      if (artwork) {
+        // Version 2 keeps the img: changing its src is what closes the old stream.
+        expect(renewed).toBe(image);
+        expect(renewed.getAttribute('src')).not.toBe(source);
+        expect(renewed.getAttribute('src')).toContain('/camera/stream');
+      } else {
+        expect(image.getAttribute('src')).toBeNull();
+        expect(renewed).not.toBe(image);
+      }
+    } finally {
+      view.unmount();
+      timers.mockRestore();
+    }
+  });
+
+  it.each(['', '&artwork=2'])('keeps the camera closed while it waits, whatever re-renders the page (%s)', async (artwork) => {
+    // Signed in: a new stream token (minted after a failed stream) changes the
+    // URL; the re-render it causes must not reconnect before the renewal.
+    setStreamToken('first');
+    const timers = vi.spyOn(globalThis, 'setTimeout');
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+    const view = rtlRender(overlayTree(1, artwork ? `?${artwork.slice(1)}` : '', queryClient));
+    try {
+      await screen.findByAltText('Camera stream');
+      const close = timers.mock.calls.find(([, delay]) => delay === 59_800)?.[0];
+      if (typeof close !== 'function') throw new Error('Missing close timer');
+      act(() => close());
+
+      setStreamToken('second');
+      view.rerender(overlayTree(1, artwork ? `?${artwork.slice(1)}` : '', queryClient));
+
+      expect(screen.getByAltText('Camera stream').getAttribute('src')).toBeNull();
+    } finally {
+      view.unmount();
+      timers.mockRestore();
+      setStreamToken(null);
+    }
+  });
+
   describe('updated artwork', () => {
     it('maps legacy model codes in version 2', async () => {
       server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model: 'BL-P001' })));

+ 115 - 3
frontend/src/components/CameraTile.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react';
 import { getAuthToken, withStreamToken } from '../api/client';
@@ -31,6 +31,15 @@ interface CameraTileProps {
 // no drag/resize/zoom shell, and snapshot fallback when off-cap. The server
 // still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
 const LIVE_FPS = 8;
+const LIVE_RENEWAL_MS = 60_000;
+// Between closing the old stream and opening the new one: a USB camera serves
+// one reader, and the old ffmpeg must let go of the device first.
+const RENEWAL_GAP_MS = 200;
+// A renewal that failed is tried once more after this.
+const RENEWAL_RETRY_MS = 5_000;
+// A renewal with no frame by then has failed too: a snapshot camera that is
+// down keeps its stream open without one, and the img fires no event at all.
+const RENEWAL_FIRST_FRAME_MS = 15_000;
 
 type StatusBucket = 'printing' | 'paused' | 'finished' | 'error' | 'idle';
 
@@ -77,7 +86,31 @@ export function CameraTile({
   const { t } = useTranslation();
   const [bust, setBust] = useState(0);
   const [errored, setErrored] = useState(false);
+  // The old stream is closed and the new one not yet due: the img renders with
+  // no src (removing it closes the connection), so no re-render reopens it early.
+  const [reopening, setReopening] = useState(false);
   const lastModeRef = useRef<CameraTileMode>(mode);
+  const imgRef = useRef<HTMLImageElement | null>(null);
+  // Every img this tile drops (leaving live mode, a renewal, unmounting) has
+  // its stream closed: a detached img keeps its MJPEG connection open, and the
+  // /camera/stop sent on leaving live mode is skipped while it does.
+  const attachImg = useCallback((img: HTMLImageElement | null) => {
+    imgRef.current = img;
+    if (!img) return;
+    return () => {
+      if (imgRef.current === img) imgRef.current = null;
+      // Once the commit is done: React runs this with the img still in the
+      // page, and in development (StrictMode) also for an img that stays.
+      queueMicrotask(() => {
+        if (!img.isConnected) img.removeAttribute('src');
+      });
+    };
+  }, []);
+  // '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');
+  const renewalTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const firstFrameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
   // Tell the backend to release its MJPEG transcoder when this tile stops
   // being live — either by unmounting or by transitioning to snapshot/paused.
@@ -121,6 +154,76 @@ export function CameraTile({
     return () => clearInterval(interval);
   }, [mode, snapshotIntervalMs]);
 
+  // A stream the server ended (its re-check, the camera dropping) fires no img
+  // event: the tile would keep its last frame. Renew it, closing the old
+  // connection first (a detached img keeps it open). No /camera/stop: other
+  // viewers of the printer share the stream. Not in a hidden page, where the
+  // stream token is not refreshed. A tile showing no signal is left alone:
+  // each failed camera img makes the app re-mint the stream token and
+  // reconnect every camera on the page.
+  //
+  // Built once: they only use refs and state setters, and call each other.
+  const { clearFirstFrameTimer, streamFailed, reopenAfter } = useMemo(() => {
+    const clearFirstFrameTimer = () => {
+      if (firstFrameTimerRef.current !== null) clearTimeout(firstFrameTimerRef.current);
+      firstFrameTimerRef.current = null;
+    };
+
+    // The stream failed (an error, or a renewal with no frame in time): a
+    // renewal is retried once, anything else shows no signal.
+    const streamFailed = () => {
+      clearFirstFrameTimer();
+      if (renewalRef.current === 'renewed') {
+        renewalRef.current = 'retried';
+        reopenAfter(RENEWAL_RETRY_MS);
+        return;
+      }
+      renewalRef.current = 'none';
+      setErrored(true);
+    };
+
+    const reopenAfter = (ms: number) => {
+      // Closed now, not at the next render: React may batch this with the reopen.
+      imgRef.current?.removeAttribute('src');
+      setReopening(true);
+      if (renewalTimerRef.current !== null) clearTimeout(renewalTimerRef.current);
+      renewalTimerRef.current = setTimeout(() => {
+        renewalTimerRef.current = null;
+        setReopening(false);
+        setBust((b) => b + 1);
+        clearFirstFrameTimer();
+        // A hidden page does not load a lazy img: that time does not count.
+        const deadline = () => {
+          if (document.hidden) {
+            firstFrameTimerRef.current = setTimeout(deadline, RENEWAL_FIRST_FRAME_MS);
+            return;
+          }
+          streamFailed();
+        };
+        firstFrameTimerRef.current = setTimeout(deadline, RENEWAL_FIRST_FRAME_MS);
+      }, ms);
+    };
+
+    return { clearFirstFrameTimer, streamFailed, reopenAfter };
+  }, []);
+
+  useEffect(() => {
+    renewalRef.current = 'none';
+    if (mode !== 'live' || !connected) return;
+    const interval = setInterval(() => {
+      if (document.hidden || !imgRef.current || renewalTimerRef.current !== null) return;
+      renewalRef.current = 'renewed';
+      reopenAfter(RENEWAL_GAP_MS);
+    }, LIVE_RENEWAL_MS);
+    return () => {
+      clearInterval(interval);
+      if (renewalTimerRef.current !== null) clearTimeout(renewalTimerRef.current);
+      renewalTimerRef.current = null;
+      clearFirstFrameTimer();
+      setReopening(false);
+    };
+  }, [mode, connected, reopenAfter, clearFirstFrameTimer]);
+
   const liveUrl = withStreamToken(
     `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`,
   );
@@ -168,14 +271,23 @@ export function CameraTile({
         </div>
       ) : (
         <img
+          ref={attachImg}
           key={`${mode}-${bust}`}
-          src={mode === 'live' ? liveUrl : snapshotUrl}
+          src={reopening ? undefined : mode === 'live' ? liveUrl : snapshotUrl}
           alt={printerName}
           draggable={false}
           loading="lazy"
           className="h-full w-full select-none object-contain"
           style={{ transform }}
-          onError={() => setErrored(true)}
+          onLoad={() => {
+            clearFirstFrameTimer();
+            renewalRef.current = 'none';
+          }}
+          onError={(e) => {
+            // Closing the old stream (src removed) fires error too.
+            if (!e.currentTarget.getAttribute('src')) return;
+            streamFailed();
+          }}
         />
       )}
 

+ 9 - 3
frontend/src/components/UpdatedStreamOverlay.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref, type RefObject, type SyntheticEvent } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Clock, Layers, Printer, Timer } from 'lucide-react';
 import './UpdatedStreamOverlay.css';
@@ -12,7 +12,13 @@ interface UpdatedStreamOverlayProps {
   customLogo?: ReactNode;
   progressBackground?: string;
   size: 'small' | 'medium' | 'large';
-  camera: { url: string; rotation: number; onError: () => void } | null;
+  // url is undefined while the stream is closed for a renewal (useOverlayCameraRecovery).
+  camera: {
+    url: string | undefined;
+    rotation: number;
+    onError: (event: SyntheticEvent<HTMLImageElement>) => void;
+    imgRef?: Ref<HTMLImageElement>;
+  } | null;
   name: string | null;
   model: string | null;
   filename: string | null;
@@ -133,7 +139,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
       {camera && (
         <div className="updated-overlay__camera" ref={cameraRef}>
           <img
-            key={camera.url}
+            ref={camera.imgRef}
             src={camera.url}
             alt={t('streamOverlay.cameraStream')}
             style={{

+ 30 - 7
frontend/src/hooks/useOverlayCameraRecovery.ts

@@ -1,20 +1,34 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState, type SyntheticEvent } from 'react';
 
 // MJPEG <img> connections can stop delivering frames without firing onError.
 // Renew only the camera connection, keeping the overlay and its credentials.
 // This also renews healthy connections; it does not measure frame freshness.
 const STREAM_RENEWAL_MS = 60_000;
 const ERROR_RETRY_MS = 3_000;
+// The old stream is closed this long before the new one opens: a detached img
+// keeps its connection open (measured in Chromium, which OBS browser sources
+// run on), and a USB camera serves one reader.
+const RENEWAL_GAP_MS = 200;
 
 export function useOverlayCameraRecovery(enabled: boolean) {
   const [imageKey, setImageKey] = useState(Date.now);
+  // While true the img renders with no src, so no re-render reopens it early.
+  const [closing, setClosing] = useState(false);
+  const imgRef = useRef<HTMLImageElement | null>(null);
   const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
 
+  const close = useCallback(() => {
+    // Closed now, not at the next render, which may already be the reopen.
+    imgRef.current?.removeAttribute('src');
+    setClosing(true);
+  }, []);
+
   const renew = useCallback(() => {
     if (retryTimer.current !== null) {
       clearTimeout(retryTimer.current);
       retryTimer.current = null;
     }
+    setClosing(false);
     setImageKey((key) => key + 1);
   }, []);
 
@@ -22,20 +36,29 @@ export function useOverlayCameraRecovery(enabled: boolean) {
     if (!enabled) return;
     // Start a fresh deadline after each retry so a newly opened connection
     // isn't immediately replaced by the previous connection's renewal timer.
+    const closeTimer = setTimeout(close, STREAM_RENEWAL_MS - RENEWAL_GAP_MS);
     const renewalTimer = setTimeout(renew, STREAM_RENEWAL_MS);
     return () => {
+      clearTimeout(closeTimer);
       clearTimeout(renewalTimer);
       if (retryTimer.current !== null) {
         clearTimeout(retryTimer.current);
         retryTimer.current = null;
       }
+      setClosing(false);
     };
-  }, [enabled, imageKey, renew]);
+  }, [enabled, imageKey, renew, close]);
 
-  const handleStreamError = useCallback(() => {
-    if (!enabled || retryTimer.current !== null) return;
-    retryTimer.current = setTimeout(renew, ERROR_RETRY_MS);
-  }, [enabled, renew]);
+  const handleStreamError = useCallback(
+    (event?: SyntheticEvent<HTMLImageElement>) => {
+      if (!enabled || retryTimer.current !== null) return;
+      // Closing the stream (src removed) fires error too.
+      if (event && !event.currentTarget.getAttribute('src')) return;
+      close();
+      retryTimer.current = setTimeout(renew, ERROR_RETRY_MS);
+    },
+    [enabled, renew, close],
+  );
 
-  return { imageKey, handleStreamError };
+  return { imageKey, closing, imgRef, handleStreamError };
 }

+ 4 - 3
frontend/src/pages/StreamOverlayPage.tsx

@@ -253,7 +253,7 @@ export function StreamOverlayPage() {
     config.showModel ? mapModelCode(printer?.model ?? null) : null,
   ].filter(Boolean).join(' · ');
   const status = kiosk ? overlay : statusData;
-  const { imageKey, handleStreamError } = useOverlayCameraRecovery(id > 0 && config.showCamera && status != null);
+  const { imageKey, closing, imgRef, handleStreamError } = useOverlayCameraRecovery(id > 0 && config.showCamera && status != null);
   const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
 
   // WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
@@ -416,7 +416,7 @@ export function StreamOverlayPage() {
       customLogo={customLogo}
       progressBackground={progressBackground}
       size={config.size}
-      camera={config.showCamera ? { url: streamUrl, rotation: printer?.camera_rotation ?? 0, onError: handleStreamError } : null}
+      camera={config.showCamera ? { url: closing ? undefined : streamUrl, rotation: printer?.camera_rotation ?? 0, onError: handleStreamError, imgRef } : null}
       name={config.showPrinter ? printer?.name ?? null : null}
       model={config.showModel ? mapModelCode(printer?.model ?? null) || null : null}
       filename={config.showFilename && status.current_print ? formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t) : null}
@@ -439,8 +439,9 @@ export function StreamOverlayPage() {
       {/* Camera feed - fullscreen background (optional) */}
       {config.showCamera && (
         <img
+          ref={imgRef}
           key={imageKey}
-          src={streamUrl}
+          src={closing ? undefined : streamUrl}
           alt={t('streamOverlay.cameraStream')}
           className="absolute inset-0 w-full h-full object-contain"
           style={dimensions ? {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AdditionalSection-CwKyz5Sr.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AmsUnitCard-PjejW833.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ArchivesPage-C3I6CLzl.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AssignToAmsModal-4K9FiRqa.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CamWallPage-CTto3-N6.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraDiagnoseModal-BTvBiyQp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraPage-UKhKNqXC.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraWall-AqjsdxyX.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraWall-D5tCGkia.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ColumnConfigModal-B9r_qSfw.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConfigureAmsSlotModal-C_VBRQMP.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConnectAuthorizePage-CReM4XzB.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ContextMenu-zUhzBJbh.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ExternalLinkPage-DWwYnu_s.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/FileManagerPage-ZiIjQrno.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/FileUploadModal-qMznQ8WM.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/FinancePage-BXvYiead.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/GCodeViewerPage-DQ2HxpM1.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/GroupEditPage-BuovOh9S.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ImagePreviewModal-ORqThLGa.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/InventoryPage-kqHgA82w.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/LibraryTrashPage-CrODOFaZ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/LoginPage-zHETLQiS.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/MaintenancePage-Dgt4Pgei.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ModelSourcesPage-6A50mNkc.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/NotificationsPage-DpgYlXHO.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/PdfPreviewModal-Dvn8HsoM.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PhotoGalleryModal-Dl_7Fygo.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PrinterLocationsPage-DFGexhU4.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PrintersPage-XKILhdfY.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProfilesPage-DcBh0XMZ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProjectDetailPage-C96Jdbwk.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProjectsPage-BY6S3QTg.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/QueuePage-D_CbSM6z.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/RichTextEditor-BaxNds12.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SettingsPage-CU1C2FWa.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SetupPage-QENmlDhA.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SliceModal-Csi9p3y8.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BC-XvSCU.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-CKdaok-0.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyDashboard-BukI3SG2.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-B0J-5BHE.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DJ3N9L6r.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-HIkTlfK2.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DKnoQiXA.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/StatsPage-XzkFxZom.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/StreamOverlayPage-B_FjRZW1.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/StreamOverlayPage-CVb7u673.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SystemInfoPage-D0zovPHr.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-hIcpEngp.js


+ 1 - 1
static/assets/inventorySearch-Cv7-TyxG.js → static/assets/inventorySearch-C_rSVtx3.js

@@ -1 +1 @@
-import{ft as e}from"./index-DUkGVBl-.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{ft as e}from"./index-hIcpEngp.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};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/locationSensorDefaults-1CLlneMk.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/pdf-CGhCyg8M.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/utils-Cv5N-dzn.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-DUkGVBl-.js"></script>
+    <script type="module" crossorigin src="/assets/index-hIcpEngp.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DnySrlFv.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-Ce8_sN3w.js">

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