timelapse_processor.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. """Timelapse video processing service using FFmpeg."""
  2. import asyncio
  3. import contextlib
  4. import json
  5. import logging
  6. import tempfile
  7. from collections.abc import Callable
  8. from pathlib import Path
  9. from backend.app.services.camera import get_ffmpeg_path
  10. from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
  11. logger = logging.getLogger(__name__)
  12. # ffprobe and a single thumbnail frame take well under a second on a healthy
  13. # file; these bound a broken or unreadable one, which runs inside a request.
  14. PROBE_TIMEOUT_SECONDS = 60
  15. THUMBNAIL_TIMEOUT_SECONDS = 30
  16. async def _communicate(process: asyncio.subprocess.Process, timeout: float) -> tuple[bytes, bytes]:
  17. """``process.communicate()`` that kills the process when it takes too long."""
  18. try:
  19. return await asyncio.wait_for(process.communicate(), timeout)
  20. except (TimeoutError, asyncio.CancelledError):
  21. _kill(process)
  22. await process.wait()
  23. raise
  24. def _kill(process: asyncio.subprocess.Process) -> None:
  25. with contextlib.suppress(ProcessLookupError):
  26. process.kill()
  27. class TimelapseProcessor:
  28. """Service for processing timelapse videos with FFmpeg."""
  29. def __init__(self, input_path: Path):
  30. self.input_path = input_path
  31. self.ffmpeg = get_ffmpeg_path()
  32. if not self.ffmpeg:
  33. raise RuntimeError("FFmpeg not found")
  34. # Derive ffprobe path from ffmpeg path
  35. self.ffprobe = self.ffmpeg.replace("ffmpeg", "ffprobe")
  36. async def get_info(self) -> dict:
  37. """Get video metadata using ffprobe."""
  38. cmd = [
  39. self.ffprobe,
  40. "-v",
  41. "quiet",
  42. "-print_format",
  43. "json",
  44. "-show_format",
  45. "-show_streams",
  46. str(self.input_path),
  47. ]
  48. process = await asyncio.create_subprocess_exec(
  49. *cmd,
  50. stdout=asyncio.subprocess.PIPE,
  51. stderr=asyncio.subprocess.PIPE,
  52. )
  53. try:
  54. stdout, stderr = await _communicate(process, PROBE_TIMEOUT_SECONDS)
  55. except TimeoutError:
  56. raise RuntimeError(f"ffprobe took longer than {PROBE_TIMEOUT_SECONDS} s") from None
  57. if process.returncode != 0:
  58. # Summarised once and used for both: the raise carried a second,
  59. # bare ``stderr.decode()`` that could itself raise UnicodeDecodeError
  60. # on the bytes ffprobe copies out of a broken file (#2968).
  61. detail = summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT
  62. logger.error("ffprobe failed: %s", detail)
  63. raise RuntimeError(f"ffprobe failed: {detail}")
  64. data = json.loads(stdout.decode())
  65. video_stream = next(
  66. (s for s in data.get("streams", []) if s.get("codec_type") == "video"),
  67. {},
  68. )
  69. audio_stream = next(
  70. (s for s in data.get("streams", []) if s.get("codec_type") == "audio"),
  71. None,
  72. )
  73. # Parse frame rate (can be "30/1" or "29.97")
  74. fps = 30.0
  75. r_frame_rate = video_stream.get("r_frame_rate", "30/1")
  76. try:
  77. if "/" in r_frame_rate:
  78. num, den = r_frame_rate.split("/")
  79. fps = float(num) / float(den)
  80. else:
  81. fps = float(r_frame_rate)
  82. except (ValueError, ZeroDivisionError):
  83. pass # Keep default fps if frame rate string is unparseable
  84. return {
  85. "duration": float(data.get("format", {}).get("duration", 0)),
  86. "width": video_stream.get("width", 0),
  87. "height": video_stream.get("height", 0),
  88. "fps": fps,
  89. "codec": video_stream.get("codec_name", "unknown"),
  90. "file_size": int(data.get("format", {}).get("size", 0)),
  91. "has_audio": audio_stream is not None,
  92. }
  93. async def generate_thumbnails(
  94. self,
  95. count: int = 10,
  96. width: int = 160,
  97. ) -> list[tuple[float, bytes]]:
  98. """Generate evenly-spaced thumbnail frames."""
  99. info = await self.get_info()
  100. duration = info["duration"]
  101. if duration <= 0:
  102. return []
  103. interval = duration / max(count, 1)
  104. thumbnails = []
  105. with tempfile.TemporaryDirectory() as tmpdir:
  106. for i in range(count):
  107. timestamp = i * interval
  108. output_path = Path(tmpdir) / f"thumb_{i:03d}.jpg"
  109. cmd = [
  110. self.ffmpeg,
  111. "-y",
  112. "-ss",
  113. str(timestamp),
  114. "-i",
  115. str(self.input_path),
  116. "-vframes",
  117. "1",
  118. "-vf",
  119. f"scale={width}:-1",
  120. "-q:v",
  121. "5",
  122. str(output_path),
  123. ]
  124. process = await asyncio.create_subprocess_exec(
  125. *cmd,
  126. stdout=asyncio.subprocess.PIPE,
  127. stderr=asyncio.subprocess.PIPE,
  128. )
  129. try:
  130. await _communicate(process, THUMBNAIL_TIMEOUT_SECONDS)
  131. except TimeoutError:
  132. logger.warning("Timelapse thumbnail at %.1f s took too long; skipped", timestamp)
  133. continue
  134. if output_path.exists():
  135. thumbnails.append((timestamp, output_path.read_bytes()))
  136. return thumbnails
  137. async def process(
  138. self,
  139. output_path: Path,
  140. trim_start: float = 0,
  141. trim_end: float | None = None,
  142. speed: float = 1.0,
  143. audio_path: Path | None = None,
  144. audio_volume: float = 1.0,
  145. on_progress: Callable[[float], None] | None = None,
  146. ) -> bool:
  147. """Process video with trim, speed, and optional audio overlay.
  148. Args:
  149. output_path: Where to save the processed video
  150. trim_start: Start time in seconds
  151. trim_end: End time in seconds (None = full duration)
  152. speed: Speed multiplier (0.25 to 4.0)
  153. audio_path: Optional music file to overlay
  154. audio_volume: Volume for audio overlay (0.0 to 1.0)
  155. on_progress: Called with the share done (0.0 to 1.0) as ffmpeg
  156. reports its position
  157. Returns:
  158. True if processing succeeded, False otherwise
  159. Cancelling the call stops ffmpeg and removes the partial output.
  160. """
  161. # The output's length, for progress: what is kept of the input, at
  162. # the new speed. An audio overlay with -shortest may end it earlier.
  163. expected_seconds = None
  164. if on_progress is not None:
  165. end = trim_end if trim_end is not None and trim_end > trim_start else None
  166. if end is None:
  167. try:
  168. end = (await self.get_info())["duration"]
  169. except Exception as e:
  170. # Progress is a nicety: encode without it.
  171. logger.warning("Timelapse length unknown, no progress: %s", e)
  172. if end is not None:
  173. expected_seconds = max(end - trim_start, 0) / speed
  174. # Build FFmpeg command
  175. cmd = [self.ffmpeg, "-y"]
  176. # Input seeking (fast seek before input)
  177. if trim_start > 0:
  178. cmd.extend(["-ss", str(trim_start)])
  179. cmd.extend(["-i", str(self.input_path)])
  180. # Add audio input if provided
  181. if audio_path:
  182. cmd.extend(["-i", str(audio_path)])
  183. # Duration limit
  184. if trim_end is not None and trim_end > trim_start:
  185. duration = trim_end - trim_start
  186. cmd.extend(["-t", str(duration)])
  187. # Build filters - use filter_complex when we have audio overlay
  188. video_filter = ""
  189. if speed != 1.0:
  190. # setpts changes video speed: PTS/speed = faster, PTS*speed = slower
  191. setpts_value = 1.0 / speed
  192. video_filter = f"setpts={setpts_value}*PTS"
  193. if audio_path:
  194. # Use filter_complex for audio overlay (can't mix with -vf/-af)
  195. filter_parts = []
  196. # Video filter
  197. if video_filter:
  198. filter_parts.append(f"[0:v]{video_filter}[v]")
  199. video_out = "[v]"
  200. else:
  201. video_out = "0:v"
  202. # Audio filter with volume
  203. filter_parts.append(f"[1:a]volume={audio_volume}[a]")
  204. cmd.extend(["-filter_complex", ";".join(filter_parts)])
  205. cmd.extend(["-map", video_out, "-map", "[a]"])
  206. cmd.extend(["-shortest"])
  207. elif speed != 1.0:
  208. # No audio overlay - use simple -vf and -af
  209. if video_filter:
  210. cmd.extend(["-vf", video_filter])
  211. # Adjust original audio speed with atempo
  212. atempo_chain = self._build_atempo_chain(speed)
  213. if atempo_chain:
  214. cmd.extend(["-af", atempo_chain])
  215. # Output settings
  216. cmd.extend(
  217. [
  218. "-c:v",
  219. "libx264",
  220. "-preset",
  221. "fast",
  222. "-crf",
  223. "23",
  224. "-c:a",
  225. "aac",
  226. "-b:a",
  227. "128k",
  228. "-movflags",
  229. "+faststart", # Enable streaming
  230. # Position reports on stdout, for progress.
  231. "-progress",
  232. "pipe:1",
  233. "-nostats",
  234. str(output_path),
  235. ]
  236. )
  237. logger.info("Processing timelapse: %s", " ".join(cmd))
  238. # Run FFmpeg
  239. process = await asyncio.create_subprocess_exec(
  240. *cmd,
  241. stdout=asyncio.subprocess.PIPE,
  242. stderr=asyncio.subprocess.PIPE,
  243. )
  244. try:
  245. _, stderr = await asyncio.gather(
  246. self._read_progress(process, expected_seconds, on_progress),
  247. process.stderr.read(), # type: ignore[union-attr]
  248. )
  249. await process.wait()
  250. except asyncio.CancelledError:
  251. _kill(process)
  252. await process.wait()
  253. output_path.unlink(missing_ok=True)
  254. raise
  255. if process.returncode != 0:
  256. logger.error("FFmpeg processing failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
  257. return False
  258. return output_path.exists()
  259. @staticmethod
  260. async def _read_progress(
  261. process: asyncio.subprocess.Process,
  262. expected_seconds: float | None,
  263. on_progress: Callable[[float], None] | None,
  264. ) -> None:
  265. """Read ffmpeg's ``-progress`` lines, passing on the share done."""
  266. assert process.stdout is not None
  267. async for raw in process.stdout:
  268. key, _, value = raw.decode(errors="replace").strip().partition("=")
  269. if on_progress is None or not expected_seconds or key != "out_time_us":
  270. continue
  271. try:
  272. on_progress(int(value) / 1_000_000 / expected_seconds)
  273. except ValueError:
  274. pass # "N/A" before the first frame
  275. def _build_atempo_chain(self, speed: float) -> str:
  276. """Build atempo filter chain.
  277. atempo filter only supports values between 0.5 and 2.0,
  278. so we chain multiple filters for extreme speeds.
  279. """
  280. if speed == 1.0:
  281. return ""
  282. filters = []
  283. remaining_speed = speed
  284. # Handle speeds > 2.0 by chaining atempo=2.0
  285. while remaining_speed > 2.0:
  286. filters.append("atempo=2.0")
  287. remaining_speed /= 2.0
  288. # Handle speeds < 0.5 by chaining atempo=0.5
  289. while remaining_speed < 0.5:
  290. filters.append("atempo=0.5")
  291. remaining_speed *= 2.0
  292. # Add final atempo for remaining adjustment
  293. # After the while loops above, remaining_speed is guaranteed to be in [0.5, 2.0]
  294. if remaining_speed != 1.0:
  295. filters.append(f"atempo={remaining_speed:.4f}")
  296. return ",".join(filters)