| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- """视频抽帧:把视频转成一组"卡片"(帧),供多模态提取 + 溯源使用。
- 策略(见 技术文档/技术架构.md §12):
- 主策略 = 场景切换检测(ffmpeg select='gt(scene,T)'),知识类视频常是字幕卡/场景切换,
- 正好对应"卡片";附带 showinfo 解析每帧时间戳。
- 护栏 = 最小间隔去近重复、最多 max_frames 控成本、下采样到长边≤720。
- 回退 = 场景帧 < FALLBACK_MIN 时,按时长均匀采样 FALLBACK_TARGET 帧。
- 无 ffprobe 依赖:时长从 ffmpeg stderr 的 "Duration:" 解析。
- 帧文件写到 out_dir;Card.url = f"{url_prefix}/{文件名}",由 API 的 /frames 静态服务。
- """
- from __future__ import annotations
- import logging
- import re
- import subprocess
- from pathlib import Path
- from typing import Callable, Optional
- import httpx
- from core.models import Card
- logger = logging.getLogger(__name__)
- SCENE_THRESHOLD = 0.3 # 场景切换阈值(电影 30° 规则)
- MIN_GAP_SECONDS = 1.5 # 相邻保留帧最小间隔,去突发近重复
- FALLBACK_MIN = 4 # 场景帧少于此数则触发均匀采样回退
- FALLBACK_TARGET = 8 # 回退时采样的帧数
- SCALE_VF = "scale='min(720,iw)':-2" # 长边下采样到 ≤720
- # 各平台下载视频需要的 Referer
- _REFERER = {
- "douyin": "https://www.douyin.com/",
- "kuaishou": "https://www.kuaishou.com/",
- "bilibili": "https://www.bilibili.com/",
- "shipinhao": "https://channels.weixin.qq.com/",
- "xiaohongshu": "https://www.xiaohongshu.com/",
- }
- class VideoFramesError(RuntimeError):
- pass
- def _ffmpeg_bin() -> str:
- try:
- import imageio_ffmpeg
- return imageio_ffmpeg.get_ffmpeg_exe()
- except Exception:
- return "ffmpeg"
- def _download(url: str, platform: str, dst: Path, timeout: float) -> None:
- headers = {"User-Agent": "Mozilla/5.0", "Referer": _REFERER.get(platform, "")}
- with httpx.stream("GET", url, headers=headers, timeout=timeout,
- follow_redirects=True) as r:
- r.raise_for_status()
- with open(dst, "wb") as f:
- for chunk in r.iter_bytes():
- f.write(chunk)
- def _run(cmd: list[str]) -> str:
- """跑 ffmpeg,返回 stderr(ffmpeg 把信息打到 stderr)。"""
- proc = subprocess.run(cmd, capture_output=True, text=True)
- return proc.stderr or ""
- def _probe_duration(ffmpeg: str, video: Path) -> float:
- err = _run([ffmpeg, "-hide_banner", "-i", str(video)])
- m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.?\d*)", err)
- if not m:
- return 0.0
- h, mn, s = m.groups()
- return int(h) * 3600 + int(mn) * 60 + float(s)
- def _scene_frames(ffmpeg: str, video: Path, out: Path, threshold: float,
- min_gap: float, max_frames: int) -> list[tuple[Path, Optional[float]]]:
- pattern = str(out / "scene_%04d.jpg")
- err = _run([ffmpeg, "-hide_banner", "-i", str(video),
- "-vf", f"select='gt(scene,{threshold})',showinfo,{SCALE_VF}",
- "-vsync", "vfr", "-q:v", "3", pattern])
- times = [float(t) for t in re.findall(r"pts_time:(\d+\.?\d*)", err)]
- files = sorted(out.glob("scene_*.jpg"))
- paired = list(zip(files, times + [None] * (len(files) - len(times))))
- # 最小间隔过滤
- kept: list[tuple[Path, Optional[float]]] = []
- last = None
- for path, ts in paired:
- if ts is not None and last is not None and ts - last < min_gap:
- continue
- kept.append((path, ts))
- if ts is not None:
- last = ts
- if len(kept) >= max_frames:
- break
- return kept
- def _uniform_frames(ffmpeg: str, video: Path, out: Path, duration: float,
- count: int) -> list[tuple[Path, Optional[float]]]:
- if duration <= 0:
- duration = float(count) # 兜底,避免全 0
- result: list[tuple[Path, Optional[float]]] = []
- for k in range(count):
- ts = duration * (k + 0.5) / count
- path = out / f"uni_{k:04d}.jpg"
- _run([ffmpeg, "-hide_banner", "-ss", f"{ts:.2f}", "-i", str(video),
- "-frames:v", "1", "-vf", SCALE_VF, "-q:v", "3", str(path)])
- if path.exists():
- result.append((path, round(ts, 2)))
- return result
- def extract_frames(
- video_src: str,
- *,
- out_dir: str | Path,
- url_prefix: str,
- platform: str = "",
- max_frames: int = 12,
- start_index: int = 1,
- timeout: float = 60.0,
- ) -> list[Card]:
- """把视频抽成帧卡片。video_src 可为 URL 或本地文件路径。返回 Card(kind='frame')。"""
- out = Path(out_dir)
- out.mkdir(parents=True, exist_ok=True)
- ffmpeg = _ffmpeg_bin()
- src = Path(video_src)
- if not src.exists(): # 是 URL,先下载
- src = out / "_src.mp4"
- _download(video_src, platform, src, timeout)
- frames = _scene_frames(ffmpeg, src, out, SCENE_THRESHOLD, MIN_GAP_SECONDS, max_frames)
- if len(frames) < FALLBACK_MIN:
- logger.info("场景帧仅 %d,回退均匀采样", len(frames))
- dur = _probe_duration(ffmpeg, src)
- frames = _uniform_frames(ffmpeg, src, out, dur, FALLBACK_TARGET)
- frames = frames[:max_frames]
- if not frames:
- raise VideoFramesError(f"未能从视频抽出任何帧: {video_src}")
- return [
- Card(index=start_index + i, kind="frame",
- url=f"{url_prefix.rstrip('/')}/{path.name}", timestamp=ts)
- for i, (path, ts) in enumerate(frames)
- ]
|