|
|
@@ -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:
|