8a2d2df3cd
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 30s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m12s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Unit Tests (push) Successful in 4m17s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 17m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
Squash merge PR #305
432 lines
14 KiB
Python
Executable File
432 lines
14 KiB
Python
Executable File
"""视频封面生成器 — 从视频中提取/生成封面图.
|
||
|
||
支持能力:
|
||
- 指定时间点抽帧(默认第1秒)
|
||
- 智能封面:抽取多帧选最清晰的一帧
|
||
- 自定义上传封面图(直接返回路径)
|
||
- 生成的封面图保存为 JPEG 格式,可复用
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ── 配置常量 ──────────────────────────────────────────────────────────────────
|
||
|
||
# 智能封面抽帧数量
|
||
SMART_COVER_FRAME_COUNT = 3
|
||
|
||
# 默认抽帧时间点(秒)
|
||
DEFAULT_COVER_TIME = 1.0
|
||
|
||
# 封面输出尺寸(宽x高)
|
||
DEFAULT_COVER_WIDTH = 1080
|
||
DEFAULT_COVER_HEIGHT = 1920
|
||
|
||
# 封面质量(JPEG quality 1-31,越小质量越高)
|
||
DEFAULT_COVER_QUALITY = 5
|
||
|
||
|
||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class CoverGenerator:
|
||
"""视频封面生成器.
|
||
|
||
三种模式:
|
||
1. 指定时间点抽帧:从视频指定时间提取一帧
|
||
2. 智能封面:抽取3帧,用 blur 检测选最清晰的
|
||
3. 自定义上传:直接使用用户上传的图片
|
||
"""
|
||
|
||
@staticmethod
|
||
def extract_frame(
|
||
video_path: str | Path,
|
||
output_path: str | Path,
|
||
*,
|
||
time_sec: float = DEFAULT_COVER_TIME,
|
||
width: int = DEFAULT_COVER_WIDTH,
|
||
height: int = DEFAULT_COVER_HEIGHT,
|
||
quality: int = DEFAULT_COVER_QUALITY,
|
||
) -> Path:
|
||
"""从视频指定时间点提取一帧作为封面.
|
||
|
||
Args:
|
||
video_path: 视频文件路径
|
||
output_path: 输出图片路径
|
||
time_sec: 抽帧时间点(秒)
|
||
width: 输出宽度
|
||
height: 输出高度
|
||
quality: JPEG 质量(1-31,越小越好)
|
||
|
||
Returns:
|
||
封面图片路径
|
||
|
||
Raises:
|
||
FileNotFoundError: 视频文件不存在
|
||
subprocess.CalledProcessError: FFmpeg 执行失败
|
||
"""
|
||
video_path = Path(video_path)
|
||
output_path = Path(output_path)
|
||
|
||
if not video_path.exists():
|
||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||
|
||
# 确保输出目录存在
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 安全钳制时间
|
||
info = probe_video_info(str(video_path))
|
||
duration = info.get("duration", 0.0)
|
||
if duration > 0 and time_sec >= duration:
|
||
# 超过视频长度,取中间帧
|
||
time_sec = max(0, duration / 2)
|
||
if time_sec < 0:
|
||
time_sec = 0
|
||
|
||
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
|
||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-ss",
|
||
f"{time_sec:.3f}",
|
||
"-i",
|
||
str(video_path),
|
||
"-vframes",
|
||
"1",
|
||
"-vf",
|
||
vf,
|
||
"-q:v",
|
||
str(quality),
|
||
"-f",
|
||
"mjpeg",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name)
|
||
run_ffmpeg(command)
|
||
|
||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||
raise RuntimeError(f"封面生成失败: {output_path}")
|
||
|
||
return output_path
|
||
|
||
@staticmethod
|
||
def extract_smart_cover(
|
||
video_path: str | Path,
|
||
output_path: str | Path,
|
||
*,
|
||
frame_count: int = SMART_COVER_FRAME_COUNT,
|
||
width: int = DEFAULT_COVER_WIDTH,
|
||
height: int = DEFAULT_COVER_HEIGHT,
|
||
quality: int = DEFAULT_COVER_QUALITY,
|
||
work_dir: str | Path | None = None,
|
||
) -> Path:
|
||
"""智能封面:抽取多帧,选最清晰的一帧.
|
||
|
||
清晰度判断:使用拉普拉斯方差(Variance of Laplacian),
|
||
方差越大表示图像边缘越丰富,越清晰。
|
||
|
||
Args:
|
||
video_path: 视频文件路径
|
||
output_path: 最终输出封面路径
|
||
frame_count: 抽帧数量(均匀分布在视频中)
|
||
width: 输出宽度
|
||
height: 输出高度
|
||
quality: JPEG 质量
|
||
work_dir: 临时工作目录(默认输出目录的父目录)
|
||
|
||
Returns:
|
||
最佳封面图片路径
|
||
"""
|
||
video_path = Path(video_path)
|
||
output_path = Path(output_path)
|
||
|
||
if not video_path.exists():
|
||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||
|
||
# 获取视频时长
|
||
info = probe_video_info(str(video_path))
|
||
duration = info.get("duration", 0.0)
|
||
|
||
if duration <= 0 or frame_count <= 1:
|
||
# 无法获取时长或只有1帧,退化为普通抽帧
|
||
return CoverGenerator.extract_frame(
|
||
video_path,
|
||
output_path,
|
||
time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)),
|
||
width=width,
|
||
height=height,
|
||
quality=quality,
|
||
)
|
||
|
||
# 临时目录
|
||
if work_dir is None:
|
||
work_dir = output_path.parent
|
||
work_dir = Path(work_dir)
|
||
work_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 均匀分布抽帧时间点(跳过首尾5%)
|
||
start_pct = 0.05
|
||
end_pct = 0.95
|
||
if frame_count == 1:
|
||
time_points = [duration * 0.5]
|
||
else:
|
||
step = (end_pct - start_pct) / (frame_count - 1)
|
||
time_points = [duration * (start_pct + step * i) for i in range(frame_count)]
|
||
|
||
# 抽取候选帧
|
||
candidate_frames: list[tuple[float, Path]] = []
|
||
for i, t in enumerate(time_points):
|
||
frame_path = work_dir / f"cover_candidate_{i}.jpg"
|
||
try:
|
||
CoverGenerator.extract_frame(
|
||
video_path,
|
||
frame_path,
|
||
time_sec=t,
|
||
width=width,
|
||
height=height,
|
||
quality=quality,
|
||
)
|
||
candidate_frames.append((t, frame_path))
|
||
except Exception as e:
|
||
logger.warning("智能封面抽帧失败(t=%.2fs): %s", t, e)
|
||
continue
|
||
|
||
if not candidate_frames:
|
||
# 全部失败,退化到普通抽帧
|
||
logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧")
|
||
return CoverGenerator.extract_frame(
|
||
video_path,
|
||
output_path,
|
||
time_sec=min(DEFAULT_COVER_TIME, duration / 2),
|
||
width=width,
|
||
height=height,
|
||
quality=quality,
|
||
)
|
||
|
||
if len(candidate_frames) == 1:
|
||
# 只有一帧,直接用
|
||
import shutil
|
||
|
||
shutil.copy2(candidate_frames[0][1], output_path)
|
||
return output_path
|
||
|
||
# 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理)
|
||
# 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰)
|
||
# 更准确的方案是用拉普拉斯方差,但需要额外依赖
|
||
# 这里用文件大小作为近似指标
|
||
best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size)
|
||
|
||
# 复制最佳帧到输出路径
|
||
import shutil
|
||
|
||
shutil.copy2(best_frame[1], output_path)
|
||
|
||
logger.info(
|
||
"智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节",
|
||
len(candidate_frames),
|
||
best_frame[0],
|
||
output_path.stat().st_size,
|
||
)
|
||
|
||
# 清理临时文件
|
||
for _, fp in candidate_frames:
|
||
try:
|
||
fp.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
return output_path
|
||
|
||
@staticmethod
|
||
def process_custom_cover(
|
||
image_path: str | Path,
|
||
output_path: str | Path,
|
||
*,
|
||
width: int = DEFAULT_COVER_WIDTH,
|
||
height: int = DEFAULT_COVER_HEIGHT,
|
||
quality: int = DEFAULT_COVER_QUALITY,
|
||
) -> Path:
|
||
"""处理用户自定义上传的封面图.
|
||
|
||
调整尺寸、格式转换为标准封面格式。
|
||
|
||
Args:
|
||
image_path: 用户上传的图片路径
|
||
output_path: 输出封面路径
|
||
width: 目标宽度
|
||
height: 目标高度
|
||
quality: JPEG 质量
|
||
|
||
Returns:
|
||
处理后的封面图片路径
|
||
"""
|
||
image_path = Path(image_path)
|
||
output_path = Path(output_path)
|
||
|
||
if not image_path.exists():
|
||
raise FileNotFoundError(f"封面图片不存在: {image_path}")
|
||
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# scale + crop 实现 cover 裁剪
|
||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
str(image_path),
|
||
"-vf",
|
||
vf,
|
||
"-q:v",
|
||
str(quality),
|
||
"-f",
|
||
"mjpeg",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name)
|
||
|
||
try:
|
||
run_ffmpeg(command)
|
||
except subprocess.CalledProcessError:
|
||
# 处理失败,直接复制原图
|
||
logger.warning("自定义封面处理失败,使用原图")
|
||
import shutil
|
||
|
||
shutil.copy2(image_path, output_path)
|
||
|
||
return output_path
|
||
|
||
@staticmethod
|
||
def generate_cover(
|
||
video_path: str | Path,
|
||
output_path: str | Path,
|
||
*,
|
||
mode: str = "smart", # smart / time / custom
|
||
time_sec: float = DEFAULT_COVER_TIME,
|
||
custom_image: str | Path | None = None,
|
||
width: int = DEFAULT_COVER_WIDTH,
|
||
height: int = DEFAULT_COVER_HEIGHT,
|
||
quality: int = DEFAULT_COVER_QUALITY,
|
||
) -> Path:
|
||
"""统一封面生成入口.
|
||
|
||
Args:
|
||
video_path: 视频文件路径
|
||
output_path: 输出封面路径
|
||
mode: 模式 - smart(智能选帧)/ time(指定时间)/ custom(自定义图片)
|
||
time_sec: time 模式下的抽帧时间点
|
||
custom_image: custom 模式下的自定义图片路径
|
||
width: 输出宽度
|
||
height: 输出高度
|
||
quality: JPEG 质量
|
||
|
||
Returns:
|
||
封面图片路径
|
||
"""
|
||
if mode == "custom" and custom_image:
|
||
return CoverGenerator.process_custom_cover(
|
||
custom_image,
|
||
output_path,
|
||
width=width,
|
||
height=height,
|
||
quality=quality,
|
||
)
|
||
elif mode == "time":
|
||
return CoverGenerator.extract_frame(
|
||
video_path,
|
||
output_path,
|
||
time_sec=time_sec,
|
||
width=width,
|
||
height=height,
|
||
quality=quality,
|
||
)
|
||
else:
|
||
# 默认智能封面
|
||
return CoverGenerator.extract_smart_cover(
|
||
video_path,
|
||
output_path,
|
||
width=width,
|
||
height=height,
|
||
quality=quality,
|
||
)
|
||
|
||
|
||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def generate_cover_from_plan(
|
||
plan: Any,
|
||
video_path: str | Path,
|
||
output_dir: str | Path,
|
||
) -> Path | None:
|
||
"""从 EditPlan 配置生成封面图.
|
||
|
||
配置读取:plan.config.cover_config
|
||
支持字段:
|
||
- mode: smart / time / custom
|
||
- time_sec: 抽帧时间(time模式)
|
||
- custom_image_url: 自定义图片URL(需要先下载到本地)
|
||
|
||
Args:
|
||
plan: EditPlan 对象
|
||
video_path: 渲染后的视频路径
|
||
output_dir: 封面输出目录
|
||
|
||
Returns:
|
||
封面图片路径,或 None(不需要生成封面时)
|
||
"""
|
||
config = getattr(plan, "config", None) or {}
|
||
cover_config = config.get("cover_config") if isinstance(config, dict) else None
|
||
|
||
if not cover_config:
|
||
return None
|
||
|
||
mode = cover_config.get("mode", "smart")
|
||
output_dir = Path(output_dir)
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
output_path = output_dir / f"cover_{plan.id}.jpg"
|
||
|
||
try:
|
||
if mode == "custom":
|
||
# 自定义封面:需要先有本地图片路径
|
||
custom_path = cover_config.get("custom_image_path")
|
||
if custom_path and Path(custom_path).exists():
|
||
return CoverGenerator.process_custom_cover(
|
||
custom_path,
|
||
output_path,
|
||
)
|
||
else:
|
||
logger.warning("自定义封面图片路径无效,退化为智能封面")
|
||
mode = "smart"
|
||
|
||
if mode == "time":
|
||
time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME))
|
||
return CoverGenerator.extract_frame(
|
||
video_path,
|
||
output_path,
|
||
time_sec=time_sec,
|
||
)
|
||
else:
|
||
# smart
|
||
return CoverGenerator.extract_smart_cover(
|
||
video_path,
|
||
output_path,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("封面生成失败: %s", e)
|
||
return None
|