feat: 封面管理 - 配置读写 + 片段抽帧 + 智能选帧 #396

Merged
xiaoxia merged 2 commits from feat/cover-management into develop 2026-07-17 10:00:19 +08:00
4 changed files with 1152 additions and 0 deletions
+2
View File
@@ -605,6 +605,7 @@ def save_plan_as_template(
from .edit_plans_ai import router as ai_router
from .edit_plans_clips import router as clips_router
from .edit_plans_clips_batch import router as clips_batch_router
from .edit_plans_cover import router as cover_router
from .edit_plans_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router
@@ -613,3 +614,4 @@ router.include_router(ai_router)
router.include_router(timeline_router)
router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
router.include_router(cover_router)
+315
View File
@@ -0,0 +1,315 @@
"""封面管理 API.
- GET /{plan_id}/cover 获取封面配置
- PUT /{plan_id}/cover 更新封面配置
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
- POST /{plan_id}/cover/smart 智能选帧生成封面
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService
from app.services.cover_service import CoverService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class CoverConfigResponse(BaseModel):
"""封面配置响应"""
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
image_url: str = Field(default="", description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
class CoverUpdateRequest(BaseModel):
"""更新封面配置请求"""
type: Optional[str] = Field(default=None, description="封面类型")
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
class CoverExtractRequest(BaseModel):
"""从片段抽帧生成封面请求"""
clip_id: str = Field(..., description="片段 ID")
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
class CoverSmartRequest(BaseModel):
"""智能选帧请求"""
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
class CoverGenerateResponse(BaseModel):
"""封面生成响应"""
type: str = Field(..., description="封面类型")
image_url: str = Field(..., description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse)
def get_cover(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""获取封面配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
cover = CoverService.get_cover_config(plan.config or {})
return CoverConfigResponse(**cover)
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse)
def update_cover(
plan_id: str,
body: CoverUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""更新封面配置
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current_cover = CoverService.get_cover_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_cover = {**current_cover, **updates}
# 验证 type 值
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
if "type" in updates and updates["type"] not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
)
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["cover"] = new_cover
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
result = CoverService.get_cover_config(updated_plan.config or {})
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
return CoverConfigResponse(**result)
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse)
def extract_cover(
plan_id: str,
body: CoverExtractRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""从指定片段的指定时间点抽帧生成封面"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 获取片段对应的素材
clip = svc.get_clip(body.clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {body.clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材,无法抽帧",
)
# 抽帧生成封面
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.extract_cover_from_clip(
plan_id=plan_id,
asset_id=clip.asset_id,
frame_time=body.frame_time,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"封面抽帧失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
plan_id,
body.clip_id,
body.frame_time,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse)
def smart_cover(
plan_id: str,
body: CoverSmartRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""智能选帧生成封面
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 确定使用哪个片段
clip_id = body.clip_id
asset_id = ""
if clip_id:
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材",
)
asset_id = clip.asset_id
else:
# 找第一个有素材的视频片段
clips = svc.list_clips(plan_id, limit=50, skip=0)
for c in clips:
if c.asset_id and c.clip_type == "video":
asset_id = c.asset_id
clip_id = c.id
break
if not asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有找到可用的视频片段",
)
# 智能选帧
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.generate_smart_cover(
plan_id=plan_id,
asset_id=asset_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"智能封面生成失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
plan_id,
clip_id,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
+276
View File
@@ -0,0 +1,276 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力。
抽帧使用 FFmpeg,上传使用共享存储服务。
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional
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
+559
View File
@@ -0,0 +1,559 @@
"""
封面管理 API 单元测试
覆盖:
- GET /{plan_id}/cover - 获取封面配置
- PUT /{plan_id}/cover - 更新封面配置
- POST /{plan_id}/cover/extract - 从片段抽帧
- POST /{plan_id}/cover/smart - 智能选帧
"""
from __future__ import annotations
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
# ---------------------------------------------------------------------------
# Stub Repository
# ---------------------------------------------------------------------------
class StubEditPlanRepository:
def __init__(self, plans: dict[str, EditPlan] | None = None):
self._plans = plans or {}
self._counter = 100
def _next_id(self) -> str:
self._counter += 1
return f"plan-{self._counter:03d}"
def list_all(self, *, status=None, skip=0, limit=50):
items = list(self._plans.values())
if status is not None:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
items = [p for p in self._plans.values() if p.template_id == template_id]
if status is not None:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def get(self, plan_id: str) -> Optional[EditPlan]:
return self._plans.get(plan_id)
def create(self, plan: EditPlan) -> EditPlan:
if not plan.id:
plan.id = self._next_id()
self._plans[plan.id] = plan
return plan
def update(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def delete(self, plan_id: str) -> bool:
if plan_id in self._plans:
del self._plans[plan_id]
return True
return False
def count(self, *, status=None, template_id=None):
items = list(self._plans.values())
if status is not None:
items = [p for p in items if p.status == status]
if template_id is not None:
items = [p for p in items if p.template_id == template_id]
return len(items)
class StubEditPlanClipRepository:
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
self._clips = clips or {}
self._counter = 200
def _next_id(self) -> str:
self._counter += 1
return f"clip-{self._counter:03d}"
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
items = [c for c in self._clips.values() if c.plan_id == plan_id]
if status is not None:
items = [c for c in items if c.status == status]
items.sort(key=lambda c: c.order)
return items[skip : skip + limit]
def count(self, plan_id, *, status=None):
items = [c for c in self._clips.values() if c.plan_id == plan_id]
if status is not None:
items = [c for c in items if c.status == status]
return len(items)
def get(self, clip_id: str) -> Optional[EditPlanClip]:
return self._clips.get(clip_id)
def create(self, clip: EditPlanClip) -> EditPlanClip:
if not clip.id:
clip.id = self._next_id()
self._clips[clip.id] = clip
return clip
def update(self, clip: EditPlanClip) -> EditPlanClip:
self._clips[clip.id] = clip
return clip
def delete(self, clip_id: str) -> bool:
if clip_id in self._clips:
del self._clips[clip_id]
return True
return False
def delete_by_plan(self, plan_id: str) -> int:
to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
for cid in to_delete:
del self._clips[cid]
return len(to_delete)
class StubAssetRepository:
def __init__(self, assets: dict | None = None):
self._assets = assets or {}
def get(self, asset_id: str):
return self._assets.get(asset_id)
class StubStorageService:
def __init__(self):
self.uploaded = {}
self.downloaded = {}
def upload_file(self, file_or_path, storage_key, content_type="application/octet-stream"):
self.uploaded[storage_key] = file_or_path
return f"https://oss.example.com/{storage_key}"
def get_url(self, storage_key: str) -> str:
return f"https://oss.example.com/{storage_key}"
def download_file(self, storage_key: str, local_path: str):
self.downloaded[storage_key] = local_path
# 创建一个假文件(空文件也可以,因为抽帧会被 mock 掉)
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
with open(local_path, "wb") as f:
f.write(b"fake video data for testing")
# ---------------------------------------------------------------------------
# Test Fixtures
# ---------------------------------------------------------------------------
def _make_sample_plan(plan_id="plan-001", config=None):
if config is None:
config = normalize_plan_config({})
return EditPlan(
id=plan_id,
template_id="tpl-001",
name="测试计划",
status=EditPlanStatus.EDITING,
total_duration=30.0,
config=config,
project_id="",
created_by_user_id="user-001",
created_at=datetime(2026, 7, 16, 10, 0, 0),
updated_at=datetime(2026, 7, 16, 10, 0, 0),
)
def _make_sample_clip(clip_id="clip-001", plan_id="plan-001", asset_id="asset-001", clip_type="video"):
return EditPlanClip(
id=clip_id,
plan_id=plan_id,
clip_type=clip_type,
order=0,
asset_id=asset_id,
text_content="",
start_time=0.0,
duration=10.0,
transition_effect="none",
transition_duration=0.0,
playback_speed=1.0,
status=EditPlanClipStatus.READY,
config={},
created_at=datetime(2026, 7, 16, 10, 0, 0),
updated_at=datetime(2026, 7, 16, 10, 0, 0),
)
def _create_test_app():
import app.api.routes.edit_plans_cover as cover_module
import app.services.edit_plan_service as service_module
from app.api.routes.edit_plans import router
# 创建 stub
plan = _make_sample_plan()
clip = _make_sample_clip()
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
stub_clip_repo = StubEditPlanClipRepository({clip.id: clip})
# 替换服务模块中的 Repository 类
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
app = FastAPI()
app.include_router(router, prefix="/api/v1/edit-plans")
# Mock 认证
def _mock_auth():
mock = MagicMock()
mock.user.id = "user-001"
return mock
# Mock 项目访问检查
import app.api.routes._helpers as helpers_module
original_check = helpers_module.check_project_access
helpers_module.check_project_access = lambda *a, **kw: None
# 覆盖依赖
app.dependency_overrides[cover_module.get_current_user] = _mock_auth
app.dependency_overrides[cover_module.get_db_session] = lambda: MagicMock()
app.dependency_overrides[cover_module.get_project_repository] = lambda: MagicMock()
# Mock storage 和 asset repo
stub_storage = StubStorageService()
stub_asset_repo = StubAssetRepository(
{
"asset-001": MagicMock(
storage_key="videos/test.mp4",
mime_type="video/mp4",
),
"asset-img": MagicMock(
storage_key="images/test.jpg",
mime_type="image/jpeg",
),
}
)
app.dependency_overrides[cover_module.get_storage_service] = lambda: stub_storage
app.dependency_overrides[cover_module.get_asset_repository] = lambda: stub_asset_repo
# 也需要覆盖 edit_plans 主模块的 auth(用于其他路由)
from app.api.routes import edit_plans as main_module
app.dependency_overrides[main_module.get_current_user] = _mock_auth
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
def cleanup():
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
helpers_module.check_project_access = original_check
return app, stub_plan_repo, stub_clip_repo, stub_storage, cleanup
@pytest.fixture
def cover_client():
app, plan_repo, clip_repo, storage, cleanup = _create_test_app()
yield TestClient(app), plan_repo, clip_repo, storage
cleanup()
# ---------------------------------------------------------------------------
# GET /{plan_id}/cover 测试
# ---------------------------------------------------------------------------
class TestGetCover:
def test_get_default_cover(self, cover_client):
c, _, _, _ = cover_client
resp = c.get("/api/v1/edit-plans/plan-001/cover")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "ai_frame"
assert data["image_url"] == ""
assert data["frame_time"] is None
def test_get_cover_not_found(self, cover_client):
c, _, _, _ = cover_client
resp = c.get("/api/v1/edit-plans/plan-nonexist/cover")
assert resp.status_code == 404
def test_get_cover_with_custom_config(self, cover_client):
c, plan_repo, _, _ = cover_client
# 更新 plan 的 cover 配置
plan = plan_repo.get("plan-001")
new_config = dict(plan.config)
new_config["cover"] = {"type": "manual", "image_url": "https://example.com/cover.jpg", "frame_time": 5.5}
plan.config = new_config
plan_repo.update(plan)
resp = c.get("/api/v1/edit-plans/plan-001/cover")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "manual"
assert data["image_url"] == "https://example.com/cover.jpg"
assert data["frame_time"] == 5.5
# ---------------------------------------------------------------------------
# PUT /{plan_id}/cover 测试
# ---------------------------------------------------------------------------
class TestUpdateCover:
def test_update_cover_type_and_url(self, cover_client):
c, plan_repo, _, _ = cover_client
resp = c.put(
"/api/v1/edit-plans/plan-001/cover",
json={"type": "upload", "image_url": "https://example.com/uploaded.jpg"},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "upload"
assert data["image_url"] == "https://example.com/uploaded.jpg"
# 验证存储
plan = plan_repo.get("plan-001")
assert plan.config["cover"]["type"] == "upload"
assert plan.config["cover"]["image_url"] == "https://example.com/uploaded.jpg"
def test_update_cover_frame_time(self, cover_client):
c, plan_repo, _, _ = cover_client
resp = c.put(
"/api/v1/edit-plans/plan-001/cover",
json={"type": "manual", "frame_time": 3.14},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "manual"
assert data["frame_time"] == 3.14
plan = plan_repo.get("plan-001")
assert plan.config["cover"]["frame_time"] == 3.14
def test_update_cover_invalid_type(self, cover_client):
c, _, _, _ = cover_client
resp = c.put(
"/api/v1/edit-plans/plan-001/cover",
json={"type": "invalid_type"},
)
assert resp.status_code == 400
def test_update_cover_not_found(self, cover_client):
c, _, _, _ = cover_client
resp = c.put(
"/api/v1/edit-plans/plan-nonexist/cover",
json={"type": "upload", "image_url": "test.jpg"},
)
assert resp.status_code == 404
def test_update_cover_partial(self, cover_client):
"""只更新 image_urltype 保持不变"""
c, plan_repo, _, _ = cover_client
# 先设置一个类型
c.put("/api/v1/edit-plans/plan-001/cover", json={"type": "manual", "frame_time": 2.0})
# 只更新 image_url
resp = c.put(
"/api/v1/edit-plans/plan-001/cover",
json={"image_url": "https://example.com/new.jpg"},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "manual" # 保持不变
assert data["image_url"] == "https://example.com/new.jpg"
assert data["frame_time"] == 2.0 # 保持不变
# ---------------------------------------------------------------------------
# POST /{plan_id}/cover/extract 测试
# ---------------------------------------------------------------------------
class TestExtractCover:
def test_extract_success(self, cover_client):
c, plan_repo, _, _ = cover_client
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
# mock ffmpeg 抽帧,直接创建输出文件
def fake_extract(video_path, output_path, **kwargs):
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(b"fake jpeg data")
mock_extract.side_effect = fake_extract
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/extract",
json={"clip_id": "clip-001", "frame_time": 2.5},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "manual"
assert data["frame_time"] == 2.5
assert data["image_url"].startswith("https://oss.example.com/covers/")
# 验证 plan.config 已更新
plan = plan_repo.get("plan-001")
assert plan.config["cover"]["type"] == "manual"
assert plan.config["cover"]["frame_time"] == 2.5
def test_extract_clip_not_found(self, cover_client):
c, _, _, _ = cover_client
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/extract",
json={"clip_id": "clip-nonexist", "frame_time": 1.0},
)
assert resp.status_code == 404
def test_extract_plan_not_found(self, cover_client):
c, _, _, _ = cover_client
resp = c.post(
"/api/v1/edit-plans/plan-nonexist/cover/extract",
json={"clip_id": "clip-001", "frame_time": 1.0},
)
assert resp.status_code == 404
def test_extract_clip_no_asset(self, cover_client):
c, _, clip_repo, _ = cover_client
# 创建一个没有 asset 的片段
empty_clip = _make_sample_clip(clip_id="clip-empty", asset_id="")
clip_repo.create(empty_clip)
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/extract",
json={"clip_id": "clip-empty", "frame_time": 1.0},
)
assert resp.status_code == 400
assert "没有关联素材" in resp.json()["detail"]
def test_extract_clip_not_in_plan(self, cover_client):
c, _, clip_repo, _ = cover_client
# 创建属于另一个 plan 的片段
other_clip = _make_sample_clip(clip_id="clip-other", plan_id="plan-other")
clip_repo.create(other_clip)
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/extract",
json={"clip_id": "clip-other", "frame_time": 1.0},
)
assert resp.status_code == 400
assert "不属于该剪辑计划" in resp.json()["detail"]
def test_extract_negative_frame_time(self, cover_client):
c, _, _, _ = cover_client
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/extract",
json={"clip_id": "clip-001", "frame_time": -1.0},
)
assert resp.status_code == 422 # pydantic 校验失败
# ---------------------------------------------------------------------------
# POST /{plan_id}/cover/smart 测试
# ---------------------------------------------------------------------------
class TestSmartCover:
def test_smart_cover_with_clip_id(self, cover_client):
c, plan_repo, _, _ = cover_client
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
def fake_extract(video_path, output_path, **kwargs):
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(b"fake jpeg data")
mock_extract.side_effect = fake_extract
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/smart",
json={"clip_id": "clip-001"},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "ai_frame"
assert data["image_url"].startswith("https://oss.example.com/covers/")
plan = plan_repo.get("plan-001")
assert plan.config["cover"]["type"] == "ai_frame"
def test_smart_cover_auto_pick_first_video(self, cover_client):
c, plan_repo, clip_repo, _ = cover_client
# 添加多个片段,第一个视频应该被选中
clip2 = _make_sample_clip(clip_id="clip-002", clip_type="audio", asset_id="asset-audio")
clip2.order = 1
clip_repo.create(clip2)
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
def fake_extract(video_path, output_path, **kwargs):
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(b"fake jpeg data")
mock_extract.side_effect = fake_extract
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/smart",
json={},
)
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "ai_frame"
def test_smart_cover_clip_not_found(self, cover_client):
c, _, _, _ = cover_client
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/smart",
json={"clip_id": "clip-nonexist"},
)
assert resp.status_code == 404
def test_smart_cover_no_video_clips(self, cover_client):
c, _, clip_repo, _ = cover_client
# 删除原有片段,添加纯音频片段
clip_repo.delete("clip-001")
audio_clip = _make_sample_clip(clip_id="clip-audio", clip_type="audio", asset_id="asset-001")
clip_repo.create(audio_clip)
resp = c.post(
"/api/v1/edit-plans/plan-001/cover/smart",
json={},
)
assert resp.status_code == 400
assert "没有找到可用的视频片段" in resp.json()["detail"]
def test_smart_cover_plan_not_found(self, cover_client):
c, _, _, _ = cover_client
resp = c.post(
"/api/v1/edit-plans/plan-nonexist/cover/smart",
json={},
)
assert resp.status_code == 404