layer_timelapse.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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 contextlib
  6. import logging
  7. import shutil
  8. import time
  9. from dataclasses import dataclass, field
  10. from datetime import datetime
  11. from pathlib import Path
  12. from backend.app.core.config import settings
  13. from backend.app.services.camera import apply_camera_rotation
  14. from backend.app.services.external_camera import capture_frame
  15. from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
  16. logger = logging.getLogger(__name__)
  17. # Active timelapse sessions: {printer_id: TimelapseSession}
  18. _active_sessions: dict[int, "TimelapseSession"] = {}
  19. # Sessions whose frames are being stitched right now: {printer_id: session_id}.
  20. # on_print_complete removes the session from _active_sessions *before* handing
  21. # frames_dir to ffmpeg, so for the length of a stitch (up to an hour, see
  22. # _stitch_timeout) nothing in _active_sessions marks that directory as in use.
  23. # Without this second registry the only thing standing between an in-progress
  24. # stitch and cleanup_orphaned_timelapse_sessions() is the age margin, which is
  25. # far shorter than a long stitch.
  26. # A set per printer: a short print can finish while a long one still stitches.
  27. _finalizing_sessions: dict[int, set[str]] = {}
  28. # Stitch deadline: a libx264 encode of every frame takes about this long per
  29. # frame on a Raspberry Pi, never less than 5 minutes and never more than an
  30. # hour. A flat 300 s lost the timelapse of any print with more than a few
  31. # hundred layers on slow hardware.
  32. _STITCH_SECONDS_PER_FRAME = 0.5
  33. _STITCH_MIN_SECONDS = 300.0
  34. _STITCH_MAX_SECONDS = 3600.0
  35. # A stitch that fails keeps its frames, marked with this file, so the video can
  36. # still be made from them; the orphan janitor leaves them for this long.
  37. KEPT_FRAMES_MARKER = "STITCH_FAILED"
  38. KEEP_FAILED_FRAMES_SECONDS = 24 * 60 * 60
  39. def _stitch_timeout(frame_count: int) -> float:
  40. return min(max(_STITCH_MIN_SECONDS, frame_count * _STITCH_SECONDS_PER_FRAME), _STITCH_MAX_SECONDS)
  41. def get_ffmpeg_path() -> str | None:
  42. """Get the path to ffmpeg executable."""
  43. # Try shutil.which first
  44. path = shutil.which("ffmpeg")
  45. if path:
  46. return path
  47. # Check common locations (systemd services may have limited PATH)
  48. for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
  49. if Path(common_path).exists():
  50. return common_path
  51. return None
  52. @dataclass
  53. class TimelapseSession:
  54. """Active timelapse recording session."""
  55. printer_id: int
  56. archive_id: int | None
  57. camera_url: str
  58. camera_type: str
  59. snapshot_url: str | None = None # Optional single-frame override; #1177
  60. rotation: int = 0 # Printer's configured camera_rotation, degrees clockwise
  61. last_layer: int = -1
  62. frame_count: int = 0
  63. session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
  64. frames_dir: Path = field(init=False)
  65. # Set once ffmpeg has been started on the frames: only a stitch that got
  66. # that far and failed keeps them. Without ffmpeg, or without frames, there
  67. # is nothing a later attempt could do differently.
  68. ffmpeg_ran: bool = field(default=False, init=False)
  69. def __post_init__(self):
  70. self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
  71. self.frames_dir.mkdir(parents=True, exist_ok=True)
  72. logger.info("Created timelapse session %s for printer %s", self.session_id, self.printer_id)
  73. async def capture_layer(self, layer_num: int) -> bool:
  74. """Capture frame if layer changed.
  75. Args:
  76. layer_num: Current layer number from printer
  77. Returns:
  78. True if frame was captured, False otherwise
  79. """
  80. # Only capture if layer increased
  81. if layer_num <= self.last_layer:
  82. return False
  83. self.last_layer = layer_num
  84. try:
  85. # Reuse the live view's frame instead of opening a second handle on
  86. # a single-reader device (#2707). Unguarded, a print watched from
  87. # start to finish recorded zero successful layer captures, and the
  88. # stitched video came out empty or badly truncated.
  89. from backend.app.api.routes.camera import live_frame_for_capture
  90. defer, buffered = live_frame_for_capture(self.printer_id)
  91. if defer:
  92. if not buffered:
  93. # Viewer attached but nothing buffered yet: skip this layer
  94. # rather than compete and kick them off (#1348).
  95. logger.debug(
  96. "Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
  97. layer_num,
  98. self.printer_id,
  99. )
  100. return False
  101. frame_data = buffered
  102. else:
  103. frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
  104. if frame_data:
  105. if self.rotation:
  106. frame_data = await asyncio.to_thread(apply_camera_rotation, frame_data, self.rotation, logger)
  107. frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
  108. await asyncio.to_thread(frame_path.write_bytes, frame_data)
  109. self.frame_count += 1
  110. logger.debug(
  111. "Captured layer %s for printer %s (frame %s)", layer_num, self.printer_id, self.frame_count
  112. )
  113. return True
  114. else:
  115. logger.warning("Failed to capture frame for layer %s", layer_num)
  116. return False
  117. except Exception as e:
  118. logger.error("Error capturing timelapse frame: %s", e)
  119. return False
  120. async def stitch(self, output_path: Path, fps: int = 30) -> bool:
  121. """Create MP4 from captured frames using ffmpeg.
  122. Args:
  123. output_path: Path for output video file
  124. fps: Frames per second for output video
  125. Returns:
  126. True if stitching succeeded, False otherwise
  127. """
  128. if self.frame_count == 0:
  129. logger.warning("No frames to stitch")
  130. return False
  131. ffmpeg = get_ffmpeg_path()
  132. if not ffmpeg:
  133. logger.error("ffmpeg not found - required for timelapse stitching")
  134. return False
  135. # Find all frame files and create a sequential list
  136. # This handles gaps in layer numbers (e.g., if some captures failed)
  137. frame_files = sorted(self.frames_dir.glob("layer_*.jpg"))
  138. if not frame_files:
  139. logger.warning("No frame files found in timelapse directory")
  140. return False
  141. # Create a concat file listing all frames
  142. concat_file = self.frames_dir / "frames.txt"
  143. try:
  144. with open(concat_file, "w") as f:
  145. for frame in frame_files:
  146. # Each frame shown for 1/fps duration
  147. f.write(f"file '{frame.name}'\n")
  148. f.write(f"duration {1.0 / fps}\n")
  149. # Add last frame again (required by concat demuxer)
  150. if frame_files:
  151. f.write(f"file '{frame_files[-1].name}'\n")
  152. except Exception as e:
  153. logger.error("Failed to create concat file: %s", e)
  154. return False
  155. # Use ffmpeg concat demuxer for variable-gap frame sequences
  156. cmd = [
  157. ffmpeg,
  158. "-y", # Overwrite output
  159. "-f",
  160. "concat",
  161. "-safe",
  162. "0",
  163. "-i",
  164. str(concat_file),
  165. "-c:v",
  166. "libx264",
  167. "-pix_fmt",
  168. "yuv420p",
  169. "-preset",
  170. "medium",
  171. "-crf",
  172. "23",
  173. str(output_path),
  174. ]
  175. process = None
  176. timeout = _STITCH_MIN_SECONDS
  177. # Marked before ffmpeg starts, so a restart in the middle of a stitch
  178. # (which can now run an hour) keeps the frames too.
  179. self.mark_kept("The timelapse was being stitched when Bambuddy stopped.")
  180. try:
  181. process = await asyncio.create_subprocess_exec(
  182. *cmd,
  183. stdout=asyncio.subprocess.PIPE,
  184. stderr=asyncio.subprocess.PIPE,
  185. cwd=str(self.frames_dir), # Run in frames dir so relative paths work
  186. )
  187. self.ffmpeg_ran = True
  188. timeout = _stitch_timeout(len(frame_files))
  189. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  190. if process.returncode != 0:
  191. logger.error("ffmpeg timelapse stitch failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
  192. output_path.unlink(missing_ok=True)
  193. return False
  194. logger.info("Created timelapse video: %s (%s frames)", output_path, self.frame_count)
  195. return True
  196. except TimeoutError:
  197. logger.error("Timelapse stitching timed out after %.0f s (%s frames)", timeout, len(frame_files))
  198. if process:
  199. with contextlib.suppress(ProcessLookupError):
  200. process.kill()
  201. try:
  202. await asyncio.wait_for(process.wait(), 10)
  203. except TimeoutError:
  204. logger.warning("ffmpeg did not exit after the timelapse stitch was killed")
  205. output_path.unlink(missing_ok=True)
  206. return False
  207. except Exception as e:
  208. logger.error("Timelapse stitch failed: %s", e)
  209. return False
  210. def mark_kept(self, reason: str) -> bool:
  211. """Mark the frames to be kept for KEEP_FAILED_FRAMES_SECONDS."""
  212. try:
  213. marker = self.frames_dir / KEPT_FRAMES_MARKER # SEC-PATH-OK: this module's constant filename
  214. marker.write_text(
  215. f"{reason}\nThese frames are kept for 24 hours. To make the video from them, run in this folder:\n"
  216. "ffmpeg -f concat -safe 0 -i frames.txt -c:v libx264 -pix_fmt yuv420p timelapse.mp4\n"
  217. )
  218. return True
  219. except OSError as e:
  220. logger.warning("Could not mark timelapse frames as kept: %s", e)
  221. return False
  222. def keep_frames(self) -> None:
  223. """Keep the frames of a failed stitch for a day instead of deleting them."""
  224. if self.mark_kept("The timelapse could not be stitched."):
  225. logger.warning(
  226. "Timelapse frames for printer %s kept for 24 hours at %s (stitching failed)",
  227. self.printer_id,
  228. self.frames_dir,
  229. )
  230. else:
  231. self.cleanup()
  232. def cleanup(self):
  233. """Remove temporary frames directory."""
  234. try:
  235. if self.frames_dir.exists():
  236. shutil.rmtree(self.frames_dir, ignore_errors=True)
  237. logger.info("Cleaned up timelapse frames for session %s", self.session_id)
  238. except Exception as e:
  239. logger.warning("Failed to cleanup timelapse frames: %s", e)
  240. def start_session(
  241. printer_id: int,
  242. archive_id: int | None,
  243. url: str,
  244. cam_type: str,
  245. snapshot_url: str | None = None,
  246. rotation: int = 0,
  247. ) -> TimelapseSession:
  248. """Start new timelapse session for a printer.
  249. Args:
  250. printer_id: The printer ID
  251. archive_id: Associated print archive ID (optional)
  252. url: External camera URL
  253. cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
  254. snapshot_url: Optional single-frame URL override; when set, layer captures
  255. fetch from it directly instead of opening the live stream. #1177.
  256. rotation: Printer's configured camera_rotation (degrees clockwise),
  257. applied to every captured frame before it's saved.
  258. Returns:
  259. The new TimelapseSession
  260. """
  261. # Cancel any existing session
  262. cancel_session(printer_id)
  263. session = TimelapseSession(
  264. printer_id=printer_id,
  265. archive_id=archive_id,
  266. camera_url=url,
  267. camera_type=cam_type,
  268. snapshot_url=snapshot_url,
  269. rotation=rotation,
  270. )
  271. _active_sessions[printer_id] = session
  272. logger.info("Started timelapse session for printer %s", printer_id)
  273. return session
  274. def get_session(printer_id: int) -> TimelapseSession | None:
  275. """Get active timelapse session for a printer."""
  276. return _active_sessions.get(printer_id)
  277. async def on_layer_change(printer_id: int, layer_num: int):
  278. """Called on layer change - captures frame if session active.
  279. Args:
  280. printer_id: The printer ID
  281. layer_num: Current layer number
  282. """
  283. session = get_session(printer_id)
  284. if session:
  285. await session.capture_layer(layer_num)
  286. async def on_print_complete(printer_id: int) -> Path | None:
  287. """Stitch timelapse and return path. Cleans up session.
  288. Args:
  289. printer_id: The printer ID
  290. Returns:
  291. Path to stitched video, or None if no session or stitching failed
  292. """
  293. session = _active_sessions.get(printer_id)
  294. if not session:
  295. return None
  296. # Marked finalizing for the length of the stitch, and before it leaves
  297. # _active_sessions (the janitor runs in a thread) — otherwise a sweep sees
  298. # a frames directory that matches no session and whose mtime is the last
  299. # layer's write, which on a tall print's final layer is easily older than
  300. # the age margin, and deletes ffmpeg's input from under it.
  301. _finalizing_sessions.setdefault(printer_id, set()).add(session.session_id)
  302. _active_sessions.pop(printer_id, None)
  303. try:
  304. if session.frame_count == 0:
  305. logger.info("No timelapse frames captured for printer %s", printer_id)
  306. session.cleanup()
  307. return None
  308. # Create output path in parent of frames dir
  309. output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
  310. success = await session.stitch(output_path)
  311. if success:
  312. # Cleanup frames after successful stitch
  313. session.cleanup()
  314. return output_path
  315. _keep_or_clean(session)
  316. return None
  317. except Exception as e:
  318. logger.error("Timelapse completion failed: %s", e)
  319. _keep_or_clean(session)
  320. return None
  321. finally:
  322. finalizing = _finalizing_sessions.get(printer_id)
  323. if finalizing is not None:
  324. finalizing.discard(session.session_id)
  325. if not finalizing:
  326. _finalizing_sessions.pop(printer_id, None)
  327. def _keep_or_clean(session: "TimelapseSession") -> None:
  328. """After a failed stitch: keep the frames if ffmpeg ran on them (a slow
  329. encode, an ffmpeg error), so the video can still be made; delete them if it
  330. never could (no ffmpeg, no frames)."""
  331. if session.ffmpeg_ran:
  332. session.keep_frames()
  333. else:
  334. session.cleanup()
  335. def cancel_session(printer_id: int):
  336. """Cancel and cleanup timelapse session (on print fail/cancel).
  337. Args:
  338. printer_id: The printer ID
  339. """
  340. session = _active_sessions.pop(printer_id, None)
  341. if session:
  342. session.cleanup()
  343. logger.info("Cancelled timelapse session for printer %s", printer_id)
  344. def cleanup_orphaned_timelapse_sessions(min_age_seconds: float = 300) -> int:
  345. """Remove timelapse_frames/<printer_id>/* left behind by a crash or
  346. restart that happened while a session was active.
  347. _active_sessions is in-memory only, so a process restart loses track of
  348. any in-flight session without ever calling cancel_session()/cleanup() -
  349. the frames directory (and, if stitching had already produced output
  350. before the restart, a stray `timelapse_<session_id>.mp4`) are then
  351. orphaned on disk with nothing else to reap them (unlike the ffmpeg
  352. orphan janitor in routes/camera.py, there was no equivalent here).
  353. Safe to call once at startup: normal operation always cleans up via
  354. on_print_complete/cancel_session, so anything found here predates this
  355. process - and a restart-recovered print doesn't get a new timelapse
  356. session either (`_maybe_start_layer_timelapse` is only wired into fresh
  357. PRINT_START events, see #1353), so an orphaned directory can never be
  358. resumed.
  359. Also safe to call mid-run, which needs all three guards rather than the
  360. age margin alone:
  361. * `_active_sessions` covers a session that is still capturing.
  362. * `_finalizing_sessions` covers the stitch window. on_print_complete drops
  363. the session from `_active_sessions` before handing frames_dir to ffmpeg,
  364. so without this the directory matches no session for up to an hour
  365. (`_stitch_timeout`) while being actively read.
  366. * `min_age_seconds` covers the remaining gap - a session in the middle of
  367. being created, and the stitched `.mp4` between ffmpeg finishing it and
  368. the caller attaching and unlinking it. Both are freshly written, so the
  369. margin has real headroom there; it has none for the stitch window.
  370. Frames a failed stitch kept (`KEPT_FRAMES_MARKER`) stay for
  371. `KEEP_FAILED_FRAMES_SECONDS`, counted from the marker.
  372. Returns the number of orphaned directories/files removed.
  373. """
  374. base_dir = settings.base_dir / "timelapse_frames"
  375. if not base_dir.exists():
  376. return 0
  377. now = time.time()
  378. removed = 0
  379. for printer_dir in base_dir.iterdir():
  380. if not printer_dir.is_dir():
  381. continue
  382. try:
  383. printer_id = int(printer_dir.name)
  384. except ValueError:
  385. continue
  386. active_session = _active_sessions.get(printer_id)
  387. in_use_session_ids = {active_session.session_id if active_session else None} - {None}
  388. in_use_session_ids |= _finalizing_sessions.get(printer_id, set())
  389. for entry in printer_dir.iterdir():
  390. # Frame dirs are named "<session_id>/"; stitched-but-not-yet-
  391. # attached output files are "timelapse_<session_id>.mp4" (see
  392. # on_print_complete's output_path). Anything else under here was
  393. # not written by this module, so leave it alone rather than
  394. # deleting a file on the strength of its age.
  395. if entry.is_dir():
  396. entry_session_id = entry.name
  397. elif entry.name.startswith("timelapse_") and entry.name.endswith(".mp4"):
  398. entry_session_id = entry.name[len("timelapse_") : -len(".mp4")]
  399. else:
  400. continue
  401. if entry_session_id in in_use_session_ids:
  402. continue
  403. try:
  404. if now - entry.stat().st_mtime < min_age_seconds:
  405. continue
  406. marker = entry / KEPT_FRAMES_MARKER if entry.is_dir() else None
  407. if marker is not None and marker.exists() and now - marker.stat().st_mtime < KEEP_FAILED_FRAMES_SECONDS:
  408. continue
  409. except OSError:
  410. continue
  411. try:
  412. # No ignore_errors: it would swallow a failed removal while the
  413. # count and the log line below still claimed success, and that
  414. # log is the only evidence an operator has of what was deleted.
  415. if entry.is_dir():
  416. shutil.rmtree(entry)
  417. else:
  418. entry.unlink(missing_ok=True)
  419. removed += 1
  420. logger.info("Removed orphaned timelapse artifact: %s", entry)
  421. except OSError as e:
  422. logger.warning("Failed to remove orphaned timelapse artifact %s: %s", entry, e)
  423. return removed