layer_timelapse.py 8.7 KB

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