Files
autoclip/backend/utils/thumbnail_generator.py
Kris K 593cc62bd5 fix: desktop client, CI fixes, and backend (#61)
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.
2026-05-28 15:48:49 +08:00

307 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
视频缩略图生成工具
"""
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)