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

Archives: each print keeps its own timelapse and photos (archive redesign P1b)

- The timelapse scan, the layer stitch and the finish photo follow the
  run that ended: its printer, its print-start baseline and its own
  video. The archive shows its newest run's video; a late video of an
  earlier plate stays on that plate's run
- A scan ends once a later print of the archive starts on its printer
  or of its plate; a reprint of a plate still replaces that plate's
  earlier videos, and other plates' videos are kept
- Finish photos go on their run and stay in the archive's gallery
- A video another print of the printer holds counts as taken, also
  another plate of the same archive
- Attaching, deleting and converting a video, and deleting a photo,
  keep the runs that hold them in step
- GET /archives/{id}?run_id= shows that run's video and photos
maziggy 21 часов назад
Родитель
Сommit
e12aa5d5de
100 измененных файлов с 1016 добавлено и 155 удалено
  1. 0 8
      .basedpyright/baseline.json
  2. 24 23
      backend/app/api/routes/archives.py
  3. 199 89
      backend/app/main.py
  4. 32 10
      backend/app/services/archive.py
  5. 125 0
      backend/app/services/print_log.py
  6. 42 0
      backend/tests/integration/test_print_complete_end_to_end.py
  7. 562 0
      backend/tests/integration/test_timelapse_per_run.py
  8. 1 0
      backend/tests/unit/services/test_attach_timelapse_safe_path.py
  9. 3 0
      backend/tests/unit/test_archive_filtering.py
  10. 2 0
      backend/tests/unit/test_send_all_timelapse_per_plate_3275.py
  11. 7 5
      backend/tests/unit/test_timelapse_scan_2704.py
  12. 0 0
      static/assets/AdditionalSection-9IDYoEpu.js
  13. 0 0
      static/assets/AmsUnitCard-BFt6fcw3.js
  14. 0 0
      static/assets/ArchivesPage-JEEGcZqh.js
  15. 0 0
      static/assets/AreaChart-C7o0wAO4.js
  16. 0 0
      static/assets/AssignToAmsModal-B56xFY46.js
  17. 0 0
      static/assets/AuthContext-BkJO3em_.js
  18. 1 1
      static/assets/Button-DYYZvzLo.js
  19. 0 0
      static/assets/CamWallPage-Dz3o04by.js
  20. 0 0
      static/assets/CameraDiagnoseModal-BifWXcZc.js
  21. 0 0
      static/assets/CameraPage-CSHl71kG.js
  22. 0 0
      static/assets/CameraWall-C-uifLlE.js
  23. 1 1
      static/assets/Card-D4V_oQwu.js
  24. 0 0
      static/assets/CartesianChart-DreHQWhJ.js
  25. 0 0
      static/assets/ColumnConfigModal-Cgev-XtU.js
  26. 0 0
      static/assets/ConfigureAmsSlotModal-CORIRX0n.js
  27. 0 0
      static/assets/ConfirmModal-BAcVwl-S.js
  28. 0 0
      static/assets/ConfirmOutcomeDialog-DQcbXiuf.js
  29. 0 0
      static/assets/ConfirmOutcomeDialog-Dal-cSao.js
  30. 0 0
      static/assets/ConnectAuthorizePage-By2XwAPt.js
  31. 0 0
      static/assets/ConnectionDiagnostic-DQAdSC36.js
  32. 0 0
      static/assets/ContextMenu-focSVK91.js
  33. 0 0
      static/assets/CopyButton-PcW_3Anh.js
  34. 0 0
      static/assets/EmbeddedCameraViewer-DJDisui-.js
  35. 0 0
      static/assets/ErrorBarContext-Bjss1n8z.js
  36. 0 0
      static/assets/ExternalLinkPage-0rS8mS28.js
  37. 1 1
      static/assets/FilamentSwatch-YN9cyuiV.js
  38. 0 1
      static/assets/FileManagerPage-CCg1jTJR.js
  39. 0 0
      static/assets/FileUploadModal-B7-ZgclE.js
  40. 0 0
      static/assets/FinancePage-CExgNu-s.js
  41. 0 0
      static/assets/GCodeViewerPage-BmMNin-K.js
  42. 0 0
      static/assets/GcodeToolpathViewer-DbikpNEN.js
  43. 0 0
      static/assets/GroupEditPage-Wh6lHVkv.js
  44. 0 0
      static/assets/IconPicker-Bs7QtuML.js
  45. 0 0
      static/assets/ImagePreviewModal-CSS-jpjU.js
  46. 0 0
      static/assets/InventoryPage-CuQzKTSx.js
  47. 0 0
      static/assets/Layout-B1sOAU5B.js
  48. 0 0
      static/assets/Layout-fff8uYnZ.js
  49. 0 0
      static/assets/LibraryTrashPage-rCmFsW8R.js
  50. 0 0
      static/assets/LoginPage-PMmE2MUa.js
  51. 0 0
      static/assets/MaintenancePage-D4Z_E4vg.js
  52. 0 0
      static/assets/ModelSourcesPage-bTazZy_r.js
  53. 0 0
      static/assets/NotificationsPage-Ckeq1eRc.js
  54. 1 1
      static/assets/NumberInput-BaT0XBfV.js
  55. 1 1
      static/assets/OverlayFrame-Bftf9d2g.js
  56. 0 0
      static/assets/PdfPreviewModal-BcAzK05M.js
  57. 0 0
      static/assets/PhotoGalleryModal-D50nEPW2.js
  58. 0 0
      static/assets/PrintModal-BN27coZJ.js
  59. 0 0
      static/assets/PrinterLocationsPage-B50mzuk9.js
  60. 0 0
      static/assets/PrintersPage-Dmjv7rQi.js
  61. 0 0
      static/assets/ProfilesPage-BX1ovOk3.js
  62. 0 0
      static/assets/ProjectDetailPage-_hJr_5fD.js
  63. 0 0
      static/assets/ProjectsPage-wUNwt7E7.js
  64. 0 0
      static/assets/QueryClientProvider-BytsNH5G.js
  65. 0 0
      static/assets/QueuePage-HjOyEbVP.js
  66. 0 0
      static/assets/ReferenceLine-D2TF4QRA.js
  67. 0 0
      static/assets/RichTextEditor-Ba_DNCVd.js
  68. 0 0
      static/assets/SettingsPage-Bqq75Ayx.js
  69. 0 0
      static/assets/SetupPage-CHrCcuTL.js
  70. 0 0
      static/assets/SkipObjectsModal-ixrCEckS.js
  71. 0 0
      static/assets/SliceModal-Ckev_VNF.js
  72. 0 0
      static/assets/SpoolBuddyAmsPage-BnkN1nhN.js
  73. 0 0
      static/assets/SpoolBuddyDashboard-DfMqFYjX.js
  74. 0 0
      static/assets/SpoolBuddyInventoryPage-7Bf5DCCS.js
  75. 1 1
      static/assets/SpoolBuddySettingsPage-Cl4HkltR.js
  76. 0 0
      static/assets/SpoolBuddyWriteTagPage-DnL3CKC8.js
  77. 1 1
      static/assets/SpoolIcon-BBThOs9Y.js
  78. 0 0
      static/assets/SpreadsheetPreviewModal-CH2TrBYh.js
  79. 0 0
      static/assets/StatsPage-BzYOUuyx.js
  80. 0 0
      static/assets/StreamOverlayPage-DcCOW1P0.js
  81. 0 0
      static/assets/SystemHealthPanel-BxDo8Ewn.js
  82. 0 0
      static/assets/SystemInfoPage-FqBce2iU.js
  83. 0 0
      static/assets/ThemeContext-CxrwGucU.js
  84. 0 0
      static/assets/ToastContext-C1KWXcWl.js
  85. 1 1
      static/assets/Toggle-DVKVuNmx.js
  86. 1 1
      static/assets/archive-CPxb9vAg.js
  87. 1 0
      static/assets/arrow-down-DWfwAA1B.js
  88. 0 1
      static/assets/arrow-down-K9BctJx-.js
  89. 1 0
      static/assets/arrow-left-59yC6trx.js
  90. 0 1
      static/assets/arrow-left-uORrkJgb.js
  91. 0 1
      static/assets/arrow-right-CYovoxi-.js
  92. 1 0
      static/assets/arrow-right-FF8C7ZzY.js
  93. 1 0
      static/assets/arrow-up-DeMSWxAI.js
  94. 0 1
      static/assets/arrow-up-DjMuLGFa.js
  95. 1 1
      static/assets/arrow-up-down-BD2P5n9I.js
  96. 1 1
      static/assets/bell-DFhLXGbL.js
  97. 1 1
      static/assets/box-DICznt0m.js
  98. 1 1
      static/assets/briefcase-CYs7NlTT.js
  99. 1 1
      static/assets/calendar-BMe2cZro.js
  100. 1 1
      static/assets/camera-FLMbnnIS.js

+ 0 - 8
.basedpyright/baseline.json

@@ -1795,14 +1795,6 @@
                     "lineCount": 1
                     "lineCount": 1
                 }
                 }
             },
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 61,
-                    "endColumn": 79,
-                    "lineCount": 1
-                }
-            },
             {
             {
                 "code": "reportArgumentType",
                 "code": "reportArgumentType",
                 "range": {
                 "range": {

+ 24 - 23
backend/app/api/routes/archives.py

@@ -32,7 +32,7 @@ from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
-from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spool_usage_history import SpoolUsageHistory
@@ -49,6 +49,7 @@ from backend.app.services.print_confirmation import (
     is_unattended_fetch,
     is_unattended_fetch,
 )
 )
 from backend.app.services.print_cost_estimate import default_cost_per_kg as _default_cost_per_kg, price_one_print
 from backend.app.services.print_cost_estimate import default_cost_per_kg as _default_cost_per_kg, price_one_print
+from backend.app.services.print_log import owns_media, runs_with_timelapse
 from backend.app.services.print_storage import (
 from backend.app.services.print_storage import (
     REASON_FTP_TRANSFER_FAILED,
     REASON_FTP_TRANSFER_FAILED,
     REASON_FTPS_COOLOFF,
     REASON_FTPS_COOLOFF,
@@ -157,25 +158,15 @@ def _match_timelapse_by_timestamp(
 
 
 
 
 async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_id: int) -> set[str]:
 async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_id: int) -> set[str]:
-    """Video filenames already attached to another archive of this printer (#2704).
-
-    Lets the baseline diff drop a previous print's late-landing video from the
-    candidate list without ordering the candidates — ordering could only be done
-    on mtime or the filename timestamp, and both come from a clock the printer
-    can't sync in LAN-only mode. ``attach_timelapse`` stores the video under the
-    printer's own filename and the MP4 conversion keeps the stem, so the stem of
-    ``timelapse_path`` is what was claimed.
-    """
-    if printer_id is None:
-        return set()
-    rows = await db.execute(
-        select(PrintArchive.timelapse_path).where(
-            PrintArchive.printer_id == printer_id,
-            PrintArchive.id != exclude_archive_id,
-            PrintArchive.timelapse_path.is_not(None),
-        )
+    """Video filenames already attached to another print of this printer
+    (#2704): ``print_log.claimed_timelapse_stems``, for the print the archive
+    shows, its newest run."""
+    from backend.app.services.print_log import claimed_timelapse_stems, newest_run
+
+    run = await newest_run(db, exclude_archive_id)
+    return await claimed_timelapse_stems(
+        db, printer_id, archive_id=exclude_archive_id, run_id=run.id if run is not None else None
     )
     )
-    return {Path(p).stem for p in rows.scalars().all() if p}
 
 
 
 
 def _visible_archive_clauses(user, can_read_all: bool, printer_scope: PrinterScope) -> list:
 def _visible_archive_clauses(user, can_read_all: bool, printer_scope: PrinterScope) -> list:
@@ -366,6 +357,12 @@ def _with_run_outcome(data: dict, run: PrintLogEntry) -> dict:
             else data["confirm_requested"],
             else data["confirm_requested"],
         }
         }
     )
     )
+    # Its own video and finish photos, once it keeps them; one logged before
+    # shares the archive's.
+    if owns_media(run):
+        data["timelapse_path"] = run.timelapse_path
+    if run.photos is not None:
+        data["photos"] = run.photos or None
     return data
     return data
 
 
 
 
@@ -617,8 +614,6 @@ async def list_last_archive_per_printer(
     """
     """
     from sqlalchemy.orm import selectinload
     from sqlalchemy.orm import selectinload
 
 
-    from backend.app.models.print_log import INCLUDE_OPEN_RUNS
-
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     filters: list[ColumnElement[bool]] = [PrintArchive.deleted_at.is_(None)]
     filters: list[ColumnElement[bool]] = [PrintArchive.deleted_at.is_(None)]
     if user is not None and not can_read_all:
     if user is not None and not can_read_all:
@@ -2995,7 +2990,9 @@ async def delete_timelapse(
     if timelapse_path.exists():
     if timelapse_path.exists():
         timelapse_path.unlink()
         timelapse_path.unlink()
 
 
-    # Clear the path in database
+    # Clear the path in database, on the runs that held it too
+    for run in await runs_with_timelapse(db, archive.id, archive.timelapse_path):
+        run.timelapse_path = None
     archive.timelapse_path = None
     archive.timelapse_path = None
     archive.timelapse_plate_id = None
     archive.timelapse_plate_id = None
     await db.commit()
     await db.commit()
@@ -3830,9 +3827,13 @@ async def delete_photo(
     if photo_path is not None:
     if photo_path is not None:
         photo_path.unlink()
         photo_path.unlink()
 
 
-    # Update archive photos list
+    # Update archive photos list, and the list of the run it was taken for
     photos = [p for p in archive.photos if p != filename]
     photos = [p for p in archive.photos if p != filename]
     archive.photos = photos if photos else None
     archive.photos = photos if photos else None
+    runs = select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id)
+    for run in await db.scalars(runs.execution_options(**{INCLUDE_OPEN_RUNS: True})):
+        if run.photos and filename in run.photos:
+            run.photos = [p for p in run.photos if p != filename]
 
 
     await db.commit()
     await db.commit()
 
 

+ 199 - 89
backend/app/main.py

@@ -1046,6 +1046,42 @@ async def _open_run_for(archive_id: int, printer_id: int, subtask_id: str | None
         logging.getLogger(__name__).warning("[RUN] Could not open the run of archive %s: %s", archive_id, e)
         logging.getLogger(__name__).warning("[RUN] Could not open the run of archive %s: %s", archive_id, e)
 
 
 
 
+async def _drop_replaced_plate_videos(
+    db, archive_id: int, plate_id: int | None, stale_relpath: str | None, logger: logging.Logger
+) -> None:
+    """Delete the videos a reprint of a plate replaces: ``stale_relpath``, the
+    one the archive showed (when print start judged it this plate's), and
+    those of the archive's earlier runs of the same plate. A plate keeps one
+    video, as when the archive held the only one; another plate's stays. The
+    runs that held them lose them. Commits."""
+    from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
+    from backend.app.utils.safe_path import PathTraversalError, safe_join_under
+
+    runs = select(PrintLogEntry).where(
+        PrintLogEntry.archive_id == archive_id, PrintLogEntry.timelapse_path.is_not(None)
+    )
+    with_video = list(await db.scalars(runs.execution_options(**{INCLUDE_OPEN_RUNS: True})))
+    paths = {stale_relpath} if stale_relpath else set()
+    if plate_id is not None:
+        paths |= {r.timelapse_path for r in with_video if r.plate_id == plate_id}
+        # A video another plate's run holds is that plate's.
+        paths -= {r.timelapse_path for r in with_video if r.plate_id is not None and r.plate_id != plate_id}
+    if not paths:
+        return
+    for run in with_video:
+        if run.timelapse_path in paths:
+            run.timelapse_path = None
+    await db.commit()
+    for relpath in sorted(p for p in paths if p):
+        try:
+            stale_path = safe_join_under(app_settings.base_dir, relpath, http=False)
+            if stale_path.is_file():
+                stale_path.unlink()
+                logger.info("Deleted stale timelapse %s on reprint of archive %s", relpath, archive_id)
+        except (OSError, PathTraversalError) as e:
+            logger.warning("Failed to delete stale timelapse %s on reprint: %s", relpath, e)
+
+
 def _mark_owned_if_dispatched(archive) -> None:
 def _mark_owned_if_dispatched(archive) -> None:
     """Remember a running print's archive as Bambuddy's when it carries a
     """Remember a running print's archive as Bambuddy's when it carries a
     dispatch marker Bambuddy writes itself: ``billing_run_id`` (minted per
     dispatch marker Bambuddy writes itself: ``billing_run_id`` (minted per
@@ -5301,22 +5337,13 @@ async def on_print_start(printer_id: int, data: dict):
                     await forget_printer(printer_id)
                     await forget_printer(printer_id)
                     return
                     return
 
 
-                if delete_stale_video and stale_timelapse_relpath:
-                    try:
-                        stale_path = app_settings.base_dir / stale_timelapse_relpath
-                        if stale_path.is_file():
-                            stale_path.unlink()
-                            logger.info(
-                                "Deleted stale timelapse %s on reprint of archive %s",
-                                stale_timelapse_relpath,
-                                expected_archive_id,
-                            )
-                    except OSError as e:
-                        logger.warning(
-                            "Failed to delete stale timelapse %s on reprint: %s",
-                            stale_timelapse_relpath,
-                            e,
-                        )
+                await _drop_replaced_plate_videos(
+                    db,
+                    archive.id,
+                    starting_plate_id,
+                    stale_timelapse_relpath if delete_stale_video else None,
+                    logger,
+                )
 
 
                 # Track as active print
                 # Track as active print
                 _bind_active_print(printer_id, archive.id, archive.filename, filename, subtask_name)
                 _bind_active_print(printer_id, archive.id, archive.filename, filename, subtask_name)
@@ -6562,32 +6589,6 @@ def _timelapse_scan_max_attempts() -> int:
     return max(1, int(_TIMELAPSE_SCAN_TIMEOUT_SECONDS // _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS) + 1)
     return max(1, int(_TIMELAPSE_SCAN_TIMEOUT_SECONDS // _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS) + 1)
 
 
 
 
-async def _claimed_timelapse_names(db, printer_id: int, exclude_archive_id: int) -> set[str]:
-    """Video filenames already attached to some other archive of this printer.
-
-    Used to disambiguate when more than one file is new since the baseline —
-    which happens when a previous print's video landed after this print's
-    baseline was taken. Ordering the candidates would be the obvious fix and is
-    the wrong one: it can only be done on mtime or on the filename timestamp,
-    both of which come from the printer's own clock, and a LAN-only printer
-    can't reach Bambu's NTP server. Exclusion needs no clock at all.
-
-    ``attach_timelapse`` saves the video into the archive directory under the
-    printer's original filename, and the later MP4 conversion keeps the stem,
-    so the stem of ``timelapse_path`` recovers what was claimed.
-    """
-    from backend.app.models.archive import PrintArchive
-
-    rows = await db.execute(
-        select(PrintArchive.timelapse_path).where(
-            PrintArchive.printer_id == printer_id,
-            PrintArchive.id != exclude_archive_id,
-            PrintArchive.timelapse_path.is_not(None),
-        )
-    )
-    return {Path(p).stem for p in rows.scalars().all() if p}
-
-
 async def _read_timelapse_baseline(printer) -> tuple[set[str], bool]:
 async def _read_timelapse_baseline(printer) -> tuple[set[str], bool]:
     """The video names on *printer*'s card, and whether the card was read.
     """The video names on *printer*'s card, and whether the card was read.
 
 
@@ -6741,10 +6742,12 @@ async def _capture_timelapse_baseline_at_start(
         logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
         logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
 
 
     run_started_at: datetime | None = None
     run_started_at: datetime | None = None
+    run_id: int | None = None
     if archive_id is not None:
     if archive_id is not None:
         try:
         try:
             async with async_session() as db:
             async with async_session() as db:
                 from backend.app.models.archive import PrintArchive
                 from backend.app.models.archive import PrintArchive
+                from backend.app.services.print_log import open_run_on_printer
 
 
                 archive = await db.get(PrintArchive, archive_id)
                 archive = await db.get(PrintArchive, archive_id)
                 if archive is not None:
                 if archive is not None:
@@ -6754,9 +6757,18 @@ async def _capture_timelapse_baseline_at_start(
                     # against the state of the printer before the *last* one —
                     # against the state of the printer before the *last* one —
                     # and a stale baseline reads as authoritative, where NULL
                     # and a stale baseline reads as authoritative, where NULL
                     # correctly falls back to a fresh snapshot.
                     # correctly falls back to a fresh snapshot.
-                    archive.timelapse_baseline = sorted(names) if names is not None else None
+                    baseline = sorted(names) if names is not None else None
+                    archive.timelapse_baseline = baseline
                     archive.timelapse_baseline_unread = unread
                     archive.timelapse_baseline_unread = unread
                     run_started_at = archive.started_at
                     run_started_at = archive.started_at
+                    # And on the run this print opened, whose scan reads it:
+                    # the archive's is its latest start's, which another plate
+                    # of it on another printer can take over.
+                    run = await open_run_on_printer(db, printer_id)
+                    if run is not None and run.archive_id == archive_id:
+                        run.timelapse_baseline = baseline
+                        run.timelapse_baseline_unread = unread
+                        run_id = run.id
                     await db.commit()
                     await db.commit()
         except Exception as e:
         except Exception as e:
             # In-memory baseline still covers the normal completion path.
             # In-memory baseline still covers the normal completion path.
@@ -6764,7 +6776,7 @@ async def _capture_timelapse_baseline_at_start(
 
 
     if unread and names is not None:
     if unread and names is not None:
         spawn_background_task(
         spawn_background_task(
-            _reread_unread_timelapse_baseline(printer_id, names, archive_id, run_started_at),
+            _reread_unread_timelapse_baseline(printer_id, names, archive_id, run_started_at, run_id),
             name=f"reread-timelapse-baseline-{printer_id}",
             name=f"reread-timelapse-baseline-{printer_id}",
         )
         )
 
 
@@ -6774,17 +6786,22 @@ async def _capture_timelapse_baseline_at_start(
 _BASELINE_REREAD_STATES = _ACTIVE_PRINT_STATES | {"PREPARE", "SLICING"}
 _BASELINE_REREAD_STATES = _ACTIVE_PRINT_STATES | {"PREPARE", "SLICING"}
 
 
 
 
-def _schedule_timelapse_scan(archive_id: int, printer_id: int, *, plate_id: int | None) -> None:
+def _schedule_timelapse_scan(
+    archive_id: int, printer_id: int, *, plate_id: int | None, run_id: int | None = None
+) -> None:
     """Start the completion scan with the print-start baseline: the names, and
     """Start the completion scan with the print-start baseline: the names, and
     whether they were taken off a card that could not be read. Taking them
     whether they were taken off a card that could not be read. Taking them
-    ends any re-read still running for it."""
+    ends any re-read still running for it. ``run_id`` is the print that
+    ended, whose video it looks for."""
     unread = printer_id in _timelapse_baselines_unread
     unread = printer_id in _timelapse_baselines_unread
     _timelapse_baselines_unread.discard(printer_id)
     _timelapse_baselines_unread.discard(printer_id)
     baseline = _timelapse_baselines.pop(printer_id, None)
     baseline = _timelapse_baselines.pop(printer_id, None)
     spawn_background_task(
     spawn_background_task(
         _mark_run_finishing(
         _mark_run_finishing(
             archive_id,
             archive_id,
-            _scan_for_timelapse_with_retries(archive_id, baseline, plate_id=plate_id, baseline_unread=unread),
+            _scan_for_timelapse_with_retries(
+                archive_id, baseline, plate_id=plate_id, baseline_unread=unread, run_id=run_id
+            ),
         ),
         ),
         name=f"scan-timelapse-{archive_id}",
         name=f"scan-timelapse-{archive_id}",
     )
     )
@@ -6810,7 +6827,11 @@ def _unread_baseline_round(printer_id: int, names: set[str]) -> bool | None:
 
 
 
 
 async def _reread_unread_timelapse_baseline(
 async def _reread_unread_timelapse_baseline(
-    printer_id: int, names: set[str], archive_id: int | None, run_started_at: datetime | None
+    printer_id: int,
+    names: set[str],
+    archive_id: int | None,
+    run_started_at: datetime | None,
+    run_id: int | None = None,
 ) -> None:
 ) -> None:
     """Read again a print-start baseline the card could not give (#3412).
     """Read again a print-start baseline the card could not give (#3412).
 
 
@@ -6822,6 +6843,7 @@ async def _reread_unread_timelapse_baseline(
     """
     """
     from backend.app.models.archive import PrintArchive
     from backend.app.models.archive import PrintArchive
     from backend.app.models.printer import Printer
     from backend.app.models.printer import Printer
+    from backend.app.services.print_log import run_of
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
     for _ in range(_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS):
     for _ in range(_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS):
@@ -6868,7 +6890,11 @@ async def _reread_unread_timelapse_baseline(
                 if archive is not None and archive.started_at == run_started_at and archive.timelapse_baseline_unread:
                 if archive is not None and archive.started_at == run_started_at and archive.timelapse_baseline_unread:
                     archive.timelapse_baseline = sorted(names)
                     archive.timelapse_baseline = sorted(names)
                     archive.timelapse_baseline_unread = False
                     archive.timelapse_baseline_unread = False
-                    await db.commit()
+                run = await run_of(db, archive_id, run_id) if run_id is not None else None
+                if run is not None and run.timelapse_baseline_unread:
+                    run.timelapse_baseline = sorted(names)
+                    run.timelapse_baseline_unread = False
+                await db.commit()
         except Exception as e:
         except Exception as e:
             # The in-memory baseline is read now, which covers completion
             # The in-memory baseline is read now, which covers completion
             # unless Bambuddy restarts first.
             # unless Bambuddy restarts first.
@@ -6876,12 +6902,26 @@ async def _reread_unread_timelapse_baseline(
         return
         return
 
 
 
 
+async def _timelapse_still_wanted(db, archive, run_id: int | None, run_started_at: datetime | None) -> bool:
+    """Whether the print a timelapse is for still has none: its run, by id,
+    exists without a video and no later print took over (``superseded``).
+    Without a run, the archive is still on that print's start (a newer run's
+    video is its own) and has none."""
+    from backend.app.services.print_log import run_of, superseded
+
+    if run_id is not None:
+        run = await run_of(db, archive.id, run_id)
+        return run is not None and run.timelapse_path is None and not await superseded(db, run)
+    return archive.timelapse_path is None and archive.started_at == run_started_at
+
+
 async def _scan_for_timelapse_with_retries(
 async def _scan_for_timelapse_with_retries(
     archive_id: int,
     archive_id: int,
     baseline_names: set[str] | None = None,
     baseline_names: set[str] | None = None,
     *,
     *,
     plate_id: int | None = None,
     plate_id: int | None = None,
     baseline_unread: bool = False,
     baseline_unread: bool = False,
+    run_id: int | None = None,
 ):
 ):
     """Poll the printer for this print's timelapse and attach it.
     """Poll the printer for this print's timelapse and attach it.
 
 
@@ -6903,7 +6943,14 @@ async def _scan_for_timelapse_with_retries(
 
 
     ``baseline_unread`` marks the caller's baseline as taken off a card that
     ``baseline_unread`` marks the caller's baseline as taken off a card that
     could not be read; a stored one carries the same mark on the archive.
     could not be read; a stored one carries the same mark on the archive.
+
+    ``run_id`` is the print that ended (archive redesign): the video is that
+    run's, the stored baseline is the run's own, and the poll ends once the
+    run has a video or is gone. Without it (no run was written), the run is
+    whichever the archive is on when the scan starts.
     """
     """
+    from backend.app.services.print_log import run_of
+
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
     # Cleared when the baseline was taken off a card we could not read, so the
     # Cleared when the baseline was taken off a card we could not read, so the
@@ -6921,27 +6968,36 @@ async def _scan_for_timelapse_with_retries(
             if not archive:
             if not archive:
                 logger.warning("[TIMELAPSE] Archive %s not found, aborting", archive_id)
                 logger.warning("[TIMELAPSE] Archive %s not found, aborting", archive_id)
                 return
                 return
-            if archive.timelapse_path:
+            run = await run_of(db, archive_id, run_id) if run_id is not None else None
+            if run_id is not None and run is None:
+                logger.info("[TIMELAPSE] Run %s of archive %s is gone, aborting", run_id, archive_id)
+                return
+            if run.timelapse_path if run is not None else archive.timelapse_path:
                 logger.info("[TIMELAPSE] Archive %s already has timelapse attached", archive_id)
                 logger.info("[TIMELAPSE] Archive %s already has timelapse attached", archive_id)
                 return
                 return
-            if not archive.printer_id:
+            # The printer the print ran on: plates of one archive can print on
+            # two printers, and the archive's is the one that started last.
+            scan_printer_id = run.printer_id if run is not None and run.printer_id else archive.printer_id
+            if not scan_printer_id:
                 logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
                 logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
                 return
                 return
             # The run this scan is for. The archive row passes to a newer run
             # The run this scan is for. The archive row passes to a newer run
             # when it prints again (the next plate, a reprint), and that run's
             # when it prints again (the next plate, a reprint), and that run's
             # video is its own.
             # video is its own.
-            run_started_at = archive.started_at
-
-            if (
-                baseline_names is not None
-                and baseline_unread
-                and archive.timelapse_baseline is not None
-                and not archive.timelapse_baseline_unread
-            ):
+            run_started_at = run.started_at if run is not None else archive.started_at
+            # The baseline stored for it: the run's own, else the archive's
+            # while that is still this run's start's.
+            stored_baseline, stored_unread = None, False
+            if run is not None and run.timelapse_baseline is not None:
+                stored_baseline, stored_unread = run.timelapse_baseline, bool(run.timelapse_baseline_unread)
+            elif archive.started_at == run_started_at:
+                stored_baseline, stored_unread = archive.timelapse_baseline, archive.timelapse_baseline_unread
+
+            if baseline_names is not None and baseline_unread and stored_baseline is not None and not stored_unread:
                 # The card was read at print start, and the in-memory copy is
                 # The card was read at print start, and the in-memory copy is
                 # one Bambuddy took after a restart off a card it could not
                 # one Bambuddy took after a restart off a card it could not
                 # read: the stored one is this run's and is evidence.
                 # read: the stored one is this run's and is evidence.
-                baseline_names = set(archive.timelapse_baseline)
+                baseline_names = set(stored_baseline)
                 logger.info(
                 logger.info(
                     "[TIMELAPSE] Using stored baseline over an unread one: %s existing video files for archive %s",
                     "[TIMELAPSE] Using stored baseline over an unread one: %s existing video files for archive %s",
                     len(baseline_names),
                     len(baseline_names),
@@ -6954,17 +7010,17 @@ async def _scan_for_timelapse_with_retries(
                     len(baseline_names),
                     len(baseline_names),
                     archive_id,
                     archive_id,
                 )
                 )
-            elif archive.timelapse_baseline is not None:
+            elif stored_baseline is not None:
                 # Persisted at print start — survives a restart mid-print.
                 # Persisted at print start — survives a restart mid-print.
-                baseline_names = set(archive.timelapse_baseline)
-                baseline_trusted = not archive.timelapse_baseline_unread
+                baseline_names = set(stored_baseline)
+                baseline_trusted = not stored_unread
                 logger.info(
                 logger.info(
                     "[TIMELAPSE] Using stored baseline: %s existing video files for archive %s",
                     "[TIMELAPSE] Using stored baseline: %s existing video files for archive %s",
                     len(baseline_names),
                     len(baseline_names),
                     archive_id,
                     archive_id,
                 )
                 )
             else:
             else:
-                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+                result = await db.execute(select(Printer).where(Printer.id == scan_printer_id))
                 printer = result.scalar_one_or_none()
                 printer = result.scalar_one_or_none()
                 if not printer:
                 if not printer:
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
@@ -6984,7 +7040,7 @@ async def _scan_for_timelapse_with_retries(
                         "read. A single new video still resolves; several will not be guessed between — "
                         "read. A single new video still resolves; several will not be guessed between — "
                         "use Scan for Timelapse to pick one by hand",
                         "use Scan for Timelapse to pick one by hand",
                         archive_id,
                         archive_id,
-                        archive.printer_id,
+                        scan_printer_id,
                     )
                     )
                 logger.info(
                 logger.info(
                     "[TIMELAPSE] Baseline snapshot (fallback): %s existing video files for archive %s",
                     "[TIMELAPSE] Baseline snapshot (fallback): %s existing video files for archive %s",
@@ -7016,28 +7072,28 @@ async def _scan_for_timelapse_with_retries(
             # across the FTP round-trips left one connection idle-in-transaction per
             # across the FTP round-trips left one connection idle-in-transaction per
             # in-flight scan (issue #2572).
             # in-flight scan (issue #2572).
             async with async_session() as db:
             async with async_session() as db:
+                from backend.app.services.print_log import claimed_timelapse_stems
+
                 service = ArchiveService(db)
                 service = ArchiveService(db)
                 archive = await service.get_archive(archive_id)
                 archive = await service.get_archive(archive_id)
 
 
                 if not archive:
                 if not archive:
                     logger.warning("[TIMELAPSE] Archive %s not found, stopping poll", archive_id)
                     logger.warning("[TIMELAPSE] Archive %s not found, stopping poll", archive_id)
                     return
                     return
-                if archive.timelapse_path:
-                    logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping poll", archive_id)
-                    return
-                if archive.started_at != run_started_at:
+                if not await _timelapse_still_wanted(db, archive, run_id, run_started_at):
                     logger.info(
                     logger.info(
-                        "[TIMELAPSE] Archive %s is printing again, stopping the previous run's poll", archive_id
+                        "[TIMELAPSE] Archive %s has its video or is printing again, stopping the run's poll",
+                        archive_id,
                     )
                     )
                     return
                     return
 
 
-                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+                result = await db.execute(select(Printer).where(Printer.id == scan_printer_id))
                 printer = result.scalar_one_or_none()
                 printer = result.scalar_one_or_none()
                 if not printer:
                 if not printer:
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping poll", archive_id)
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping poll", archive_id)
                     return
                     return
 
 
-                claimed = await _claimed_timelapse_names(db, archive.printer_id, archive_id)
+                claimed = await claimed_timelapse_stems(db, scan_printer_id, archive_id=archive_id, run_id=run_id)
 
 
             # I/O phase (no DB connection held): FTP list + download.
             # I/O phase (no DB connection held): FTP list + download.
             video_files, found_path = await _list_timelapse_videos(printer)
             video_files, found_path = await _list_timelapse_videos(printer)
@@ -7068,6 +7124,7 @@ async def _scan_for_timelapse_with_retries(
                     require_unambiguous=not baseline_trusted,
                     require_unambiguous=not baseline_trusted,
                     plate_id=plate_id,
                     plate_id=plate_id,
                     run_started_at=run_started_at,
                     run_started_at=run_started_at,
+                    run_id=run_id,
                 )
                 )
                 if attached:
                 if attached:
                     return
                     return
@@ -7092,7 +7149,11 @@ async def _scan_for_timelapse_with_retries(
 
 
 
 
 async def _attach_layer_timelapse(
 async def _attach_layer_timelapse(
-    archive_id: int, timelapse_path: Path, plate_id: int | None, run_started_at: datetime | None
+    archive_id: int,
+    timelapse_path: Path,
+    plate_id: int | None,
+    run_started_at: datetime | None,
+    run_id: int | None = None,
 ) -> None:
 ) -> None:
     """Attach the external camera's stitched timelapse to the archive.
     """Attach the external camera's stitched timelapse to the archive.
 
 
@@ -7103,7 +7164,12 @@ async def _attach_layer_timelapse(
     the run the frames were taken for: when the archive is printing again by
     the run the frames were taken for: when the archive is printing again by
     the time the stitch is done, the video is not the new run's and is
     the time the stitch is done, the video is not the new run's and is
     dropped. The stitched file is a temporary either way.
     dropped. The stitched file is a temporary either way.
+
+    ``run_id`` is the print that ended (archive redesign): the video is that
+    run's and replaces that run's printer video; the poll by start above is
+    for a print without a run.
     """
     """
+    from backend.app.services.print_log import run_of, superseded
     from backend.app.utils.safe_path import PathTraversalError, safe_join_under
     from backend.app.utils.safe_path import PathTraversalError, safe_join_under
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
@@ -7113,17 +7179,31 @@ async def _attach_layer_timelapse(
             archive = await service.get_archive(archive_id)
             archive = await service.get_archive(archive_id)
             if archive is None:
             if archive is None:
                 return
                 return
-            if archive.started_at != run_started_at:
+            run = await run_of(db, archive_id, run_id) if run_id is not None else None
+            if run_id is not None and run is None:
+                logger.info("[LAYER-TL] Run %s of archive %s is gone; dropping its layer timelapse", run_id, archive_id)
+                return
+            if run is not None and await superseded(db, run):
+                logger.info(
+                    "[LAYER-TL] A later print took over run %s of archive %s; dropping its layer timelapse",
+                    run_id,
+                    archive_id,
+                )
+                return
+            if run is None and archive.started_at != run_started_at:
                 logger.info(
                 logger.info(
                     "[LAYER-TL] Archive %s is printing again; dropping the previous run's layer timelapse", archive_id
                     "[LAYER-TL] Archive %s is printing again; dropping the previous run's layer timelapse", archive_id
                 )
                 )
                 return
                 return
-            replaced = archive.timelapse_path
+            holder = run if run is not None else archive
+            replaced = holder.timelapse_path
             logger.info("[LAYER-TL] Attaching timelapse %s to archive %s", timelapse_path, archive_id)
             logger.info("[LAYER-TL] Attaching timelapse %s to archive %s", timelapse_path, archive_id)
             timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)
             timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)
-            if not await service.attach_timelapse(archive_id, timelapse_data, "layer_timelapse.mp4", plate_id=plate_id):
+            if not await service.attach_timelapse(
+                archive_id, timelapse_data, "layer_timelapse.mp4", plate_id=plate_id, run_id=run_id
+            ):
                 return
                 return
-            attached = archive.timelapse_path
+            attached = holder.timelapse_path
         logger.info("[LAYER-TL] Layer timelapse attached successfully")
         logger.info("[LAYER-TL] Layer timelapse attached successfully")
         if replaced and replaced != attached:
         if replaced and replaced != attached:
             try:
             try:
@@ -7153,6 +7233,7 @@ async def _attach_first_unclaimed_timelapse(
     require_unambiguous: bool = False,
     require_unambiguous: bool = False,
     plate_id: int | None = None,
     plate_id: int | None = None,
     run_started_at: datetime | None | object = _ANY_RUN,
     run_started_at: datetime | None | object = _ANY_RUN,
+    run_id: int | None = None,
 ) -> bool:
 ) -> bool:
     """Download and attach the one video that belongs to this print.
     """Download and attach the one video that belongs to this print.
 
 
@@ -7266,9 +7347,10 @@ async def _attach_first_unclaimed_timelapse(
     # and the poll ends (True).
     # and the poll ends (True).
     async with async_session() as db:
     async with async_session() as db:
         service = ArchiveService(db)
         service = ArchiveService(db)
-        if run_started_at is not _ANY_RUN:
+        if run_id is not None or run_started_at is not _ANY_RUN:
             current = await service.get_archive(archive_id)
             current = await service.get_archive(archive_id)
-            if current is None or current.timelapse_path or current.started_at != run_started_at:
+            started = None if run_started_at is _ANY_RUN else cast("datetime | None", run_started_at)
+            if current is None or not await _timelapse_still_wanted(db, current, run_id, started):
                 logger.info(
                 logger.info(
                     "[TIMELAPSE] Archive %s moved on during the download (video attached or printing again); "
                     "[TIMELAPSE] Archive %s moved on during the download (video attached or printing again); "
                     "%s stays on the printer",
                     "%s stays on the printer",
@@ -7276,7 +7358,9 @@ async def _attach_first_unclaimed_timelapse(
                     file_name,
                     file_name,
                 )
                 )
                 return True
                 return True
-        success = await service.attach_timelapse(archive_id, timelapse_data, file_name, plate_id=plate_id)
+        success = await service.attach_timelapse(
+            archive_id, timelapse_data, file_name, plate_id=plate_id, run_id=run_id
+        )
     if not success:
     if not success:
         logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
         logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
         return False
         return False
@@ -7318,6 +7402,7 @@ async def _capture_finish_photo_from_timelapse(
     archive_dir: Path,
     archive_dir: Path,
     timeout: float | None = None,
     timeout: float | None = None,
     rotation: int = 0,
     rotation: int = 0,
+    run_id: int | None = None,
 ) -> tuple[str | None, bool]:
 ) -> tuple[str | None, bool]:
     """Wait for the per-print timelapse to land on the archive and extract its
     """Wait for the per-print timelapse to land on the archive and extract its
     last frame as the finish photo (#1397).
     last frame as the finish photo (#1397).
@@ -7342,11 +7427,15 @@ async def _capture_finish_photo_from_timelapse(
     still (#2708) so this source agrees with every other finish-photo source.
     still (#2708) so this source agrees with every other finish-photo source.
     The archived video itself is the printer's own file and is left alone —
     The archived video itself is the printer's own file and is left alone —
     rotating it would mean re-encoding it.
     rotating it would mean re-encoding it.
+
+    ``run_id`` is the print that ended: its own video is the one waited for,
+    not the archive's, which can be another plate's.
     """
     """
     import uuid
     import uuid
 
 
     from backend.app.models.archive import PrintArchive
     from backend.app.models.archive import PrintArchive
     from backend.app.services.camera import apply_camera_rotation_to_file, extract_video_last_frame
     from backend.app.services.camera import apply_camera_rotation_to_file, extract_video_last_frame
+    from backend.app.services.print_log import run_of
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
@@ -7356,9 +7445,13 @@ async def _capture_finish_photo_from_timelapse(
 
 
     while True:
     while True:
         async with async_session() as db:
         async with async_session() as db:
-            result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-            archive = result.scalar_one_or_none()
-            timelapse_relpath = archive.timelapse_path if archive else None
+            if run_id is not None:
+                run = await run_of(db, archive_id, run_id)
+                timelapse_relpath = run.timelapse_path if run else None
+            else:
+                result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+                archive = result.scalar_one_or_none()
+                timelapse_relpath = archive.timelapse_path if archive else None
 
 
         if timelapse_relpath:
         if timelapse_relpath:
             video_path = app_settings.base_dir / timelapse_relpath
             video_path = app_settings.base_dir / timelapse_relpath
@@ -7394,7 +7487,9 @@ async def _capture_finish_photo_from_timelapse(
         await asyncio.sleep(poll_interval)
         await asyncio.sleep(poll_interval)
 
 
 
 
-async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path, rotation: int = 0) -> None:
+async def _upgrade_finish_photo_from_timelapse(
+    archive_id: int, archive_dir: Path, rotation: int = 0, run_id: int | None = None
+) -> None:
     """Add the timelapse's last frame to an archive after the fact (#2704).
     """Add the timelapse's last frame to an archive after the fact (#2704).
 
 
     The print-complete notification waits only ~60s for the video, because
     The print-complete notification waits only ~60s for the video, because
@@ -7408,12 +7503,15 @@ async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Pat
     the frame and puts it *first* in the archive's photo list, so opening the
     the frame and puts it *first* in the archive's photo list, so opening the
     gallery shows it. The live grab is deliberately kept: the notification that
     gallery shows it. The live grab is deliberately kept: the notification that
     already went out links to that exact file, and deleting it would leave a
     already went out links to that exact file, and deleting it would leave a
-    broken image in Discord or Telegram.
+    broken image in Discord or Telegram. ``run_id`` is the print it is for,
+    whose list leads with it too.
     """
     """
+    from backend.app.services.print_log import add_run_photo, run_of
+
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
     filename, _ = await _capture_finish_photo_from_timelapse(
     filename, _ = await _capture_finish_photo_from_timelapse(
-        archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS, rotation=rotation
+        archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS, rotation=rotation, run_id=run_id
     )
     )
     if not filename:
     if not filename:
         logger.info("[PHOTO-UPGRADE] No timelapse frame for archive %s; keeping the live grab", archive_id)
         logger.info("[PHOTO-UPGRADE] No timelapse frame for archive %s; keeping the live grab", archive_id)
@@ -7431,6 +7529,7 @@ async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Pat
                 return
                 return
             # Front of the list: PhotoGalleryModal opens at index 0.
             # Front of the list: PhotoGalleryModal opens at index 0.
             archive.photos = [filename, *photos]
             archive.photos = [filename, *photos]
+            add_run_photo(await run_of(db, archive_id, run_id) if run_id is not None else None, filename, first=True)
             await db.commit()
             await db.commit()
     except Exception as e:
     except Exception as e:
         logger.warning("[PHOTO-UPGRADE] Failed to attach upgraded photo to archive %s: %s", archive_id, e)
         logger.warning("[PHOTO-UPGRADE] Failed to attach upgraded photo to archive %s: %s", archive_id, e)
@@ -9931,6 +10030,7 @@ async def _close_out_print(printer_id: int, data: dict):
                     archive_id=archive_id,
                     archive_id=archive_id,
                     archive_dir=archive_dir,
                     archive_dir=archive_dir,
                     rotation=getattr(printer, "camera_rotation", 0),
                     rotation=getattr(printer, "camera_rotation", 0),
+                    run_id=completed_run_id,
                 )
                 )
 
 
             # #1721: replacement framing path — on_finish_photo_moment
             # #1721: replacement framing path — on_finish_photo_moment
@@ -10081,12 +10181,17 @@ async def _close_out_print(printer_id: int, data: dict):
             if photo_filename:
             if photo_filename:
                 async with async_session() as db:
                 async with async_session() as db:
                     from backend.app.models.archive import PrintArchive
                     from backend.app.models.archive import PrintArchive
+                    from backend.app.services.print_log import add_run_photo, run_of
 
 
                     arch = await db.get(PrintArchive, archive_id)
                     arch = await db.get(PrintArchive, archive_id)
+                    # The ended print's own list takes it too: the archive's
+                    # holds every print's.
+                    run = await run_of(db, archive_id, completed_run_id) if completed_run_id is not None else None
                     if arch is not None:
                     if arch is not None:
                         photos = arch.photos or []
                         photos = arch.photos or []
                         photos.append(photo_filename)
                         photos.append(photo_filename)
                         arch.photos = photos
                         arch.photos = photos
+                        add_run_photo(run, photo_filename)
                         await db.commit()
                         await db.commit()
                 logger.info("[PHOTO-BG] Saved: %s", photo_filename)
                 logger.info("[PHOTO-BG] Saved: %s", photo_filename)
 
 
@@ -10107,7 +10212,10 @@ async def _close_out_print(printer_id: int, data: dict):
             if timelapse_still_pending:
             if timelapse_still_pending:
                 spawn_background_task(
                 spawn_background_task(
                     _upgrade_finish_photo_from_timelapse(
                     _upgrade_finish_photo_from_timelapse(
-                        archive_id, archive_dir, rotation=getattr(printer, "camera_rotation", 0)
+                        archive_id,
+                        archive_dir,
+                        rotation=getattr(printer, "camera_rotation", 0),
+                        run_id=completed_run_id,
                     ),
                     ),
                     name=f"finish-photo-upgrade-{archive_id}",
                     name=f"finish-photo-upgrade-{archive_id}",
                 )
                 )
@@ -10387,7 +10495,9 @@ async def _close_out_print(printer_id: int, data: dict):
                         run_started_at = run_archive.started_at if run_archive else None
                         run_started_at = run_archive.started_at if run_archive else None
                 timelapse_path = await tl_complete(printer_id)
                 timelapse_path = await tl_complete(printer_id)
                 if timelapse_path and archive_id:
                 if timelapse_path and archive_id:
-                    await _attach_layer_timelapse(archive_id, timelapse_path, notify_plate_id, run_started_at)
+                    await _attach_layer_timelapse(
+                        archive_id, timelapse_path, notify_plate_id, run_started_at, run_id=completed_run_id
+                    )
                 elif timelapse_path:
                 elif timelapse_path:
                     # Timelapse created but no archive - just clean up
                     # Timelapse created but no archive - just clean up
                     await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
                     await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
@@ -10420,7 +10530,7 @@ async def _close_out_print(printer_id: int, data: dict):
         logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id)
         logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id)
         # Schedule timelapse scan as background task with retries
         # Schedule timelapse scan as background task with retries
         # The printer needs time to encode the video after print completion
         # The printer needs time to encode the video after print completion
-        _schedule_timelapse_scan(archive_id, printer_id, plate_id=notify_plate_id)
+        _schedule_timelapse_scan(archive_id, printer_id, plate_id=notify_plate_id, run_id=completed_run_id)
         log_timing("Timelapse scan scheduled")
         log_timing("Timelapse scan scheduled")
 
 
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)

+ 32 - 10
backend/app/services/archive.py

@@ -1896,6 +1896,7 @@ class ArchiveService:
         filename: str = "timelapse.mp4",
         filename: str = "timelapse.mp4",
         *,
         *,
         plate_id: int | None = None,
         plate_id: int | None = None,
+        run_id: int | None = None,
     ) -> bool:
     ) -> bool:
         """Attach a timelapse video to an archive.
         """Attach a timelapse video to an archive.
 
 
@@ -1905,9 +1906,16 @@ class ArchiveService:
         ``plate_id`` is the plate whose run produced the video, when the caller
         ``plate_id`` is the plate whose run produced the video, when the caller
         knows it; print start uses it to tell a reprint from the next plate of
         knows it; print start uses it to tell a reprint from the next plate of
         a shared archive (#3275).
         a shared archive (#3275).
+
+        ``run_id`` is the print whose video it is (archive redesign): the
+        video is that run's, and the archive's only while that run is the
+        one it shows, its newest. Without one (attached by hand) it is the
+        archive's and its newest run's.
         """
         """
         import asyncio
         import asyncio
 
 
+        from backend.app.services.print_log import is_newest_run, newest_run, run_of
+
         archive = await self.get_archive(archive_id)
         archive = await self.get_archive(archive_id)
         if not archive:
         if not archive:
             return False
             return False
@@ -1942,6 +1950,10 @@ class ArchiveService:
                 archive_id,
                 archive_id,
             )
             )
             return False
             return False
+        run = await (run_of(self.db, archive_id, run_id) if run_id is not None else newest_run(self.db, archive_id))
+        if run_id is not None and run is None:
+            logger.info("Run %s of archive %s is gone; not attaching its timelapse", run_id, archive_id)
+            return False
         # Created only once the name has been vetted, so a rejected filename
         # Created only once the name has been vetted, so a rejected filename
         # leaves nothing behind. A no-3MF archive has never had a directory of
         # leaves nothing behind. A no-3MF archive has never had a directory of
         # its own, and the timelapse can be the first thing to want one.
         # its own, and the timelapse can be the first thing to want one.
@@ -1952,14 +1964,17 @@ class ArchiveService:
         # (#3275). Writing over it would destroy the copy that was kept: the
         # (#3275). Writing over it would destroy the copy that was kept: the
         # external-camera stitch always names its output layer_timelapse.mp4.
         # external-camera stitch always names its output layer_timelapse.mp4.
         # Re-attaching the file the archive does point at still replaces it.
         # Re-attaching the file the archive does point at still replaces it.
-        timelapse_file = await asyncio.to_thread(
-            _free_timelapse_name, timelapse_file, archive.timelapse_path, settings.base_dir
-        )
+        # A run's video is replaced only by that run's.
+        current = run.timelapse_path if run_id is not None and run is not None else archive.timelapse_path
+        timelapse_file = await asyncio.to_thread(_free_timelapse_name, timelapse_file, current, settings.base_dir)
         await asyncio.to_thread(timelapse_file.write_bytes, timelapse_data)
         await asyncio.to_thread(timelapse_file.write_bytes, timelapse_data)
 
 
-        # Update archive record
-        archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
-        archive.timelapse_plate_id = plate_id
+        relpath = str(timelapse_file.relative_to(settings.base_dir))
+        if run is not None:
+            run.timelapse_path = relpath
+        if run_id is None or (run is not None and await is_newest_run(self.db, run)):
+            archive.timelapse_path = relpath
+            archive.timelapse_plate_id = plate_id
         await self.db.commit()
         await self.db.commit()
         await _announce_timelapse(archive_id, attached=True)
         await _announce_timelapse(archive_id, attached=True)
 
 
@@ -2084,19 +2099,26 @@ async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
         # Update DB path to the new MP4 file
         # Update DB path to the new MP4 file
         async with async_session() as db:
         async with async_session() as db:
             from backend.app.models.archive import PrintArchive
             from backend.app.models.archive import PrintArchive
+            from backend.app.services.print_log import runs_with_timelapse
 
 
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
             archive = result.scalar_one_or_none()
             archive = result.scalar_one_or_none()
             source_rel = str(source_path.relative_to(settings.base_dir))
             source_rel = str(source_path.relative_to(settings.base_dir))
-            # Only if the archive still shows this video: one deleted while it
-            # converted, or replaced by a reprint's, must not come back.
-            if archive is None or archive.timelapse_path != source_rel:
+            runs = await runs_with_timelapse(db, archive_id, source_rel) if archive is not None else []
+            # Only if the archive or one of its runs still shows this video:
+            # one deleted while it converted, or replaced by a reprint's, must
+            # not come back.
+            if archive is None or (archive.timelapse_path != source_rel and not runs):
                 logger.info(
                 logger.info(
                     "Archive %s no longer shows %s; discarding its MP4 conversion", archive_id, source_path.name
                     "Archive %s no longer shows %s; discarding its MP4 conversion", archive_id, source_path.name
                 )
                 )
                 mp4_path.unlink(missing_ok=True)
                 mp4_path.unlink(missing_ok=True)
                 return
                 return
-            archive.timelapse_path = str(mp4_path.relative_to(settings.base_dir))
+            mp4_rel = str(mp4_path.relative_to(settings.base_dir))
+            if archive.timelapse_path == source_rel:
+                archive.timelapse_path = mp4_rel
+            for run in runs:
+                run.timelapse_path = mp4_rel
             await db.commit()
             await db.commit()
         await _announce_timelapse(archive_id, attached=True)
         await _announce_timelapse(archive_id, attached=True)
 
 

+ 125 - 0
backend/app/services/print_log.py

@@ -7,6 +7,7 @@ import logging
 import uuid
 import uuid
 from collections.abc import Set as AbstractSet
 from collections.abc import Set as AbstractSet
 from datetime import datetime, timezone
 from datetime import datetime, timezone
+from pathlib import Path
 from types import SimpleNamespace
 from types import SimpleNamespace
 
 
 from sqlalchemy import delete, func, or_, select
 from sqlalchemy import delete, func, or_, select
@@ -449,6 +450,8 @@ async def open_run(
         cost_center_id=(item.cost_center_id if item is not None else None) or archive.cost_center_id,
         cost_center_id=(item.cost_center_id if item is not None else None) or archive.cost_center_id,
         confirm_requested=archive.confirm_requested,
         confirm_requested=archive.confirm_requested,
         quantity=archive.quantity,
         quantity=archive.quantity,
+        # Its own finish photos from here on (``owns_media``).
+        photos=[],
     )
     )
     db.add(entry)
     db.add(entry)
     await db.flush()
     await db.flush()
@@ -532,3 +535,125 @@ async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -
     runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
     runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
     if (runs or 0) <= 1:
     if (runs or 0) <= 1:
         archive.wear_cost = entry.wear_cost
         archive.wear_cost = entry.wear_cost
+
+
+async def run_of(db: AsyncSession, archive_id: int, run_id: int) -> PrintLogEntry | None:
+    """The archive's run ``run_id``, open or not; None when it is not one."""
+    return await db.scalar(
+        select(PrintLogEntry)
+        .where(PrintLogEntry.id == run_id, PrintLogEntry.archive_id == archive_id)
+        .execution_options(**{INCLUDE_OPEN_RUNS: True})
+    )
+
+
+async def newest_run(db: AsyncSession, archive_id: int) -> PrintLogEntry | None:
+    """The archive's run started last, a printing one included: whose
+    timelapse and photos the archive shows. A print starting clears the
+    archive's video, so an earlier run's that lands after must not take it."""
+    return await db.scalar(
+        select(PrintLogEntry)
+        .where(PrintLogEntry.archive_id == archive_id)
+        .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
+        .limit(1)
+        .execution_options(**{INCLUDE_OPEN_RUNS: True})
+    )
+
+
+async def is_newest_run(db: AsyncSession, run: PrintLogEntry) -> bool:
+    """Whether the archive shows ``run``'s timelapse and photos."""
+    if run.archive_id is None:
+        return False
+    newest = await newest_run(db, run.archive_id)
+    return newest is not None and newest.id == run.id
+
+
+async def runs_with_timelapse(db: AsyncSession, archive_id: int, relpath: str) -> list[PrintLogEntry]:
+    """The archive's runs whose video is the file at ``relpath``."""
+    return list(
+        await db.scalars(
+            select(PrintLogEntry)
+            .where(PrintLogEntry.archive_id == archive_id, PrintLogEntry.timelapse_path == relpath)
+            .execution_options(**{INCLUDE_OPEN_RUNS: True})
+        )
+    )
+
+
+async def claimed_timelapse_stems(
+    db: AsyncSession, printer_id: int | None, *, archive_id: int, run_id: int | None
+) -> set[str]:
+    """Video filenames already attached to another print of this printer (#2704).
+
+    Lets the baseline diff drop a previous print's late-landing video from the
+    candidate list without ordering the candidates: ordering could only be
+    done on mtime or the filename timestamp, and both come from a clock the
+    printer can't sync in LAN-only mode. ``attach_timelapse`` stores the video
+    under the printer's own filename and the MP4 conversion keeps the stem, so
+    the stem of a ``timelapse_path`` is what was claimed.
+
+    Another print is another archive, or another run, also of this archive
+    (another plate of it): ``run_id`` is the print looking. Without one, every
+    run of the archive is its own.
+    """
+    from backend.app.models.archive import PrintArchive
+
+    if printer_id is None:
+        return set()
+    archives = await db.scalars(
+        select(PrintArchive.timelapse_path).where(
+            PrintArchive.printer_id == printer_id,
+            PrintArchive.id != archive_id,
+            PrintArchive.timelapse_path.is_not(None),
+        )
+    )
+    runs = select(PrintLogEntry.timelapse_path).where(
+        PrintLogEntry.printer_id == printer_id, PrintLogEntry.timelapse_path.is_not(None)
+    )
+    if run_id is not None:
+        runs = runs.where(PrintLogEntry.id != run_id)
+    else:
+        runs = runs.where(or_(PrintLogEntry.archive_id.is_(None), PrintLogEntry.archive_id != archive_id))
+    run_paths = await db.scalars(runs.execution_options(**{INCLUDE_OPEN_RUNS: True}))
+    return {Path(p).stem for p in [*archives.all(), *run_paths.all()] if p}
+
+
+def owns_media(run: PrintLogEntry) -> bool:
+    """Whether ``run`` keeps its own timelapse: opened at its start with a
+    photo list, or given a video or baseline of its own. A run logged before
+    runs kept them shares the archive's. Its photos are its own only once it
+    has a list (``run.photos`` not None)."""
+    return run.photos is not None or run.timelapse_path is not None or run.timelapse_baseline is not None
+
+
+async def superseded(db: AsyncSession, run: PrintLogEntry) -> bool:
+    """Whether a later print of the run's archive has started on its printer,
+    or of its plate: a video landing now is not the run's to take. The one on
+    its printer is that print's own, and a plate keeps one video, the latest
+    print's (a reprint replaces it). Another plate on another printer leaves
+    the run's video to it."""
+    if run.archive_id is None or run.started_at is None:
+        return False
+    same = [PrintLogEntry.printer_id == run.printer_id] if run.printer_id is not None else []
+    if run.plate_id is not None:
+        same.append(PrintLogEntry.plate_id == run.plate_id)
+    if not same:
+        return False
+    later = await db.scalar(
+        select(PrintLogEntry.id)
+        .where(
+            PrintLogEntry.archive_id == run.archive_id,
+            PrintLogEntry.id != run.id,
+            PrintLogEntry.started_at > run.started_at,
+            or_(*same),
+        )
+        .limit(1)
+        .execution_options(**{INCLUDE_OPEN_RUNS: True})
+    )
+    return later is not None
+
+
+def add_run_photo(run: PrintLogEntry | None, filename: str, *, first: bool = False) -> None:
+    """Put a finish photo on its run's list too: last, or ``first``."""
+    if run is None:
+        return
+    photos = [p for p in (run.photos or []) if p != filename]
+    run.photos = [filename, *photos] if first else [*photos, filename]

+ 42 - 0
backend/tests/integration/test_print_complete_end_to_end.py

@@ -386,6 +386,48 @@ async def test_the_layer_timelapse_is_attached_for_the_run_that_ended(test_engin
     attach.assert_awaited_once()
     attach.assert_awaited_once()
     archive_id, path, _plate, run_started_at = attach.await_args.args
     archive_id, path, _plate, run_started_at = attach.await_args.args
     assert (archive_id, path, run_started_at) == (queued_print["archive"], stitched, datetime(2026, 10, 19, 8, 0))
     assert (archive_id, path, run_started_at) == (queued_print["archive"], stitched, datetime(2026, 10, 19, 8, 0))
+    # And its run, which the video is for (archive redesign).
+    _archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    assert attach.await_args.kwargs["run_id"] == logs[0].id
+
+
+async def test_the_timelapse_scan_is_for_the_run_that_ended(test_engine, db_session, queued_print):
+    with patch("backend.app.main._schedule_timelapse_scan") as schedule:
+        await _complete(test_engine, queued_print["printer"], "completed", extra={"timelapse_was_active": True})
+
+    _archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    assert schedule.call_args.kwargs["run_id"] == logs[0].id
+
+
+async def test_the_finish_photo_is_the_ended_runs_too(test_engine, db_session, queued_print):
+    from backend.app.core.tasks import spawn_background_task
+
+    # The SQLite test database is one shared connection: tasks writing at the
+    # same time fail each other, which a real database does not. They take
+    # turns, but for the notification, which waits for the photo.
+    turn = asyncio.Lock()
+
+    async def in_turn(coro):
+        async with turn:
+            return await coro
+
+    def spawn(coro, name=None, **kwargs):
+        return spawn_background_task(coro if name == "photo-then-notify" else in_turn(coro), name=name, **kwargs)
+
+    capture = AsyncMock(return_value=("finish.jpg", False))
+    with (
+        patch("backend.app.main.spawn_background_task", side_effect=spawn),
+        patch("backend.app.main._capture_finish_photo_from_timelapse", capture),
+        patch("backend.app.main._schedule_timelapse_scan"),
+        # Off by default; the archive's 3MF-less test row still takes photos.
+        patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+    ):
+        await _complete(test_engine, queued_print["printer"], "completed", extra={"timelapse_was_active": True})
+
+    archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    assert capture.await_args.kwargs["run_id"] == logs[0].id
+    assert logs[0].photos == ["finish.jpg"]
+    assert archive.photos == ["finish.jpg"]
 
 
 
 
 async def test_the_registered_mapping_is_drained_when_the_printer_reported_one(test_engine, db_session, queued_print):
 async def test_the_registered_mapping_is_drained_when_the_printer_reported_one(test_engine, db_session, queued_print):

+ 562 - 0
backend/tests/integration/test_timelapse_per_run.py

@@ -0,0 +1,562 @@
+"""Each print of an archive keeps its own timelapse and photos (archive redesign P1b).
+
+Plates of one archive printed on two printers are two runs. Each run's scan,
+layer stitch and finish photo find its own video by the run's id, and the
+archive shows its newest run's. A reprint of a plate still replaces that
+plate's earlier video, as when the archive held the only one.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+T1 = datetime(2026, 10, 30, 9)
+T2 = T1 + timedelta(hours=1)
+ARCHIVE_DIR = "archives/test"
+logger = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def files(tmp_path, monkeypatch):
+    """The data directory, in tmp_path rather than the real one."""
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+    (tmp_path / ARCHIVE_DIR).mkdir(parents=True)
+    return tmp_path
+
+
+@pytest.fixture
+def sessions(test_engine):
+    """The sessions main and the services open, on the test database."""
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.main.async_session", maker),
+        patch("backend.app.core.database.async_session", maker),
+        patch("backend.app.services.archive._announce_timelapse", AsyncMock()),
+        patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
+    ):
+        yield maker
+
+
+@pytest.fixture
+async def two_plates(db_session, printer_factory, files, sessions):
+    """One archive: plate 1 printed on A, then plate 2 started on B since.
+    Neither has a video yet."""
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    archive = PrintArchive(
+        printer_id=b.id,
+        filename="two.gcode.3mf",
+        file_path=f"{ARCHIVE_DIR}/two.gcode.3mf",
+        file_size=1,
+        print_name="two",
+        status="completed",
+        started_at=T2,
+    )
+    db_session.add(archive)
+    await db_session.flush()
+    first = PrintLogEntry(
+        archive_id=archive.id, printer_id=a.id, plate_id=1, status="completed", started_at=T1, photos=[]
+    )
+    second = PrintLogEntry(
+        archive_id=archive.id, printer_id=b.id, plate_id=2, status="completed", started_at=T2, photos=[]
+    )
+    db_session.add_all([first, second])
+    await db_session.commit()
+    return SimpleNamespace(archive=archive.id, a=a.id, b=b.id, first=first.id, second=second.id, files=files)
+
+
+async def _row(db_session, model, row_id):
+    db_session.expire_all()
+    return await db_session.get(model, row_id)
+
+
+async def _set(db_session, model, row_id, **values):
+    row = await _row(db_session, model, row_id)
+    for name, value in values.items():
+        setattr(row, name, value)
+    await db_session.commit()
+
+
+async def _attach(archive_id, data, name, **kwargs):
+    from backend.app.core.database import async_session
+    from backend.app.services.archive import ArchiveService
+
+    async with async_session() as db:
+        return await ArchiveService(db).attach_timelapse(archive_id, data, name, **kwargs)
+
+
+class TestAttach:
+    async def test_an_earlier_plates_video_is_its_runs_only(self, db_session, two_plates):
+        assert await _attach(two_plates.archive, b"one", "video_1.mp4", plate_id=1, run_id=two_plates.first)
+
+        first = await _row(db_session, PrintLogEntry, two_plates.first)
+        assert first.timelapse_path == f"{ARCHIVE_DIR}/video_1.mp4"
+        archive = await _row(db_session, PrintArchive, two_plates.archive)
+        assert archive.timelapse_path is None
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path is None
+
+    async def test_the_newest_runs_video_is_the_archives_too(self, db_session, two_plates):
+        assert await _attach(two_plates.archive, b"two", "video_2.mp4", plate_id=2, run_id=two_plates.second)
+
+        archive = await _row(db_session, PrintArchive, two_plates.archive)
+        assert (archive.timelapse_path, archive.timelapse_plate_id) == (f"{ARCHIVE_DIR}/video_2.mp4", 2)
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
+
+    async def test_a_printing_run_is_the_newest(self, db_session, two_plates):
+        """A print starting clears the archive's video: plate 2's landing
+        after plate 3 started is not the archive's."""
+        db_session.add(
+            PrintLogEntry(
+                archive_id=two_plates.archive,
+                printer_id=two_plates.a,
+                status=RUN_OPEN,
+                started_at=T2 + timedelta(hours=1),
+            )
+        )
+        await db_session.commit()
+
+        assert await _attach(two_plates.archive, b"two", "video_2.mp4", run_id=two_plates.second)
+
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
+
+    async def test_one_attached_by_hand_is_the_newest_runs(self, db_session, two_plates):
+        assert await _attach(two_plates.archive, b"hand", "picked.mp4")
+
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path == f"{ARCHIVE_DIR}/picked.mp4"
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/picked.mp4"
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
+
+    async def test_a_gone_run_gets_nothing(self, db_session, two_plates):
+        assert not await _attach(two_plates.archive, b"x", "video_9.mp4", run_id=999999)
+
+        assert not (two_plates.files / ARCHIVE_DIR / "video_9.mp4").exists()
+
+    async def test_does_not_write_over_another_runs_video(self, db_session, two_plates):
+        """The external-camera stitch always names its output layer_timelapse.mp4."""
+        assert await _attach(two_plates.archive, b"one", "layer_timelapse.mp4", run_id=two_plates.first)
+        assert await _attach(two_plates.archive, b"two", "layer_timelapse.mp4", run_id=two_plates.second)
+
+        assert (two_plates.files / ARCHIVE_DIR / "layer_timelapse.mp4").read_bytes() == b"one"
+        second = await _row(db_session, PrintLogEntry, two_plates.second)
+        assert second.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse_2.mp4"
+
+    async def test_an_earlier_runs_stitch_keeps_the_archives_video(self, db_session, two_plates):
+        """Plate 2's stitch is the archive's; plate 1's, done later under the
+        same name, is plate 1's own file."""
+        assert await _attach(two_plates.archive, b"two", "layer_timelapse.mp4", run_id=two_plates.second)
+        assert await _attach(two_plates.archive, b"one", "layer_timelapse.mp4", run_id=two_plates.first)
+
+        assert (two_plates.files / ARCHIVE_DIR / "layer_timelapse.mp4").read_bytes() == b"two"
+        first = await _row(db_session, PrintLogEntry, two_plates.first)
+        assert first.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse_2.mp4"
+
+
+class TestConversion:
+    async def _convert(self, archive_id, source):
+        from backend.app.services.archive import _convert_timelapse_to_mp4
+
+        process = AsyncMock(returncode=0)
+        process.communicate = AsyncMock(return_value=(b"", b""))
+        source.with_suffix(".mp4").write_bytes(b"mp4")
+        with (
+            patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=process)),
+        ):
+            await _convert_timelapse_to_mp4(archive_id, source)
+
+    async def test_an_earlier_runs_video_is_converted_for_it(self, db_session, two_plates):
+        source = two_plates.files / ARCHIVE_DIR / "video_1.avi"
+        source.write_bytes(b"avi")
+        await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.avi")
+
+        await self._convert(two_plates.archive, source)
+
+        first = await _row(db_session, PrintLogEntry, two_plates.first)
+        assert first.timelapse_path == f"{ARCHIVE_DIR}/video_1.mp4"
+        assert (two_plates.files / ARCHIVE_DIR / "video_1.mp4").exists()
+        assert not source.exists()
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
+
+    async def test_the_archive_and_its_run_both_move(self, db_session, two_plates):
+        source = two_plates.files / ARCHIVE_DIR / "video_2.avi"
+        source.write_bytes(b"avi")
+        await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/video_2.avi")
+        await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/video_2.avi")
+
+        await self._convert(two_plates.archive, source)
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
+
+
+class TestClaimed:
+    async def test_another_plate_of_the_archive_holds_its_video(self, db_session, two_plates):
+        from backend.app.services.print_log import claimed_timelapse_stems
+
+        await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.mp4")
+        third = PrintLogEntry(archive_id=two_plates.archive, printer_id=two_plates.a, status="completed", started_at=T2)
+        db_session.add(third)
+        await db_session.commit()
+
+        claimed = await claimed_timelapse_stems(
+            db_session, two_plates.a, archive_id=two_plates.archive, run_id=third.id
+        )
+        assert claimed == {"video_1"}
+        own = await claimed_timelapse_stems(
+            db_session, two_plates.a, archive_id=two_plates.archive, run_id=two_plates.first
+        )
+        assert own == set()
+
+    async def test_without_a_run_the_archives_runs_are_its_own(self, db_session, two_plates):
+        from backend.app.services.print_log import claimed_timelapse_stems
+
+        await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.mp4")
+
+        assert (
+            await claimed_timelapse_stems(db_session, two_plates.a, archive_id=two_plates.archive, run_id=None) == set()
+        )
+        other = PrintArchive(filename="o.3mf", file_path="o/o.3mf", file_size=1, printer_id=two_plates.b)
+        db_session.add(other)
+        await db_session.commit()
+        assert await claimed_timelapse_stems(db_session, two_plates.a, archive_id=other.id, run_id=None) == {"video_1"}
+
+
+def _video(name, size=3):
+    return {"name": name, "size": size, "path": f"/timelapse/{name}"}
+
+
+class TestScan:
+    async def _scan(self, plates, listings, *, baseline=None, on_list=None):
+        from backend.app.main import _scan_for_timelapse_with_retries
+
+        listed_printers = []
+
+        async def list_videos(printer):
+            listed_printers.append(printer.id)
+            if on_list is not None:
+                await on_list(len(listed_printers))
+            return listings[min(len(listed_printers), len(listings)) - 1], "/timelapse"
+
+        async def download(*_args, expected_size=None, **_kwargs):
+            return b"v" * expected_size
+
+        with (
+            patch("backend.app.main._list_timelapse_videos", side_effect=list_videos),
+            patch("backend.app.main.asyncio.sleep", AsyncMock()),
+            patch("backend.app.main._timelapse_scan_max_attempts", return_value=3),
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", side_effect=download),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
+        ):
+            await _scan_for_timelapse_with_retries(plates.archive, baseline, plate_id=1, run_id=plates.first)
+        return listed_printers
+
+    async def test_scans_the_printer_the_run_printed_on(self, db_session, two_plates):
+        printers = await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert printers == [two_plates.a]
+        first = await _row(db_session, PrintLogEntry, two_plates.first)
+        assert first.timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
+
+    async def test_the_next_plates_start_does_not_end_the_scan(self, db_session, two_plates):
+        async def next_plate_starts(round_no):
+            if round_no == 1:
+                await _set(db_session, PrintArchive, two_plates.archive, started_at=T2 + timedelta(hours=1))
+
+        await self._scan(two_plates, [[], [_video("new.mp4")]], baseline=set(), on_list=next_plate_starts)
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
+
+    async def test_reads_the_baseline_stored_on_its_run(self, db_session, two_plates):
+        """After a restart: the archive's baseline is plate 2's start's."""
+        await _set(
+            db_session, PrintLogEntry, two_plates.first, timelapse_baseline=["old.mp4"], timelapse_baseline_unread=False
+        )
+        await _set(db_session, PrintArchive, two_plates.archive, timelapse_baseline=["old.mp4", "new.mp4"])
+
+        await self._scan(two_plates, [[_video("old.mp4"), _video("new.mp4")]])
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
+
+    async def test_another_plates_video_on_the_archive_does_not_stop_it(self, db_session, two_plates):
+        await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/plate2.mp4")
+
+        await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
+
+    async def _later_print(self, db_session, plates, *, printer_id, plate_id):
+        db_session.add(
+            PrintLogEntry(
+                archive_id=plates.archive,
+                printer_id=printer_id,
+                plate_id=plate_id,
+                status=RUN_OPEN,
+                started_at=T2 + timedelta(hours=1),
+            )
+        )
+        await db_session.commit()
+
+    async def test_a_reprint_of_its_plate_ends_it(self, db_session, two_plates):
+        """The reprint's video is the plate's one (it replaces this one)."""
+        await self._later_print(db_session, two_plates, printer_id=two_plates.b, plate_id=1)
+
+        await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
+
+    async def test_the_next_print_on_its_printer_ends_it(self, db_session, two_plates):
+        """A video landing now can be that print's."""
+        await self._later_print(db_session, two_plates, printer_id=two_plates.a, plate_id=3)
+
+        await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
+
+    async def test_an_earlier_print_of_its_plate_does_not_end_it(self, db_session, two_plates):
+        db_session.add(
+            PrintLogEntry(
+                archive_id=two_plates.archive,
+                printer_id=two_plates.a,
+                plate_id=1,
+                status="completed",
+                started_at=T1 - timedelta(hours=1),
+            )
+        )
+        await db_session.commit()
+
+        await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
+
+    async def test_another_plate_on_another_printer_does_not_end_it(self, db_session, two_plates):
+        await self._later_print(db_session, two_plates, printer_id=two_plates.b, plate_id=3)
+
+        await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
+
+    async def test_stops_once_its_run_has_a_video(self, db_session, two_plates):
+        await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/kept.mp4")
+
+        printers = await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert printers == []
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/kept.mp4"
+
+
+class TestBaseline:
+    async def test_print_start_writes_it_on_the_run(self, db_session, two_plates):
+        from backend.app.main import _capture_timelapse_baseline_at_start
+
+        run = PrintLogEntry(archive_id=two_plates.archive, printer_id=two_plates.b, status=RUN_OPEN, started_at=T2)
+        db_session.add(run)
+        await db_session.commit()
+        run_id = run.id
+        with patch("backend.app.main._read_timelapse_baseline", AsyncMock(return_value=({"a.mp4"}, True))):
+            await _capture_timelapse_baseline_at_start(MagicMock(), two_plates.b, logger, archive_id=two_plates.archive)
+
+        db_session.expire_all()
+        run = await db_session.get(PrintLogEntry, run_id, execution_options={INCLUDE_OPEN_RUNS: True})
+        assert (run.timelapse_baseline, run.timelapse_baseline_unread) == (["a.mp4"], False)
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_baseline == ["a.mp4"]
+
+    async def test_a_reread_fills_in_its_run(self, db_session, two_plates):
+        from backend.app.main import _reread_unread_timelapse_baseline
+
+        await _set(db_session, PrintLogEntry, two_plates.first, timelapse_baseline=[], timelapse_baseline_unread=True)
+        with (
+            patch("backend.app.main._TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS", 0),
+            patch("backend.app.main._unread_baseline_round", return_value=True),
+            patch("backend.app.main._read_timelapse_baseline", AsyncMock(return_value=({"a.mp4"}, True))),
+        ):
+            await _reread_unread_timelapse_baseline(two_plates.a, set(), two_plates.archive, T1, two_plates.first)
+
+        first = await _row(db_session, PrintLogEntry, two_plates.first)
+        assert (first.timelapse_baseline, first.timelapse_baseline_unread) == (["a.mp4"], False)
+
+
+class TestLayerTimelapse:
+    async def test_lands_on_its_run_after_the_next_plate_started(self, db_session, two_plates):
+        from backend.app.main import _attach_layer_timelapse
+
+        printer_video = two_plates.files / ARCHIVE_DIR / "video_1.mp4"
+        printer_video.write_bytes(b"printer")
+        await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.mp4")
+        stitched = two_plates.files / "stitched.mp4"
+        stitched.write_bytes(b"camera")
+
+        await _attach_layer_timelapse(two_plates.archive, stitched, 1, T1, run_id=two_plates.first)
+
+        first = await _row(db_session, PrintLogEntry, two_plates.first)
+        assert first.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse.mp4"
+        assert not printer_video.exists()
+        assert not stitched.exists()
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
+
+    async def test_a_reprint_of_its_plate_drops_it(self, db_session, two_plates):
+        from backend.app.main import _attach_layer_timelapse
+
+        db_session.add(
+            PrintLogEntry(
+                archive_id=two_plates.archive,
+                printer_id=two_plates.b,
+                plate_id=1,
+                status=RUN_OPEN,
+                started_at=T2 + timedelta(hours=1),
+            )
+        )
+        await db_session.commit()
+        stitched = two_plates.files / "stitched.mp4"
+        stitched.write_bytes(b"camera")
+
+        await _attach_layer_timelapse(two_plates.archive, stitched, 1, T1, run_id=two_plates.first)
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
+        assert not stitched.exists()
+
+
+class TestFinishPhoto:
+    async def test_waits_for_its_runs_video_not_the_archives(self, db_session, two_plates):
+        from backend.app.main import _capture_finish_photo_from_timelapse
+
+        (two_plates.files / ARCHIVE_DIR / "video_2.mp4").write_bytes(b"plate 2")
+        await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
+        extract = AsyncMock(return_value=True)
+        with patch("backend.app.services.camera.extract_video_last_frame", extract):
+            photo, pending = await _capture_finish_photo_from_timelapse(
+                two_plates.archive, two_plates.files / ARCHIVE_DIR, timeout=0, run_id=two_plates.first
+            )
+
+        assert (photo, pending) == (None, True)
+        extract.assert_not_awaited()
+
+    async def test_the_upgrade_leads_its_runs_photos(self, db_session, two_plates):
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        await _set(db_session, PrintLogEntry, two_plates.first, photos=["live.jpg"])
+        await _set(db_session, PrintArchive, two_plates.archive, photos=["live.jpg", "plate2.jpg"])
+        with patch(
+            "backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=("frame.jpg", False))
+        ) as capture:
+            await _upgrade_finish_photo_from_timelapse(two_plates.archive, two_plates.files, run_id=two_plates.first)
+
+        assert capture.await_args.kwargs["run_id"] == two_plates.first
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).photos == ["frame.jpg", "live.jpg"]
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).photos == []
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).photos == [
+            "frame.jpg",
+            "live.jpg",
+            "plate2.jpg",
+        ]
+
+
+class TestReprint:
+    async def test_replaces_the_plates_earlier_videos_only(self, db_session, two_plates):
+        from backend.app.main import _drop_replaced_plate_videos
+
+        for run_id, name in ((two_plates.first, "video_1.mp4"), (two_plates.second, "video_2.mp4")):
+            (two_plates.files / ARCHIVE_DIR / name).write_bytes(b"v")
+            await _set(db_session, PrintLogEntry, run_id, timelapse_path=f"{ARCHIVE_DIR}/{name}")
+
+        await _drop_replaced_plate_videos(db_session, two_plates.archive, 1, None, logger)
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
+        assert not (two_plates.files / ARCHIVE_DIR / "video_1.mp4").exists()
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
+        assert (two_plates.files / ARCHIVE_DIR / "video_2.mp4").exists()
+
+    async def test_the_archives_video_judged_this_plates_goes_with_its_run(self, db_session, two_plates):
+        from backend.app.main import _drop_replaced_plate_videos
+
+        (two_plates.files / ARCHIVE_DIR / "old.mp4").write_bytes(b"v")
+        await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/old.mp4")
+
+        await _drop_replaced_plate_videos(db_session, two_plates.archive, None, f"{ARCHIVE_DIR}/old.mp4", logger)
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path is None
+        assert not (two_plates.files / ARCHIVE_DIR / "old.mp4").exists()
+
+    async def test_another_plates_video_is_kept_even_if_named(self, db_session, two_plates):
+        from backend.app.main import _drop_replaced_plate_videos
+
+        (two_plates.files / ARCHIVE_DIR / "video_2.mp4").write_bytes(b"v")
+        await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
+
+        await _drop_replaced_plate_videos(db_session, two_plates.archive, 1, f"{ARCHIVE_DIR}/video_2.mp4", logger)
+
+        assert (two_plates.files / ARCHIVE_DIR / "video_2.mp4").exists()
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path == f"{ARCHIVE_DIR}/video_2.mp4"
+
+
+class TestRoutes:
+    async def test_deleting_the_video_clears_its_run(self, async_client, db_session, two_plates):
+        (two_plates.files / ARCHIVE_DIR / "video_2.mp4").write_bytes(b"v")
+        await _set(db_session, PrintLogEntry, two_plates.second, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
+        await _set(db_session, PrintArchive, two_plates.archive, timelapse_path=f"{ARCHIVE_DIR}/video_2.mp4")
+
+        response = await async_client.delete(f"/api/v1/archives/{two_plates.archive}/timelapse")
+        assert response.status_code == 200, response.text
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.second)).timelapse_path is None
+
+    async def test_deleting_a_photo_takes_it_off_its_run(self, async_client, db_session, two_plates):
+        await _set(db_session, PrintLogEntry, two_plates.first, photos=["a.jpg", "b.jpg"])
+        await _set(db_session, PrintArchive, two_plates.archive, photos=["a.jpg", "b.jpg"])
+
+        response = await async_client.delete(f"/api/v1/archives/{two_plates.archive}/photos/a.jpg")
+        assert response.status_code == 200, response.text
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).photos == ["b.jpg"]
+
+    async def test_a_run_shows_its_own_video_and_photos(self, async_client, db_session, two_plates):
+        await _set(db_session, PrintLogEntry, two_plates.first, photos=["one.jpg"], timelapse_path="a/one.mp4")
+        await _set(
+            db_session, PrintArchive, two_plates.archive, photos=["one.jpg", "two.jpg"], timelapse_path="a/two.mp4"
+        )
+
+        response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.first}")
+        assert response.status_code == 200, response.text
+        body = response.json()
+        assert (body["photos"], body["timelapse_path"]) == (["one.jpg"], "a/one.mp4")
+
+        none_yet = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.second}")
+        assert (none_yet.json()["photos"], none_yet.json()["timelapse_path"]) == (None, None)
+
+    async def test_a_run_logged_before_keeps_the_archives_photos_with_a_video(
+        self, async_client, db_session, two_plates
+    ):
+        """A video attached by hand to it is its own; photos it never had are the archive's."""
+        await _set(db_session, PrintLogEntry, two_plates.second, photos=None, timelapse_path="a/picked.mp4")
+        await _set(db_session, PrintArchive, two_plates.archive, photos=["two.jpg"], timelapse_path="a/picked.mp4")
+
+        response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.second}")
+        assert (response.json()["photos"], response.json()["timelapse_path"]) == (["two.jpg"], "a/picked.mp4")
+
+    async def test_a_run_logged_before_shows_the_archives(self, async_client, db_session, two_plates):
+        await _set(db_session, PrintLogEntry, two_plates.second, photos=None)
+        await _set(db_session, PrintArchive, two_plates.archive, photos=["two.jpg"], timelapse_path="a/two.mp4")
+
+        response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.second}")
+        assert (response.json()["photos"], response.json()["timelapse_path"]) == (["two.jpg"], "a/two.mp4")
+
+
+async def test_a_run_opened_at_its_start_keeps_its_own_photos(db_session, two_plates):
+    from backend.app.services.print_log import open_run
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    archive.started_at = T2 + timedelta(hours=1)
+    run = await open_run(db_session, archive, printer_id=two_plates.a)
+    assert run is not None and run.photos == []

+ 1 - 0
backend/tests/unit/services/test_attach_timelapse_safe_path.py

@@ -103,6 +103,7 @@ async def test_attach_timelapse_accepts_legit_filename(tmp_path: Path, monkeypat
 
 
     db = MagicMock()
     db = MagicMock()
     db.commit = AsyncMock()
     db.commit = AsyncMock()
+    db.scalar = AsyncMock(return_value=None)  # no run logged
     service = ArchiveService(db)
     service = ArchiveService(db)
 
 
     fake_archive = MagicMock()
     fake_archive = MagicMock()

+ 3 - 0
backend/tests/unit/test_archive_filtering.py

@@ -290,6 +290,8 @@ class TestScanForTimelapseWithRetries:
                 scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
                 scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
             )
             )
         )
         )
+        # The claimed-videos lookup reads runs and archives with scalars().
+        mock_session.scalars = AsyncMock(return_value=MagicMock(all=MagicMock(return_value=[])))
         return mock_session
         return mock_session
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -638,6 +640,7 @@ class TestListTimelapseVideosAvi:
                 scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
                 scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
             )
             )
         )
         )
+        mock_session.scalars = AsyncMock(return_value=MagicMock(all=MagicMock(return_value=[])))
 
 
         with (
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
             patch("backend.app.main.async_session", return_value=mock_session),

+ 2 - 0
backend/tests/unit/test_send_all_timelapse_per_plate_3275.py

@@ -119,6 +119,8 @@ def _service(tmp_path: Path, monkeypatch, current: str | None):
 
 
     db = MagicMock()
     db = MagicMock()
     db.commit = AsyncMock()
     db.commit = AsyncMock()
+    # No run logged: a video attached by hand to an archive printed before runs.
+    db.scalar = AsyncMock(return_value=None)
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = MagicMock()
     archive = MagicMock()
     archive.file_path = "archive/1/20260101_plates/Plates.3mf"
     archive.file_path = "archive/1/20260101_plates/Plates.3mf"

+ 7 - 5
backend/tests/unit/test_timelapse_scan_2704.py

@@ -17,6 +17,7 @@ These tests pin the parts that make the diff dependable:
 """
 """
 
 
 import logging
 import logging
+from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
@@ -393,15 +394,16 @@ class TestManualScanUsesTheBaseline:
         session.__aenter__ = AsyncMock(return_value=session)
         session.__aenter__ = AsyncMock(return_value=session)
         session.__aexit__ = AsyncMock()
         session.__aexit__ = AsyncMock()
         session.execute = AsyncMock(
         session.execute = AsyncMock(
-            return_value=MagicMock(
-                scalar_one_or_none=MagicMock(return_value=_printer()),
-                # Videos other archives of this printer hold
-                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=list(claimed)))),
-            )
+            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=_printer())),
         )
         )
 
 
         with (
         with (
             patch("backend.app.core.database.async_session", return_value=session),
             patch("backend.app.core.database.async_session", return_value=session),
+            # Videos other prints of this printer hold
+            patch(
+                "backend.app.api.routes.archives._claimed_timelapse_stems",
+                AsyncMock(return_value={Path(p).stem for p in claimed}),
+            ),
             patch("backend.app.api.routes.archives.ArchiveService", return_value=service),
             patch("backend.app.api.routes.archives.ArchiveService", return_value=service),
             patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
             patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
             patch(
             patch(

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


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


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


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


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


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


+ 1 - 1
static/assets/Button-DZq9YkGs.js → static/assets/Button-DYYZvzLo.js

@@ -1 +1 @@
-import{w as e}from"./client-8XqSBxc-.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-uq_Z1wxL.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

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


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


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


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


+ 1 - 1
static/assets/Card-DQ4O8sjc.js → static/assets/Card-D4V_oQwu.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

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


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


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


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


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


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


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


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


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


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


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


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


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


+ 1 - 1
static/assets/FilamentSwatch-b34zntYa.js → static/assets/FilamentSwatch-YN9cyuiV.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


+ 1 - 1
static/assets/NumberInput-mKi1v1k8.js → static/assets/NumberInput-BaT0XBfV.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-Deb3W49X.js → static/assets/OverlayFrame-Bftf9d2g.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-8XqSBxc-.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-uq_Z1wxL.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


+ 1 - 1
static/assets/SpoolIcon-CiFF8I-M.js → static/assets/SpoolIcon-BBThOs9Y.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-uq_Z1wxL.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

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


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


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


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


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


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


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


+ 1 - 1
static/assets/Toggle-CGvrVi1c.js → static/assets/Toggle-DVKVuNmx.js

@@ -1 +1 @@
-import{w as e}from"./client-8XqSBxc-.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};
+import{w as e}from"./client-uq_Z1wxL.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};

+ 1 - 1
static/assets/archive-D1YlfNBg.js → static/assets/archive-CPxb9vAg.js

@@ -1 +1 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`archive`,[[`rect`,{width:`20`,height:`5`,x:`2`,y:`3`,rx:`1`,key:`1wp1u1`}],[`path`,{d:`M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8`,key:`1s80jp`}],[`path`,{d:`M10 12h4`,key:`a56b0p`}]]);export{t};
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`archive`,[[`rect`,{width:`20`,height:`5`,x:`2`,y:`3`,rx:`1`,key:`1wp1u1`}],[`path`,{d:`M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8`,key:`1s80jp`}],[`path`,{d:`M10 12h4`,key:`a56b0p`}]]);export{t};

+ 1 - 0
static/assets/arrow-down-DWfwAA1B.js

@@ -0,0 +1 @@
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 0 - 1
static/assets/arrow-down-K9BctJx-.js

@@ -1 +0,0 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 1 - 0
static/assets/arrow-left-59yC6trx.js

@@ -0,0 +1 @@
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 0 - 1
static/assets/arrow-left-uORrkJgb.js

@@ -1 +0,0 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 0 - 1
static/assets/arrow-right-CYovoxi-.js

@@ -1 +0,0 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 1 - 0
static/assets/arrow-right-FF8C7ZzY.js

@@ -0,0 +1 @@
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 1 - 0
static/assets/arrow-up-DeMSWxAI.js

@@ -0,0 +1 @@
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 0 - 1
static/assets/arrow-up-DjMuLGFa.js

@@ -1 +0,0 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 1 - 1
static/assets/arrow-up-down-CW1Sq_bU.js → static/assets/arrow-up-down-BD2P5n9I.js

@@ -1 +1 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};

+ 1 - 1
static/assets/bell-BIhbuimu.js → static/assets/bell-DFhLXGbL.js

@@ -1 +1 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`bell`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}]]);export{t};
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`bell`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}]]);export{t};

+ 1 - 1
static/assets/box-YwaBCshA.js → static/assets/box-DICznt0m.js

@@ -1 +1 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};

+ 1 - 1
static/assets/briefcase-BIt-Y3gB.js → static/assets/briefcase-CYs7NlTT.js

@@ -1 +1 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};

+ 1 - 1
static/assets/calendar-BREs5g1s.js → static/assets/calendar-BMe2cZro.js

@@ -1 +1 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};

+ 1 - 1
static/assets/camera-BBw757Uc.js → static/assets/camera-FLMbnnIS.js

@@ -1 +1 @@
-import{C as e}from"./client-8XqSBxc-.js";var t=e(`camera`,[[`path`,{d:`M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z`,key:`18u6gg`}],[`circle`,{cx:`12`,cy:`13`,r:`3`,key:`1vg3eu`}]]);export{t};
+import{C as e}from"./client-uq_Z1wxL.js";var t=e(`camera`,[[`path`,{d:`M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z`,key:`18u6gg`}],[`circle`,{cx:`12`,cy:`13`,r:`3`,key:`1vg3eu`}]]);export{t};

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