Browse Source

Archives: finish photos, plate park, outcome change, video count

- Finish photo park: after the photo the plate went to print top +
  100 mm on every model; on a 238 mm H2C print that is Z337.8, past
  the bottom of its travel (the firmware does not clamp MQTT G-code,
  #2579). The end G-code's final Z is now read from the archived 3MF
  (Bambu Studio's MACHINE_END_GCODE_START, OrcaSlicer's last filament
  end gcode). The plate is raised only when left below print top +
  10 mm and parked back at exactly that Z; an unknown end Z (no
  marker, Z in an M622 block, G28/G92, relative Z with no start)
  means no move. Both photo paths go through
  _raise_plate_for_finish_photo.
- One finish photo: a late timelapse frame replaces the live finish
  photo under the same name instead of being added beside it. The
  photo route sends Cache-Control: no-cache.
- Every plate keeps its finish photo: with photos already on the
  archive, the next print's was appended to the list in place, which
  the ORM does not write, so only the run had it (archive #455). The
  list is copied first now. One-shot startup repair (settings flag):
  a photo a run lists, its archive does not and that is on disk is
  appended in run order.
- Change outcome: the archive window's "Change outcome" opened a
  dialog that only stated the verdict. The dialog takes a change
  flag: titled "Change outcome", Good and Reject offered with the
  current one marked, Cancel instead of "Ask me later".
- Video count: archive responses carry timelapse_count (each run's
  video, plus the archive's when no run holds it; printer scope
  counts its runs only). Card button and list icon show it; with
  more than one, or one only an earlier print has, they and the card
  menu open the archive window. archives.card.viewTimelapses in all
  locales.
- Tests for each, SQLite and PostgreSQL; CHANGELOG.
maziggy 2 days ago
parent
commit
72d8d636ee
86 changed files with 1167 additions and 124 deletions
  1. 5 0
      CHANGELOG.md
  2. 32 3
      backend/app/api/routes/archives.py
  3. 68 0
      backend/app/core/database.py
  4. 132 69
      backend/app/main.py
  5. 3 0
      backend/app/schemas/archive.py
  6. 103 0
      backend/app/utils/threemf_tools.py
  7. 32 0
      backend/tests/integration/test_archive_scope_per_run.py
  8. 3 0
      backend/tests/integration/test_fallback_archive_photos_1820.py
  9. 65 0
      backend/tests/integration/test_print_complete_end_to_end.py
  10. 21 0
      backend/tests/integration/test_timelapse_per_run.py
  11. 157 0
      backend/tests/unit/test_archive_run_photos_backfill.py
  12. 162 28
      backend/tests/unit/test_finish_photo_moment_sync.py
  13. 141 0
      backend/tests/unit/test_threemf_tools.py
  14. 60 3
      backend/tests/unit/test_timelapse_scan_2704.py
  15. 12 0
      frontend/src/__tests__/components/ArchiveDetailModal.test.tsx
  16. 42 0
      frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx
  17. 58 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  18. 2 0
      frontend/src/api/client.ts
  19. 1 0
      frontend/src/components/ArchiveDetailModal.tsx
  20. 13 7
      frontend/src/components/ConfirmOutcomeDialog.tsx
  21. 1 0
      frontend/src/i18n/locales/de.ts
  22. 1 0
      frontend/src/i18n/locales/en.ts
  23. 1 0
      frontend/src/i18n/locales/es.ts
  24. 1 0
      frontend/src/i18n/locales/fr.ts
  25. 1 0
      frontend/src/i18n/locales/it.ts
  26. 1 0
      frontend/src/i18n/locales/ja.ts
  27. 1 0
      frontend/src/i18n/locales/ko.ts
  28. 1 0
      frontend/src/i18n/locales/nl.ts
  29. 1 0
      frontend/src/i18n/locales/pl.ts
  30. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  31. 1 0
      frontend/src/i18n/locales/ru.ts
  32. 1 0
      frontend/src/i18n/locales/sv.ts
  33. 1 0
      frontend/src/i18n/locales/tr.ts
  34. 1 0
      frontend/src/i18n/locales/uk.ts
  35. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  36. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  37. 35 10
      frontend/src/pages/ArchivesPage.tsx
  38. 0 0
      static/assets/ArchivesPage-CsBkUVly.js
  39. 0 0
      static/assets/ArchivesPage-D6ilKR1P.js
  40. 0 0
      static/assets/CamWallPage-CN_iXBYa.js
  41. 0 0
      static/assets/CameraPage-FUEFv-n-.js
  42. 0 0
      static/assets/ConfirmOutcomeDialog-BDm0_cKj.js
  43. 0 0
      static/assets/ConfirmOutcomeDialog-CZt5lzm2.js
  44. 0 0
      static/assets/FileManagerPage-BU5Y5tn1.js
  45. 0 0
      static/assets/InventoryPage-1Reg_1ji.js
  46. 0 0
      static/assets/Layout-9sMyOqKM.js
  47. 0 0
      static/assets/ModelSourcesPage-BzJlqbI7.js
  48. 0 0
      static/assets/PrintersPage-CC0jLL3j.js
  49. 0 0
      static/assets/SettingsPage-NBSUhHCs.js
  50. 0 0
      static/assets/SliceModal-CRaaExwJ.js
  51. 0 0
      static/assets/SpoolBuddyInventoryPage-AlkKETcA.js
  52. 1 1
      static/assets/SpoolBuddySettingsPage-BJTQQM-S.js
  53. 0 0
      static/assets/de-Baajb4U2.js
  54. 0 0
      static/assets/de-CRIJfR-_.js
  55. 0 0
      static/assets/es-Dq2_XotR.js
  56. 0 0
      static/assets/es-IhRbElHG.js
  57. 0 0
      static/assets/fr-AD9-WTwX.js
  58. 0 0
      static/assets/fr-m7Eh5XiF.js
  59. 0 0
      static/assets/index-Dh3Mfp1u.js
  60. 0 0
      static/assets/it-CcojkNtO.js
  61. 0 0
      static/assets/it-DEt58Xqg.js
  62. 0 0
      static/assets/ja-B0PBhJzH.js
  63. 0 0
      static/assets/ja-CYCGIdb6.js
  64. 0 0
      static/assets/ko-DlQV2CP3.js
  65. 0 0
      static/assets/ko-LBwQXDJ4.js
  66. 0 0
      static/assets/locationSensorDefaults-DqpPcsBr.js
  67. 0 0
      static/assets/nl-CDb2x4SC.js
  68. 0 0
      static/assets/nl-p7amfW2c.js
  69. 0 0
      static/assets/pl-86viVzBH.js
  70. 0 0
      static/assets/pl-BnS9GarG.js
  71. 0 0
      static/assets/pt-BR-PUbvf61c.js
  72. 0 0
      static/assets/pt-BR-wxsdPePQ.js
  73. 0 0
      static/assets/ru-CxhLs1uk.js
  74. 0 0
      static/assets/ru-D6oTok3o.js
  75. 0 0
      static/assets/spoolCost-DRYHR5kH.js
  76. 0 0
      static/assets/sv-0-Z1guh-.js
  77. 0 0
      static/assets/sv-BPXRmLqd.js
  78. 0 0
      static/assets/tr-Kt9nhBtm.js
  79. 0 0
      static/assets/uk-B_pxErtv.js
  80. 0 0
      static/assets/uk-DGGI8X6x.js
  81. 0 0
      static/assets/usePageFileDrop-rQbvu4ku.js
  82. 0 0
      static/assets/zh-CN-1SLNemp_.js
  83. 0 0
      static/assets/zh-CN-BdJLzflv.js
  84. 0 0
      static/assets/zh-TW-B94bEGEo.js
  85. 0 0
      static/assets/zh-TW-CQLA-R0K.js
  86. 3 3
      static/index.html

+ 5 - 0
CHANGELOG.md

@@ -219,6 +219,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Finish photo: the plate is no longer driven to the bottom after a tall print** — With **Restore plate for finish photo** on, Bambuddy lowered the plate after the photo to the print's height + 100 mm, whatever the printer. On a 238 mm print on an H2C that is Z338, past the bottom of its travel: Bambu's own end G-code stops at the print's height + 2 mm when +100 doesn't fit, and the printer does not stop G-code sent over the network at its limits. The plate now goes back exactly where the print's end G-code left it, read from the archived file, and is only raised for the photo when the end G-code left it lower than just above the print (the H2C's doesn't, so its plate stays put). A file whose end position can't be read is not moved at all. Wiki updated.
+- **An archive card counts every print's video** — An archive printed more than once (each plate of a project, or a reprint) counted every print's finish photos on its card, but its video button knew only the latest print's video. The button now shows how many videos the prints have and, when there is more than one, opens the archive, where each print's video plays. A single video still plays in place. The list view and the card's menu do the same.
+- **"Change outcome" in an archive's print list changes it** — The button opened the outcome dialog, which for a print with a verdict only said "Already confirmed as a good part." under "How did your print come out?", with nothing to choose. It now opens as **Change outcome** with Good and Reject offered again, the current one marked, and Cancel to leave it as it was.
+- **Every plate of an archive keeps its finish photo** — When an archive already had a photo, the finish photo of its next print (the second plate of a project, or a reprint) was taken and saved on disk but never added to the archive, so that print showed no photo. It is now added. Photos already lost this way are put back once at startup, wherever the file is still on disk.
+- **One finish photo when the timelapse arrives late** — When the timelapse video arrived after the print-complete notification's 60-second wait, the archive kept both photos of the finished print: the live camera photo and the video's last frame. The frame now replaces the live photo under the same name, so the archive keeps one finish photo; with authentication off, the link in a notification already sent shows the better photo too.
 - **MakerWorld imports work again (#3447 reported by @waynem-bris, #3448 reported by @frederic3114)** — Since 3 November every MakerWorld import, Save and Save & Slice failed with "MakerWorld did not return a download URL": the Bambu Lab endpoint Bambuddy asked for the download link still answers "success", but with an empty link. The link now comes from MakerWorld's download endpoint for the plate (its instance), with the same Bambu Cloud sign-in; the old endpoint is still asked when that gives none.
 - **"Force color match" is kept on a job for a specific printer (#3445, reported by @frnzzle)** — With a specific printer picked, the print dialog showed the "Force color match" checkbox under each slot but never saved it: the job was added or saved with its trays only, and the box came back unticked. Once the tray picked for a slot no longer fit when the job came up, the scheduler matched the trays again against the colour the file was sliced with and, without that colour loaded, took any spool of the type, with "prefer lowest" the emptiest. A part sliced in white and set to black printed in green. A ticked slot is now saved, so the box stays ticked: set to a tray you picked, it asks for that tray's filament; on a tray the dialog matched by itself, for the file's own, so the job waits for it. Matching again takes only that filament or holds the job. Jobs for "Any" model were not affected.
 - **Configure accepts a setting id as the filament (#3444, reported by @Novastorm4332)** — A client of the REST API that configured a slot with a spool's slicer filament as `tray_info_idx` sent a Bambu setting id such as `GFSL99` to the printer, which knows only filament ids (`GFL99`): the slot came back without a colour or type and auto-unlink dropped the spool. Assigning the spool already converted it. Configure now converts it the same way, the setting id staying as the slot's setting id, and does the same for a K profile's filament id. Support and SUNLU ids, which also begin with GFS, are filament ids and stay as they are. Bambuddy's own Configure dialog was not affected.

+ 32 - 3
backend/app/api/routes/archives.py

@@ -464,6 +464,21 @@ async def _load_run_aggregates(
         aggregate["latest_run_id"] = row.id
         # What its time accuracy is measured from (#3375).
         aggregate["latest_run"] = row
+    # Every print's own video: the archive holds only its latest print's, so
+    # a card counts these to say how many there are.
+    videos = await db.execute(
+        select(PrintLogEntry.archive_id, PrintLogEntry.timelapse_path)
+        .where(
+            PrintLogEntry.archive_id.in_(archive_ids),
+            PrintLogEntry.timelapse_path.is_not(None),
+            *([scope] if scope is not None else []),
+        )
+        .distinct()
+        .execution_options(**{INCLUDE_OPEN_RUNS: True})
+    )
+    for archive_id, path in videos.all():
+        if archive_id is not None:
+            aggregates.setdefault(archive_id, {}).setdefault("run_timelapses", set()).add(path)
     return aggregates
 
 
@@ -523,8 +538,12 @@ def _scoped(data: dict, view: ScopedView | None) -> dict:
     data["actual_time_seconds"] = data["time_estimate_seconds"] = data["time_accuracy"] = None
     for name in ("energy_kwh", "energy_cost", "wear_cost"):
         data[name] = getattr(view.shown, name) if view.shown is not None else None
-    if view.hidden_timelapse is not None and data["timelapse_path"] == view.hidden_timelapse:
-        data["timelapse_path"] = None
+    if view.hidden_timelapse is not None:
+        if data["timelapse_path"] == view.hidden_timelapse:
+            data["timelapse_path"] = None
+        # The archive's own, which the count took; no print in the scope
+        # holds it, or it would not be hidden.
+        data["timelapse_count"] = max(0, data["timelapse_count"] - 1)
     if data["photos"]:
         data["photos"] = [p for p in data["photos"] if p not in view.hidden_photos] or None
     return data
@@ -600,6 +619,13 @@ def archive_to_response(
         "created_by_username": archive.created_by.username if archive.created_by else None,
     }
 
+    # Its videos: every print's own, and the archive's when no print holds it
+    # (attached by hand, or from before prints kept their own).
+    videos = set((run_aggregate or {}).get("run_timelapses", ()))
+    if archive.timelapse_path:
+        videos.add(archive.timelapse_path)
+    data["timelapse_count"] = len(videos)
+
     # The time accuracy of the latest print, from ``run_aggregate`` (#3375).
     accuracy_data = compute_time_accuracy(archive, run_aggregate)
     data.update(accuracy_data)
@@ -4207,7 +4233,10 @@ async def get_photo(
     }
     media_type = media_types.get(ext, "image/jpeg")
 
-    return FileResponse(path=photo_path, media_type=media_type)
+    # Revalidated, not cached: a late timelapse frame is written over the live
+    # finish photo under the same name (#2547), and a cached copy would keep
+    # showing the old one.
+    return FileResponse(path=photo_path, media_type=media_type, headers={"Cache-Control": "no-cache"})
 
 
 @router.delete("/{archive_id}/photos/{filename}")

+ 68 - 0
backend/app/core/database.py

@@ -6037,6 +6037,8 @@ async def _run_migrations(conn):
     # (``print_cost_estimate.fill_plate_estimates``).
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_estimates JSON")
 
+    await _backfill_archive_run_photos(conn)
+
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_wallet_transactions_keep_deleted_users(conn)
@@ -6088,6 +6090,72 @@ async def _backfill_print_log_plate_ids(conn) -> None:
         await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
 
 
+async def _backfill_archive_run_photos(conn) -> None:
+    """Put back on its archive a finish photo only its run kept.
+
+    The finish photo of a later print on an archive was appended to the
+    archive's list in place, which the ORM does not see as a change, so only
+    the run's list was saved. The archive serves and shows photos from its own
+    list, so that print looked like it had none. Each photo a run lists, that
+    its archive does not and that is on disk, is appended to the archive's
+    list in run order. A photo whose file is gone stays off.
+
+    Once only, gated by a settings flag: a photo deleted from an archive is
+    also removed from its run, but a second pass must never be what decides
+    that."""
+    from types import SimpleNamespace
+
+    from sqlalchemy import JSON, bindparam, text
+
+    from backend.app.utils.archive_paths import find_archive_photo
+
+    flag = "_backfill_archive_run_photos_done"
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already is not None:
+            return
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT a.id, a.file_path, a.photos, e.photos AS run_photos FROM print_archives a "
+                    "JOIN print_log_entries e ON e.archive_id = a.id WHERE e.photos IS NOT NULL "
+                    "ORDER BY a.id, CASE WHEN e.started_at IS NULL THEN 1 ELSE 0 END, e.started_at, e.id"
+                ).columns(photos=JSON, run_photos=JSON)
+            )
+        ).fetchall()
+        archives: dict[int, tuple[SimpleNamespace, list, list]] = {}
+        for row in rows:
+            archive, _, photos = archives.setdefault(
+                row.id,
+                (SimpleNamespace(id=row.id, file_path=row.file_path), list(row.photos or []), list(row.photos or [])),
+            )
+            for name in row.run_photos or []:
+                if not isinstance(name, str) or name in photos:
+                    continue
+                # Per photo, and broad: one unreadable path must not cost the
+                # user their boot.
+                try:
+                    found = find_archive_photo(archive, name) is not None
+                except Exception as exc:
+                    logger.warning("Could not look for photo %s of archive %s: %s", name, row.id, exc)
+                    continue
+                if found:
+                    photos.append(name)
+        update = text("UPDATE print_archives SET photos = :photos WHERE id = :id").bindparams(
+            bindparam("photos", type_=JSON)
+        )
+        restored = 0
+        for archive_id, (_, before, photos) in archives.items():
+            if photos != before:
+                await conn.execute(update, {"photos": photos, "id": archive_id})
+                restored += 1
+        if restored:
+            logger.info("Restored finish photos kept only by their print on %d archive(s)", restored)
+        await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
+
+
 async def _release_slots_of_archived_spools(conn) -> None:
     """An archived spool gives its AMS slots up: archiving does so now, and the
     slots still held by one archived earlier are released here. Idempotent."""

+ 132 - 69
backend/app/main.py

@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
 from pathlib import Path, PurePosixPath
-from typing import cast
+from typing import NamedTuple, cast
 from urllib.parse import urlparse
 
 from fastapi import FastAPI
@@ -7628,9 +7628,13 @@ async def _capture_finish_photo_from_timelapse(
 
 
 async def _upgrade_finish_photo_from_timelapse(
-    archive_id: int, archive_dir: Path, rotation: int = 0, run_id: int | None = None
+    archive_id: int,
+    archive_dir: Path,
+    rotation: int = 0,
+    run_id: int | None = None,
+    replace: str | None = None,
 ) -> None:
-    """Add the timelapse's last frame to an archive after the fact (#2704).
+    """Put the timelapse's last frame on an archive after the fact (#2704).
 
     The print-complete notification waits only ~60s for the video, because
     holding a notification for minutes is worse than sending it with a live
@@ -7641,10 +7645,15 @@ async def _upgrade_finish_photo_from_timelapse(
 
     This keeps waiting in the background and, when the video arrives, extracts
     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. ``run_id`` is the print it is for,
-    whose list leads with it too.
+    gallery shows it. ``replace`` is the live grab: the frame is written over
+    that file, under its name, rather than added next to it. Two finish photos
+    of the same moment is one too many, and the name has to survive: with
+    authentication off, the notification that already went out links to
+    ``/archives/<id>/photos/<name>``, which now shows the better photo. (With
+    it on, the link is to a copy in notification_photos, and attachments are
+    bytes, so neither cares.) Without ``replace``, or once that photo has been
+    deleted, the frame is added under its own name. ``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
 
@@ -7667,6 +7676,12 @@ async def _upgrade_finish_photo_from_timelapse(
             photos = list(archive.photos or [])
             if filename in photos:
                 return
+            photos_dir = archive_dir / "photos"
+            if replace and replace in photos and (photos_dir / replace).is_file():
+                # Atomic, so the gallery never reads half a JPEG.
+                await asyncio.to_thread(os.replace, photos_dir / filename, photos_dir / replace)
+                filename = replace
+                photos.remove(replace)
             # 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)
@@ -7675,7 +7690,12 @@ async def _upgrade_finish_photo_from_timelapse(
         logger.warning("[PHOTO-UPGRADE] Failed to attach upgraded photo to archive %s: %s", archive_id, e)
         return
 
-    logger.info("[PHOTO-UPGRADE] Archive %s now leads with the timelapse frame %s", archive_id, filename)
+    logger.info(
+        "[PHOTO-UPGRADE] Archive %s now leads with the timelapse frame %s%s",
+        archive_id,
+        filename,
+        " (replacing the live grab)" if filename == replace else "",
+    )
     await ws_manager.send_archive_updated({"id": archive_id, "photo_added": filename})
 
 
@@ -8204,16 +8224,12 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
 # the part by then, so this is belt-and-braces against a max_z_height that
 # under-reports (e.g. a slicer that excludes a final Z hop).
 _PLATE_RESTORE_CLEARANCE_MM = 10.0
-# How far below the restored position to drop the plate again afterwards, so
-# the print is as reachable as Bambu's own end G-code leaves it. Matches the
-# stock `G1 Z{max_layer_z + 100}`; the firmware clamps it to the travel limit
-# on machines with less headroom.
-_PLATE_PARK_DROP_MM = 100.0
 # Feedrate for both moves. F600 is exactly what Bambu's own end G-code uses on
 # this axis, so it is a proven-safe speed for the full travel.
 _PLATE_RESTORE_FEEDRATE = 600
 # Time allowed for the plate to reach the restored position before the camera
-# grab. Sized for the ~100 mm the stock end G-code drops at F600 (10 mm/s).
+# grab. Sized for the ~100 mm the stock end G-code drops on X1/P1 at F600
+# (10 mm/s); most H2 and P2 end G-code drops less, or not at all.
 _PLATE_RESTORE_SETTLE_SECONDS = 12.0
 # How long `_background_finish_photo` waits for this producer. Must cover the
 # settle window plus a worst-case RTSP grab (15s), and stay below the
@@ -8310,17 +8326,28 @@ def _bound_print(printer_id: int, data: dict) -> tuple[int | None, int | None]:
         return None, None
 
 
-async def _max_z_for_current_print(
+class PlateHeights(NamedTuple):
+    """Where the finished print is, and where its end G-code left the plate (#2547)."""
+
+    print_top: float
+    """The plate's ``max_z_height``: the Z the toolhead sat at for the last layer."""
+    end_z: float
+    """The last Z the end G-code moved to: where the plate is now, and where it is parked back to."""
+
+
+async def _plate_heights_for_current_print(
     printer_id: int,
     data: dict,
     logger,
     archive_id: int | None,
     plate_id: int | None = None,
-) -> float | None:
-    """Height of the print that just finished on ``printer_id``, or None (#2547).
+) -> PlateHeights | None:
+    """Heights of the print that just finished on ``printer_id``, or None (#2547).
 
-    This number becomes the target of a real Z move, so every step here refuses
-    rather than guesses. A height belonging to some *other* print is the one
+    These numbers become the targets of real Z moves, so every step here refuses
+    rather than guesses. Both have to be known: without the end Z there is no
+    position to park the plate back to that the printer's own G-code chose, and
+    the firmware won't stop a made-up one at the travel limit (#2579). A height belonging to some *other* print is the one
     failure that could drive the nozzle into the model: 20 mm carried onto a
     200 mm print would command the plate up through the part.
 
@@ -8367,7 +8394,7 @@ async def _max_z_for_current_print(
 
     try:
         from backend.app.models.archive import PrintArchive
-        from backend.app.utils.threemf_tools import extract_max_z_height_from_3mf
+        from backend.app.utils.threemf_tools import extract_end_z_from_3mf, extract_max_z_height_from_3mf
 
         async with async_session() as db:
             result = await db.execute(
@@ -8408,7 +8435,18 @@ async def _max_z_for_current_print(
         # (#3240: this read a `data_dir` setting that does not exist).
         path = app_settings.base_dir / archive.file_path
         plate = plate_id or archive.plate_id or 1
-        return await asyncio.to_thread(extract_max_z_height_from_3mf, path, plate)
+        print_top = await asyncio.to_thread(extract_max_z_height_from_3mf, path, plate)
+        if print_top is None:
+            return None
+        end_z = await asyncio.to_thread(extract_end_z_from_3mf, path, plate)
+        if end_z is None:
+            logger.info(
+                "[PLATE-RESTORE] printer %s: archive %s has no end G-code position to park at — skipping",
+                printer_id,
+                archive_id,
+            )
+            return None
+        return PlateHeights(print_top, end_z)
     except Exception as e:
         logger.debug("[PLATE-RESTORE] printer %s: no usable print height: %s", printer_id, e)
         return None
@@ -8417,11 +8455,13 @@ async def _max_z_for_current_print(
 async def _restore_plate_for_finish_photo(printer_id: int, max_z_height: float, logger) -> bool:
     """Raise the plate back into camera framing before the finish photo (#2547).
 
-    Bambu's end G-code drops the plate ~100 mm as the last thing it does, so by
-    the time ``gcode_state`` reaches FINISH the finished print sits far below
-    the camera's natural framing — the complaint behind #1145, #1397 and #1565.
-    This commands an absolute ``G1 Z`` back to just above the last printed
-    layer.
+    On X1/P1/A1 Bambu's end G-code drops the plate ~100 mm as the last thing it
+    does, so by the time ``gcode_state`` reaches FINISH the finished print sits
+    far below the camera's natural framing — the complaint behind #1145, #1397
+    and #1565. This commands an absolute ``G1 Z`` back to just above the last
+    printed layer. Whether that is needed at all is ``_raise_plate_for_finish_photo``'s
+    call: plenty of end G-code (H2C, tall prints anywhere) leaves the plate in
+    view already.
 
     Absolute, not relative, is the whole safety argument. ``max_z_height +
     clearance`` is a height the toolhead was physically at seconds earlier, so
@@ -8466,12 +8506,15 @@ async def _restore_plate_for_finish_photo(printer_id: int, max_z_height: float,
     return True
 
 
-def _park_plate_after_finish_photo(printer_id: int, max_z_height: float, logger) -> None:
-    """Drop the plate again after the finish photo (#2547).
+def _park_plate_after_finish_photo(printer_id: int, end_z: float, logger) -> None:
+    """Put the plate back where the end G-code left it, after the finish photo (#2547).
 
-    Without this the user walks up to a finished print sitting just under the
-    nozzle, which is exactly the position Bambu's end G-code goes out of its way
-    to avoid — awkward to lift the plate out, and easy to knock the toolhead.
+    ``end_z`` is the last Z the print's own end G-code moved to, never a height
+    worked out here. That is the safety argument: Bambu's templates keep their
+    end moves inside each model's travel, and the firmware does not clamp
+    G-code sent over MQTT (#2579). Parking at print top + 100 mm instead drove
+    the plate of a 238 mm H2C print to the bottom of its travel, a move Bambu's
+    own template had declined to make.
     Fire-and-forget: if it doesn't land, the plate is merely high, and the next
     print homes anyway.
     """
@@ -8479,8 +8522,39 @@ def _park_plate_after_finish_photo(printer_id: int, max_z_height: float, logger)
     state = getattr(client, "state", None) if client else None
     if client is None or state is None or state.state != "FINISH":
         return
-    client.send_gcode(f"G90\nG1 Z{max_z_height + _PLATE_PARK_DROP_MM:.2f} F{_PLATE_RESTORE_FEEDRATE}")
-    logger.debug("[PLATE-RESTORE] printer %s: plate returned to unload height", printer_id)
+    client.send_gcode(f"G90\nG1 Z{end_z:.2f} F{_PLATE_RESTORE_FEEDRATE}")
+    logger.info("[PLATE-RESTORE] printer %s: plate back to the end G-code's Z%.2f", printer_id, end_z)
+
+
+async def _raise_plate_for_finish_photo(
+    printer_id: int, data: dict, logger, archive_id: int | None, plate_id: int | None
+) -> float | None:
+    """Raise the plate into view for the finish photo if it needs it (#2547).
+
+    Returns the Z to park the plate back to once the photo is taken, or None
+    when the plate wasn't moved. It needs moving only when the end G-code
+    left it lower than the restore height; when it is already at or above it
+    (the H2C end G-code puts it back 2 mm under the nozzle), raising would be
+    a move away from the camera.
+    """
+    heights = await _plate_heights_for_current_print(printer_id, data, logger, archive_id, plate_id)
+    if heights is None:
+        logger.info("[PLATE-RESTORE] printer %s: print height unknown — capturing without restore", printer_id)
+        return None
+    if heights.end_z <= heights.print_top + _PLATE_RESTORE_CLEARANCE_MM:
+        logger.info(
+            "[PLATE-RESTORE] printer %s: end G-code left the plate at Z%.2f (print top %.2f) — already in view",
+            printer_id,
+            heights.end_z,
+            heights.print_top,
+        )
+        return None
+    if await _plate_restore_is_blocked_by_queue(printer_id):
+        logger.info("[PLATE-RESTORE] printer %s has queued work — skipping plate restore", printer_id)
+        return None
+    if await _restore_plate_for_finish_photo(printer_id, heights.print_top, logger):
+        return heights.end_z
+    return None
 
 
 async def _plate_restore_is_blocked_by_queue(printer_id: int) -> bool:
@@ -8562,10 +8636,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
     # print's archive binding and its plate; read here, they are still there.
     bound_archive_id, bound_plate_id = _bound_print(printer_id, data)
 
-    # #2547: set once the plate has actually been raised, and read by the
-    # `finally` below. Declared out here so a failure anywhere after the move —
-    # a camera timeout, a DB error — still lowers the plate again.
-    restore_max_z: float | None = None
+    # #2547: the Z to park the plate back to, set once the plate has actually
+    # been raised, and read by the `finally` below. Declared out here so a
+    # failure anywhere after the move — a camera timeout, a DB error — still
+    # lowers the plate again.
+    park_z: float | None = None
 
     try:
         async with async_session() as db:
@@ -8604,9 +8679,9 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
         # is no longer the finished print, and no amount of moving the plate
         # brings it back. Use the banked in-print frame instead.
         #
-        # #2547: otherwise the print is still sitting there, just ~100 mm lower
-        # than the camera frames well, and the toolhead is parked out of the
-        # way. That is the *best* moment available on firmware that never emits
+        # #2547: otherwise the print is still sitting there — on X1/P1/A1 ~100 mm
+        # lower than the camera frames well — and the toolhead is parked out of
+        # the way. That is the *best* moment available on firmware that never emits
         # stage 22 (H2C, A1 Mini) — so capture live, after putting the plate
         # back. Preferring the bank here unconditionally, as this code used to,
         # is what shipped a mid-print photo with the toolhead over the part.
@@ -8628,7 +8703,7 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
                     printer_id,
                 )
 
-        # `restore_max_z` is set only once the plate is actually up, because the
+        # `park_z` is set only once the plate is actually up, because the
         # `finally` reads it to decide whether it owes a move back down.
         #
         # Never on a print whose End G-code Bambuddy injected, even when the bank
@@ -8641,19 +8716,7 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
             and restore_plate_enabled
             and not print_dispatch_context.end_gcode_injected(printer_id)
         ):
-            wants_restore = await _max_z_for_current_print(printer_id, data, logger, bound_archive_id, bound_plate_id)
-            if wants_restore is None:
-                logger.info(
-                    "[PLATE-RESTORE] printer %s: print height unknown — capturing without restore",
-                    printer_id,
-                )
-            elif await _plate_restore_is_blocked_by_queue(printer_id):
-                logger.info(
-                    "[PLATE-RESTORE] printer %s has queued work — skipping plate restore",
-                    printer_id,
-                )
-            elif await _restore_plate_for_finish_photo(printer_id, wants_restore, logger):
-                restore_max_z = wants_restore
+            park_z = await _raise_plate_for_finish_photo(printer_id, data, logger, bound_archive_id, bound_plate_id)
 
         # Chamber light for the live capture below (#1655). The banked frame
         # above was taken without it, like every in-print bank.
@@ -8723,9 +8786,9 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
     finally:
         # #2547: we raised the plate, so we own lowering it — including when the
         # capture above failed or threw partway through.
-        if restore_max_z is not None:
+        if park_z is not None:
             try:
-                _park_plate_after_finish_photo(printer_id, restore_max_z, logger)
+                _park_plate_after_finish_photo(printer_id, park_z, logger)
             except Exception as e:
                 logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e)
         # #1790: always unblock the consumer's bounded wait — whether we stored
@@ -10111,11 +10174,11 @@ async def _close_out_print(printer_id: int, data: dict):
 
     async def _background_finish_photo() -> str | None:
         """Capture finish photo in background. Returns photo filename if captured."""
-        # #2547: set once this function has raised the plate itself (the
-        # timelapse path, where the moment producer returned without doing it).
-        # Declared out here so the `finally` can lower it again no matter where
-        # the capture below fails.
-        plate_restored_z: float | None = None
+        # #2547: the Z to park the plate back to, set once this function has
+        # raised the plate itself (the timelapse path, where the moment producer
+        # returned without doing it). Declared out here so the `finally` can
+        # lower it again no matter where the capture below fails.
+        park_z: float | None = None
         try:
             logger.info("[PHOTO-BG] Starting finish photo capture for archive %s", archive_id)
 
@@ -10247,12 +10310,9 @@ async def _close_out_print(printer_id: int, data: dict):
 
                         restore_setting = await get_setting(db, "finish_photo_restore_plate")
                     if restore_setting is None or restore_setting.lower() == "true":
-                        max_z = await _max_z_for_current_print(
+                        park_z = await _raise_plate_for_finish_photo(
                             printer_id, data, logger, bound_archive_id, notify_plate_id
                         )
-                        if max_z is not None and not await _plate_restore_is_blocked_by_queue(printer_id):
-                            if await _restore_plate_for_finish_photo(printer_id, max_z, logger):
-                                plate_restored_z = max_z
                 except Exception as e:
                     logger.warning("[PLATE-RESTORE] printer %s: restore failed: %s", printer_id, e)
 
@@ -10332,7 +10392,9 @@ async def _close_out_print(printer_id: int, data: dict):
                     # 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 []
+                        # A new list: appended in place, the ORM sees no
+                        # change and a second print's photo is never saved.
+                        photos = list(arch.photos or [])
                         photos.append(photo_filename)
                         arch.photos = photos
                         add_run_photo(run, photo_filename)
@@ -10342,7 +10404,7 @@ async def _close_out_print(printer_id: int, data: dict):
             # The short wait above is bounded so a slow printer can't hold up
             # the print-complete notification, which is what the caller is
             # blocking on. When it ran out with the video still on its way,
-            # keep waiting off to the side and add the better frame to the
+            # keep waiting off to the side and swap the better frame into the
             # archive once it arrives (#2704 follow-up) — otherwise P1-series
             # users, whose videos routinely take minutes to transfer, never get
             # the pre-bed-drop framing this path exists to provide.
@@ -10364,6 +10426,7 @@ async def _close_out_print(printer_id: int, data: dict):
                             archive_dir,
                             rotation=getattr(printer, "camera_rotation", 0),
                             run_id=completed_run_id,
+                            replace=photo_filename,
                         ),
                     ),
                     name=f"finish-photo-upgrade-{archive_id}",
@@ -10377,9 +10440,9 @@ async def _close_out_print(printer_id: int, data: dict):
             # #2547: we raised the plate, so we owe the move back down — even if
             # the capture in between threw. Otherwise the user finds the print
             # pinned under the nozzle.
-            if plate_restored_z is not None:
+            if park_z is not None:
                 try:
-                    _park_plate_after_finish_photo(printer_id, plate_restored_z, logger)
+                    _park_plate_after_finish_photo(printer_id, park_z, logger)
                 except Exception as e:
                     logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e)
 

+ 3 - 0
backend/app/schemas/archive.py

@@ -157,6 +157,9 @@ class ArchiveResponse(BaseModel):
     # The latest print of the archive (by start, among those the caller may
     # see): the run the per-print fields are taken from.
     latest_run_id: int | None = None
+    # How many videos its prints have: the archive's timelapse_path is only
+    # its latest print's.
+    timelapse_count: int = 0
 
     # Energy tracking
     energy_kwh: float | None = _per_print(None, _FIRST_PRINT)

+ 103 - 0
backend/app/utils/threemf_tools.py

@@ -1543,6 +1543,109 @@ def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None)
     return value if value > 0 else None
 
 
+# The end G-code is a few KB; the tail kept while streaming the plate G-code
+# only has to be sure to hold all of it.
+_END_GCODE_TAIL_BYTES = 256 * 1024
+_END_GCODE_MARKER = "; MACHINE_END_GCODE_START"
+# OrcaSlicer writes no MACHINE_END_GCODE_START; its end G-code follows the last
+# filament end G-code, as Bambu Studio's does the marker.
+_ORCA_END_GCODE_MARKER = "; filament end gcode"
+_MOVE_COMMANDS = frozenset({"G0", "G1", "G2", "G3"})
+_Z_WORD = re.compile(r"^Z(-?\d+(?:\.\d*)?|-?\.\d+)$", re.IGNORECASE)
+
+
+def end_z_from_gcode_tail(tail: str) -> float | None:
+    """The Z the end G-code leaves the plate at, or None if it can't be told.
+
+    Follows every Z the end G-code sets after ``; MACHINE_END_GCODE_START``
+    (Bambu Studio) or, in a file without it, after the last ``; filament end
+    gcode`` (OrcaSlicer, which writes it right before the machine end G-code).
+    Anything that makes the final position uncertain gives None rather than a
+    guess: no marker, a Z move inside an ``M622``/``M623`` block (run or
+    skipped by the firmware, we can't know which), a relative Z with no known
+    start, ``G28``/``G92`` touching Z, or any other G-code carrying a Z word.
+    The print body before the marker is absolute in Bambu and Orca G-code, so
+    absolute is the mode the end G-code starts in unless the tail says G91.
+    """
+    start = tail.rfind(_END_GCODE_MARKER)
+    if start < 0:
+        start = tail.rfind(_ORCA_END_GCODE_MARKER)
+    if start < 0:
+        return None
+
+    absolute = True
+    for line in tail[:start].splitlines():
+        code = line.split(";", 1)[0].split()
+        if code and code[0].upper() in ("G90", "G91"):
+            absolute = code[0].upper() == "G90"
+
+    z: float | None = None
+    conditional = 0
+    for line in tail[start:].splitlines():
+        words = line.split(";", 1)[0].split()
+        if not words:
+            continue
+        command = words[0].upper()
+        if command == "M622":
+            conditional += 1
+            continue
+        if command == "M623":
+            conditional = max(0, conditional - 1)
+            continue
+        if command == "G90":
+            absolute = True
+            continue
+        if command == "G91":
+            absolute = False
+            continue
+        if command == "G28":
+            # A bare G28 homes every axis, Z included.
+            if len(words) == 1 or any(w.upper().startswith("Z") for w in words[1:]):
+                z = None
+            continue
+        if not command.startswith("G"):
+            continue
+        z_words = [w for w in words[1:] if w[:1] in ("Z", "z")]
+        if not z_words:
+            continue
+        match = _Z_WORD.match(z_words[0])
+        if command not in _MOVE_COMMANDS or match is None or conditional:
+            z = None
+            continue
+        value = float(match.group(1))
+        if absolute:
+            z = value
+        else:
+            z = None if z is None else z + value
+    return z if z is not None and z > 0 else None
+
+
+def extract_end_z_from_3mf(file_path: Path, plate_id: int | None = None) -> float | None:
+    """The Z the plate's end G-code leaves the plate at, or None (#2547).
+
+    The finish-photo plate restore parks the plate back here, so it never
+    sends a Z the printer's own G-code didn't: Bambu's templates keep their
+    end moves inside each model's travel (``G1 Z{max_layer_z + 100}`` only
+    while that fits), and the firmware does not clamp G-code sent over MQTT
+    (#2579). The whole plate G-code is streamed through, because the end
+    G-code is its last few KB and a deflated entry can't be read from the end.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            names = zf.namelist()
+            target = select_plate_gcode_name(names, plate_id) or default_plate_gcode_name(names)
+            if target is None:
+                return None
+            tail = b""
+            with zf.open(target, "r") as fh:
+                while chunk := fh.read(1024 * 1024):
+                    tail = (tail + chunk)[-_END_GCODE_TAIL_BYTES:]
+    except (OSError, zipfile.BadZipFile, KeyError) as e:
+        logger.debug("end Z: cannot read %s: %s", file_path, e)
+        return None
+    return end_z_from_gcode_tail(tail.decode("utf-8", errors="ignore"))
+
+
 def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
     """Replace `{var}` placeholders with header values, leaving unknowns intact."""
 

+ 32 - 0
backend/tests/integration/test_archive_scope_per_run.py

@@ -129,6 +129,38 @@ class TestSeeing:
         assert body["timelapse_path"] == f"{DIR}/b.mp4"
         assert body["photos"] == ["finish_b.jpg", "0a1b2c3d.jpg"]
 
+    async def test_the_card_counts_every_prints_video(self, async_client, db_session, plates):
+        # The archive holds plate 2's video only; plate 1's is on its print.
+        listed = (await async_client.get("/api/v1/archives/", headers=plates.admin)).json()
+        assert listed[0]["timelapse_count"] == 2
+        assert (await async_client.get(_url(plates), headers=plates.admin)).json()["timelapse_count"] == 2
+
+        # A scoped user counts their prints' videos only.
+        assert (await async_client.get("/api/v1/archives/", headers=plates.of_a)).json()[0]["timelapse_count"] == 1
+        assert (await async_client.get(_url(plates), headers=plates.of_b)).json()["timelapse_count"] == 1
+
+    async def test_a_video_no_print_holds_counts_too(self, async_client, db_session, plates):
+        archive = await _row(db_session, PrintArchive, plates.archive)
+        archive.timelapse_path = f"{DIR}/hand.mp4"
+        await db_session.commit()
+
+        assert (await async_client.get(_url(plates), headers=plates.admin)).json()["timelapse_count"] == 3
+        # Of the archive, not of a print of theirs: hidden from A's user, as
+        # its path is.
+        assert (await async_client.get(_url(plates), headers=plates.of_a)).json()["timelapse_count"] == 1
+
+    async def test_no_video_counts_none(self, async_client, db_session, plates):
+        db_session.expire_all()
+        for model, row_id in (
+            (PrintArchive, plates.archive),
+            (PrintLogEntry, plates.first),
+            (PrintLogEntry, plates.second),
+        ):
+            (await db_session.get(model, row_id)).timelapse_path = None
+        await db_session.commit()
+
+        assert (await async_client.get(_url(plates), headers=plates.admin)).json()["timelapse_count"] == 0
+
     async def test_a_printer_it_never_printed_on_sees_nothing(self, async_client, plates):
         assert (await async_client.get("/api/v1/archives/", headers=plates.of_c)).json() == []
         assert (await async_client.get(_url(plates), headers=plates.of_c)).status_code == 404

+ 3 - 0
backend/tests/integration/test_fallback_archive_photos_1820.py

@@ -58,6 +58,9 @@ class TestReadingACapturedPhoto:
 
         assert response.status_code == 200
         assert response.content == JPEG
+        # A late timelapse frame is written over the live finish photo under
+        # its name (#2547): the browser must revalidate, not keep the old one.
+        assert response.headers["cache-control"] == "no-cache"
 
     async def test_a_photo_in_the_old_shared_location_is_still_served(
         self, async_client: AsyncClient, archive_factory, printer_factory, base_dir

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

@@ -430,6 +430,41 @@ async def test_the_finish_photo_is_the_ended_runs_too(test_engine, db_session, q
     assert archive.photos == ["finish.jpg"]
 
 
+async def test_a_later_prints_finish_photo_is_added_to_the_archives(test_engine, db_session, queued_print):
+    """Archive #455: the second plate's photo went on its run only. The
+    archive already held the first plate's, and appending to that list in
+    place is no change the ORM writes."""
+    from backend.app.core.tasks import spawn_background_task
+    from backend.app.models.archive import PrintArchive
+
+    archive = await db_session.get(PrintArchive, queued_print["archive"])
+    archive.photos = ["plate1.jpg"]
+    await db_session.commit()
+
+    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=("plate2.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"),
+        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})
+
+    db_session.expire_all()
+    archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    assert logs[0].photos == ["plate2.jpg"]
+    assert archive.photos == ["plate1.jpg", "plate2.jpg"]
+
+
 async def test_the_registered_mapping_is_drained_when_the_printer_reported_one(test_engine, db_session, queued_print):
     """PAM/I3: left behind, the archive's next run (sent without a mapping)
     would charge its usage through this run's trays."""
@@ -604,6 +639,36 @@ async def test_the_completions_media_work_holds_the_archive(test_engine, db_sess
     assert not archive_work.is_held(queued_print["archive"])
 
 
+async def test_the_late_timelapse_frame_replaces_the_live_grab(test_engine, db_session, queued_print):
+    """Archive 454: the video landed 2s after the notification's wait, and the
+    archive kept both photos. The upgrade is told which one to write over."""
+    from backend.app.core.tasks import spawn_background_task
+
+    # One shared SQLite connection: tasks take turns, but for the notification.
+    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)
+
+    upgrade = AsyncMock()
+    with (
+        patch("backend.app.main.spawn_background_task", side_effect=spawn),
+        patch("backend.app.services.camera.capture_finish_photo", AsyncMock(return_value="finish_live.jpg")),
+        patch("backend.app.main._scan_for_timelapse_with_retries", AsyncMock()),
+        patch("backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=(None, True))),
+        patch("backend.app.main._upgrade_finish_photo_from_timelapse", upgrade),
+        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})
+
+    upgrade.assert_called_once()
+    assert upgrade.call_args.kwargs["replace"] == "finish_live.jpg"
+
+
 async def test_the_print_end_announces_the_run_it_wrote(test_engine, db_session, queued_print):
     """On MQTT, and in the notification's variables (archive redesign P3)."""
     publish = AsyncMock()

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

@@ -515,6 +515,27 @@ class TestFinishPhoto:
             "plate2.jpg",
         ]
 
+    async def test_the_upgrade_replaces_the_live_grab_on_its_run(self, db_session, two_plates):
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        photos = two_plates.files / "photos"
+        photos.mkdir(exist_ok=True)
+        (photos / "live.jpg").write_bytes(b"live")
+        (photos / "frame.jpg").write_bytes(b"frame")
+        await _set(db_session, PrintLogEntry, two_plates.first, photos=["upload.jpg", "live.jpg"])
+        await _set(db_session, PrintArchive, two_plates.archive, photos=["plate2.jpg", "live.jpg"])
+        with patch(
+            "backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=("frame.jpg", False))
+        ):
+            await _upgrade_finish_photo_from_timelapse(
+                two_plates.archive, two_plates.files, run_id=two_plates.first, replace="live.jpg"
+            )
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).photos == ["live.jpg", "upload.jpg"]
+        assert (await _row(db_session, PrintArchive, two_plates.archive)).photos == ["live.jpg", "plate2.jpg"]
+        assert (photos / "live.jpg").read_bytes() == b"frame"
+        assert not (photos / "frame.jpg").exists()
+
 
 class TestReprint:
     async def test_replaces_the_plates_earlier_videos_only(self, db_session, two_plates):

+ 157 - 0
backend/tests/unit/test_archive_run_photos_backfill.py

@@ -0,0 +1,157 @@
+"""A finish photo only its run kept is put back on its archive, once.
+
+A later print's finish photo was appended to the archive's photo list in
+place, which the ORM does not write, so only the run's list had it (archive
+#455). The archive serves and shows photos from its own list.
+"""
+
+from datetime import datetime
+
+import pytest
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import async_sessionmaker
+
+from backend.app.core.config import settings
+from backend.app.core.database import _backfill_archive_run_photos
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+
+FLAG = "_backfill_archive_run_photos_done"
+
+
+@pytest.fixture
+async def engine(test_engine, tmp_path, monkeypatch):
+    # The suite's database, so a --pg run checks it on PostgreSQL too.
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    return test_engine
+
+
+def _photo(tmp_path, folder: str, name: str) -> None:
+    photos = tmp_path / folder / "photos"
+    photos.mkdir(parents=True, exist_ok=True)
+    (photos / name).write_bytes(b"jpg")
+
+
+async def _seed(engine, archives: dict[str, list | None], runs: list[tuple[str, int, list | None]]) -> dict[str, int]:
+    maker = async_sessionmaker(engine, expire_on_commit=False)
+    async with maker() as db:
+        rows = {
+            name: PrintArchive(
+                filename=f"{name}.3mf", file_path=f"{name}/{name}.3mf", file_size=1, status="completed", photos=photos
+            )
+            for name, photos in archives.items()
+        }
+        db.add_all(rows.values())
+        await db.flush()
+        for name, minute, photos in runs:
+            db.add(
+                PrintLogEntry(
+                    archive_id=rows[name].id,
+                    status="completed",
+                    started_at=datetime(2026, 11, 5, 9, minute),
+                    photos=photos,
+                )
+            )
+        await db.commit()
+        return {name: row.id for name, row in rows.items()}
+
+
+async def _photos(engine) -> dict[int, list | None]:
+    maker = async_sessionmaker(engine)
+    async with maker() as db:
+        return dict((await db.execute(select(PrintArchive.id, PrintArchive.photos))).tuples().all())
+
+
+async def _run(engine) -> None:
+    async with engine.begin() as conn:
+        await _backfill_archive_run_photos(conn)
+
+
+@pytest.mark.asyncio
+async def test_a_photo_only_its_run_kept_is_put_back_in_run_order(engine, tmp_path):
+    for name in ("p1.jpg", "p2.jpg", "p3.jpg", "upload.jpg"):
+        _photo(tmp_path, "a", name)
+    ids = await _seed(
+        engine,
+        {"a": ["p1.jpg", "upload.jpg"]},
+        # Inserted out of order: the archive takes them as the prints ran.
+        [("a", 30, ["p3.jpg"]), ("a", 0, ["p1.jpg"]), ("a", 15, ["p2.jpg"])],
+    )
+
+    await _run(engine)
+
+    assert (await _photos(engine))[ids["a"]] == ["p1.jpg", "upload.jpg", "p2.jpg", "p3.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_an_archive_with_no_photos_left_gets_its_runs(engine, tmp_path):
+    _photo(tmp_path, "a", "p2.jpg")
+    ids = await _seed(engine, {"a": None}, [("a", 0, None), ("a", 10, ["p2.jpg"])])
+
+    await _run(engine)
+
+    assert (await _photos(engine))[ids["a"]] == ["p2.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_a_photo_whose_file_is_gone_stays_off(engine, tmp_path):
+    _photo(tmp_path, "a", "kept.jpg")
+    ids = await _seed(
+        engine, {"a": ["p1.jpg"], "b": ["x.jpg"]}, [("a", 0, ["gone.jpg", "kept.jpg"]), ("b", 0, ["gone.jpg"])]
+    )
+
+    await _run(engine)
+
+    photos = await _photos(engine)
+    assert photos[ids["a"]] == ["p1.jpg", "kept.jpg"]
+    # Another archive's directory holding the name does not count.
+    assert photos[ids["b"]] == ["x.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_archives_that_already_list_their_runs_photos_are_left_alone(engine, tmp_path):
+    _photo(tmp_path, "a", "p1.jpg")
+    ids = await _seed(engine, {"a": ["upload.jpg", "p1.jpg"], "b": ["only.jpg"]}, [("a", 0, ["p1.jpg"])])
+
+    await _run(engine)
+
+    photos = await _photos(engine)
+    assert photos[ids["a"]] == ["upload.jpg", "p1.jpg"]
+    assert photos[ids["b"]] == ["only.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_it_runs_once(engine, tmp_path):
+    _photo(tmp_path, "a", "p2.jpg")
+    ids = await _seed(engine, {"a": ["p1.jpg"]}, [("a", 0, ["p2.jpg"])])
+
+    await _run(engine)
+    async with engine.begin() as conn:
+        flag = (await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one()
+        assert flag == "true"
+        # Taken off the archive again after the repair: a later start must not
+        # be what puts it back.
+        await conn.execute(text("UPDATE print_archives SET photos = '[\"p1.jpg\"]' WHERE id = :id"), {"id": ids["a"]})
+
+    await _run(engine)
+
+    assert (await _photos(engine))[ids["a"]] == ["p1.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_nothing_to_repair_still_marks_it_done(engine):
+    await _run(engine)
+    await _run(engine)
+
+    async with engine.begin() as conn:
+        count = (await conn.execute(text('SELECT COUNT(*) FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one()
+    assert count == 1
+
+
+def test_every_start_runs_it():
+    import inspect
+
+    from backend.app.core.database import _run_migrations
+
+    assert "await _backfill_archive_run_photos(conn)" in inspect.getsource(_run_migrations)

+ 162 - 28
backend/tests/unit/test_finish_photo_moment_sync.py

@@ -31,6 +31,8 @@ from backend.app.main import on_finish_photo_moment
 from backend.app.models.archive import PrintArchive
 from backend.app.services import print_dispatch_context
 
+HEIGHTS = main_module.PlateHeights(16.0, 114.0)
+
 
 @asynccontextmanager
 async def _fake_session(printer):
@@ -97,7 +99,7 @@ def patched_env(fake_printer, monkeypatch):
     async def _not_blocked(_printer_id):
         return False
 
-    monkeypatch.setattr(main_module, "_max_z_for_current_print", _no_height)
+    monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _no_height)
     monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _not_blocked)
     return fake_printer
 
@@ -536,9 +538,10 @@ def test_the_consumer_does_not_rotate_the_cached_frame():
 class TestPlateRestore:
     """#2547 / #1145 / #1397 / #1565: put the plate back into camera framing.
 
-    Bambu's end G-code drops the plate ~100mm as the last thing it does, so by
-    FINISH the finished print sits well below where the camera frames it. The
-    restore commands an ABSOLUTE Z back to just above the last printed layer.
+    On X1/P1/A1 Bambu's end G-code drops the plate ~100mm as the last thing it
+    does, so by FINISH the finished print sits well below where the camera
+    frames it. The restore commands an ABSOLUTE Z back to just above the last
+    printed layer.
 
     Absolute is the safety argument, and these tests pin it: the target is a
     height the toolhead was physically at seconds earlier, so it is inside the
@@ -600,10 +603,20 @@ class TestPlateRestore:
 
         assert ok is False
 
-    def test_park_lowers_the_plate_again(self, printer_client):
-        main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
+    def test_park_returns_the_plate_to_the_end_gcode_position(self, printer_client):
+        """The park target is the Z the print's own end G-code left the plate
+        at, sent as given. It used to be print top + 100 mm, which on a 238 mm
+        H2C print drove the plate to the bottom of its travel: the firmware
+        doesn't clamp MQTT G-code (#2579), and Bambu's template had stopped at
+        print top + 2 mm for exactly that reason."""
+        main_module._park_plate_after_finish_photo(7, 239.8, logging.getLogger(__name__))
+
+        assert printer_client.sent == ["G90\nG1 Z239.80 F600"]
 
-        assert printer_client.sent == ["G90\nG1 Z116.00 F600"]
+    def test_park_never_touches_m211(self, printer_client):
+        main_module._park_plate_after_finish_photo(7, 114.0, logging.getLogger(__name__))
+
+        assert not any("M211" in line for line in printer_client.sent)
 
     def test_park_skipped_once_the_next_print_has_started(self, printer_client):
         printer_client.state.state = "RUNNING"
@@ -619,7 +632,13 @@ class TestPlateRestoreWiring:
 
     @pytest.fixture
     def restore_env(self, patched_env, monkeypatch):
-        calls = {"restore": [], "park": [], "blocked": False, "height": 16.0, "bound": []}
+        calls = {
+            "restore": [],
+            "park": [],
+            "blocked": False,
+            "height": main_module.PlateHeights(16.0, 114.0),
+            "bound": [],
+        }
 
         async def _height(_printer_id, _data, _logger, archive_id, plate_id=None):
             calls["bound"].append((archive_id, plate_id))
@@ -632,10 +651,10 @@ class TestPlateRestoreWiring:
             calls["restore"].append((printer_id, max_z))
             return True
 
-        def _park(printer_id, max_z, _logger):
-            calls["park"].append((printer_id, max_z))
+        def _park(printer_id, end_z, _logger):
+            calls["park"].append((printer_id, end_z))
 
-        monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
+        monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _height)
         monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked)
         monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
         monkeypatch.setattr(main_module, "_park_plate_after_finish_photo", _park)
@@ -650,7 +669,27 @@ class TestPlateRestoreWiring:
         await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
 
         assert restore_env["restore"] == [(patched_env.id, 16.0)]
-        assert restore_env["park"] == [(patched_env.id, 16.0)]
+        assert restore_env["park"] == [(patched_env.id, 114.0)]
+
+    async def test_not_restored_when_the_end_gcode_left_the_plate_in_view(self, patched_env, restore_env):
+        """Archive 454: the H2C end G-code ended 2 mm under the nozzle (Z239.8
+        for a 237.8 mm print). Raising "back" to print top + 10 would have
+        lowered it, and the park after that sent it to the bottom."""
+        restore_env["height"] = main_module.PlateHeights(237.8, 239.8)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == []
+        assert restore_env["park"] == []
+
+    async def test_restored_only_when_the_plate_is_below_the_restore_height(self, patched_env, restore_env):
+        for end_z, moved in ((26.0, False), (26.01, True)):
+            restore_env["restore"].clear()
+            restore_env["height"] = main_module.PlateHeights(16.0, end_z)
+
+            await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+            assert bool(restore_env["restore"]) is moved, end_z
 
     async def test_reads_the_binding_before_on_print_complete_pops_it(self, patched_env, restore_env, monkeypatch):
         """#3240: on the FINISH-state path on_print_complete is dispatched right
@@ -758,7 +797,7 @@ class TestPlateRestoreWiring:
         await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
 
         assert restore_env["restore"] == [(patched_env.id, 16.0)]
-        assert restore_env["park"] == [(patched_env.id, 16.0)]
+        assert restore_env["park"] == [(patched_env.id, 114.0)]
 
     async def test_producer_event_is_still_set_after_a_restore(self, patched_env, restore_env):
         """#1790: the consumer's bounded wait must be released on every exit,
@@ -768,6 +807,80 @@ class TestPlateRestoreWiring:
         assert main_module._stage22_finish_in_flight[patched_env.id].is_set()
 
 
+class TestRaisePlateDecision:
+    """``_raise_plate_for_finish_photo``: the one place both finish-photo paths
+    decide whether to move the plate and where to park it afterwards."""
+
+    @pytest.fixture
+    def decision_env(self, monkeypatch):
+        env = {"heights": HEIGHTS, "blocked": False, "restore": [], "restored": True}
+
+        async def _heights(*_args):
+            return env["heights"]
+
+        async def _blocked(_printer_id):
+            return env["blocked"]
+
+        async def _restore(printer_id, print_top, _logger):
+            env["restore"].append((printer_id, print_top))
+            return env["restored"]
+
+        monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _heights)
+        monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked)
+        monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
+        return env
+
+    @staticmethod
+    async def _decide():
+        return await main_module._raise_plate_for_finish_photo(7, {}, logging.getLogger(__name__), 5, 1)
+
+    async def test_raises_and_parks_at_the_end_gcode_position(self, decision_env):
+        assert await self._decide() == 114.0
+        assert decision_env["restore"] == [(7, 16.0)]
+
+    async def test_no_move_when_the_plate_is_already_in_view(self, decision_env):
+        decision_env["heights"] = main_module.PlateHeights(237.8, 239.8)
+        assert await self._decide() is None
+        assert decision_env["restore"] == []
+
+    async def test_no_move_when_heights_are_unknown(self, decision_env):
+        decision_env["heights"] = None
+        assert await self._decide() is None
+        assert decision_env["restore"] == []
+
+    async def test_no_move_when_the_queue_has_work(self, decision_env):
+        decision_env["blocked"] = True
+        assert await self._decide() is None
+        assert decision_env["restore"] == []
+
+    async def test_nothing_to_park_when_the_restore_did_not_happen(self, decision_env):
+        decision_env["restored"] = False
+        assert await self._decide() is None
+
+
+def test_every_plate_raise_goes_through_the_decision():
+    """Both finish-photo paths raise the plate. The park target is safe only
+    because it comes from ``_raise_plate_for_finish_photo``, so no other code
+    may call the raise directly, and nothing may compute a park height."""
+    import ast
+    import inspect
+
+    tree = ast.parse(inspect.getsource(main_module))
+    callers = set()
+    for fn in ast.walk(tree):
+        if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
+            continue
+        for node in ast.walk(fn):
+            if (
+                isinstance(node, ast.Call)
+                and isinstance(node.func, ast.Name)
+                and node.func.id == "_restore_plate_for_finish_photo"
+            ):
+                callers.add(fn.name)
+    assert callers == {"_raise_plate_for_finish_photo"}
+    assert not hasattr(main_module, "_PLATE_PARK_DROP_MM")
+
+
 async def test_producer_wait_budget_covers_the_restore():
     """The consumer's wait has to outlast settle + a worst-case RTSP grab, and
     still finish inside the notification's own photo budget — otherwise the
@@ -807,7 +920,7 @@ class TestMaxZResolution:
         async with engine.begin() as conn:
             await conn.run_sync(Base.metadata.create_all)
         session_maker = async_sessionmaker(engine, expire_on_commit=False)
-        env = {"reported_layers": 30, "height": 16.0, "read": [], "session_maker": session_maker}
+        env = {"reported_layers": 30, "height": 16.0, "end_z": 114.0, "read": [], "session_maker": session_maker}
 
         monkeypatch.setattr(main_module, "async_session", session_maker)
         monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
@@ -821,7 +934,12 @@ class TestMaxZResolution:
             env["read"].append((path, plate))
             return env["height"]
 
+        def _extract_end(path, plate):
+            env["read_end"] = (path, plate)
+            return env["end_z"]
+
         monkeypatch.setattr("backend.app.utils.threemf_tools.extract_max_z_height_from_3mf", _extract)
+        monkeypatch.setattr("backend.app.utils.threemf_tools.extract_end_z_from_3mf", _extract_end)
         try:
             yield env
         finally:
@@ -849,11 +967,13 @@ class TestMaxZResolution:
     @staticmethod
     async def _resolve(name, archive_id, plate_id=None):
         data = {"subtask_name": name} if name is not None else {}
-        return await main_module._max_z_for_current_print(1, data, logging.getLogger(__name__), archive_id, plate_id)
+        return await main_module._plate_heights_for_current_print(
+            1, data, logging.getLogger(__name__), archive_id, plate_id
+        )
 
     async def test_returns_the_height_when_binding_name_and_layers_agree(self, resolver_env):
         archive_id = await self._archive(resolver_env)
-        assert await self._resolve("job", archive_id) == 16.0
+        assert await self._resolve("job", archive_id) == HEIGHTS
 
     async def test_refuses_a_print_that_is_not_bound_to_an_archive(self, resolver_env):
         """A name alone is never enough: with no binding there is no move, even
@@ -870,7 +990,7 @@ class TestMaxZResolution:
         await self._archive(resolver_env, file_path="archive/other-plate.gcode.3mf", total_layers=313)
         resolver_env["reported_layers"] = 125
 
-        assert await self._resolve("job", bound) == 16.0
+        assert await self._resolve("job", bound) == HEIGHTS
         assert resolver_env["read"][0][0].name == "reprinted.gcode.3mf"
 
     async def test_reads_the_plate_the_job_was_dispatched_with(self, resolver_env):
@@ -890,30 +1010,30 @@ class TestMaxZResolution:
         lookup raised, was logged at debug level, and the plate never moved."""
         archive_id = await self._archive(resolver_env, file_path="archive/1/job/job.gcode.3mf", plate_id=2)
 
-        assert await self._resolve("job", archive_id) == 16.0
+        assert await self._resolve("job", archive_id) == HEIGHTS
         assert resolver_env["read"] == [(tmp_path / "archive/1/job/job.gcode.3mf", 2)]
 
     async def test_an_absolute_file_path_is_read_as_stored(self, resolver_env):
         archive_id = await self._archive(resolver_env, file_path="/srv/bambuddy/archive/job.gcode.3mf")
 
-        assert await self._resolve("job", archive_id) == 16.0
+        assert await self._resolve("job", archive_id) == HEIGHTS
         assert str(resolver_env["read"][0][0]) == "/srv/bambuddy/archive/job.gcode.3mf"
 
     async def test_matches_a_name_the_printer_echoed_with_underscores(self, resolver_env):
         """#3240: the printer reports "Cube v2" as "Cube_v2"."""
         archive_id = await self._archive(resolver_env, print_name="Cube v2", filename="Cube v2.gcode.3mf")
-        assert await self._resolve("Cube_v2", archive_id) == 16.0
+        assert await self._resolve("Cube_v2", archive_id) == HEIGHTS
 
     async def test_matches_on_the_filename_with_or_without_its_extension(self, resolver_env):
         archive_id = await self._archive(resolver_env, print_name=None, filename="Bracket Left.gcode.3mf")
-        assert await self._resolve("Bracket_Left", archive_id) == 16.0
-        assert await self._resolve("Bracket_Left.gcode", archive_id) == 16.0
-        assert await self._resolve("Bracket Left.gcode.3mf", archive_id) == 16.0
+        assert await self._resolve("Bracket_Left", archive_id) == HEIGHTS
+        assert await self._resolve("Bracket_Left.gcode", archive_id) == HEIGHTS
+        assert await self._resolve("Bracket Left.gcode.3mf", archive_id) == HEIGHTS
 
     async def test_matches_a_file_name_that_ends_in_a_space(self, resolver_env):
         """#3241: "Part .gcode.3mf" is echoed as "Part_"."""
         archive_id = await self._archive(resolver_env, print_name="Part ", filename="Part .gcode.3mf")
-        assert await self._resolve("Part_", archive_id) == 16.0
+        assert await self._resolve("Part_", archive_id) == HEIGHTS
 
     async def test_refuses_a_bound_archive_with_another_name(self, resolver_env):
         archive_id = await self._archive(resolver_env)
@@ -954,11 +1074,11 @@ class TestMaxZResolution:
         layer count for still gets its height, because binding and name agree."""
         archive_id = await self._archive(resolver_env)
         resolver_env["reported_layers"] = 0
-        assert await self._resolve("job", archive_id) == 16.0
+        assert await self._resolve("job", archive_id) == HEIGHTS
 
         resolver_env["reported_layers"] = 30
         unknown_layers = await self._archive(resolver_env, total_layers=None)
-        assert await self._resolve("job", unknown_layers) == 16.0
+        assert await self._resolve("job", unknown_layers) == HEIGHTS
 
     async def test_refuses_when_the_archive_has_no_file(self, resolver_env):
         archive_id = await self._archive(resolver_env, file_path="")
@@ -969,6 +1089,20 @@ class TestMaxZResolution:
         resolver_env["height"] = None
         assert await self._resolve("job", archive_id) is None
 
+    async def test_refuses_when_the_end_gcode_position_is_unknown(self, resolver_env):
+        """No end Z, no park target the printer's own G-code chose, so no move
+        at all rather than one to a height worked out here."""
+        archive_id = await self._archive(resolver_env)
+        resolver_env["end_z"] = None
+        assert await self._resolve("job", archive_id) is None
+
+    async def test_reads_the_end_position_from_the_same_file_and_plate(self, resolver_env, tmp_path):
+        archive_id = await self._archive(resolver_env, plate_id=2)
+
+        await self._resolve("job", archive_id)
+
+        assert resolver_env["read_end"] == (tmp_path / "archive/1/job/job.gcode.3mf", 2)
+
 
 class TestBoundPrint:
     """The binding the plate restore reads (#3240)."""
@@ -1050,10 +1184,10 @@ class TestTimelapsePathPlateRestore:
             return True
 
         async def _height(_printer_id, _data, _logger, _archive_id, _plate_id=None):
-            return 16.0
+            return main_module.PlateHeights(16.0, 114.0)
 
         monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
-        monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
+        monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _height)
 
         await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True})
 

+ 141 - 0
backend/tests/unit/test_threemf_tools.py

@@ -12,8 +12,10 @@ import zipfile
 import pytest
 
 from backend.app.utils.threemf_tools import (
+    end_z_from_gcode_tail,
     expand_to_project_slots,
     extract_embedded_presets_from_3mf,
+    extract_end_z_from_3mf,
     extract_filament_usage_from_3mf,
     extract_layer_filament_usage_from_3mf,
     extract_max_z_height_from_3mf,
@@ -1511,3 +1513,142 @@ class TestExtractLayerFilamentUsagePlateSelection:
         path = tmp_path / "broken.3mf"
         path.write_text("not a zip")
         assert extract_layer_filament_usage_from_3mf(path, 1) is None
+
+
+# Shaped like the X1 Carbon end G-code in the archive: the plate drops
+# print top + 100 and settles 2 mm back.
+_X1_END = """; MACHINE_END_GCODE_START
+; filament end gcode
+M400 ; wait for buffer to clear
+G92 E0 ; zero the extruder
+G1 E-0.8 F1800 ; retract
+G1 Z53.3 F900 \t;lower z a little
+G1 X65 Y245 F12000 ; move to safe pos
+M17 Z0.4 \t    ;lower z motor current to reduce impact if there is something in the bottom
+    G1 Z152.8 F600
+    G1 Z150.8
+M400 P100
+G90
+M17 X0.8 Y0.8 Z0.5 ; lower motor current to 45% power
+"""
+
+# Archive 454's H2C end G-code: a 237.8 mm print is too tall for the +100
+# drop, so the template's other branch ends 2 mm under the nozzle.
+_H2C_END_TALL = """; MACHINE_END_GCODE_START
+G90
+G1 Z238.2 F900 ; lower z a little
+M1002 judge_flag timelapse_record_flag
+M622 J1
+    G150.3
+    M400 ; wait all motion done
+    M991 S0 P-1 ;end smooth timelapse at safe pos
+M623  ;end of "timelapse_record_flag"
+G90
+G1 Z247.8 F900 ; lower z a little
+M17 Z0.4 ; lower z motor current
+        G1 Z241.8 F600
+        G1 Z239.8
+M400 P100
+"""
+
+
+class TestEndZFromGcodeTail:
+    """#2547: where the end G-code leaves the plate. The finish-photo restore
+    parks the plate back there, so this decides a real Z move: anything that
+    makes the position uncertain must come back None, never a best guess."""
+
+    def test_x1_end_gcode(self):
+        assert end_z_from_gcode_tail("G1 Z52.8\n" + _X1_END) == 150.8
+
+    def test_h2c_tall_print_stays_just_under_the_nozzle(self):
+        """Archive 454. Bambuddy parked this at print top + 100 = Z337.8."""
+        assert end_z_from_gcode_tail(_H2C_END_TALL) == 239.8
+
+    def test_m17_z_is_motor_current_not_a_move(self):
+        assert end_z_from_gcode_tail("; MACHINE_END_GCODE_START\nG1 Z20\nM17 Z0.4\n") == 20.0
+
+    def test_no_marker_is_unknown(self):
+        assert end_z_from_gcode_tail("G1 Z20\nG1 Z120\n") is None
+
+    def test_orca_end_gcode_follows_the_last_filament_end_gcode(self):
+        """OrcaSlicer 2.3.2 writes no MACHINE_END_GCODE_START (P1S file in the
+        archive: print top 17, end G-code ends G1 Z117 / G1 Z115). A tool
+        change mid-print writes the same comment; the last one is the end."""
+        tail = (
+            "; filament end gcode \nG1 Z10\nT1\nG1 Z10.2\nG1 Z17\n"
+            "; FEATURE: Custom\n; filament end gcode \nM400\nG1 Z17.5 F900\n"
+            "G1 X65 Y245 F12000\nM17 Z0.4\nG1 Z117 F600\nG1 Z115\n; EXECUTABLE_BLOCK_END\n"
+        )
+        assert end_z_from_gcode_tail(tail) == 115.0
+
+    def test_no_z_move_in_the_end_gcode_is_unknown(self):
+        assert end_z_from_gcode_tail("G1 Z20\n; MACHINE_END_GCODE_START\nM400\n") is None
+
+    def test_a_z_move_inside_a_firmware_conditional_is_unknown(self):
+        """M622 J1 blocks run or not on a firmware flag we can't see."""
+        tail = "; MACHINE_END_GCODE_START\nG1 Z20\nM622 J1\nG1 Z120\nM623\n"
+        assert end_z_from_gcode_tail(tail) is None
+
+    def test_an_unconditional_move_after_a_conditional_one_is_known(self):
+        tail = "; MACHINE_END_GCODE_START\nM622 J1\nG1 Z120\nM623\nG1 Z130\n"
+        assert end_z_from_gcode_tail(tail) == 130.0
+
+    def test_relative_moves_add_to_a_known_position(self):
+        tail = "; MACHINE_END_GCODE_START\nG1 Z20\nG91\nG1 Z5\nG1 Z-1\nG90\n"
+        assert end_z_from_gcode_tail(tail) == 24.0
+
+    def test_relative_move_with_no_known_start_is_unknown(self):
+        assert end_z_from_gcode_tail("; MACHINE_END_GCODE_START\nG91\nG1 Z100\n") is None
+
+    def test_relative_mode_from_before_the_marker_carries_over(self):
+        assert end_z_from_gcode_tail("G91\n; MACHINE_END_GCODE_START\nG1 Z100\n") is None
+
+    def test_homing_or_resetting_z_is_unknown(self):
+        for line in ("G28", "G28 Z", "G92 Z0", "G380 S2 Z-5"):
+            tail = f"; MACHINE_END_GCODE_START\nG1 Z120\n{line}\n"
+            assert end_z_from_gcode_tail(tail) is None, line
+
+    def test_homing_other_axes_keeps_z(self):
+        assert end_z_from_gcode_tail("; MACHINE_END_GCODE_START\nG1 Z120\nG28 X\n") == 120.0
+
+    def test_arcs_with_z_count(self):
+        assert end_z_from_gcode_tail("; MACHINE_END_GCODE_START\nG3 Z120.5 I1 J0 P1 F30000\n") == 120.5
+
+    def test_commented_out_moves_are_ignored(self):
+        assert end_z_from_gcode_tail("; MACHINE_END_GCODE_START\nG1 Z120\n; G1 Z300\n") == 120.0
+
+    def test_unparseable_z_is_unknown(self):
+        assert end_z_from_gcode_tail("; MACHINE_END_GCODE_START\nG1 Z{max_layer_z+100}\n") is None
+
+    def test_the_last_end_gcode_block_wins(self):
+        tail = "; MACHINE_END_GCODE_START\nG1 Z300\n; MACHINE_END_GCODE_START\nG1 Z120\n"
+        assert end_z_from_gcode_tail(tail) == 120.0
+
+
+class TestExtractEndZFrom3mf:
+    def test_reads_the_requested_plate(self, tmp_path):
+        path = _make_plate_3mf(
+            tmp_path,
+            {
+                "Metadata/plate_1.gcode": _header(max_z_height="52.80") + "\n" + _X1_END,
+                "Metadata/plate_2.gcode": _header(max_z_height="237.80") + "\n" + _H2C_END_TALL,
+            },
+        )
+        assert extract_end_z_from_3mf(path, 1) == 150.8
+        assert extract_end_z_from_3mf(path, 2) == 239.8
+
+    def test_finds_the_end_gcode_after_a_long_print_body(self, tmp_path):
+        """The body is streamed through; only the tail is kept."""
+        body = "G1 X1 Y1 E0.1\n" * 200_000
+        path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="52.80") + body + _X1_END})
+        assert extract_end_z_from_3mf(path, 1) == 150.8
+
+    def test_unreadable_file_is_unknown(self, tmp_path):
+        bad = tmp_path / "bad.3mf"
+        bad.write_bytes(b"not a zip")
+        assert extract_end_z_from_3mf(bad, 1) is None
+        assert extract_end_z_from_3mf(tmp_path / "missing.3mf", 1) is None
+
+    def test_no_plate_gcode_is_unknown(self, tmp_path):
+        path = _make_plate_3mf(tmp_path, {"3D/3dmodel.model": "<model/>"})
+        assert extract_end_z_from_3mf(path, 1) is None

+ 60 - 3
backend/tests/unit/test_timelapse_scan_2704.py

@@ -519,9 +519,66 @@ class TestFinishPhotoUpgrade:
     bed, which is the worse of the two photos. The upgrade runs afterwards."""
 
     @pytest.mark.asyncio
-    async def test_puts_the_timelapse_frame_first_and_keeps_the_live_grab(self):
-        """First, because the gallery opens at index 0. Kept, because the
-        notification that already went out links to that exact file."""
+    async def test_replaces_the_live_grab_in_place(self, tmp_path):
+        """Archive 454 ended up with two finish photos of the same moment. The
+        frame now goes over the live grab's file, under its name: one photo,
+        and the archive link a notification already sent (auth off) keeps
+        working and shows the better photo."""
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        photos = tmp_path / "photos"
+        photos.mkdir()
+        (photos / "finish_live_grab.jpg").write_bytes(b"live")
+        (photos / "finish_from_timelapse.jpg").write_bytes(b"frame")
+        archive = MagicMock()
+        archive.photos = ["finish_upload.jpg", "finish_live_grab.jpg"]
+        session = _session(archive)
+
+        with (
+            patch(
+                "backend.app.main._capture_finish_photo_from_timelapse",
+                AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
+            ),
+            patch("backend.app.main.async_session", return_value=session),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())) as ws,
+        ):
+            await _upgrade_finish_photo_from_timelapse(7, tmp_path, replace="finish_live_grab.jpg")
+
+        assert archive.photos == ["finish_live_grab.jpg", "finish_upload.jpg"]
+        assert (photos / "finish_live_grab.jpg").read_bytes() == b"frame"
+        assert sorted(f.name for f in photos.iterdir()) == ["finish_live_grab.jpg"]
+        session.commit.assert_awaited()
+        assert ws.send_archive_updated.await_args.args[0]["photo_added"] == "finish_live_grab.jpg"
+
+    @pytest.mark.asyncio
+    async def test_adds_the_frame_when_the_live_grab_was_deleted_meanwhile(self, tmp_path):
+        """Nothing to replace: the user removed it from the gallery. Writing
+        over a name the archive no longer lists would hide the frame."""
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        photos = tmp_path / "photos"
+        photos.mkdir()
+        (photos / "finish_from_timelapse.jpg").write_bytes(b"frame")
+        archive = MagicMock()
+        archive.photos = []
+        session = _session(archive)
+
+        with (
+            patch(
+                "backend.app.main._capture_finish_photo_from_timelapse",
+                AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
+            ),
+            patch("backend.app.main.async_session", return_value=session),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
+        ):
+            await _upgrade_finish_photo_from_timelapse(7, tmp_path, replace="finish_live_grab.jpg")
+
+        assert archive.photos == ["finish_from_timelapse.jpg"]
+        assert (photos / "finish_from_timelapse.jpg").read_bytes() == b"frame"
+
+    @pytest.mark.asyncio
+    async def test_puts_the_timelapse_frame_first_without_a_live_grab_to_replace(self):
+        """First, because the gallery opens at index 0."""
         from backend.app.main import _upgrade_finish_photo_from_timelapse
 
         archive = MagicMock()

+ 12 - 0
frontend/src/__tests__/components/ArchiveDetailModal.test.tsx

@@ -475,6 +475,18 @@ describe('ArchiveDetailModal', () => {
     await waitFor(() => expect(patched).toEqual({ id: '12', body: { notes: 'warped' } }));
   });
 
+  it('opens a decided print to change its outcome', async () => {
+    render(<ArchiveDetailModal archiveId={Number(routeId)} onClose={onClose} />);
+    await screen.findByTestId('archive-run-11');
+    const decided = openRow(11);
+
+    fireEvent.click(within(decided).getByRole('button', { name: /Change outcome/ }));
+
+    const dialog = await screen.findByTestId('confirm-outcome-dialog');
+    expect(within(dialog).getByRole('heading', { name: 'Change outcome' })).toBeInTheDocument();
+    expect(await within(dialog).findByRole('button', { name: 'Reject' })).toBeInTheDocument();
+  });
+
   it('opens the archive a merged one went into', async () => {
     routeId = '4';
     window.history.pushState({}, '', '/archives/4?run=11');

+ 42 - 0
frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx

@@ -235,4 +235,46 @@ describe('ConfirmOutcomeDialog', () => {
       expect(patchPayload).toEqual({ user_verdict: 'good', user_verdict_source: 'dialog', run_id: 7 });
     });
   });
+  it('opened to change a verdict, offers the choice again with the current one marked', async () => {
+    const user = userEvent.setup();
+    server.use(
+      http.get('/api/v1/archives/42', () =>
+        HttpResponse.json({ ...baseArchive, run_id: 7, user_verdict: 'good', user_verdict_source: 'dialog' })
+      ),
+    );
+    const onClose = vi.fn();
+    render(<ConfirmOutcomeDialog archiveId={42} runId={7} change onClose={onClose} />);
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: 'Good' })).toHaveAttribute('aria-pressed', 'true');
+    });
+    expect(screen.getByText('Change outcome')).toBeInTheDocument();
+    expect(screen.queryByText('How did your print come out?')).not.toBeInTheDocument();
+    expect(screen.queryByText('Already confirmed as a good part.')).not.toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Good' })).toHaveAttribute('aria-pressed', 'true');
+    expect(screen.getByRole('button', { name: 'Reject' })).toHaveAttribute('aria-pressed', 'false');
+    expect(screen.queryByText('Ask me later')).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'Reject' }));
+    await user.click(screen.getByText('Save'));
+
+    await waitFor(() => {
+      expect(patchPayload).toEqual({ user_verdict: 'reject', user_verdict_source: 'dialog', run_id: 7 });
+    });
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('closes without a change on Cancel', async () => {
+    const user = userEvent.setup();
+    server.use(
+      http.get('/api/v1/archives/42', () => HttpResponse.json({ ...baseArchive, user_verdict: 'reject' })),
+    );
+    const onClose = vi.fn();
+    render(<ConfirmOutcomeDialog archiveId={42} change onClose={onClose} />);
+
+    await user.click(await screen.findByText('Cancel'));
+
+    expect(onClose).toHaveBeenCalled();
+    expect(patchPayload).toBeNull();
+  });
 });

+ 58 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -630,6 +630,64 @@ describe('ArchivesPage', () => {
       await waitFor(() => expect(window.location.pathname).toBe('/archives/2'));
     });
 
+    it("counts every print's video and opens the archive to play them", async () => {
+      // Plate 2's video is the archive's; plate 1's is on its print.
+      server.use(
+        http.get('/api/v1/archives/', () =>
+          HttpResponse.json([{ ...mockArchives[0], timelapse_path: 'archives/1/b.mp4', timelapse_count: 2 }]),
+        ),
+      );
+      render(<ArchivesPage />);
+      const button = await screen.findByTestId('archive-card-timelapse');
+
+      expect(button).toHaveAttribute('title', 'View 2 timelapses');
+      expect(within(button).getByText('2')).toBeInTheDocument();
+      fireEvent.click(button);
+
+      await waitFor(() => expect(window.location.pathname).toBe('/archives/1'));
+    });
+
+    it('plays a single video in place', async () => {
+      server.use(
+        http.get('/api/v1/archives/', () =>
+          HttpResponse.json([{ ...mockArchives[0], timelapse_path: 'archives/1/a.mp4', timelapse_count: 1 }]),
+        ),
+      );
+      render(<ArchivesPage />);
+      const button = await screen.findByTestId('archive-card-timelapse');
+
+      expect(button).toHaveAttribute('title', 'View timelapse');
+      fireEvent.click(button);
+
+      expect(window.location.pathname).toBe('/archives');
+    });
+
+    it("opens the archive for a video only an earlier print has", async () => {
+      server.use(
+        http.get('/api/v1/archives/', () =>
+          HttpResponse.json([{ ...mockArchives[0], timelapse_path: null, timelapse_count: 1 }]),
+        ),
+      );
+      render(<ArchivesPage />);
+      fireEvent.click(await screen.findByTestId('archive-card-timelapse'));
+
+      await waitFor(() => expect(window.location.pathname).toBe('/archives/1'));
+    });
+
+    it("says in the list how many videos an archive's prints have", async () => {
+      server.use(
+        http.get('/api/v1/archives/', () =>
+          HttpResponse.json([{ ...mockArchives[0], timelapse_path: null, timelapse_count: 2 }]),
+        ),
+      );
+      render(<ArchivesPage />);
+      await screen.findByTestId('archive-card-timelapse');
+      fireEvent.click(screen.getByTitle(/list/i));
+
+      await waitFor(() => expect(screen.queryByTestId('archive-card-timelapse')).not.toBeInTheDocument());
+      expect(screen.getByTitle('View 2 timelapses')).toBeInTheDocument();
+    });
+
     it('leaves the page alone for a click on a control in the card', async () => {
       render(<ArchivesPage />);
       await screen.findByText('#2');

+ 2 - 0
frontend/src/api/client.ts

@@ -927,6 +927,8 @@ export interface Archive {
   // The latest print of the archive (among those the caller may see), whose
   // figures the per-print fields show (archive redesign).
   latest_run_id?: number | null;
+  // How many videos its prints have; timelapse_path is the latest print's only.
+  timelapse_count?: number;
   quantity: number;
   energy_kwh: number | null;
   energy_cost: number | null;

+ 1 - 0
frontend/src/components/ArchiveDetailModal.tsx

@@ -615,6 +615,7 @@ export function ArchiveDetailModal({ archiveId, onClose }: { archiveId: number;
         <ConfirmOutcomeDialog
           archiveId={archive.id}
           runId={confirmRun}
+          change={runs.find((r) => r.id === confirmRun)?.user_verdict != null}
           onClose={() => {
             setConfirmRun(null);
             queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });

+ 13 - 7
frontend/src/components/ConfirmOutcomeDialog.tsx

@@ -13,6 +13,9 @@ interface ConfirmOutcomeDialogProps {
   // The run asked about: plates of one archive each have their own question.
   // Omitted (an archive card's menu, an older link) means its latest run.
   runId?: number | null;
+  // Opened to change a verdict already given (the archive window's "Change
+  // outcome"): the choice is offered again instead of only stating it.
+  change?: boolean;
   onClose: () => void;
 }
 
@@ -23,7 +26,7 @@ interface ConfirmOutcomeDialogProps {
  * ?confirm=<id> deep link a push notification carries. Rejecting offers an
  * optional reason and a one-click reprint through the regular PrintModal.
  */
-export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutcomeDialogProps) {
+export function ConfirmOutcomeDialog({ archiveId, runId, change = false, onClose }: ConfirmOutcomeDialogProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
@@ -70,7 +73,8 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
   // fall back to the thumbnail when no camera shot exists.
   const photoFilename = archive?.photos?.[0] ?? null;
   const photoUrl = photoFilename ? api.getArchivePhotoUrl(targetId, photoFilename) : null;
-  const alreadyDecided = archive?.user_verdict != null;
+  const verdict = archive?.user_verdict ?? null;
+  const alreadyDecided = verdict != null && !change;
   // Why this print already has a verdict — the plate-clear default answers
   // prompts on its own, and without this the answer looks like it appeared
   // from nowhere (#1898).
@@ -109,7 +113,7 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
       <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md overflow-hidden">
         {/* Header */}
         <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
-          <h3 className="text-white font-semibold">{t('confirmOutcome.title')}</h3>
+          <h3 className="text-white font-semibold">{change ? t('archivePage.changeOutcome') : t('confirmOutcome.title')}</h3>
           <button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors" title={t('common.close')}>
             <X className="w-5 h-5" />
           </button>
@@ -133,7 +137,7 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
           {alreadyDecided ? (
             <>
               <p className="text-sm text-bambu-gray text-center">
-                {archive?.user_verdict === 'good'
+                {verdict === 'good'
                   ? t('confirmOutcome.alreadyGood')
                   : t('confirmOutcome.alreadyRejected')}
               </p>
@@ -149,7 +153,8 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
                 type="button"
                 onClick={() => setRejecting(true)}
                 disabled={verdictMutation.isPending}
-                className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors"
+                aria-pressed={change ? verdict === 'reject' : undefined}
+                className={`flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors ${change && verdict === 'reject' ? 'bg-red-500/10 ring-2 ring-red-500/60' : ''}`}
               >
                 <ThumbsDown className="w-7 h-7" />
                 <span className="text-xs">{t('confirmOutcome.reject')}</span>
@@ -158,7 +163,8 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
                 type="button"
                 onClick={handleGood}
                 disabled={verdictMutation.isPending}
-                className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors"
+                aria-pressed={change ? verdict === 'good' : undefined}
+                className={`flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors ${change && verdict === 'good' ? 'bg-bambu-green/10 ring-2 ring-bambu-green/60' : ''}`}
               >
                 <ThumbsUp className="w-7 h-7" />
                 <span className="text-xs">{t('confirmOutcome.good')}</span>
@@ -212,7 +218,7 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
               onClick={onClose}
               className="w-full mt-4 text-xs text-bambu-gray hover:text-white transition-colors"
             >
-              {t('confirmOutcome.later')}
+              {change ? t('common.cancel') : t('confirmOutcome.later')}
             </button>
           )}
         </div>

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: 'Quell-3MF in Bambu Studio öffnen (Rechtsklick für mehr Optionen)',
       downloadF3d: 'Fusion 360 Designdatei herunterladen',
       viewTimelapse: 'Zeitraffer ansehen',
+      viewTimelapses: '{{count}} Zeitraffer ansehen',
       viewPhoto: '1 Foto ansehen',
       viewPhotos: '{{count}} Fotos ansehen',
       openFolder: 'Ordner öffnen: {{name}}',

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

@@ -1181,6 +1181,7 @@ export default {
       openSource3mf: 'Open source 3MF in Bambu Studio (right-click for more options)',
       downloadF3d: 'Download Fusion 360 design file',
       viewTimelapse: 'View timelapse',
+      viewTimelapses: 'View {{count}} timelapses',
       viewPhoto: 'View 1 photo',
       viewPhotos: 'View {{count}} photos',
       openFolder: 'Open folder: {{name}}',

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: 'Abrir el 3MF de origen en Bambu Studio (clic derecho para más opciones)',
       downloadF3d: 'Descargar el archivo de diseño de Fusion 360',
       viewTimelapse: 'Ver time-lapse',
+      viewTimelapses: 'Ver {{count}} time-lapses',
       viewPhoto: 'Ver 1 foto',
       viewPhotos: 'Ver {{count}} fotos',
       openFolder: 'Abrir carpeta: {{name}}',

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: 'Ouvrir 3MF dans Bambu Studio (clic droit pour plus)',
       downloadF3d: 'Télécharger fichier Fusion 360',
       viewTimelapse: 'Voir timelapse',
+      viewTimelapses: 'Voir {{count}} timelapses',
       viewPhoto: 'Voir 1 photo',
       viewPhotos: 'Voir {{count}} photos',
       openFolder: 'Ouvrir le dossier : {{name}}',

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: 'Apri sorgente 3MF in Bambu Studio (clic destro per altre opzioni)',
       downloadF3d: 'Scarica file design Fusion 360',
       viewTimelapse: 'Vedi timelapse',
+      viewTimelapses: 'Vedi {{count}} timelapse',
       viewPhoto: 'Vedi 1 foto',
       viewPhotos: 'Vedi {{count}} foto',
       openFolder: 'Apri cartella: {{name}}',

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

@@ -1173,6 +1173,7 @@ export default {
       openSource3mf: 'ソース3MFをBambu Studioで開く(右クリックでオプション表示)',
       downloadF3d: 'Fusion 360デザインファイルをダウンロード',
       viewTimelapse: 'タイムラプスを表示',
+      viewTimelapses: '{{count}}件のタイムラプスを表示',
       viewPhoto: '写真を表示',
       viewPhotos: '{{count}}枚の写真を表示',
       openFolder: 'フォルダーを開く: {{name}}',

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

@@ -1130,6 +1130,7 @@ export default {
       openSource3mf: 'Bambu Studio에서 소스 3MF 열기 (더 많은 옵션은 우클릭)',
       downloadF3d: 'Fusion 360 디자인 파일 다운로드',
       viewTimelapse: '타임랩스 보기',
+      viewTimelapses: '타임랩스 {{count}}개 보기',
       viewPhoto: '사진 1장 보기',
       viewPhotos: '사진 {{count}}장 보기',
       openFolder: '폴더 열기: {{name}}',

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

@@ -1179,6 +1179,7 @@ export default {
       openSource3mf: 'Bron-3MF openen in Bambu Studio (klik met rechts voor meer opties)',
       downloadF3d: 'Fusion 360-ontwerpbestand downloaden',
       viewTimelapse: 'Timelapse bekijken',
+      viewTimelapses: '{{count}} timelapses bekijken',
       viewPhoto: '1 foto bekijken',
       viewPhotos: '{{count}} foto\'s bekijken',
       openFolder: 'Map openen: {{name}}',

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

@@ -1181,6 +1181,7 @@ export default {
       openSource3mf: 'Otwórz źródłowy plik 3MF w Bambu Studio (prawy przycisk, aby zobaczyć więcej opcji)',
       downloadF3d: 'Pobierz plik projektu Fusion 360',
       viewTimelapse: 'Pokaż timelapse',
+      viewTimelapses: 'Pokaż timelapse ({{count}})',
       viewPhoto: 'Pokaż 1 zdjęcie',
       viewPhotos: 'Pokaż zdjęcia ({{count}})',
       openFolder: 'Otwórz folder: {{name}}',

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: 'Abrir source 3MF no Bambu Studio (clique com o botão direito para mais opções)',
       downloadF3d: 'Baixar arquivo de design do Fusion 360',
       viewTimelapse: 'Ver timelapse',
+      viewTimelapses: 'Ver {{count}} timelapses',
       viewPhoto: 'Ver 1 foto',
       viewPhotos: 'Ver {{count}} fotos',
       openFolder: 'Abrir pasta: {{name}}',

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

@@ -1129,6 +1129,7 @@ export default {
       openSource3mf: "Открыть исходный 3MF в Bambu Studio (щёлкните правой кнопкой для дополнительных действий)",
       downloadF3d: "Скачать файл проекта Fusion 360",
       viewTimelapse: "Посмотреть таймлапс",
+      viewTimelapses: "Посмотреть таймлапсы: {{count}}",
       viewPhoto: "Посмотреть фотографию",
       viewPhotos: "Посмотреть фотографии: {{count}}",
       openFolder: "Открыть папку: {{name}}",

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

@@ -1179,6 +1179,7 @@ export default {
       openSource3mf: 'Öppna 3MF-källfil i Bambu Studio (högerklicka för fler alternativ)',
       downloadF3d: 'Ladda ner Fusion 360-designfil',
       viewTimelapse: 'Visa timelapse',
+      viewTimelapses: 'Visa {{count}} timelapse-videor',
       viewPhoto: 'Visa 1 foto',
       viewPhotos: 'Visa {{count}} foton',
       openFolder: 'Öppna mapp: {{name}}',

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: "Kaynak 3MF'yi Bambu Studio'da aç (daha fazla seçenek için sağ tıklayın)",
       downloadF3d: 'Fusion 360 tasarım dosyasını indir',
       viewTimelapse: 'Zaman atlamalı videoyu görüntüle',
+      viewTimelapses: '{{count}} zaman atlamalı videoyu görüntüle',
       viewPhoto: '1 fotoğrafı görüntüle',
       viewPhotos: '{{count}} fotoğrafı görüntüle',
       openFolder: 'Klasörü aç: {{name}}',

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

@@ -1178,6 +1178,7 @@ export default {
       openSource3mf: "Відкрити вихідний файл 3MF у Bambu Studio (клацніть правою кнопкою, щоб переглянути додаткові дії)",
       downloadF3d: "Завантажити файл дизайну Fusion 360",
       viewTimelapse: "Переглянути сповільнену зйомку",
+      viewTimelapses: "Переглянути сповільнені зйомки: {{count}}",
       viewPhoto: "Переглянути 1 фото",
       viewPhotos: "Переглянути фото: {{count}}",
       openFolder: "Відкрити папку: {{name}}",

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: '在 Bambu Studio 中打开源 3MF(右键查看更多选项)',
       downloadF3d: '下载 Fusion 360 设计文件',
       viewTimelapse: '查看延时摄影',
+      viewTimelapses: '查看 {{count}} 个延时摄影',
       viewPhoto: '查看 1 张照片',
       viewPhotos: '查看 {{count}} 张照片',
       openFolder: '打开文件夹:{{name}}',

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

@@ -1174,6 +1174,7 @@ export default {
       openSource3mf: '在 Bambu Studio 中開啟源 3MF(右鍵檢視更多選項)',
       downloadF3d: '下載 Fusion 360 設計檔案',
       viewTimelapse: '檢視縮時攝影',
+      viewTimelapses: '檢視 {{count}} 個縮時攝影',
       viewPhoto: '檢視 1 張照片',
       viewPhotos: '檢視 {{count}} 張照片',
       openFolder: '開啟資料夾:{{name}}',

+ 35 - 10
frontend/src/pages/ArchivesPage.tsx

@@ -245,6 +245,23 @@ function isOwnClick(e: React.MouseEvent<Element>): boolean {
 
 // formatDate imported from '../utils/date' - handles UTC conversion
 
+// Its videos: the archive's timelapse_path is its latest print's only, the
+// others are on their prints, which the archive window plays.
+function videoCount(archive: Archive): number {
+  return Math.max(archive.timelapse_count ?? 0, archive.timelapse_path ? 1 : 0);
+}
+
+// One video plays in place; more, or one only an earlier print has, open the
+// archive window, where each print's plays.
+function useArchiveVideos(archive: Archive, navigate: (to: string) => void, play: () => void) {
+  const videos = videoCount(archive);
+  const openTimelapse = () => {
+    if (videos > 1 || !archive.timelapse_path) navigate(`/archives/${archive.id}`);
+    else play();
+  };
+  return { videos, openTimelapse };
+}
+
 // Ended without finishing: the archive's grams and cost stay one full print of
 // the file (COST-3), not what this run used.
 const UNFINISHED_STATUSES = new Set(['failed', 'aborted', 'cancelled', 'stopped']);
@@ -287,6 +304,7 @@ function ArchiveCard({
   const { showToast } = useToast();
   const { hasPermission, canModify } = useAuth();
   const navigate = useNavigate();
+  const { videos, openTimelapse } = useArchiveVideos(archive, navigate, () => setShowTimelapse(true));
   // Name of the printer this archive's saved slicer AMS mapping was resolved
   // against, or undefined when there is none. Undefined also when the printer
   // has since been deleted — a mapping whose printer is gone can never be
@@ -619,8 +637,8 @@ function ArchiveCard({
     {
       label: t('archives.menu.viewTimelapse'),
       icon: <Film className="w-4 h-4" />,
-      onClick: () => setShowTimelapse(true),
-      disabled: !archive.timelapse_path,
+      onClick: openTimelapse,
+      disabled: videos === 0,
     },
     {
       label: t('archives.media.download'),
@@ -1122,22 +1140,28 @@ function ArchiveCard({
           <Layers className="w-4 h-4 text-white" />
         </button>
         {/* Timelapse badge */}
-        {archive.timelapse_path && (
+        {videos > 0 && (
           <button
             className="absolute bottom-2 right-12 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
             onClick={(e) => {
               e.stopPropagation();
-              setShowTimelapse(true);
+              openTimelapse();
             }}
-            title={t('archives.card.viewTimelapse')}
+            title={videos > 1 ? t('archives.card.viewTimelapses', { count: videos }) : t('archives.card.viewTimelapse')}
+            data-testid="archive-card-timelapse"
           >
             <Film className="w-4 h-4 text-bambu-green" />
+            {videos > 1 && (
+              <span className="absolute -top-1 -right-1 w-4 h-4 bg-bambu-green rounded-full text-[10px] text-white flex items-center justify-center">
+                {videos}
+              </span>
+            )}
           </button>
         )}
         {/* Photos badge */}
         {archive.photos && archive.photos.length > 0 && (
           <button
-            className={`absolute bottom-2 ${archive.timelapse_path ? 'right-[5.5rem]' : 'right-12'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
+            className={`absolute bottom-2 ${videos > 0 ? 'right-[5.5rem]' : 'right-12'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}
             onClick={(e) => {
               e.stopPropagation();
               setShowPhotos(true);
@@ -1900,6 +1924,7 @@ function ArchiveListRow({
     staleTime: 0,
   });
   const navigate = useNavigate();
+  const { videos, openTimelapse } = useArchiveVideos(archive, navigate, () => setShowTimelapse(true));
   const [showReprint, setShowReprint] = useState(false);
   // Post-print outcome confirmation dialog (#1898)
   const [showConfirmOutcome, setShowConfirmOutcome] = useState(false);
@@ -2181,8 +2206,8 @@ function ArchiveListRow({
     {
       label: t('archives.menu.viewTimelapse'),
       icon: <Film className="w-4 h-4" />,
-      onClick: () => setShowTimelapse(true),
-      disabled: !archive.timelapse_path,
+      onClick: openTimelapse,
+      disabled: videos === 0,
     },
     {
       label: t('archives.menu.scanForTimelapse'),
@@ -2510,8 +2535,8 @@ function ArchiveListRow({
                 +{archive.duplicate_count}
               </span>
             )}
-            {archive.timelapse_path && (
-              <span title={t('archives.list.hasTimelapse')}>
+            {videos > 0 && (
+              <span title={videos > 1 ? t('archives.card.viewTimelapses', { count: videos }) : t('archives.list.hasTimelapse')}>
                 <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
               </span>
             )}

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


+ 3 - 3
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BuMh076b.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dh3Mfp1u.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-A76-KVrT.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-82e9FLNr.js">
@@ -110,7 +110,7 @@
     <link rel="modulepreload" crossorigin href="/assets/trash-2-DV6pNwZ0.js">
     <link rel="modulepreload" crossorigin href="/assets/weight-BrGbsav6.js">
     <link rel="modulepreload" crossorigin href="/assets/projectTree-C5LrJHZP.js">
-    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-BDm0_cKj.js">
+    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-CZt5lzm2.js">
     <link rel="modulepreload" crossorigin href="/assets/key-eHBzEQ60.js">
     <link rel="modulepreload" crossorigin href="/assets/log-out-w_j59-w-.js">
     <link rel="modulepreload" crossorigin href="/assets/shield-alert-uon1l8O-.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6NM9pNL.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-6gmNNa1B.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CHLiTxan.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-B5C8BmRi.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-9sMyOqKM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-C2XdLYj2.css">
   </head>
   <body>

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