layer_timelapse.py 11 KB

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