mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-09-03 06:24:14 +08:00
Squashed merge of fix/problem-fixes-from-main. - Desktop client (Tauri v2) wiring + packaging - CI workflow updates (Python 3.11, Rust toolchain, Tauri CLI, WebKit deps) - Backend test fix (test_missing_api_key respects CI-injected env var) - build_backend.py: support CI without venv + Windows-safe ASCII output Desktop build workflows (Linux/Windows/macOS) still failing — tracked in #65.
307 lines
12 KiB
Python
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)
|
||
|