Просмотр исходного кода

fix(camera): start layer timelapse for queue/VP-dispatched prints (#1353)

  Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
  print queue and got no MP4 output even though the log said "Stitching layer
  timelapse for printer 1" after each print. Support bundle confirmed the
  external camera was working (Obico was polling the snapshot URL fine for
  plate detection).

  Root cause: start_session() only ran in the two new-archive paths in
  on_print_start (fallback_archive at main.py:2510 and regular new-archive at
  2600). The expected-archive branch at main.py:1981-2052 — where every
  reprint and every queue/VP-dispatched print lands — updated the existing
  archive row to status=printing but never started a timelapse session.

  So _background_layer_timelapse ran at print complete, called tl_complete(),
  found nothing in _active_sessions, returned None silently, and the wrapper
  at main.py:3917 produced no log message for the no-session case. Every
  print through the queue silently lost its timelapse — likely the reason
  this hasn't been caught before (direct slice-and-send-to-printer prints
  take the new-archive path and work fine).

  Fix: mirror the same start_session() call in the expected-archive branch,
  guarded by the same external_camera_enabled + external_camera_url check the
  other two paths use.

  Also reworded the snapshot URL help text across all 8 locales to make clear
  that timelapse and plate detection each require their own per-printer
  toggle — the URL is just the image source they pull from when active. The
  previous wording read as if filling in the URL was sufficient.
maziggy 1 неделя назад
Родитель
Сommit
f2e3de0a63

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 18 - 0
backend/app/main.py

@@ -1991,6 +1991,24 @@ async def on_print_start(printer_id: int, data: dict):
                 if subtask_name:
                 if subtask_name:
                     _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
                     _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
 
 
+                # Start timelapse session if external camera is enabled (#1353).
+                # The two new-archive paths below also call start_session, but
+                # queue / VP-dispatched prints land here in the expected-archive
+                # branch and used to skip it entirely — so the timelapse session
+                # never started, no frames were captured, and the post-print
+                # stitch silently returned None.
+                if printer.external_camera_enabled and printer.external_camera_url:
+                    from backend.app.services.layer_timelapse import start_session
+
+                    start_session(
+                        printer_id,
+                        archive.id,
+                        printer.external_camera_url,
+                        printer.external_camera_type or "mjpeg",
+                        snapshot_url=printer.external_camera_snapshot_url,
+                    )
+                    logger.info("Started layer timelapse for printer %s, expected archive %s", printer_id, archive.id)
+
                 # Inject ams_mapping into usage tracker session — the session was created
                 # Inject ams_mapping into usage tracker session — the session was created
                 # before expected-print promotion, so it may have ams_mapping=None when
                 # before expected-print promotion, so it may have ams_mapping=None when
                 # the MQTT request topic subscription failed (common on P1S/A1).
                 # the MQTT request topic subscription failed (common on P1S/A1).

+ 223 - 0
backend/tests/unit/test_layer_timelapse_expected_archive.py

@@ -0,0 +1,223 @@
+"""Regression test for #1353: layer timelapse must start for queue/VP-dispatched prints.
+
+Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
+print queue (so each print landed in the on_print_start "expected archive"
+branch). Frames were never captured, no MP4 was produced, yet the post-print
+log line said "Stitching layer timelapse for printer 1" — because
+`tl_complete()` ran, found no active session, and silently returned None.
+
+Root cause: only the two new-archive code paths in on_print_start
+(`fallback_archive` + `archive_print`) called `layer_timelapse.start_session`.
+The expected-archive branch — where reprints and queue dispatch land —
+updated the existing archive's status to "printing" but never started a
+timelapse session.
+
+Fix: start_session is now called in the expected-archive branch too, guarded
+by the same `external_camera_enabled and external_camera_url` check that
+the other two paths use.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import (
+    _active_prints,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+    register_expected_print,
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_dicts():
+    """Clear module-level tracking dicts before and after each test."""
+    _expected_prints.clear()
+    _expected_print_registered_at.clear()
+    _expected_print_creators.clear()
+    _print_ams_mappings.clear()
+    _active_prints.clear()
+    yield
+    _expected_prints.clear()
+    _expected_print_registered_at.clear()
+    _expected_print_creators.clear()
+    _print_ams_mappings.clear()
+    _active_prints.clear()
+
+
+def _build_mocks(*, external_camera_enabled: bool, external_camera_url: str | None):
+    """Construct the mock matrix needed to drive on_print_start through the
+    expected-archive branch. Returns a dict of mock contexts that the test
+    enters via contextlib.ExitStack.
+
+    The session.execute mock returns the printer for the first call (printer
+    lookup) and the archive row for the second call (expected-archive
+    re-fetch). The archive row carries a unique filename so the
+    expected-print key lookup succeeds.
+    """
+    mock_printer = MagicMock()
+    mock_printer.id = 1
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = external_camera_enabled
+    mock_printer.external_camera_url = external_camera_url
+    mock_printer.external_camera_type = "snapshot"
+    mock_printer.external_camera_snapshot_url = external_camera_url
+    mock_printer.name = "TestA1"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 42
+    mock_archive.filename = "Universal_Spirit_level_Holder.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 1
+    mock_archive.print_name = "Universal Spirit Level Holder"
+    mock_archive.status = "pending"
+    mock_archive.file_path = "/tmp/fake.3mf"
+
+    return mock_printer, mock_archive
+
+
+@pytest.mark.asyncio
+async def test_expected_archive_path_starts_timelapse_when_external_camera_enabled():
+    """Queue/VP-dispatched prints land in the expected-archive branch and must
+    start the timelapse session there (the #1353 root cause)."""
+    mock_printer, mock_archive = _build_mocks(
+        external_camera_enabled=True, external_camera_url="http://camera.local:5000/snapshot.jpg"
+    )
+
+    # Register the expected print so the dispatch flow finds an archive_id.
+    register_expected_print(1, "Universal_Spirit_level_Holder.3mf", archive_id=42, ams_mapping=[1])
+
+    # on_print_start fires many db.execute() calls (settings lookups,
+    # usage tracker, plate detection, etc) before reaching the expected-
+    # archive branch. Route on SQL text so each query gets a sensible
+    # response regardless of order, rather than queuing N mocks.
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
+            )
+        if "from print_archives" in sql or "from print_archive" in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_archive),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
+            )
+        # Settings, spool assignments, anything else — return empty.
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    mock_session = AsyncMock()
+    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_session.__aexit__ = AsyncMock()
+    mock_session.execute = AsyncMock(side_effect=execute_router)
+    mock_session.commit = AsyncMock()
+
+    with (
+        patch("backend.app.main.async_session") as mock_session_maker,
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        # The actual subject under test: assert start_session is called.
+        patch("backend.app.services.layer_timelapse.start_session") as mock_start_session,
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(
+            1,
+            {
+                "filename": "Universal_Spirit_level_Holder.3mf",
+                "subtask_name": "Universal_Spirit_level_Holder",
+            },
+        )
+
+        mock_start_session.assert_called_once()
+        # Verify it was called with the archive_id from the expected-print
+        # registration, not a fresh one — that's the contract.
+        call_args = mock_start_session.call_args
+        assert call_args.args[0] == 1, "printer_id must match"
+        assert call_args.args[1] == 42, "archive_id must come from the expected-print registration"
+        assert call_args.args[2] == "http://camera.local:5000/snapshot.jpg"
+        assert call_args.args[3] == "snapshot"
+
+
+@pytest.mark.asyncio
+async def test_expected_archive_path_skips_timelapse_when_external_camera_disabled():
+    """The same guard that the new-archive paths use must hold here: no
+    external camera → no timelapse session. Otherwise we'd try to capture
+    from a None URL and crash the print-start flow."""
+    mock_printer, mock_archive = _build_mocks(external_camera_enabled=False, external_camera_url=None)
+
+    mock_archive.filename = "test.3mf"
+    mock_archive.id = 99
+    register_expected_print(1, "test.3mf", archive_id=99, ams_mapping=None)
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
+            )
+        if "from print_archives" in sql or "from print_archive" in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_archive),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    mock_session = AsyncMock()
+    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_session.__aexit__ = AsyncMock()
+    mock_session.execute = AsyncMock(side_effect=execute_router)
+    mock_session.commit = AsyncMock()
+
+    with (
+        patch("backend.app.main.async_session") as mock_session_maker,
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        patch("backend.app.services.layer_timelapse.start_session") as mock_start_session,
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": "test.3mf", "subtask_name": "test"})
+
+        mock_start_session.assert_not_called()

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

@@ -2116,7 +2116,7 @@ export default {
     cameraTypeUsb: 'USB-Kamera (V4L2)',
     cameraTypeUsb: 'USB-Kamera (V4L2)',
     cameraSnapshotUrl: 'Snapshot-URL (optional)',
     cameraSnapshotUrl: 'Snapshot-URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'URL für Einzelbildaufnahmen — wird für Benachrichtigungs-Vorschaubilder, Abschlussfotos, Zeitraffer und Plattenerkennung verwendet. Leer lassen, um Bilder aus dem oben konfigurierten Live-Stream zu verwenden. Nützlich für go2rtc (/api/frame.jpeg) und IP-Kameras mit dediziertem Snapshot-Endpunkt.',
+    cameraSnapshotUrlHelp: 'URL für Einzelbildaufnahmen — wird für Benachrichtigungs-Vorschaubilder, Abschlussfotos, Schicht-Zeitraffer und Plattenerkennung verwendet. Zeitraffer und Plattenerkennung benötigen jeweils eigene drucker-spezifische Schalter — diese URL ist nur die Bildquelle, die sie verwenden, wenn sie aktiv sind. Leer lassen, um Bilder aus dem oben konfigurierten Live-Stream zu verwenden. Nützlich für go2rtc (/api/frame.jpeg) und IP-Kameras mit dediziertem Snapshot-Endpunkt.',
     cameraRotation: 'Drehung',
     cameraRotation: 'Drehung',
     test: 'Testen',
     test: 'Testen',
     connected: 'Verbunden',
     connected: 'Verbunden',

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

@@ -2119,7 +2119,7 @@ export default {
     cameraTypeUsb: 'USB Camera (V4L2)',
     cameraTypeUsb: 'USB Camera (V4L2)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotation',
     cameraRotation: 'Rotation',
     test: 'Test',
     test: 'Test',
     connected: 'Connected',
     connected: 'Connected',

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -2070,7 +2070,7 @@ export default {
     cameraTypeUsb: 'Caméra USB (V4L2)',
     cameraTypeUsb: 'Caméra USB (V4L2)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotation',
     cameraRotation: 'Rotation',
     test: 'Tester',
     test: 'Tester',
     connected: 'Connecté',
     connected: 'Connecté',

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

@@ -2069,7 +2069,7 @@ export default {
     cameraTypeUsb: 'Fotocamera USB (V4L2)',
     cameraTypeUsb: 'Fotocamera USB (V4L2)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotazione',
     cameraRotation: 'Rotazione',
     test: 'Test',
     test: 'Test',
     connected: 'Connesso',
     connected: 'Connesso',

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

@@ -2115,7 +2115,7 @@ export default {
     cameraTypeUsb: 'USBカメラ (V4L2)',
     cameraTypeUsb: 'USBカメラ (V4L2)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: '回転',
     cameraRotation: '回転',
     test: 'テスト',
     test: 'テスト',
     connected: '接続済み',
     connected: '接続済み',

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

@@ -2069,7 +2069,7 @@ export default {
     cameraTypeUsb: 'Câmera USB (V4L2)',
     cameraTypeUsb: 'Câmera USB (V4L2)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotação',
     cameraRotation: 'Rotação',
     test: 'Testar',
     test: 'Testar',
     connected: 'Conectado',
     connected: 'Conectado',

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

@@ -2113,7 +2113,7 @@ export default {
     cameraTypeUsb: 'USB 摄像头 (V4L2)',
     cameraTypeUsb: 'USB 摄像头 (V4L2)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: '旋转',
     cameraRotation: '旋转',
     test: '测试',
     test: '测试',
     connected: '已连接',
     connected: '已连接',

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

@@ -2113,7 +2113,7 @@ export default {
     cameraTypeUsb: 'USB 攝影機 (V4L2)',
     cameraTypeUsb: 'USB 攝影機 (V4L2)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrl: 'Snapshot URL (optional)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: '旋轉',
     cameraRotation: '旋轉',
     test: '測試',
     test: '測試',
     connected: '已連線',
     connected: '已連線',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-mobeoqIT.js


+ 1 - 1
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов