Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 157ebd4ef2 | |||
| 3717787cc8 | |||
| 74e2bdf914 | |||
| f03c1ccc17 | |||
| b7b51d306c | |||
| 80c2000bb7 | |||
| 8abb0e88cb | |||
| f3dccd7d00 | |||
| d4c5c96597 | |||
| 474d7d77e5 | |||
| 04330d2e9d | |||
| dee5054e4f | |||
| a9f6d7e712 | |||
| d7bc908ed3 | |||
| 59faf37fc9 | |||
| 206d517a91 | |||
| 0301370dd8 | |||
| 85bfe58f39 | |||
| 2ff2798c97 | |||
| 00348a2154 | |||
| c00c56a742 | |||
| 8e4a8a8184 | |||
| 6a4085452d | |||
| 0896f3e161 | |||
| 21e84c71c4 | |||
| 17174e2cf5 | |||
| 0a00870ab6 | |||
| 68fa7fd163 | |||
| 7ba46cb9c0 | |||
| 0529c61347 | |||
| 915e551ecc | |||
| b0812b14f9 | |||
| c062ff3912 | |||
| 8e4834c927 | |||
| e1076f7e88 | |||
| 5b95bdef6f | |||
| 61c75ad809 | |||
| 52eb37472d | |||
| d67d6eb2cd | |||
| cd6fde790a | |||
| 9c6af4dd45 | |||
| 7b9e803603 | |||
| 12d7b9ac2f | |||
| 887e66b0f9 | |||
| 9520507f38 | |||
| 0d99a28fc2 |
@@ -10,8 +10,6 @@ Changes:
|
||||
3. config 为 JSON 字段,存储封面配置信息
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
@@ -73,7 +71,7 @@ def upgrade() -> None:
|
||||
name=name,
|
||||
thumbnail_url="",
|
||||
is_system=True,
|
||||
config=json.dumps(config),
|
||||
config=config,
|
||||
created_at=sa.func.now(),
|
||||
updated_at=sa.func.now(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""修复 cover_templates.config 双重序列化
|
||||
|
||||
Revision ID: 056_fix_cover_templates_config
|
||||
Revises: 055_cover_templates
|
||||
Create Date: 2026-08-13
|
||||
|
||||
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
|
||||
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
|
||||
|
||||
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "056_fix_cover_templates_config"
|
||||
down_revision = "055_cover_templates"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
|
||||
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE cover_templates SET config = (config#>>'{}')::json "
|
||||
"WHERE jsonb_typeof(config::jsonb) = 'string'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No safe rollback — the original data was incorrect
|
||||
pass
|
||||
@@ -8,6 +8,7 @@ from app.api.routes.classification_jobs import router as classification_jobs_rou
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_cover import router as generation_cover_router
|
||||
from app.api.routes.generation_preview import router as generation_preview_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
@@ -98,6 +99,11 @@ api_router.include_router(
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_cover_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
|
||||
@@ -7,7 +7,6 @@ API:
|
||||
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -55,7 +54,7 @@ def list_cover_templates(
|
||||
thumbnail_url=t.thumbnail_url,
|
||||
is_system=t.is_system,
|
||||
created_at=t.created_at,
|
||||
config=t.config,
|
||||
config=t.config or {},
|
||||
)
|
||||
for t in items
|
||||
],
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""封面生成路由 — Generation 模块.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
|
||||
挂载路径: /api/v1/generation/generate-cover
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
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, Query
|
||||
from pydantic import BaseModel, Field
|
||||
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 .templates_editor.dependencies import get_draft_plan_id, get_editor_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Generation"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
template_id: str = Query(..., description="模板 ID"),
|
||||
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 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── 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", "")
|
||||
|
||||
# 第二步:如果还没有,通过 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],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(pt.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 source_edit_plan_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
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
|
||||
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
|
||||
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
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
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
if body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
cover_url_from_task = None
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
|
||||
# 步骤 A:通过 generation_task_id 直接查找
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
if generation_task_id:
|
||||
try:
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task and getattr(task, "cover_url", ""):
|
||||
cover_url_from_task = task.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤A读取 cover_url 失败: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 C:通过 user+template 查找最近的已完成预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤C-user+template): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤C查找 cover_url 失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if cover_url_from_task:
|
||||
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
|
||||
cover_data = {
|
||||
"type": "ai_frame",
|
||||
"image_url": cover_url_from_task,
|
||||
"frame_time": 0.0,
|
||||
"confidence": 0.95,
|
||||
}
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
logger.warning(
|
||||
"[封面生成] 统一管道未找到 cover_url: plan_id=%s",
|
||||
plan_id,
|
||||
)
|
||||
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
|
||||
)
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
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
|
||||
normalized = normalize_plan_config(current_config)
|
||||
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,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -267,6 +268,20 @@ def create_preview_generation_task(
|
||||
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
|
||||
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
||||
|
||||
# 处理标题配置:如果有标题文本,序列化到 custom_title 字段传递给 worker
|
||||
title_config = request.title_config or {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
# 将标题文本和样式配置序列化为 JSON 存入 custom_title
|
||||
# Worker 端会解析 JSON 获取完整标题配置
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
logger.info(
|
||||
"[预览生成] 标题配置: text=%s, config_keys=%s",
|
||||
title_text[:30],
|
||||
list(title_config.keys()),
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
try:
|
||||
@@ -290,6 +305,7 @@ def create_preview_generation_task(
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
is_preview=True,
|
||||
custom_title=custom_title_value,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
- bgm.py: BGM 管理
|
||||
- effects.py: 转场 + 滤镜
|
||||
- export.py: 导出配置
|
||||
- cover.py: 封面管理 + AI 生成封面
|
||||
- subtitles.py: 字幕管理
|
||||
- ai_features.py: AI 推荐
|
||||
- generation.py: 生成(触发/进度/记录)
|
||||
@@ -31,7 +30,6 @@ from .adjustments import router as adjustments_router
|
||||
from .ai_features import router as ai_features_router
|
||||
from .bgm import router as bgm_router
|
||||
from .clips import router as clips_router
|
||||
from .cover import router as cover_router
|
||||
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
@@ -51,7 +49,6 @@ _sub_routers = [
|
||||
bgm_router,
|
||||
effects_router,
|
||||
export_router,
|
||||
cover_router,
|
||||
subtitles_router,
|
||||
ai_features_router,
|
||||
generation_router,
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""封面管理路由.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
"""
|
||||
|
||||
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 (
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
@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 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("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(
|
||||
"封面生成: 通过 generation_task_id 找到视频: plan_id=%s task_id=%s",
|
||||
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)
|
||||
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:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
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
|
||||
normalized = normalize_plan_config(current_config)
|
||||
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,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -223,7 +223,12 @@ def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
url = getattr(videos[0], "file_url", "") or ""
|
||||
# 规范化:合并路径中的双斜杠(保留协议头 ://)
|
||||
if url:
|
||||
import re as _re
|
||||
url = _re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@@ -99,29 +99,6 @@ class AIRecommendResponse(BaseModel):
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
# ── 封面生成 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── BGM ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,8 +7,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
class ConfirmGenerationRequest(BaseModel):
|
||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
||||
|
||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, description="输出视频高度")
|
||||
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
@@ -181,6 +181,10 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
default="",
|
||||
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
|
||||
)
|
||||
title_config: dict = Field(
|
||||
default_factory=dict,
|
||||
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
|
||||
@@ -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
|
||||
@@ -200,15 +200,7 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
// Step 4: title(新顺序:标题在预览之前)
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
@@ -222,6 +214,14 @@ test.describe("Core generation flow", () => {
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
@@ -258,9 +258,12 @@ test.describe("Core generation flow", () => {
|
||||
// Generate API may return 400 in test env if template has no ready segments
|
||||
// That is OK for a wizard flow smoke test
|
||||
if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
const genData = (await genResp.json()) as {
|
||||
items: Array<{ id: string; status: string }>
|
||||
total: number
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 后端路由: /api/v1/cover-templates
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { CoverTemplate } from "@/pages/editing-planner/types"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
|
||||
export interface CoverTemplateListResponse {
|
||||
items: CoverTemplate[]
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import type { CoverConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string
|
||||
cover: {
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
image_url?: string
|
||||
thumbnail_url?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 生成封面 — 从预览视频中抽帧 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post<GenerateCoverResponse>(
|
||||
"/generation/generate-cover",
|
||||
{ ...data, template_id: templateId },
|
||||
{
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -10,3 +10,6 @@ export type {
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
export { generateCover } from "./cover"
|
||||
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
|
||||
|
||||
@@ -13,6 +13,17 @@ export interface CreatePreviewRequest {
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* AI 推荐 + 封面生成 API
|
||||
* AI 推荐 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
AIRecommendRequest,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
} from "./types"
|
||||
import type { AIRecommendRequest, AIRecommendResponse } from "./types"
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
export async function aiRecommendClips(
|
||||
@@ -17,14 +12,3 @@ export async function aiRecommendClips(
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data, {
|
||||
timeout: 180000, // 封面生成涉及 MediaKit 抽帧,最长 180 秒
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ export type {
|
||||
AIRecommendRequest,
|
||||
AIRecommendClipItem,
|
||||
AIRecommendResponse,
|
||||
GenerateCoverRequest,
|
||||
GenerateCoverResponse,
|
||||
CoverResult,
|
||||
EditPlanClipStatus,
|
||||
EditPlanClip,
|
||||
CreateEditPlanClipRequest,
|
||||
@@ -81,8 +78,8 @@ export {
|
||||
createClipsFromAssets,
|
||||
} from "./clips"
|
||||
|
||||
// AI 推荐 + 封面生成
|
||||
export { aiRecommendClips, generateCover } from "./aiFeatures"
|
||||
// AI 推荐
|
||||
export { aiRecommendClips } from "./aiFeatures"
|
||||
|
||||
// 素材库
|
||||
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
|
||||
|
||||
@@ -9,8 +9,8 @@ import type {
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import type { CoverConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
/* ── 模板草稿状态 ── */
|
||||
|
||||
@@ -118,6 +118,10 @@ export interface EditPlanConfig {
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 预览视频 URL(封面生成用) */
|
||||
rendered_storage_key?: string
|
||||
/** 生成任务 ID */
|
||||
generation_task_id?: string
|
||||
}
|
||||
|
||||
/* ── 模板草稿主体 ── */
|
||||
@@ -240,7 +244,7 @@ export interface GeneratedVideo {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ── AI 推荐 & 封面生成 ── */
|
||||
/* ── AI 推荐 ── */
|
||||
|
||||
/** AI 推荐请求 */
|
||||
export interface AIRecommendRequest {
|
||||
@@ -270,28 +274,6 @@ export interface AIRecommendResponse {
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** AI 封面生成请求 */
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string
|
||||
cover: CoverResult
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
image_url?: string
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
/* ── 片段 CRUD 相关 ── */
|
||||
|
||||
/** 片段状态 */
|
||||
|
||||
@@ -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,
|
||||
@@ -243,7 +238,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => drawers.setCoverDrawerOpen(true)}
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
@@ -336,12 +330,6 @@ const EditingPlanner: React.FC = () => {
|
||||
stickerSettings={stickerSettings}
|
||||
onStickerChange={setStickerSettings}
|
||||
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
|
||||
coverDrawerOpen={drawers.coverDrawerOpen}
|
||||
onCloseCoverDrawer={() => drawers.setCoverDrawerOpen(false)}
|
||||
coverConfig={coverConfig}
|
||||
setCoverConfig={setCoverConfig}
|
||||
templateId={urlTemplateId}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
|
||||
|
||||
@@ -141,21 +140,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 封面设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
封面设置
|
||||
</div>
|
||||
{onOpenCoverDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置视频封面</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<ClipDetailSection
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* 封面选择器入口(向后兼容)
|
||||
* 实际实现位于 ./cover-selector/ 目录
|
||||
*/
|
||||
export { default } from "./cover-selector"
|
||||
@@ -10,7 +10,6 @@ import PipConfigPanel from "./PipConfigPanel"
|
||||
import FilterPanel from "./FilterPanel"
|
||||
import GreenScreenPanel from "./GreenScreenPanel"
|
||||
import StickerPanel from "./StickerPanel"
|
||||
import CoverSelector from "./CoverSelector"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
@@ -24,7 +23,6 @@ import type {
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
|
||||
@@ -87,13 +85,6 @@ interface EditorDrawersProps {
|
||||
stickerSettings: StickerConfig
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
onCloseStickerDrawer: () => void
|
||||
// 封面
|
||||
coverDrawerOpen: boolean
|
||||
onCloseCoverDrawer: () => void
|
||||
coverConfig: CoverConfig
|
||||
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
templateId: string
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
@@ -144,11 +135,6 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
stickerSettings,
|
||||
onStickerChange,
|
||||
onCloseStickerDrawer,
|
||||
coverDrawerOpen,
|
||||
onCloseCoverDrawer,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
templateId,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
@@ -253,16 +239,6 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* 封面选择器 Drawer */}
|
||||
<CoverSelector
|
||||
open={coverDrawerOpen}
|
||||
onClose={onCloseCoverDrawer}
|
||||
config={coverConfig}
|
||||
onChange={setCoverConfig}
|
||||
totalDuration={totalDuration}
|
||||
templateId={templateId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ interface RightPanelProps {
|
||||
onOpenFilterDrawer: () => void
|
||||
onOpenGreenScreenDrawer: () => void
|
||||
onOpenStickerDrawer: () => void
|
||||
onOpenCoverDrawer: () => void
|
||||
// 片段 tab
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
@@ -73,7 +72,6 @@ const RightPanel: React.FC<RightPanelProps> = ({
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
clips,
|
||||
selectedClipId,
|
||||
onClipSelect,
|
||||
@@ -144,7 +142,6 @@ const RightPanel: React.FC<RightPanelProps> = ({
|
||||
onOpenFilterDrawer={onOpenFilterDrawer}
|
||||
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
|
||||
onOpenStickerDrawer={onOpenStickerDrawer}
|
||||
onOpenCoverDrawer={onOpenCoverDrawer}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -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,195 +0,0 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { Drawer, Modal, Spin, message } from "antd"
|
||||
import { generateCover } from "@/api/template-editor/aiFeatures"
|
||||
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
|
||||
templateId: string
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
templateId,
|
||||
}) => {
|
||||
const {
|
||||
fileInputRef,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
update,
|
||||
handleReset,
|
||||
handleModeChange,
|
||||
handleFileUpload,
|
||||
handleDrop,
|
||||
handleUseAiSuggestion,
|
||||
formatTime,
|
||||
} = useCoverSelector({ config, onChange })
|
||||
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const handleGenerateCover = async () => {
|
||||
if (!templateId) {
|
||||
message.error("请先保存模板")
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
try {
|
||||
const res = await generateCover(templateId, {
|
||||
asset_ids: [],
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
const imageUrl = res.cover?.image_url || res.cover?.thumbnail_url || ""
|
||||
if (imageUrl) {
|
||||
update({ upload_url: imageUrl, thumbnail_url: imageUrl })
|
||||
}
|
||||
message.success("封面生成成功")
|
||||
} catch (err: any) {
|
||||
message.error("封面生成失败: " + (err?.message || "未知错误"))
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* AI 生成封面按钮 */}
|
||||
{config.enabled && config.mode === "auto" && (
|
||||
<div className="cover-generate-section">
|
||||
<button
|
||||
className="cover-generate-btn"
|
||||
onClick={handleGenerateCover}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? "生成中..." : "🤖 AI 生成封面"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成进度弹窗 */}
|
||||
<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>
|
||||
</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,
|
||||
])
|
||||
|
||||
@@ -17,7 +17,6 @@ export const useEditorDrawers = () => {
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 目标片段 ID ── */
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
@@ -67,8 +66,6 @@ export const useEditorDrawers = () => {
|
||||
setChromaKeyDrawerOpen,
|
||||
stickerDrawerOpen,
|
||||
setStickerDrawerOpen,
|
||||
coverDrawerOpen,
|
||||
setCoverDrawerOpen,
|
||||
// 目标 ID
|
||||
transitionTargetClipId,
|
||||
speedTargetClipId,
|
||||
|
||||
@@ -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,6 +67,4 @@ export interface ClipPropertiesPanelProps {
|
||||
onOpenGreenScreenDrawer?: () => void
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void
|
||||
}
|
||||
|
||||
@@ -71,9 +71,6 @@ export {
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "./sticker"
|
||||
|
||||
/* 封面 */
|
||||
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG, type CoverTemplate } from "./cover"
|
||||
|
||||
/* 片段数据 */
|
||||
export { type ClipType, type ClipData } from "./clip"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 选择标题 → 生成预览 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
@@ -12,7 +12,10 @@
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { getAssetsByKind } from "@/api/assets/assets"
|
||||
import type { AssetItem } from "@/api/assets/types"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
@@ -24,7 +27,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useStep4Preview } from "./hooks/useStep4Preview"
|
||||
import { useStep5Preview } from "./hooks/useStep5Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -71,6 +74,20 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewModalOpen,
|
||||
} = formState
|
||||
|
||||
/* ── 查询视频素材,用于 Step4 标题预览背景 ── */
|
||||
const { data: videoAssets = [] } = useQuery({
|
||||
queryKey: ["generate-video-assets"],
|
||||
queryFn: () => getAssetsByKind("video", { limit: 50 }),
|
||||
})
|
||||
|
||||
// 获取第一个选中素材的 URL
|
||||
const sourceVideoUrl = useMemo(() => {
|
||||
const firstId = selectedMaterials[0]
|
||||
if (!firstId) return undefined
|
||||
const asset = videoAssets.find((a: AssetItem) => a.id === firstId)
|
||||
return asset?.file_url
|
||||
}, [selectedMaterials, videoAssets])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
@@ -96,8 +113,8 @@ const GeneratePage: React.FC = () => {
|
||||
return id ? [id] : []
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice])
|
||||
|
||||
/* ── Step4 预览生成(多预览 + voice_ids) ── */
|
||||
const step4Preview = useStep4Preview({
|
||||
/* ── Step5 预览生成(多预览 + voice_ids) ── */
|
||||
const step5Preview = useStep5Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
@@ -108,6 +125,7 @@ const GeneratePage: React.FC = () => {
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
})
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -119,7 +137,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step4Preview.canProceed,
|
||||
previewReady: step5Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -150,7 +168,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: step4Preview.selectedTaskId,
|
||||
previewTaskId: step5Preview.selectedTaskId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
@@ -207,20 +225,20 @@ const GeneratePage: React.FC = () => {
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/* Step4 多预览 */
|
||||
/* Step5 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step4Preview.items}
|
||||
previewSelectedIndex={step4Preview.selectedIndex}
|
||||
onSelectPreview={step4Preview.setSelectedIndex}
|
||||
previewOverallStatus={step4Preview.previewStatus}
|
||||
previewOverallError={step4Preview.previewError}
|
||||
previewOverallProgress={step4Preview.progress}
|
||||
previewAnyGenerating={step4Preview.anyGenerating}
|
||||
previewTemplateName={step4Preview.templateName}
|
||||
previewMaterialCount={step4Preview.materialCount}
|
||||
onGeneratePreview={step4Preview.generatePreview}
|
||||
onRegeneratePreview={step4Preview.regeneratePreview}
|
||||
previewItems={step5Preview.items}
|
||||
previewSelectedIndex={step5Preview.selectedIndex}
|
||||
onSelectPreview={step5Preview.setSelectedIndex}
|
||||
previewOverallStatus={step5Preview.previewStatus}
|
||||
previewOverallError={step5Preview.previewError}
|
||||
previewOverallProgress={step5Preview.progress}
|
||||
previewAnyGenerating={step5Preview.anyGenerating}
|
||||
previewTemplateName={step5Preview.templateName}
|
||||
previewMaterialCount={step5Preview.materialCount}
|
||||
onGeneratePreview={step5Preview.generatePreview}
|
||||
onRegeneratePreview={step5Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -236,22 +254,24 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step4+ 常驻,展示选中的预览) */}
|
||||
{/* 预览视频面板(Step4+ 常驻,Step4 显示标题预览,Step5+ 显示预览视频) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step4Preview.previewStatus}
|
||||
previewResult={step4Preview.previewResult}
|
||||
previewError={step4Preview.previewError}
|
||||
progress={step4Preview.progress}
|
||||
previewStatus={step5Preview.previewStatus}
|
||||
previewResult={step5Preview.previewResult}
|
||||
previewError={step5Preview.previewError}
|
||||
progress={step5Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step4Preview.regeneratePreview}
|
||||
onRegenerate={step5Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={currentStep >= 5 ? titleSettings : undefined}
|
||||
titleSettings={titleSettings}
|
||||
showTitlePreview={currentStep === 4}
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果(Step5+ 才显示) */}
|
||||
{currentStep >= 5 && (
|
||||
{/* 正式生成结果(Step6+ 才显示) */}
|
||||
{currentStep >= 6 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step4GeneratePreview from "../components/Step4GeneratePreview"
|
||||
import Step5TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5GeneratePreview from "../components/Step5GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
@@ -157,7 +157,14 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4GeneratePreview
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
@@ -175,13 +182,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { drawTitleOnCanvas } from "../utils/drawTitleOnCanvas"
|
||||
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
previewStatus: PreviewStatus
|
||||
@@ -23,134 +25,14 @@ interface PreviewVideoPanelProps {
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 标题文字(Step5 起传入) */
|
||||
/** 标题文字 */
|
||||
titleText?: string
|
||||
/** 标题样式设置(Step5 起传入) */
|
||||
/** 标题样式设置 */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
/* ── Canvas 绘制工具函数 ── */
|
||||
|
||||
/**
|
||||
* 将文本按 maxWidth 逐字换行,返回行数组。
|
||||
* 与 ASS 字幕引擎的逐字换行行为一致。
|
||||
*/
|
||||
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
for (const char of text) {
|
||||
const testLine = currentLine + char
|
||||
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
|
||||
lines.push(currentLine)
|
||||
currentLine = char
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push(currentLine)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
*
|
||||
* Canvas 已通过 CSS 定位到视频实际渲染位置,
|
||||
* 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。
|
||||
*
|
||||
* @param ctx canvas 上下文
|
||||
* @param w canvas CSS 宽度(= 视频渲染宽度)
|
||||
* @param h canvas CSS 高度(= 视频渲染高度)
|
||||
* @param text 标题文字
|
||||
* @param settings 标题样式
|
||||
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
|
||||
* @param position "top" | "center" | "bottom"
|
||||
* @param topOffset 顶部/底部偏移量
|
||||
*/
|
||||
function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
text: string,
|
||||
settings: TitleSettings,
|
||||
paddingX: number,
|
||||
position: string,
|
||||
topOffset: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
// 设置 canvas 物理像素尺寸(高清屏适配)
|
||||
ctx.canvas.width = Math.round(w * dpr)
|
||||
ctx.canvas.height = Math.round(h * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// 清除
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 可用宽度 = 总宽 - 左右边距
|
||||
const availableWidth = w - paddingX * 2
|
||||
if (availableWidth <= 0) return
|
||||
|
||||
// 字体设置
|
||||
const fontSize = Math.round(Math.min(settings.size, 36))
|
||||
const fontWeight = settings.bold ? "bold" : "normal"
|
||||
const fontStyle = settings.italic ? "italic" : "normal"
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
|
||||
|
||||
// 文字属性
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillStyle = settings.color
|
||||
|
||||
const lineHeight = fontSize * 1.4
|
||||
|
||||
// 描边 & 阴影
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.6)"
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = "round"
|
||||
}
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.7)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 2
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
// 换行
|
||||
const displayText = text && text.trim() ? text : "请选择或输入标题"
|
||||
const lines = wrapText(ctx, displayText, availableWidth)
|
||||
|
||||
// 起始 Y:根据 position 计算
|
||||
const totalTextHeight = lines.length * lineHeight
|
||||
let startY: number
|
||||
switch (position) {
|
||||
case "top":
|
||||
startY = topOffset
|
||||
break
|
||||
case "center":
|
||||
startY = (h - totalTextHeight) / 2 + lineHeight / 2
|
||||
break
|
||||
case "bottom":
|
||||
default:
|
||||
startY = h - topOffset - totalTextHeight + lineHeight / 2
|
||||
break
|
||||
}
|
||||
|
||||
// 居中 x = w/2(Canvas 已定位到视频位置,无需额外偏移)
|
||||
const x = w / 2
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineHeight
|
||||
if (settings.stroke) ctx.strokeText(line, x, y)
|
||||
ctx.fillText(line, x, y)
|
||||
})
|
||||
|
||||
// 重置 shadow(避免影响后续绘制)
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
/** Step4 标题预览模式 */
|
||||
showTitlePreview?: boolean
|
||||
/** 素材视频 URL(用于 Step4 标题预览背景) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/* ── 组件 ── */
|
||||
@@ -164,11 +46,12 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
onRegenerate,
|
||||
titleText,
|
||||
titleSettings,
|
||||
showTitlePreview,
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
@@ -293,12 +176,35 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{hasPreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
<h3>{showTitlePreview ? "标题预览" : "预览视频"}</h3>
|
||||
{hasPreview && !showTitlePreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
</div>
|
||||
|
||||
{/* 空状态:还没生成预览 */}
|
||||
{previewStatus === "idle" && (
|
||||
{/* Step4 标题预览模式 */}
|
||||
{showTitlePreview && titleSettings && titleText && (
|
||||
<div style={{ padding: "0 16px 16px" }}>
|
||||
<TitlePreviewCanvas
|
||||
titleText={titleText}
|
||||
titleSettings={titleSettings}
|
||||
videoRatio="9:16"
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step4 但无标题时的空状态 */}
|
||||
{showTitlePreview && (!titleText || !titleSettings) && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">请输入标题</p>
|
||||
<p className="xx-preview-empty-desc">在左侧设置标题后,这里会实时预览效果</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态:还没生成预览(非 Step4 模式) */}
|
||||
{!showTitlePreview && previewStatus === "idle" && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
@@ -308,8 +214,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
{/* 生成中(非 Step4 模式) */}
|
||||
{!showTitlePreview && isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
@@ -325,8 +231,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
{/* 生成失败(非 Step4 模式) */}
|
||||
{!showTitlePreview && isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
@@ -369,8 +275,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览信息 */}
|
||||
{hasPreview && previewResult && (
|
||||
{/* 预览信息(非 Step4 模式) */}
|
||||
{!showTitlePreview && hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>时长</span>
|
||||
|
||||
+43
-56
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Step 4 生成预览组件(支持多预览)
|
||||
* Step 5 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
*/
|
||||
import React from "react"
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
|
||||
interface Step4GeneratePreviewProps {
|
||||
interface Step5GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
@@ -39,7 +39,7 @@ const PREVIEW_COUNT_OPTIONS = [
|
||||
{ value: 3, label: "3个" },
|
||||
]
|
||||
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio,
|
||||
@@ -161,54 +161,57 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 缩略图/状态区域 */}
|
||||
{/* 轻量卡片:深色背景 + 状态指示 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
background: "#000",
|
||||
background: "#1a1a2e",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{item.status === "ready" && item.result && (
|
||||
<video
|
||||
src={item.result.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(item.status === "pending" || item.status === "generating") && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
|
||||
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
|
||||
</p>
|
||||
{/* 中心:预览编号 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</span>
|
||||
|
||||
{/* 状态指示 */}
|
||||
{item.status === "generating" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<LoadingOutlined style={{ fontSize: 14, color: "#fff" }} spin />
|
||||
<span style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
|
||||
生成中 {item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "pending" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<ClockCircleOutlined
|
||||
style={{ fontSize: 14, color: "rgba(255,255,255,0.6)" }}
|
||||
/>
|
||||
<span style={{ color: "rgba(255,255,255,0.6)", fontSize: 12 }}>
|
||||
排队中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "ready" && (
|
||||
<CheckCircleFilled style={{ fontSize: 18, color: "#52c41a" }} />
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
生成失败
|
||||
</p>
|
||||
</div>
|
||||
<ExclamationCircleFilled style={{ fontSize: 18, color: "#ef4444" }} />
|
||||
)}
|
||||
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
@@ -227,22 +230,6 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息 */}
|
||||
{item.status === "ready" && item.result && (
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
background: "#fafafa",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{item.result.duration.toFixed(1)}秒</span>
|
||||
<span>{item.result.clipCount}段</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -291,4 +278,4 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4GeneratePreview
|
||||
export default Step5GeneratePreview
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { Modal, Spin } from "antd"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
@@ -18,6 +19,7 @@ interface Step6CoverSettingsProps {
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
@@ -95,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react"
|
||||
import type { CoverTemplate } from "../../../editing-planner/types"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import type { CoverMode } from "../../../editing-planner/types"
|
||||
import type { CoverMode } from "../../types/cover"
|
||||
|
||||
interface CoverModeSelectorProps {
|
||||
mode: CoverMode
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import type { CoverTemplate } from "../../../editing-planner/types"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 标题实时预览 Canvas 组件
|
||||
*
|
||||
* 在 Step4 标题设置面板中嵌入,让用户实时看到标题文字、字体、大小、颜色、
|
||||
* 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
|
||||
*
|
||||
* 使用共享的 drawTitleOnCanvas 工具函数,与 PreviewVideoPanel 行为一致。
|
||||
*/
|
||||
import React, { useRef, useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { drawTitleOnCanvas } from "../../utils/drawTitleOnCanvas"
|
||||
|
||||
interface TitlePreviewCanvasProps {
|
||||
/** 标题文字 */
|
||||
titleText: string
|
||||
/** 标题样式设置 */
|
||||
titleSettings: TitleSettings
|
||||
/** 视频比例,默认 "9:16"(竖屏) */
|
||||
videoRatio?: string
|
||||
/** 素材视频 URL(作为背景显示) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 videoRatio 字符串为 aspect-ratio CSS 值
|
||||
*/
|
||||
function parseAspect(ratio: string): string {
|
||||
return (ratio || "9:16").replace(":", "/")
|
||||
}
|
||||
|
||||
const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
|
||||
titleText,
|
||||
titleSettings,
|
||||
videoRatio = "9:16",
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// 字体加载状态
|
||||
const fontLoadedRef = useRef(false)
|
||||
|
||||
/** 在 Canvas 上绘制标题 */
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return
|
||||
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
|
||||
// 更新 Canvas CSS 尺寸匹配容器
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40)
|
||||
}
|
||||
|
||||
// 字体加载:确保 measureText 使用正确字体
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fontLoadedRef.current = false
|
||||
|
||||
const fontWeight = titleSettings.bold ? "bold" : ""
|
||||
const fontStyle = titleSettings.italic ? "italic" : ""
|
||||
const fontSpec =
|
||||
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
|
||||
|
||||
const onFontReady = () => {
|
||||
if (cancelled) return
|
||||
fontLoadedRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) draw()
|
||||
})
|
||||
}
|
||||
|
||||
// 用 FontFace API 加载字体,失败则降级
|
||||
try {
|
||||
const fontFace = new FontFace(titleSettings.font, `local("${titleSettings.font}")`)
|
||||
fontFace
|
||||
.load()
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
;(document.fonts as any).add(fontFace)
|
||||
onFontReady()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 字体加载失败,用默认字体继续
|
||||
onFontReady()
|
||||
})
|
||||
} catch {
|
||||
// FontFace 不可用,直接绘制
|
||||
onFontReady()
|
||||
}
|
||||
|
||||
// 同时检查 document.fonts 是否已有该字体
|
||||
if (document.fonts.check(fontSpec)) {
|
||||
onFontReady()
|
||||
return
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic])
|
||||
|
||||
// props 变化时重绘
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(draw)
|
||||
}, [titleText, titleSettings])
|
||||
|
||||
// ResizeObserver 监听容器尺寸变化
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
requestAnimationFrame(draw)
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginBottom: 6,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
预览效果
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: parseAspect(videoRatio),
|
||||
background: sourceVideoUrl
|
||||
? "#000"
|
||||
: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{sourceVideoUrl && (
|
||||
<video
|
||||
src={sourceVideoUrl}
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitlePreviewCanvas
|
||||
@@ -2,7 +2,7 @@
|
||||
* 智能剪辑页面 — 常量定义
|
||||
*/
|
||||
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
import type { CoverConfig } from "./types/cover"
|
||||
|
||||
/* ── 克隆声音状态配置 ── */
|
||||
export const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
@@ -32,8 +32,8 @@ export const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "生成预览" },
|
||||
{ key: 5, label: "选择标题" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "生成预览" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
/** useGenerateVideo 入参 */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getEditPlan } from "@/api/template-editor"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface UseTitleCoverSyncOptions {
|
||||
|
||||
@@ -56,10 +56,41 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
try {
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
||||
const ratio = props.videoRatio || "9:16"
|
||||
let outputWidth: number
|
||||
let outputHeight: number
|
||||
|
||||
if (ratio.includes(":")) {
|
||||
// 宽高比格式,如 "9:16" → 基于基准高度 1920 计算
|
||||
const [rw, rh] = ratio.split(":").map(Number)
|
||||
if (rw > 0 && rh > 0) {
|
||||
// 基准:长边 1920,短边按比例计算
|
||||
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
|
||||
const baseLong = 1920
|
||||
const baseShort = Math.round((baseLong * shortSide) / longSide)
|
||||
// 确保偶数(FFmpeg 要求)
|
||||
const evenShort = baseShort - (baseShort % 2)
|
||||
if (rw < rh) {
|
||||
outputWidth = evenShort
|
||||
outputHeight = baseLong
|
||||
} else {
|
||||
outputWidth = baseLong
|
||||
outputHeight = evenShort
|
||||
}
|
||||
} else {
|
||||
outputWidth = 1080
|
||||
outputHeight = 1920
|
||||
}
|
||||
} else if (ratio.includes("x")) {
|
||||
// 分辨率格式,如 "1080x1920"
|
||||
const [wStr, hStr] = ratio.split("x")
|
||||
outputWidth = parseInt(wStr, 10) || 1080
|
||||
outputHeight = parseInt(hStr, 10) || 1920
|
||||
} else {
|
||||
outputWidth = 1080
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
@@ -78,7 +109,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, selectedTemplate, clearTimer, startPolling])
|
||||
}, [props, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
+114
-78
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Step 4 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* Step 5 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
|
||||
*/
|
||||
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"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
@@ -25,7 +27,7 @@ const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep4PreviewProps {
|
||||
interface UseStep5PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -39,6 +41,8 @@ interface UseStep4PreviewProps {
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
/** 标题设置(传递给后端,让预览视频包含标题) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
@@ -77,7 +81,7 @@ const createInitialItem = (index: number): PreviewItem => ({
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
export function useStep4Preview({
|
||||
export function useStep5Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
@@ -88,7 +92,8 @@ export function useStep4Preview({
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
titleSettings,
|
||||
}: UseStep5PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
@@ -152,6 +157,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -163,6 +169,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
titleSettings?.title || "",
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
@@ -173,6 +180,7 @@ export function useStep4Preview({
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
prevDepsRef.current.titleSettings,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||||
@@ -190,6 +198,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
@@ -201,6 +210,7 @@ export function useStep4Preview({
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
@@ -211,90 +221,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 () => {
|
||||
@@ -336,6 +358,19 @@ export function useStep4Preview({
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
voice_library_id: voiceLibraryId || undefined,
|
||||
// 标题烧录配置
|
||||
title_config: titleSettings?.title
|
||||
? {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
@@ -361,6 +396,7 @@ export function useStep4Preview({
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
@@ -437,4 +473,4 @@ export function useStep4Preview({
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Preview
|
||||
export default useStep5Preview
|
||||
@@ -2,10 +2,10 @@
|
||||
* 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"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -49,12 +49,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
return
|
||||
}
|
||||
// Step3 配音:配音为可选项,不强制校验,用户可跳过
|
||||
if (currentStep === 4 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (currentStep === 5 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
if (currentStep === 5 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 封面配置类型
|
||||
* 智能剪辑封面类型定义
|
||||
* 独立于 editing-planner,仅供 generate 模块使用
|
||||
*/
|
||||
|
||||
/** 封面来源模式 */
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Canvas 标题绘制工具函数(共享模块)
|
||||
*
|
||||
* 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。
|
||||
* 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。
|
||||
*/
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
/**
|
||||
* 将文本按 maxWidth 逐字换行,返回行数组。
|
||||
* 与 ASS 字幕引擎的逐字换行行为一致。
|
||||
*/
|
||||
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
for (const char of text) {
|
||||
const testLine = currentLine + char
|
||||
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
|
||||
lines.push(currentLine)
|
||||
currentLine = char
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push(currentLine)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
*
|
||||
* @param ctx canvas 上下文
|
||||
* @param w canvas CSS 宽度
|
||||
* @param h canvas CSS 高度
|
||||
* @param text 标题文字
|
||||
* @param settings 标题样式
|
||||
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
|
||||
* @param position "top" | "center" | "bottom"
|
||||
* @param topOffset 顶部/底部偏移量
|
||||
*/
|
||||
export function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
text: string,
|
||||
settings: TitleSettings,
|
||||
paddingX: number,
|
||||
position: string,
|
||||
topOffset: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
// 设置 canvas 物理像素尺寸(高清屏适配)
|
||||
ctx.canvas.width = Math.round(w * dpr)
|
||||
ctx.canvas.height = Math.round(h * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// 清除
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 可用宽度 = 总宽 - 左右边距
|
||||
const availableWidth = w - paddingX * 2
|
||||
if (availableWidth <= 0) return
|
||||
|
||||
// 字体设置
|
||||
const fontSize = Math.round(Math.min(settings.size, 36))
|
||||
const fontWeight = settings.bold ? "bold" : "normal"
|
||||
const fontStyle = settings.italic ? "italic" : "normal"
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
|
||||
|
||||
// 文字属性
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillStyle = settings.color
|
||||
|
||||
const lineHeight = fontSize * 1.4
|
||||
|
||||
// 描边 & 阴影
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.6)"
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = "round"
|
||||
}
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.7)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 2
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
// 换行
|
||||
const displayText = text && text.trim() ? text : "请选择或输入标题"
|
||||
const lines = wrapText(ctx, displayText, availableWidth)
|
||||
|
||||
// 起始 Y:根据 position 计算
|
||||
const totalTextHeight = lines.length * lineHeight
|
||||
let startY: number
|
||||
switch (position) {
|
||||
case "top":
|
||||
startY = topOffset
|
||||
break
|
||||
case "center":
|
||||
startY = (h - totalTextHeight) / 2 + lineHeight / 2
|
||||
break
|
||||
case "bottom":
|
||||
default:
|
||||
startY = h - topOffset - totalTextHeight + lineHeight / 2
|
||||
break
|
||||
}
|
||||
|
||||
// 居中 x = w/2
|
||||
const x = w / 2
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineHeight
|
||||
if (settings.stroke) ctx.strokeText(line, x, y)
|
||||
ctx.fillText(line, x, y)
|
||||
})
|
||||
|
||||
// 重置 shadow(避免影响后续绘制)
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
aiRecommendClips,
|
||||
generateCover,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
@@ -182,22 +181,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCover", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(generateCover("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(generateCover("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlanGenerations", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow()
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -17,7 +17,7 @@ import "@/api/generation/types"
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
@@ -47,7 +47,7 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Step4GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第4步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step4GeneratePreview module smoke test", () => {
|
||||
it("should load all step4 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Step5GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step5GeneratePreview module smoke test", () => {
|
||||
it("should load all step5 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,431 +0,0 @@
|
||||
"""视频封面生成器 — 从视频中提取/生成封面图.
|
||||
|
||||
支持能力:
|
||||
- 指定时间点抽帧(默认第1秒)
|
||||
- 智能封面:抽取多帧选最清晰的一帧
|
||||
- 自定义上传封面图(直接返回路径)
|
||||
- 生成的封面图保存为 JPEG 格式,可复用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 配置常量 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# 智能封面抽帧数量
|
||||
SMART_COVER_FRAME_COUNT = 3
|
||||
|
||||
# 默认抽帧时间点(秒)
|
||||
DEFAULT_COVER_TIME = 1.0
|
||||
|
||||
# 封面输出尺寸(宽x高)
|
||||
DEFAULT_COVER_WIDTH = 1080
|
||||
DEFAULT_COVER_HEIGHT = 1920
|
||||
|
||||
# 封面质量(JPEG quality 1-31,越小质量越高)
|
||||
DEFAULT_COVER_QUALITY = 5
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverGenerator:
|
||||
"""视频封面生成器.
|
||||
|
||||
三种模式:
|
||||
1. 指定时间点抽帧:从视频指定时间提取一帧
|
||||
2. 智能封面:抽取3帧,用 blur 检测选最清晰的
|
||||
3. 自定义上传:直接使用用户上传的图片
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract_frame(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
time_sec: float = DEFAULT_COVER_TIME,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""从视频指定时间点提取一帧作为封面.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径
|
||||
time_sec: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量(1-31,越小越好)
|
||||
|
||||
Returns:
|
||||
封面图片路径
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 视频文件不存在
|
||||
subprocess.CalledProcessError: FFmpeg 执行失败
|
||||
"""
|
||||
video_path = Path(video_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not video_path.exists():
|
||||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||
|
||||
# 确保输出目录存在
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 安全钳制时间
|
||||
info = probe_video_info(str(video_path))
|
||||
duration = info.get("duration", 0.0)
|
||||
if duration > 0 and time_sec >= duration:
|
||||
# 超过视频长度,取中间帧
|
||||
time_sec = max(0, duration / 2)
|
||||
if time_sec < 0:
|
||||
time_sec = 0
|
||||
|
||||
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name)
|
||||
run_ffmpeg(command)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError(f"封面生成失败: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def extract_smart_cover(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
frame_count: int = SMART_COVER_FRAME_COUNT,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
work_dir: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""智能封面:抽取多帧,选最清晰的一帧.
|
||||
|
||||
清晰度判断:使用拉普拉斯方差(Variance of Laplacian),
|
||||
方差越大表示图像边缘越丰富,越清晰。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 最终输出封面路径
|
||||
frame_count: 抽帧数量(均匀分布在视频中)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
work_dir: 临时工作目录(默认输出目录的父目录)
|
||||
|
||||
Returns:
|
||||
最佳封面图片路径
|
||||
"""
|
||||
video_path = Path(video_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not video_path.exists():
|
||||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||
|
||||
# 获取视频时长
|
||||
info = probe_video_info(str(video_path))
|
||||
duration = info.get("duration", 0.0)
|
||||
|
||||
if duration <= 0 or frame_count <= 1:
|
||||
# 无法获取时长或只有1帧,退化为普通抽帧
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)),
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
# 临时目录
|
||||
if work_dir is None:
|
||||
work_dir = output_path.parent
|
||||
work_dir = Path(work_dir)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 均匀分布抽帧时间点(跳过首尾5%)
|
||||
start_pct = 0.05
|
||||
end_pct = 0.95
|
||||
if frame_count == 1:
|
||||
time_points = [duration * 0.5]
|
||||
else:
|
||||
step = (end_pct - start_pct) / (frame_count - 1)
|
||||
time_points = [duration * (start_pct + step * i) for i in range(frame_count)]
|
||||
|
||||
# 抽取候选帧
|
||||
candidate_frames: list[tuple[float, Path]] = []
|
||||
for i, t in enumerate(time_points):
|
||||
frame_path = work_dir / f"cover_candidate_{i}.jpg"
|
||||
try:
|
||||
CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
frame_path,
|
||||
time_sec=t,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
candidate_frames.append((t, frame_path))
|
||||
except Exception as e:
|
||||
logger.warning("智能封面抽帧失败(t=%.2fs): %s", t, e)
|
||||
continue
|
||||
|
||||
if not candidate_frames:
|
||||
# 全部失败,退化到普通抽帧
|
||||
logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧")
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=min(DEFAULT_COVER_TIME, duration / 2),
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if len(candidate_frames) == 1:
|
||||
# 只有一帧,直接用
|
||||
import shutil
|
||||
|
||||
shutil.copy2(candidate_frames[0][1], output_path)
|
||||
return output_path
|
||||
|
||||
# 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理)
|
||||
# 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰)
|
||||
# 更准确的方案是用拉普拉斯方差,但需要额外依赖
|
||||
# 这里用文件大小作为近似指标
|
||||
best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size)
|
||||
|
||||
# 复制最佳帧到输出路径
|
||||
import shutil
|
||||
|
||||
shutil.copy2(best_frame[1], output_path)
|
||||
|
||||
logger.info(
|
||||
"智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节",
|
||||
len(candidate_frames),
|
||||
best_frame[0],
|
||||
output_path.stat().st_size,
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
for _, fp in candidate_frames:
|
||||
try:
|
||||
fp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def process_custom_cover(
|
||||
image_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""处理用户自定义上传的封面图.
|
||||
|
||||
调整尺寸、格式转换为标准封面格式。
|
||||
|
||||
Args:
|
||||
image_path: 用户上传的图片路径
|
||||
output_path: 输出封面路径
|
||||
width: 目标宽度
|
||||
height: 目标高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
处理后的封面图片路径
|
||||
"""
|
||||
image_path = Path(image_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"封面图片不存在: {image_path}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(image_path),
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except subprocess.CalledProcessError:
|
||||
# 处理失败,直接复制原图
|
||||
logger.warning("自定义封面处理失败,使用原图")
|
||||
import shutil
|
||||
|
||||
shutil.copy2(image_path, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def generate_cover(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
mode: str = "smart", # smart / time / custom
|
||||
time_sec: float = DEFAULT_COVER_TIME,
|
||||
custom_image: str | Path | None = None,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""统一封面生成入口.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出封面路径
|
||||
mode: 模式 - smart(智能选帧)/ time(指定时间)/ custom(自定义图片)
|
||||
time_sec: time 模式下的抽帧时间点
|
||||
custom_image: custom 模式下的自定义图片路径
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面图片路径
|
||||
"""
|
||||
if mode == "custom" and custom_image:
|
||||
return CoverGenerator.process_custom_cover(
|
||||
custom_image,
|
||||
output_path,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
elif mode == "time":
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=time_sec,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
else:
|
||||
# 默认智能封面
|
||||
return CoverGenerator.extract_smart_cover(
|
||||
video_path,
|
||||
output_path,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_cover_from_plan(
|
||||
plan: Any,
|
||||
video_path: str | Path,
|
||||
output_dir: str | Path,
|
||||
) -> Path | None:
|
||||
"""从 EditPlan 配置生成封面图.
|
||||
|
||||
配置读取:plan.config.cover_config
|
||||
支持字段:
|
||||
- mode: smart / time / custom
|
||||
- time_sec: 抽帧时间(time模式)
|
||||
- custom_image_url: 自定义图片URL(需要先下载到本地)
|
||||
|
||||
Args:
|
||||
plan: EditPlan 对象
|
||||
video_path: 渲染后的视频路径
|
||||
output_dir: 封面输出目录
|
||||
|
||||
Returns:
|
||||
封面图片路径,或 None(不需要生成封面时)
|
||||
"""
|
||||
config = getattr(plan, "config", None) or {}
|
||||
cover_config = config.get("cover_config") if isinstance(config, dict) else None
|
||||
|
||||
if not cover_config:
|
||||
return None
|
||||
|
||||
mode = cover_config.get("mode", "smart")
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / f"cover_{plan.id}.jpg"
|
||||
|
||||
try:
|
||||
if mode == "custom":
|
||||
# 自定义封面:需要先有本地图片路径
|
||||
custom_path = cover_config.get("custom_image_path")
|
||||
if custom_path and Path(custom_path).exists():
|
||||
return CoverGenerator.process_custom_cover(
|
||||
custom_path,
|
||||
output_path,
|
||||
)
|
||||
else:
|
||||
logger.warning("自定义封面图片路径无效,退化为智能封面")
|
||||
mode = "smart"
|
||||
|
||||
if mode == "time":
|
||||
time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME))
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=time_sec,
|
||||
)
|
||||
else:
|
||||
# smart
|
||||
return CoverGenerator.extract_smart_cover(
|
||||
video_path,
|
||||
output_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("封面生成失败: %s", e)
|
||||
return None
|
||||
@@ -85,19 +85,9 @@ def create_video_record_and_dedup(
|
||||
if thumbnail_url:
|
||||
generated_video.thumbnail_url = thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, thumbnail_url)
|
||||
logger.info("Thumbnail reused (pre-generated) for video %s", video_id)
|
||||
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80] if thumbnail_url else "")
|
||||
else:
|
||||
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
_thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
|
||||
if _thumbnail_url:
|
||||
generated_video.thumbnail_url = _thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, _thumbnail_url)
|
||||
logger.info("Thumbnail generated for video %s: %s", video_id, _thumbnail_url)
|
||||
except Exception as thumb_err:
|
||||
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
|
||||
logger.debug("No thumbnail_url provided for video %s, skipping", video_id)
|
||||
|
||||
# 计算视频指纹
|
||||
deduplicator = VideoDeduplicator()
|
||||
|
||||
@@ -47,7 +47,14 @@ def _parse_resolution(resolution_str: str | None) -> tuple[int, int]:
|
||||
w, h = resolution_str.lower().split("x", 1)
|
||||
width = int(w.strip())
|
||||
height = int(h.strip())
|
||||
if width <= 0 or height <= 0:
|
||||
# 最小 100px 防护:避免前端传入宽高比(如 "9:16")被 parseInt 截断为极小值
|
||||
if width < 100 or height < 100:
|
||||
logger.warning(
|
||||
"分辨率异常小 (%dx%d),使用默认值。原始值: %s",
|
||||
width,
|
||||
height,
|
||||
resolution_str,
|
||||
)
|
||||
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
|
||||
return width, height
|
||||
except (ValueError, TypeError):
|
||||
@@ -74,6 +81,7 @@ class RenderAdapterResult:
|
||||
failed_clip_ids: list[str] = None # 失败的 clip id 列表
|
||||
error_message: str = ""
|
||||
error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查
|
||||
cover_url: str = "" # 封面图片 URL(从渲染后视频抽帧,天然带标题)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.rendered_clip_ids is None:
|
||||
@@ -552,20 +560,35 @@ class RenderAdapter:
|
||||
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
|
||||
output_url = upload_to_oss(result.output_path, storage_key)
|
||||
|
||||
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
|
||||
self._report_progress(progress_cb, 90.0, "抽取封面帧")
|
||||
|
||||
# 6. 生成封面缩略图
|
||||
thumbnail_url = ""
|
||||
# 6. 从已渲染视频抽取封面帧(标题已通过 ASS 字幕烧录,封面天然带标题)
|
||||
cover_url = ""
|
||||
cover_frame_path = None
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
cover_frame_path = extract_first_frame(str(result.output_path), width=640)
|
||||
cover_storage_key = f"rendered/{plan_id}/cover.jpg"
|
||||
try:
|
||||
cover_url = upload_to_oss(cover_frame_path, cover_storage_key) or ""
|
||||
finally:
|
||||
if cover_frame_path:
|
||||
try:
|
||||
Path(cover_frame_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if cover_url:
|
||||
logger.info(
|
||||
"[render-adapter] 封面帧提取成功: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
cover_url[:80],
|
||||
)
|
||||
except Exception as cover_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
"[render-adapter] 封面帧提取失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
cover_err,
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
@@ -591,7 +614,7 @@ class RenderAdapter:
|
||||
success=True,
|
||||
output_url=output_url or "",
|
||||
output_path=result.output_path,
|
||||
thumbnail_url=thumbnail_url,
|
||||
thumbnail_url=cover_url,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
@@ -599,6 +622,7 @@ class RenderAdapter:
|
||||
clip_count=len(clips),
|
||||
rendered_clip_ids=final_rendered_ids,
|
||||
failed_clip_ids=final_failed_ids,
|
||||
cover_url=cover_url,
|
||||
)
|
||||
|
||||
def render_from_memory(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""视频缩略图生成工具 — 抽取首帧上传到 OSS。"""
|
||||
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
|
||||
|
||||
统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频,
|
||||
渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,28 +17,30 @@ def extract_first_frame(
|
||||
video_path: str,
|
||||
output_path: str | None = None,
|
||||
*,
|
||||
width: int = 640,
|
||||
width: int = -1,
|
||||
height: int = -1,
|
||||
timeout: int = 30,
|
||||
seek_ratio: float = 0.15,
|
||||
min_seek_seconds: float = 1.0,
|
||||
) -> str:
|
||||
"""抽取视频封面图(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||||
"""抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||||
|
||||
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径,不传则用临时文件
|
||||
width: 输出宽度(默认 640,-1 表示按比例缩放)
|
||||
height: 输出高度(默认 -1,按比例缩放)
|
||||
width: 输出宽度(默认 -1,保持原始分辨率)
|
||||
height: 输出高度(默认 -1,保持原始分辨率)
|
||||
timeout: 超时时间(秒)
|
||||
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
|
||||
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
|
||||
|
||||
Returns:
|
||||
生成的缩略图文件路径
|
||||
生成的封面帧文件路径
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: ffmpeg 执行失败
|
||||
RuntimeError: ffmpeg 执行失败或输出文件为空
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
|
||||
@@ -57,10 +63,20 @@ def extract_first_frame(
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
|
||||
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率。
|
||||
# NOTE: scale_filter 在此处通过 if/else 分支赋值,之后不再被覆盖,
|
||||
# 后续 cmd / cmd2 均复用同一变量,逻辑无变化。
|
||||
if width > 0 or height > 0:
|
||||
w_str = str(width) if width > 0 else "-1"
|
||||
h_str = str(height) if height > 0 else "-1"
|
||||
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||||
else:
|
||||
# 保持原始分辨率,只确保格式兼容
|
||||
scale_filter = "format=yuvj420p"
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
@@ -99,7 +115,7 @@ def extract_first_frame(
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
raise RuntimeError(f"Cover frame extraction failed: {output_path}")
|
||||
|
||||
return output_path
|
||||
except Exception:
|
||||
@@ -118,40 +134,3 @@ def _format_seek_time(seconds: float) -> str:
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = seconds % 60
|
||||
return f"{h:02d}:{m:02d}:{s:05.2f}"
|
||||
|
||||
|
||||
def generate_and_upload_thumbnail(
|
||||
video_path: str,
|
||||
storage_key: str,
|
||||
) -> str | None:
|
||||
"""生成缩略图并上传到 OSS,返回 URL。
|
||||
|
||||
Args:
|
||||
video_path: 本地视频路径
|
||||
storage_key: OSS 存储 key(如 generated/projects/xxx/thumbnails/yyy.jpg)
|
||||
|
||||
Returns:
|
||||
上传成功返回 URL,失败返回 None
|
||||
"""
|
||||
thumbnail_path = None
|
||||
try:
|
||||
thumbnail_path = extract_first_frame(video_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract thumbnail from %s: %s", video_path, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
url = upload_to_oss(thumbnail_path, storage_key)
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning("Failed to upload thumbnail to OSS: %s", e)
|
||||
return None
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if thumbnail_path:
|
||||
try:
|
||||
Path(thumbnail_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -52,6 +52,7 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr
|
||||
from video_processing.tts_engine import TtsEngine
|
||||
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
||||
|
||||
from packages.domain.ass_subtitle_builder import build_ass_content
|
||||
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
|
||||
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
|
||||
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
|
||||
@@ -131,6 +132,83 @@ _PIP_SCALE = 0.25 # PiP 占主画面的比例
|
||||
# ── 统一渲染引擎 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _overlay_title_on_ass(
|
||||
ass_path: Path,
|
||||
*,
|
||||
title_text: str,
|
||||
title_config: dict,
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
video_duration: float,
|
||||
) -> None:
|
||||
"""在已有的 ASS 文件上叠加标题事件。
|
||||
|
||||
用于 ASR 字幕路径:ASR 生成的 ASS 只含字幕事件,此函数将标题
|
||||
作为独立的 TitleStyle + Dialogue 事件追加进去,使标题显示在
|
||||
ASR 字幕之上(封面抽帧时也能看到标题)。
|
||||
|
||||
Args:
|
||||
ass_path: 已有的 ASS 文件路径(由 generate_ass_from_timeline 生成)
|
||||
title_text: 标题文本
|
||||
title_config: 标题样式配置
|
||||
video_width: 视频宽度
|
||||
video_height: 视频高度
|
||||
video_duration: 视频时长
|
||||
"""
|
||||
if not title_text or not title_text.strip():
|
||||
return
|
||||
|
||||
# 生成仅包含标题的 ASS 内容
|
||||
title_only_content = build_ass_content(
|
||||
video_width=video_width,
|
||||
video_height=video_height,
|
||||
video_duration=video_duration,
|
||||
title_text=title_text,
|
||||
title_config=title_config,
|
||||
)
|
||||
if not title_only_content:
|
||||
return
|
||||
|
||||
# 从 title_only_content 中提取 TitleStyle 行和标题 Dialogue 行
|
||||
title_style_line = None
|
||||
title_dialogue_line = None
|
||||
for line in title_only_content.splitlines():
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
title_style_line = line
|
||||
elif "TitleStyle" in line and line.startswith("Dialogue:"):
|
||||
title_dialogue_line = line
|
||||
|
||||
if not title_style_line or not title_dialogue_line:
|
||||
logger.warning("标题 ASS 内容解析失败,跳过叠加")
|
||||
return
|
||||
|
||||
# 读取现有 ASS 文件
|
||||
existing_content = ass_path.read_text(encoding="utf-8")
|
||||
|
||||
# 插入 TitleStyle 到 [V4+ Styles] 段(最后一个 Style: 行之后)
|
||||
# 插入标题 Dialogue 到 [Events] 段(Format 行之后)
|
||||
lines = existing_content.splitlines()
|
||||
last_style_idx = -1
|
||||
events_format_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("Style:"):
|
||||
last_style_idx = i
|
||||
if line.startswith("Format: Layer,"):
|
||||
events_format_idx = i
|
||||
|
||||
if last_style_idx >= 0:
|
||||
lines.insert(last_style_idx + 1, title_style_line)
|
||||
# events_format_idx 需要 +1 因为插入了一行
|
||||
events_format_idx += 1
|
||||
|
||||
# 2. 在 Events Format 行之后、第一个 Dialogue 之前插入标题 Dialogue
|
||||
# 标题应该显示在整个视频时长,放在最前面(最先渲染,在底层)
|
||||
if events_format_idx >= 0:
|
||||
lines.insert(events_format_idx + 1, title_dialogue_line)
|
||||
|
||||
ass_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
class UnifiedRenderService:
|
||||
"""统一渲染引擎。
|
||||
|
||||
@@ -480,7 +558,11 @@ class UnifiedRenderService:
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
title_cfg = config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
subtitle_enabled = subtitle_cfg.get("enabled", True)
|
||||
@@ -515,14 +597,71 @@ class UnifiedRenderService:
|
||||
timeline.segment_count,
|
||||
video_duration,
|
||||
)
|
||||
# ASR 路径也需要叠加标题(标题作为独立 ASS Event 追加到 ASR 字幕之上)
|
||||
# 用独立 try-except 包裹,避免叠加失败时覆盖已生成的 ASR 数据
|
||||
if has_title:
|
||||
try:
|
||||
_overlay_title_on_ass(
|
||||
ass_path,
|
||||
title_text=title_text,
|
||||
title_config=title_cfg,
|
||||
video_width=self.output_width,
|
||||
video_height=self.output_height,
|
||||
video_duration=video_duration,
|
||||
)
|
||||
logger.info(
|
||||
"ASR字幕叠加标题: plan_id=%s title=%s",
|
||||
self.plan.id,
|
||||
title_text[:30],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ASR字幕叠加标题失败,保留纯ASR字幕: plan_id=%s",
|
||||
self.plan.id,
|
||||
exc_info=True,
|
||||
)
|
||||
return ass_path
|
||||
else:
|
||||
# ASR 无结果,不生成字幕
|
||||
# ASR 无结果:如果有标题,仍然生成标题 ASS
|
||||
if has_title:
|
||||
generate_ass_subtitles(
|
||||
ass_path,
|
||||
video_width=self.output_width,
|
||||
video_height=self.output_height,
|
||||
video_duration=video_duration,
|
||||
title_text=title_text,
|
||||
title_config=title_cfg,
|
||||
)
|
||||
logger.info(
|
||||
"ASR无结果但生成标题: plan_id=%s title=%s",
|
||||
self.plan.id,
|
||||
title_text[:30],
|
||||
)
|
||||
return ass_path
|
||||
logger.info("ASR自动字幕无识别结果,跳过字幕: plan_id=%s", self.plan.id)
|
||||
return None
|
||||
except Exception:
|
||||
# ASR 失败降级:不生成字幕,不阻断主流程
|
||||
logger.warning("ASR自动字幕生成失败,跳过字幕", exc_info=True)
|
||||
# ASR 失败降级:如果有标题,仍然生成标题 ASS
|
||||
if has_title:
|
||||
try:
|
||||
generate_ass_subtitles(
|
||||
ass_path,
|
||||
video_width=self.output_width,
|
||||
video_height=self.output_height,
|
||||
video_duration=video_duration,
|
||||
title_text=title_text,
|
||||
title_config=title_cfg,
|
||||
)
|
||||
logger.info(
|
||||
"ASR失败但生成标题: plan_id=%s title=%s",
|
||||
self.plan.id,
|
||||
title_text[:30],
|
||||
)
|
||||
return ass_path
|
||||
except Exception:
|
||||
logger.warning("ASR失败后标题生成也失败", exc_info=True)
|
||||
else:
|
||||
logger.warning("ASR自动字幕生成失败,跳过字幕", exc_info=True)
|
||||
return None
|
||||
|
||||
# 静态字幕模式(原有逻辑)
|
||||
@@ -654,6 +793,8 @@ class UnifiedRenderService:
|
||||
config = self.plan.config or {}
|
||||
tts_cfg = config.get("tts", {}) or {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
use_subtitle_align = False # 是否使用字幕对齐模式
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
|
||||
@@ -163,36 +163,6 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
|
||||
job_service.fail_job(job_id, error_msg[:500])
|
||||
raise RuntimeError(result.error_message)
|
||||
|
||||
# 生成封面(如果配置启用)
|
||||
cover_url = None
|
||||
try:
|
||||
from video_processing.cover_generator import generate_cover_from_plan
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository as EditPlanRepository,
|
||||
)
|
||||
|
||||
# 获取 plan 对象
|
||||
plan_repo = EditPlanRepository(db)
|
||||
plan = plan_repo.get(plan_id)
|
||||
|
||||
if plan and result.output_path:
|
||||
# 检查 cover_config
|
||||
cover_config = (plan.config or {}).get("cover_config")
|
||||
if cover_config and cover_config.get("enabled", False):
|
||||
from pathlib import Path
|
||||
|
||||
output_dir = Path(result.output_path).parent
|
||||
cover_path = generate_cover_from_plan(plan, result.output_path, output_dir)
|
||||
if cover_path:
|
||||
# 生成 cover_url(相对路径或上传到存储)
|
||||
cover_url = f"/covers/{plan_id}.jpg"
|
||||
logger.info("封面生成成功: plan_id=%s cover_path=%s", plan_id, cover_path)
|
||||
else:
|
||||
logger.info("封面生成未启用: plan_id=%s", plan_id)
|
||||
except Exception as e:
|
||||
logger.warning("封面生成失败(不影响视频合成): plan_id=%s error=%s", plan_id, e)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
@@ -205,7 +175,6 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"file_size": result.file_size,
|
||||
"cover_url": cover_url,
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
|
||||
@@ -241,14 +241,32 @@ def _render_with_unified(
|
||||
return {"status": "error", "message": result.error_message or "渲染失败"}
|
||||
|
||||
output_path = result.output_path or Path("")
|
||||
output_url = result.output_url
|
||||
output_url = result.output_url or ""
|
||||
thumbnail_url = result.thumbnail_url or ""
|
||||
storage_key = f"rendered/{plan_id}/output.mp4"
|
||||
# adapter 上传到 rendered/{plan_id}/{job_id}.mp4,从 URL 提取实际 key
|
||||
# 不能用 output.mp4 硬编码,否则 cover 等下游通过 key 构造的 URL 指向不存在的文件
|
||||
if output_url:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_key = get_shared_storage_service().normalize_storage_key(output_url)
|
||||
else:
|
||||
storage_key = f"rendered/{plan_id}/{generation_task_id or plan_id}.mp4"
|
||||
|
||||
# 用 adapter 返回的 clip 明细(以 adapter 的结果为准)
|
||||
rendered_clip_ids = result.rendered_clip_ids or []
|
||||
failed_clip_ids = result.failed_clip_ids or []
|
||||
|
||||
# 将封面候选帧写入 plan.config(供封面 API 直接使用,跳过 MediaKit 抽帧)
|
||||
if result.cover_candidates:
|
||||
plan_config = plan.config or {}
|
||||
plan_config["cover_candidates"] = result.cover_candidates
|
||||
plan.config = plan_config
|
||||
logger.info(
|
||||
"封面候选帧已写入 plan.config: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(result.cover_candidates),
|
||||
)
|
||||
|
||||
return _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
@@ -983,9 +984,9 @@ def _load_template_plan_config(template_id: str) -> dict:
|
||||
|
||||
# 从独立字段组装成 plan.config 格式
|
||||
plan_config: dict[str, Any] = {}
|
||||
title_cfg = template.title_config or {}
|
||||
subtitle_cfg = template.subtitle_config or {}
|
||||
bgm_cfg = template.bgm_config or {}
|
||||
title_cfg = template.title_config if isinstance(template.title_config, dict) else {}
|
||||
subtitle_cfg = template.subtitle_config if isinstance(template.subtitle_config, dict) else {}
|
||||
bgm_cfg = template.bgm_config if isinstance(template.bgm_config, dict) else {}
|
||||
|
||||
if title_cfg:
|
||||
plan_config["title"] = title_cfg
|
||||
@@ -1123,14 +1124,15 @@ def _render_video(
|
||||
resolution: str = "",
|
||||
bgm_config: dict | None = None,
|
||||
voice_ids: list[str] | None = None,
|
||||
) -> tuple[Path, float]:
|
||||
custom_title: str = "",
|
||||
) -> tuple[Path, float, str]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。
|
||||
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/封面抽取逻辑。
|
||||
|
||||
Args:
|
||||
Returns:
|
||||
(output_path, render_duration)
|
||||
(output_path, render_duration, cover_url)
|
||||
"""
|
||||
if not downloaded_videos:
|
||||
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
|
||||
@@ -1169,6 +1171,61 @@ def _render_video(
|
||||
merged_bgm.get("source", ""),
|
||||
)
|
||||
|
||||
# 用户自定义标题覆盖模板标题(用户指定优先级最高)
|
||||
# 支持两种格式:
|
||||
# 1. JSON 格式(新):{"text": "xxx", "font_size": 32, ...} — 包含标题文本和样式
|
||||
# 2. 纯文本格式(旧):直接作为标题文本使用
|
||||
if custom_title and custom_title.strip():
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
_raw_title = plan_cfg.get("title", {}) or {}
|
||||
title_cfg = dict(_raw_title) if isinstance(_raw_title, dict) else {}
|
||||
ct_stripped = custom_title.strip()
|
||||
parsed_config = None
|
||||
if ct_stripped.startswith("{"):
|
||||
try:
|
||||
parsed_config = json.loads(ct_stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed_config = None
|
||||
if parsed_config and isinstance(parsed_config, dict):
|
||||
# JSON 格式:合并完整标题配置(文本 + 样式)
|
||||
title_text = (parsed_config.get("text") or "").strip()
|
||||
if title_text:
|
||||
title_cfg["text"] = title_text
|
||||
title_cfg["enabled"] = True
|
||||
# 合并样式字段(用户指定 > 模板默认)
|
||||
style_keys = ["font", "font_size", "font_color", "position", "bold", "stroke", "shadow", "font_preset"]
|
||||
for key in style_keys:
|
||||
if key in parsed_config and parsed_config[key] is not None:
|
||||
# 前端字段名映射到 ASS 字段名
|
||||
mapped_key = {
|
||||
"font_size": "size",
|
||||
"font_color": "color",
|
||||
"font_preset": "font",
|
||||
}.get(key, key)
|
||||
title_cfg[mapped_key] = parsed_config[key]
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 用户标题配置(JSON)已注入: text=%s, style_keys=%s",
|
||||
task_id,
|
||||
title_text[:50],
|
||||
[k for k in style_keys if k in parsed_config],
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[task_id=%s] [渲染] JSON标题缺少text字段,跳过",
|
||||
task_id,
|
||||
)
|
||||
else:
|
||||
# 纯文本格式:仅设置文本
|
||||
title_cfg["text"] = ct_stripped
|
||||
title_cfg["enabled"] = True
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 用户自定义标题已注入: title=%s",
|
||||
task_id,
|
||||
ct_stripped[:50],
|
||||
)
|
||||
plan_cfg["title"] = title_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
|
||||
# 确保输出分辨率配置存在
|
||||
# 优先级:用户指定 > 模板配置 > 默认 1280x720
|
||||
# 预览模式:强制 854x480 + 低码率
|
||||
@@ -1189,7 +1246,10 @@ def _render_video(
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["voice_id"] = voice_ids[0]
|
||||
subtitle_cfg = plan_cfg.get("subtitle", {}) or {}
|
||||
if not isinstance(subtitle_cfg, dict):
|
||||
subtitle_cfg = {}
|
||||
subtitle_cfg["auto_generated"] = True
|
||||
subtitle_cfg["enabled"] = True # 确保 ASR 字幕路径被触发,标题叠加也依赖此路径
|
||||
plan_cfg["subtitle"] = subtitle_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
@@ -1244,8 +1304,9 @@ def _render_video(
|
||||
|
||||
# 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理
|
||||
output_path = render_output_path
|
||||
cover_url = getattr(render_result, "cover_url", "") or ""
|
||||
|
||||
return output_path, render_duration
|
||||
return output_path, render_duration, cover_url
|
||||
|
||||
|
||||
def _upload_and_record(
|
||||
@@ -1256,13 +1317,16 @@ def _upload_and_record(
|
||||
editing_mode,
|
||||
user_id: str = "",
|
||||
video_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> tuple[str, float, int, int]:
|
||||
"""上传 OSS、创建视频记录并查重。
|
||||
|
||||
Returns:
|
||||
(file_url, duration, file_size, video_count)
|
||||
"""
|
||||
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_path.name}"
|
||||
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
|
||||
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
|
||||
storage_key = "/".join(path_parts)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
# 上传 OSS
|
||||
@@ -1499,12 +1563,22 @@ def generate_video(self, task_id: str) -> dict:
|
||||
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
|
||||
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
|
||||
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
|
||||
# 防护:前端可能误传宽高比(如 parseInt("9:16") = 9),宽度 < 100 时忽略
|
||||
if _ow < 100 or _oh < 100:
|
||||
logger.warning(
|
||||
"[task_id=%s] output_width/output_height 异常 (%dx%d),回退到默认",
|
||||
task_id,
|
||||
_ow,
|
||||
_oh,
|
||||
)
|
||||
_ow = OUTPUT_WIDTH
|
||||
_oh = OUTPUT_HEIGHT
|
||||
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
|
||||
_resolved_resolution = f"{_ow}x{_oh}"
|
||||
else:
|
||||
_resolved_resolution = task_info.get("resolution", "")
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
output_path, render_duration, cover_url = _render_video(
|
||||
task_id=task_id,
|
||||
downloaded_videos=downloaded_videos,
|
||||
voice_path=audio_path,
|
||||
@@ -1517,12 +1591,42 @@ def generate_video(self, task_id: str) -> dict:
|
||||
resolution=_resolved_resolution,
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
custom_title=task_info.get("custom_title", ""),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# 持久化封面 URL 到 GenerationTask(统一封面管道:从渲染后视频抽帧)
|
||||
if cover_url:
|
||||
_cover_session = None
|
||||
try:
|
||||
_cover_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
|
||||
|
||||
_cover_model = (
|
||||
_cover_session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
|
||||
)
|
||||
if _cover_model:
|
||||
_cover_model.cover_url = cover_url
|
||||
_cover_session.commit()
|
||||
logger.info(
|
||||
"[task_id=%s] 封面URL已持久化: %s",
|
||||
task_id,
|
||||
cover_url[:80],
|
||||
)
|
||||
finally:
|
||||
if _cover_session:
|
||||
_cover_session.close()
|
||||
except Exception as cover_err:
|
||||
logger.warning(
|
||||
"[task_id=%s] 封面URL持久化失败(不影响主流程): %s",
|
||||
task_id,
|
||||
cover_err,
|
||||
)
|
||||
|
||||
_update_task_progress(task_id, 80, "渲染完成")
|
||||
|
||||
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────────────
|
||||
@@ -1535,6 +1639,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
editing_mode=editing_mode,
|
||||
user_id=user_id,
|
||||
video_name=task_info.get("video_title", ""),
|
||||
thumbnail_url=cover_url,
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
@@ -206,11 +206,21 @@ def ingest_asset(job_id: str) -> dict:
|
||||
# 视频类型:生成缩略图(文件还在的时候生成)
|
||||
thumbnail_url = None
|
||||
if media_type == "video" and extract_success:
|
||||
frame_path = None
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
frame_path = extract_first_frame(str(local_file), width=640)
|
||||
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
|
||||
try:
|
||||
thumbnail_url = upload_to_oss(frame_path, thumb_storage_key)
|
||||
finally:
|
||||
if frame_path:
|
||||
try:
|
||||
Path(frame_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if thumbnail_url:
|
||||
logger.info(
|
||||
"素材缩略图生成成功: job_id=%s url=%s",
|
||||
|
||||
@@ -53,9 +53,10 @@ ARG APP_VERSION=dev
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖)
|
||||
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖,ffmpeg 用于封面兜底取帧)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制虚拟环境
|
||||
|
||||
@@ -32,6 +32,7 @@ class CreateGenerationTaskCommand:
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
title_config: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
|
||||
@@ -361,7 +361,10 @@ def _call_ai_cover_service(
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 封面生成服务.
|
||||
|
||||
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
|
||||
统一封面管道下,封面已由渲染后视频抽帧生成并持久化到 GenerationTask.cover_url。
|
||||
此函数仅处理 manual/upload 等需要前端交互的类型,
|
||||
ai_frame/ai_regenerate 类型应由调用方直接从持久化的封面 URL 读取。
|
||||
|
||||
失败时抛出 RuntimeError。
|
||||
|
||||
Args:
|
||||
@@ -369,7 +372,7 @@ def _call_ai_cover_service(
|
||||
asset_ids: 素材 ID 列表
|
||||
cover_type: 封面类型
|
||||
frame_time: 手动选帧时间点
|
||||
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
|
||||
primary_video_url: 主视频的可访问 URL
|
||||
"""
|
||||
if cover_type == "upload":
|
||||
return {
|
||||
@@ -392,57 +395,14 @@ def _call_ai_cover_service(
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
# ai_frame / ai_regenerate - 尝试调用 MediaKit
|
||||
if primary_video_url:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
if client.is_available:
|
||||
try:
|
||||
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,
|
||||
poll_interval=3.0,
|
||||
max_poll_attempts=60, # 180秒超时
|
||||
)
|
||||
|
||||
if frames and len(frames) > 0:
|
||||
# 选择第一帧(SceneChange 策略的第一帧通常是最佳画面)
|
||||
best_frame = frames[0]
|
||||
image_url = best_frame.get("image_url", "")
|
||||
timestamp = best_frame.get("timestamp", 0.0)
|
||||
|
||||
if image_url:
|
||||
logger.info(
|
||||
"MediaKit 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
|
||||
plan_id,
|
||||
timestamp,
|
||||
image_url[:80],
|
||||
)
|
||||
# MediaKit 返回的 URL 是临时内部 URL,浏览器无法直接访问
|
||||
# 需要下载到本地并重新上传到 OSS,返回公开可访问的 URL
|
||||
public_url = _transfer_cover_frame_to_storage(image_url, plan_id)
|
||||
return {
|
||||
"type": "ai_frame",
|
||||
"image_url": public_url,
|
||||
"frame_time": round(timestamp, 1),
|
||||
"confidence": 0.85,
|
||||
}
|
||||
else:
|
||||
logger.warning("MediaKit 返回的帧无 image_url")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 抽帧失败: %s", str(e))
|
||||
|
||||
# 封面生成失败 - 不再降级到 stub,直接报错
|
||||
raise RuntimeError(
|
||||
f"封面生成失败: plan_id={plan_id}, MediaKit 不可用或抽帧失败。" f"请检查 primary_video_url 是否可访问。"
|
||||
# ai_frame / ai_regenerate: 封面应由渲染后视频抽帧管道生成
|
||||
# 如果调用方传入了持久化的封面 URL,直接使用
|
||||
logger.warning(
|
||||
"封面生成回退: plan_id=%s cover_type=%s — 统一管道应已生成封面,请检查 GenerationTask.cover_url",
|
||||
plan_id,
|
||||
cover_type,
|
||||
)
|
||||
|
||||
|
||||
# ── 公共入口 ────────────────────────────────────────────────────────────────
|
||||
raise RuntimeError(f"封面数据不可用 (plan_id={plan_id})。请重新生成预览视频以触发封面自动提取。")
|
||||
|
||||
|
||||
def run_ai_recommend(
|
||||
|
||||
@@ -158,7 +158,7 @@ class TestRenderVideoVoiceInjection:
|
||||
|
||||
from packages.domain import EditingMode
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
output_path, render_duration, _cover_url = _render_video(
|
||||
task_id="test_task_123",
|
||||
downloaded_videos=[Path("/tmp/video1.mp4")],
|
||||
voice_path=None,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
测试:模板 config 字段存储了非 dict 值(如 True / False / str)时,
|
||||
渲染链路不会崩溃('bool' object has no attribute 'get')。
|
||||
|
||||
覆盖两个关键文件:
|
||||
1. generation.py — _load_template_plan_config 旧系统路径
|
||||
2. unified_render_service.py — _maybe_generate_ass
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add worker app to path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
|
||||
class TestLoadTemplatePlanConfigBoolDefense:
|
||||
"""_load_template_plan_config 旧系统路径对非 dict 值的防护。"""
|
||||
|
||||
def _call_old_path(self, title_cfg, subtitle_cfg, bgm_cfg):
|
||||
"""通过 mock 新模板系统返回 None,强制走旧模板系统 fallback 路径。"""
|
||||
from worker_app.tasks.generation import _load_template_plan_config
|
||||
|
||||
mock_old_template = MagicMock()
|
||||
mock_old_template.title_config = title_cfg
|
||||
mock_old_template.subtitle_config = subtitle_cfg
|
||||
mock_old_template.bgm_config = bgm_cfg
|
||||
|
||||
mock_session = MagicMock()
|
||||
# 旧系统 query 返回 mock template
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = mock_old_template
|
||||
|
||||
# Mock 新模板系统 repo.get() 返回 None(强制走 fallback)
|
||||
mock_repo_cls = MagicMock()
|
||||
mock_repo_cls.return_value.get.return_value = None
|
||||
|
||||
with (
|
||||
patch("worker_app.tasks.generation.SessionLocal", return_value=mock_session),
|
||||
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyEditTemplateRepository", mock_repo_cls),
|
||||
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyTemplateClipConfigRepository", MagicMock()),
|
||||
):
|
||||
return _load_template_plan_config("fake-id")
|
||||
|
||||
def test_bool_values_return_empty(self):
|
||||
"""title_config=True / subtitle_config=False / bgm_config='str' → 全部过滤掉"""
|
||||
result = self._call_old_path(True, False, "not_a_dict")
|
||||
assert isinstance(result, dict)
|
||||
assert "title" not in result
|
||||
assert "subtitle" not in result
|
||||
assert "bgm" not in result
|
||||
|
||||
def test_valid_dict_passes_through(self):
|
||||
"""正常 dict 正常传递"""
|
||||
result = self._call_old_path(
|
||||
{"text": "标题", "enabled": True},
|
||||
{"text": "副标题"},
|
||||
{"enabled": True, "source": "test.mp3"},
|
||||
)
|
||||
assert result["title"] == {"text": "标题", "enabled": True}
|
||||
assert result["subtitle"] == {"text": "副标题"}
|
||||
assert result["bgm"] == {"enabled": True, "source": "test.mp3"}
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
"""None → 空 dict"""
|
||||
result = self._call_old_path(None, None, None)
|
||||
assert result == {}
|
||||
|
||||
def test_mixed_valid_and_invalid(self):
|
||||
"""部分有效、部分无效时只保留有效的"""
|
||||
result = self._call_old_path({"text": "OK"}, True, None)
|
||||
assert "title" in result
|
||||
assert "subtitle" not in result
|
||||
assert "bgm" not in result
|
||||
|
||||
def test_int_and_list_also_filtered(self):
|
||||
"""int / list 类型也被过滤"""
|
||||
result = self._call_old_path(42, [1, 2, 3], 0)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestUnifiedRenderBoolConfigDefense:
|
||||
"""_maybe_generate_ass 对 plan.config 中非 dict title/subtitle 的防护。"""
|
||||
|
||||
def _make_service(self, config):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = config
|
||||
service.plan = mock_plan
|
||||
service.task_id = "test-task"
|
||||
return service
|
||||
|
||||
def test_bool_title_does_not_crash(self):
|
||||
"""config['title']=True → 不崩溃,返回 None"""
|
||||
service = self._make_service({"title": True, "subtitle": {}})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_bool_subtitle_does_not_crash(self):
|
||||
"""config['subtitle']=False → 不崩溃,返回 None"""
|
||||
service = self._make_service({"title": {}, "subtitle": False})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_str_title_does_not_crash(self):
|
||||
"""config['title']='plain string' → 不崩溃"""
|
||||
service = self._make_service({"title": "plain string", "subtitle": {}})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_none_config_does_not_crash(self):
|
||||
"""config=None → 不崩溃"""
|
||||
service = self._make_service(None)
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
|
||||
def test_int_title_does_not_crash(self):
|
||||
"""config['title']=42 → 不崩溃"""
|
||||
service = self._make_service({"title": 42, "subtitle": 0})
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
assert result is None
|
||||
@@ -1,232 +0,0 @@
|
||||
"""测试 compose_video 任务中封面生成集成."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestComposeVideoCoverIntegration:
|
||||
"""测试视频合成任务中的封面生成集成."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job_service(self):
|
||||
"""模拟 JobService."""
|
||||
service = MagicMock()
|
||||
service.get_job.return_value = MagicMock(
|
||||
id="job_123",
|
||||
payload={"plan_id": "plan_456"},
|
||||
)
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(self):
|
||||
"""模拟数据库会话."""
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_render_result(self):
|
||||
"""模拟渲染结果."""
|
||||
result = MagicMock()
|
||||
result.success = True
|
||||
result.output_path = Path("/tmp/output/video_123.mp4")
|
||||
result.output_url = "https://example.com/video_123.mp4"
|
||||
result.duration = 30.0
|
||||
result.clip_count = 5
|
||||
result.width = 1080
|
||||
result.height = 1920
|
||||
result.file_size = 1024000
|
||||
return result
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plan_with_cover_enabled(self):
|
||||
"""模拟启用封面的 plan."""
|
||||
plan = MagicMock()
|
||||
plan.id = "plan_456"
|
||||
plan.config = {
|
||||
"cover_config": {
|
||||
"enabled": True,
|
||||
"mode": "smart",
|
||||
}
|
||||
}
|
||||
return plan
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plan_with_cover_disabled(self):
|
||||
"""模拟禁用封面的 plan."""
|
||||
plan = MagicMock()
|
||||
plan.id = "plan_456"
|
||||
plan.config = {
|
||||
"cover_config": {
|
||||
"enabled": False,
|
||||
}
|
||||
}
|
||||
return plan
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plan_without_cover_config(self):
|
||||
"""模拟没有 cover_config 的 plan."""
|
||||
plan = MagicMock()
|
||||
plan.id = "plan_456"
|
||||
plan.config = {}
|
||||
return plan
|
||||
|
||||
def test_cover_generation_called_when_enabled(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_with_cover_enabled,
|
||||
):
|
||||
"""测试封面生成在启用时被调用."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
# 模拟 RenderAdapter
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
# 模拟 EditPlanRepository
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_with_cover_enabled
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
# 模拟 generate_cover_from_plan
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
mock_gen_cover.return_value = Path("/tmp/output/cover_plan_456.jpg")
|
||||
|
||||
# 执行
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证封面生成被调用
|
||||
mock_gen_cover.assert_called_once()
|
||||
call_args = mock_gen_cover.call_args
|
||||
assert call_args[0][0] == mock_plan_with_cover_enabled # plan
|
||||
assert call_args[0][1] == mock_render_result.output_path # video_path
|
||||
assert call_args[0][2] == mock_render_result.output_path.parent # output_dir
|
||||
|
||||
# 验证结果包含 cover_url
|
||||
assert "cover_url" in result["result"]
|
||||
assert result["result"]["cover_url"] == "/covers/plan_456.jpg"
|
||||
|
||||
def test_cover_generation_skipped_when_disabled(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_with_cover_disabled,
|
||||
):
|
||||
"""测试封面生成在禁用时被跳过."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_with_cover_disabled
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证封面生成未被调用
|
||||
mock_gen_cover.assert_not_called()
|
||||
|
||||
# 验证结果中 cover_url 为 None
|
||||
assert "cover_url" in result["result"]
|
||||
assert result["result"]["cover_url"] is None
|
||||
|
||||
def test_cover_generation_skipped_when_no_config(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_without_cover_config,
|
||||
):
|
||||
"""测试没有 cover_config 时封面生成被跳过."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_without_cover_config
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证封面生成未被调用
|
||||
mock_gen_cover.assert_not_called()
|
||||
|
||||
# 验证结果中 cover_url 为 None
|
||||
assert "cover_url" in result["result"]
|
||||
assert result["result"]["cover_url"] is None
|
||||
|
||||
def test_cover_generation_failure_does_not_break_video(
|
||||
self,
|
||||
mock_job_service,
|
||||
mock_db,
|
||||
mock_render_result,
|
||||
mock_plan_with_cover_enabled,
|
||||
):
|
||||
"""测试封面生成失败不影响视频合成."""
|
||||
from worker_app.tasks.compose_video import _compose_with_unified_engine
|
||||
|
||||
with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter:
|
||||
adapter_instance = MagicMock()
|
||||
adapter_instance.render_plan.return_value = mock_render_result
|
||||
adapter_instance.validate_plan.return_value = (True, [], [], 5, 5)
|
||||
MockAdapter.return_value = adapter_instance
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository"
|
||||
) as MockPlanRepo:
|
||||
plan_repo_instance = MagicMock()
|
||||
plan_repo_instance.get.return_value = mock_plan_with_cover_enabled
|
||||
MockPlanRepo.return_value = plan_repo_instance
|
||||
|
||||
with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover:
|
||||
# 模拟封面生成抛出异常
|
||||
mock_gen_cover.side_effect = Exception("FFmpeg failed")
|
||||
|
||||
task = MagicMock()
|
||||
result = _compose_with_unified_engine(
|
||||
task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db
|
||||
)
|
||||
|
||||
# 验证视频合成仍然成功
|
||||
assert result["status"] == "completed"
|
||||
assert "result" in result
|
||||
assert result["result"]["output_url"] == mock_render_result.output_url
|
||||
|
||||
# 验证结果中 cover_url 为 None
|
||||
assert result["result"]["cover_url"] is None
|
||||
@@ -237,7 +237,7 @@ class TestAIRunTasks:
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
with pytest.raises(RuntimeError, match="封面数据不可用"):
|
||||
run_generate_cover(
|
||||
plan_id="plan-001",
|
||||
asset_ids=["asset-1"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""确认生成 API 单元测试.
|
||||
|
||||
覆盖 POST /tasks/{task_id}/confirm 端点:
|
||||
覆盖 POST /generation/tasks/{task_id}/confirm 端点:
|
||||
- 预览任务已完成 → 直接复用(mark_confirmed),秒出
|
||||
- 预览任务未完成 → 创建新任务走渲染流程
|
||||
- 预览任务不存在 → 404
|
||||
@@ -150,7 +150,7 @@ def app(
|
||||
)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1")
|
||||
test_app.include_router(router, prefix="/api/v1/generation")
|
||||
|
||||
def override_get_current_user():
|
||||
return FakeAuthenticatedUser()
|
||||
@@ -228,7 +228,7 @@ class TestConfirmGenerationReuse:
|
||||
initial_count = len(gen_task_repo._store)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={
|
||||
"output_width": 1080,
|
||||
"output_height": 1920,
|
||||
@@ -266,7 +266,7 @@ class TestConfirmGenerationReuse:
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={"cover_url": "https://cdn.example.com/cover.png", "custom_title": "测试标题"},
|
||||
)
|
||||
|
||||
@@ -290,7 +290,7 @@ class TestConfirmGenerationReuse:
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
|
||||
@@ -308,7 +308,7 @@ class TestConfirmGenerationErrors:
|
||||
def test_confirm_not_found(self, client: TestClient) -> None:
|
||||
"""预览任务不存在 → 404"""
|
||||
resp = client.post(
|
||||
"/api/v1/tasks/nonexistent-task/confirm",
|
||||
"/api/v1/generation/tasks/nonexistent-task/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -324,7 +324,7 @@ class TestConfirmGenerationErrors:
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -345,7 +345,7 @@ class TestConfirmGenerationErrors:
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1920, "output_height": 1080},
|
||||
)
|
||||
|
||||
@@ -373,7 +373,7 @@ class TestConfirmGenerationErrors:
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={
|
||||
"output_width": 1080,
|
||||
"output_height": 1920,
|
||||
@@ -397,7 +397,7 @@ class TestConfirmGenerationErrors:
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={},
|
||||
)
|
||||
|
||||
@@ -419,7 +419,7 @@ class TestConfirmGenerationErrors:
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
f"/api/v1/generation/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 720, "output_height": 1280},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for unified cover frame extraction pipeline.
|
||||
|
||||
统一封面管道测试:
|
||||
- extract_first_frame: 从已渲染视频抽取封面帧
|
||||
- 封面天然带标题(ASS 字幕已烧录到视频中)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestExtractFirstFrame(unittest.TestCase):
|
||||
"""extract_first_frame 单元测试."""
|
||||
|
||||
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
|
||||
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
|
||||
def test_extracts_frame_at_default_ratio(self, mock_probe, mock_run):
|
||||
"""默认在视频 15% 处抽帧."""
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
# Mock run_ffmpeg 创建输出文件(ffmpeg 真实行为)
|
||||
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
|
||||
Path(video.name).write_bytes(b"fake video")
|
||||
result = extract_first_frame(video.name)
|
||||
|
||||
self.assertTrue(Path(result).exists())
|
||||
# 验证 ffmpeg 被调用
|
||||
mock_run.assert_called()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn("-vframes", cmd)
|
||||
self.assertIn("1", cmd)
|
||||
Path(result).unlink(missing_ok=True)
|
||||
|
||||
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
|
||||
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
|
||||
def test_custom_seek_ratio(self, mock_probe, mock_run):
|
||||
"""自定义抽帧位置."""
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
|
||||
Path(video.name).write_bytes(b"fake video")
|
||||
result = extract_first_frame(video.name, seek_ratio=0.5)
|
||||
|
||||
self.assertTrue(Path(result).exists())
|
||||
# 50% of 10s = 5s
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss") + 1
|
||||
seek_val = cmd[ss_idx]
|
||||
# Should be around 5 seconds
|
||||
self.assertIn("05", seek_val)
|
||||
Path(result).unlink(missing_ok=True)
|
||||
|
||||
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
|
||||
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
|
||||
def test_output_path_parameter(self, mock_probe, mock_run):
|
||||
"""指定输出路径."""
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
|
||||
Path(video.name).write_bytes(b"fake video")
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as out:
|
||||
pass # just get a path
|
||||
|
||||
# Create the file so ffmpeg "succeeds"
|
||||
mock_run.side_effect = lambda *a, **k: Path(out.name).write_bytes(b"fake image")
|
||||
|
||||
result = extract_first_frame(video.name, output_path=out.name)
|
||||
self.assertEqual(result, out.name)
|
||||
Path(out.name).unlink(missing_ok=True)
|
||||
|
||||
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
|
||||
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
|
||||
def test_keeps_original_resolution_by_default(self, mock_probe, mock_run):
|
||||
"""默认保持原始分辨率(width=-1, height=-1)."""
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
|
||||
Path(video.name).write_bytes(b"fake video")
|
||||
result = extract_first_frame(video.name)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf") + 1
|
||||
vf_filter = cmd[vf_idx]
|
||||
# Should NOT have scale filter (only format)
|
||||
self.assertNotIn("scale", vf_filter)
|
||||
self.assertIn("format", vf_filter)
|
||||
Path(result).unlink(missing_ok=True)
|
||||
|
||||
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
|
||||
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
|
||||
def test_custom_width_triggers_scale(self, mock_probe, mock_run):
|
||||
"""指定宽度时添加 scale 滤镜."""
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
|
||||
Path(video.name).write_bytes(b"fake video")
|
||||
result = extract_first_frame(video.name, width=640)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf") + 1
|
||||
vf_filter = cmd[vf_idx]
|
||||
self.assertIn("scale=640", vf_filter)
|
||||
Path(result).unlink(missing_ok=True)
|
||||
|
||||
@patch("video_processing.ffmpeg_utils.run_ffmpeg", side_effect=RuntimeError("fail"))
|
||||
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
|
||||
def test_cleanup_temp_file_on_failure(self, mock_probe, mock_run):
|
||||
"""失败时清理临时文件."""
|
||||
from video_processing.thumbnail_generator import extract_first_frame
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
|
||||
Path(video.name).write_bytes(b"fake video")
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
extract_first_frame(video.name)
|
||||
|
||||
|
||||
class TestRenderAdapterCoverUrl(unittest.TestCase):
|
||||
"""RenderAdapterResult.cover_url 字段测试."""
|
||||
|
||||
def test_result_has_cover_url_field(self):
|
||||
"""RenderAdapterResult 包含 cover_url 字段."""
|
||||
from video_processing.render_adapter import RenderAdapterResult
|
||||
|
||||
result = RenderAdapterResult(success=True, cover_url="https://example.com/cover.jpg")
|
||||
self.assertEqual(result.cover_url, "https://example.com/cover.jpg")
|
||||
|
||||
def test_result_cover_url_defaults_empty(self):
|
||||
"""cover_url 默认为空字符串."""
|
||||
from video_processing.render_adapter import RenderAdapterResult
|
||||
|
||||
result = RenderAdapterResult(success=True)
|
||||
self.assertEqual(result.cover_url, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,538 +0,0 @@
|
||||
"""CoverGenerator 纯逻辑单测 — 时间钳制 + 智能选帧算法.
|
||||
|
||||
通过 mock run_ffmpeg 和 probe_video_info 验证纯逻辑部分,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.cover_generator import (
|
||||
DEFAULT_COVER_HEIGHT,
|
||||
DEFAULT_COVER_QUALITY,
|
||||
DEFAULT_COVER_TIME,
|
||||
DEFAULT_COVER_WIDTH,
|
||||
SMART_COVER_FRAME_COUNT,
|
||||
CoverGenerator,
|
||||
)
|
||||
|
||||
|
||||
class TestCoverGeneratorConstants:
|
||||
"""常量默认值测试."""
|
||||
|
||||
def test_default_cover_time(self):
|
||||
"""默认抽帧时间为 1.0 秒."""
|
||||
assert DEFAULT_COVER_TIME == 1.0
|
||||
|
||||
def test_default_dimensions(self):
|
||||
"""默认封面尺寸 1080x1920 (竖屏)."""
|
||||
assert DEFAULT_COVER_WIDTH == 1080
|
||||
assert DEFAULT_COVER_HEIGHT == 1920
|
||||
|
||||
def test_default_quality(self):
|
||||
"""默认质量为 5 (JPEG q:v, 越小越好)."""
|
||||
assert DEFAULT_COVER_QUALITY == 5
|
||||
|
||||
def test_smart_cover_frame_count(self):
|
||||
"""智能封面默认抽 3 帧."""
|
||||
assert SMART_COVER_FRAME_COUNT == 3
|
||||
|
||||
|
||||
class TestExtractFrameCommand:
|
||||
"""extract_frame 命令构建测试."""
|
||||
|
||||
def _probe_video_info_mock(self, duration=10.0):
|
||||
"""创建 probe_video_info 的 mock."""
|
||||
return {"duration": duration, "width": 1920, "height": 1080, "fps": 25.0}
|
||||
|
||||
def test_default_params_command(self, tmp_path):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
# 让 output_path 在 run_ffmpeg 后存在
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert result == Path(output_file)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构
|
||||
assert cmd[0].endswith("ffmpeg") or "ffmpeg" in cmd[0]
|
||||
assert "-y" in cmd
|
||||
assert "-vframes" in cmd
|
||||
assert cmd[cmd.index("-vframes") + 1] == "1"
|
||||
assert "-f" in cmd
|
||||
assert "mjpeg" in cmd[cmd.index("-f") + 1]
|
||||
|
||||
# 时间点
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(DEFAULT_COVER_TIME, abs=0.001)
|
||||
|
||||
# 输入文件
|
||||
i_idx = cmd.index("-i")
|
||||
assert cmd[i_idx + 1] == str(video_file)
|
||||
|
||||
# 输出文件
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop 滤镜
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
assert "force_original_aspect_ratio=increase" in vf_value
|
||||
|
||||
def test_custom_time(self, tmp_path):
|
||||
"""自定义抽帧时间点."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=30.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=5.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.5, abs=0.001)
|
||||
|
||||
def test_custom_dimensions(self, tmp_path):
|
||||
"""自定义输出尺寸."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), width=1920, height=1080)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=1920:1080:" in vf_value
|
||||
assert "crop=1920:1080" in vf_value
|
||||
|
||||
def test_custom_quality(self, tmp_path):
|
||||
"""自定义 JPEG 质量."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), quality=2)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
q_idx = cmd.index("-q:v")
|
||||
assert cmd[q_idx + 1] == "2"
|
||||
|
||||
def test_time_exceeds_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""抽帧时间超过视频时长时,钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=5.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# 钳制到 duration/2 = 2.5
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(2.5, abs=0.001)
|
||||
|
||||
def test_negative_time_clamps_to_zero(self, tmp_path):
|
||||
"""负时间钳制到 0."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=-2.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.0, abs=0.001)
|
||||
|
||||
def test_time_equals_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""时间点等于时长时钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_video(self, tmp_path):
|
||||
"""视频时长为 0 时的行为(不钳制,用原始时间)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=0.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=0.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.5, abs=0.001)
|
||||
|
||||
def test_video_not_found_raises(self, tmp_path):
|
||||
"""视频文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.extract_frame(str(tmp_path / "nonexistent.mp4"), str(output_file))
|
||||
|
||||
def test_output_creates_parent_dir(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
out_dir = tmp_path / "deep" / "nested"
|
||||
output_file = out_dir / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
|
||||
def test_ffmpeg_failure_propagates(self, tmp_path):
|
||||
"""FFmpeg 失败时异常向上传递."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch(
|
||||
"video_processing.cover_generator.run_ffmpeg",
|
||||
side_effect=RuntimeError("FFmpeg error"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg error"):
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
|
||||
class TestSmartCoverTimePoints:
|
||||
"""智能封面时间点计算测试."""
|
||||
|
||||
def test_single_frame_falls_back_to_default(self, tmp_path):
|
||||
"""只有 1 帧时退化为普通抽帧(取 DEFAULT_COVER_TIME 和 midpoint 中较小值)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 20.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
# frame_count=1 时退化为普通抽帧
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=1)
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# min(DEFAULT_COVER_TIME=1.0, duration/2=10.0) = 1.0
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(1.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_falls_back(self, tmp_path):
|
||||
"""视频时长为 0 时退化为普通抽帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 0.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_three_frames_uniform_distribution(self, tmp_path):
|
||||
"""3 帧均匀分布在 5%~95% 区间."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
# 记录抽帧时间
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
# 在输出路径写文件
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
# 不同文件大小,让第三帧"最清晰"
|
||||
idx = len(call_times) - 1
|
||||
size = 1000 * (idx + 1) # 递增的文件大小
|
||||
Path(output_arg).write_bytes(b"x" * size)
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 3 帧:5%、50%、95%
|
||||
assert len(call_times) == 3
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1) # 5%
|
||||
assert call_times[1] == pytest.approx(50.0, abs=0.1) # 50%
|
||||
assert call_times[2] == pytest.approx(95.0, abs=0.1) # 95%
|
||||
|
||||
def test_five_frames_distribution(self, tmp_path):
|
||||
"""5 帧均匀分布."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = len(call_times) - 1
|
||||
Path(output_arg).write_bytes(b"x" * (1000 * (idx + 1)))
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=5)
|
||||
|
||||
assert len(call_times) == 5
|
||||
# step = (95-5) / (5-1) = 22.5
|
||||
# times: 5, 27.5, 50, 72.5, 95
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1)
|
||||
assert call_times[1] == pytest.approx(27.5, abs=0.1)
|
||||
assert call_times[2] == pytest.approx(50.0, abs=0.1)
|
||||
assert call_times[3] == pytest.approx(72.5, abs=0.1)
|
||||
assert call_times[4] == pytest.approx(95.0, abs=0.1)
|
||||
|
||||
def test_selects_largest_file_as_best(self, tmp_path):
|
||||
"""选择文件最大的帧作为最佳封面(清晰度近似)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
sizes = [5000, 15000, 8000] # 第二帧最大
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
call_idx = [0]
|
||||
|
||||
def fake_run(cmd):
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = call_idx[0]
|
||||
Path(output_arg).write_bytes(b"x" * sizes[idx])
|
||||
call_idx[0] += 1
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 第二帧(索引1)应该是最佳
|
||||
assert result == output_file
|
||||
# 输出文件大小应等于第二帧大小
|
||||
assert output_file.stat().st_size == 15000
|
||||
|
||||
|
||||
class TestProcessCustomCover:
|
||||
"""自定义封面处理测试."""
|
||||
|
||||
def test_custom_cover_resize_command(self, tmp_path):
|
||||
"""自定义封面调整尺寸命令正确."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file))
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
assert "-i" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == str(input_file)
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
|
||||
def test_custom_cover_not_found_raises(self, tmp_path):
|
||||
"""自定义封面文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.process_custom_cover(str(tmp_path / "nonexistent.jpg"), str(output_file))
|
||||
|
||||
def test_custom_cover_custom_dimensions(self, tmp_path):
|
||||
"""自定义封面自定义输出尺寸."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file), width=800, height=600)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=800:600:" in vf_value
|
||||
assert "crop=800:600" in vf_value
|
||||
@@ -1,860 +0,0 @@
|
||||
"""封面生成 + 视频倒放 + 贴纸叠加 单元测试.
|
||||
|
||||
覆盖三个新渲染能力的核心场景和降级逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.cover_generator import (
|
||||
DEFAULT_COVER_HEIGHT,
|
||||
DEFAULT_COVER_WIDTH,
|
||||
CoverGenerator,
|
||||
generate_cover_from_plan,
|
||||
)
|
||||
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
|
||||
from video_processing.sticker_engine import (
|
||||
POSITION_PRESETS,
|
||||
STICKER_CATEGORIES,
|
||||
ImageStickerConfig,
|
||||
StickerEngine,
|
||||
TextStickerConfig,
|
||||
get_sticker_categories,
|
||||
parse_stickers_from_config,
|
||||
)
|
||||
from video_processing.unified_render_service import (
|
||||
ResolvedClip,
|
||||
UnifiedRenderService,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakePlan:
|
||||
"""模拟 EditPlan."""
|
||||
|
||||
id: str = "plan_001"
|
||||
name: str = "测试计划"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_video(tmp_path):
|
||||
"""创建一个测试视频文件(空文件,仅用于路径测试)."""
|
||||
video_path = tmp_path / "test_video.mp4"
|
||||
video_path.write_bytes(b"fake video data")
|
||||
return video_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image(tmp_path):
|
||||
"""创建一个测试图片文件."""
|
||||
img_path = tmp_path / "sticker.png"
|
||||
img_path.write_bytes(b"fake png data")
|
||||
return img_path
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 一、视频倒放引擎测试
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestReverseConfig:
|
||||
"""ReverseConfig 配置解析测试."""
|
||||
|
||||
def test_default_disabled(self):
|
||||
"""默认配置为关闭."""
|
||||
config = ReverseConfig.from_dict(None)
|
||||
assert config.enabled is False
|
||||
assert config.reverse_video is True
|
||||
assert config.reverse_audio is True
|
||||
|
||||
def test_empty_dict(self):
|
||||
"""空字典视为关闭."""
|
||||
config = ReverseConfig.from_dict({})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_enabled(self):
|
||||
"""启用倒放."""
|
||||
config = ReverseConfig.from_dict({"enabled": True})
|
||||
assert config.enabled is True
|
||||
assert config.reverse_video is True
|
||||
assert config.reverse_audio is True
|
||||
|
||||
def test_video_only(self):
|
||||
"""只倒放视频."""
|
||||
config = ReverseConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"reverse_video": True,
|
||||
"reverse_audio": False,
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.reverse_video is True
|
||||
assert config.reverse_audio is False
|
||||
|
||||
def test_audio_only(self):
|
||||
"""只倒放音频."""
|
||||
config = ReverseConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"reverse_video": False,
|
||||
"reverse_audio": True,
|
||||
}
|
||||
)
|
||||
assert config.reverse_video is False
|
||||
assert config.reverse_audio is True
|
||||
|
||||
def test_invalid_config_fallback(self):
|
||||
"""无效配置降级为默认."""
|
||||
config = ReverseConfig.from_dict("invalid") # type: ignore
|
||||
assert config.enabled is False
|
||||
|
||||
def test_none_config(self):
|
||||
"""None 配置."""
|
||||
config = ReverseConfig.from_dict(None)
|
||||
assert config.enabled is False
|
||||
|
||||
|
||||
class TestReverseEngine:
|
||||
"""ReverseEngine 滤镜生成测试."""
|
||||
|
||||
def test_video_reverse_filter(self):
|
||||
"""视频倒放滤镜生成."""
|
||||
config = ReverseConfig(enabled=True, reverse_video=True)
|
||||
f = ReverseEngine.build_video_filter(config, duration=10.0)
|
||||
assert f == "reverse"
|
||||
|
||||
def test_video_disabled(self):
|
||||
"""视频倒放关闭时返回空."""
|
||||
config = ReverseConfig(enabled=False)
|
||||
f = ReverseEngine.build_video_filter(config, duration=10.0)
|
||||
assert f == ""
|
||||
|
||||
def test_video_disabled_flag(self):
|
||||
"""启用但 reverse_video=False."""
|
||||
config = ReverseConfig(enabled=True, reverse_video=False)
|
||||
f = ReverseEngine.build_video_filter(config, duration=10.0)
|
||||
assert f == ""
|
||||
|
||||
def test_audio_reverse_filter(self):
|
||||
"""音频倒放滤镜生成."""
|
||||
config = ReverseConfig(enabled=True, reverse_audio=True)
|
||||
f = ReverseEngine.build_audio_filter(config, duration=10.0)
|
||||
assert f == "areverse"
|
||||
|
||||
def test_audio_disabled(self):
|
||||
"""音频倒放关闭."""
|
||||
config = ReverseConfig(enabled=False)
|
||||
f = ReverseEngine.build_audio_filter(config, duration=10.0)
|
||||
assert f == ""
|
||||
|
||||
def test_long_video_safety_limit(self):
|
||||
"""超长视频安全限制:跳过倒放."""
|
||||
config = ReverseConfig(enabled=True)
|
||||
f = ReverseEngine.build_video_filter(config, duration=200.0)
|
||||
assert f == "" # 超过 MAX_SAFE_DURATION
|
||||
|
||||
def test_long_audio_safety_limit(self):
|
||||
"""超长音频安全限制."""
|
||||
config = ReverseConfig(enabled=True)
|
||||
f = ReverseEngine.build_audio_filter(config, duration=200.0)
|
||||
assert f == ""
|
||||
|
||||
def test_duration_zero(self):
|
||||
"""时长为0时正常返回."""
|
||||
config = ReverseConfig(enabled=True)
|
||||
f = ReverseEngine.build_video_filter(config, duration=0.0)
|
||||
assert f == "reverse"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 二、贴纸引擎测试
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestStickerPosition:
|
||||
"""贴纸位置计算测试."""
|
||||
|
||||
def test_presets_exist(self):
|
||||
"""9宫格预设存在."""
|
||||
assert "top_left" in POSITION_PRESETS
|
||||
assert "center" in POSITION_PRESETS
|
||||
assert "bottom_right" in POSITION_PRESETS
|
||||
assert len(POSITION_PRESETS) == 9
|
||||
|
||||
def test_resolve_position_center(self):
|
||||
"""居中位置计算."""
|
||||
sticker = ImageStickerConfig(position="center")
|
||||
x, y = StickerEngine._resolve_position(sticker, 1000, 1000, 200, 200)
|
||||
assert abs(x - 400) < 1 # (1000-200)/2 = 400
|
||||
assert abs(y - 400) < 1
|
||||
|
||||
def test_resolve_position_top_left(self):
|
||||
"""左上角位置."""
|
||||
sticker = ImageStickerConfig(position="top_left")
|
||||
x, y = StickerEngine._resolve_position(sticker, 1000, 1000, 100, 100)
|
||||
assert x == 0 # 0.05*1000 - 50 = 0 (clamped)
|
||||
assert y == 0
|
||||
|
||||
def test_custom_position_percent(self):
|
||||
"""自定义百分比位置."""
|
||||
sticker = ImageStickerConfig(
|
||||
position="center",
|
||||
x=30.0,
|
||||
y=70.0,
|
||||
x_unit="percent",
|
||||
y_unit="percent",
|
||||
)
|
||||
x, y = StickerEngine._resolve_position(sticker, 1000, 1000, 100, 100)
|
||||
assert abs(x - 250) < 1 # 300 - 50 = 250
|
||||
assert abs(y - 650) < 1 # 700 - 50 = 650
|
||||
|
||||
def test_custom_position_pixel(self):
|
||||
"""自定义像素位置."""
|
||||
sticker = ImageStickerConfig(
|
||||
position="center",
|
||||
x=100.0,
|
||||
y=200.0,
|
||||
x_unit="pixel",
|
||||
y_unit="pixel",
|
||||
)
|
||||
x, y = StickerEngine._resolve_position(sticker, 1000, 1000, 50, 50)
|
||||
assert abs(x - 75) < 1 # 100 - 25 = 75
|
||||
assert abs(y - 175) < 1 # 200 - 25 = 175
|
||||
|
||||
def test_position_clamped(self):
|
||||
"""位置钳制在画布内."""
|
||||
sticker = ImageStickerConfig(
|
||||
position="center",
|
||||
x=-10.0,
|
||||
y=-10.0,
|
||||
x_unit="pixel",
|
||||
y_unit="pixel",
|
||||
)
|
||||
x, y = StickerEngine._resolve_position(sticker, 1000, 1000, 50, 50)
|
||||
assert x >= 0
|
||||
assert y >= 0
|
||||
|
||||
|
||||
class TestTextSticker:
|
||||
"""文字贴纸测试."""
|
||||
|
||||
def test_drawtext_filter_basic(self):
|
||||
"""基础文字贴纸滤镜生成."""
|
||||
sticker = TextStickerConfig(
|
||||
enabled=True,
|
||||
text="Hello World",
|
||||
font_size=36,
|
||||
font_color="#FFFFFF",
|
||||
position="center",
|
||||
)
|
||||
f = StickerEngine._build_drawtext_filter(sticker, "[in]", "[out]", 1080, 1920)
|
||||
assert "drawtext" in f
|
||||
assert "Hello World" in f
|
||||
assert "fontsize=36" in f
|
||||
assert "[in]" in f
|
||||
assert "[out]" in f
|
||||
|
||||
def test_drawtext_with_stroke(self):
|
||||
"""带描边的文字贴纸."""
|
||||
sticker = TextStickerConfig(
|
||||
enabled=True,
|
||||
text="Test",
|
||||
stroke_width=3,
|
||||
stroke_color="#FF0000",
|
||||
)
|
||||
f = StickerEngine._build_drawtext_filter(sticker, "[in]", "[out]", 1080, 1920)
|
||||
assert "borderw=3" in f
|
||||
assert "bordercolor=#FF0000" in f
|
||||
|
||||
def test_drawtext_with_shadow(self):
|
||||
"""带阴影的文字贴纸."""
|
||||
sticker = TextStickerConfig(
|
||||
enabled=True,
|
||||
text="Shadow",
|
||||
shadow_x=4,
|
||||
shadow_y=4,
|
||||
shadow_alpha=0.5,
|
||||
)
|
||||
f = StickerEngine._build_drawtext_filter(sticker, "[in]", "[out]", 1080, 1920)
|
||||
assert "shadowx=4" in f
|
||||
assert "shadowy=4" in f
|
||||
|
||||
def test_drawtext_time_range(self):
|
||||
"""带时间范围的文字贴纸."""
|
||||
sticker = TextStickerConfig(
|
||||
enabled=True,
|
||||
text="Timed",
|
||||
start_time=2.0,
|
||||
duration=3.0,
|
||||
)
|
||||
f = StickerEngine._build_drawtext_filter(sticker, "[in]", "[out]", 1080, 1920)
|
||||
assert "enable='between(t,2.0,5.0)'" in f
|
||||
|
||||
def test_drawtext_empty_text(self):
|
||||
"""空文字直通."""
|
||||
sticker = TextStickerConfig(enabled=True, text="")
|
||||
f = StickerEngine._build_drawtext_filter(sticker, "[in]", "[out]", 1080, 1920)
|
||||
assert "[in]copy[out]" in f
|
||||
|
||||
def test_drawtext_with_fade(self):
|
||||
"""带淡入淡出的文字贴纸."""
|
||||
sticker = TextStickerConfig(
|
||||
enabled=True,
|
||||
text="Fade",
|
||||
start_time=1.0,
|
||||
duration=5.0,
|
||||
fade_in=0.5,
|
||||
fade_out=0.5,
|
||||
)
|
||||
f = StickerEngine._build_drawtext_filter(sticker, "[in]", "[out]", 1080, 1920)
|
||||
assert "alpha=" in f
|
||||
|
||||
|
||||
class TestImageSticker:
|
||||
"""图片贴纸测试."""
|
||||
|
||||
def test_image_sticker_overlay(self, sample_image):
|
||||
"""图片贴纸 overlay 滤镜生成."""
|
||||
result = StickerEngine.build_sticker_chain(
|
||||
stickers=[
|
||||
{
|
||||
"type": "image",
|
||||
"image_path": str(sample_image),
|
||||
"position": "top_right",
|
||||
"scale": 0.5,
|
||||
"opacity": 0.8,
|
||||
"z_index": 10,
|
||||
}
|
||||
],
|
||||
input_label="[base]",
|
||||
output_label="[final]",
|
||||
canvas_w=1080,
|
||||
canvas_h=1920,
|
||||
)
|
||||
assert result.filter_str != ""
|
||||
assert "overlay" in result.filter_str
|
||||
assert len(result.extra_inputs) == 1
|
||||
assert result.extra_inputs[0] == str(sample_image)
|
||||
|
||||
def test_image_sticker_missing_file(self):
|
||||
"""图片贴纸素材不存在时跳过."""
|
||||
result = StickerEngine.build_sticker_chain(
|
||||
stickers=[
|
||||
{
|
||||
"type": "image",
|
||||
"image_path": "/nonexistent/image.png",
|
||||
"position": "center",
|
||||
}
|
||||
],
|
||||
input_label="[in]",
|
||||
output_label="[out]",
|
||||
canvas_w=1080,
|
||||
canvas_h=1920,
|
||||
)
|
||||
# 素材不存在,跳过,返回直通
|
||||
assert "[in]copy[out]" in result.filter_str
|
||||
assert len(result.extra_inputs) == 0
|
||||
|
||||
def test_mixed_stickers(self, sample_image):
|
||||
"""混合贴纸:图片 + 文字."""
|
||||
result = StickerEngine.build_sticker_chain(
|
||||
stickers=[
|
||||
{
|
||||
"type": "image",
|
||||
"image_path": str(sample_image),
|
||||
"position": "top_left",
|
||||
"z_index": 5,
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello",
|
||||
"position": "bottom_center",
|
||||
"z_index": 10,
|
||||
},
|
||||
],
|
||||
input_label="[in]",
|
||||
output_label="[out]",
|
||||
canvas_w=1080,
|
||||
canvas_h=1920,
|
||||
)
|
||||
assert "overlay" in result.filter_str
|
||||
assert "drawtext" in result.filter_str
|
||||
assert len(result.extra_inputs) == 1
|
||||
|
||||
def test_sticker_z_index_order(self, sample_image):
|
||||
"""贴纸按 z_index 排序."""
|
||||
result = StickerEngine.build_sticker_chain(
|
||||
stickers=[
|
||||
{"type": "text", "text": "Top", "z_index": 20, "position": "center"},
|
||||
{"type": "text", "text": "Bottom", "z_index": 5, "position": "center"},
|
||||
],
|
||||
input_label="[in]",
|
||||
output_label="[out]",
|
||||
canvas_w=1080,
|
||||
canvas_h=1920,
|
||||
)
|
||||
# z_index 小的先叠加,大的后叠加(在上面)
|
||||
assert result.filter_str.count("drawtext") == 2
|
||||
|
||||
def test_empty_stickers(self):
|
||||
"""空贴纸列表."""
|
||||
result = StickerEngine.build_sticker_chain(
|
||||
stickers=[],
|
||||
input_label="[in]",
|
||||
output_label="[out]",
|
||||
canvas_w=1080,
|
||||
canvas_h=1920,
|
||||
)
|
||||
assert "[in]copy[out]" in result.filter_str
|
||||
assert result.extra_inputs == []
|
||||
|
||||
def test_invalid_sticker_skipped(self):
|
||||
"""无效贴纸配置跳过."""
|
||||
result = StickerEngine.build_sticker_chain(
|
||||
stickers=[{"invalid": "data"}],
|
||||
input_label="[in]",
|
||||
output_label="[out]",
|
||||
canvas_w=1080,
|
||||
canvas_h=1920,
|
||||
)
|
||||
# 解析失败,跳过,直通
|
||||
assert "[in]copy[out]" in result.filter_str
|
||||
|
||||
|
||||
class TestStickerHelpers:
|
||||
"""贴纸辅助函数测试."""
|
||||
|
||||
def test_parse_stickers_empty(self):
|
||||
"""空配置解析."""
|
||||
assert parse_stickers_from_config(None) == []
|
||||
assert parse_stickers_from_config({}) == []
|
||||
|
||||
def test_parse_stickers_list(self):
|
||||
"""正常贴纸列表解析."""
|
||||
config = {"stickers": [{"type": "text", "text": "A"}, {"type": "text", "text": "B"}]}
|
||||
result = parse_stickers_from_config(config)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_parse_stickers_not_list(self):
|
||||
"""非列表类型返回空."""
|
||||
config = {"stickers": "not a list"}
|
||||
assert parse_stickers_from_config(config) == []
|
||||
|
||||
def test_get_categories(self):
|
||||
"""贴纸分类列表."""
|
||||
cats = get_sticker_categories()
|
||||
assert len(cats) == len(STICKER_CATEGORIES)
|
||||
assert cats[0][0] == "emoji"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 三、封面生成器测试
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCoverGenerator:
|
||||
"""CoverGenerator 测试."""
|
||||
|
||||
def test_default_dimensions(self):
|
||||
"""默认封面尺寸."""
|
||||
assert DEFAULT_COVER_WIDTH == 1080
|
||||
assert DEFAULT_COVER_HEIGHT == 1920
|
||||
|
||||
@patch("video_processing.cover_generator.run_ffmpeg")
|
||||
@patch("video_processing.cover_generator.probe_video_info")
|
||||
def test_extract_frame_basic(self, mock_probe, mock_run, sample_video, tmp_path):
|
||||
"""基础抽帧测试."""
|
||||
mock_probe.return_value = {"duration": 30.0}
|
||||
|
||||
# mock run_ffmpeg 实际创建输出文件
|
||||
def fake_run_ffmpeg(cmd):
|
||||
# 找到输出路径并创建文件
|
||||
output_path = Path(cmd[-1])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_run.side_effect = fake_run_ffmpeg
|
||||
|
||||
output = tmp_path / "cover.jpg"
|
||||
result = CoverGenerator.extract_frame(
|
||||
sample_video,
|
||||
output,
|
||||
time_sec=2.0,
|
||||
)
|
||||
|
||||
assert result == output
|
||||
mock_run.assert_called_once()
|
||||
# 检查命令参数
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-ss" in cmd
|
||||
assert "2.000" in cmd
|
||||
assert "-vframes" in cmd
|
||||
assert "1" in cmd
|
||||
|
||||
@patch("video_processing.cover_generator.run_ffmpeg")
|
||||
@patch("video_processing.cover_generator.probe_video_info")
|
||||
def test_extract_frame_time_clamped(self, mock_probe, mock_run, sample_video, tmp_path):
|
||||
"""抽帧时间超过视频长度时钳制."""
|
||||
mock_probe.return_value = {"duration": 10.0}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
output_path = Path(cmd[-1])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_run.side_effect = fake_run_ffmpeg
|
||||
|
||||
output = tmp_path / "cover.jpg"
|
||||
CoverGenerator.extract_frame(
|
||||
sample_video,
|
||||
output,
|
||||
time_sec=100.0, # 超过视频时长
|
||||
)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
time_val = float(cmd[ss_idx + 1])
|
||||
# 应该被钳制到中间帧(5秒左右)
|
||||
assert time_val <= 10.0
|
||||
|
||||
@patch("video_processing.cover_generator.run_ffmpeg")
|
||||
@patch("video_processing.cover_generator.probe_video_info")
|
||||
def test_extract_frame_negative_time(self, mock_probe, mock_run, sample_video, tmp_path):
|
||||
"""负时间钳制到0."""
|
||||
mock_probe.return_value = {"duration": 30.0}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
output_path = Path(cmd[-1])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_run.side_effect = fake_run_ffmpeg
|
||||
|
||||
output = tmp_path / "cover.jpg"
|
||||
CoverGenerator.extract_frame(
|
||||
sample_video,
|
||||
output,
|
||||
time_sec=-5.0,
|
||||
)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
time_val = float(cmd[ss_idx + 1])
|
||||
assert time_val >= 0
|
||||
|
||||
def test_extract_frame_file_not_found(self, tmp_path):
|
||||
"""视频文件不存在抛异常."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.extract_frame(
|
||||
"/nonexistent/video.mp4",
|
||||
tmp_path / "cover.jpg",
|
||||
)
|
||||
|
||||
@patch("video_processing.cover_generator.CoverGenerator.extract_frame")
|
||||
@patch("video_processing.cover_generator.probe_video_info")
|
||||
def test_smart_cover_3_frames(self, mock_probe, mock_extract, sample_video, tmp_path):
|
||||
"""智能封面抽取3帧选最佳."""
|
||||
mock_probe.return_value = {"duration": 30.0}
|
||||
|
||||
# 创建三个大小不同的临时文件(模拟清晰度不同)
|
||||
def create_frame(video_path, output_path, **kwargs):
|
||||
# 第二帧最大(最清晰)
|
||||
p = Path(output_path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if "candidate_1" in str(p):
|
||||
p.write_bytes(b"x" * 10000) # 最大 = 最清晰
|
||||
elif "candidate_0" in str(p):
|
||||
p.write_bytes(b"x" * 1000)
|
||||
else:
|
||||
p.write_bytes(b"x" * 5000)
|
||||
return p
|
||||
|
||||
mock_extract.side_effect = create_frame
|
||||
|
||||
output = tmp_path / "smart_cover.jpg"
|
||||
result = CoverGenerator.extract_smart_cover(
|
||||
sample_video,
|
||||
output,
|
||||
frame_count=3,
|
||||
)
|
||||
|
||||
assert result == output
|
||||
assert output.exists()
|
||||
# 应该选最大的那个文件(candidate_1)
|
||||
assert output.stat().st_size == 10000
|
||||
|
||||
@patch("video_processing.cover_generator.CoverGenerator.extract_frame")
|
||||
@patch("video_processing.cover_generator.probe_video_info")
|
||||
def test_smart_cover_fallback(self, mock_probe, mock_extract, sample_video, tmp_path):
|
||||
"""智能封面全部失败时降级."""
|
||||
mock_probe.return_value = {"duration": 0.0} # 时长为0
|
||||
|
||||
output = tmp_path / "cover.jpg"
|
||||
output.write_bytes(b"x" * 100)
|
||||
mock_extract.return_value = output
|
||||
|
||||
result = CoverGenerator.extract_smart_cover(sample_video, output, frame_count=3)
|
||||
assert result == output
|
||||
|
||||
@patch("video_processing.cover_generator.run_ffmpeg")
|
||||
def test_custom_cover(self, mock_run, sample_image, tmp_path):
|
||||
"""自定义封面处理."""
|
||||
output = tmp_path / "custom_cover.jpg"
|
||||
|
||||
result = CoverGenerator.process_custom_cover(
|
||||
sample_image,
|
||||
output,
|
||||
)
|
||||
|
||||
assert result == output
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert str(sample_image) in cmd
|
||||
|
||||
def test_custom_cover_not_found(self, tmp_path):
|
||||
"""自定义封面文件不存在."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.process_custom_cover(
|
||||
"/nonexistent/img.png",
|
||||
tmp_path / "cover.jpg",
|
||||
)
|
||||
|
||||
@patch("video_processing.cover_generator.CoverGenerator.extract_frame")
|
||||
def test_generate_cover_time_mode(self, mock_extract, sample_video, tmp_path):
|
||||
"""统一入口 - time 模式."""
|
||||
output = tmp_path / "cover.jpg"
|
||||
mock_extract.return_value = output
|
||||
|
||||
result = CoverGenerator.generate_cover(
|
||||
sample_video,
|
||||
output,
|
||||
mode="time",
|
||||
time_sec=3.0,
|
||||
)
|
||||
|
||||
assert result == output
|
||||
mock_extract.assert_called_once()
|
||||
|
||||
@patch("video_processing.cover_generator.CoverGenerator.extract_smart_cover")
|
||||
def test_generate_cover_smart_mode(self, mock_smart, sample_video, tmp_path):
|
||||
"""统一入口 - smart 模式."""
|
||||
output = tmp_path / "cover.jpg"
|
||||
mock_smart.return_value = output
|
||||
|
||||
result = CoverGenerator.generate_cover(
|
||||
sample_video,
|
||||
output,
|
||||
mode="smart",
|
||||
)
|
||||
|
||||
assert result == output
|
||||
mock_smart.assert_called_once()
|
||||
|
||||
@patch("video_processing.cover_generator.CoverGenerator.process_custom_cover")
|
||||
def test_generate_cover_custom_mode(self, mock_custom, sample_video, sample_image, tmp_path):
|
||||
"""统一入口 - custom 模式."""
|
||||
output = tmp_path / "cover.jpg"
|
||||
mock_custom.return_value = output
|
||||
|
||||
result = CoverGenerator.generate_cover(
|
||||
sample_video,
|
||||
output,
|
||||
mode="custom",
|
||||
custom_image=sample_image,
|
||||
)
|
||||
|
||||
assert result == output
|
||||
mock_custom.assert_called_once()
|
||||
|
||||
|
||||
class TestGenerateCoverFromPlan:
|
||||
"""从 plan 配置生成封面测试."""
|
||||
|
||||
@patch("video_processing.cover_generator.CoverGenerator.extract_smart_cover")
|
||||
def test_smart_mode_from_plan(self, mock_smart, sample_video, tmp_path):
|
||||
"""plan 配置 smart 模式."""
|
||||
plan = FakePlan(id="plan_001", config={"cover_config": {"mode": "smart"}})
|
||||
mock_smart.return_value = tmp_path / "cover.jpg"
|
||||
(tmp_path / "cover.jpg").write_bytes(b"test")
|
||||
|
||||
result = generate_cover_from_plan(plan, sample_video, tmp_path)
|
||||
assert result is not None
|
||||
|
||||
def test_no_cover_config(self, sample_video, tmp_path):
|
||||
"""没有封面配置时返回 None."""
|
||||
plan = FakePlan(id="plan_001", config={})
|
||||
result = generate_cover_from_plan(plan, sample_video, tmp_path)
|
||||
assert result is None
|
||||
|
||||
def test_none_config(self, sample_video, tmp_path):
|
||||
"""config 为 None."""
|
||||
plan = FakePlan(id="plan_001", config=None) # type: ignore
|
||||
result = generate_cover_from_plan(plan, sample_video, tmp_path)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 四、UnifiedRenderService 集成测试
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _make_clip(clip_id="c1", asset_id="a1", path=Path("/fake/video.mp4"), clip_type="main", config=None):
|
||||
"""创建测试用 ResolvedClip."""
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=asset_id,
|
||||
local_path=path,
|
||||
clip_type=clip_type,
|
||||
order=0,
|
||||
start_time=0.0,
|
||||
duration=0.0,
|
||||
transition_effect="cut",
|
||||
config=config or {},
|
||||
actual_duration=10.0,
|
||||
)
|
||||
|
||||
|
||||
def _make_service(plan, clips, asset_path_map=None, work_dir=None, tmp_path=None):
|
||||
"""创建测试用 UnifiedRenderService."""
|
||||
from pathlib import Path as P
|
||||
|
||||
work_dir = work_dir or (tmp_path or P("/tmp")) / "render_test"
|
||||
work_dir.mkdir(exist_ok=True, parents=True)
|
||||
return UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_path_map or {},
|
||||
work_dir=work_dir,
|
||||
output_width=1080,
|
||||
output_height=1920,
|
||||
output_fps=30,
|
||||
transition_duration=0.5,
|
||||
)
|
||||
|
||||
|
||||
class TestReverseIntegration:
|
||||
"""倒放功能集成测试."""
|
||||
|
||||
@patch("video_processing.unified_render_service.probe_video_info")
|
||||
@patch("video_processing.unified_render_service.run_ffmpeg")
|
||||
def test_reverse_in_filter_complex(self, mock_run, mock_probe, tmp_path):
|
||||
"""filter_complex 路径中包含倒放滤镜."""
|
||||
mock_probe.return_value = {"duration": 10.0, "has_audio": True, "width": 1920, "height": 1080}
|
||||
mock_run.return_value = None
|
||||
|
||||
plan = FakePlan(id="p1")
|
||||
clip = _make_clip(config={"reverse": {"enabled": True}})
|
||||
clip.actual_duration = 5.0
|
||||
# 两个 clip 触发 filter_complex 路径
|
||||
clip2 = _make_clip(clip_id="c2", config={})
|
||||
clip2.actual_duration = 5.0
|
||||
clip2.order = 1
|
||||
|
||||
service = _make_service(plan, [clip, clip2], tmp_path=tmp_path)
|
||||
|
||||
# 直接测 _build_filter_complex
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
layer = RenderLayer(role="main", clips=[clip, clip2])
|
||||
filter_str, inputs = service._build_filter_complex([layer])
|
||||
|
||||
assert "reverse" in filter_str
|
||||
|
||||
def test_can_use_pass_through_with_reverse(self, tmp_path):
|
||||
"""倒放不影响直通模式判断(只有贴纸才禁用)."""
|
||||
plan = FakePlan(id="p1")
|
||||
clip = _make_clip(config={"reverse": {"enabled": True}})
|
||||
clip.actual_duration = 5.0
|
||||
|
||||
service = _make_service(plan, [clip], tmp_path=tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
layer = RenderLayer(role="main", clips=[clip])
|
||||
layers = [layer]
|
||||
|
||||
assert service._can_use_pass_through(layers) is True
|
||||
|
||||
|
||||
class TestStickerIntegration:
|
||||
"""贴纸功能集成测试."""
|
||||
|
||||
def test_can_use_pass_through_with_stickers(self, tmp_path):
|
||||
"""有贴纸时禁用直通模式."""
|
||||
plan = FakePlan(id="p1", config={"stickers": [{"type": "text", "text": "Hello", "position": "center"}]})
|
||||
clip = _make_clip()
|
||||
clip.actual_duration = 5.0
|
||||
|
||||
service = _make_service(plan, [clip], tmp_path=tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
layer = RenderLayer(role="main", clips=[clip])
|
||||
layers = [layer]
|
||||
|
||||
assert service._can_use_pass_through(layers) is False
|
||||
|
||||
def test_can_use_pass_through_no_stickers(self, tmp_path):
|
||||
"""无贴纸时直通模式正常."""
|
||||
plan = FakePlan(id="p1", config={})
|
||||
clip = _make_clip()
|
||||
clip.actual_duration = 5.0
|
||||
|
||||
service = _make_service(plan, [clip], tmp_path=tmp_path)
|
||||
from video_processing.unified_render_service import RenderLayer
|
||||
|
||||
layer = RenderLayer(role="main", clips=[clip])
|
||||
layers = [layer]
|
||||
|
||||
assert service._can_use_pass_through(layers) is True
|
||||
|
||||
def test_build_sticker_filters_text(self, tmp_path):
|
||||
"""文字贴纸滤镜构建."""
|
||||
plan = FakePlan(
|
||||
id="p1", config={"stickers": [{"type": "text", "text": "Hello", "position": "top_center", "z_index": 10}]}
|
||||
)
|
||||
service = _make_service(plan, [], tmp_path=tmp_path)
|
||||
|
||||
filter_str, extra_inputs = service._build_sticker_filters("in", "out")
|
||||
|
||||
assert "drawtext" in filter_str
|
||||
assert len(extra_inputs) == 0
|
||||
|
||||
def test_build_sticker_filters_empty(self, tmp_path):
|
||||
"""无贴纸返回空."""
|
||||
plan = FakePlan(id="p1", config={})
|
||||
service = _make_service(plan, [], tmp_path=tmp_path)
|
||||
|
||||
filter_str, extra_inputs = service._build_sticker_filters("in", "out")
|
||||
|
||||
assert filter_str == ""
|
||||
assert extra_inputs == []
|
||||
|
||||
def test_build_sticker_filters_image(self, sample_image, tmp_path):
|
||||
"""图片贴纸滤镜构建 + 额外输入."""
|
||||
plan = FakePlan(
|
||||
id="p1",
|
||||
config={
|
||||
"stickers": [
|
||||
{
|
||||
"type": "image",
|
||||
"image_path": str(sample_image),
|
||||
"position": "bottom_right",
|
||||
"z_index": 5,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
service = _make_service(plan, [], tmp_path=tmp_path)
|
||||
|
||||
filter_str, extra_inputs = service._build_sticker_filters("in", "out")
|
||||
|
||||
assert "overlay" in filter_str
|
||||
assert len(extra_inputs) == 1
|
||||
@@ -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"
|
||||
@@ -310,9 +310,9 @@ class TestThumbnailInDedupHelpers:
|
||||
mock_dedup.VideoDeduplicator = MagicMock()
|
||||
sys.modules["video_processing.dedup"] = mock_dedup
|
||||
|
||||
# mock video_processing.thumbnail_generator
|
||||
# mock video_processing.thumbnail_generator (统一封面管道: 仅保留 extract_first_frame)
|
||||
mock_thumb = MagicMock()
|
||||
mock_thumb.generate_and_upload_thumbnail = MagicMock()
|
||||
mock_thumb.extract_first_frame = MagicMock()
|
||||
sys.modules["video_processing.thumbnail_generator"] = mock_thumb
|
||||
|
||||
# 关键:给 video_processing 包设置子模块属性,让 patch() 能通过属性访问找到
|
||||
@@ -322,7 +322,7 @@ class TestThumbnailInDedupHelpers:
|
||||
video_processing.thumbnail_generator = mock_thumb
|
||||
|
||||
def test_pre_generated_thumbnail_url_is_reused(self):
|
||||
"""传入 thumbnail_url 时直接复用,不调用 generate_and_upload_thumbnail。"""
|
||||
"""传入 thumbnail_url 时直接复用,统一封面管道不再自动生成缩略图。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
@@ -339,26 +339,23 @@ class TestThumbnailInDedupHelpers:
|
||||
mock_dedup.check_duplicate.return_value = None
|
||||
mock_dedup.check_batch_duplicate.return_value = None
|
||||
|
||||
with patch("video_processing.thumbnail_generator.generate_and_upload_thumbnail") as mock_gen:
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-reuse",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
thumbnail_url=pre_thumb_url,
|
||||
)
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-reuse",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
thumbnail_url=pre_thumb_url,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# 预生成缩略图时不应调用 generate_and_upload_thumbnail
|
||||
mock_gen.assert_not_called()
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
@@ -368,8 +365,8 @@ class TestThumbnailInDedupHelpers:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_thumbnail_generated_when_not_provided(self):
|
||||
"""未传 thumbnail_url 时调用 generate_and_upload_thumbnail 生成。"""
|
||||
def test_no_thumbnail_when_not_provided(self):
|
||||
"""未传 thumbnail_url 时不生成缩略图(统一封面管道已移除自动缩略图生成)。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
@@ -377,8 +374,6 @@ class TestThumbnailInDedupHelpers:
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
generated_thumb_url = "https://oss.example.com/generated-thumb.jpg"
|
||||
|
||||
try:
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
|
||||
mock_dedup = mock_dedup_cls.return_value
|
||||
@@ -386,43 +381,34 @@ class TestThumbnailInDedupHelpers:
|
||||
mock_dedup.check_duplicate.return_value = None
|
||||
mock_dedup.check_batch_duplicate.return_value = None
|
||||
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
return_value=generated_thumb_url,
|
||||
) as mock_gen:
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-gen",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-gen",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# 应调用一次缩略图生成
|
||||
mock_gen.assert_called_once()
|
||||
# 验证参数:video_path 和 storage_key
|
||||
call_args = mock_gen.call_args
|
||||
assert call_args[0][0] == "/tmp/fake.mp4"
|
||||
assert "thumbnails" in call_args[0][1]
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-gen").first()
|
||||
assert video is not None
|
||||
assert video.thumbnail_url == generated_thumb_url
|
||||
# 统一封面管道下,不传 thumbnail_url 时不自动生成
|
||||
assert not video.thumbnail_url
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_thumbnail_generation_failure_does_not_block(self):
|
||||
"""缩略图生成失败不影响主流程。"""
|
||||
def test_no_thumbnail_does_not_block(self):
|
||||
"""统一封面管道下,缩略图不再在 dedup 阶段生成。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
@@ -437,24 +423,20 @@ class TestThumbnailInDedupHelpers:
|
||||
mock_dedup.check_duplicate.return_value = None
|
||||
mock_dedup.check_batch_duplicate.return_value = None
|
||||
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
side_effect=RuntimeError("cv2 not found"),
|
||||
):
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-fail",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id="task-thumb-fail",
|
||||
project_id="proj-1",
|
||||
batch_id="",
|
||||
file_url="https://oss.example.com/video.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
session=session,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
assert result == 1 # 不阻断
|
||||
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
"""Tests for generation cover route — schema validation and import checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
def test_generation_cover_router_importable():
|
||||
"""新路由模块可以正确导入"""
|
||||
from app.api.routes.generation_cover import router
|
||||
|
||||
assert router is not None
|
||||
# tags 应该是 Generation
|
||||
assert "Generation" in router.tags
|
||||
|
||||
|
||||
def test_generation_cover_route_path():
|
||||
"""路由路径应为 /generate-cover"""
|
||||
from app.api.routes.generation_cover import router
|
||||
|
||||
paths = [route.path for route in router.routes]
|
||||
assert "/generate-cover" in paths
|
||||
|
||||
|
||||
def test_generation_cover_schemas_importable():
|
||||
"""Schema 可以从新模块导入"""
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse
|
||||
|
||||
# 验证请求 schema 默认值
|
||||
req = GenerateCoverRequest()
|
||||
assert req.asset_ids == []
|
||||
assert req.cover_type == "ai_frame"
|
||||
assert req.frame_time is None
|
||||
|
||||
# 验证响应 schema
|
||||
resp = GenerateCoverResponse(plan_id="p1", cover={"image_url": "http://x"})
|
||||
assert resp.plan_id == "p1"
|
||||
assert resp.cover["image_url"] == "http://x"
|
||||
|
||||
|
||||
def test_generation_cover_schemas_not_in_templates_editor():
|
||||
"""旧的 templates_editor/schemas.py 不再包含封面 schema"""
|
||||
from app.api.routes.templates_editor import schemas as te_schemas
|
||||
|
||||
assert not hasattr(te_schemas, "GenerateCoverRequest")
|
||||
assert not hasattr(te_schemas, "GenerateCoverResponse")
|
||||
|
||||
|
||||
def test_templates_editor_no_cover_router():
|
||||
"""templates_editor 不再包含 cover_router"""
|
||||
from app.api.routes.templates_editor import _sub_routers
|
||||
|
||||
# cover_router 应该已被移除
|
||||
for sub in _sub_routers:
|
||||
for route in sub.routes:
|
||||
assert "generate-cover" not in getattr(route, "path", ""), "templates_editor 不应再有 generate-cover 路由"
|
||||
|
||||
|
||||
def test_api_router_has_generation_cover():
|
||||
"""api_router 应该包含 /api/v1/generation/generate-cover 路径"""
|
||||
from app.api.router import api_router
|
||||
|
||||
all_paths = []
|
||||
for route in api_router.routes:
|
||||
if hasattr(route, "path"):
|
||||
all_paths.append(route.path)
|
||||
# 嵌套 router
|
||||
if hasattr(route, "routes"):
|
||||
for sub_route in route.routes:
|
||||
if hasattr(sub_route, "path"):
|
||||
all_paths.append(sub_route.path)
|
||||
|
||||
# 应该能找到 generate-cover 路径
|
||||
cover_paths = [p for p in all_paths if "generate-cover" in p]
|
||||
assert len(cover_paths) > 0, f"未找到 generate-cover 路由, 所有路径: {all_paths[:20]}"
|
||||
|
||||
|
||||
def test_generation_cover_request_validation():
|
||||
"""验证请求 schema 的字段约束"""
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
# frame_time 不允许负数
|
||||
with pytest.raises(ValidationError):
|
||||
GenerateCoverRequest(frame_time=-1.0)
|
||||
|
||||
# 合法的 frame_time
|
||||
req = GenerateCoverRequest(frame_time=5.5)
|
||||
assert req.frame_time == 5.5
|
||||
|
||||
# 自定义 cover_type
|
||||
req2 = GenerateCoverRequest(cover_type="upload", asset_ids=["a1", "a2"])
|
||||
assert req2.cover_type == "upload"
|
||||
assert req2.asset_ids == ["a1", "a2"]
|
||||
|
||||
|
||||
class TestUnifiedCoverPipelineEndpoint:
|
||||
"""测试统一封面管道在 generate_cover endpoint 中的逻辑 (lines 189-215)."""
|
||||
|
||||
def test_cover_url_from_generation_task(self):
|
||||
"""当 GenerationTask 有 cover_url 时,直接返回该 URL 作为封面。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse
|
||||
|
||||
# Mock plan with rendered_storage_key (so we skip the 3-step lookup)
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {
|
||||
"generation_task_id": "task-123",
|
||||
"rendered_storage_key": "rendered/plan-1/video.mp4",
|
||||
}
|
||||
|
||||
# Mock plan_svc
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
# Mock template_svc
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
# Mock generation task with cover_url
|
||||
mock_task = MagicMock()
|
||||
mock_task.cover_url = "https://oss.example.com/rendered/plan-1/cover.jpg"
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
# normalize_plan_config should return the config with cover
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/plan-1/cover.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-1",
|
||||
plan_id="plan-1",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
# 验证返回的封面数据来自 GenerationTask.cover_url
|
||||
assert result.plan_id == "plan-1"
|
||||
assert result.cover["image_url"] == "https://oss.example.com/rendered/plan-1/cover.jpg"
|
||||
assert result.cover["type"] == "ai_frame"
|
||||
|
||||
def test_cover_url_all_fallbacks_fail_returns_400(self):
|
||||
"""当所有步骤都找不到 cover_url 时,返回 400 而非 500。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {
|
||||
"generation_task_id": "task-456",
|
||||
"rendered_storage_key": "rendered/plan-2/video.mp4",
|
||||
}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
# Task has no cover_url
|
||||
mock_task = MagicMock()
|
||||
mock_task.cover_url = ""
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
# No tasks found by source_edit_plan_id or user+template
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/plan-2/video.mp4"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="template-2",
|
||||
plan_id="plan-2",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "封面尚未生成" in exc_info.value.detail
|
||||
|
||||
def test_cover_url_found_via_source_edit_plan(self):
|
||||
"""步骤B:通过 source_edit_plan_id 找到预览任务的 cover_url。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
# No generation_task_id, so step A is skipped
|
||||
mock_plan.config = {
|
||||
"rendered_storage_key": "rendered/plan-x/video.mp4",
|
||||
}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
# Preview task found by source_edit_plan_id with cover_url
|
||||
mock_preview_task = MagicMock()
|
||||
mock_preview_task.id = "preview-task-abc"
|
||||
mock_preview_task.status = "completed"
|
||||
mock_preview_task.cover_url = "https://oss.example.com/rendered/preview/cover.jpg"
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/preview/cover.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-x",
|
||||
plan_id="plan-x",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
assert result.cover["image_url"] == "https://oss.example.com/rendered/preview/cover.jpg"
|
||||
mock_repo.list_by_source_edit_plan.assert_called_once_with("plan-x")
|
||||
|
||||
def test_cover_url_found_via_user_template(self):
|
||||
"""步骤C:通过 user+template 找到预览任务的 cover_url。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {
|
||||
"rendered_storage_key": "rendered/plan-y/video.mp4",
|
||||
}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
# Step B finds nothing, step C finds a task
|
||||
mock_preview_task = MagicMock()
|
||||
mock_preview_task.id = "preview-task-def"
|
||||
mock_preview_task.status = "completed"
|
||||
mock_preview_task.cover_url = "https://oss.example.com/rendered/user-template-cover.jpg"
|
||||
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-123"
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = [mock_preview_task]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/user-template-cover.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-y",
|
||||
plan_id="plan-y",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
|
||||
assert result.cover["image_url"] == "https://oss.example.com/rendered/user-template-cover.jpg"
|
||||
mock_repo.list_latest_completed_preview.assert_called_once_with(
|
||||
user_id="user-123",
|
||||
template_id="template-y",
|
||||
)
|
||||
|
||||
|
||||
class TestSourceEditPlanFallback:
|
||||
"""测试步骤 2.5:通过 source_edit_plan_id 查找预览视频兜底逻辑。"""
|
||||
|
||||
def test_step25_finds_video_by_source_edit_plan_id(self):
|
||||
"""当步骤1和步骤2都找不到时,步骤2.5通过source_edit_plan_id找到预览视频和封面。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
# plan.config 没有 rendered_storage_key 和 generation_task_id
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
# Mock preview task found by source_edit_plan_id — with cover_url
|
||||
mock_preview_task = MagicMock()
|
||||
mock_preview_task.id = "preview-task-789"
|
||||
mock_preview_task.status = "completed"
|
||||
mock_preview_task.is_preview = True
|
||||
mock_preview_task.cover_url = "https://oss.example.com/rendered/cover.jpg"
|
||||
|
||||
# Mock generated video
|
||||
mock_video = MagicMock()
|
||||
mock_video.file_url = "rendered/plan-x/video.mp4"
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
|
||||
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
# Both video lookup (step 2.5) and cover_url lookup (step B) use this
|
||||
mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = [mock_video]
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/cover.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-1",
|
||||
plan_id="plan-x",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
# Verify step 2.5 was called for video
|
||||
mock_repo.list_by_source_edit_plan.assert_called_with("plan-x")
|
||||
# Cover was found via unified pipeline step B
|
||||
assert result.cover["image_url"] == "https://oss.example.com/rendered/cover.jpg"
|
||||
|
||||
def test_step25_skips_non_completed_or_non_preview_tasks(self):
|
||||
"""步骤2.5跳过非completed或非is_preview的任务,继续到步骤3。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
# Task that is not completed
|
||||
mock_task_failed = MagicMock()
|
||||
mock_task_failed.id = "task-failed"
|
||||
mock_task_failed.status = "failed"
|
||||
mock_task_failed.is_preview = True
|
||||
mock_task_failed.cover_url = ""
|
||||
|
||||
# Task that is not preview
|
||||
mock_task_full = MagicMock()
|
||||
mock_task_full.id = "task-full"
|
||||
mock_task_full.status = "completed"
|
||||
mock_task_full.is_preview = False
|
||||
mock_task_full.cover_url = ""
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
# Step 3 fallback finds a valid preview task WITH cover_url
|
||||
mock_step3_task = MagicMock()
|
||||
mock_step3_task.id = "step3-task"
|
||||
mock_step3_task.status = "completed"
|
||||
mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg"
|
||||
|
||||
mock_video = MagicMock()
|
||||
mock_video.file_url = "rendered/step3/video.mp4"
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
|
||||
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = [mock_task_failed, mock_task_full]
|
||||
# Video step 3 and cover step C both use list_latest_completed_preview
|
||||
mock_repo.list_latest_completed_preview.return_value = [mock_step3_task]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = [mock_video]
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"}
|
||||
}
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-1",
|
||||
plan_id="plan-y",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
# Step 2.5 found tasks but none matched -> step 3 should be called
|
||||
mock_repo.list_by_source_edit_plan.assert_called()
|
||||
mock_repo.list_latest_completed_preview.assert_called()
|
||||
assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg"
|
||||
|
||||
def test_step25_exception_does_not_block_step3(self):
|
||||
"""步骤2.5异常时不影响步骤3兜底(视频和封面都通过步骤3找到)。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
mock_step3_task = MagicMock()
|
||||
mock_step3_task.id = "step3-task"
|
||||
mock_step3_task.status = "completed"
|
||||
mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg"
|
||||
|
||||
mock_video = MagicMock()
|
||||
mock_video.file_url = "rendered/step3/video.mp4"
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
|
||||
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
# Both video step 2.5 and cover step B raise
|
||||
mock_repo.list_by_source_edit_plan.side_effect = RuntimeError("db error")
|
||||
# Step 3 / step C succeeds
|
||||
mock_repo.list_latest_completed_preview.return_value = [mock_step3_task]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = [mock_video]
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"}
|
||||
}
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-1",
|
||||
plan_id="plan-z",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
# Step 3 was called after step 2.5 failed
|
||||
mock_repo.list_latest_completed_preview.assert_called()
|
||||
assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg"
|
||||
|
||||
|
||||
class TestStrayLoggerRemoved:
|
||||
"""验证多余的 logger.info(plan_id, generation_task_id) 已被删除。"""
|
||||
|
||||
def test_no_stray_logger_call_in_source(self):
|
||||
"""源码中不应存在 logger.info(plan_id, generation_task_id) 这样的调用。"""
|
||||
import inspect
|
||||
|
||||
from app.api.routes import generation_cover
|
||||
|
||||
source = inspect.getsource(generation_cover)
|
||||
# The stray call was logger.info(\n plan_id,\n generation_task_id,\n)
|
||||
# with no format string — should not exist
|
||||
assert (
|
||||
"logger.info(\n plan_id," not in source
|
||||
), "Stray logger.info(plan_id, generation_task_id) should be removed"
|
||||
@@ -3,6 +3,7 @@
|
||||
测试 #1208: AI封面接入MediaKit视频截帧
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -140,43 +141,13 @@ class TestMediaKitClient:
|
||||
|
||||
|
||||
class TestAICoverService:
|
||||
"""AI 封面服务测试."""
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
def test_call_ai_cover_with_mediakit_success(self, mock_get_client):
|
||||
"""MediaKit 抽帧成功."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = True
|
||||
mock_client.extract_frames.return_value = [{"image_url": "https://example.com/frame.jpg", "timestamp": 3.5}]
|
||||
mock_get_client.return_value = mock_client
|
||||
"""AI 封面服务测试(统一封面管道后)。"""
|
||||
|
||||
def test_call_ai_cover_ai_frame_raises(self):
|
||||
"""ai_frame type raises RuntimeError in unified pipeline."""
|
||||
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"] == "https://example.com/frame.jpg"
|
||||
assert result["frame_time"] == 3.5
|
||||
assert result["confidence"] == 0.85
|
||||
|
||||
mock_client.extract_frames.assert_called_once()
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
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")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
with pytest.raises(RuntimeError, match="封面数据不可用"):
|
||||
_call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
@@ -184,12 +155,22 @@ class TestAICoverService:
|
||||
primary_video_url="https://example.com/video.mp4",
|
||||
)
|
||||
|
||||
def test_call_ai_cover_without_video_url_raises(self):
|
||||
"""没有视频 URL 时抛出 RuntimeError(不再降级到 stub)."""
|
||||
|
||||
def test_call_ai_cover_ai_regenerate_raises(self):
|
||||
"""ai_regenerate type raises RuntimeError in unified pipeline."""
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
with pytest.raises(RuntimeError, match="MediaKit"):
|
||||
with pytest.raises(RuntimeError, match="封面数据不可用"):
|
||||
_call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
cover_type="ai_regenerate",
|
||||
)
|
||||
|
||||
def test_call_ai_cover_without_video_url_raises(self):
|
||||
"""ai_frame without video URL still raises RuntimeError."""
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
with pytest.raises(RuntimeError, match="封面数据不可用"):
|
||||
_call_ai_cover_service(
|
||||
plan_id="plan-123",
|
||||
asset_ids=["asset-1"],
|
||||
@@ -226,41 +207,6 @@ class TestAICoverService:
|
||||
assert result["type"] == "manual"
|
||||
assert result["frame_time"] == 5.0
|
||||
|
||||
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
||||
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
|
||||
|
||||
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_raises(self, mock_get_client):
|
||||
"""MediaKit 返回空帧列表时抛出 RuntimeError(不再降级)."""
|
||||
mock_client = Mock()
|
||||
mock_client.is_available = True
|
||||
mock_client.extract_frames.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
from packages.shared.ai_service import _call_ai_cover_service
|
||||
|
||||
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:
|
||||
"""run_generate_cover 测试."""
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""测试 ASR 字幕路径的标题叠加功能。
|
||||
|
||||
验证:
|
||||
1. _overlay_title_on_ass 函数正确地将标题事件追加到 ASR 生成的 ASS 文件中
|
||||
2. _maybe_generate_ass 在 ASR 路径中正确叠加标题
|
||||
3. ASR 无结果但有标题时,仍然生成标题 ASS
|
||||
4. ASR 失败但有标题时,降级生成标题 ASS
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.worker.video_processing.unified_render_service import _overlay_title_on_ass
|
||||
|
||||
|
||||
class TestOverlayTitleOnAss:
|
||||
"""_overlay_title_on_ass 函数测试"""
|
||||
|
||||
def test_overlay_title_adds_style_and_dialogue(self, tmp_path):
|
||||
"""标题 Style 和 Dialogue 正确插入 ASS 文件"""
|
||||
# 准备一个模拟 ASR 生成的 ASS 文件
|
||||
ass_content = """[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 1280
|
||||
PlayResY: 720
|
||||
ScaledBorderAndShadow: yes
|
||||
WrapStyle: 2
|
||||
Encoding: UTF-8
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,思源黑体,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.5,0,2,40,40,60,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,这是ASR字幕
|
||||
"""
|
||||
ass_path = tmp_path / "test.ass"
|
||||
ass_path.write_text(ass_content, encoding="utf-8")
|
||||
|
||||
# 叠加标题
|
||||
_overlay_title_on_ass(
|
||||
ass_path,
|
||||
title_text="测试标题",
|
||||
title_config={"position": "top", "font": "思源黑体", "size": 48, "color": "#ffffff"},
|
||||
video_width=1280,
|
||||
video_height=720,
|
||||
video_duration=10.0,
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
result = ass_path.read_text(encoding="utf-8")
|
||||
assert "Style: TitleStyle" in result, "TitleStyle 应被插入"
|
||||
assert "测试标题" in result, "标题文本应出现在 Dialogue 中"
|
||||
# 原有的 ASR 字幕应该保留
|
||||
assert "这是ASR字幕" in result, "原有 ASR 字幕应保留"
|
||||
# TitleStyle 应该在 Default Style 之后
|
||||
lines = result.splitlines()
|
||||
style_lines = [i for i, ln in enumerate(lines) if ln.startswith("Style:")]
|
||||
assert len(style_lines) >= 2, "应有至少两个 Style 行"
|
||||
|
||||
def test_overlay_title_empty_text_noop(self, tmp_path):
|
||||
"""空标题文本时不修改 ASS 文件"""
|
||||
ass_content = "[Script Info]\n\n[V4+ Styles]\nStyle: Default,test\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,test\n"
|
||||
ass_path = tmp_path / "test.ass"
|
||||
ass_path.write_text(ass_content, encoding="utf-8")
|
||||
|
||||
_overlay_title_on_ass(
|
||||
ass_path,
|
||||
title_text="",
|
||||
title_config={},
|
||||
video_width=1280,
|
||||
video_height=720,
|
||||
video_duration=10.0,
|
||||
)
|
||||
|
||||
result = ass_path.read_text(encoding="utf-8")
|
||||
assert "TitleStyle" not in result, "空标题不应添加 TitleStyle"
|
||||
|
||||
def test_overlay_title_preserves_asr_events(self, tmp_path):
|
||||
"""叠加标题后 ASR 字幕事件保持不变"""
|
||||
ass_content = """[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 1920
|
||||
PlayResY: 1080
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,思源黑体,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.5,0,2,40,40,60,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:00.50,0:00:03.00,Default,,0,0,0,,第一段字幕
|
||||
Dialogue: 0,0:00:03.50,0:00:06.00,Default,,0,0,0,,第二段字幕
|
||||
Dialogue: 0,0:00:06.50,0:00:10.00,Default,,0,0,0,,第三段字幕
|
||||
"""
|
||||
ass_path = tmp_path / "test.ass"
|
||||
ass_path.write_text(ass_content, encoding="utf-8")
|
||||
|
||||
_overlay_title_on_ass(
|
||||
ass_path,
|
||||
title_text="我的标题",
|
||||
title_config={"position": "top", "size": 48},
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_duration=10.0,
|
||||
)
|
||||
|
||||
result = ass_path.read_text(encoding="utf-8")
|
||||
# 所有 ASR 字幕段都应保留
|
||||
assert "第一段字幕" in result
|
||||
assert "第二段字幕" in result
|
||||
assert "第三段字幕" in result
|
||||
# 标题也应存在
|
||||
assert "我的标题" in result
|
||||
|
||||
|
||||
class TestMaybeGenerateAssWithTitle:
|
||||
"""_maybe_generate_ass 方法在 ASR 路径中标题叠加的集成测试"""
|
||||
|
||||
def _make_service(self, tmp_path, plan_config, asr_service=None):
|
||||
"""创建简化的 UnifiedRenderService 实例用于测试"""
|
||||
from apps.worker.video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
service = object.__new__(UnifiedRenderService)
|
||||
service.plan = MagicMock()
|
||||
service.plan.id = "test_plan_001"
|
||||
service.plan.config = plan_config
|
||||
service.work_dir = tmp_path
|
||||
service.output_width = 1280
|
||||
service.output_height = 720
|
||||
service.asr_service = asr_service
|
||||
service._asr_timeline_cached = False
|
||||
service._asr_timeline_cache = None
|
||||
return service
|
||||
|
||||
def test_asr_path_with_title_overlays_title(self, tmp_path):
|
||||
"""ASR 路径 + 有标题 → 标题叠加到 ASS 文件"""
|
||||
plan_config = {
|
||||
"title": {
|
||||
"text": "测试标题",
|
||||
"enabled": True,
|
||||
"position": "top",
|
||||
"size": 48,
|
||||
"font": "思源黑体",
|
||||
"color": "#ffffff",
|
||||
},
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
}
|
||||
|
||||
# Mock ASR service
|
||||
mock_asr = MagicMock()
|
||||
service = self._make_service(tmp_path, plan_config, asr_service=mock_asr)
|
||||
|
||||
# Mock _generate_asr_subtitles to return a timeline with segments
|
||||
mock_timeline = MagicMock()
|
||||
mock_segment = MagicMock()
|
||||
mock_segment.start = 0.0
|
||||
mock_segment.end = 3.0
|
||||
mock_segment.text = "ASR识别的文字"
|
||||
mock_timeline.segments = [mock_segment]
|
||||
mock_timeline.segment_count = 1
|
||||
|
||||
with patch.object(service, "_generate_asr_subtitles", return_value=mock_timeline):
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
|
||||
assert result is not None, "应生成 ASS 文件"
|
||||
content = result.read_text(encoding="utf-8")
|
||||
assert "测试标题" in content, "标题应出现在 ASS 文件中"
|
||||
assert "ASR识别的文字" in content, "ASR 字幕也应保留"
|
||||
|
||||
def test_asr_no_result_with_title_generates_title_ass(self, tmp_path):
|
||||
"""ASR 无结果 + 有标题 → 仍然生成标题 ASS"""
|
||||
plan_config = {
|
||||
"title": {"text": "仅标题", "enabled": True, "position": "top", "size": 48},
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
}
|
||||
|
||||
mock_asr = MagicMock()
|
||||
service = self._make_service(tmp_path, plan_config, asr_service=mock_asr)
|
||||
|
||||
# Mock ASR returns empty timeline
|
||||
mock_timeline = MagicMock()
|
||||
mock_timeline.segments = []
|
||||
|
||||
with patch.object(service, "_generate_asr_subtitles", return_value=mock_timeline):
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
|
||||
assert result is not None, "有标题时应生成 ASS 文件"
|
||||
content = result.read_text(encoding="utf-8")
|
||||
assert "仅标题" in content, "标题应出现在 ASS 文件中"
|
||||
|
||||
def test_asr_failure_with_title_generates_title_ass(self, tmp_path):
|
||||
"""ASR 失败 + 有标题 → 降级生成标题 ASS"""
|
||||
plan_config = {
|
||||
"title": {"text": "降级标题", "enabled": True, "position": "top", "size": 48},
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
}
|
||||
|
||||
mock_asr = MagicMock()
|
||||
service = self._make_service(tmp_path, plan_config, asr_service=mock_asr)
|
||||
|
||||
# Mock ASR raises exception
|
||||
with patch.object(service, "_generate_asr_subtitles", side_effect=RuntimeError("ASR error")):
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
|
||||
assert result is not None, "ASR 失败但有标题时应生成 ASS 文件"
|
||||
content = result.read_text(encoding="utf-8")
|
||||
assert "降级标题" in content, "标题应出现在降级 ASS 文件中"
|
||||
|
||||
def test_overlay_failure_preserves_asr_data(self, tmp_path):
|
||||
"""_overlay_title_on_ass 抛异常时,ASR 生成的 ASS 文件应保留并返回"""
|
||||
plan_config = {
|
||||
"title": {"text": "测试标题", "enabled": True, "position": "top", "size": 48},
|
||||
"subtitle": {"enabled": True, "auto_generated": True},
|
||||
}
|
||||
|
||||
mock_asr = MagicMock()
|
||||
service = self._make_service(tmp_path, plan_config, asr_service=mock_asr)
|
||||
|
||||
mock_timeline = MagicMock()
|
||||
mock_segment = MagicMock()
|
||||
mock_segment.start = 0.0
|
||||
mock_segment.end = 3.0
|
||||
mock_segment.text = "ASR识别的文字"
|
||||
mock_timeline.segments = [mock_segment]
|
||||
mock_timeline.segment_count = 1
|
||||
|
||||
with patch.object(service, "_generate_asr_subtitles", return_value=mock_timeline):
|
||||
with patch(
|
||||
"apps.worker.video_processing.unified_render_service._overlay_title_on_ass",
|
||||
side_effect=RuntimeError("模拟叠加标题失败"),
|
||||
):
|
||||
result = service._maybe_generate_ass(video_duration=10.0)
|
||||
|
||||
# 即使 _overlay_title_on_ass 失败,仍返回 ASS 文件
|
||||
assert result is not None, "应返回 ASS 文件路径"
|
||||
assert result.exists(), "ASS 文件应存在"
|
||||
content = result.read_text(encoding="utf-8")
|
||||
assert "ASR识别的文字" in content, "ASR 字幕数据应保留"
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Tests for preview title_config feature.
|
||||
|
||||
验证预览 API 的 title_config 字段和 Worker 的标题配置解析逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPreviewTitleConfigSchema:
|
||||
"""测试 CreatePreviewGenerationTaskRequest 的 title_config 字段."""
|
||||
|
||||
def test_title_config_default_empty(self):
|
||||
"""title_config 默认为空 dict."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="test_template",
|
||||
asset_ids=["asset1"],
|
||||
)
|
||||
assert req.title_config == {}
|
||||
|
||||
def test_title_config_with_text(self):
|
||||
"""传入标题文本."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="test_template",
|
||||
asset_ids=["asset1"],
|
||||
title_config={"text": "测试标题"},
|
||||
)
|
||||
assert req.title_config["text"] == "测试标题"
|
||||
|
||||
def test_title_config_with_full_style(self):
|
||||
"""传入完整标题样式配置."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
config = {
|
||||
"text": "我的视频标题",
|
||||
"font": "思源黑体",
|
||||
"font_size": 48,
|
||||
"font_color": "#ffffff",
|
||||
"position": "top",
|
||||
"bold": True,
|
||||
"stroke": 2,
|
||||
"shadow": True,
|
||||
}
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="test_template",
|
||||
asset_ids=["asset1"],
|
||||
title_config=config,
|
||||
)
|
||||
assert req.title_config["text"] == "我的视频标题"
|
||||
assert req.title_config["font_size"] == 48
|
||||
assert req.title_config["position"] == "top"
|
||||
|
||||
|
||||
class TestCommandTitleConfig:
|
||||
"""测试 CreateGenerationTaskCommand 的 title_config 字段."""
|
||||
|
||||
def test_command_has_title_config(self):
|
||||
"""Command 包含 title_config 字段."""
|
||||
from packages.application.generation_tasks import CreateGenerationTaskCommand
|
||||
|
||||
cmd = CreateGenerationTaskCommand(
|
||||
title_config={"text": "hello", "font_size": 32},
|
||||
)
|
||||
assert cmd.title_config["text"] == "hello"
|
||||
assert cmd.title_config["font_size"] == 32
|
||||
|
||||
def test_command_title_config_default_empty(self):
|
||||
"""Command 的 title_config 默认为空 dict."""
|
||||
from packages.application.generation_tasks import CreateGenerationTaskCommand
|
||||
|
||||
cmd = CreateGenerationTaskCommand()
|
||||
assert cmd.title_config == {}
|
||||
|
||||
|
||||
class TestWorkerTitleConfigParsing:
|
||||
"""测试 Worker 渲染时的标题配置解析逻辑."""
|
||||
|
||||
def test_json_format_parsing(self):
|
||||
"""JSON 格式的 custom_title 能正确解析."""
|
||||
config = {"text": "测试标题", "font_size": 48, "font_color": "#ff0000"}
|
||||
custom_title = json.dumps(config, ensure_ascii=False)
|
||||
|
||||
ct_stripped = custom_title.strip()
|
||||
parsed = None
|
||||
if ct_stripped.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(ct_stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = None
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["text"] == "测试标题"
|
||||
assert parsed["font_size"] == 48
|
||||
|
||||
def test_plain_text_fallback(self):
|
||||
"""纯文本的 custom_title 不触发 JSON 解析."""
|
||||
custom_title = "简单的标题文字"
|
||||
|
||||
ct_stripped = custom_title.strip()
|
||||
parsed = None
|
||||
if ct_stripped.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(ct_stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = None
|
||||
|
||||
assert parsed is None
|
||||
|
||||
def test_invalid_json_fallback(self):
|
||||
"""无效 JSON 的 custom_title 降级为纯文本."""
|
||||
custom_title = "{invalid json"
|
||||
|
||||
ct_stripped = custom_title.strip()
|
||||
parsed = None
|
||||
if ct_stripped.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(ct_stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = None
|
||||
|
||||
assert parsed is None
|
||||
|
||||
def test_json_without_text_skipped(self):
|
||||
"""JSON 格式但缺少 text 字段时,跳过标题注入."""
|
||||
config = {"font_size": 48}
|
||||
custom_title = json.dumps(config, ensure_ascii=False)
|
||||
|
||||
ct_stripped = custom_title.strip()
|
||||
parsed = json.loads(ct_stripped)
|
||||
title_text = (parsed.get("text") or "").strip()
|
||||
|
||||
assert title_text == ""
|
||||
|
||||
def test_style_key_mapping(self):
|
||||
"""前端字段名正确映射到 ASS 字段名."""
|
||||
config = {
|
||||
"text": "标题",
|
||||
"font_size": 48,
|
||||
"font_color": "#ffffff",
|
||||
"font_preset": "思源黑体",
|
||||
}
|
||||
|
||||
style_keys = ["font", "font_size", "font_color", "position", "bold", "stroke", "shadow", "font_preset"]
|
||||
title_cfg = {}
|
||||
for key in style_keys:
|
||||
if key in config and config[key] is not None:
|
||||
mapped_key = {
|
||||
"font_size": "size",
|
||||
"font_color": "color",
|
||||
"font_preset": "font",
|
||||
}.get(key, key)
|
||||
title_cfg[mapped_key] = config[key]
|
||||
|
||||
assert title_cfg["size"] == 48
|
||||
assert title_cfg["color"] == "#ffffff"
|
||||
assert title_cfg["font"] == "思源黑体"
|
||||
|
||||
|
||||
class TestPreviewRouteTitleConfigPassing:
|
||||
"""测试预览路由正确序列化 title_config 到 custom_title."""
|
||||
|
||||
def test_title_config_serialization(self):
|
||||
"""title_config 序列化为 JSON 字符串."""
|
||||
title_config = {
|
||||
"text": "我的标题",
|
||||
"font_size": 32,
|
||||
"font_color": "#d4a843",
|
||||
}
|
||||
serialized = json.dumps(title_config, ensure_ascii=False)
|
||||
|
||||
parsed = json.loads(serialized)
|
||||
assert parsed["text"] == "我的标题"
|
||||
assert parsed["font_size"] == 32
|
||||
|
||||
def test_empty_title_config_produces_empty_string(self):
|
||||
"""空 title_config 时 custom_title 为空字符串."""
|
||||
title_config = {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
|
||||
assert custom_title_value == ""
|
||||
@@ -348,24 +348,32 @@ class TestRenderPlan:
|
||||
mock_render_cls.return_value = mock_render
|
||||
mock_upload.return_value = "https://oss.example.com/out.mp4"
|
||||
|
||||
fake_thumb = "https://oss.example.com/rendered/plan_thumb/thumbnail.jpg"
|
||||
|
||||
plan = FakePlan(id="plan_thumb")
|
||||
clips = [_make_clip("c1", order=0, duration=5.0)]
|
||||
asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"}
|
||||
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
|
||||
|
||||
# Mock extract_first_frame to return a temp file path
|
||||
import tempfile as _tf
|
||||
|
||||
_fake_frame = _tf.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
_fake_frame.write(b"fake frame")
|
||||
_fake_frame.close()
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
return_value=fake_thumb,
|
||||
"video_processing.thumbnail_generator.extract_first_frame",
|
||||
return_value=_fake_frame.name,
|
||||
):
|
||||
result = adapter.render_plan(
|
||||
"plan_thumb",
|
||||
work_dir=tmp_path / "work",
|
||||
)
|
||||
from pathlib import Path as _P
|
||||
|
||||
_P(_fake_frame.name).unlink(missing_ok=True)
|
||||
|
||||
assert result.success
|
||||
assert result.thumbnail_url == fake_thumb
|
||||
# cover_url from upload_to_oss (mocked globally)
|
||||
assert result.thumbnail_url == "https://oss.example.com/out.mp4"
|
||||
|
||||
@patch("video_processing.render_adapter.upload_to_oss")
|
||||
@patch("video_processing.render_adapter.UnifiedRenderService")
|
||||
@@ -397,8 +405,8 @@ class TestRenderPlan:
|
||||
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
|
||||
|
||||
with patch(
|
||||
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
|
||||
side_effect=RuntimeError("cv2 not available"),
|
||||
"video_processing.thumbnail_generator.extract_first_frame",
|
||||
side_effect=RuntimeError("ffmpeg not available"),
|
||||
):
|
||||
result = adapter.render_plan(
|
||||
"plan_thumb_fail",
|
||||
|
||||
@@ -436,12 +436,12 @@ class TestAiCoverService:
|
||||
|
||||
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"):
|
||||
with pytest.raises(RuntimeError, match="封面数据不可用"):
|
||||
_call_ai_cover_service("plan1", ["a1"], "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"):
|
||||
with pytest.raises(RuntimeError, match="封面数据不可用"):
|
||||
_call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
|
||||
|
||||
def test_cover_type_manual_still_works(self):
|
||||
|
||||
Reference in New Issue
Block a user