layer_timelapse.py 20 KB

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