Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1984b53890 | |||
| b7090517e0 | |||
| 9c6af4dd45 | |||
| 7b9e803603 | |||
| 12d7b9ac2f | |||
| 887e66b0f9 | |||
| 9520507f38 | |||
| 0d99a28fc2 | |||
| d995617375 | |||
| 947b3ed86e | |||
| 75f19ae9d9 | |||
| 880237ff5f | |||
| 8ac6d1e6bc | |||
| 7d6c0cc67e | |||
| cb1ba46d30 | |||
| b174a792b2 | |||
| 43fa4a5575 | |||
| cbf1f228bd | |||
| 0ec7a13562 | |||
| 308fbf2130 | |||
| 6c14170b94 | |||
| 3d5438a3af | |||
| 441d61127d | |||
| d23db7654e | |||
| 7ad076243b | |||
| 7b534c950b | |||
| e79464b9ef | |||
| 796bbbc8ab | |||
| d6f0eef929 | |||
| b7e0de2e0e |
@@ -17,6 +17,7 @@ from app.core.task_enqueue import (
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
@@ -154,29 +155,6 @@ def _mark_task_failed(repo, task, reason: str) -> None:
|
||||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
||||
|
||||
|
||||
def _sign_video_url(raw_url: str) -> str:
|
||||
"""为私有 OSS bucket 的视频 URL 生成预签名下载链接。
|
||||
|
||||
有效期 2 小时,签名失败时降级返回原始 URL。
|
||||
"""
|
||||
if not raw_url:
|
||||
return ""
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
signed = storage.get_download_url(raw_url, expires_seconds=7200)
|
||||
# 如果返回的 URL 与原始 URL 完全不同且不是签名 URL(说明 bucket 未配置),
|
||||
# 降级返回原始 URL
|
||||
if signed and signed != raw_url:
|
||||
return signed
|
||||
if signed == raw_url:
|
||||
return raw_url
|
||||
# signed 为空或与 raw_url 无关,返回原始
|
||||
return raw_url
|
||||
except Exception:
|
||||
logger.warning("[预览] URL签名失败,降级返回原始URL: %s", raw_url[:100], exc_info=True)
|
||||
return raw_url
|
||||
|
||||
|
||||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
||||
"""将领域任务对象转换为预览响应 DTO。
|
||||
|
||||
@@ -193,8 +171,12 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
|
||||
if generated_videos:
|
||||
first_video = generated_videos[0]
|
||||
raw_url = getattr(first_video, "file_url", "") or ""
|
||||
# P0 修复:私有 bucket 需要预签名 URL,否则前端 403 → 黑屏
|
||||
video_url = _sign_video_url(raw_url)
|
||||
# rendered/* 已配置公开读,直接用裸 URL
|
||||
if raw_url.startswith("http"):
|
||||
video_url = raw_url
|
||||
else:
|
||||
storage = get_storage_service()
|
||||
video_url = storage.get_url(raw_url)
|
||||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
||||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
||||
|
||||
@@ -237,6 +219,7 @@ def create_preview_generation_task(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
asset_repo=Depends(get_asset_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""创建预览生成任务。
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""封面管理路由.
|
||||
|
||||
端点:
|
||||
- GET /cover 封面配置
|
||||
- PUT /cover 更新封面
|
||||
- POST /cover/extract 抽帧生成封面
|
||||
- POST /cover/smart 智能选帧
|
||||
- POST /generate-cover AI 生成封面
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,19 +9,20 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
CoverConfigResponse,
|
||||
CoverExtractRequest,
|
||||
CoverGenerateResponse,
|
||||
CoverSmartRequest,
|
||||
CoverUpdateRequest,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
@@ -34,188 +31,136 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
@router.get("/cover", response_model=CoverConfigResponse)
|
||||
def get_editor_cover(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config = plan.config or {}
|
||||
cover_config = config.get("cover", {})
|
||||
|
||||
return CoverConfigResponse(
|
||||
type=cover_config.get("cover_type", "auto"),
|
||||
image_url=cover_config.get("cover_image_url", ""),
|
||||
frame_time=cover_config.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/cover", response_model=CoverConfigResponse)
|
||||
def update_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新草稿封面配置"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
current_cover = dict(config.get("cover", {}))
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
current_cover.update(update_data)
|
||||
|
||||
config["cover"] = current_cover
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
return CoverConfigResponse(
|
||||
type=current_cover.get("cover_type", "auto"),
|
||||
image_url=current_cover.get("cover_image_url", ""),
|
||||
frame_time=current_cover.get("frame_time", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/extract", response_model=CoverGenerateResponse)
|
||||
def extract_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverExtractRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段抽帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
clip = plan_svc.get_clip(body.clip_id)
|
||||
if not clip or clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿")
|
||||
|
||||
cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg"
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "extract",
|
||||
"cover_image_url": cover_url,
|
||||
"clip_id": body.clip_id,
|
||||
"frame_time": body.frame_time,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
type="extract",
|
||||
image_url=cover_url,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cover/smart", response_model=CoverGenerateResponse)
|
||||
def smart_editor_cover(
|
||||
template_id: str,
|
||||
body: CoverSmartRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
cover_url = f"cover/smart/{plan_id}_smart.jpg"
|
||||
strategy = getattr(body, "strategy", "auto")
|
||||
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
cover_config = dict(config.get("cover", {}))
|
||||
cover_config.update(
|
||||
{
|
||||
"cover_type": "smart",
|
||||
"cover_image_url": cover_url,
|
||||
"strategy": strategy,
|
||||
}
|
||||
)
|
||||
config["cover"] = cover_config
|
||||
normalized = normalize_plan_config(config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
strategy,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return CoverGenerateResponse(
|
||||
type="smart",
|
||||
image_url=cover_url,
|
||||
frame_time=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def editor_generate_cover(
|
||||
template_id: str,
|
||||
body: GenerateCoverRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面"""
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 获取第一个视频的下载 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url = None
|
||||
if body.asset_ids and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
try:
|
||||
from app.database import get_db_session
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
logger.info("[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id)
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
logger.info(
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
if preview_tasks:
|
||||
completed_preview = preview_tasks[0]
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(completed_preview.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
completed_preview.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 仍然找不到才报 400
|
||||
if not rendered_storage_key:
|
||||
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请先生成预览视频,再生成封面",
|
||||
)
|
||||
|
||||
# 回写到 plan.config
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读)
|
||||
primary_video_url = None
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
with get_db_session() as session:
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
first_asset = asset_repo.get(body.asset_ids[0])
|
||||
if first_asset and first_asset.storage_key:
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_download_url(first_asset.storage_key)
|
||||
logger.info(
|
||||
"获取视频URL用于封面生成: asset_id=%s url=%s",
|
||||
body.asset_ids[0],
|
||||
primary_video_url[:80] if primary_video_url else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取视频URL失败,将使用stub封面: %s", str(e))
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取预览视频URL失败: {e}",
|
||||
) from e
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
try:
|
||||
logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id)
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
@@ -223,7 +168,7 @@ def editor_generate_cover(
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
|
||||
@@ -96,7 +96,7 @@ def generate_editor_draft(
|
||||
plan_id,
|
||||
{
|
||||
"generation_task_id": reusable_task.id,
|
||||
"rendered_url": rendered_url,
|
||||
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
|
||||
},
|
||||
)
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
|
||||
@@ -260,14 +260,17 @@ def get_editor_generation_status(
|
||||
for c in clips
|
||||
]
|
||||
|
||||
raw_video_url = (plan.config or {}).get("rendered_url", "")
|
||||
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
try:
|
||||
video_url = storage_service.get_download_url(raw_video_url, expires_seconds=86400)
|
||||
except Exception as e:
|
||||
logger.warning("生成视频签名URL失败: template_id=%s error=%s", template_id, e)
|
||||
video_url = raw_video_url
|
||||
if raw_video_url.startswith("http"):
|
||||
video_url = raw_video_url # 已经是完整 URL
|
||||
else:
|
||||
try:
|
||||
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
|
||||
except Exception as e:
|
||||
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
|
||||
video_url = raw_video_url
|
||||
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
|
||||
@@ -257,43 +257,6 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── 导出配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
"""封面管理服务.
|
||||
|
||||
提供封面配置管理和从视频抽帧生成封面的能力。
|
||||
抽帧使用 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},format=yuvj420p"
|
||||
|
||||
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),
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-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
|
||||
@@ -374,7 +374,7 @@ class VideoComposeService:
|
||||
EditPlanStatus.EDITING,
|
||||
EditPlanStatus.RENDERING,
|
||||
),
|
||||
"rendered_url": plan.config.get("rendered_url", ""),
|
||||
"rendered_url": plan.config.get("rendered_storage_key", "") or plan.config.get("rendered_url", ""),
|
||||
}
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,10 +50,10 @@ type AssetListResponse = {
|
||||
}
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
|
||||
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(180_000)
|
||||
test.setTimeout(360_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
@@ -205,7 +205,7 @@ test.describe("Core generation flow", () => {
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 120_000 })
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
|
||||
@@ -7,7 +7,7 @@ export const confirmGeneration = async (
|
||||
params: ConfirmGenerationRequest,
|
||||
): Promise<ConfirmGenerationResponse> => {
|
||||
const response = await apiClient.post<ConfirmGenerationResponse>(
|
||||
`/tasks/${taskId}/confirm`,
|
||||
`/generation/tasks/${taskId}/confirm`,
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
|
||||
@@ -5,6 +5,7 @@ export type PreviewStatus = "pending" | "generating" | "completed" | "failed" |
|
||||
export interface CreatePreviewRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
source_edit_plan_id?: string
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材 */
|
||||
|
||||
@@ -23,6 +23,8 @@ export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data, {
|
||||
timeout: 180000, // 封面生成涉及 MediaKit 抽帧,最长 180 秒
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -118,6 +118,10 @@ export interface EditPlanConfig {
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 预览视频 URL(封面生成用) */
|
||||
rendered_storage_key?: string
|
||||
/** 生成任务 ID */
|
||||
generation_task_id?: string
|
||||
}
|
||||
|
||||
/* ── 模板草稿主体 ── */
|
||||
|
||||
@@ -6408,3 +6408,35 @@
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ═══ 封面 AI 生成按钮 ═══ */
|
||||
.cover-generate-section {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cover-generate-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--color-primary, #1677ff);
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary, #1677ff);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cover-generate-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover, #4096ff);
|
||||
border-color: var(--color-primary-hover, #4096ff);
|
||||
}
|
||||
|
||||
.cover-generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -74,8 +74,6 @@ const EditingPlanner: React.FC = () => {
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
} = useGlobalSettings()
|
||||
|
||||
/* ── 右侧栏 Tab ── */
|
||||
@@ -119,7 +117,6 @@ const EditingPlanner: React.FC = () => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
@@ -131,7 +128,6 @@ const EditingPlanner: React.FC = () => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
})
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
@@ -181,7 +177,6 @@ const EditingPlanner: React.FC = () => {
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
isPlaying={playback.isPlaying}
|
||||
titleConfig={titleConfig}
|
||||
coverConfig={coverConfig}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* 封面选择器入口(向后兼容)
|
||||
* 实际实现位于 ./cover-selector/ 目录
|
||||
*/
|
||||
export { default } from "./cover-selector"
|
||||
@@ -239,6 +239,7 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览 + 封面预览 并排
|
||||
* 封面为只读展示(从模板/计划继承)
|
||||
* 手机模型预览
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types"
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
@@ -21,7 +19,6 @@ interface PreviewPlayerProps {
|
||||
selectedClipId: string | null
|
||||
isPlaying: boolean
|
||||
titleConfig?: TitleConfig
|
||||
coverConfig?: CoverConfig
|
||||
subtitleSettings?: SubtitleSettings
|
||||
onClipSelect: (clipId: string) => void
|
||||
onPlayPause: () => void
|
||||
@@ -37,18 +34,11 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
isPlaying,
|
||||
titleConfig,
|
||||
coverConfig,
|
||||
subtitleSettings,
|
||||
onPlayPause,
|
||||
}) => {
|
||||
@@ -132,26 +122,6 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面预览(只读) */}
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
|
||||
<img
|
||||
src={coverConfig.thumbnail_url || coverConfig.upload_url}
|
||||
alt="封面预览"
|
||||
className="ep-cover-img"
|
||||
/>
|
||||
) : displayClip ? (
|
||||
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface CoverAutoModeProps {
|
||||
config: CoverConfig
|
||||
formatTime: (s: number) => string
|
||||
onUseAiSuggestion: () => void
|
||||
}
|
||||
|
||||
/** 智能封面模式面板 */
|
||||
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
|
||||
config,
|
||||
formatTime,
|
||||
onUseAiSuggestion,
|
||||
}) => (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">AI 将分析视频内容,自动选择最具吸引力的画面作为封面。</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">推荐时间点:{formatTime(config.ai_suggested_time)}</div>
|
||||
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverFrameModeProps {
|
||||
config: CoverConfig
|
||||
totalDuration: number
|
||||
formatTime: (s: number) => string
|
||||
onFrameTimeChange: (time: number) => void
|
||||
}
|
||||
|
||||
/** 抽帧选封面模式面板 */
|
||||
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
|
||||
config,
|
||||
totalDuration,
|
||||
formatTime,
|
||||
onFrameTimeChange,
|
||||
}) => (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CoverUploadModeProps {
|
||||
config: CoverConfig
|
||||
isDragging: boolean
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onAreaClick: () => void
|
||||
onFileChange: (file: File) => void
|
||||
}
|
||||
|
||||
/** 上传封面模式面板 */
|
||||
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
|
||||
config,
|
||||
isDragging,
|
||||
fileInputRef,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onAreaClick,
|
||||
onFileChange,
|
||||
}) => (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onAreaClick}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileChange(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
|
||||
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
} = useCoverSelector({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{config.mode === "auto" && (
|
||||
<CoverAutoMode
|
||||
config={config}
|
||||
formatTime={formatTime}
|
||||
onUseAiSuggestion={handleUseAiSuggestion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "frame" && (
|
||||
<CoverFrameMode
|
||||
config={config}
|
||||
totalDuration={totalDuration}
|
||||
formatTime={formatTime}
|
||||
onFrameTimeChange={(t) => update({ frame_time: t })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.mode === "upload" && (
|
||||
<CoverUploadMode
|
||||
config={config}
|
||||
isDragging={isDragging}
|
||||
fileInputRef={fileInputRef}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onAreaClick={() => fileInputRef.current?.click()}
|
||||
onFileChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import type { CoverConfig, CoverMode } from "../../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../../types"
|
||||
|
||||
/** 封面模式标签 */
|
||||
export const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
export const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
interface UseCoverSelectorOptions {
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 封面选择器 Hook
|
||||
* 封装状态管理、文件上传、模式切换等逻辑
|
||||
*/
|
||||
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import type { TransitionEffect } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types"
|
||||
|
||||
interface UsePlanLoadingOptions {
|
||||
loadedPlanId: string | null
|
||||
@@ -16,7 +15,6 @@ interface UsePlanLoadingOptions {
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +29,6 @@ export function usePlanLoading({
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
}: UsePlanLoadingOptions) {
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return
|
||||
@@ -77,17 +74,6 @@ export function usePlanLoading({
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}))
|
||||
}
|
||||
if (cfg.cover_config) {
|
||||
setCoverConfig((prev: CoverConfig) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}))
|
||||
}
|
||||
|
||||
/* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */
|
||||
const backendClips = clipsRes?.items || []
|
||||
@@ -152,6 +138,5 @@ export function usePlanLoading({
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
} from "../../types"
|
||||
import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
interface UseTemplateSaveOptions {
|
||||
@@ -33,7 +32,6 @@ interface UseTemplateSaveOptions {
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
loadedTemplateId: string | null
|
||||
loadTemplates: () => Promise<void>
|
||||
}
|
||||
@@ -55,7 +53,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
} = options
|
||||
@@ -132,7 +129,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
@@ -163,7 +159,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
])
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import {
|
||||
DEFAULT_WATERMARK,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
|
||||
@@ -47,8 +45,6 @@ export interface GlobalSettings {
|
||||
setChromaKeySettings: (config: ChromaKeyConfig) => void
|
||||
stickerSettings: StickerConfig
|
||||
setStickerSettings: (config: StickerConfig) => void
|
||||
coverConfig: CoverConfig
|
||||
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
}
|
||||
|
||||
export const useGlobalSettings = (): GlobalSettings => {
|
||||
@@ -92,10 +88,6 @@ export const useGlobalSettings = (): GlobalSettings => {
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
})
|
||||
|
||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
})
|
||||
|
||||
return {
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
@@ -115,7 +107,5 @@ export const useGlobalSettings = (): GlobalSettings => {
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
@@ -29,7 +28,6 @@ interface UseTemplateManagementParams {
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
titleConfig: TitleConfig
|
||||
@@ -41,7 +39,6 @@ interface UseTemplateManagementParams {
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +56,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
@@ -71,7 +67,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
} = params
|
||||
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
|
||||
@@ -119,7 +114,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
loadedTemplateId,
|
||||
loadTemplates,
|
||||
})
|
||||
@@ -146,7 +140,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
})
|
||||
|
||||
/* ── 事件 ── */
|
||||
|
||||
@@ -67,5 +67,4 @@ export interface ClipPropertiesPanelProps {
|
||||
onOpenGreenScreenDrawer?: () => void
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import Button from "@/components/ui/Button"
|
||||
@@ -18,13 +19,13 @@ interface Step6CoverSettingsProps {
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
selectedTemplateName,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
@@ -61,8 +62,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="xx-cover-selected-template">已选模板: {selectedTemplateName}</div>
|
||||
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{previewUrl ? (
|
||||
@@ -98,6 +97,14 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* AI 生成封面进度弹窗 */}
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI 正在生成封面,请稍候...</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
}, [props, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import { updateEditPlan } from "@/api/template-editor/editPlans"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
@@ -211,90 +212,102 @@ export function useStep4Preview({
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询单个预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
const pollPreviewStatus = useCallback(
|
||||
(index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
}
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
),
|
||||
)
|
||||
|
||||
// 保存预览视频 URL 到 plan config,供封面生成使用
|
||||
if (result.videoUrl && selectedTemplate) {
|
||||
updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: result.videoUrl },
|
||||
}).catch((err) => {
|
||||
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
}, [])
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
},
|
||||
[selectedTemplate],
|
||||
)
|
||||
|
||||
/** 生成所有预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../../editing-planner/types"
|
||||
import { generateCover } from "@/api/template-editor"
|
||||
@@ -30,7 +30,7 @@ export function useStep6Cover({
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
}: UseStep6CoverProps) {
|
||||
const generatingRef = useRef(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
// ── 封面设置弹窗状态 ──
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
@@ -67,7 +67,7 @@ export function useStep6Cover({
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generatingRef.current) {
|
||||
if (generating) {
|
||||
message.warning("封面正在生成中,请稍候...")
|
||||
return
|
||||
}
|
||||
@@ -82,12 +82,17 @@ export function useStep6Cover({
|
||||
return
|
||||
}
|
||||
|
||||
generatingRef.current = true
|
||||
setGenerating(true)
|
||||
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
|
||||
const timeoutId = setTimeout(() => {
|
||||
setGenerating(false)
|
||||
}, 300000)
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
const thumbnailUrl = response.cover?.image_url || ""
|
||||
if (thumbnailUrl) {
|
||||
onCoverSettingsChange({
|
||||
@@ -100,12 +105,35 @@ export function useStep6Cover({
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Step6] 智能封面生成失败:", err)
|
||||
message.error("封面生成失败,请稍后重试")
|
||||
clearTimeout(timeoutId)
|
||||
console.error("[Step6] 智能封面生成失败:", err)
|
||||
|
||||
// 提取详细错误信息
|
||||
let errorMsg = "封面生成失败"
|
||||
const e = err as {
|
||||
response?: { data?: { detail?: string; message?: string }; status?: number }
|
||||
request?: unknown
|
||||
message?: string
|
||||
}
|
||||
if (e.response) {
|
||||
// 后端返回错误
|
||||
const detail = e.response.data?.detail || e.response.data?.message || ""
|
||||
errorMsg = detail || `后端错误 (${e.response.status})`
|
||||
console.error("[Step6] 后端返回:", e.response.data)
|
||||
} else if (e.request) {
|
||||
// 请求已发送但无响应
|
||||
errorMsg = "服务器无响应,请检查网络连接"
|
||||
console.error("[Step6] 请求无响应:", e.request)
|
||||
} else if (e.message) {
|
||||
errorMsg = e.message
|
||||
}
|
||||
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
generatingRef.current = false
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange])
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
@@ -165,6 +193,7 @@ export function useStep6Cover({
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
showCoverSettings,
|
||||
|
||||
@@ -149,7 +149,6 @@ vi.mock("@/api/editing-planner", () => ({
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
generateCover: vi.fn().mockResolvedValue({}),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
@@ -223,9 +222,6 @@ vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
|
||||
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
|
||||
}))
|
||||
@@ -260,7 +256,6 @@ vi.mock("@/pages/editing-planner/types", () => ({
|
||||
DEFAULT_FILTER_CONFIG: { enabled: false },
|
||||
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
|
||||
DEFAULT_STICKER_CONFIG: { enabled: false },
|
||||
DEFAULT_COVER_CONFIG: { enabled: false },
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import CoverSelector from "@/pages/editing-planner/components/CoverSelector"
|
||||
import { DEFAULT_COVER_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
config: DEFAULT_COVER_CONFIG,
|
||||
onChange: vi.fn(),
|
||||
totalDuration: 60,
|
||||
}
|
||||
|
||||
describe("CoverSelector", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<CoverSelector {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<CoverSelector {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,10 @@ const defaultProps = {
|
||||
clips: [],
|
||||
selectedClipId: null,
|
||||
isPlaying: false,
|
||||
currentCoverScheme: "scheme1",
|
||||
coverSchemes: [{ id: "scheme1", name: "方案1", cover_url: "" }],
|
||||
aiCoverLoading: false,
|
||||
titleSettings: undefined,
|
||||
subtitleSettings: undefined,
|
||||
onClipSelect: vi.fn(),
|
||||
onCoverSchemeChange: vi.fn(),
|
||||
onPlayPause: vi.fn(),
|
||||
onAiGenerateCover: vi.fn(),
|
||||
}
|
||||
|
||||
describe("PreviewPlayer", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { render, cleanup } from "@testing-library/react"
|
||||
import { render, cleanup, act } from "@testing-library/react"
|
||||
import TtsPanel from "@/pages/editing-planner/components/TtsPanel"
|
||||
import { DEFAULT_TTS_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
@@ -16,17 +16,28 @@ const defaultProps = {
|
||||
}
|
||||
|
||||
describe("TtsPanel", () => {
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
// Flush pending React updates before cleanup to avoid
|
||||
// "window is not defined" errors after jsdom teardown
|
||||
await act(async () => {})
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<TtsPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
it("should render without crashing", async () => {
|
||||
let container: HTMLElement
|
||||
await act(async () => {
|
||||
const result = render(<TtsPanel {...defaultProps} />)
|
||||
container = result.container
|
||||
})
|
||||
expect(container!).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render when closed", () => {
|
||||
const { container } = render(<TtsPanel {...defaultProps} open={false} />)
|
||||
expect(container).toBeTruthy()
|
||||
it("should render when closed", async () => {
|
||||
let container: HTMLElement
|
||||
await act(async () => {
|
||||
const result = render(<TtsPanel {...defaultProps} open={false} />)
|
||||
container = result.container
|
||||
})
|
||||
expect(container!).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ import "@/pages/editing-planner/utils/clipProperties"
|
||||
// 子组件
|
||||
import "@/pages/editing-planner/components/BgmSelector"
|
||||
import "@/pages/editing-planner/components/ClipPropertiesPanel"
|
||||
import "@/pages/editing-planner/components/CoverSelector"
|
||||
import "@/pages/editing-planner/components/EditorClipList"
|
||||
import "@/pages/editing-planner/components/EditingDrawers"
|
||||
import "@/pages/editing-planner/components/FilterPanel"
|
||||
|
||||
@@ -1436,6 +1436,64 @@ def generate_video(self, task_id: str) -> dict:
|
||||
|
||||
_update_task_progress(task_id, 30, "素材下载完成")
|
||||
|
||||
# ── 2.5 MediaKit 视频理解(渲染前分析素材内容)─────────────────
|
||||
asset_analyses = {}
|
||||
if task_asset_ids:
|
||||
try:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
mk_client = get_mediakit_client()
|
||||
if mk_client.is_available:
|
||||
storage_svc = get_shared_storage_service()
|
||||
asset_urls = []
|
||||
valid_asset_ids = []
|
||||
|
||||
# 获取素材 URL(从 downloaded_videos 获取本地路径或从 asset 表获取 OSS URL)
|
||||
for i, asset_id in enumerate(task_asset_ids[:5]):
|
||||
try:
|
||||
# 优先使用已下载的本地文件
|
||||
if i < len(downloaded_videos) and downloaded_videos[i]:
|
||||
# 本地文件路径,需要上传或直接用 OSS URL
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
|
||||
_s = SessionLocal()
|
||||
try:
|
||||
_ar = SQLAlchemyAssetRepository(_s)
|
||||
asset = _ar.get(asset_id)
|
||||
if asset and getattr(asset, "storage_key", ""):
|
||||
asset_urls.append(storage_svc.get_url(asset.storage_key))
|
||||
valid_asset_ids.append(asset_id)
|
||||
finally:
|
||||
_s.close()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if asset_urls:
|
||||
_update_task_progress(task_id, 35, "MediaKit 视频理解中...")
|
||||
analyses = mk_client.analyze_videos(
|
||||
prompt="分析这个视频的主要内容,描述场景、物体、人物动作和主题",
|
||||
video_urls=asset_urls,
|
||||
level="Economy",
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=15,
|
||||
)
|
||||
if analyses:
|
||||
for i, content in enumerate(analyses):
|
||||
if i < len(valid_asset_ids) and content:
|
||||
asset_analyses[valid_asset_ids[i]] = content
|
||||
logger.info("[task_id=%s] MediaKit 视频理解完成: %d 个素材", task_id, len(asset_urls))
|
||||
|
||||
# 保存分析结果到 extra_meta
|
||||
if asset_analyses and gen_task:
|
||||
gen_task.extra_meta = {**(gen_task.extra_meta or {}), "asset_analyses": asset_analyses}
|
||||
_repo.update(gen_task)
|
||||
_flush_logs(task_id, gen_task)
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] MediaKit 视频理解失败,继续渲染", task_id, exc_info=True)
|
||||
|
||||
# ── 3. 渲染 + 混音 ───────────────────────────────────────────────
|
||||
_update_task_progress(task_id, 40, "开始渲染")
|
||||
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
|
||||
|
||||
@@ -148,6 +148,22 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
)
|
||||
return [_to_domain(m) for m in models]
|
||||
|
||||
def list_latest_completed_preview(self, user_id: str, template_id: str, limit: int = 1) -> list[GenerationTask]:
|
||||
"""按用户+模板查找最近已完成的预览任务。"""
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.created_by_user_id == user_id,
|
||||
GenerationTaskModel.template_id == template_id,
|
||||
GenerationTaskModel.is_preview,
|
||||
GenerationTaskModel.status == "completed",
|
||||
)
|
||||
.order_by(GenerationTaskModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_to_domain(m) for m in models]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]:
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
|
||||
@@ -362,7 +362,7 @@ def _call_ai_cover_service(
|
||||
"""调用 AI 封面生成服务.
|
||||
|
||||
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
|
||||
失败或未配置时降级为 stub 行为。
|
||||
失败时抛出 RuntimeError。
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
@@ -402,8 +402,10 @@ def _call_ai_cover_service(
|
||||
logger.info("调用 MediaKit 抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
|
||||
frames = client.extract_frames(
|
||||
video_url=primary_video_url,
|
||||
strategy="TimeInterval",
|
||||
max_frames=5,
|
||||
strategy="SceneChange", # 自动识别画面变化,选最佳帧
|
||||
max_frames=3, # 减少到 3 帧,平衡速度和质量
|
||||
poll_interval=2.0, # 缩短轮询间隔
|
||||
max_poll_attempts=60, # 120秒超时
|
||||
)
|
||||
|
||||
if frames and len(frames) > 0:
|
||||
@@ -432,25 +434,12 @@ def _call_ai_cover_service(
|
||||
logger.warning("MediaKit 返回的帧无 image_url")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 抽帧失败,降级到 stub: %s", str(e))
|
||||
logger.exception("MediaKit 抽帧失败: %s", str(e))
|
||||
|
||||
# 降级:stub 行为 - 返回 SVG data URI 占位图
|
||||
logger.info("使用 stub 封面: plan_id=%s", plan_id)
|
||||
time.sleep(0.3)
|
||||
svg_placeholder = (
|
||||
"data:image/svg+xml,"
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='1080' height='1920'>"
|
||||
"<rect width='1080' height='1920' fill='#1a1a2e'/>"
|
||||
"<text x='540' y='920' text-anchor='middle' fill='#e0e0e0' font-size='48' font-family='sans-serif'>封面生成中</text>"
|
||||
"<text x='540' y='1000' text-anchor='middle' fill='#888888' font-size='32' font-family='sans-serif'>请配置 MediaKit API Key</text>"
|
||||
"</svg>"
|
||||
# 封面生成失败 - 不再降级到 stub,直接报错
|
||||
raise RuntimeError(
|
||||
f"封面生成失败: plan_id={plan_id}, MediaKit 不可用或抽帧失败。" f"请检查 primary_video_url 是否可访问。"
|
||||
)
|
||||
return {
|
||||
"type": "ai_frame",
|
||||
"image_url": svg_placeholder,
|
||||
"frame_time": round(random.uniform(1.0, 10.0), 1),
|
||||
"confidence": round(random.uniform(0.80, 0.98), 2),
|
||||
}
|
||||
|
||||
|
||||
# ── 公共入口 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -399,18 +399,16 @@ class TestRunAIRecommend(unittest.TestCase):
|
||||
|
||||
|
||||
class TestGenerateCover(unittest.TestCase):
|
||||
"""封面生成测试(降级路径)."""
|
||||
"""封面生成测试."""
|
||||
|
||||
def test_ai_frame_type(self):
|
||||
"""AI封面模式返回预期结构."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
self.assertIn("type", result)
|
||||
self.assertEqual(result["type"], "ai_frame")
|
||||
self.assertIn("image_url", result)
|
||||
def test_ai_frame_type_raises_without_mediakit(self):
|
||||
"""AI封面模式在MediaKit不可用时抛出RuntimeError."""
|
||||
with self.assertRaises(RuntimeError):
|
||||
run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
|
||||
def test_manual_type(self):
|
||||
"""手动选帧模式."""
|
||||
|
||||
@@ -231,18 +231,18 @@ class TestAIRunTasks:
|
||||
# 即使没有素材,也应该有 intro + outro
|
||||
assert len(result["clips"]) >= 2
|
||||
|
||||
def test_run_generate_cover_ai_frame(self):
|
||||
def test_run_generate_cover_ai_frame_raises_without_mediakit(self):
|
||||
"""ai_frame cover raises RuntimeError when MediaKit is unavailable."""
|
||||
import pytest
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-001",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
assert result["type"] == "ai_frame"
|
||||
assert "image_url" in result
|
||||
assert "frame_time" in result
|
||||
assert "confidence" in result
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
run_generate_cover(
|
||||
plan_id="plan-001",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
|
||||
def test_run_generate_cover_manual(self):
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
"""
|
||||
封面管理服务单元测试
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.api.app.services.cover_service import (
|
||||
COVER_STORAGE_PREFIX,
|
||||
DEFAULT_COVER_HEIGHT,
|
||||
DEFAULT_COVER_QUALITY,
|
||||
DEFAULT_COVER_WIDTH,
|
||||
CoverService,
|
||||
)
|
||||
|
||||
|
||||
class TestGetCoverConfig:
|
||||
"""get_cover_config 静态方法测试"""
|
||||
|
||||
def test_get_cover_config_default(self):
|
||||
"""测试默认封面配置"""
|
||||
config = {}
|
||||
result = CoverService.get_cover_config(config)
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"] == ""
|
||||
assert result["frame_time"] is None
|
||||
|
||||
def test_get_cover_config_with_custom_values(self):
|
||||
"""测试自定义封面配置"""
|
||||
config = {
|
||||
"cover": {
|
||||
"type": "manual",
|
||||
"image_url": "https://example.com/cover.jpg",
|
||||
"frame_time": 5.5,
|
||||
}
|
||||
}
|
||||
result = CoverService.get_cover_config(config)
|
||||
assert result["type"] == "manual"
|
||||
assert result["image_url"] == "https://example.com/cover.jpg"
|
||||
assert result["frame_time"] == 5.5
|
||||
|
||||
def test_get_cover_config_cover_not_dict(self):
|
||||
"""测试 cover 不是 dict 时返回默认值"""
|
||||
config = {"cover": "not-a-dict"}
|
||||
result = CoverService.get_cover_config(config)
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"] == ""
|
||||
assert result["frame_time"] is None
|
||||
|
||||
def test_get_cover_config_partial_fields(self):
|
||||
"""测试部分字段存在时,其余字段用默认值"""
|
||||
config = {"cover": {"type": "custom"}}
|
||||
result = CoverService.get_cover_config(config)
|
||||
assert result["type"] == "custom"
|
||||
assert result["image_url"] == ""
|
||||
assert result["frame_time"] is None
|
||||
|
||||
def test_get_cover_config_empty_cover_dict(self):
|
||||
"""测试空的 cover dict"""
|
||||
config = {"cover": {}}
|
||||
result = CoverService.get_cover_config(config)
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"] == ""
|
||||
|
||||
|
||||
class TestExtractCoverFromClip:
|
||||
"""extract_cover_from_clip 测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage(self):
|
||||
storage = Mock()
|
||||
storage.download_file = Mock()
|
||||
storage.upload_file = Mock()
|
||||
storage.get_url = Mock(return_value="https://oss.example.com/covers/plan1/cover_1000.jpg")
|
||||
return storage
|
||||
|
||||
@pytest.fixture
|
||||
def mock_asset_repo(self):
|
||||
repo = Mock()
|
||||
repo.get = Mock(return_value=None)
|
||||
return repo
|
||||
|
||||
@pytest.fixture
|
||||
def video_asset(self):
|
||||
asset = Mock()
|
||||
asset.storage_key = "videos/test-video.mp4"
|
||||
asset.mime_type = "video/mp4"
|
||||
return asset
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, mock_storage, mock_asset_repo):
|
||||
return CoverService(storage_service=mock_storage, asset_repository=mock_asset_repo)
|
||||
|
||||
def test_extract_cover_asset_not_found(self, service, mock_asset_repo):
|
||||
"""测试素材不存在时报错"""
|
||||
mock_asset_repo.get.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="素材不存在"):
|
||||
service.extract_cover_from_clip(plan_id="plan-1", asset_id="nonexistent")
|
||||
|
||||
def test_extract_cover_asset_no_storage_key(self, service, mock_asset_repo):
|
||||
"""测试素材没有文件时报错"""
|
||||
asset = Mock()
|
||||
asset.storage_key = ""
|
||||
asset.mime_type = "video/mp4"
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
with pytest.raises(ValueError, match="素材没有文件"):
|
||||
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-no-file")
|
||||
|
||||
def test_extract_cover_asset_not_video(self, service, mock_asset_repo):
|
||||
"""测试非视频素材报错"""
|
||||
asset = Mock()
|
||||
asset.storage_key = "images/photo.jpg"
|
||||
asset.mime_type = "image/jpeg"
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
with pytest.raises(ValueError, match="素材不是视频类型"):
|
||||
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-img")
|
||||
|
||||
def test_extract_cover_download_failure(self, service, mock_asset_repo, mock_storage, video_asset):
|
||||
"""测试下载素材失败"""
|
||||
mock_asset_repo.get.return_value = video_asset
|
||||
mock_storage.download_file.side_effect = Exception("网络错误")
|
||||
|
||||
with pytest.raises(RuntimeError, match="下载素材失败"):
|
||||
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-1")
|
||||
|
||||
def test_extract_cover_upload_failure(self, service, mock_asset_repo, mock_storage, video_asset):
|
||||
"""测试上传封面失败"""
|
||||
mock_asset_repo.get.return_value = video_asset
|
||||
|
||||
def fake_download(storage_key, local_path):
|
||||
# 创建一个假的视频文件
|
||||
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(b"fake video data")
|
||||
|
||||
mock_storage.download_file.side_effect = fake_download
|
||||
mock_storage.upload_file.side_effect = Exception("上传失败")
|
||||
|
||||
# mock _extract_frame 避免真的调 ffmpeg
|
||||
with patch.object(CoverService, "_extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
# 创建假的封面文件
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(b"\xff\xd8\xff\xe0fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
with pytest.raises(RuntimeError, match="上传封面失败"):
|
||||
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-1")
|
||||
|
||||
def test_extract_cover_get_url_falls_back_to_key(self, service, mock_asset_repo, mock_storage, video_asset):
|
||||
"""测试获取 URL 失败时降级为 storage_key"""
|
||||
mock_asset_repo.get.return_value = video_asset
|
||||
|
||||
def fake_download(storage_key, local_path):
|
||||
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(b"fake video data")
|
||||
|
||||
mock_storage.download_file.side_effect = fake_download
|
||||
mock_storage.get_url.side_effect = Exception("URL服务不可用")
|
||||
|
||||
with patch.object(CoverService, "_extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(b"\xff\xd8\xff\xe0fake jpeg")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
result = service.extract_cover_from_clip(plan_id="plan-abc", asset_id="asset-xyz", frame_time=2.5)
|
||||
|
||||
assert result["type"] == "manual"
|
||||
assert result["frame_time"] == 2.5
|
||||
# URL 失败时返回 storage_key
|
||||
assert COVER_STORAGE_PREFIX in result["image_url"]
|
||||
assert "plan-abc" in result["image_url"]
|
||||
|
||||
def test_extract_cover_success(self, service, mock_asset_repo, mock_storage, video_asset):
|
||||
"""测试抽帧成功完整流程"""
|
||||
mock_asset_repo.get.return_value = video_asset
|
||||
|
||||
def fake_download(storage_key, local_path):
|
||||
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")
|
||||
|
||||
mock_storage.download_file.side_effect = fake_download
|
||||
|
||||
with patch.object(CoverService, "_extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(b"\xff\xd8\xff\xe0fake jpeg image data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
result = service.extract_cover_from_clip(
|
||||
plan_id="plan-123",
|
||||
asset_id="asset-456",
|
||||
frame_time=3.0,
|
||||
width=720,
|
||||
height=1280,
|
||||
quality=3,
|
||||
)
|
||||
|
||||
assert result["type"] == "manual"
|
||||
assert result["image_url"] == "https://oss.example.com/covers/plan1/cover_1000.jpg"
|
||||
assert result["frame_time"] == 3.0
|
||||
|
||||
# 验证上传被调用
|
||||
mock_storage.upload_file.assert_called_once()
|
||||
upload_args = mock_storage.upload_file.call_args[1]
|
||||
assert upload_args["content_type"] == "image/jpeg"
|
||||
assert "plan-123" in upload_args["storage_key"]
|
||||
assert "3000" in upload_args["storage_key"] # frame_time * 1000
|
||||
|
||||
# 验证 _extract_frame 被调用且参数正确
|
||||
mock_extract.assert_called_once()
|
||||
extract_kwargs = mock_extract.call_args[1]
|
||||
assert extract_kwargs["time_sec"] == 3.0
|
||||
assert extract_kwargs["width"] == 720
|
||||
assert extract_kwargs["height"] == 1280
|
||||
assert extract_kwargs["quality"] == 3
|
||||
|
||||
|
||||
class TestGenerateSmartCover:
|
||||
"""generate_smart_cover 测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return CoverService(storage_service=Mock(), asset_repository=Mock())
|
||||
|
||||
def test_generate_smart_cover_calls_extract_with_default_time(self, service):
|
||||
"""测试智能封面调用 extract_cover_from_clip 并设置 type 为 ai_frame"""
|
||||
fake_result = {"type": "manual", "image_url": "test.jpg", "frame_time": 3.0}
|
||||
|
||||
with patch.object(service, "extract_cover_from_clip", return_value=fake_result) as mock_extract:
|
||||
result = service.generate_smart_cover(plan_id="plan-1", asset_id="asset-1")
|
||||
|
||||
mock_extract.assert_called_once()
|
||||
call_kwargs = mock_extract.call_args[1]
|
||||
assert call_kwargs["plan_id"] == "plan-1"
|
||||
assert call_kwargs["asset_id"] == "asset-1"
|
||||
assert call_kwargs["frame_time"] == 3.0 # 默认第3秒
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"] == "test.jpg"
|
||||
|
||||
def test_generate_smart_cover_passes_dimensions(self, service):
|
||||
"""测试智能封面传递尺寸和质量参数"""
|
||||
fake_result = {"type": "manual", "image_url": "test.jpg", "frame_time": 3.0}
|
||||
|
||||
with patch.object(service, "extract_cover_from_clip", return_value=fake_result) as mock_extract:
|
||||
service.generate_smart_cover(
|
||||
plan_id="plan-1",
|
||||
asset_id="asset-1",
|
||||
width=1080,
|
||||
height=1920,
|
||||
quality=5,
|
||||
)
|
||||
|
||||
call_kwargs = mock_extract.call_args[1]
|
||||
assert call_kwargs["width"] == 1080
|
||||
assert call_kwargs["height"] == 1920
|
||||
assert call_kwargs["quality"] == 5
|
||||
|
||||
|
||||
class TestExtractFrame:
|
||||
"""_extract_frame 静态方法测试(mock subprocess)"""
|
||||
|
||||
@pytest.fixture
|
||||
def video_path(self, tmp_path):
|
||||
path = tmp_path / "test_video.mp4"
|
||||
path.write_bytes(b"fake video")
|
||||
return path
|
||||
|
||||
@pytest.fixture
|
||||
def output_path(self, tmp_path):
|
||||
return tmp_path / "cover.jpg"
|
||||
|
||||
def test_extract_frame_success(self, video_path, output_path):
|
||||
"""测试 FFmpeg 抽帧成功"""
|
||||
fake_result = Mock()
|
||||
fake_result.returncode = 0
|
||||
|
||||
with patch("subprocess.run", return_value=fake_result) as mock_run:
|
||||
CoverService._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=2.5,
|
||||
width=1080,
|
||||
height=1920,
|
||||
quality=5,
|
||||
)
|
||||
|
||||
assert mock_run.call_count == 1
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-ss" in cmd
|
||||
assert "2.500" in cmd
|
||||
assert "-vframes" in cmd
|
||||
# 验证 scale+crop 滤镜存在
|
||||
vf_index = cmd.index("-vf") + 1
|
||||
assert "scale=" in cmd[vf_index]
|
||||
assert "crop=" in cmd[vf_index]
|
||||
|
||||
def test_extract_frame_fallback_to_simple_command(self, video_path, output_path):
|
||||
"""测试主命令失败时回退到简化命令"""
|
||||
fail_result = Mock()
|
||||
fail_result.returncode = 1
|
||||
fail_result.stderr = "Filter graph error"
|
||||
|
||||
success_result = Mock()
|
||||
success_result.returncode = 0
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return fail_result
|
||||
return success_result
|
||||
|
||||
with patch("subprocess.run", side_effect=fake_run) as mock_run:
|
||||
CoverService._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=1.0,
|
||||
width=1080,
|
||||
height=1920,
|
||||
quality=5,
|
||||
)
|
||||
|
||||
assert mock_run.call_count == 2
|
||||
# 第二次是简化命令(没有 -vf 参数)
|
||||
second_cmd = mock_run.call_args_list[1][0][0]
|
||||
assert "-vf" not in second_cmd
|
||||
|
||||
def test_extract_frame_both_commands_fail(self, video_path, output_path):
|
||||
"""测试两个命令都失败时报错"""
|
||||
fail_result = Mock()
|
||||
fail_result.returncode = 1
|
||||
fail_result.stderr = "Invalid data found when processing input"
|
||||
|
||||
with patch("subprocess.run", return_value=fail_result):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg 抽帧失败"):
|
||||
CoverService._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=1.0,
|
||||
width=1080,
|
||||
height=1920,
|
||||
quality=5,
|
||||
)
|
||||
|
||||
def test_extract_frame_timeout(self, video_path, output_path):
|
||||
"""测试 FFmpeg 抽帧超时"""
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=60)):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg 抽帧超时"):
|
||||
CoverService._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=1.0,
|
||||
width=1080,
|
||||
height=1920,
|
||||
quality=5,
|
||||
)
|
||||
|
||||
def test_extract_frame_ffmpeg_not_found(self, video_path, output_path):
|
||||
"""测试 FFmpeg 不可用"""
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("ffmpeg not found")):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg 不可用"):
|
||||
CoverService._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=1.0,
|
||||
width=1080,
|
||||
height=1920,
|
||||
quality=5,
|
||||
)
|
||||
|
||||
|
||||
class TestDefaults:
|
||||
"""默认常量测试"""
|
||||
|
||||
def test_default_dimensions(self):
|
||||
"""测试默认尺寸常量"""
|
||||
assert DEFAULT_COVER_WIDTH == 1080
|
||||
assert DEFAULT_COVER_HEIGHT == 1920
|
||||
assert DEFAULT_COVER_QUALITY == 5
|
||||
assert COVER_STORAGE_PREFIX == "covers"
|
||||
@@ -630,7 +630,7 @@ class TestToPreviewResponse:
|
||||
assert resp.file_size == 0
|
||||
|
||||
def test_completed_task_with_videos(self):
|
||||
"""已完成任务,带视频结果(URL签名后返回)"""
|
||||
"""已完成任务,带视频结果(裸URL直接返回,rendered/*已公开读)"""
|
||||
task = _make_task(
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
progress=100.0,
|
||||
@@ -639,12 +639,8 @@ class TestToPreviewResponse:
|
||||
video.file_url = "https://cdn.example.com/preview.mp4"
|
||||
video.duration = 30.5
|
||||
video.file_size = 1024000
|
||||
# Mock storage service to return a signed URL
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = "https://cdn.example.com/preview.mp4?sig=test123"
|
||||
with patch("app.api.routes.generation_preview.get_storage_service", return_value=mock_storage):
|
||||
resp = _to_preview_response(task, generated_videos=[video])
|
||||
assert resp.video_url == "https://cdn.example.com/preview.mp4?sig=test123"
|
||||
resp = _to_preview_response(task, generated_videos=[video])
|
||||
assert resp.video_url == "https://cdn.example.com/preview.mp4"
|
||||
assert resp.duration == 30.5
|
||||
assert resp.file_size == 1024000
|
||||
|
||||
@@ -986,7 +982,7 @@ class TestGetPreviewRoute:
|
||||
|
||||
# Mock URL 签名(返回带签名的 URL)
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = "https://cdn.example.com/preview_final.mp4?sig=abc123"
|
||||
mock_storage.get_download_url.return_value = "https://cdn.example.com/preview_final.mp4"
|
||||
|
||||
with patch("app.api.routes.generation_preview.GetGenerationTaskUseCase") as MockGet:
|
||||
MockGet.return_value.execute.return_value = task
|
||||
@@ -999,7 +995,7 @@ class TestGetPreviewRoute:
|
||||
generation_task_repository=repo,
|
||||
generated_video_repository=vid_repo,
|
||||
)
|
||||
assert resp.video_url == "https://cdn.example.com/preview_final.mp4?sig=abc123"
|
||||
assert resp.video_url == "https://cdn.example.com/preview_final.mp4"
|
||||
assert resp.duration == 25.0
|
||||
|
||||
|
||||
@@ -1088,50 +1084,6 @@ class TestWorkerPreviewResolution:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSignVideoUrl:
|
||||
"""_sign_video_url 预签名 URL 测试。"""
|
||||
|
||||
def test_empty_url_returns_empty(self):
|
||||
"""空 URL 直接返回空字符串。"""
|
||||
from app.api.routes.generation_preview import _sign_video_url
|
||||
|
||||
assert _sign_video_url("") == ""
|
||||
|
||||
def test_signs_oss_url(self):
|
||||
"""OSS URL 应被签名。"""
|
||||
from app.api.routes.generation_preview import _sign_video_url
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = "https://signed.example.com/video.mp4?sig=abc"
|
||||
|
||||
with patch("app.api.routes.generation_preview.get_storage_service", return_value=mock_storage):
|
||||
result = _sign_video_url("https://bucket.oss.example.com/video.mp4")
|
||||
|
||||
assert result == "https://signed.example.com/video.mp4?sig=abc"
|
||||
mock_storage.get_download_url.assert_called_once()
|
||||
|
||||
def test_fallback_on_sign_failure(self):
|
||||
"""签名失败时降级返回原始 URL。"""
|
||||
from app.api.routes.generation_preview import _sign_video_url
|
||||
|
||||
with patch("app.api.routes.generation_preview.get_storage_service", side_effect=RuntimeError("no storage")):
|
||||
result = _sign_video_url("https://bucket.oss.example.com/video.mp4")
|
||||
|
||||
assert result == "https://bucket.oss.example.com/video.mp4"
|
||||
|
||||
def test_sign_returns_none_fallback(self):
|
||||
"""get_download_url 返回 None 时降级返回原始 URL。"""
|
||||
from app.api.routes.generation_preview import _sign_video_url
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_download_url.return_value = None
|
||||
|
||||
with patch("app.api.routes.generation_preview.get_storage_service", return_value=mock_storage):
|
||||
result = _sign_video_url("https://bucket.oss.example.com/video.mp4")
|
||||
|
||||
assert result == "https://bucket.oss.example.com/video.mp4"
|
||||
|
||||
|
||||
class TestInferVideoRatioFromTemplate:
|
||||
"""_infer_video_ratio_from_template 单元测试。"""
|
||||
|
||||
|
||||
@@ -167,8 +167,8 @@ class TestAICoverService:
|
||||
mock_client.extract_frames.assert_called_once()
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_with_mediakit_failure_fallback(self, mock_get_client):
|
||||
"""MediaKit 失败时降级到 stub."""
|
||||
def test_call_ai_cover_with_mediakit_failure_raises(self, mock_get_client):
|
||||
"""MediaKit 失败时抛出 RuntimeError(不再降级到 stub)."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = True
|
||||
mock_client.extract_frames.side_effect = Exception("API error")
|
||||
@@ -176,30 +176,26 @@ class TestAICoverService:
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
_call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
# 应该降级到 stub
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"].startswith("data:image/svg+xml,")
|
||||
def test_call_ai_cover_without_video_url_raises(self):
|
||||
"""没有视频 URL 时抛出 RuntimeError(不再降级到 stub)."""
|
||||
|
||||
def test_call_ai_cover_without_video_url_fallback(self):
|
||||
"""没有视频 URL 时使用 stub."""
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url=None,
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"].startswith("data:image/svg+xml,")
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
_call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url=None,
|
||||
)
|
||||
|
||||
def test_call_ai_cover_upload_type(self):
|
||||
"""upload 类型直接返回."""
|
||||
@@ -231,27 +227,25 @@ class TestAICoverService:
|
||||
assert result["frame_time"] == 5.0
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_mediakit_not_available(self, mock_get_client):
|
||||
"""MediaKit 未配置时使用 stub."""
|
||||
def test_call_ai_cover_mediakit_not_available_raises(self, mock_get_client):
|
||||
"""MediaKit 未配置时抛出 RuntimeError(不再降级到 stub)."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = False
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"].startswith("data:image/svg+xml,")
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
_call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_empty_frames_fallback(self, mock_get_client):
|
||||
"""MediaKit 返回空帧列表时降级."""
|
||||
def test_call_ai_cover_empty_frames_raises(self, mock_get_client):
|
||||
"""MediaKit 返回空帧列表时抛出 RuntimeError(不再降级)."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = True
|
||||
mock_client.extract_frames.return_value = []
|
||||
@@ -259,15 +253,13 @@ class TestAICoverService:
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
result = _call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["image_url"].startswith("data:image/svg+xml,")
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
_call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_frame",
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateCover:
|
||||
|
||||
@@ -434,34 +434,22 @@ class TestAiCoverService:
|
||||
assert result["image_url"].startswith("data:image/svg+xml,")
|
||||
assert "手动选帧" in result["image_url"]
|
||||
|
||||
def test_cover_type_ai_frame(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
with patch("shared.ai_service.random.uniform", side_effect=[5.0, 0.9]):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["frame_time"] == 5.0
|
||||
assert result["confidence"] == 0.9
|
||||
assert result["image_url"].startswith("data:image/svg+xml,")
|
||||
assert "封面生成中" in result["image_url"]
|
||||
def test_cover_type_ai_frame_raises_without_mediakit(self):
|
||||
"""ai_frame mode raises RuntimeError when MediaKit is unavailable."""
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
_call_ai_cover_service("plan1", ["a1"], "ai_frame")
|
||||
|
||||
def test_cover_type_ai_regenerate(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
|
||||
assert result["type"] == "ai_frame"
|
||||
def test_cover_type_ai_regenerate_raises_without_mediakit(self):
|
||||
"""ai_regenerate mode raises RuntimeError when MediaKit is unavailable."""
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
_call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
|
||||
|
||||
def test_cover_frame_time_in_range(self):
|
||||
def test_cover_type_manual_still_works(self):
|
||||
"""manual mode does not require MediaKit and still returns stub."""
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
|
||||
assert 1.0 <= result["frame_time"] <= 10.0
|
||||
|
||||
def test_stub_returns_svg_data_uri(self):
|
||||
"""stub 降级返回 SVG data URI,不含任何后端 API 路径."""
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
|
||||
assert result["image_url"].startswith("data:image/svg+xml,")
|
||||
assert "/api/v1/" not in result["image_url"]
|
||||
assert "1080" in result["image_url"]
|
||||
assert "1920" in result["image_url"]
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=5.5)
|
||||
assert result["type"] == "manual"
|
||||
assert result["frame_time"] == 5.5
|
||||
|
||||
def test_manual_stub_returns_svg_data_uri(self):
|
||||
"""manual 模式返回 SVG data URI 占位图."""
|
||||
|
||||
Reference in New Issue
Block a user