layer_timelapse.py 9.1 KB

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