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 creation_knowledge.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. ]