layer_timelapse.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. """Layer-based timelapse for external cameras.
  2. Captures a frame on each layer change and stitches them into a video on print completion.
  3. """
  4. import asyncio
  5. import logging
  6. import shutil
  7. import time
  8. from dataclasses import dataclass, field
  9. from datetime import datetime
  10. from pathlib import Path
  11. from backend.app.core.config import settings
  12. from backend.app.services.external_camera import capture_frame
  13. logger = logging.getLogger(__name__)
  14. # Active timelapse sessions: {printer_id: TimelapseSession}
  15. _active_sessions: dict[int, "TimelapseSession"] = {}
  16. def get_ffmpeg_path() -> str | None:
  17. """Get the path to ffmpeg executable."""
  18. # Try shutil.which first
  19. path = shutil.which("ffmpeg")
  20. if path:
  21. return path
  22. # Check common locations (systemd services may have limited PATH)
  23. for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
  24. if Path(common_path).exists():
  25. return common_path
  26. return None
  27. @dataclass
  28. class TimelapseSession:
  29. """Active timelapse recording session."""
  30. printer_id: int
  31. archive_id: int | None
  32. camera_url: str
  33. camera_type: str
  34. snapshot_url: str | None = None # Optional single-frame override; #1177
  35. last_layer: int = -1
  36. frame_count: int = 0
  37. session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
  38. frames_dir: Path = field(init=False)
  39. def __post_init__(self):
  40. self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
  41. self.frames_dir.mkdir(parents=True, exist_ok=True)
  42. logger.info("Created timelapse session %s for printer %s", self.session_id, self.printer_id)
  43. async def capture_layer(self, layer_num: int) -> bool:
  44. """Capture frame if layer changed.
  45. Args:
  46. layer_num: Current layer number from printer
  47. Returns:
  48. True if frame was captured, False otherwise
  49. """
  50. # Only capture if layer increased
  51. if layer_num <= self.last_layer:
  52. return False
  53. self.last_layer = layer_num
  54. try:
  55. # Reuse the live view's frame instead of opening a second handle on
  56. # a single-reader device (#2707). Unguarded, a print watched from
  57. # start to finish recorded zero successful layer captures, and the
  58. # stitched video came out empty or badly truncated.
  59. from backend.app.api.routes.camera import live_frame_for_capture
  60. defer, buffered = live_frame_for_capture(self.printer_id)
  61. if defer:
  62. if not buffered:
  63. # Viewer attached but nothing buffered yet: skip this layer
  64. # rather than compete and kick them off (#1348).
  65. logger.debug(
  66. "Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
  67. layer_num,
  68. self.printer_id,
  69. )
  70. return False
  71. frame_data = buffered
  72. else:
  73. frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
  74. if frame_data:
  75. frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
  76. await asyncio.to_thread(frame_path.write_bytes, frame_data)
  77. self.frame_count += 1
  78. logger.debug(
  79. "Captured layer %s for printer %s (frame %s)", layer_num, self.printer_id, self.frame_count
  80. )
  81. return True
  82. else:
  83. logger.warning("Failed to capture frame for layer %s", layer_num)
  84. return False
  85. except Exception as e:
  86. logger.error("Error capturing timelapse frame: %s", e)
  87. return False
  88. async def stitch(self, output_path: Path, fps: int = 30) -> bool:
  89. """Create MP4 from captured frames using ffmpeg.
  90. Args:
  91. output_path: Path for output video file
  92. fps: Frames per second for output video
  93. Returns:
  94. True if stitching succeeded, False otherwise
  95. """
  96. if self.frame_count == 0:
  97. logger.warning("No frames to stitch")
  98. return False
  99. ffmpeg = get_ffmpeg_path()
  100. if not ffmpeg:
  101. logger.error("ffmpeg not found - required for timelapse stitching")
  102. return False
  103. # Find all frame files and create a sequential list
  104. # This handles gaps in layer numbers (e.g., if some captures failed)
  105. frame_files = sorted(self.frames_dir.glob("layer_*.jpg"))
  106. if not frame_files:
  107. logger.warning("No frame files found in timelapse directory")
  108. return False
  109. # Create a concat file listing all frames
  110. concat_file = self.frames_dir / "frames.txt"
  111. try:
  112. with open(concat_file, "w") as f:
  113. for frame in frame_files:
  114. # Each frame shown for 1/fps duration
  115. f.write(f"file '{frame.name}'\n")
  116. f.write(f"duration {1.0 / fps}\n")
  117. # Add last frame again (required by concat demuxer)
  118. if frame_files:
  119. f.write(f"file '{frame_files[-1].name}'\n")
  120. except Exception as e:
  121. logger.error("Failed to create concat file: %s", e)
  122. return False
  123. # Use ffmpeg concat demuxer for variable-gap frame sequences
  124. cmd = [
  125. ffmpeg,
  126. "-y", # Overwrite output
  127. "-f",
  128. "concat",
  129. "-safe",
  130. "0",
  131. "-i",
  132. str(concat_file),
  133. "-c:v",
  134. "libx264",
  135. "-pix_fmt",
  136. "yuv420p",
  137. "-preset",
  138. "medium",
  139. "-crf",
  140. "23",
  141. str(output_path),
  142. ]
  143. try:
  144. process = await asyncio.create_subprocess_exec(
  145. *cmd,
  146. stdout=asyncio.subprocess.PIPE,
  147. stderr=asyncio.subprocess.PIPE,
  148. cwd=str(self.frames_dir), # Run in frames dir so relative paths work
  149. )
  150. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
  151. if process.returncode != 0:
  152. logger.error("ffmpeg timelapse stitch failed: %s", stderr.decode()[:500])
  153. return False
  154. logger.info("Created timelapse video: %s (%s frames)", output_path, self.frame_count)
  155. return True
  156. except TimeoutError:
  157. logger.error("Timelapse stitching timed out")
  158. if process:
  159. process.kill()
  160. return False
  161. except Exception as e:
  162. logger.error("Timelapse stitch failed: %s", e)
  163. return False
  164. def cleanup(self):
  165. """Remove temporary frames directory."""
  166. try:
  167. if self.frames_dir.exists():
  168. shutil.rmtree(self.frames_dir, ignore_errors=True)
  169. logger.info("Cleaned up timelapse frames for session %s", self.session_id)
  170. except Exception as e:
  171. logger.warning("Failed to cleanup timelapse frames: %s", e)
  172. def start_session(
  173. printer_id: int,
  174. archive_id: int | None,
  175. url: str,
  176. cam_type: str,
  177. snapshot_url: str | None = None,
  178. ) -> TimelapseSession:
  179. """Start new timelapse session for a printer.
  180. Args:
  181. printer_id: The printer ID
  182. archive_id: Associated print archive ID (optional)
  183. url: External camera URL
  184. cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
  185. snapshot_url: Optional single-frame URL override; when set, layer captures
  186. fetch from it directly instead of opening the live stream. #1177.
  187. Returns:
  188. The new TimelapseSession
  189. """
  190. # Cancel any existing session
  191. cancel_session(printer_id)
  192. session = TimelapseSession(
  193. printer_id=printer_id,
  194. archive_id=archive_id,
  195. camera_url=url,
  196. camera_type=cam_type,
  197. snapshot_url=snapshot_url,
  198. )
  199. _active_sessions[printer_id] = session
  200. logger.info("Started timelapse session for printer %s", printer_id)
  201. return session
  202. def get_session(printer_id: int) -> TimelapseSession | None:
  203. """Get active timelapse session for a printer."""
  204. return _active_sessions.get(printer_id)
  205. async def on_layer_change(printer_id: int, layer_num: int):
  206. """Called on layer change - captures frame if session active.
  207. Args:
  208. printer_id: The printer ID
  209. layer_num: Current layer number
  210. """
  211. session = get_session(printer_id)
  212. if session:
  213. await session.capture_layer(layer_num)
  214. async def on_print_complete(printer_id: int) -> Path | None:
  215. """Stitch timelapse and return path. Cleans up session.
  216. Args:
  217. printer_id: The printer ID
  218. Returns:
  219. Path to stitched video, or None if no session or stitching failed
  220. """
  221. session = _active_sessions.pop(printer_id, None)
  222. if not session:
  223. return None
  224. if session.frame_count == 0:
  225. logger.info("No timelapse frames captured for printer %s", printer_id)
  226. session.cleanup()
  227. return None
  228. # Create output path in parent of frames dir
  229. output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
  230. try:
  231. success = await session.stitch(output_path)
  232. if success:
  233. # Cleanup frames after successful stitch
  234. session.cleanup()
  235. return output_path
  236. else:
  237. session.cleanup()
  238. return None
  239. except Exception as e:
  240. logger.error("Timelapse completion failed: %s", e)
  241. session.cleanup()
  242. return None
  243. def cancel_session(printer_id: int):
  244. """Cancel and cleanup timelapse session (on print fail/cancel).
  245. Args:
  246. printer_id: The printer ID
  247. """
  248. session = _active_sessions.pop(printer_id, None)
  249. if session:
  250. session.cleanup()
  251. logger.info("Cancelled timelapse session for printer %s", printer_id)
  252. def get_active_sessions() -> dict[int, TimelapseSession]:
  253. """Get all active timelapse sessions."""
  254. return _active_sessions.copy()
  255. def cleanup_orphaned_timelapse_sessions(min_age_seconds: float = 300) -> int:
  256. """Remove timelapse_frames/<printer_id>/* left behind by a crash or
  257. restart that happened while a session was active.
  258. _active_sessions is in-memory only, so a process restart loses track of
  259. any in-flight session without ever calling cancel_session()/cleanup() -
  260. the frames directory (and, if stitching had already produced output
  261. before the restart, a stray `timelapse_<session_id>.mp4`) are then
  262. orphaned on disk with nothing else to reap them (unlike the ffmpeg
  263. orphan janitor in routes/camera.py, there was no equivalent here).
  264. Safe to call once at startup: normal operation always cleans up via
  265. on_print_complete/cancel_session, so anything found here predates this
  266. process - and a restart-recovered print doesn't get a new timelapse
  267. session either (`_maybe_start_layer_timelapse` is only wired into fresh
  268. PRINT_START events, see #1353), so an orphaned directory can never be
  269. resumed. `min_age_seconds` is just a defensive margin against reordering
  270. if this is ever also called mid-run.
  271. Returns the number of orphaned directories/files removed.
  272. """
  273. base_dir = settings.base_dir / "timelapse_frames"
  274. if not base_dir.exists():
  275. return 0
  276. now = time.time()
  277. removed = 0
  278. for printer_dir in base_dir.iterdir():
  279. if not printer_dir.is_dir():
  280. continue
  281. try:
  282. printer_id = int(printer_dir.name)
  283. except ValueError:
  284. continue
  285. active_session = _active_sessions.get(printer_id)
  286. active_session_id = active_session.session_id if active_session else None
  287. for entry in printer_dir.iterdir():
  288. # Frame dirs are named "<session_id>/"; stitched-but-not-yet-
  289. # attached output files are "timelapse_<session_id>.mp4" (see
  290. # on_print_complete's output_path).
  291. entry_session_id = entry.name.removeprefix("timelapse_").removesuffix(".mp4") if entry.is_file() else entry.name
  292. if entry_session_id == active_session_id:
  293. continue
  294. try:
  295. if now - entry.stat().st_mtime < min_age_seconds:
  296. continue
  297. except OSError:
  298. continue
  299. try:
  300. if entry.is_dir():
  301. shutil.rmtree(entry, ignore_errors=True)
  302. else:
  303. entry.unlink(missing_ok=True)
  304. removed += 1
  305. logger.info("Removed orphaned timelapse artifact: %s", entry)
  306. except OSError as e:
  307. logger.warning("Failed to remove orphaned timelapse artifact %s: %s", entry, e)
  308. return removed