timelapse_processor.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. """Timelapse video processing service using FFmpeg."""
  2. import asyncio
  3. import json
  4. import logging
  5. import tempfile
  6. from pathlib import Path
  7. from backend.app.services.camera import get_ffmpeg_path
  8. logger = logging.getLogger(__name__)
  9. class TimelapseProcessor:
  10. """Service for processing timelapse videos with FFmpeg."""
  11. def __init__(self, input_path: Path):
  12. self.input_path = input_path
  13. self.ffmpeg = get_ffmpeg_path()
  14. if not self.ffmpeg:
  15. raise RuntimeError("FFmpeg not found")
  16. # Derive ffprobe path from ffmpeg path
  17. self.ffprobe = self.ffmpeg.replace("ffmpeg", "ffprobe")
  18. async def get_info(self) -> dict:
  19. """Get video metadata using ffprobe."""
  20. cmd = [
  21. self.ffprobe,
  22. "-v",
  23. "quiet",
  24. "-print_format",
  25. "json",
  26. "-show_format",
  27. "-show_streams",
  28. str(self.input_path),
  29. ]
  30. process = await asyncio.create_subprocess_exec(
  31. *cmd,
  32. stdout=asyncio.subprocess.PIPE,
  33. stderr=asyncio.subprocess.PIPE,
  34. )
  35. stdout, stderr = await process.communicate()
  36. if process.returncode != 0:
  37. logger.error("ffprobe failed: %s", stderr.decode())
  38. raise RuntimeError(f"ffprobe failed: {stderr.decode()}")
  39. data = json.loads(stdout.decode())
  40. video_stream = next(
  41. (s for s in data.get("streams", []) if s.get("codec_type") == "video"),
  42. {},
  43. )
  44. audio_stream = next(
  45. (s for s in data.get("streams", []) if s.get("codec_type") == "audio"),
  46. None,
  47. )
  48. # Parse frame rate (can be "30/1" or "29.97")
  49. fps = 30.0
  50. r_frame_rate = video_stream.get("r_frame_rate", "30/1")
  51. try:
  52. if "/" in r_frame_rate:
  53. num, den = r_frame_rate.split("/")
  54. fps = float(num) / float(den)
  55. else:
  56. fps = float(r_frame_rate)
  57. except (ValueError, ZeroDivisionError):
  58. pass
  59. return {
  60. "duration": float(data.get("format", {}).get("duration", 0)),
  61. "width": video_stream.get("width", 0),
  62. "height": video_stream.get("height", 0),
  63. "fps": fps,
  64. "codec": video_stream.get("codec_name", "unknown"),
  65. "file_size": int(data.get("format", {}).get("size", 0)),
  66. "has_audio": audio_stream is not None,
  67. }
  68. async def generate_thumbnails(
  69. self,
  70. count: int = 10,
  71. width: int = 160,
  72. ) -> list[tuple[float, bytes]]:
  73. """Generate evenly-spaced thumbnail frames."""
  74. info = await self.get_info()
  75. duration = info["duration"]
  76. if duration <= 0:
  77. return []
  78. interval = duration / max(count, 1)
  79. thumbnails = []
  80. with tempfile.TemporaryDirectory() as tmpdir:
  81. for i in range(count):
  82. timestamp = i * interval
  83. output_path = Path(tmpdir) / f"thumb_{i:03d}.jpg"
  84. cmd = [
  85. self.ffmpeg,
  86. "-y",
  87. "-ss",
  88. str(timestamp),
  89. "-i",
  90. str(self.input_path),
  91. "-vframes",
  92. "1",
  93. "-vf",
  94. f"scale={width}:-1",
  95. "-q:v",
  96. "5",
  97. str(output_path),
  98. ]
  99. process = await asyncio.create_subprocess_exec(
  100. *cmd,
  101. stdout=asyncio.subprocess.PIPE,
  102. stderr=asyncio.subprocess.PIPE,
  103. )
  104. await process.communicate()
  105. if output_path.exists():
  106. thumbnails.append((timestamp, output_path.read_bytes()))
  107. return thumbnails
  108. async def process(
  109. self,
  110. output_path: Path,
  111. trim_start: float = 0,
  112. trim_end: float | None = None,
  113. speed: float = 1.0,
  114. audio_path: Path | None = None,
  115. audio_volume: float = 1.0,
  116. ) -> bool:
  117. """Process video with trim, speed, and optional audio overlay.
  118. Args:
  119. output_path: Where to save the processed video
  120. trim_start: Start time in seconds
  121. trim_end: End time in seconds (None = full duration)
  122. speed: Speed multiplier (0.25 to 4.0)
  123. audio_path: Optional music file to overlay
  124. audio_volume: Volume for audio overlay (0.0 to 1.0)
  125. Returns:
  126. True if processing succeeded, False otherwise
  127. """
  128. # Build FFmpeg command
  129. cmd = [self.ffmpeg, "-y"]
  130. # Input seeking (fast seek before input)
  131. if trim_start > 0:
  132. cmd.extend(["-ss", str(trim_start)])
  133. cmd.extend(["-i", str(self.input_path)])
  134. # Add audio input if provided
  135. if audio_path:
  136. cmd.extend(["-i", str(audio_path)])
  137. # Duration limit
  138. if trim_end is not None and trim_end > trim_start:
  139. duration = trim_end - trim_start
  140. cmd.extend(["-t", str(duration)])
  141. # Build filters - use filter_complex when we have audio overlay
  142. video_filter = ""
  143. if speed != 1.0:
  144. # setpts changes video speed: PTS/speed = faster, PTS*speed = slower
  145. setpts_value = 1.0 / speed
  146. video_filter = f"setpts={setpts_value}*PTS"
  147. if audio_path:
  148. # Use filter_complex for audio overlay (can't mix with -vf/-af)
  149. filter_parts = []
  150. # Video filter
  151. if video_filter:
  152. filter_parts.append(f"[0:v]{video_filter}[v]")
  153. video_out = "[v]"
  154. else:
  155. video_out = "0:v"
  156. # Audio filter with volume
  157. filter_parts.append(f"[1:a]volume={audio_volume}[a]")
  158. cmd.extend(["-filter_complex", ";".join(filter_parts)])
  159. cmd.extend(["-map", video_out, "-map", "[a]"])
  160. cmd.extend(["-shortest"])
  161. elif speed != 1.0:
  162. # No audio overlay - use simple -vf and -af
  163. if video_filter:
  164. cmd.extend(["-vf", video_filter])
  165. # Adjust original audio speed with atempo
  166. atempo_chain = self._build_atempo_chain(speed)
  167. if atempo_chain:
  168. cmd.extend(["-af", atempo_chain])
  169. # Output settings
  170. cmd.extend(
  171. [
  172. "-c:v",
  173. "libx264",
  174. "-preset",
  175. "fast",
  176. "-crf",
  177. "23",
  178. "-c:a",
  179. "aac",
  180. "-b:a",
  181. "128k",
  182. "-movflags",
  183. "+faststart", # Enable streaming
  184. str(output_path),
  185. ]
  186. )
  187. logger.info("Processing timelapse: %s", " ".join(cmd))
  188. # Run FFmpeg
  189. process = await asyncio.create_subprocess_exec(
  190. *cmd,
  191. stdout=asyncio.subprocess.PIPE,
  192. stderr=asyncio.subprocess.PIPE,
  193. )
  194. _, stderr = await process.communicate()
  195. if process.returncode != 0:
  196. logger.error("FFmpeg processing failed: %s", stderr.decode())
  197. return False
  198. return output_path.exists()
  199. def _build_atempo_chain(self, speed: float) -> str:
  200. """Build atempo filter chain.
  201. atempo filter only supports values between 0.5 and 2.0,
  202. so we chain multiple filters for extreme speeds.
  203. """
  204. if speed == 1.0:
  205. return ""
  206. filters = []
  207. remaining_speed = speed
  208. # Handle speeds > 2.0 by chaining atempo=2.0
  209. while remaining_speed > 2.0:
  210. filters.append("atempo=2.0")
  211. remaining_speed /= 2.0
  212. # Handle speeds < 0.5 by chaining atempo=0.5
  213. while remaining_speed < 0.5:
  214. filters.append("atempo=0.5")
  215. remaining_speed *= 2.0
  216. # Add final atempo for remaining adjustment
  217. if 0.5 <= remaining_speed <= 2.0 and remaining_speed != 1.0:
  218. filters.append(f"atempo={remaining_speed:.4f}")
  219. return ",".join(filters)