File size: 5,439 Bytes
5316124 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """
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
|