| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172 |
- """Video frame extraction fallback for multimodal readers."""
- from __future__ import annotations
- import logging
- import re
- import subprocess
- from pathlib import Path
- from typing import Optional
- import httpx
- from core.models import Card
- logger = logging.getLogger(__name__)
- SCENE_THRESHOLD = 0.3
- MIN_GAP_SECONDS = 1.5
- FALLBACK_MIN = 4
- FALLBACK_TARGET = 8
- SCALE_VF = "scale='min(720,iw)':-2"
- 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 response:
- response.raise_for_status()
- with open(dst, "wb") as file:
- for chunk in response.iter_bytes():
- file.write(chunk)
- def _run(cmd: list[str]) -> str:
- 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)])
- match = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.?\d*)", err)
- if not match:
- return 0.0
- hours, minutes, seconds = match.groups()
- return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
- 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(ts) for ts 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, timestamp in paired:
- if timestamp is not None and last is not None and timestamp - last < min_gap:
- continue
- kept.append((path, timestamp))
- if timestamp is not None:
- last = timestamp
- 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)
- result: list[tuple[Path, Optional[float]]] = []
- for idx in range(count):
- timestamp = duration * (idx + 0.5) / count
- path = out / f"uni_{idx:04d}.jpg"
- _run(
- [
- ffmpeg,
- "-hide_banner",
- "-ss",
- f"{timestamp:.2f}",
- "-i",
- str(video),
- "-frames:v",
- "1",
- "-vf",
- SCALE_VF,
- "-q:v",
- "3",
- str(path),
- ]
- )
- if path.exists():
- result.append((path, round(timestamp, 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]:
- out = Path(out_dir)
- out.mkdir(parents=True, exist_ok=True)
- ffmpeg = _ffmpeg_bin()
- src = Path(video_src)
- if not src.exists():
- 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("scene frames only %d, falling back to uniform sampling", len(frames))
- duration = _probe_duration(ffmpeg, src)
- frames = _uniform_frames(ffmpeg, src, out, duration, FALLBACK_TARGET)
- frames = frames[:max_frames]
- if not frames:
- raise VideoFramesError(f"未能从视频抽出任何帧: {video_src}")
- return [
- Card(index=start_index + idx, kind="frame", url=f"{url_prefix.rstrip('/')}/{path.name}", timestamp=timestamp)
- for idx, (path, timestamp) in enumerate(frames)
- ]
|