09d2b12ea8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 18m38s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m7s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
277 lines
9.0 KiB
Python
277 lines
9.0 KiB
Python
"""封面管理服务.
|
|
|
|
提供封面配置管理和从视频抽帧生成封面的能力。
|
|
抽帧使用 FFmpeg,上传使用共享存储服务。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
|
|
DEFAULT_COVER_WIDTH = 1080
|
|
DEFAULT_COVER_HEIGHT = 1920
|
|
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
|
|
COVER_STORAGE_PREFIX = "covers"
|
|
|
|
|
|
class CoverService:
|
|
"""封面管理服务."""
|
|
|
|
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
|
|
self._storage = storage_service
|
|
self._asset_repo = asset_repository
|
|
|
|
# ── 配置读写 ──────────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""从 plan.config 中提取封面配置.
|
|
|
|
Args:
|
|
plan_config: 剪辑计划的 config 字段
|
|
|
|
Returns:
|
|
封面配置 dict
|
|
"""
|
|
cover = plan_config.get("cover", {})
|
|
if not isinstance(cover, dict):
|
|
cover = {}
|
|
# 确保默认字段存在
|
|
return {
|
|
"type": cover.get("type", "ai_frame"),
|
|
"image_url": cover.get("image_url", ""),
|
|
"frame_time": cover.get("frame_time"),
|
|
}
|
|
|
|
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
|
|
|
|
def extract_cover_from_clip(
|
|
self,
|
|
plan_id: str,
|
|
asset_id: str,
|
|
frame_time: float = 1.0,
|
|
*,
|
|
width: int = DEFAULT_COVER_WIDTH,
|
|
height: int = DEFAULT_COVER_HEIGHT,
|
|
quality: int = DEFAULT_COVER_QUALITY,
|
|
) -> Dict[str, Any]:
|
|
"""从指定素材的指定时间点抽取一帧作为封面.
|
|
|
|
Args:
|
|
plan_id: 剪辑计划 ID(用于生成存储路径)
|
|
asset_id: 素材 ID
|
|
frame_time: 抽帧时间点(秒)
|
|
width: 输出宽度
|
|
height: 输出高度
|
|
quality: JPEG 质量
|
|
|
|
Returns:
|
|
封面数据 dict,包含 type / image_url / frame_time
|
|
|
|
Raises:
|
|
ValueError: 素材不存在或不是视频
|
|
RuntimeError: 抽帧或上传失败
|
|
"""
|
|
# 1. 获取素材
|
|
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
|
|
if not asset:
|
|
raise ValueError(f"素材不存在: {asset_id}")
|
|
|
|
storage_key = getattr(asset, "storage_key", "")
|
|
if not storage_key:
|
|
raise ValueError(f"素材没有文件: {asset_id}")
|
|
|
|
mime_type = getattr(asset, "mime_type", "")
|
|
if mime_type and not mime_type.startswith("video"):
|
|
raise ValueError(f"素材不是视频类型: {mime_type}")
|
|
|
|
# 2. 下载视频到临时目录
|
|
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
|
|
tmp_path = Path(tmp_dir)
|
|
video_path = tmp_path / f"source_{asset_id[:8]}"
|
|
|
|
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
|
|
try:
|
|
self._storage.download_file(storage_key, str(video_path))
|
|
except Exception as e:
|
|
raise RuntimeError(f"下载素材失败: {e}") from e
|
|
|
|
if not video_path.exists() or video_path.stat().st_size == 0:
|
|
raise RuntimeError("下载的素材文件为空")
|
|
|
|
# 3. FFmpeg 抽帧
|
|
output_path = tmp_path / "cover.jpg"
|
|
self._extract_frame(
|
|
video_path=video_path,
|
|
output_path=output_path,
|
|
time_sec=frame_time,
|
|
width=width,
|
|
height=height,
|
|
quality=quality,
|
|
)
|
|
|
|
if not output_path.exists() or output_path.stat().st_size == 0:
|
|
raise RuntimeError("封面抽帧失败")
|
|
|
|
# 4. 上传到 OSS
|
|
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
|
|
logger.info("上传封面到存储: key=%s", cover_key)
|
|
|
|
try:
|
|
self._storage.upload_file(
|
|
file_or_path=str(output_path),
|
|
storage_key=cover_key,
|
|
content_type="image/jpeg",
|
|
)
|
|
except Exception as e:
|
|
raise RuntimeError(f"上传封面失败: {e}") from e
|
|
|
|
# 5. 获取访问 URL
|
|
try:
|
|
image_url = self._storage.get_url(cover_key)
|
|
except Exception:
|
|
image_url = cover_key # 降级为 storage_key
|
|
|
|
logger.info(
|
|
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
|
|
plan_id,
|
|
asset_id,
|
|
frame_time,
|
|
output_path.stat().st_size if output_path.exists() else 0,
|
|
)
|
|
|
|
return {
|
|
"type": "manual",
|
|
"image_url": image_url,
|
|
"frame_time": frame_time,
|
|
}
|
|
|
|
def generate_smart_cover(
|
|
self,
|
|
plan_id: str,
|
|
asset_id: str,
|
|
*,
|
|
width: int = DEFAULT_COVER_WIDTH,
|
|
height: int = DEFAULT_COVER_HEIGHT,
|
|
quality: int = DEFAULT_COVER_QUALITY,
|
|
) -> Dict[str, Any]:
|
|
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
|
|
|
|
Args:
|
|
plan_id: 剪辑计划 ID
|
|
asset_id: 素材 ID
|
|
width: 输出宽度
|
|
height: 输出高度
|
|
quality: JPEG 质量
|
|
|
|
Returns:
|
|
封面数据 dict
|
|
"""
|
|
# 简单实现:取视频 1/3 处的帧作为智能封面
|
|
# 更复杂的多帧选清晰帧可以后续优化
|
|
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
|
|
|
|
result = self.extract_cover_from_clip(
|
|
plan_id=plan_id,
|
|
asset_id=asset_id,
|
|
frame_time=frame_time,
|
|
width=width,
|
|
height=height,
|
|
quality=quality,
|
|
)
|
|
result["type"] = "ai_frame"
|
|
return result
|
|
|
|
# ── 内部方法 ──────────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _extract_frame(
|
|
video_path: Path,
|
|
output_path: Path,
|
|
*,
|
|
time_sec: float,
|
|
width: int,
|
|
height: int,
|
|
quality: int,
|
|
) -> None:
|
|
"""使用 FFmpeg 从视频中抽取一帧.
|
|
|
|
Args:
|
|
video_path: 视频文件路径
|
|
output_path: 输出图片路径
|
|
time_sec: 抽帧时间点(秒)
|
|
width: 输出宽度
|
|
height: 输出高度
|
|
quality: JPEG 质量
|
|
"""
|
|
import subprocess
|
|
|
|
# scale + crop 实现 cover 裁剪
|
|
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
|
|
|
command = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-ss",
|
|
f"{time_sec:.3f}",
|
|
"-i",
|
|
str(video_path),
|
|
"-vframes",
|
|
"1",
|
|
"-vf",
|
|
vf,
|
|
"-q:v",
|
|
str(quality),
|
|
"-f",
|
|
"mjpeg",
|
|
str(output_path),
|
|
]
|
|
|
|
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
if result.returncode != 0:
|
|
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
|
|
# 尝试不使用 scale+crop 的简化命令
|
|
simple_command = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-ss",
|
|
f"{time_sec:.3f}",
|
|
"-i",
|
|
str(video_path),
|
|
"-vframes",
|
|
"1",
|
|
"-q:v",
|
|
str(quality),
|
|
"-f",
|
|
"mjpeg",
|
|
str(output_path),
|
|
]
|
|
result2 = subprocess.run(
|
|
simple_command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
if result2.returncode != 0:
|
|
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
|
|
except subprocess.TimeoutExpired as e:
|
|
raise RuntimeError("FFmpeg 抽帧超时") from e
|
|
except FileNotFoundError as e:
|
|
raise RuntimeError("FFmpeg 不可用") from e
|