layer_timelapse.py 8.8 KB

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