video_frames.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. """Video frame extraction fallback for multimodal readers."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. import subprocess
  6. from pathlib import Path
  7. from typing import Optional
  8. import httpx
  9. from core.models import Card
  10. logger = logging.getLogger(__name__)
  11. SCENE_THRESHOLD = 0.3
  12. MIN_GAP_SECONDS = 1.5
  13. FALLBACK_MIN = 4
  14. FALLBACK_TARGET = 8
  15. SCALE_VF = "scale='min(720,iw)':-2"
  16. REFERER = {
  17. "douyin": "https://www.douyin.com/",
  18. "kuaishou": "https://www.kuaishou.com/",
  19. "bilibili": "https://www.bilibili.com/",
  20. "shipinhao": "https://channels.weixin.qq.com/",
  21. "xiaohongshu": "https://www.xiaohongshu.com/",
  22. }
  23. class VideoFramesError(RuntimeError):
  24. pass
  25. def _ffmpeg_bin() -> str:
  26. try:
  27. import imageio_ffmpeg
  28. return imageio_ffmpeg.get_ffmpeg_exe()
  29. except Exception:
  30. return "ffmpeg"
  31. def _download(url: str, platform: str, dst: Path, timeout: float) -> None:
  32. headers = {"User-Agent": "Mozilla/5.0", "Referer": REFERER.get(platform, "")}
  33. with httpx.stream("GET", url, headers=headers, timeout=timeout, follow_redirects=True) as response:
  34. response.raise_for_status()
  35. with open(dst, "wb") as file:
  36. for chunk in response.iter_bytes():
  37. file.write(chunk)
  38. def _run(cmd: list[str]) -> str:
  39. proc = subprocess.run(cmd, capture_output=True, text=True)
  40. return proc.stderr or ""
  41. def _probe_duration(ffmpeg: str, video: Path) -> float:
  42. err = _run([ffmpeg, "-hide_banner", "-i", str(video)])
  43. match = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.?\d*)", err)
  44. if not match:
  45. return 0.0
  46. hours, minutes, seconds = match.groups()
  47. return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
  48. def _scene_frames(
  49. ffmpeg: str,
  50. video: Path,
  51. out: Path,
  52. threshold: float,
  53. min_gap: float,
  54. max_frames: int,
  55. ) -> list[tuple[Path, Optional[float]]]:
  56. pattern = str(out / "scene_%04d.jpg")
  57. err = _run(
  58. [
  59. ffmpeg,
  60. "-hide_banner",
  61. "-i",
  62. str(video),
  63. "-vf",
  64. f"select='gt(scene,{threshold})',showinfo,{SCALE_VF}",
  65. "-vsync",
  66. "vfr",
  67. "-q:v",
  68. "3",
  69. pattern,
  70. ]
  71. )
  72. times = [float(ts) for ts in re.findall(r"pts_time:(\d+\.?\d*)", err)]
  73. files = sorted(out.glob("scene_*.jpg"))
  74. paired = list(zip(files, times + [None] * (len(files) - len(times))))
  75. kept: list[tuple[Path, Optional[float]]] = []
  76. last = None
  77. for path, timestamp in paired:
  78. if timestamp is not None and last is not None and timestamp - last < min_gap:
  79. continue
  80. kept.append((path, timestamp))
  81. if timestamp is not None:
  82. last = timestamp
  83. if len(kept) >= max_frames:
  84. break
  85. return kept
  86. def _uniform_frames(
  87. ffmpeg: str,
  88. video: Path,
  89. out: Path,
  90. duration: float,
  91. count: int,
  92. ) -> list[tuple[Path, Optional[float]]]:
  93. if duration <= 0:
  94. duration = float(count)
  95. result: list[tuple[Path, Optional[float]]] = []
  96. for idx in range(count):
  97. timestamp = duration * (idx + 0.5) / count
  98. path = out / f"uni_{idx:04d}.jpg"
  99. _run(
  100. [
  101. ffmpeg,
  102. "-hide_banner",
  103. "-ss",
  104. f"{timestamp:.2f}",
  105. "-i",
  106. str(video),
  107. "-frames:v",
  108. "1",
  109. "-vf",
  110. SCALE_VF,
  111. "-q:v",
  112. "3",
  113. str(path),
  114. ]
  115. )
  116. if path.exists():
  117. result.append((path, round(timestamp, 2)))
  118. return result
  119. def extract_frames(
  120. video_src: str,
  121. *,
  122. out_dir: str | Path,
  123. url_prefix: str,
  124. platform: str = "",
  125. max_frames: int = 12,
  126. start_index: int = 1,
  127. timeout: float = 60.0,
  128. ) -> list[Card]:
  129. out = Path(out_dir)
  130. out.mkdir(parents=True, exist_ok=True)
  131. ffmpeg = _ffmpeg_bin()
  132. src = Path(video_src)
  133. if not src.exists():
  134. src = out / "_src.mp4"
  135. _download(video_src, platform, src, timeout)
  136. frames = _scene_frames(ffmpeg, src, out, SCENE_THRESHOLD, MIN_GAP_SECONDS, max_frames)
  137. if len(frames) < FALLBACK_MIN:
  138. logger.info("scene frames only %d, falling back to uniform sampling", len(frames))
  139. duration = _probe_duration(ffmpeg, src)
  140. frames = _uniform_frames(ffmpeg, src, out, duration, FALLBACK_TARGET)
  141. frames = frames[:max_frames]
  142. if not frames:
  143. raise VideoFramesError(f"未能从视频抽出任何帧: {video_src}")
  144. return [
  145. Card(index=start_index + idx, kind="frame", url=f"{url_prefix.rstrip('/')}/{path.name}", timestamp=timestamp)
  146. for idx, (path, timestamp) in enumerate(frames)
  147. ]