video_frames.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. """视频抽帧:把视频转成一组"卡片"(帧),供多模态提取 + 溯源使用。
  2. 策略(见 技术文档/技术架构.md §12):
  3. 主策略 = 场景切换检测(ffmpeg select='gt(scene,T)'),知识类视频常是字幕卡/场景切换,
  4. 正好对应"卡片";附带 showinfo 解析每帧时间戳。
  5. 护栏 = 最小间隔去近重复、最多 max_frames 控成本、下采样到长边≤720。
  6. 回退 = 场景帧 < FALLBACK_MIN 时,按时长均匀采样 FALLBACK_TARGET 帧。
  7. 无 ffprobe 依赖:时长从 ffmpeg stderr 的 "Duration:" 解析。
  8. 帧文件写到 out_dir;Card.url = f"{url_prefix}/{文件名}",由 API 的 /frames 静态服务。
  9. """
  10. from __future__ import annotations
  11. import logging
  12. import re
  13. import subprocess
  14. from pathlib import Path
  15. from typing import Callable, Optional
  16. import httpx
  17. from core.models import Card
  18. logger = logging.getLogger(__name__)
  19. SCENE_THRESHOLD = 0.3 # 场景切换阈值(电影 30° 规则)
  20. MIN_GAP_SECONDS = 1.5 # 相邻保留帧最小间隔,去突发近重复
  21. FALLBACK_MIN = 4 # 场景帧少于此数则触发均匀采样回退
  22. FALLBACK_TARGET = 8 # 回退时采样的帧数
  23. SCALE_VF = "scale='min(720,iw)':-2" # 长边下采样到 ≤720
  24. # 各平台下载视频需要的 Referer
  25. _REFERER = {
  26. "douyin": "https://www.douyin.com/",
  27. "kuaishou": "https://www.kuaishou.com/",
  28. "bilibili": "https://www.bilibili.com/",
  29. "shipinhao": "https://channels.weixin.qq.com/",
  30. "xiaohongshu": "https://www.xiaohongshu.com/",
  31. }
  32. class VideoFramesError(RuntimeError):
  33. pass
  34. def _ffmpeg_bin() -> str:
  35. try:
  36. import imageio_ffmpeg
  37. return imageio_ffmpeg.get_ffmpeg_exe()
  38. except Exception:
  39. return "ffmpeg"
  40. def _download(url: str, platform: str, dst: Path, timeout: float) -> None:
  41. headers = {"User-Agent": "Mozilla/5.0", "Referer": _REFERER.get(platform, "")}
  42. with httpx.stream("GET", url, headers=headers, timeout=timeout,
  43. follow_redirects=True) as r:
  44. r.raise_for_status()
  45. with open(dst, "wb") as f:
  46. for chunk in r.iter_bytes():
  47. f.write(chunk)
  48. def _run(cmd: list[str]) -> str:
  49. """跑 ffmpeg,返回 stderr(ffmpeg 把信息打到 stderr)。"""
  50. proc = subprocess.run(cmd, capture_output=True, text=True)
  51. return proc.stderr or ""
  52. def _probe_duration(ffmpeg: str, video: Path) -> float:
  53. err = _run([ffmpeg, "-hide_banner", "-i", str(video)])
  54. m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.?\d*)", err)
  55. if not m:
  56. return 0.0
  57. h, mn, s = m.groups()
  58. return int(h) * 3600 + int(mn) * 60 + float(s)
  59. def _scene_frames(ffmpeg: str, video: Path, out: Path, threshold: float,
  60. min_gap: float, max_frames: int) -> list[tuple[Path, Optional[float]]]:
  61. pattern = str(out / "scene_%04d.jpg")
  62. err = _run([ffmpeg, "-hide_banner", "-i", str(video),
  63. "-vf", f"select='gt(scene,{threshold})',showinfo,{SCALE_VF}",
  64. "-vsync", "vfr", "-q:v", "3", pattern])
  65. times = [float(t) for t in re.findall(r"pts_time:(\d+\.?\d*)", err)]
  66. files = sorted(out.glob("scene_*.jpg"))
  67. paired = list(zip(files, times + [None] * (len(files) - len(times))))
  68. # 最小间隔过滤
  69. kept: list[tuple[Path, Optional[float]]] = []
  70. last = None
  71. for path, ts in paired:
  72. if ts is not None and last is not None and ts - last < min_gap:
  73. continue
  74. kept.append((path, ts))
  75. if ts is not None:
  76. last = ts
  77. if len(kept) >= max_frames:
  78. break
  79. return kept
  80. def _uniform_frames(ffmpeg: str, video: Path, out: Path, duration: float,
  81. count: int) -> list[tuple[Path, Optional[float]]]:
  82. if duration <= 0:
  83. duration = float(count) # 兜底,避免全 0
  84. result: list[tuple[Path, Optional[float]]] = []
  85. for k in range(count):
  86. ts = duration * (k + 0.5) / count
  87. path = out / f"uni_{k:04d}.jpg"
  88. _run([ffmpeg, "-hide_banner", "-ss", f"{ts:.2f}", "-i", str(video),
  89. "-frames:v", "1", "-vf", SCALE_VF, "-q:v", "3", str(path)])
  90. if path.exists():
  91. result.append((path, round(ts, 2)))
  92. return result
  93. def extract_frames(
  94. video_src: str,
  95. *,
  96. out_dir: str | Path,
  97. url_prefix: str,
  98. platform: str = "",
  99. max_frames: int = 12,
  100. start_index: int = 1,
  101. timeout: float = 60.0,
  102. ) -> list[Card]:
  103. """把视频抽成帧卡片。video_src 可为 URL 或本地文件路径。返回 Card(kind='frame')。"""
  104. out = Path(out_dir)
  105. out.mkdir(parents=True, exist_ok=True)
  106. ffmpeg = _ffmpeg_bin()
  107. src = Path(video_src)
  108. if not src.exists(): # 是 URL,先下载
  109. src = out / "_src.mp4"
  110. _download(video_src, platform, src, timeout)
  111. frames = _scene_frames(ffmpeg, src, out, SCENE_THRESHOLD, MIN_GAP_SECONDS, max_frames)
  112. if len(frames) < FALLBACK_MIN:
  113. logger.info("场景帧仅 %d,回退均匀采样", len(frames))
  114. dur = _probe_duration(ffmpeg, src)
  115. frames = _uniform_frames(ffmpeg, src, out, dur, FALLBACK_TARGET)
  116. frames = frames[:max_frames]
  117. if not frames:
  118. raise VideoFramesError(f"未能从视频抽出任何帧: {video_src}")
  119. return [
  120. Card(index=start_index + i, kind="frame",
  121. url=f"{url_prefix.rstrip('/')}/{path.name}", timestamp=ts)
  122. for i, (path, ts) in enumerate(frames)
  123. ]