| """ |
| VideoProcessor — 视频预处理。 |
| |
| 将视频缩放到指定分辨率、控制帧率,输出单个处理后视频文件用于嵌入。 |
| """ |
|
|
| import logging |
| import subprocess |
| import tempfile |
| import uuid |
| from pathlib import Path |
| from typing import Dict, List, Optional |
|
|
| from config.settings import settings |
|
|
| logger = logging.getLogger(__name__) |
|
|
| SUPPORTED_VIDEO_FORMATS = { |
| ".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv", |
| } |
|
|
|
|
| class VideoProcessor: |
| """视频预处理器 — 单视频单向量。 |
| |
| 将视频缩放到指定分辨率、控制帧率,输出一个 MP4 文件, |
| 整个视频作为一个 VIDEO 向量嵌入。 |
| |
| Usage: |
| proc = VideoProcessor(target_size=256, max_frames=32) |
| result = proc.process("video.mp4") |
| # result["video_bytes"] → 处理后的视频数据 |
| """ |
|
|
| def __init__( |
| self, |
| target_size: int = 256, |
| max_frames: int = 32, |
| ): |
| self.target_size = target_size |
| self.max_frames = max_frames |
|
|
| def process(self, video_path: str) -> dict: |
| """处理视频文件。 |
| |
| 缩放 + 帧率控制 → 单个 MP4 文件。 |
| |
| Returns: |
| {chunk_id, video_bytes, metadata} |
| """ |
| path = Path(video_path) |
| if not path.exists(): |
| raise FileNotFoundError(f"视频文件不存在: {video_path}") |
|
|
| suffix = path.suffix.lower() |
| if suffix not in SUPPORTED_VIDEO_FORMATS: |
| raise ValueError(f"不支持的视频格式: {suffix}") |
|
|
| |
| metadata = self._get_video_metadata(video_path) |
| duration = metadata.get("duration", 0) |
|
|
| with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp: |
| out_path = tmp.name |
|
|
| try: |
| |
| target_fps = min(self.max_frames / duration, 30) if duration > 0 else 10 |
|
|
| cmd = [ |
| "ffmpeg", "-y", |
| "-i", video_path, |
| "-vf", ( |
| f"scale='min({self.target_size},iw)':min'({self.target_size},ih)':force_original_aspect_ratio=1," |
| f"fps={target_fps}," |
| f"pad={self.target_size}:{self.target_size}:(ow-iw)/2:(oh-ih)/2:black" |
| ), |
| "-c:v", "libx264", |
| "-preset", "ultrafast", |
| "-crf", "28", |
| "-an", |
| "-movflags", "+faststart", |
| out_path, |
| ] |
| subprocess.run(cmd, capture_output=True, timeout=300) |
|
|
| out_file = Path(out_path) |
| if out_file.exists() and out_file.stat().st_size > 0: |
| video_bytes = out_file.read_bytes() |
| chunk_id = str(uuid.uuid4()) |
| return { |
| "chunk_id": chunk_id, |
| "video_bytes": video_bytes, |
| "text": f"[视频: {path.name}, {duration:.0f}s, {target_fps:.1f}fps]", |
| "metadata": { |
| "source_file": str(path.resolve()), |
| "source_file_name": path.name, |
| "duration_sec": round(duration, 2), |
| "target_size": self.target_size, |
| "target_fps": round(target_fps, 1), |
| "max_frames": self.max_frames, |
| "file_size_mb": round(out_file.stat().st_size / (1024 * 1024), 2), |
| }, |
| } |
| else: |
| raise RuntimeError("ffmpeg 输出文件为空") |
|
|
| finally: |
| Path(out_path).unlink(missing_ok=True) |
|
|
| def _get_video_metadata(self, video_path: str) -> dict: |
| """获取视频元数据 (ffprobe)。""" |
| try: |
| import json |
| result = subprocess.run( |
| ["ffprobe", "-v", "quiet", "-print_format", "json", |
| "-show_format", "-show_streams", video_path], |
| capture_output=True, text=True, timeout=30, |
| ) |
| data = json.loads(result.stdout) |
| fmt = data.get("format", {}) |
| video_stream = None |
| for s in data.get("streams", []): |
| if s.get("codec_type") == "video": |
| video_stream = s |
| break |
| duration = float(fmt.get("duration", 0)) |
| return { |
| "duration": round(duration, 2), |
| "fps": self._parse_fps(video_stream), |
| "resolution": ( |
| f"{video_stream.get('width','?')}x{video_stream.get('height','?')}" |
| if video_stream else "unknown" |
| ), |
| "codec": video_stream.get("codec_name", "unknown") if video_stream else "unknown", |
| } |
| except Exception: |
| return {"duration": 0, "fps": 0, "resolution": "unknown", "codec": "unknown"} |
|
|
| @staticmethod |
| def _parse_fps(video_stream: Optional[dict]) -> float: |
| if not video_stream: |
| return 0 |
| r = video_stream.get("r_frame_rate", "0/1") |
| if "/" in r: |
| parts = r.split("/") |
| return round(float(parts[0]) / float(parts[1]), 2) if float(parts[1]) > 0 else 0 |
| try: |
| return float(r) |
| except (ValueError, TypeError): |
| return 0 |
|
|