layer_timelapse.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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. from dataclasses import dataclass, field
  8. from datetime import datetime
  9. from pathlib import Path
  10. from backend.app.core.config import settings
  11. from backend.app.services.external_camera import capture_frame
  12. logger = logging.getLogger(__name__)
  13. # Active timelapse sessions: {printer_id: TimelapseSession}
  14. _active_sessions: dict[int, "TimelapseSession"] = {}
  15. def get_ffmpeg_path() -> str | None:
  16. """Get the path to ffmpeg executable."""
  17. # Try shutil.which first
  18. path = shutil.which("ffmpeg")
  19. if path:
  20. return path
  21. # Check common locations (systemd services may have limited PATH)
  22. for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
  23. if Path(common_path).exists():
  24. return common_path
  25. return None
  26. @dataclass
  27. class TimelapseSession:
  28. """Active timelapse recording session."""
  29. printer_id: int
  30. archive_id: int | None
  31. camera_url: str
  32. camera_type: str
  33. snapshot_url: str | None = None # Optional single-frame override; #1177
  34. last_layer: int = -1
  35. frame_count: int = 0
  36. session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
  37. frames_dir: Path = field(init=False)
  38. def __post_init__(self):
  39. self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
  40. self.frames_dir.mkdir(parents=True, exist_ok=True)
  41. logger.info("Created timelapse session %s for printer %s", self.session_id, self.printer_id)
  42. async def capture_layer(self, layer_num: int) -> bool:
  43. """Capture frame if layer changed.
  44. Args:
  45. layer_num: Current layer number from printer
  46. Returns:
  47. True if frame was captured, False otherwise
  48. """
  49. # Only capture if layer increased
  50. if layer_num <= self.last_layer:
  51. return False
  52. self.last_layer = layer_num
  53. try:
  54. # Reuse the live view's frame instead of opening a second handle on
  55. # a single-reader device (#2707). Unguarded, a print watched from
  56. # start to finish recorded zero successful layer captures, and the
  57. # stitched video came out empty or badly truncated.
  58. from backend.app.api.routes.camera import live_frame_for_capture
  59. defer, buffered = live_frame_for_capture(self.printer_id)
  60. if defer:
  61. if not buffered:
  62. # Viewer attached but nothing buffered yet: skip this layer
  63. # rather than compete and kick them off (#1348).
  64. logger.debug(
  65. "Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
  66. layer_num,
  67. self.printer_id,
  68. )
  69. return False
  70. frame_data = buffered
  71. else:
  72. frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
  73. if frame_data:
  74. frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
  75. await asyncio.to_thread(frame_path.write_bytes, frame_data)
  76. self.frame_count += 1
  77. logger.debug(
  78. "Captured layer %s for printer %s (frame %s)", layer_num, self.printer_id, self.frame_count
  79. )
  80. return True
  81. else:
  82. logger.warning("Failed to capture frame for layer %s", layer_num)
  83. return False
  84. except Exception as e:
  85. logger.error("Error capturing timelapse frame: %s", e)
  86. return False
  87. async def stitch(self, output_path: Path, fps: int = 30) -> bool:
  88. """Create MP4 from captured frames using ffmpeg.
  89. Args:
  90. output_path: Path for output video file
  91. fps: Frames per second for output video
  92. Returns:
  93. True if stitching succeeded, False otherwise
  94. """
  95. if self.frame_count == 0:
  96. logger.warning("No frames to stitch")
  97. return False
  98. ffmpeg = get_ffmpeg_path()
  99. if not ffmpeg:
  100. logger.error("ffmpeg not found - required for timelapse stitching")
  101. return False
  102. # Find all frame files and create a sequential list
  103. # This handles gaps in layer numbers (e.g., if some captures failed)
  104. frame_files = sorted(self.frames_dir.glob("layer_*.jpg"))
  105. if not frame_files:
  106. logger.warning("No frame files found in timelapse directory")
  107. return False
  108. # Create a concat file listing all frames
  109. concat_file = self.frames_dir / "frames.txt"
  110. try:
  111. with open(concat_file, "w") as f:
  112. for frame in frame_files:
  113. # Each frame shown for 1/fps duration
  114. f.write(f"file '{frame.name}'\n")
  115. f.write(f"duration {1.0 / fps}\n")
  116. # Add last frame again (required by concat demuxer)
  117. if frame_files:
  118. f.write(f"file '{frame_files[-1].name}'\n")
  119. except Exception as e:
  120. logger.error("Failed to create concat file: %s", e)
  121. return False
  122. # Use ffmpeg concat demuxer for variable-gap frame sequences
  123. cmd = [
  124. ffmpeg,
  125. "-y", # Overwrite output
  126. "-f",
  127. "concat",
  128. "-safe",
  129. "0",
  130. "-i",
  131. str(concat_file),
  132. "-c:v",
  133. "libx264",
  134. "-pix_fmt",
  135. "yuv420p",
  136. "-preset",
  137. "medium",
  138. "-crf",
  139. "23",
  140. str(output_path),
  141. ]
  142. try:
  143. process = await asyncio.create_subprocess_exec(
  144. *cmd,
  145. stdout=asyncio.subprocess.PIPE,
  146. stderr=asyncio.subprocess.PIPE,
  147. cwd=str(self.frames_dir), # Run in frames dir so relative paths work
  148. )
  149. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
  150. if process.returncode != 0:
  151. logger.error("ffmpeg timelapse stitch failed: %s", stderr.decode()[:500])
  152. return False
  153. logger.info("Created timelapse video: %s (%s frames)", output_path, self.frame_count)
  154. return True
  155. except TimeoutError:
  156. logger.error("Timelapse stitching timed out")
  157. if process:
  158. process.kill()
  159. return False
  160. except Exception as e:
  161. logger.error("Timelapse stitch failed: %s", e)
  162. return False
  163. def cleanup(self):
  164. """Remove temporary frames directory."""
  165. try:
  166. if self.frames_dir.exists():
  167. shutil.rmtree(self.frames_dir, ignore_errors=True)
  168. logger.info("Cleaned up timelapse frames for session %s", self.session_id)
  169. except Exception as e:
  170. logger.warning("Failed to cleanup timelapse frames: %s", e)
  171. def start_session(
  172. printer_id: int,
  173. archive_id: int | None,
  174. url: str,
  175. cam_type: str,
  176. snapshot_url: str | None = None,
  177. ) -> TimelapseSession:
  178. """Start new timelapse session for a printer.
  179. Args:
  180. printer_id: The printer ID
  181. archive_id: Associated print archive ID (optional)
  182. url: External camera URL
  183. cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
  184. snapshot_url: Optional single-frame URL override; when set, layer captures
  185. fetch from it directly instead of opening the live stream. #1177.
  186. Returns:
  187. The new TimelapseSession
  188. """
  189. # Cancel any existing session
  190. cancel_session(printer_id)
  191. session = TimelapseSession(
  192. printer_id=printer_id,
  193. archive_id=archive_id,
  194. camera_url=url,
  195. camera_type=cam_type,
  196. snapshot_url=snapshot_url,
  197. )
  198. _active_sessions[printer_id] = session
  199. logger.info("Started timelapse session for printer %s", printer_id)
  200. return session
  201. def get_session(printer_id: int) -> TimelapseSession | None:
  202. """Get active timelapse session for a printer."""
  203. return _active_sessions.get(printer_id)
  204. async def on_layer_change(printer_id: int, layer_num: int):
  205. """Called on layer change - captures frame if session active.
  206. Args:
  207. printer_id: The printer ID
  208. layer_num: Current layer number
  209. """
  210. session = get_session(printer_id)
  211. if session:
  212. await session.capture_layer(layer_num)
  213. async def on_print_complete(printer_id: int) -> Path | None:
  214. """Stitch timelapse and return path. Cleans up session.
  215. Args:
  216. printer_id: The printer ID
  217. Returns:
  218. Path to stitched video, or None if no session or stitching failed
  219. """
  220. session = _active_sessions.pop(printer_id, None)
  221. if not session:
  222. return None
  223. if session.frame_count == 0:
  224. logger.info("No timelapse frames captured for printer %s", printer_id)
  225. session.cleanup()
  226. return None
  227. # Create output path in parent of frames dir
  228. output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
  229. try:
  230. success = await session.stitch(output_path)
  231. if success:
  232. # Cleanup frames after successful stitch
  233. session.cleanup()
  234. return output_path
  235. else:
  236. session.cleanup()
  237. return None
  238. except Exception as e:
  239. logger.error("Timelapse completion failed: %s", e)
  240. session.cleanup()
  241. return None
  242. def cancel_session(printer_id: int):
  243. """Cancel and cleanup timelapse session (on print fail/cancel).
  244. Args:
  245. printer_id: The printer ID
  246. """
  247. session = _active_sessions.pop(printer_id, None)
  248. if session:
  249. session.cleanup()
  250. logger.info("Cancelled timelapse session for printer %s", printer_id)
  251. def get_active_sessions() -> dict[int, TimelapseSession]:
  252. """Get all active timelapse sessions."""
  253. return _active_sessions.copy()