"""视频抽帧:把视频转成一组"卡片"(帧),供多模态提取 + 溯源使用。 策略(见 技术文档/技术架构.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) ]