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

fix(photo): source finish photo from forced timelapse + cleanup (#1397)

  Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
  live-camera grab captured the bed already dropped, ruining the photo
  framing. Source the photo from a brief Bambu timelapse instead —
  firmware stops timelapse recording AFTER toolhead parks but BEFORE
  bed-drop runs, so the last frame frames the finished print correctly.

  When capture_finish_photo is on AND the user did not opt in to
  timelapse for this print, force timelapse=True at dispatch + mark the
  new PrintArchive.bambuddy_forced_timelapse column. After extraction
  (success or failure), cleanup deletes the locally-attached file,
  clears archive.timelapse_path, and walks the four scanner directories
  (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
  against the original filename. User-opted-in timelapses pass through
  unchanged.

  Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
  (module-level so the print queue can reuse it). Both dispatch paths
  wired: background_dispatch.py (Print Now / Reprint) AND
  print_scheduler.py:_start_print (the queue). Field testing caught the
  scheduler gap on the first round — AST regression test now asserts
  start_print(timelapse=...) references effective_timelapse, not the raw
  item.timelapse, so a future refactor can't silently drop it.

  Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
  frames overwrite the same output file, so the file left on disk is the
  literal last frame regardless of duration. Bambu records one frame per
  layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
  original -sseof -1.0 approach seeked before the start of the file and
  returned frame 0 (empty bed). Decoding every frame is fine; Bambu
  timelapses are short by construction even on hours-long prints.

  Migration adds bambuddy_forced_timelapse branched on is_sqlite()
  (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
  Verified live on postgres:16-alpine.

  Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
  so the notification carries the bed-up photo instead of falling back
  to the live-cam grab on slow links.

  Scope limit, documented in the camera wiki: prints started directly
  on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
  both dispatch paths, so the override doesn't fire there. Future
  option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.

  Setting description rewritten in all 11 locales to drop the "only
  works when timelapse enabled" caveat (Bambuddy now forces it) and
  explain the kept-or-deleted behaviour.
maziggy 3 месяцев назад
Родитель
Сommit
12d17bfbe7

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


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

@@ -2040,6 +2040,19 @@ async def run_migrations(conn):
         "CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
     )
 
+    # Migration: Add bambuddy_forced_timelapse to print_archives (#1397)
+    # Tracks prints where Bambuddy forced the firmware to record a timelapse
+    # so the finish-photo extractor could pull the post-park-pre-drop frame.
+    # The cleanup path uses this to delete the timelapse both locally and on
+    # the printer's SD after extraction — the user didn't opt in to a
+    # timelapse recording. Postgres rejects `DEFAULT 0` for BOOLEAN; SQLite
+    # accepts both 0/FALSE — branch the literal.
+    _bool_false_literal = "0" if is_sqlite() else "FALSE"
+    await _safe_execute(
+        conn,
+        f"ALTER TABLE print_archives ADD COLUMN bambuddy_forced_timelapse BOOLEAN DEFAULT {_bool_false_literal}",
+    )
+
     # Migration: Create smart_plug_energy_snapshots table (#941)
     # Hourly snapshots of each plug's lifetime counter, so date-range queries in
     # "total consumption" energy mode can compute (last - first) deltas.

+ 240 - 48
backend/app/main.py

@@ -8,6 +8,7 @@ import time
 from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
+from pathlib import Path
 from urllib.parse import urlparse
 
 from fastapi import FastAPI
@@ -3146,6 +3147,158 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
     logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
 
 
+# Defaults for the finish-photo-from-timelapse polling loop (#1397). These are
+# module-level so tests can monkeypatch them down to ~0 without timing out.
+_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS: float = 3.0
+_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS: float = 60.0
+
+
+async def _capture_finish_photo_from_timelapse(
+    archive_id: int,
+    archive_dir: Path,
+) -> str | None:
+    """Wait for the per-print timelapse to land on the archive and extract its
+    last frame as the finish photo (#1397).
+
+    Bambu firmware stops timelapse recording after the toolhead parks but
+    before the bed-drop end-gcode runs, so the last frame frames the finished
+    print correctly. A live camera grab at gcode_state=FINISH captures the
+    bed already lowered.
+
+    ``_scan_for_timelapse_with_retries`` runs in parallel and writes
+    ``archive.timelapse_path`` when the file lands. This function polls for
+    that field. Returns the saved photo filename on success, or None if the
+    timelapse never arrives within the timeout / extraction fails / no
+    timelapse path was set — in which case the caller falls back to the
+    existing live-camera capture chain.
+    """
+    import uuid
+
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.camera import extract_video_last_frame
+
+    logger = logging.getLogger(__name__)
+
+    deadline = asyncio.get_event_loop().time() + _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS
+    poll_interval = _FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS
+
+    while True:
+        async with async_session() as db:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+            archive = result.scalar_one_or_none()
+            timelapse_relpath = archive.timelapse_path if archive else None
+
+        if timelapse_relpath:
+            video_path = app_settings.base_dir / timelapse_relpath
+            if video_path.exists() and video_path.stat().st_size > 0:
+                photos_dir = archive_dir / "photos"
+                photos_dir.mkdir(parents=True, exist_ok=True)
+                timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                output_path = photos_dir / filename
+                if await extract_video_last_frame(video_path, output_path):
+                    logger.info(
+                        "[PHOTO-BG] Extracted finish photo from timelapse %s for archive %s",
+                        video_path.name,
+                        archive_id,
+                    )
+                    return filename
+                logger.warning(
+                    "[PHOTO-BG] Timelapse %s landed but last-frame extraction failed for archive %s; falling back",
+                    video_path.name,
+                    archive_id,
+                )
+                return None
+
+        if asyncio.get_event_loop().time() >= deadline:
+            logger.info(
+                "[PHOTO-BG] Timelapse for archive %s didn't land within %.0fs; falling back to live camera",
+                archive_id,
+                _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS,
+            )
+            return None
+
+        await asyncio.sleep(poll_interval)
+
+
+async def _cleanup_forced_timelapse(archive_id: int, printer_id: int) -> None:
+    """Delete the timelapse Bambuddy forced on for #1397's finish-photo path.
+
+    Called from the finish-photo background task after the extractor has had
+    its turn (regardless of whether extraction succeeded — the user never
+    asked for a video and we shouldn't leave one behind even if ffmpeg
+    failed). Cleanup is best-effort and never raises: a printer that's
+    offline at cleanup time means a single orphaned file on the SD card,
+    not a broken Bambuddy flow.
+
+    Cleans both:
+      - the locally-attached file (clears archive.timelapse_path)
+      - the printer-side file via FTP DELE
+    """
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.printer import Printer
+    from backend.app.services.bambu_ftp import delete_file_async
+
+    logger = logging.getLogger(__name__)
+
+    local_relpath: str | None = None
+    printer = None
+
+    async with async_session() as db:
+        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+        archive = archive_result.scalar_one_or_none()
+        if not archive or not archive.bambuddy_forced_timelapse:
+            return
+
+        local_relpath = archive.timelapse_path
+        if local_relpath:
+            local_abspath = app_settings.base_dir / local_relpath
+            try:
+                if local_abspath.exists():
+                    local_abspath.unlink()
+                    logger.info(
+                        "[FORCED-TIMELAPSE] Deleted local timelapse %s for archive %s",
+                        local_relpath,
+                        archive_id,
+                    )
+            except OSError as e:
+                logger.warning("[FORCED-TIMELAPSE] Could not delete local timelapse %s: %s", local_relpath, e)
+            archive.timelapse_path = None
+            await db.commit()
+
+        printer_result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = printer_result.scalar_one_or_none()
+
+    if printer is None or not local_relpath:
+        return
+
+    # _scan_for_timelapse_with_retries used the original filename when it
+    # attached, so the basename of timelapse_path matches the printer-side
+    # filename. Try the directories the scanner walks (#1397).
+    filename = Path(local_relpath).name
+    for remote_dir in ("/timelapse", "/timelapse/video", "/record", "/recording"):
+        remote_path = f"{remote_dir}/{filename}"
+        try:
+            ok = await delete_file_async(
+                printer.ip_address,
+                printer.access_code,
+                remote_path,
+                printer_model=printer.model,
+            )
+        except Exception as e:
+            logger.debug("[FORCED-TIMELAPSE] FTP delete attempt failed for %s: %s", remote_path, e)
+            continue
+        if ok:
+            logger.info("[FORCED-TIMELAPSE] Deleted printer-side timelapse %s", remote_path)
+            return
+
+    logger.warning(
+        "[FORCED-TIMELAPSE] Could not delete printer-side timelapse %s for archive %s (file may already be gone)",
+        filename,
+        archive_id,
+    )
+
+
 async def on_print_running_observed(printer_id: int, data: dict):
     """Restart-recovery: capture a fresh timelapse baseline for a print that
     started before Bambuddy came up.
@@ -4119,54 +4272,72 @@ async def on_print_complete(printer_id: int, data: dict):
                                 archive_dir = app_settings.archive_dir / str(archive.id)
                             photo_filename = None
 
-                            # Check for external camera first
-                            if printer.external_camera_enabled and printer.external_camera_url:
-                                logger.info("[PHOTO-BG] Using external camera")
-                                from backend.app.services.external_camera import capture_frame
+                            # Prefer the timelapse last-frame source when a timelapse was
+                            # recording — it captures the moment after the toolhead parks
+                            # but before the bed drops, which the live-camera grab below
+                            # would miss (#1397). Skipped for external cameras (those have
+                            # their own framing and don't see a Bambu timelapse).
+                            prefer_timelapse_source = bool(data.get("timelapse_was_active")) and not (
+                                printer.external_camera_enabled and printer.external_camera_url
+                            )
 
-                                frame_data = await capture_frame(
-                                    printer.external_camera_url,
-                                    printer.external_camera_type or "mjpeg",
-                                    snapshot_url=printer.external_camera_snapshot_url,
+                            if prefer_timelapse_source:
+                                photo_filename = await _capture_finish_photo_from_timelapse(
+                                    archive_id=archive_id,
+                                    archive_dir=archive_dir,
                                 )
-                                if frame_data:
-                                    photos_dir = archive_dir / "photos"
-                                    photos_dir.mkdir(parents=True, exist_ok=True)
-                                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-                                    photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-                                    photo_path = photos_dir / photo_filename
-                                    await asyncio.to_thread(photo_path.write_bytes, frame_data)
-                                    logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
-                            else:
-                                # Check if camera stream is active - use buffered frame to avoid freeze
-                                # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
-                                active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
-                                active_chamber_for_printer = [
-                                    k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
-                                ]
-                                buffered_frame = get_buffered_frame(printer_id)
-
-                                if (active_for_printer or active_chamber_for_printer) and buffered_frame:
-                                    # Use frame from active stream
-                                    logger.info("[PHOTO-BG] Using buffered frame from active stream")
-                                    photos_dir = archive_dir / "photos"
-                                    photos_dir.mkdir(parents=True, exist_ok=True)
-                                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-                                    photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-                                    photo_path = photos_dir / photo_filename
-                                    await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
-                                    logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
-                                else:
-                                    # No active stream - capture new frame
-                                    from backend.app.services.camera import capture_finish_photo
-
-                                    photo_filename = await capture_finish_photo(
-                                        printer_id=printer_id,
-                                        ip_address=printer.ip_address,
-                                        access_code=printer.access_code,
-                                        model=printer.model,
-                                        archive_dir=archive_dir,
+
+                            # Fallback chain: external camera → buffered live frame →
+                            # fresh RTSP capture. Only runs if the timelapse path above
+                            # didn't already produce a photo.
+                            if not photo_filename:
+                                if printer.external_camera_enabled and printer.external_camera_url:
+                                    logger.info("[PHOTO-BG] Using external camera")
+                                    from backend.app.services.external_camera import capture_frame
+
+                                    frame_data = await capture_frame(
+                                        printer.external_camera_url,
+                                        printer.external_camera_type or "mjpeg",
+                                        snapshot_url=printer.external_camera_snapshot_url,
                                     )
+                                    if frame_data:
+                                        photos_dir = archive_dir / "photos"
+                                        photos_dir.mkdir(parents=True, exist_ok=True)
+                                        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                                        photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                                        photo_path = photos_dir / photo_filename
+                                        await asyncio.to_thread(photo_path.write_bytes, frame_data)
+                                        logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
+                                else:
+                                    # Check if camera stream is active - use buffered frame to avoid freeze
+                                    # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
+                                    active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
+                                    active_chamber_for_printer = [
+                                        k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
+                                    ]
+                                    buffered_frame = get_buffered_frame(printer_id)
+
+                                    if (active_for_printer or active_chamber_for_printer) and buffered_frame:
+                                        # Use frame from active stream
+                                        logger.info("[PHOTO-BG] Using buffered frame from active stream")
+                                        photos_dir = archive_dir / "photos"
+                                        photos_dir.mkdir(parents=True, exist_ok=True)
+                                        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                                        photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                                        photo_path = photos_dir / photo_filename
+                                        await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
+                                        logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
+                                    else:
+                                        # No active stream - capture new frame
+                                        from backend.app.services.camera import capture_finish_photo
+
+                                        photo_filename = await capture_finish_photo(
+                                            printer_id=printer_id,
+                                            ip_address=printer.ip_address,
+                                            access_code=printer.access_code,
+                                            model=printer.model,
+                                            archive_dir=archive_dir,
+                                        )
 
                             if photo_filename:
                                 photos = archive.photos or []
@@ -4174,6 +4345,18 @@ async def on_print_complete(printer_id: int, data: dict):
                                 archive.photos = photos
                                 await db.commit()
                                 logger.info("[PHOTO-BG] Saved: %s", photo_filename)
+
+                            # When Bambuddy forced timelapse on for this print, delete
+                            # the timelapse afterward (#1397). The user didn't ask for
+                            # a video to keep — only the finish photo. Runs even when
+                            # photo extraction failed, so we don't leave debris.
+                            if archive.bambuddy_forced_timelapse:
+                                await _cleanup_forced_timelapse(
+                                    archive_id=archive_id,
+                                    printer_id=printer_id,
+                                )
+
+                            if photo_filename:
                                 return photo_filename
             return None
         except Exception as e:
@@ -4364,15 +4547,24 @@ async def on_print_complete(printer_id: int, data: dict):
     asyncio.create_task(_background_smart_plug())
     asyncio.create_task(_background_maintenance_check())
 
-    # Notification task waits for photo capture to complete first (with timeout)
+    # Notification task waits for photo capture to complete first (with timeout).
+    # When a timelapse was recording, photo sourcing polls the per-print
+    # timelapse for up to 60s (#1397) — extend the budget so the notification
+    # carries the correct bed-up photo instead of falling through to the
+    # live-cam grab. Adds ~30s of notification latency at worst on slow links.
+    photo_wait_timeout = 75 if data.get("timelapse_was_active") else 45
+
     async def _photo_then_notify():
         """Wait for photo capture, then send notification with photo URL."""
         finish_photo = None
         try:
-            finish_photo = await asyncio.wait_for(photo_task, timeout=45)
+            finish_photo = await asyncio.wait_for(photo_task, timeout=photo_wait_timeout)
             logger.info("[PHOTO-NOTIFY] Photo task returned: %s", finish_photo)
         except TimeoutError:
-            logger.warning("[PHOTO-NOTIFY] Photo capture timed out after 45s, sending notification without photo")
+            logger.warning(
+                "[PHOTO-NOTIFY] Photo capture timed out after %ss, sending notification without photo",
+                photo_wait_timeout,
+            )
         except Exception as e:
             logger.warning("[PHOTO-NOTIFY] Photo task failed: %s", e)
         try:

+ 6 - 0
backend/app/models/archive.py

@@ -20,6 +20,12 @@ class PrintArchive(Base):
     content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     timelapse_path: Mapped[str | None] = mapped_column(String(500))
+    # True when Bambuddy forced timelapse recording on for this print so the
+    # finish-photo extractor (#1397) could pull the post-park-pre-drop frame.
+    # The cleanup path uses this to know the timelapse should be deleted
+    # both locally and on the printer's SD after extraction — the user
+    # didn't opt in to a timelapse recording.
+    bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
     source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer
     f3d_path: Mapped[str | None] = mapped_column(String(500))  # Fusion 360 design file
 

+ 7 - 1
backend/app/schemas/settings.py

@@ -9,7 +9,13 @@ class AppSettings(BaseModel):
     auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
     save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
     capture_finish_photo: bool = Field(
-        default=True, description="Capture photo from printer camera when print completes"
+        default=True,
+        description=(
+            "Capture photo from printer camera when print completes. Bambuddy records a "
+            "brief timelapse during the print so the photo can be sourced from the moment "
+            "before the bed drops; the timelapse file is kept if you enabled timelapse for "
+            "this print, otherwise it is deleted automatically after the photo is captured."
+        ),
     )
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     currency: str = Field(default="USD", description="Currency for cost tracking")

+ 54 - 2
backend/app/services/background_dispatch.py

@@ -492,6 +492,17 @@ class BackgroundDispatchService:
         if self._is_cancel_requested(job.id):
             raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
 
+    async def _resolve_effective_timelapse(self, db, archive, job: PrintDispatchJob) -> bool:
+        """Dispatch-flow wrapper around the shared resolver (#1397).
+
+        Returns the effective value to pass to ``start_print(timelapse=...)``.
+        """
+        return await resolve_effective_timelapse(
+            db,
+            archive,
+            user_wanted_timelapse=bool(job.options.get("timelapse", False)),
+        )
+
     def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
         processing = len(self._active_jobs)
         dispatched = len(self._queued_jobs)
@@ -666,13 +677,15 @@ class BackgroundDispatchService:
 
                 self._raise_if_cancel_requested(job)
 
+                effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
+
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 started = printer_manager.start_print(
                     job.printer_id,
                     remote_filename,
                     plate_id,
                     ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=job.options.get("timelapse", False),
+                    timelapse=effective_timelapse,
                     bed_levelling=job.options.get("bed_levelling", True),
                     flow_cali=job.options.get("flow_cali", False),
                     vibration_cali=job.options.get("vibration_cali", True),
@@ -864,13 +877,15 @@ class BackgroundDispatchService:
 
                 self._raise_if_cancel_requested(job)
 
+                effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
+
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 started = printer_manager.start_print(
                     job.printer_id,
                     remote_filename,
                     plate_id,
                     ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=job.options.get("timelapse", False),
+                    timelapse=effective_timelapse,
                     bed_levelling=job.options.get("bed_levelling", True),
                     flow_cali=job.options.get("flow_cali", False),
                     vibration_cali=job.options.get("vibration_cali", True),
@@ -1077,4 +1092,41 @@ class BackgroundDispatchService:
         return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
 
 
+async def resolve_effective_timelapse(db, archive, user_wanted_timelapse: bool) -> bool:
+    """Resolve whether this print should record a timelapse (#1397).
+
+    Shared by both the on-demand dispatch path (``background_dispatch.py``,
+    used by Print Now / Reprint flows) and the queued-dispatch path
+    (``print_scheduler.py``, used by the print queue). Both must apply the
+    same override semantics or the queue path's prints would slip through
+    without a finish photo.
+
+    Bambuddy forces timelapse recording on when:
+      - the global ``capture_finish_photo`` setting is enabled, AND
+      - the user did NOT opt in to a timelapse for this specific print
+
+    The forced bit is recorded on the archive so the post-extraction
+    cleanup path can delete the timelapse afterward (the user didn't
+    ask for a video to keep, only the framed finish photo, #1397).
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    if user_wanted_timelapse:
+        return True
+
+    # User didn't ask — check the master capture-finish-photo toggle.
+    capture_setting = await get_setting(db, "capture_finish_photo")
+    capture_enabled = capture_setting is None or capture_setting.lower() == "true"
+    if not capture_enabled:
+        return False
+
+    archive.bambuddy_forced_timelapse = True
+    await db.commit()
+    logging.getLogger(__name__).info(
+        "[FORCED-TIMELAPSE] Forcing timelapse on for archive %s (capture_finish_photo enabled, user did not opt in)",
+        archive.id,
+    )
+    return True
+
+
 background_dispatch = BackgroundDispatchService()

+ 78 - 0
backend/app/services/camera.py

@@ -625,6 +625,84 @@ async def capture_camera_frame_bytes(
         await proxy_server.wait_closed()
 
 
+async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
+    """Extract the last frame of `video_path` as JPEG at `output_path`.
+
+    Used to source finish photos from a Bambu timelapse. The Bambu firmware
+    stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop
+    end-gcode runs, so the last frame frames the finished print correctly.
+    A live camera grab at `gcode_state=FINISH` captures the bed already
+    lowered (#1397).
+
+    Implementation: ``-update 1`` writes each decoded frame to the same
+    output file (overwriting), so the file left on disk after ffmpeg
+    finishes is the LAST frame. This works regardless of how short the
+    video is — a small print's timelapse can be sub-second / sub-30 frames
+    (one frame per layer-change capture), and the earlier ``-sseof -1.0``
+    approach failed there because the seek went before the start of the
+    file and ffmpeg silently returned frame 0 (empty bed at print start).
+    Decoding every frame is fine: Bambu timelapses are short by
+    construction (<1 minute even on hours-long prints).
+
+    Returns False on missing ffmpeg, missing video, subprocess failure or
+    timeout. Never raises.
+    """
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.warning("Cannot extract video last frame: ffmpeg not available")
+        return False
+
+    if not video_path.exists() or video_path.stat().st_size == 0:
+        logger.warning("Cannot extract last frame: %s missing or empty", video_path)
+        return False
+
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+
+    cmd = [
+        ffmpeg,
+        "-y",
+        "-i",
+        str(video_path),
+        "-q:v",
+        "2",
+        "-update",
+        "1",
+        str(output_path),
+    ]
+
+    process = None
+    try:
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        _, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
+        if process.returncode != 0:
+            logger.warning(
+                "ffmpeg failed extracting last frame from %s: %s",
+                video_path,
+                stderr.decode(errors="replace")[:500],
+            )
+            return False
+        if not output_path.exists() or output_path.stat().st_size == 0:
+            logger.warning("ffmpeg produced no output for %s", video_path)
+            return False
+        return True
+    except asyncio.TimeoutError:
+        logger.warning("ffmpeg timed out extracting last frame from %s", video_path)
+        if process is not None:
+            try:
+                process.kill()
+                await process.wait()
+            except ProcessLookupError:
+                pass  # Already exited
+        return False
+    except OSError as e:
+        logger.warning("ffmpeg subprocess error for %s: %s", video_path, e)
+        return False
+
+
 async def capture_finish_photo(
     printer_id: int,
     ip_address: str,

+ 17 - 1
backend/app/services/print_scheduler.py

@@ -2142,6 +2142,22 @@ class PrintScheduler:
         pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
         pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
 
+        # #1397: force timelapse on when capture_finish_photo is enabled so
+        # the finish-photo extractor has something to pull from. Same override
+        # semantics as background_dispatch.py — both queue paths must apply
+        # the same rule or queued prints slip through without a finish photo.
+        # When archive_print failed (library_file path, line 1968 except), we
+        # have no archive to mark — fall back to the literal user choice; the
+        # downstream finish-photo path can't run without an archive anyway.
+        if archive is not None:
+            from backend.app.services.background_dispatch import resolve_effective_timelapse
+
+            effective_timelapse = await resolve_effective_timelapse(
+                db, archive, user_wanted_timelapse=bool(item.timelapse)
+            )
+        else:
+            effective_timelapse = bool(item.timelapse)
+
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(
             item.printer_id,
@@ -2152,7 +2168,7 @@ class PrintScheduler:
             flow_cali=item.flow_cali,
             vibration_cali=item.vibration_cali,
             layer_inspect=item.layer_inspect,
-            timelapse=item.timelapse,
+            timelapse=effective_timelapse,
             use_ams=item.use_ams,
         )
 

+ 6 - 1
backend/tests/unit/services/test_background_dispatch.py

@@ -231,7 +231,12 @@ def test_dispatch_option_defaults_align_with_request_schema_defaults():
     from backend.app.schemas.library import FilePrintRequest
     from backend.app.services import background_dispatch as bd
 
-    fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "timelapse", "use_ams")
+    # `timelapse` deliberately excluded — the dispatcher now resolves it via
+    # ``_resolve_effective_timelapse`` so the value passed to ``start_print``
+    # depends on the ``capture_finish_photo`` setting + ``bambuddy_forced_timelapse``
+    # column (#1397). The original literal `job.options.get("timelapse", False)`
+    # pattern no longer appears.
+    fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "use_ams")
     reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
     libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
     assert reprint_defaults == libprint_defaults, (

+ 16 - 0
backend/tests/unit/services/test_background_dispatch_watchdog.py

@@ -606,6 +606,14 @@ class TestReprintArchiveDispatchWiring:
                 new_callable=AsyncMock,
             ),
             patch("backend.app.main.register_expected_print"),
+            # #1397: _resolve_effective_timelapse touches DB + setting layer
+            # that this watchdog-focused test isn't equipped to mock. Stub
+            # the whole helper so the dispatch flow proceeds unaffected.
+            patch.object(
+                BackgroundDispatchService,
+                "_resolve_effective_timelapse",
+                new=AsyncMock(return_value=False),
+            ),
             pytest.raises(RuntimeError, match="did not acknowledge print command"),
         ):
             await service._run_reprint_archive(job)
@@ -674,6 +682,14 @@ class TestReprintArchiveDispatchWiring:
                 new_callable=AsyncMock,
             ),
             patch("backend.app.main.register_expected_print"),
+            # #1397: _resolve_effective_timelapse touches DB + setting layer
+            # that this watchdog-focused test isn't equipped to mock. Stub
+            # the whole helper so the dispatch flow proceeds unaffected.
+            patch.object(
+                BackgroundDispatchService,
+                "_resolve_effective_timelapse",
+                new=AsyncMock(return_value=False),
+            ),
         ):
             await service._run_reprint_archive(job)  # must not raise
 

+ 156 - 0
backend/tests/unit/services/test_dispatch_force_timelapse.py

@@ -0,0 +1,156 @@
+"""Tests for _resolve_effective_timelapse (#1397).
+
+Bambuddy forces timelapse recording on at dispatch time when the
+capture_finish_photo setting is enabled and the user did not opt in
+to timelapse for the specific print. The forced bit is recorded on
+the archive so the post-extraction cleanup path can delete the file.
+
+These tests exercise the four decision shapes the helper has to handle:
+
+  1. capture_finish_photo OFF → no override regardless of user choice
+  2. capture_finish_photo ON, user chose timelapse → no override (the
+     user's choice already covers the photo path)
+  3. capture_finish_photo ON, user chose NO timelapse → override to ON,
+     mark archive.bambuddy_forced_timelapse=True
+  4. capture_finish_photo unset (None / missing) → defaults to ON, so
+     the same override applies as case 3
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.background_dispatch import (
+    BackgroundDispatchService,
+    PrintDispatchJob,
+)
+
+
+def _make_job(timelapse: bool | None) -> PrintDispatchJob:
+    """Mint a job with the smallest valid shape — the only field
+    _resolve_effective_timelapse reads from job is `options`."""
+    return PrintDispatchJob(
+        id=1,
+        kind="print_library_file",
+        source_id=42,
+        source_name="test.gcode.3mf",
+        printer_id=10,
+        printer_name="Printer A",
+        options={"timelapse": timelapse} if timelapse is not None else {},
+    )
+
+
+def _make_archive() -> SimpleNamespace:
+    """Stand-in archive object; the helper only touches .id and
+    .bambuddy_forced_timelapse."""
+    return SimpleNamespace(id=99, bambuddy_forced_timelapse=False)
+
+
+def _make_db() -> AsyncMock:
+    """Fake db with a no-op .commit()."""
+    db = AsyncMock()
+    return db
+
+
+@pytest.mark.asyncio
+async def test_capture_finish_photo_off_means_no_override():
+    """Master toggle off → user's timelapse=False stays False, no flag set."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=False)
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="false"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is False
+    assert archive.bambuddy_forced_timelapse is False
+    db.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_user_opted_in_passes_through_unchanged():
+    """User asked for a timelapse → no override needed (their normal flow
+    already records one). bambuddy_forced_timelapse stays False so cleanup
+    leaves the file alone."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=True)
+
+    # get_setting shouldn't even be consulted — but if it is, no override
+    # should still fire.
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="true"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is False
+    db.commit.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_capture_on_user_off_forces_timelapse_and_marks_flag():
+    """The whole point of the fix: capture_finish_photo=on + user-timelapse=off
+    flips the MQTT command to timelapse=True and marks the archive for
+    post-extraction cleanup."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=False)
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="true"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is True
+    db.commit.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_capture_finish_photo_unset_defaults_to_enabled():
+    """Setting absent from DB → default is True (per the Field default in the
+    schema), so the override fires just like when explicitly enabled."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=False)
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value=None),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is True
+    db.commit.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_user_missing_timelapse_treated_as_false():
+    """Some queue paths pass options without a timelapse key. Treat absent
+    as False (matches existing job.options.get('timelapse', False) default
+    that the caller previously used)."""
+    service = BackgroundDispatchService()
+    archive = _make_archive()
+    db = _make_db()
+    job = _make_job(timelapse=None)  # falls through to {}
+
+    with patch(
+        "backend.app.api.routes.settings.get_setting",
+        new=AsyncMock(return_value="true"),
+    ):
+        effective = await service._resolve_effective_timelapse(db, archive, job)
+
+    assert effective is True
+    assert archive.bambuddy_forced_timelapse is True

+ 205 - 0
backend/tests/unit/test_cleanup_forced_timelapse.py

@@ -0,0 +1,205 @@
+"""Tests for _cleanup_forced_timelapse (#1397).
+
+When Bambuddy forced timelapse on for the finish-photo path, this helper
+runs after the extractor (success OR failure — we never leave debris).
+It deletes:
+  - the locally-attached file (clears archive.timelapse_path)
+  - the printer-side file via FTP DELE, walking the four scanner dirs
+
+These tests pin the four branches:
+
+  1. archive doesn't exist → no-op
+  2. archive exists but bambuddy_forced_timelapse=False → no-op (user wanted
+     the timelapse)
+  3. archive exists, forced=True, local file present → delete local + DB
+     update + FTP DELE on the first directory that succeeds
+  4. archive exists, forced=True, but FTP DELE fails on every dir → local
+     side still cleaned up; warn log emitted (best-effort)
+"""
+
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.main import _cleanup_forced_timelapse
+
+
+def _fake_session_factory(rows: dict):
+    """Return an async_session() replacement that yields the given rows.
+
+    `rows` is a mapping of model -> object that the test wants returned
+    from `db.execute(select(...)).scalar_one_or_none()`. The select
+    target is detected by walking the column descriptions — for these
+    tests we just look at the model class name.
+    """
+    from contextlib import asynccontextmanager
+
+    @asynccontextmanager
+    async def fake_session():
+        async def execute(stmt):
+            # The select(...) statement carries the target entity in
+            # `stmt.column_descriptions[0]["entity"]`. Match by class name.
+            target_name = stmt.column_descriptions[0]["entity"].__name__
+            row = rows.get(target_name)
+            return SimpleNamespace(scalar_one_or_none=lambda: row)
+
+        commits: list[None] = []
+
+        async def commit():
+            commits.append(None)
+
+        yield SimpleNamespace(execute=execute, commit=commit, _commits=commits)
+
+    return fake_session
+
+
+@pytest.fixture(autouse=True)
+def patch_app_settings(monkeypatch, tmp_path):
+    """Point base_dir at a tmp_path so the helper can resolve relative
+    timelapse paths against a real fs we control."""
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+@pytest.mark.asyncio
+async def test_no_archive_is_noop(monkeypatch):
+    """Archive deleted between print start and cleanup? Don't crash."""
+    monkeypatch.setattr(main_module, "async_session", _fake_session_factory({"PrintArchive": None, "Printer": None}))
+    delete_mock = AsyncMock()
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+    delete_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_not_forced_is_noop(monkeypatch, tmp_path):
+    """User wanted a timelapse → don't delete anything."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=False,
+        timelapse_path="archive/1/timelapse.mp4",
+    )
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": None}),
+    )
+
+    # Lay down a real file so we'd detect a stray delete.
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    delete_mock = AsyncMock(return_value=True)
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    delete_mock.assert_not_awaited()
+    assert video_path.exists()
+    # archive.timelapse_path is untouched — we still have the user's video
+    # tracked correctly.
+    assert archive.timelapse_path == "archive/1/timelapse.mp4"
+
+
+@pytest.mark.asyncio
+async def test_forced_deletes_local_and_remote(monkeypatch, tmp_path):
+    """Happy path: forced=True → local file unlinked, DB row cleared, FTP
+    DELE called against /timelapse/<filename> (the first dir to succeed)."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=True,
+        timelapse_path="archive/1/myprint.mp4",
+    )
+    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
+    )
+
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    # FTP DELE succeeds on the first directory we try.
+    delete_mock = AsyncMock(return_value=True)
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    # Local side: file gone, DB cleared.
+    assert not video_path.exists()
+    assert archive.timelapse_path is None
+    # Remote side: DELE'd against /timelapse/myprint.mp4 — that's the
+    # first dir the cleanup tries.
+    delete_mock.assert_awaited()
+    call = delete_mock.await_args
+    assert call.args[0] == "10.0.0.5"
+    assert call.args[1] == "12345678"
+    assert call.args[2] == "/timelapse/myprint.mp4"
+
+
+@pytest.mark.asyncio
+async def test_forced_walks_alternate_dirs_when_first_fails(monkeypatch, tmp_path):
+    """If /timelapse/ DELE returns False (file not there), try the other
+    scanner dirs in order."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=True,
+        timelapse_path="archive/1/myprint.mp4",
+    )
+    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
+    )
+
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    # First two attempts fail (False), third succeeds (True). Cleanup
+    # should stop after the third.
+    delete_mock = AsyncMock(side_effect=[False, False, True])
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    assert delete_mock.await_count == 3
+    paths_tried = [call.args[2] for call in delete_mock.await_args_list]
+    assert paths_tried == [
+        "/timelapse/myprint.mp4",
+        "/timelapse/video/myprint.mp4",
+        "/record/myprint.mp4",
+    ]
+
+
+@pytest.mark.asyncio
+async def test_forced_local_cleanup_runs_even_if_ftp_unreachable(monkeypatch, tmp_path):
+    """FTP completely failing must not block local cleanup — the user's
+    archive UI should reflect that the timelapse is gone immediately,
+    even if the printer-side file lingers."""
+    archive = SimpleNamespace(
+        bambuddy_forced_timelapse=True,
+        timelapse_path="archive/1/myprint.mp4",
+    )
+    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
+    monkeypatch.setattr(
+        main_module,
+        "async_session",
+        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
+    )
+
+    video_path = tmp_path / archive.timelapse_path
+    video_path.parent.mkdir(parents=True, exist_ok=True)
+    video_path.write_bytes(b"x" * 100)
+
+    # Every FTP attempt throws.
+    delete_mock = AsyncMock(side_effect=OSError("connection refused"))
+    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
+        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
+
+    # Local side cleaned up even though all FTP attempts threw.
+    assert not video_path.exists()
+    assert archive.timelapse_path is None
+    # All four dirs were attempted before giving up.
+    assert delete_mock.await_count == 4

+ 171 - 0
backend/tests/unit/test_extract_video_last_frame.py

@@ -0,0 +1,171 @@
+"""Tests for extract_video_last_frame (#1397).
+
+Sources the finish photo from the per-print Bambu timelapse's last frame —
+captured by firmware after the toolhead parks but before the bed-drop
+end-gcode runs, so the print is framed correctly. A live camera grab at
+gcode_state=FINISH would capture the bed already lowered.
+
+We can't ship a real Bambu timelapse fixture in the repo (~7-11 MB each),
+so the happy-path test builds a tiny synthetic MP4 with ffmpeg at runtime.
+Failure paths (missing ffmpeg, missing source, subprocess failure, timeout)
+are exercised with monkeypatching so the suite stays hermetic and fast.
+"""
+
+import asyncio
+import shutil
+import subprocess
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services.camera import extract_video_last_frame
+
+_HAS_FFMPEG = shutil.which("ffmpeg") is not None
+
+
+def _make_synthetic_mp4(dest: Path, duration_seconds: float = 1.0) -> None:
+    """Create a tiny test MP4 via ffmpeg's testsrc generator.
+
+    Smallest valid MP4 we can construct without committing binary fixtures —
+    one second of 32x32 testsrc, ultrafast encode, ~3-5 KB.
+    """
+    cmd = [
+        "ffmpeg",
+        "-y",
+        "-hide_banner",
+        "-loglevel",
+        "error",
+        "-f",
+        "lavfi",
+        "-i",
+        f"testsrc=duration={duration_seconds}:size=32x32:rate=10",
+        "-preset",
+        "ultrafast",
+        "-pix_fmt",
+        "yuv420p",
+        str(dest),
+    ]
+    result = subprocess.run(cmd, capture_output=True, check=False)
+    if result.returncode != 0:
+        pytest.fail(f"ffmpeg fixture build failed (exit {result.returncode}): {result.stderr.decode()[:300]}")
+
+
+@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
+async def test_extracts_jpeg_from_real_mp4(tmp_path: Path):
+    src = tmp_path / "synthetic.mp4"
+    _make_synthetic_mp4(src)
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is True
+    assert out.exists()
+    assert out.stat().st_size > 0
+    # JPEG starts with the SOI marker (FFD8). Lightweight sanity check —
+    # we'd otherwise depend on Pillow just to decode.
+    assert out.read_bytes()[:2] == b"\xff\xd8"
+
+
+@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
+async def test_extracts_correctly_from_sub_second_video(tmp_path: Path):
+    """Regression for #1397 round 1: small prints (few layers) produce
+    sub-second Bambu timelapses (~0.6s / 16 frames). The earlier
+    ``-sseof -1.0`` approach seeked 1 second before end → before the
+    start of the file → ffmpeg silently returned frame 0. Verify the
+    write-every-frame-overwrite approach grabs a real frame regardless
+    of duration."""
+    src = tmp_path / "short.mp4"
+    _make_synthetic_mp4(src, duration_seconds=0.5)  # 5 frames at 10fps
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is True
+    assert out.exists()
+    assert out.stat().st_size > 0
+    assert out.read_bytes()[:2] == b"\xff\xd8"
+
+
+async def test_returns_false_when_source_missing(tmp_path: Path):
+    src = tmp_path / "does_not_exist.mp4"
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    assert not out.exists()
+
+
+async def test_returns_false_when_source_empty(tmp_path: Path):
+    src = tmp_path / "empty.mp4"
+    src.touch()
+    out = tmp_path / "out.jpg"
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    assert not out.exists()
+
+
+async def test_returns_false_when_ffmpeg_unavailable(tmp_path: Path):
+    src = tmp_path / "any.mp4"
+    src.write_bytes(b"\x00" * 100)
+    out = tmp_path / "out.jpg"
+
+    # Force the lookup path to return None — same shape as a host without
+    # ffmpeg installed. We don't want to be skipped on CI here; the
+    # not-installed path is a real production fallback and must be tested.
+    with patch("backend.app.services.camera.get_ffmpeg_path", return_value=None):
+        ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    assert not out.exists()
+
+
+async def test_returns_false_when_ffmpeg_exits_nonzero(tmp_path: Path):
+    """ffmpeg failures (corrupt file, codec issue, etc.) return False, not
+    raise. The caller falls through to the existing live-camera path."""
+    src = tmp_path / "garbage.mp4"
+    src.write_bytes(b"not actually an mp4" * 100)
+    out = tmp_path / "out.jpg"
+
+    # Use a real ffmpeg invocation on garbage — guaranteed to fail with a
+    # non-zero exit code without us monkey-patching subprocess.
+    if not _HAS_FFMPEG:
+        pytest.skip("ffmpeg not on PATH; cannot exercise real failure path")
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False
+    # ffmpeg may briefly touch the output file before failing; we don't
+    # require the file to be absent, only that the function reported failure
+    # so the caller falls back.
+
+
+async def test_returns_false_on_subprocess_timeout(tmp_path: Path, monkeypatch):
+    """A hung ffmpeg (network FS, bad codec, kernel bug) must not block the
+    finish-photo task forever. Patch ffmpeg to a sleep command that never
+    finishes — confirms the timeout path kills the subprocess."""
+    src = tmp_path / "stub.mp4"
+    src.write_bytes(b"\x00" * 100)
+    out = tmp_path / "out.jpg"
+
+    sleep_path = shutil.which("sleep")
+    if not sleep_path:
+        pytest.skip("sleep binary not available")
+
+    # Point get_ffmpeg_path at a real binary that never exits in 15s.
+    monkeypatch.setattr("backend.app.services.camera.get_ffmpeg_path", lambda: sleep_path)
+    # Tighten the timeout via monkeypatch on asyncio.wait_for to keep the
+    # test fast — patch only inside the call so we don't affect the harness.
+    real_wait_for = asyncio.wait_for
+
+    async def short_wait_for(awaitable, timeout):
+        return await real_wait_for(awaitable, timeout=0.5)
+
+    monkeypatch.setattr("backend.app.services.camera.asyncio.wait_for", short_wait_for)
+
+    ok = await extract_video_last_frame(src, out)
+
+    assert ok is False

+ 174 - 0
backend/tests/unit/test_finish_photo_from_timelapse.py

@@ -0,0 +1,174 @@
+"""Tests for _capture_finish_photo_from_timelapse (#1397).
+
+The polling helper runs in parallel with _scan_for_timelapse_with_retries —
+it waits for archive.timelapse_path to land in the DB, then extracts the
+last frame as the finish photo. These tests exercise the four shapes the
+helper has to handle correctly:
+
+  1. timelapse never lands within timeout → return None (caller falls back)
+  2. timelapse lands, extraction succeeds → return filename
+  3. timelapse lands, extraction fails → return None (caller falls back)
+  4. timelapse_path is set but the file doesn't exist on disk → keep polling
+
+DB access is patched at the session-maker boundary so these tests run in
+~50ms each without standing up a real engine.
+"""
+
+from contextlib import asynccontextmanager
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.main import _capture_finish_photo_from_timelapse
+
+
+@asynccontextmanager
+async def _fake_session(archive):
+    """A fake session whose execute().scalar_one_or_none() returns `archive`.
+
+    `archive` is mutated by the test mid-poll to simulate the real flow:
+    the timelapse-attach background task setting `timelapse_path` after a
+    few poll cycles.
+    """
+    result = SimpleNamespace(scalar_one_or_none=lambda: archive)
+    session = SimpleNamespace(execute=AsyncMock(return_value=result))
+    yield session
+
+
+@pytest.fixture
+def fake_archive():
+    """Mutable archive stand-in. Tests flip `.timelapse_path` to simulate
+    the timelapse-attach task writing to the DB."""
+    return SimpleNamespace(id=42, timelapse_path=None)
+
+
+@pytest.fixture(autouse=True)
+def _fast_poll(monkeypatch):
+    """Shrink poll interval + timeout so tests don't sleep for real."""
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.01)
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 0.2)
+
+
+@pytest.fixture
+def patched_session(fake_archive, monkeypatch):
+    """Patch main.async_session so the helper reads our fake archive."""
+    monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_archive))
+    return fake_archive
+
+
+async def test_returns_none_when_timelapse_never_lands(tmp_path: Path, patched_session):
+    """Print finished without a timelapse — bail after timeout so the caller
+    falls back to the live-camera grab."""
+    result = await _capture_finish_photo_from_timelapse(
+        archive_id=42,
+        archive_dir=tmp_path,
+    )
+    assert result is None
+
+
+async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_session, monkeypatch):
+    """Simulate the timelapse landing after one poll cycle and extraction
+    succeeding — should return a filename matching the finish_*.jpg pattern."""
+    # Lay down a stub timelapse file relative to base_dir so the path
+    # join works the way the helper expects.
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    video_abspath = tmp_path / video_relpath
+    video_abspath.parent.mkdir(parents=True, exist_ok=True)
+    video_abspath.write_bytes(b"x" * 100)  # non-empty so the size check passes
+
+    # Patch extraction to succeed unconditionally — the actual ffmpeg
+    # codepath has its own test file.
+    async def fake_extract(src, dst):
+        dst.write_bytes(b"\xff\xd8" + b"\x00" * 50)  # JPEG SOI
+        return True
+
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
+
+    # Flip the archive into the "timelapse landed" state before the first
+    # poll — the helper picks it up on its initial read.
+    patched_session.timelapse_path = str(video_relpath)
+
+    with patch(
+        "backend.app.services.camera.extract_video_last_frame",
+        new=fake_extract,
+    ):
+        result = await _capture_finish_photo_from_timelapse(
+            archive_id=42,
+            archive_dir=tmp_path / "archive_dir",
+        )
+
+    assert result is not None
+    assert result.startswith("finish_")
+    assert result.endswith(".jpg")
+    assert (tmp_path / "archive_dir" / "photos" / result).exists()
+
+
+async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_session, monkeypatch):
+    """Timelapse landed but ffmpeg said no — we don't keep retrying on the
+    same broken file; return None so the caller falls back."""
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    video_abspath = tmp_path / video_relpath
+    video_abspath.parent.mkdir(parents=True, exist_ok=True)
+    video_abspath.write_bytes(b"x" * 100)
+
+    async def fake_extract_fails(src, dst):
+        return False
+
+    patched_session.timelapse_path = str(video_relpath)
+
+    with patch(
+        "backend.app.services.camera.extract_video_last_frame",
+        new=fake_extract_fails,
+    ):
+        result = await _capture_finish_photo_from_timelapse(
+            archive_id=42,
+            archive_dir=tmp_path / "archive_dir",
+        )
+
+    assert result is None
+
+
+async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeypatch):
+    """timelapse_path is set, but the file isn't on disk yet (the attach
+    background task hasn't finished writing). Should keep polling — and
+    succeed once the file materialises."""
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.05)
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 1.0)
+
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    patched_session.timelapse_path = str(video_relpath)
+
+    # File not present yet. Schedule it to land after ~150ms.
+    import asyncio
+
+    async def materialise_later():
+        await asyncio.sleep(0.15)
+        video_abspath = tmp_path / video_relpath
+        video_abspath.parent.mkdir(parents=True, exist_ok=True)
+        video_abspath.write_bytes(b"x" * 100)
+
+    async def fake_extract(src, dst):
+        dst.write_bytes(b"\xff\xd8")
+        return True
+
+    materialise = asyncio.create_task(materialise_later())
+    try:
+        with patch(
+            "backend.app.services.camera.extract_video_last_frame",
+            new=fake_extract,
+        ):
+            result = await _capture_finish_photo_from_timelapse(
+                archive_id=42,
+                archive_dir=tmp_path / "archive_dir",
+            )
+    finally:
+        materialise.cancel()
+
+    assert result is not None
+    assert result.startswith("finish_")

+ 71 - 0
backend/tests/unit/test_scheduler_force_timelapse_wiring.py

@@ -0,0 +1,71 @@
+"""Regression test for the print-queue path of the #1397 force-timelapse fix.
+
+The first round of #1397 only wired the override into ``background_dispatch.py``,
+which covers Print Now / Reprint Now flows. The print *queue* uses a separate
+scheduler at ``print_scheduler.py:_start_print`` that calls
+``printer_manager.start_print`` directly — and the first attempt skipped that
+call site, so queued prints' timelapse setting passed through unchanged and
+the finish-photo path had nothing to draw from. Field-test caught this when
+Martin queued two prints (H2D + X1C); neither got a forced timelapse and
+``archive.bambuddy_forced_timelapse`` stayed False on both.
+
+This test pins the wiring at the source level: the helper is imported AND
+its return value is what ``start_print(timelapse=...)`` receives. We can't
+exercise the full ``_start_print`` method without standing up a real DB +
+printer_manager + ams_assignment fixture stack, but the structural assert
+is enough to catch regression at the dispatch hook.
+"""
+
+import ast
+from pathlib import Path
+
+SCHEDULER_PATH = Path(__file__).resolve().parent.parent.parent / "app" / "services" / "print_scheduler.py"
+
+
+def _find_call_to_start_print(tree: ast.AST) -> ast.Call:
+    """Walk the AST and return the printer_manager.start_print(...) Call node
+    inside _start_print. Should be exactly one."""
+    for node in ast.walk(tree):
+        if not isinstance(node, ast.Call):
+            continue
+        func = node.func
+        if not isinstance(func, ast.Attribute):
+            continue
+        if func.attr != "start_print":
+            continue
+        value = func.value
+        if not isinstance(value, ast.Name) or value.id != "printer_manager":
+            continue
+        return node
+    raise AssertionError("Could not find printer_manager.start_print(...) call in print_scheduler.py")
+
+
+def test_start_print_timelapse_kwarg_uses_resolved_value():
+    """``timelapse=`` kwarg passed to start_print must reference
+    ``effective_timelapse`` (the resolved value) — not ``item.timelapse``
+    (the user's raw choice). If a refactor drops the resolver call and
+    restores ``item.timelapse``, this test fails."""
+    source = SCHEDULER_PATH.read_text()
+    tree = ast.parse(source)
+
+    call = _find_call_to_start_print(tree)
+    timelapse_kw = next((kw for kw in call.keywords if kw.arg == "timelapse"), None)
+    assert timelapse_kw is not None, "start_print(timelapse=...) kwarg is missing"
+
+    # The value must be the resolved variable, not item.timelapse.
+    value = timelapse_kw.value
+    assert isinstance(value, ast.Name) and value.id == "effective_timelapse", (
+        f"timelapse= must be the resolver's return value (effective_timelapse), "
+        f"got {ast.dump(value)}. The queue path must apply the same #1397 "
+        f"override as background_dispatch.py — otherwise queued prints' "
+        f"finish-photo extractor has nothing to pull from."
+    )
+
+
+def test_scheduler_imports_resolve_effective_timelapse():
+    """The import must exist somewhere in print_scheduler.py — guards against
+    a future refactor removing it and falling back to item.timelapse."""
+    source = SCHEDULER_PATH.read_text()
+    assert "from backend.app.services.background_dispatch import resolve_effective_timelapse" in source, (
+        "print_scheduler.py must import resolve_effective_timelapse from background_dispatch"
+    )

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

@@ -2156,7 +2156,7 @@ export default {
     autoArchivePrints: 'Drucke automatisch archivieren',
     autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
     saveThumbnailsDescription: 'Vorschaubilder aus 3MF-Dateien extrahieren und speichern',
-    captureFinishPhotoDescription: 'Foto von der Druckerkamera aufnehmen, wenn der Druck abgeschlossen ist',
+    captureFinishPhotoDescription: 'Foto von der Druckerkamera aufnehmen, wenn der Druck abgeschlossen ist. Bambuddy zeichnet während des Drucks einen kurzen Zeitraffer auf, damit das Foto aus dem Moment vor dem Absenken der Druckplatte stammen kann. Die Zeitraffer-Datei bleibt erhalten, wenn du den Zeitraffer für diesen Druck aktiviert hast, andernfalls wird sie nach Aufnahme des Fotos automatisch gelöscht.',
     ffmpegNotInstalled: 'ffmpeg nicht installiert',
     ffmpegRequired: 'Kameraaufnahme benötigt ffmpeg. Installieren über <brew>brew install ffmpeg</brew> (macOS) oder <apt>apt install ffmpeg</apt> (Linux).',
     // Camera

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

@@ -2159,7 +2159,7 @@ export default {
     autoArchivePrints: 'Auto-archive prints',
     autoArchiveDescription: 'Automatically save 3MF files when prints complete',
     saveThumbnailsDescription: 'Extract and save preview images from 3MF files',
-    captureFinishPhotoDescription: 'Take a photo from printer camera when print completes',
+    captureFinishPhotoDescription: 'Take a photo from printer camera when print completes. Bambuddy records a brief timelapse during the print so the photo can be sourced from the moment before the bed drops; the timelapse file is kept if you enabled timelapse for this print, otherwise it is deleted automatically after the photo is captured.',
     ffmpegNotInstalled: 'ffmpeg not installed',
     ffmpegRequired: 'Camera capture requires ffmpeg. Install it via <brew>brew install ffmpeg</brew> (macOS) or <apt>apt install ffmpeg</apt> (Linux).',
     // Camera

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

@@ -2159,7 +2159,7 @@ export default {
     autoArchivePrints: 'Archivar impresiones automáticamente',
     autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
     saveThumbnailsDescription: 'Extraer y guardar imágenes de vista previa de los archivos 3MF',
-    captureFinishPhotoDescription: 'Tomar una foto desde la cámara de la impresora cuando se completa la impresión',
+    captureFinishPhotoDescription: 'Tomar una foto desde la cámara de la impresora cuando se completa la impresión. Bambuddy graba un breve timelapse durante la impresión para que la foto pueda obtenerse del momento previo al descenso de la cama; el archivo del timelapse se conserva si activaste el timelapse para esta impresión, de lo contrario se elimina automáticamente tras capturar la foto.',
     ffmpegNotInstalled: 'ffmpeg no instalado',
     ffmpegRequired: 'La captura de cámara requiere ffmpeg. Instálelo mediante <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
     // Camera

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

@@ -2110,7 +2110,7 @@ export default {
     autoArchivePrints: 'Archiver automatiquement les impressions',
     autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
     saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
-    captureFinishPhotoDescription: 'Prendre une photo avec la caméra de l\'imprimante à la fin de l\'impression',
+    captureFinishPhotoDescription: 'Prendre une photo avec la caméra de l\'imprimante à la fin de l\'impression. Bambuddy enregistre un court timelapse pendant l\'impression afin que la photo puisse provenir du moment précédant l\'abaissement du plateau ; le fichier du timelapse est conservé si vous avez activé le timelapse pour cette impression, sinon il est supprimé automatiquement après la capture de la photo.',
     ffmpegNotInstalled: 'ffmpeg non installé',
     ffmpegRequired: 'La capture caméra nécessite ffmpeg. Installez-le via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
     camera: 'Caméra',

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

@@ -2109,7 +2109,7 @@ export default {
     autoArchivePrints: 'Archiviazione automatica stampe',
     autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
     saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
-    captureFinishPhotoDescription: 'Scatta una foto dalla fotocamera della stampante al completamento della stampa',
+    captureFinishPhotoDescription: 'Scatta una foto dalla fotocamera della stampante al completamento della stampa. Bambuddy registra un breve timelapse durante la stampa in modo che la foto possa essere ricavata dal momento precedente all\'abbassamento del piatto; il file del timelapse viene mantenuto se hai abilitato il timelapse per questa stampa, altrimenti viene eliminato automaticamente dopo l\'acquisizione della foto.',
     ffmpegNotInstalled: 'ffmpeg non installato',
     ffmpegRequired: 'L\'acquisizione dalla fotocamera richiede ffmpeg. Installalo tramite <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
     camera: 'Fotocamera',

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

@@ -2155,7 +2155,7 @@ export default {
     autoArchivePrints: '印刷を自動アーカイブ',
     autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
     saveThumbnailsDescription: '3MFファイルからプレビュー画像を抽出して保存',
-    captureFinishPhotoDescription: '印刷完了時にプリンターカメラから写真を撮影',
+    captureFinishPhotoDescription: '印刷完了時にプリンターカメラから写真を撮影します。Bambuddy は印刷中に短いタイムラプスを記録し、ベッドが下がる前の瞬間から写真を取得できるようにします。この印刷でタイムラプスを有効にしていた場合はタイムラプスファイルが保存され、それ以外の場合は写真の取得後に自動的に削除されます。',
     ffmpegNotInstalled: 'ffmpegがインストールされていません',
     ffmpegRequired: 'カメラ撮影にはffmpegが必要です。<brew>brew install ffmpeg</brew>(macOS)または<apt>apt install ffmpeg</apt>(Linux)でインストールしてください。',
     // Camera

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

@@ -2026,7 +2026,7 @@ export default {
     autoArchivePrints: '인쇄 자동 아카이브',
     autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
     saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
-    captureFinishPhotoDescription: '인쇄 완료 시 프린터 카메라로 사진 촬영',
+    captureFinishPhotoDescription: '인쇄 완료 시 프린터 카메라로 사진 촬영. Bambuddy는 인쇄 중 짧은 타임랩스를 기록하여 베드가 내려가기 전 순간에서 사진을 가져올 수 있도록 합니다. 이 인쇄에 대해 타임랩스를 활성화한 경우 타임랩스 파일이 보관되며, 그렇지 않으면 사진 촬영 후 자동으로 삭제됩니다.',
     ffmpegNotInstalled: 'ffmpeg 미설치',
     ffmpegRequired: '카메라 캡처에 ffmpeg가 필요합니다. macOS에서는 <brew>brew install ffmpeg</brew>, Linux에서는 <apt>apt install ffmpeg</apt>로 설치하세요.',
     camera: '카메라',

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

@@ -2109,7 +2109,7 @@ export default {
     autoArchivePrints: 'Arquivar impressões automaticamente',
     autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
     saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
-    captureFinishPhotoDescription: 'Tirar foto da câmera da impressora quando a impressão for concluída',
+    captureFinishPhotoDescription: 'Tirar foto da câmera da impressora quando a impressão for concluída. Bambuddy grava um timelapse curto durante a impressão para que a foto possa ser obtida do momento antes da mesa descer; o arquivo do timelapse é mantido se você habilitou o timelapse para esta impressão, caso contrário ele é excluído automaticamente após a captura da foto.',
     ffmpegNotInstalled: 'ffmpeg não instalado',
     ffmpegRequired: 'A captura de câmera requer ffmpeg. Instale via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
     camera: 'Câmera',

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

@@ -2159,7 +2159,7 @@ export default {
     autoArchivePrints: 'Baskıları otomatik arşivle',
     autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
     saveThumbnailsDescription: '3MF dosyalarından önizleme görüntülerini çıkar ve kaydet',
-    captureFinishPhotoDescription: 'Baskı tamamlandığında yazıcı kamerasından bir fotoğraf çek',
+    captureFinishPhotoDescription: 'Baskı tamamlandığında yazıcı kamerasından bir fotoğraf çek. Bambuddy, baskı sırasında kısa bir zaman atlamalı kayıt yapar, böylece fotoğraf tabla inmeden önceki andan alınabilir. Bu baskı için zaman atlamalı kaydı etkinleştirdiyseniz dosya saklanır, aksi takdirde fotoğraf çekildikten sonra otomatik olarak silinir.',
     ffmpegNotInstalled: 'ffmpeg yüklü değil',
     ffmpegRequired: 'Kamera yakalama ffmpeg gerektirir. <brew>brew install ffmpeg</brew> (macOS) veya <apt>apt install ffmpeg</apt> (Linux) ile yükleyin.',
     // Kamera

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

@@ -2154,7 +2154,7 @@ export default {
     autoArchivePrints: '自动归档打印',
     autoArchiveDescription: '打印完成时自动保存3MF文件',
     saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
-    captureFinishPhotoDescription: '打印完成时从打印机摄像头拍照',
+    captureFinishPhotoDescription: '打印完成时从打印机摄像头拍照。Bambuddy 会在打印期间录制一段短延时摄影,以便从热床下降前的瞬间获取照片;如果您为本次打印启用了延时摄影,文件将保留,否则会在拍照完成后自动删除。',
     ffmpegNotInstalled: '未安装ffmpeg',
     ffmpegRequired: '摄像头捕获需要ffmpeg。通过 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安装。',
     camera: '摄像头',

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

@@ -2154,7 +2154,7 @@ export default {
     autoArchivePrints: '自動歸檔列印',
     autoArchiveDescription: '列印完成時自動儲存3MF檔案',
     saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
-    captureFinishPhotoDescription: '列印完成時從印表機攝影機拍照',
+    captureFinishPhotoDescription: '列印完成時從印表機攝影機拍照。Bambuddy 會在列印期間錄製一段短縮時攝影,以便從熱床下降前的瞬間取得照片;如果您為本次列印啟用了縮時攝影,檔案將保留,否則會在拍照完成後自動刪除。',
     ffmpegNotInstalled: '未安裝ffmpeg',
     ffmpegRequired: '攝影機捕獲需要ffmpeg。透過 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安裝。',
     camera: '攝影機',

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


+ 1 - 1
static/index.html

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

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