Explorar el Código

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 hace 22 horas
padre
commit
e12aa5d5de
Se han modificado 100 ficheros con 1016 adiciones y 155 borrados
  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
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 61,
-                    "endColumn": 79,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "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.models.archive import PrintArchive
 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.project import Project
 from backend.app.models.spool_usage_history import SpoolUsageHistory
@@ -49,6 +49,7 @@ from backend.app.services.print_confirmation import (
     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_log import owns_media, runs_with_timelapse
 from backend.app.services.print_storage import (
     REASON_FTP_TRANSFER_FAILED,
     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]:
-    """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:
@@ -366,6 +357,12 @@ def _with_run_outcome(data: dict, run: PrintLogEntry) -> dict:
             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
 
 
@@ -617,8 +614,6 @@ async def list_last_archive_per_printer(
     """
     from sqlalchemy.orm import selectinload
 
-    from backend.app.models.print_log import INCLUDE_OPEN_RUNS
-
     user, can_read_all = auth_result
     filters: list[ColumnElement[bool]] = [PrintArchive.deleted_at.is_(None)]
     if user is not None and not can_read_all:
@@ -2995,7 +2990,9 @@ async def delete_timelapse(
     if timelapse_path.exists():
         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_plate_id = None
     await db.commit()
@@ -3830,9 +3827,13 @@ async def delete_photo(
     if photo_path is not None:
         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]
     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()
 

+ 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)
 
 
+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:
     """Remember a running print's archive as Bambuddy's when it carries a
     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)
                     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
                 _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)
 
 
-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]:
     """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)
 
     run_started_at: datetime | None = None
+    run_id: int | None = None
     if archive_id is not None:
         try:
             async with async_session() as db:
                 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)
                 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 —
                     # and a stale baseline reads as authoritative, where NULL
                     # 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
                     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()
         except Exception as e:
             # 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:
         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}",
         )
 
@@ -6774,17 +6786,22 @@ async def _capture_timelapse_baseline_at_start(
 _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
     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
     _timelapse_baselines_unread.discard(printer_id)
     baseline = _timelapse_baselines.pop(printer_id, None)
     spawn_background_task(
         _mark_run_finishing(
             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}",
     )
@@ -6810,7 +6827,11 @@ def _unread_baseline_round(printer_id: int, names: set[str]) -> bool | None:
 
 
 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:
     """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.printer import Printer
+    from backend.app.services.print_log import run_of
 
     logger = logging.getLogger(__name__)
     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:
                     archive.timelapse_baseline = sorted(names)
                     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:
             # The in-memory baseline is read now, which covers completion
             # unless Bambuddy restarts first.
@@ -6876,12 +6902,26 @@ async def _reread_unread_timelapse_baseline(
         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(
     archive_id: int,
     baseline_names: set[str] | None = None,
     *,
     plate_id: int | None = None,
     baseline_unread: bool = False,
+    run_id: int | None = None,
 ):
     """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
     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__)
 
     # 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:
                 logger.warning("[TIMELAPSE] Archive %s not found, aborting", archive_id)
                 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)
                 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)
                 return
             # 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
             # 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
                 # one Bambuddy took after a restart off a card it could not
                 # read: the stored one is this run's and is evidence.
-                baseline_names = set(archive.timelapse_baseline)
+                baseline_names = set(stored_baseline)
                 logger.info(
                     "[TIMELAPSE] Using stored baseline over an unread one: %s existing video files for archive %s",
                     len(baseline_names),
@@ -6954,17 +7010,17 @@ async def _scan_for_timelapse_with_retries(
                     len(baseline_names),
                     archive_id,
                 )
-            elif archive.timelapse_baseline is not None:
+            elif stored_baseline is not None:
                 # 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(
                     "[TIMELAPSE] Using stored baseline: %s existing video files for archive %s",
                     len(baseline_names),
                     archive_id,
                 )
             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()
                 if not printer:
                     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 — "
                         "use Scan for Timelapse to pick one by hand",
                         archive_id,
-                        archive.printer_id,
+                        scan_printer_id,
                     )
                 logger.info(
                     "[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
             # in-flight scan (issue #2572).
             async with async_session() as db:
+                from backend.app.services.print_log import claimed_timelapse_stems
+
                 service = ArchiveService(db)
                 archive = await service.get_archive(archive_id)
 
                 if not archive:
                     logger.warning("[TIMELAPSE] Archive %s not found, stopping poll", archive_id)
                     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(
-                        "[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
 
-                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()
                 if not printer:
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping poll", archive_id)
                     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.
             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,
                     plate_id=plate_id,
                     run_started_at=run_started_at,
+                    run_id=run_id,
                 )
                 if attached:
                     return
@@ -7092,7 +7149,11 @@ async def _scan_for_timelapse_with_retries(
 
 
 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:
     """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 time the stitch is done, the video is not the new run's and is
     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
 
     logger = logging.getLogger(__name__)
@@ -7113,17 +7179,31 @@ async def _attach_layer_timelapse(
             archive = await service.get_archive(archive_id)
             if archive is None:
                 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(
                     "[LAYER-TL] Archive %s is printing again; dropping the previous run's layer timelapse", archive_id
                 )
                 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)
             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
-            attached = archive.timelapse_path
+            attached = holder.timelapse_path
         logger.info("[LAYER-TL] Layer timelapse attached successfully")
         if replaced and replaced != attached:
             try:
@@ -7153,6 +7233,7 @@ async def _attach_first_unclaimed_timelapse(
     require_unambiguous: bool = False,
     plate_id: int | None = None,
     run_started_at: datetime | None | object = _ANY_RUN,
+    run_id: int | None = None,
 ) -> bool:
     """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).
     async with async_session() as 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)
-            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(
                     "[TIMELAPSE] Archive %s moved on during the download (video attached or printing again); "
                     "%s stays on the printer",
@@ -7276,7 +7358,9 @@ async def _attach_first_unclaimed_timelapse(
                     file_name,
                 )
                 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:
         logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
         return False
@@ -7318,6 +7402,7 @@ async def _capture_finish_photo_from_timelapse(
     archive_dir: Path,
     timeout: float | None = None,
     rotation: int = 0,
+    run_id: int | None = None,
 ) -> tuple[str | None, bool]:
     """Wait for the per-print timelapse to land on the archive and extract its
     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.
     The archived video itself is the printer's own file and is left alone —
     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
 
     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.print_log import run_of
 
     logger = logging.getLogger(__name__)
 
@@ -7356,9 +7445,13 @@ async def _capture_finish_photo_from_timelapse(
 
     while True:
         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:
             video_path = app_settings.base_dir / timelapse_relpath
@@ -7394,7 +7487,9 @@ async def _capture_finish_photo_from_timelapse(
         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).
 
     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
     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
-    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__)
 
     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:
         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
             # Front of the list: PhotoGalleryModal opens at index 0.
             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()
     except Exception as 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_dir=archive_dir,
                     rotation=getattr(printer, "camera_rotation", 0),
+                    run_id=completed_run_id,
                 )
 
             # #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:
                 async with async_session() as db:
                     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)
+                    # 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:
                         photos = arch.photos or []
                         photos.append(photo_filename)
                         arch.photos = photos
+                        add_run_photo(run, photo_filename)
                         await db.commit()
                 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:
                 spawn_background_task(
                     _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}",
                 )
@@ -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
                 timelapse_path = await tl_complete(printer_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:
                     # Timelapse created but no archive - just clean up
                     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)
         # Schedule timelapse scan as background task with retries
         # 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")
 
     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",
         *,
         plate_id: int | None = None,
+        run_id: int | None = None,
     ) -> bool:
         """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
         knows it; print start uses it to tell a reprint from the next plate of
         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
 
+        from backend.app.services.print_log import is_newest_run, newest_run, run_of
+
         archive = await self.get_archive(archive_id)
         if not archive:
             return False
@@ -1942,6 +1950,10 @@ class ArchiveService:
                 archive_id,
             )
             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
         # 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.
@@ -1952,14 +1964,17 @@ class ArchiveService:
         # (#3275). Writing over it would destroy the copy that was kept: the
         # external-camera stitch always names its output layer_timelapse.mp4.
         # 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)
 
-        # 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 _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
         async with async_session() as db:
             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))
             archive = result.scalar_one_or_none()
             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(
                     "Archive %s no longer shows %s; discarding its MP4 conversion", archive_id, source_path.name
                 )
                 mp4_path.unlink(missing_ok=True)
                 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 _announce_timelapse(archive_id, attached=True)
 

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

@@ -7,6 +7,7 @@ import logging
 import uuid
 from collections.abc import Set as AbstractSet
 from datetime import datetime, timezone
+from pathlib import Path
 from types import SimpleNamespace
 
 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,
         confirm_requested=archive.confirm_requested,
         quantity=archive.quantity,
+        # Its own finish photos from here on (``owns_media``).
+        photos=[],
     )
     db.add(entry)
     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))
     if (runs or 0) <= 1:
         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()
     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))
+    # 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):

+ 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.commit = AsyncMock()
+    db.scalar = AsyncMock(return_value=None)  # no run logged
     service = ArchiveService(db)
 
     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=[]))),
             )
         )
+        # The claimed-videos lookup reads runs and archives with scalars().
+        mock_session.scalars = AsyncMock(return_value=MagicMock(all=MagicMock(return_value=[])))
         return mock_session
 
     @pytest.mark.asyncio
@@ -638,6 +640,7 @@ class TestListTimelapseVideosAvi:
                 scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
             )
         )
+        mock_session.scalars = AsyncMock(return_value=MagicMock(all=MagicMock(return_value=[])))
 
         with (
             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.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)
     archive = MagicMock()
     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
+from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -393,15 +394,16 @@ class TestManualScanUsesTheBaseline:
         session.__aenter__ = AsyncMock(return_value=session)
         session.__aexit__ = 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 (
             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.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
             patch(

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/AdditionalSection-9IDYoEpu.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/AmsUnitCard-BFt6fcw3.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ArchivesPage-JEEGcZqh.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/AreaChart-C7o0wAO4.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/AssignToAmsModal-B56xFY46.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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};

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/CamWallPage-Dz3o04by.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/CameraDiagnoseModal-BifWXcZc.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/CameraPage-CSHl71kG.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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};

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/CartesianChart-DreHQWhJ.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ColumnConfigModal-Cgev-XtU.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CORIRX0n.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ConfirmModal-BAcVwl-S.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ConfirmOutcomeDialog-DQcbXiuf.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ConfirmOutcomeDialog-Dal-cSao.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ConnectAuthorizePage-By2XwAPt.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ConnectionDiagnostic-DQAdSC36.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ContextMenu-focSVK91.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/CopyButton-PcW_3Anh.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/EmbeddedCameraViewer-DJDisui-.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ErrorBarContext-Bjss1n8z.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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};

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 1
static/assets/FileManagerPage-CCg1jTJR.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/FileUploadModal-B7-ZgclE.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/FinancePage-CExgNu-s.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/GCodeViewerPage-BmMNin-K.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/GcodeToolpathViewer-DbikpNEN.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/GroupEditPage-Wh6lHVkv.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/IconPicker-Bs7QtuML.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ImagePreviewModal-CSS-jpjU.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/InventoryPage-CuQzKTSx.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/Layout-B1sOAU5B.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/Layout-fff8uYnZ.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/LibraryTrashPage-rCmFsW8R.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/LoginPage-PMmE2MUa.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/MaintenancePage-D4Z_E4vg.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ModelSourcesPage-bTazZy_r.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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};

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/PdfPreviewModal-BcAzK05M.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/PhotoGalleryModal-D50nEPW2.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/PrintModal-BN27coZJ.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/PrinterLocationsPage-B50mzuk9.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/PrintersPage-Dmjv7rQi.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ProfilesPage-BX1ovOk3.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ProjectDetailPage-_hJr_5fD.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ProjectsPage-wUNwt7E7.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/QueryClientProvider-BytsNH5G.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/QueuePage-HjOyEbVP.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ReferenceLine-D2TF4QRA.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/RichTextEditor-Ba_DNCVd.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SettingsPage-Bqq75Ayx.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SetupPage-CHrCcuTL.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SkipObjectsModal-ixrCEckS.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SliceModal-Ckev_VNF.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BnkN1nhN.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SpoolBuddyDashboard-DfMqFYjX.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-7Bf5DCCS.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Cl4HkltR.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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};

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SpreadsheetPreviewModal-CH2TrBYh.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/StatsPage-BzYOUuyx.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/StreamOverlayPage-DcCOW1P0.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SystemHealthPanel-BxDo8Ewn.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/SystemInfoPage-FqBce2iU.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/ThemeContext-CxrwGucU.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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};

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio