Files
wehub-resource-sync 41b710f9c7
CI / Frontend checks (push) Failing after 0s
CI / Backend tests (push) Failing after 1s
I18n Documentation Sync / sync-docs (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:28:40 +08:00

307 lines
12 KiB
Python

"""
视频缩略图生成工具
"""
import subprocess
import logging
from pathlib import Path
from typing import Optional
import base64
from PIL import Image
import io
from .ffmpeg_utils import get_ffmpeg_path, get_ffprobe_path
logger = logging.getLogger(__name__)
class ThumbnailGenerator:
"""视频缩略图生成器"""
def __init__(self):
self.supported_formats = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv']
def generate_thumbnail(self, video_path: Path, output_path: Optional[Path] = None,
time_offset: Optional[float] = None, width: int = 320, height: int = 180) -> Optional[Path]:
"""
生成视频缩略图 - 使用智能帧选择策略
Args:
video_path: 视频文件路径
output_path: 输出缩略图路径,如果为None则自动生成
time_offset: 提取时间点(秒),如果为None则自动选择最佳时间点
width: 缩略图宽度
height: 缩略图高度
Returns:
生成的缩略图路径,失败返回None
"""
try:
if not video_path.exists():
logger.error(f"视频文件不存在: {video_path}")
return None
# 检查文件格式
if video_path.suffix.lower() not in self.supported_formats:
logger.error(f"不支持的视频格式: {video_path.suffix}")
return None
# 生成输出路径
if output_path is None:
output_path = video_path.parent / f"{video_path.stem}_thumbnail.jpg"
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 智能选择时间点
if time_offset is None:
time_offset = self._get_optimal_thumbnail_time(video_path)
# 检查是否使用视频封面
if time_offset == -1.0:
# 使用视频封面
cover_path = video_path.parent / f"{video_path.stem}_cover.jpg"
if cover_path.exists():
# 直接复制封面文件并调整大小
ffmpeg_bin = get_ffmpeg_path()
cmd = [
ffmpeg_bin,
'-i', str(cover_path),
'-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black',
'-q:v', '2',
'-y',
str(output_path)
]
logger.info(f"使用视频封面生成缩略图: {cover_path} -> {output_path}")
else:
# 封面不存在,回退到默认时间点
time_offset = 1.0
cmd = [
'ffmpeg',
'-ss', str(time_offset),
'-i', str(video_path),
'-vframes', '1',
'-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black',
'-q:v', '2',
'-y',
str(output_path)
]
logger.info(f"封面不存在,回退到默认时间点: {time_offset}秒")
else:
# 使用指定时间点
logger.info(f"为视频 {video_path.name} 选择缩略图时间点: {time_offset}秒")
ffmpeg_bin = get_ffmpeg_path()
cmd = [
ffmpeg_bin,
'-ss', str(time_offset), # 跳转到指定时间
'-i', str(video_path), # 输入视频
'-vframes', '1', # 只提取一帧
'-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black', # 缩放并居中
'-q:v', '2', # 高质量
'-y', # 覆盖输出文件
str(output_path)
]
logger.info(f"生成缩略图: {video_path} -> {output_path}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
logger.info(f"缩略图生成成功: {output_path}")
return output_path
else:
logger.error(f"缩略图生成失败: {result.stderr}")
return None
except subprocess.TimeoutExpired:
logger.error(f"缩略图生成超时: {video_path}")
return None
except Exception as e:
logger.error(f"缩略图生成异常: {e}")
return None
def _extract_video_cover(self, video_path: Path) -> Optional[Path]:
"""
尝试提取视频的封面图片
Args:
video_path: 视频文件路径
Returns:
封面图片路径,如果不存在则返回None
"""
try:
# 检查是否有嵌入的封面图片
ffmpeg_bin = get_ffmpeg_path()
cmd = [
ffmpeg_bin,
'-i', str(video_path),
'-an', # 禁用音频
'-vcodec', 'copy', # 复制视频流
'-f', 'image2',
'-vframes', '1',
'-y',
str(video_path.parent / f"{video_path.stem}_cover.jpg")
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
cover_path = video_path.parent / f"{video_path.stem}_cover.jpg"
if cover_path.exists() and cover_path.stat().st_size > 0:
logger.info(f"成功提取视频封面: {cover_path}")
return cover_path
return None
except Exception as e:
logger.debug(f"提取视频封面失败: {e}")
return None
def _get_optimal_thumbnail_time(self, video_path: Path) -> float:
"""
智能选择最佳的缩略图时间点
策略:
1. 优先尝试提取视频封面(如果存在)
2. 如果视频很短(<30秒),选择中间位置
3. 如果视频中等长度(30秒-5分钟),选择10%位置
4. 如果视频很长(>5分钟),选择5%位置
5. 避免选择开头和结尾,因为这些位置通常是黑屏或过渡
Args:
video_path: 视频文件路径
Returns:
最佳时间点(秒)
"""
try:
# 首先尝试提取视频封面
cover_path = self._extract_video_cover(video_path)
if cover_path:
logger.info(f"使用视频封面作为缩略图: {cover_path}")
# 如果成功提取封面,返回一个特殊值表示使用封面
return -1.0 # 特殊值,表示使用封面
# 获取视频信息
video_info = self.get_video_info(video_path)
if not video_info:
logger.warning(f"无法获取视频信息,使用默认时间点: {video_path}")
return 1.0
# 获取视频时长
duration = float(video_info.get('format', {}).get('duration', 0))
if duration <= 0:
logger.warning(f"视频时长为0,使用默认时间点: {video_path}")
return 1.0
logger.info(f"视频时长: {duration}秒")
# 智能选择时间点
if duration < 30:
# 短视频:选择中间位置
optimal_time = duration * 0.5
elif duration < 300: # 5分钟
# 中等长度:选择10%位置,避免开头可能的黑屏
optimal_time = duration * 0.1
else:
# 长视频:选择5%位置
optimal_time = duration * 0.05
# 确保时间点合理(至少1秒,最多不超过视频长度)
optimal_time = max(1.0, min(optimal_time, duration - 1))
logger.info(f"为视频 {video_path.name} 选择最佳时间点: {optimal_time}秒 (总时长: {duration}秒)")
return optimal_time
except Exception as e:
logger.error(f"选择最佳时间点失败: {e}")
return 1.0
def generate_thumbnail_base64(self, video_path: Path, time_offset: Optional[float] = None,
width: int = 320, height: int = 180) -> Optional[str]:
"""
生成缩略图并返回base64编码
Args:
video_path: 视频文件路径
time_offset: 提取时间点(秒),如果为None则自动选择最佳时间点
width: 缩略图宽度
height: 缩略图高度
Returns:
base64编码的缩略图数据,失败返回None
"""
try:
# 生成临时缩略图
temp_path = video_path.parent / f"temp_thumbnail_{video_path.stem}.jpg"
thumbnail_path = self.generate_thumbnail(video_path, temp_path, time_offset, width, height)
if thumbnail_path and thumbnail_path.exists():
# 读取图片并转换为base64
with open(thumbnail_path, 'rb') as f:
image_data = f.read()
base64_data = base64.b64encode(image_data).decode('utf-8')
# 清理临时文件
try:
temp_path.unlink()
except:
pass
return f"data:image/jpeg;base64,{base64_data}"
else:
return None
except Exception as e:
logger.error(f"生成base64缩略图失败: {e}")
return None
def get_video_info(self, video_path: Path) -> Optional[dict]:
"""
获取视频信息
Args:
video_path: 视频文件路径
Returns:
视频信息字典,失败返回None
"""
try:
if not video_path.exists():
return None
ffprobe_bin = get_ffprobe_path()
cmd = [
ffprobe_bin,
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
str(video_path)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
import json
return json.loads(result.stdout)
else:
logger.error(f"获取视频信息失败: {result.stderr}")
return None
except Exception as e:
logger.error(f"获取视频信息异常: {e}")
return None
# 便捷函数
def generate_project_thumbnail(project_id: str, video_path: Path) -> Optional[str]:
"""
为项目生成缩略图
Args:
project_id: 项目ID
video_path: 视频文件路径
Returns:
base64编码的缩略图数据
"""
generator = ThumbnailGenerator()
return generator.generate_thumbnail_base64(video_path)