timelapse_processor.py 8.6 KB

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