Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5520e18e2e | |||
| abb8e0fcaf | |||
| 6b16f0c738 | |||
| f6f9d6fc99 | |||
| 55825a4b28 | |||
| 48a9d11dae | |||
| c3ac621ebd | |||
| 8740585e41 | |||
| f8075597f7 | |||
| 7cd76084e6 | |||
| cf796c2e3c | |||
| 2a8e949350 | |||
| 8ba7698c06 | |||
| 4efc44e0dc | |||
| 3c800d3f3f | |||
| e2d10dd357 | |||
| c62125cf7b | |||
| 6c04bb53ad |
@@ -49,6 +49,10 @@ class GenerateCoverRequest(BaseModel):
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
cover_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
@@ -80,6 +84,29 @@ def generate_cover(
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── upload 类型:直接保存前端上传的封面图片,不需要预览视频 ──────
|
||||
if body.cover_type == "upload":
|
||||
if not body.cover_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="cover_type=upload 时必须提供 cover_url",
|
||||
)
|
||||
cover_data = {
|
||||
"type": "upload",
|
||||
"image_url": body.cover_url,
|
||||
}
|
||||
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(
|
||||
"封面上传完成: plan_id=%s cover_url=%s by user=%s",
|
||||
plan_id,
|
||||
body.cover_url[:80] if body.cover_url else "",
|
||||
current_user.user.id,
|
||||
)
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_asset_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
ClipBatchDeleteRequest,
|
||||
@@ -43,30 +46,100 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
def _clip_to_response(clip) -> EditorClipResponse:
|
||||
"""统一构造片段响应"""
|
||||
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
|
||||
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
|
||||
|
||||
def _enum_str(val) -> str:
|
||||
return val.value if hasattr(val, "value") else str(val)
|
||||
|
||||
def _fmt_dt(val) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
if hasattr(val, "isoformat"):
|
||||
return val.isoformat()
|
||||
return str(val)
|
||||
|
||||
return EditorClipResponse(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type.value
|
||||
if hasattr(clip.clip_type, "value")
|
||||
else str(clip.clip_type),
|
||||
clip_type=_enum_str(getattr(clip, "clip_type", "")),
|
||||
order=clip.order,
|
||||
duration=clip.duration,
|
||||
start_time=getattr(clip, "start_time", 0.0) or 0.0,
|
||||
text_content=clip.text_content or "",
|
||||
transition_effect=clip.transition_effect.value
|
||||
if hasattr(clip.transition_effect, "value")
|
||||
else str(clip.transition_effect),
|
||||
transition_effect=_enum_str(getattr(clip, "transition_effect", "cut")),
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
asset_id=getattr(clip, "asset_id", "") or "",
|
||||
asset_url=asset_url,
|
||||
status=getattr(clip, "status", "pending") or "pending",
|
||||
template_clip_config_id=getattr(clip, "template_clip_config_id", "") or "",
|
||||
config=clip.config or {},
|
||||
created_at=_fmt_dt(getattr(clip, "created_at", None)),
|
||||
updated_at=_fmt_dt(getattr(clip, "updated_at", None)),
|
||||
)
|
||||
|
||||
|
||||
def _build_asset_url_map(
|
||||
asset_ids: list[str],
|
||||
asset_repo: SQLAlchemyAssetRepository,
|
||||
) -> dict[str, str | None]:
|
||||
"""批量查询素材并生成签名URL映射.
|
||||
|
||||
Returns:
|
||||
{asset_id: signed_url_or_None}
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
|
||||
# 去重:多个 clip 可能引用同一个素材
|
||||
# 去重并保持顺序
|
||||
seen: set[str] = set()
|
||||
unique_ids = []
|
||||
for aid in asset_ids:
|
||||
if aid and aid not in seen:
|
||||
seen.add(aid)
|
||||
unique_ids.append(aid)
|
||||
|
||||
result: dict[str, str | None] = {}
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception:
|
||||
logger.warning("获取存储服务失败,跳过asset_url生成")
|
||||
return {aid: None for aid in asset_ids}
|
||||
|
||||
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
|
||||
try:
|
||||
assets = asset_repo.find_by_ids(unique_ids)
|
||||
asset_map = {a.id: a for a in assets}
|
||||
except Exception:
|
||||
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
|
||||
return {aid: None for aid in asset_ids if aid}
|
||||
|
||||
for aid in unique_ids:
|
||||
try:
|
||||
asset = asset_map.get(aid)
|
||||
if asset is None:
|
||||
result[aid] = None
|
||||
continue
|
||||
storage_key = getattr(asset, "storage_key", None) or ""
|
||||
if not storage_key:
|
||||
result[aid] = None
|
||||
continue
|
||||
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
|
||||
except Exception:
|
||||
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
|
||||
result[aid] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/clips", response_model=EditorClipListResponse)
|
||||
def list_draft_clips(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -75,8 +148,17 @@ def list_draft_clips(
|
||||
_, plan_svc = services
|
||||
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
|
||||
total = plan_svc.count_clips(plan_id)
|
||||
|
||||
# 批量解析素材签名URL
|
||||
asset_ids = [getattr(c, "asset_id", "") or "" for c in clips]
|
||||
asset_ids = [aid for aid in asset_ids if aid]
|
||||
url_map = _build_asset_url_map(asset_ids, asset_repo)
|
||||
|
||||
return EditorClipListResponse(
|
||||
items=[_clip_to_response(c) for c in clips],
|
||||
items=[
|
||||
_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or ""))
|
||||
for c in clips
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -156,6 +238,7 @@ def get_draft_clip_detail(
|
||||
clip_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""获取草稿中的片段详情"""
|
||||
@@ -165,16 +248,20 @@ def get_draft_clip_detail(
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
return _clip_to_response(clip)
|
||||
|
||||
asset_id = getattr(clip, "asset_id", "") or ""
|
||||
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
|
||||
return _clip_to_response(clip, asset_url=url_map.get(asset_id))
|
||||
|
||||
|
||||
@router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
||||
@router.post("/clips/{clip_id}/split", status_code=status.HTTP_200_OK)
|
||||
def split_draft_clip(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: SplitClipRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""将一个片段从指定时间点分割为两个片段"""
|
||||
@@ -190,32 +277,22 @@ def split_draft_clip(
|
||||
) from exc
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
|
||||
asset_ids = [a for a in asset_ids if a]
|
||||
url_map = _build_asset_url_map(asset_ids, asset_repo)
|
||||
return {
|
||||
"left_clip": {
|
||||
"id": left.id,
|
||||
"plan_id": left.plan_id,
|
||||
"clip_type": left.clip_type,
|
||||
"order": left.order,
|
||||
"duration": left.duration,
|
||||
"start_time": left.start_time,
|
||||
},
|
||||
"right_clip": {
|
||||
"id": right.id,
|
||||
"plan_id": right.plan_id,
|
||||
"clip_type": right.clip_type,
|
||||
"order": right.order,
|
||||
"duration": right.duration,
|
||||
"start_time": right.start_time,
|
||||
},
|
||||
"left_clip": _clip_to_response(left, asset_url=url_map.get(getattr(left, "asset_id", "") or "")),
|
||||
"right_clip": _clip_to_response(right, asset_url=url_map.get(getattr(right, "asset_id", "") or "")),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
||||
@router.post("/clips/merge", status_code=status.HTTP_200_OK)
|
||||
def merge_draft_clips(
|
||||
template_id: str,
|
||||
body: MergeClipsRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""将多个连续的同类型片段合并为一个片段"""
|
||||
@@ -230,13 +307,11 @@ def merge_draft_clips(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
asset_id = getattr(merged, "asset_id", "") or ""
|
||||
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
|
||||
return {
|
||||
"id": merged.id,
|
||||
"plan_id": merged.plan_id,
|
||||
"clip_type": merged.clip_type,
|
||||
"order": merged.order,
|
||||
"duration": merged.duration,
|
||||
"text_content": merged.text_content,
|
||||
"merged_clip": _clip_to_response(merged, asset_url=url_map.get(asset_id)),
|
||||
"deleted_clip_ids": body.clip_ids,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
@@ -45,6 +46,7 @@ from ._fallback import (
|
||||
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateRequest,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
@@ -57,6 +59,7 @@ router = APIRouter(tags=["Template Editor"])
|
||||
@router.post("/generate", response_model=EditPlanGenerateResponse)
|
||||
def generate_editor_draft(
|
||||
template_id: str,
|
||||
request: Optional[EditPlanGenerateRequest] = None,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -65,6 +68,7 @@ def generate_editor_draft(
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发模板草稿渲染生成"""
|
||||
req = request or EditPlanGenerateRequest()
|
||||
_, plan_svc = services
|
||||
plan_check = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
@@ -85,6 +89,36 @@ def generate_editor_draft(
|
||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
# 如果前端传了 title_config,需要创建新任务(因为预览任务的 custom_title 可能不同)
|
||||
title_config_reuse = req.title_config or {}
|
||||
title_text_reuse = (title_config_reuse.get("text") or "").strip()
|
||||
existing_custom_title = getattr(reusable_task, "custom_title", "") or ""
|
||||
if title_text_reuse and existing_custom_title:
|
||||
# 如果新标题和已有标题不同,不能复用,走新建任务流程
|
||||
new_title_json = json.dumps(title_config_reuse, ensure_ascii=False)
|
||||
if new_title_json != existing_custom_title:
|
||||
logger.info(
|
||||
"[模板生成] 标题已变更,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif title_text_reuse and not existing_custom_title:
|
||||
# 原来没标题,现在有标题,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 新增标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif not title_text_reuse and existing_custom_title:
|
||||
# 原来有标题,现在移除了,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 移除标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
reusable_task.mark_confirmed()
|
||||
@@ -133,6 +167,21 @@ def generate_editor_draft(
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
# 从 plan config 读取封面 URL(由 generate-cover 保存)
|
||||
cover_url_from_config = (plan.config or {}).get("cover", {}).get("image_url", "")
|
||||
|
||||
# 处理标题配置:序列化 title_config 为 JSON 存入 custom_title
|
||||
title_config = req.title_config or {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
logger.info(
|
||||
"[模板生成] 标题配置: text=%s, config_keys=%s",
|
||||
title_text[:30],
|
||||
list(title_config.keys()),
|
||||
)
|
||||
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=plan.project_id or "",
|
||||
@@ -140,6 +189,8 @@ def generate_editor_draft(
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
asset_ids=list(config_asset_ids) if config_asset_ids else [],
|
||||
cover_url=cover_url_from_config,
|
||||
custom_title=custom_title_value,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
@@ -44,6 +44,14 @@ class EditPlanGenerationStatusResponse(BaseModel):
|
||||
clips: List[ClipStatusItem]
|
||||
|
||||
|
||||
class EditPlanGenerateRequest(BaseModel):
|
||||
"""模板编辑器触发生成请求体"""
|
||||
title_config: Optional[Dict[str, Any]] = Field(
|
||||
default_factory=dict,
|
||||
description="标题配置(可选),渲染时烧录到视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
|
||||
)
|
||||
|
||||
|
||||
class EditPlanGenerateResponse(BaseModel):
|
||||
"""剪辑计划触发生成响应体"""
|
||||
|
||||
@@ -227,6 +235,7 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
|
||||
success: bool = True
|
||||
created_count: int
|
||||
plan_id: str = ""
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
|
||||
@@ -439,17 +448,28 @@ class EditorUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class EditorClipResponse(BaseModel):
|
||||
"""片段响应"""
|
||||
"""片段响应 — 与数据库 edit_plan_clips 表字段对齐"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
duration: float
|
||||
start_time: float = 0.0
|
||||
text_content: str = ""
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
asset_id: str = ""
|
||||
asset_url: str | None = Field(
|
||||
default=None,
|
||||
description="素材视频签名URL(1小时有效),用于前端预览播放",
|
||||
)
|
||||
status: str = "pending"
|
||||
template_clip_config_id: str = ""
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class EditorClipListResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 主动 Token 刷新模块
|
||||
*
|
||||
* 在 access_token 过期前主动刷新,避免 API 请求触发 401。
|
||||
* JWT payload 是 base64 编码的 JSON,无需第三方库即可解码。
|
||||
*/
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./login"
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 提前刷新的缓冲时间(秒) */
|
||||
const REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
/**
|
||||
* 解码 JWT payload(不验签,仅读取 exp 字段)
|
||||
*/
|
||||
function decodeJwtPayload(token: string): { exp?: number } | null {
|
||||
try {
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
// JWT 使用 base64url 编码,需要转换为标准 base64
|
||||
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
|
||||
const decoded = atob(padded)
|
||||
return JSON.parse(decoded)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消已调度的主动刷新
|
||||
*/
|
||||
export function cancelProactiveRefresh(): void {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
|
||||
*/
|
||||
export function scheduleProactiveRefresh(): void {
|
||||
cancelProactiveRefresh()
|
||||
|
||||
const accessToken = localStorage.getItem("access_token")
|
||||
const refreshTokenValue = useAuthStore.getState().refreshToken
|
||||
|
||||
if (!accessToken || !refreshTokenValue) return
|
||||
|
||||
const payload = decodeJwtPayload(accessToken)
|
||||
if (!payload?.exp) return
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const secondsUntilExpiry = payload.exp - now
|
||||
|
||||
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
|
||||
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
|
||||
|
||||
refreshTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshTokenValue)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshTokenValue
|
||||
|
||||
// 更新 Zustand store + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
|
||||
// 递归调度下一次刷新
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// 刷新失败 → 清除认证状态,跳转登录页
|
||||
cancelProactiveRefresh()
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}, delaySeconds * 1000)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./auth"
|
||||
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -109,6 +110,9 @@ apiClient.interceptors.response.use(
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// 重新调度主动刷新(基于新 token 的过期时间)
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
// 重试原始请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||
@@ -116,6 +120,7 @@ apiClient.interceptors.response.use(
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败 → 登出
|
||||
cancelProactiveRefresh()
|
||||
processQueue(refreshError, null)
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import * as authApi from "@/api/auth"
|
||||
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
// 登录 Hook
|
||||
@@ -31,6 +32,9 @@ export const useLogin = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
// 启动主动 token 刷新,避免后续请求触发 401
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
localStorage.removeItem("login_redirect")
|
||||
@@ -73,6 +77,9 @@ export const useWechatCallback = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
// 启动主动 token 刷新
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
@@ -121,6 +128,7 @@ export const useLogout = () => {
|
||||
} catch (error) {
|
||||
// 即使登出失败也清除本地状态
|
||||
} finally {
|
||||
cancelProactiveRefresh()
|
||||
clearAuth()
|
||||
queryClient.clear()
|
||||
navigate("/")
|
||||
|
||||
@@ -9,6 +9,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ConfigProvider, App as AntApp } from "antd"
|
||||
import zhCN from "antd/locale/zh_CN"
|
||||
import router from "./router"
|
||||
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
|
||||
|
||||
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
|
||||
// 这样可以在 token 过期前自动刷新,避免 API 请求触发 401
|
||||
if (localStorage.getItem("access_token")) {
|
||||
scheduleProactiveRefresh()
|
||||
}
|
||||
import "./index.css"
|
||||
import "./styles/global.css"
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 选择标题 → 生成预览 → 选择封面 → 确认生成
|
||||
* 智能剪辑页面 — V24 前端预览播放器架构改造
|
||||
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
* 步骤导航 → hooks/useStepNavigation
|
||||
* 步骤内容 → components/GenerateStepContent
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*
|
||||
* 架构改造:
|
||||
* - Step5 预览改为前端素材切片播放(FrontendPreviewPlayer)
|
||||
* - 完全去除后端 FFmpeg 预览依赖
|
||||
* - 标题样式通过 CSS 层实时叠加,所见即所得
|
||||
* - 最终成片仍走后端 FFmpeg 渲染(Step7 确认生成)
|
||||
*/
|
||||
import React, { useState, useMemo } from "react"
|
||||
import React, { useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
@@ -24,7 +24,8 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useStep5Preview } from "./hooks/useStep5Preview"
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -71,6 +72,12 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewModalOpen,
|
||||
} = formState
|
||||
|
||||
/* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
})
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
@@ -80,36 +87,23 @@ const GeneratePage: React.FC = () => {
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 预览数量(多预览) ── */
|
||||
const [previewCount, setPreviewCount] = useState(1)
|
||||
/* ── 前端预览:加载选中素材的视频文件信息 ── */
|
||||
const previewAssetIds = useMemo(
|
||||
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
|
||||
[materialMode, smartSelectedIds, selectedMaterials],
|
||||
)
|
||||
const previewAssetsEnabled = currentStep >= 4 && previewAssetIds.length > 0
|
||||
const {
|
||||
assets: previewAssets,
|
||||
loading: previewAssetsLoading,
|
||||
ready: previewAssetsReady,
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
|
||||
/* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */
|
||||
/* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID),
|
||||
见 useGenerateFormState 返回值类型定义 */
|
||||
const previewVoiceIds = useMemo((): string[] => {
|
||||
if (voiceMode === "clone") {
|
||||
const id: string = selectedClonedVoice
|
||||
return id ? [id] : []
|
||||
}
|
||||
// preset / custom 模式
|
||||
const id: string = selectedVoice
|
||||
return id ? [id] : []
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice])
|
||||
|
||||
/* ── Step5 预览生成(多预览 + voice_ids) ── */
|
||||
const step5Preview = useStep5Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
})
|
||||
/* ── 当前模板对象(传给前端预览播放器) ── */
|
||||
const currentTemplate = useMemo(
|
||||
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
|
||||
[userTemplates, selectedTemplate],
|
||||
)
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
@@ -120,7 +114,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step5Preview.canProceed,
|
||||
previewReady: previewAssetsReady,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -151,7 +145,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: step5Preview.selectedTaskId,
|
||||
previewTaskId: "",
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
@@ -183,6 +177,17 @@ const GeneratePage: React.FC = () => {
|
||||
onSmartSelectedIdsChange={setSmartSelectedIds}
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={setTitleSettings}
|
||||
/* 标题样式回调 */
|
||||
onUpdatePosition={styleUpdaters.updatePosition}
|
||||
onUpdateFont={styleUpdaters.updateFont}
|
||||
onUpdateSize={styleUpdaters.updateSize}
|
||||
onToggleBold={styleUpdaters.toggleBold}
|
||||
onToggleItalic={styleUpdaters.toggleItalic}
|
||||
onToggleStroke={styleUpdaters.toggleStroke}
|
||||
onToggleShadow={styleUpdaters.toggleShadow}
|
||||
onApplyPreset={styleUpdaters.applyPreset}
|
||||
activePreset={styleUpdaters.activePreset}
|
||||
titlePresets={styleUpdaters.titlePresets}
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
duration={duration}
|
||||
@@ -207,19 +212,6 @@ const GeneratePage: React.FC = () => {
|
||||
onRetry={handleRetryGenerate}
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/* Step5 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step5Preview.items}
|
||||
previewSelectedIndex={step5Preview.selectedIndex}
|
||||
onSelectPreview={step5Preview.setSelectedIndex}
|
||||
previewOverallStatus={step5Preview.previewStatus}
|
||||
previewOverallError={step5Preview.previewError}
|
||||
previewOverallProgress={step5Preview.progress}
|
||||
previewAnyGenerating={step5Preview.anyGenerating}
|
||||
onGeneratePreview={step5Preview.generatePreview}
|
||||
onRegeneratePreview={step5Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -235,15 +227,15 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step4+ 显示) */}
|
||||
{/* 预览视频面板(Step4+ 显示,含 CSS 标题实时预览层) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step5Preview.previewStatus}
|
||||
previewResult={step5Preview.previewResult}
|
||||
previewError={step5Preview.previewError}
|
||||
progress={step5Preview.progress}
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step5Preview.regeneratePreview}
|
||||
assetsReady={previewAssetsReady}
|
||||
assetsLoading={previewAssetsLoading}
|
||||
titleSettings={titleSettings}
|
||||
/>
|
||||
)}
|
||||
{currentStep >= 6 && (
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 前端预览播放器
|
||||
* 用原生 <video> 标签按时间线播放素材片段
|
||||
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
/** 选中的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 当前模板(用于获取片段时长配置) */
|
||||
template: EditingTemplate | null
|
||||
/** 视频比例 */
|
||||
videoRatio: string
|
||||
/** 是否准备好播放(素材已加载) */
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将素材映射为播放片段
|
||||
* 每个素材对应一个模板片段,按顺序分配
|
||||
*/
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
template: EditingTemplate | null,
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length) return []
|
||||
|
||||
const templateSegments = template?.segments || []
|
||||
const segments: PlaybackSegment[] = []
|
||||
|
||||
assets.forEach((asset, i) => {
|
||||
// 获取素材时长(从 metadata 或顶层字段)
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
// 模板片段的时长约束
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
const startTime = 0
|
||||
const endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
|
||||
segments.push({
|
||||
assetId: asset.id,
|
||||
videoUrl: asset.file_url || asset.storage_key,
|
||||
startTime,
|
||||
endTime,
|
||||
order: i,
|
||||
})
|
||||
})
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
ready,
|
||||
}) => {
|
||||
// 构建播放片段
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
isEnded,
|
||||
canPlay,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRef,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// 进度条拖拽
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
seekTo(ratio * totalDuration)
|
||||
},
|
||||
[totalDuration, seekTo],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true)
|
||||
handleProgressClick(e)
|
||||
},
|
||||
[handleProgressClick],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
seekTo(ratio * totalDuration)
|
||||
}
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, totalDuration, seekTo])
|
||||
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
// 未就绪状态
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
<div className="xx-preview-empty">
|
||||
<SoundOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
|
||||
<p className="xx-preview-empty-title">准备预览素材...</p>
|
||||
<p className="xx-preview-empty-desc">加载素材后即可预览播放</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 无播放片段
|
||||
if (!canPlay) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-frontend-preview-player">
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
background: "#000",
|
||||
}}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* 播放/暂停按钮覆盖 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
className="xx-preview-play-btn"
|
||||
onClick={togglePlayPause}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: 56,
|
||||
height: 56,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 28,
|
||||
zIndex: 10,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
{isEnded ? <PlayCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 当前片段指示器 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
片段 {currentSegmentIndex + 1}/{segments.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className="xx-preview-controls"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* 播放/暂停 */}
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-primary, #fff)",
|
||||
fontSize: 18,
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 时间 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary, #999)",
|
||||
minWidth: 80,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 4,
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
background: "#3b82f6",
|
||||
borderRadius: 2,
|
||||
transition: isDragging ? "none" : "width 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
{/* 进度指示点 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `${progressPercent}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: "#3b82f6",
|
||||
border: "2px solid #fff",
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrontendPreviewPlayer
|
||||
@@ -2,6 +2,8 @@
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* V24: 移除 Step5 预览生成相关 props,改为纯标题样式编辑
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -9,7 +11,6 @@ import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
@@ -35,6 +36,17 @@ export interface GenerateStepContentProps {
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/* 标题样式回调 — Step5 样式面板使用 */
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
@@ -63,19 +75,6 @@ export interface GenerateStepContentProps {
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
videoRatio: string
|
||||
/* Step4 预览(多预览) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
previewItems: PreviewItem[]
|
||||
previewSelectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
previewOverallStatus: PreviewStatus
|
||||
previewOverallError: string
|
||||
previewOverallProgress: number
|
||||
previewAnyGenerating: boolean
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
@@ -92,6 +91,16 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSmartSelectedIdsChange,
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
@@ -110,18 +119,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
previewItems,
|
||||
previewSelectedIndex,
|
||||
onSelectPreview,
|
||||
previewOverallStatus,
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
previewAnyGenerating,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
|
||||
switch (currentStep) {
|
||||
@@ -161,18 +158,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
videoRatio={videoRatio}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
items={previewItems}
|
||||
selectedIndex={previewSelectedIndex}
|
||||
onSelectPreview={onSelectPreview}
|
||||
overallStatus={previewOverallStatus}
|
||||
overallError={previewOverallError}
|
||||
overallProgress={previewOverallProgress}
|
||||
anyGenerating={previewAnyGenerating}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
titleSettings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
|
||||
@@ -1,117 +1,213 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step4+: 显示预览视频面板
|
||||
* Step5+: 显示后端生成的预览视频(标题已由 FFmpeg 烧录)
|
||||
* Step5: 前端实时预览 — 用原生 video 播放素材片段 + CSS 标题叠加
|
||||
*
|
||||
* 设计说明:
|
||||
* - Step4(标题设置页):右侧显示空状态提示,引导用户输入标题
|
||||
* - Step5(预览生成页):显示后端返回的预览视频
|
||||
* - Canvas 预览已删除(统一由后端 FFmpeg 渲染标题)
|
||||
* 架构改造:完全去除后端 FFmpeg 预览依赖
|
||||
* - 使用 FrontendPreviewPlayer 直接播放素材片段
|
||||
* - TitleOverlay CSS 层实时响应标题样式变化
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import React, { useMemo } from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { TitleSettings } from "../types"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
/** 已加载的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 当前模板 */
|
||||
template: EditingTemplate | null
|
||||
/** 视频比例 */
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 素材是否已加载就绪 */
|
||||
assetsReady: boolean
|
||||
/** 素材是否正在加载 */
|
||||
assetsLoading: boolean
|
||||
/** 标题设置 — 用于 CSS 实时预览层 */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
/* ── 组件 ── */
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
const ASS_VIDEO_HEIGHT = 720
|
||||
const ASS_TITLE_MARGIN_TOP = 60
|
||||
const ASS_TITLE_MARGIN_BOTTOM = 60
|
||||
const ASS_TITLE_MARGIN_SIDE = 40
|
||||
|
||||
/**
|
||||
* 根据 position 计算 CSS 垂直定位
|
||||
* 与后端 position_to_ass_alignment() 对齐:top→8, center→5, bottom→2
|
||||
*/
|
||||
function getPositionStyle(position: string): React.CSSProperties {
|
||||
const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100
|
||||
|
||||
switch (position) {
|
||||
case "bottom":
|
||||
return {
|
||||
bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "center":
|
||||
return {
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "top":
|
||||
default:
|
||||
return {
|
||||
top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 CSS 标题层的样式
|
||||
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
|
||||
*/
|
||||
function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
|
||||
const fontSizePercent = (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * 100
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: settings.font || "思源黑体",
|
||||
fontSize: `${fontSizePercent}%`,
|
||||
color: settings.color || "#ffffff",
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
fontStyle: settings.italic ? "italic" : "normal",
|
||||
lineHeight: 1.3,
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
paddingLeft: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
|
||||
paddingRight: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
|
||||
}
|
||||
|
||||
if (settings.stroke) {
|
||||
base.WebkitTextStroke = "1px #000000"
|
||||
}
|
||||
|
||||
if (settings.shadow) {
|
||||
base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 标题预览覆盖层
|
||||
*/
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
|
||||
const positionStyle = useMemo(
|
||||
() => getPositionStyle(titleSettings.position),
|
||||
[titleSettings.position],
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.bold,
|
||||
titleSettings.italic,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
],
|
||||
)
|
||||
|
||||
if (!titleSettings.title?.trim()) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="xx-preview-title-overlay"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 20,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...positionStyle,
|
||||
...titleStyle,
|
||||
position: "absolute",
|
||||
}}
|
||||
>
|
||||
{titleSettings.title}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 主组件 ── */
|
||||
|
||||
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
onRegenerate,
|
||||
assetsReady,
|
||||
assetsLoading,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{hasPreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
{assetsReady && assets.length > 0 && <span className="xx-preview-badge">实时预览</span>}
|
||||
</div>
|
||||
|
||||
{/* 空状态:还没生成预览 */}
|
||||
{previewStatus === "idle" && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
{/* 加载中 */}
|
||||
{assetsLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
{previewStatus === "pending" ? "排队中..." : `生成中 ${progress}%`}
|
||||
加载素材中...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-progress-bar-wrap">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{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>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">
|
||||
{typeof previewError === "string" && previewError ? previewError : "请重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onRegenerate}>
|
||||
重新生成
|
||||
</button>
|
||||
{/* 前端预览播放器 + CSS 标题叠加 */}
|
||||
{!assetsLoading && (
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
template={template}
|
||||
videoRatio={videoRatio}
|
||||
ready={assetsReady}
|
||||
/>
|
||||
{/* CSS 标题实时预览层 — 与 FFmpeg ASS 渲染坐标对齐 */}
|
||||
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览成功 */}
|
||||
{hasPreview && (
|
||||
<>
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<video src={previewResult.videoUrl} controls preload="metadata" />
|
||||
{/* 素材信息 */}
|
||||
{assetsReady && assets.length > 0 && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>素材数</span>
|
||||
<span>{assets.length} 个</span>
|
||||
</div>
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>时长</span>
|
||||
<span>
|
||||
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(
|
||||
1,
|
||||
)}{" "}
|
||||
秒
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>片段数</span>
|
||||
<span>{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
* 仅包含标题文字输入 + AI 标题生成
|
||||
* 标题样式面板已迁移到 Step5(生成预览页面)
|
||||
*/
|
||||
import React from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
@@ -110,23 +110,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 样式面板始终可见,两种模式下都可调整标题展示样式 */}
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={t.updatePosition}
|
||||
onUpdateFont={t.updateFont}
|
||||
onUpdateSize={t.updateSize}
|
||||
onToggleBold={t.toggleBold}
|
||||
onToggleItalic={t.toggleItalic}
|
||||
onToggleStroke={t.toggleStroke}
|
||||
onToggleShadow={t.toggleShadow}
|
||||
onApplyPreset={t.applyPreset}
|
||||
activePreset={t.activePreset}
|
||||
titlePresets={t.titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,274 +1,82 @@
|
||||
/**
|
||||
* Step 5 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
* Step 5 生成预览组件
|
||||
* 架构改造:移除后端预览生成,改为前端实时预览
|
||||
* 左侧仅保留标题样式面板,视频在右侧 PreviewVideoPanel 实时播放
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import { PlayCircleOutlined } from "@ant-design/icons"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step5GeneratePreviewProps {
|
||||
videoRatio: string
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
items: PreviewItem[]
|
||||
selectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
overallStatus: PreviewStatus
|
||||
overallError: string
|
||||
overallProgress: number
|
||||
anyGenerating: boolean
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
/* 标题样式 */
|
||||
titleSettings: TitleSettings
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
}
|
||||
|
||||
/** 预览数量选项 */
|
||||
const PREVIEW_COUNT_OPTIONS = [
|
||||
{ value: 1, label: "1个" },
|
||||
{ value: 2, label: "2个" },
|
||||
{ value: 3, label: "3个" },
|
||||
]
|
||||
|
||||
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelectPreview,
|
||||
overallStatus,
|
||||
overallError,
|
||||
overallProgress,
|
||||
anyGenerating,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
titleSettings,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
}) => {
|
||||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
<h3>🎬 预览设置</h3>
|
||||
|
||||
{/* 预览数量选择器(仅在 idle 状态显示) */}
|
||||
{isIdle && (
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: 14, color: "#666" }}>预览数量:</span>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{PREVIEW_COUNT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(opt.value)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
|
||||
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
|
||||
color: previewCount === opt.value ? "#1677ff" : "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: previewCount === opt.value ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={previewCount}
|
||||
onChange={(val) => val && onPreviewCountChange(val)}
|
||||
style={{ width: 70 }}
|
||||
placeholder="自定义"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#999", marginLeft: 4 }}>(1~10)</span>
|
||||
</div>
|
||||
{previewCount > 1 && (
|
||||
<span style={{ fontSize: 12, color: "#999" }}>生成多个预览可对比不同剪辑效果</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 前端实时预览提示 */}
|
||||
<div
|
||||
className="xx-preview-tip"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "12px 16px",
|
||||
background: "rgba(59, 130, 246, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: "1px solid rgba(59, 130, 246, 0.15)",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: "#3b82f6" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
右侧面板直接播放素材片段,调整标题样式可实时预览效果
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 预览生成按钮(idle 状态) */}
|
||||
{isIdle && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板、素材和配音,智能生成
|
||||
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览{previewCount > 1 ? `(${previewCount}个)` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体排队中(所有都在 pending) */}
|
||||
{anyGenerating && items.every((it) => it.status === "pending") && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
className="xx-preview-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||||
margin: "0 auto 16px",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.index === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
onClick={() => {
|
||||
if (item.status === "ready") onSelectPreview(item.index)
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
overflow: "hidden",
|
||||
cursor: item.status === "ready" ? "pointer" : "default",
|
||||
opacity: item.status === "error" ? 0.6 : 1,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 轻量卡片:深色背景 + 状态指示 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
background: "#1a1a2e",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{/* 中心:预览编号 */}
|
||||
<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" && (
|
||||
<ExclamationCircleFilled style={{ fontSize: 18, color: "#ef4444" }} />
|
||||
)}
|
||||
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
fontSize: 10,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体进度条(多预览生成中) */}
|
||||
{anyGenerating && (
|
||||
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部完成提示 */}
|
||||
{overallStatus === "ready" && (
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>
|
||||
{items.filter((it) => it.status === "ready").length} 个预览生成成功
|
||||
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
|
||||
</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 标题样式面板 */}
|
||||
<TitleStylePanel
|
||||
settings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { confirmGeneration } from "@/api/generation"
|
||||
import { confirmGeneration, createPreview } from "@/api/generation"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
@@ -55,21 +55,17 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率: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
|
||||
@@ -83,7 +79,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
outputHeight = 1920
|
||||
}
|
||||
} else if (ratio.includes("x")) {
|
||||
// 分辨率格式,如 "1080x1920"
|
||||
const [wStr, hStr] = ratio.split("x")
|
||||
outputWidth = parseInt(wStr, 10) || 1080
|
||||
outputHeight = parseInt(hStr, 10) || 1920
|
||||
@@ -92,7 +87,35 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
// 获取或创建后端任务 ID
|
||||
// 预览改为前端播放后,不再有预览任务,需要在此处创建
|
||||
let taskId = props.previewTaskId
|
||||
if (!taskId) {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
voice_ids: undefined,
|
||||
title_config: props.titleSettings?.title
|
||||
? {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
taskId = previewResp.task_id
|
||||
}
|
||||
|
||||
await confirmGeneration(taskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
@@ -109,7 +132,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, clearTimer, startPolling])
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 预览素材加载 Hook
|
||||
* 根据选中的素材 ID 列表,批量获取素材详情(含 file_url、duration 等)
|
||||
* 供前端预览播放器使用
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { AxiosResponse } from "axios"
|
||||
|
||||
/** 批量获取素材详情的 API 路径 */
|
||||
const ASSETS_BATCH_URL = "/assets/batch"
|
||||
|
||||
/**
|
||||
* 通过 ID 列表批量获取素材
|
||||
* 优先使用批量接口,失败则回退为逐个获取
|
||||
*/
|
||||
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
|
||||
if (!ids.length) return []
|
||||
|
||||
try {
|
||||
// 尝试批量接口
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const response = await apiClient.post(ASSETS_BATCH_URL, { ids })
|
||||
const items: AssetItem[] = response.data?.items || response.data || []
|
||||
if (items.length > 0) return items
|
||||
} catch {
|
||||
// 批量接口不存在,回退为逐个获取
|
||||
}
|
||||
|
||||
// 回退:逐个获取
|
||||
try {
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
)
|
||||
return results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<AxiosResponse<AssetItem>> =>
|
||||
r.status === "fulfilled" && !!r.value?.data,
|
||||
)
|
||||
.map((r) => r.value.data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface UsePreviewAssetsReturn {
|
||||
/** 加载后的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 是否正在加载 */
|
||||
loading: boolean
|
||||
/** 是否已就绪(加载完成) */
|
||||
ready: boolean
|
||||
/** 手动触发重新加载 */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* usePreviewAssets — 加载选中素材的视频文件信息
|
||||
*/
|
||||
export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn {
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// 稳定化 assetIds:只有内容真正变化时才更新引用
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!stableAssetIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
}
|
||||
|
||||
const thisRequestId = ++requestIdRef.current
|
||||
setLoading(true)
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(stableAssetIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
setReady(result.length > 0)
|
||||
}
|
||||
} catch {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
}
|
||||
} finally {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return { assets, loading, ready, reload: load }
|
||||
}
|
||||
|
||||
/**
|
||||
* useStableArray — 数组内容稳定化 Hook
|
||||
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
|
||||
*/
|
||||
function useStableArray<T>(array: T[]): T[] {
|
||||
const ref = useRef<T[]>(array)
|
||||
|
||||
// 比较数组内容是否真正变化
|
||||
const hasChanged =
|
||||
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
|
||||
|
||||
if (hasChanged) {
|
||||
ref.current = array
|
||||
}
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePreviewAssets
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook
|
||||
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
|
||||
* 实现前端预览播放,替代后端 FFmpeg 渲染
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
/** 单个播放片段 */
|
||||
export interface PlaybackSegment {
|
||||
/** 素材 ID */
|
||||
assetId: string
|
||||
/** 素材视频 URL */
|
||||
videoUrl: string
|
||||
/** 片段在素材中的入点(秒) */
|
||||
startTime: number
|
||||
/** 片段在素材中的出点(秒) */
|
||||
endTime: number
|
||||
/** 片段在时间线中的顺序 */
|
||||
order: number
|
||||
}
|
||||
|
||||
/** 调度器返回 */
|
||||
export interface SegmentSchedulerState {
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean
|
||||
/** 当前播放的全局时间(秒) */
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 当前片段索引(在 segments 数组中的位置) */
|
||||
currentSegmentIndex: number
|
||||
/** 当前片段的本地播放时间 */
|
||||
segmentLocalTime: number
|
||||
/** 是否已播完 */
|
||||
isEnded: boolean
|
||||
/** 是否可以播放(至少有 1 个片段) */
|
||||
canPlay: boolean
|
||||
/** 播放 */
|
||||
play: () => void
|
||||
/** 暂停 */
|
||||
pause: () => void
|
||||
/** 切换播放/暂停 */
|
||||
togglePlayPause: () => void
|
||||
/** 跳转到全局时间 */
|
||||
seekTo: (time: number) => void
|
||||
/** 绑定到 <video> 元素 */
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据全局时间定位对应的片段和本地时间
|
||||
*/
|
||||
function findSegmentAtTime(
|
||||
segments: PlaybackSegment[],
|
||||
globalTime: number,
|
||||
): { index: number; localTime: number } {
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]
|
||||
const segDuration = seg.endTime - seg.startTime
|
||||
if (globalTime < accumulated + segDuration || i === segments.length - 1) {
|
||||
return { index: i, localTime: seg.startTime + (globalTime - accumulated) }
|
||||
}
|
||||
accumulated += segDuration
|
||||
}
|
||||
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每个片段的全局起始时间
|
||||
*/
|
||||
function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
const starts: number[] = []
|
||||
let acc = 0
|
||||
for (const seg of segments) {
|
||||
starts.push(acc)
|
||||
acc += seg.endTime - seg.startTime
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
/**
|
||||
* useSegmentScheduler — 素材片段调度器
|
||||
*/
|
||||
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
|
||||
const [isEnded, setIsEnded] = useState(false)
|
||||
const rafRef = useRef<number>(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
|
||||
// 预加载用的隐藏 video 元素
|
||||
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
// 计算时间线
|
||||
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDuration = useMemo(
|
||||
() => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0),
|
||||
[segments],
|
||||
)
|
||||
|
||||
const canPlay = segments.length > 0
|
||||
|
||||
// 当前片段信息
|
||||
const currentSegment = segments[currentSegmentIndex] || null
|
||||
const segmentLocalTime = currentSegment
|
||||
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 0
|
||||
|
||||
/** 切换到指定片段 */
|
||||
const switchToSegment = useCallback(
|
||||
(index: number, seekToLocalTime?: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video || index >= segments.length) return
|
||||
|
||||
const seg = segments[index]
|
||||
video.src = seg.videoUrl
|
||||
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
// 等待 src 设置后再设置 currentTime
|
||||
const onLoaded = () => {
|
||||
video.currentTime = localTime
|
||||
video.removeEventListener("loadedmetadata", onLoaded)
|
||||
}
|
||||
video.addEventListener("loadedmetadata", onLoaded)
|
||||
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
// 预加载下一段
|
||||
if (index + 1 < segments.length) {
|
||||
const nextSeg = segments[index + 1]
|
||||
if (!preloadVideoRef.current) {
|
||||
preloadVideoRef.current = document.createElement("video")
|
||||
preloadVideoRef.current.preload = "auto"
|
||||
}
|
||||
preloadVideoRef.current.src = nextSeg.videoUrl
|
||||
}
|
||||
},
|
||||
[segments],
|
||||
)
|
||||
|
||||
/** 播放循环 — 检测片段边界并切换 */
|
||||
const tick = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segments[currentSegmentIndex]
|
||||
if (!seg) return
|
||||
|
||||
// 检查是否到达出点(容差 0.15s)
|
||||
if (video.currentTime >= seg.endTime - 0.15) {
|
||||
const nextIndex = currentSegmentIndex + 1
|
||||
if (nextIndex < segments.length) {
|
||||
// 切到下一段
|
||||
switchToSegment(nextIndex)
|
||||
const accumulatedTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
|
||||
setCurrentTime(accumulatedTime)
|
||||
} else {
|
||||
// 播放结束
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
setIsEnded(true)
|
||||
setCurrentTime(totalDuration)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 更新全局时间
|
||||
const globalTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
|
||||
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}, [segments, currentSegmentIndex, timelineStarts, totalDuration, switchToSegment])
|
||||
|
||||
/** 播放 */
|
||||
const play = useCallback(async () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) return
|
||||
|
||||
setIsEnded(false)
|
||||
|
||||
// 如果还没设置 src(首次播放),先加载第一段
|
||||
if (!video.src || video.src === "") {
|
||||
switchToSegment(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await video.play()
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch {
|
||||
// 自动播放可能被浏览器阻止,忽略
|
||||
console.warn("[useSegmentScheduler] auto-play blocked by browser")
|
||||
}
|
||||
}, [canPlay, switchToSegment, tick])
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (isPlaying) {
|
||||
pause()
|
||||
} else {
|
||||
if (isEnded) {
|
||||
// 播放结束后再次播放,从头开始
|
||||
setIsEnded(false)
|
||||
switchToSegment(0, segments[0]?.startTime)
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
const onSeeked = () => {
|
||||
video.play().catch(() => {})
|
||||
setIsPlaying(true)
|
||||
setCurrentTime(0)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
video.removeEventListener("seeked", onSeeked)
|
||||
}
|
||||
video.addEventListener("seeked", onSeeked)
|
||||
}
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
}, [isPlaying, isEnded, pause, play, switchToSegment, segments, tick])
|
||||
|
||||
/** 跳转到指定全局时间 */
|
||||
const seekTo = useCallback(
|
||||
(time: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) return
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
|
||||
|
||||
isSeekingRef.current = true
|
||||
|
||||
// 如果片段变了,需要切换 src
|
||||
if (index !== currentSegmentIndex) {
|
||||
switchToSegment(index, localTime)
|
||||
// switchToSegment 会设置 src 并在 loadedmetadata 后设置 currentTime
|
||||
// 所以这里不需要再设置
|
||||
} else {
|
||||
video.currentTime = localTime
|
||||
}
|
||||
|
||||
setCurrentTime(clampedTime)
|
||||
setCurrentSegmentIndex(index)
|
||||
setIsEnded(false)
|
||||
|
||||
// 延迟恢复 tick 检测
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false
|
||||
}, 200)
|
||||
},
|
||||
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
|
||||
)
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (preloadVideoRef.current) {
|
||||
preloadVideoRef.current.src = ""
|
||||
preloadVideoRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 片段列表变化时重置
|
||||
useEffect(() => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setCurrentSegmentIndex(0)
|
||||
setIsEnded(false)
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
video.src = ""
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
segmentLocalTime,
|
||||
isEnded,
|
||||
canPlay,
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRef,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSegmentScheduler
|
||||
@@ -1,476 +0,0 @@
|
||||
/**
|
||||
* 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 => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep5PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
/** 标题设置(传递给后端,让预览视频包含标题) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 单个预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
/** 单个预览项的完整状态(用于多预览) */
|
||||
export interface PreviewItem {
|
||||
index: number
|
||||
status: PreviewStatus
|
||||
result: PreviewResult | null
|
||||
error: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/** 初始单项状态 */
|
||||
const createInitialItem = (index: number): PreviewItem => ({
|
||||
index,
|
||||
status: "idle",
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
export function useStep5Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
titleSettings,
|
||||
}: UseStep5PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 多预览状态 ── */
|
||||
const [items, setItems] = useState<PreviewItem[]>(() =>
|
||||
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
|
||||
const taskIdsRef = useRef<Map<number, string>>(new Map())
|
||||
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback((index?: number) => {
|
||||
if (index !== undefined) {
|
||||
const timer = pollTimersRef.current.get(index)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pollTimersRef.current.delete(index)
|
||||
}
|
||||
} else {
|
||||
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
|
||||
pollTimersRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 同步 previewCount 变化(增减项)
|
||||
useEffect(() => {
|
||||
setItems((prev) => {
|
||||
if (prev.length === previewCount) return prev
|
||||
if (prev.length > previewCount) return prev.slice(0, previewCount)
|
||||
return [
|
||||
...prev,
|
||||
...Array.from({ length: previewCount - prev.length }, (_, i) =>
|
||||
createInitialItem(prev.length + i),
|
||||
),
|
||||
]
|
||||
})
|
||||
// 如果 selectedIndex 超出范围,重置
|
||||
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
|
||||
}, [previewCount])
|
||||
|
||||
/* ── 参数变化时重置所有预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
titleSettings?.title || "",
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
prevDepsRef.current.titleSettings,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||||
taskIdsRef.current.clear()
|
||||
clearPollTimer()
|
||||
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [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)
|
||||
|
||||
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: 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))
|
||||
},
|
||||
[selectedTemplate],
|
||||
)
|
||||
|
||||
/** 生成所有预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的所有轮询
|
||||
clearPollTimer()
|
||||
taskIdsRef.current.clear()
|
||||
|
||||
// 初始化所有项为 pending
|
||||
setItems(
|
||||
Array.from({ length: previewCount }, (_, i) => ({
|
||||
index: i,
|
||||
status: "pending" as PreviewStatus,
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})),
|
||||
)
|
||||
setSelectedIndex(0)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
|
||||
// 并发创建所有预览任务(Promise.all 并行请求,减少串行等待)
|
||||
const createTasks = Array.from({ length: previewCount }, async (_, i) => {
|
||||
try {
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
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
|
||||
|
||||
taskIdsRef.current.set(i, response.task_id)
|
||||
pollPreviewStatus(i, response.task_id)
|
||||
} catch (e) {
|
||||
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(createTasks)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成所有预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否所有预览都已完成 */
|
||||
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
|
||||
/** 是否至少有一个预览已完成 */
|
||||
const anyReady = items.some((it) => it.status === "ready")
|
||||
/** 是否有任一正在生成中 */
|
||||
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
|
||||
|
||||
/** 当前选中的预览结果 */
|
||||
const selectedResult = items[selectedIndex]?.result ?? null
|
||||
|
||||
/** 综合状态(兼容旧逻辑) */
|
||||
const previewStatus: PreviewStatus = useMemo(() => {
|
||||
if (items.every((it) => it.status === "idle")) return "idle"
|
||||
if (items.some((it) => it.status === "pending" || it.status === "generating"))
|
||||
return "generating"
|
||||
if (allReady) return "ready"
|
||||
if (items.every((it) => it.status === "error")) return "error"
|
||||
// 部分完成部分出错
|
||||
if (anyReady) return "ready"
|
||||
return "error"
|
||||
}, [items, allReady, anyReady])
|
||||
|
||||
/** 综合进度(取平均) */
|
||||
const progress = useMemo(() => {
|
||||
if (items.length === 0) return 0
|
||||
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
|
||||
}, [items])
|
||||
|
||||
/** 综合错误信息 */
|
||||
const previewError = useMemo(() => {
|
||||
const errorItems = items.filter((it) => it.status === "error" && it.error)
|
||||
if (errorItems.length === 0) return ""
|
||||
if (errorItems.length === 1) return errorItems[0].error
|
||||
return `${errorItems.length} 个预览生成失败`
|
||||
}, [items])
|
||||
|
||||
const canProceed = anyReady
|
||||
|
||||
/** 当前选中预览的 taskId(用于确认生成时复用预览产物) */
|
||||
const selectedTaskId = selectedResult?.taskId ?? ""
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 多预览状态
|
||||
items,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
previewCount,
|
||||
// 综合状态
|
||||
previewStatus,
|
||||
previewResult: selectedResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
allReady,
|
||||
anyReady,
|
||||
anyGenerating,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
// 确认生成复用预览产物
|
||||
selectedTaskId,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep5Preview
|
||||
@@ -109,26 +109,30 @@ export function useStep6Cover({
|
||||
console.error("[Step6] 智能封面生成失败:", err)
|
||||
|
||||
// 提取详细错误信息
|
||||
let errorMsg = "封面生成失败"
|
||||
const e = err as {
|
||||
response?: { data?: { detail?: string; message?: string }; status?: number }
|
||||
request?: unknown
|
||||
message?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyErr = err as any
|
||||
// 如果 API 拦截器已经弹出了后端返回的具体错误信息,这里跳过重复 toast
|
||||
if (anyErr?.__msgShown) {
|
||||
// 拦截器已处理,不再重复弹出
|
||||
} else {
|
||||
let errorMsg = "封面生成失败"
|
||||
const e = anyErr 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)
|
||||
}
|
||||
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 {
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* V24: previewReady 改为前端素材加载状态
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -14,7 +16,7 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** Step4 是否已生成预览 */
|
||||
/** 预览是否就绪(前端素材已加载) */
|
||||
previewReady: boolean
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
return
|
||||
}
|
||||
if (currentStep === 5 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
message.warning("请先选择素材以预览效果")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
|
||||
@@ -31,14 +31,27 @@ vi.mock("react-router-dom", async () => {
|
||||
})
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
useAuthStore: Object.assign(
|
||||
(selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -47,7 +47,8 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
@@ -98,6 +98,7 @@ def _finalize_render_success(
|
||||
output_path: Path,
|
||||
engine: str,
|
||||
thumbnail_url: str = "",
|
||||
cover_candidates: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
@@ -163,6 +164,19 @@ def _finalize_render_success(
|
||||
clip_count=len(rendered_clip_ids),
|
||||
)
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
# 回写封面 URL 到 GenerationTask,供封面生成接口读取
|
||||
if cover_candidates:
|
||||
first_cover = cover_candidates[0].get("image_url") or cover_candidates[0].get("url") or ""
|
||||
if first_cover:
|
||||
gen_task.cover_url = first_cover
|
||||
logger.info(
|
||||
"预览渲染完成,回写 cover_url: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
first_cover[:80],
|
||||
)
|
||||
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
logger.info(
|
||||
@@ -288,6 +302,7 @@ def _render_with_unified(
|
||||
output_path=output_path,
|
||||
engine="unified",
|
||||
thumbnail_url=thumbnail_url,
|
||||
cover_candidates=result.cover_candidates,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -37,8 +36,6 @@ from packages.domain.bgm_utils import merge_bgm_config
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
OUTPUT_FPS = 25.0
|
||||
OUTPUT_DURATION_SECONDS = 5.0
|
||||
GENERATED_FILES_DIR = Path(os.getenv("GENERATED_FILES_DIR", "/app/generated"))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -155,7 +152,6 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
from video_processing.oss_helpers import (
|
||||
download_asset,
|
||||
get_signed_download_url,
|
||||
@@ -330,61 +326,6 @@ def _build_plan_and_clips_from_task(
|
||||
return plan, clips, asset_path_map
|
||||
|
||||
|
||||
def _create_fallback_clip(output_path: Path, title: str) -> None:
|
||||
"""创建 fallback 视频(无素材时)"""
|
||||
safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80]
|
||||
run_ffmpeg(
|
||||
[
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
|
||||
"-vf",
|
||||
f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _mux_audio_track(video_path: Path, audio_path: str, output_path: Path) -> None:
|
||||
"""将音频轨混入已渲染的视频(后处理步骤)。
|
||||
|
||||
使用 FFmpeg 将视频和音频合并,视频时长为准,音频不足则循环,
|
||||
音频过长则截断。
|
||||
"""
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
audio_path,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-shortest",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
run_ffmpeg(command)
|
||||
|
||||
|
||||
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
"""下载配音文件。
|
||||
|
||||
@@ -428,105 +369,6 @@ def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
return download_asset(storage_key, local_path)
|
||||
|
||||
|
||||
def _prepare_bgm_track(
|
||||
*,
|
||||
bgm_config: dict,
|
||||
temp_path: Path,
|
||||
task_id: str = "",
|
||||
) -> str | None:
|
||||
"""准备 BGM 音频文件(下载到本地).
|
||||
|
||||
支持 3 种来源(按优先级):
|
||||
1. audio_url — 外部直链 URL(最高优先级)
|
||||
2. asset_id — 素材库中的音频素材
|
||||
3. preset_id — 预设 BGM 库
|
||||
|
||||
Returns:
|
||||
BGM 本地文件路径,准备失败返回 None
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
audio_url = bgm_config.get("audio_url", "") or ""
|
||||
asset_id = bgm_config.get("asset_id", "") or ""
|
||||
preset_id = bgm_config.get("preset_id", "") or ""
|
||||
|
||||
bgm_file = temp_path / f"bgm_{task_id or 'track'}.mp3"
|
||||
|
||||
# 优先级1:外部直链 URL
|
||||
if audio_url:
|
||||
try:
|
||||
parsed = urlparse(audio_url)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
|
||||
safe_download_file(
|
||||
audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] URL下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级2:素材库素材
|
||||
if asset_id:
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model and (model.storage_key or model.file_url):
|
||||
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
|
||||
storage_key = model.storage_key or model.file_url
|
||||
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
|
||||
ok = download_asset(storage_key, bgm_file)
|
||||
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 素材库下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级3:预设 BGM 库
|
||||
if preset_id:
|
||||
try:
|
||||
from packages.domain.preset_bgm import get_preset_bgm
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset and preset.audio_url:
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
|
||||
safe_download_file(
|
||||
preset.audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_preset_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 预设库下载失败: %s", task_id, e)
|
||||
|
||||
# 所有来源都失败
|
||||
logger.warning("[task_id=%s] [BGM] 所有来源都无法获取BGM,跳过", task_id)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_url_accessible(
|
||||
url: str,
|
||||
timeout: float = 10.0,
|
||||
|
||||
@@ -101,6 +101,13 @@ class SQLAlchemyAssetRepository:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def find_by_ids(self, asset_ids: list[str]) -> list[Asset]:
|
||||
"""批量查询素材(单次 SQL IN 查询,避免 N+1)。"""
|
||||
if not asset_ids:
|
||||
return []
|
||||
models = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def get(self, asset_id: str) -> Asset | None:
|
||||
return self.find_by_id(asset_id)
|
||||
|
||||
|
||||
@@ -14,11 +14,7 @@ import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
UnifiedRenderService,
|
||||
)
|
||||
from worker_app.tasks.generation import (
|
||||
_build_plan_and_clips_from_task,
|
||||
_create_fallback_clip,
|
||||
_mux_audio_track,
|
||||
)
|
||||
from worker_app.tasks.generation import _build_plan_and_clips_from_task
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("ffmpeg"),
|
||||
@@ -62,61 +58,6 @@ def _generate_test_audio(path: Path, duration: float = 5.0) -> None:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
# ── 测试 _create_fallback_clip ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFallbackClip:
|
||||
"""测试 fallback 视频生成。"""
|
||||
|
||||
def test_fallback_clip_creates_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "fallback.mp4"
|
||||
_create_fallback_clip(output, "Test Fallback")
|
||||
|
||||
assert output.exists()
|
||||
assert output.stat().st_size > 0
|
||||
|
||||
|
||||
# ── 测试 _mux_audio_track ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMuxAudioTrack:
|
||||
"""测试视频+音频混合。"""
|
||||
|
||||
def test_mux_audio_into_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video_path = Path(tmpdir) / "video.mp4"
|
||||
audio_path = Path(tmpdir) / "audio.aac"
|
||||
output_path = Path(tmpdir) / "output.mp4"
|
||||
|
||||
_generate_test_video(video_path, duration=3.0)
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
_mux_audio_track(video_path, str(audio_path), output_path)
|
||||
|
||||
assert output_path.exists()
|
||||
assert output_path.stat().st_size > 0
|
||||
|
||||
# 验证输出文件包含音频轨
|
||||
probe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
|
||||
# 如果有音频流,输出非空
|
||||
assert result.stdout.strip() != "" or result.returncode == 0
|
||||
|
||||
|
||||
# ── 测试 PlanGenerator → UnifiedRenderService 全链路 ─────────────────────────
|
||||
|
||||
|
||||
class TestFullPipeline:
|
||||
"""验证从虚拟 plan 构建到渲染输出的完整流程。"""
|
||||
|
||||
@@ -175,12 +116,26 @@ class TestFullPipeline:
|
||||
)
|
||||
render_result = service.render()
|
||||
|
||||
# 混音
|
||||
# 混音 - 直接用 ffmpeg(_mux_audio_track 已被清理)
|
||||
audio_path = work_dir / "voice.aac"
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
final_path = work_dir / "final.mp4"
|
||||
_mux_audio_track(render_result.output_path, str(audio_path), final_path)
|
||||
mux_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(render_result.output_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(final_path),
|
||||
]
|
||||
subprocess.run(mux_cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
assert final_path.exists()
|
||||
assert final_path.stat().st_size > 0
|
||||
|
||||
@@ -108,21 +108,30 @@ def _run_with_fakes(
|
||||
session = MagicMock()
|
||||
session_maker = MagicMock(return_value=session)
|
||||
|
||||
captured = {"upload_calls": [], "session": session_maker()}
|
||||
captured: dict = {"upload_calls": []}
|
||||
|
||||
def _tracking_upload(local_path, storage_key):
|
||||
captured["upload_calls"].append((local_path, storage_key))
|
||||
return upload_fn(local_path, storage_key)
|
||||
|
||||
# Wrap session_maker to capture the session INSIDE the patch context.
|
||||
# This avoids stale PromiseProxy cache issues in full-suite runs.
|
||||
_created_sessions: list = []
|
||||
_orig_sm = session_maker
|
||||
|
||||
def _tracking_sm(*a, **kw):
|
||||
s = _orig_sm(*a, **kw)
|
||||
_created_sessions.append(s)
|
||||
return s
|
||||
|
||||
bound_task = _make_bound_task()
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
import worker_app.db as _db_mod
|
||||
|
||||
with patch.object(_db_mod, "SessionLocal", _tracking_sm):
|
||||
with patch(
|
||||
"worker_app.db.SessionLocal",
|
||||
session_maker,
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
with patch(
|
||||
"video_processing.oss_helpers.upload_to_oss",
|
||||
@@ -134,6 +143,7 @@ def _run_with_fakes(
|
||||
):
|
||||
result = _call_task(batch_download_videos, bound_task, [v.id for v in videos], user_id)
|
||||
|
||||
captured["session"] = _created_sessions[0] if _created_sessions else None
|
||||
captured["result"] = result
|
||||
return captured
|
||||
|
||||
@@ -244,19 +254,66 @@ def test_batch_download_single_video():
|
||||
|
||||
|
||||
def test_batch_download_session_closed():
|
||||
"""DB session is always closed (via finally block)."""
|
||||
"""DB session is always closed (via finally block).
|
||||
|
||||
Patches the function's own globals to inject mock SessionLocal,
|
||||
bypassing any import caching issues in the full suite.
|
||||
"""
|
||||
import sys
|
||||
|
||||
import worker_app.db as _db_mod
|
||||
|
||||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||||
|
||||
videos = [_FakeVideo("v1", "v.mp4")]
|
||||
repo = _FakeGeneratedVideoRepository(videos)
|
||||
|
||||
session = MagicMock()
|
||||
session_maker = MagicMock(return_value=session)
|
||||
mock_session_factory = MagicMock(return_value=session)
|
||||
|
||||
_run_with_fakes(videos, session_maker=session_maker)
|
||||
def _noop_download(url, dest):
|
||||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(dest).write_bytes(b"fake video data")
|
||||
|
||||
bound_task = _make_bound_task()
|
||||
|
||||
# Get the raw function to patch its globals
|
||||
raw_fn = _get_raw_task_fn(batch_download_videos)
|
||||
|
||||
# Patch SessionLocal in ALL possible module locations
|
||||
_db_mod.SessionLocal = mock_session_factory
|
||||
if "worker_app.db" in sys.modules:
|
||||
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
|
||||
|
||||
# Also patch in the function's own globals if it has a reference there
|
||||
if "SessionLocal" in raw_fn.__globals__:
|
||||
raw_fn.__globals__["SessionLocal"] = mock_session_factory
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
with patch("video_processing.oss_helpers.upload_to_oss", return_value="https://oss.example.com/zip"):
|
||||
with patch(
|
||||
"apps.worker.worker_app.tasks.batch_download._download_video_to_file",
|
||||
_noop_download,
|
||||
):
|
||||
raw_fn(bound_task, ["v1"], "user_1")
|
||||
finally:
|
||||
pass # Don't restore - other tests handle their own patches
|
||||
|
||||
# Diagnostic: check if our mock factory was actually called
|
||||
assert mock_session_factory.called, "SessionLocal mock was never called! " f"raw_fn={raw_fn}, type={type(raw_fn)}"
|
||||
session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_batch_download_closes_session_on_error():
|
||||
"""Session is closed even when get_by_ids raises."""
|
||||
import sys
|
||||
|
||||
import worker_app.db as _db_mod
|
||||
|
||||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||||
|
||||
class _ExplodingRepo:
|
||||
@@ -264,17 +321,25 @@ def test_batch_download_closes_session_on_error():
|
||||
raise RuntimeError("db down")
|
||||
|
||||
session = MagicMock()
|
||||
session_maker = MagicMock(return_value=session)
|
||||
mock_session_factory = MagicMock(return_value=session)
|
||||
bound_task = _make_bound_task()
|
||||
|
||||
raw_fn = _get_raw_task_fn(batch_download_videos)
|
||||
|
||||
_db_mod.SessionLocal = mock_session_factory
|
||||
if "worker_app.db" in sys.modules:
|
||||
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
|
||||
if "SessionLocal" in raw_fn.__globals__:
|
||||
raw_fn.__globals__["SessionLocal"] = mock_session_factory
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=_ExplodingRepo(),
|
||||
):
|
||||
with patch("worker_app.db.SessionLocal", session_maker):
|
||||
with pytest.raises(RuntimeError, match="db down"):
|
||||
_call_task(batch_download_videos, bound_task, ["v1"], "u")
|
||||
with pytest.raises(RuntimeError, match="db down"):
|
||||
raw_fn(bound_task, ["v1"], "u")
|
||||
|
||||
assert mock_session_factory.called, "SessionLocal mock was never called!"
|
||||
session.close.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
"""片段管理路由 clips.py 增量覆盖率测试.
|
||||
|
||||
覆盖 PR fix/clips-api-response-structure 新增代码:
|
||||
- _clip_to_response: 枚举转换、日期格式化、asset_url 参数
|
||||
- _build_asset_url_map: 批量素材 URL 解析(空列表/异常/正常路径)
|
||||
- 路由层 asset_repo 注入与 URL 拼接逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 常量与工厂
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEST_TEMPLATE_ID = "tmpl-test-001"
|
||||
TEST_PLAN_ID = "plan-draft-001"
|
||||
TEST_USER_ID = "user-001"
|
||||
|
||||
|
||||
def _auth_user():
|
||||
u = MagicMock()
|
||||
u.user.id = TEST_USER_ID
|
||||
u.user_id = TEST_USER_ID
|
||||
return u
|
||||
|
||||
|
||||
def _clip(**overrides):
|
||||
"""构造 mock clip,支持 Enum 类型字段"""
|
||||
c = MagicMock()
|
||||
c.id = overrides.get("id", "clip-001")
|
||||
c.plan_id = overrides.get("plan_id", TEST_PLAN_ID)
|
||||
c.clip_type = overrides.get("clip_type", "video")
|
||||
c.order = overrides.get("order", 0)
|
||||
c.duration = overrides.get("duration", 10.0)
|
||||
c.start_time = overrides.get("start_time", 0.0)
|
||||
c.text_content = overrides.get("text_content", "")
|
||||
c.transition_effect = overrides.get("transition_effect", "cut")
|
||||
c.transition_duration = overrides.get("transition_duration", 0.0)
|
||||
c.playback_speed = overrides.get("playback_speed", 1.0)
|
||||
c.asset_id = overrides.get("asset_id", "")
|
||||
c.status = overrides.get("status", "ready")
|
||||
c.template_clip_config_id = overrides.get("template_clip_config_id", "")
|
||||
c.config = overrides.get("config", {})
|
||||
c.created_at = overrides.get("created_at", None)
|
||||
c.updated_at = overrides.get("updated_at", None)
|
||||
return c
|
||||
|
||||
|
||||
def _services(plan_svc_overrides=None):
|
||||
tpl = MagicMock()
|
||||
plan = MagicMock()
|
||||
if plan_svc_overrides:
|
||||
for k, v in plan_svc_overrides.items():
|
||||
setattr(plan, k, v)
|
||||
return tpl, plan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 单元测试: _clip_to_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClipToResponse:
|
||||
"""_clip_to_response 纯函数测试 — 覆盖行 53-80"""
|
||||
|
||||
def test_basic_fields(self):
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip(id="c1", order=3, duration=5.5, text_content="hello")
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.id == "c1"
|
||||
assert resp.order == 3
|
||||
assert resp.duration == 5.5
|
||||
assert resp.text_content == "hello"
|
||||
assert resp.asset_url is None
|
||||
|
||||
def test_enum_clip_type(self):
|
||||
"""Enum 值应被 .value 解包"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
class ClipType(str, Enum):
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
|
||||
c = _clip(clip_type=ClipType.VIDEO)
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.clip_type == "video"
|
||||
|
||||
def test_plain_string_clip_type(self):
|
||||
"""非 Enum 字符串直接用 str()"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip(clip_type="main")
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.clip_type == "main"
|
||||
|
||||
def test_enum_transition_effect(self):
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
class Transition(str, Enum):
|
||||
FADE = "fade"
|
||||
|
||||
c = _clip(transition_effect=Transition.FADE)
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.transition_effect == "fade"
|
||||
|
||||
def test_default_transition_when_none(self):
|
||||
"""transition_effect 缺失时默认 cut"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip()
|
||||
del c.transition_effect # 触发 getattr default
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.transition_effect == "cut"
|
||||
|
||||
def test_asset_url_passed(self):
|
||||
"""asset_url 参数应透传到响应"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip(asset_id="a1")
|
||||
resp = _clip_to_response(c, asset_url="https://signed-url.example.com/video.mp4")
|
||||
assert resp.asset_url == "https://signed-url.example.com/video.mp4"
|
||||
|
||||
def test_asset_url_none_by_default(self):
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip()
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.asset_url is None
|
||||
|
||||
def test_datetime_isoformat(self):
|
||||
"""datetime 对象应被 isoformat()"""
|
||||
from datetime import datetime
|
||||
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
dt = datetime(2026, 8, 17, 12, 0, 0)
|
||||
c = _clip(created_at=dt, updated_at=dt)
|
||||
resp = _clip_to_response(c)
|
||||
assert "2026-08-17" in resp.created_at
|
||||
assert "2026-08-17" in resp.updated_at
|
||||
|
||||
def test_none_datetime_empty_string(self):
|
||||
"""None 日期应格式化为空字符串"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip(created_at=None, updated_at=None)
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.created_at == ""
|
||||
assert resp.updated_at == ""
|
||||
|
||||
def test_string_datetime_passthrough(self):
|
||||
"""已经是字符串的日期直接 str()"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip(created_at="2026-08-17T00:00:00")
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.created_at == "2026-08-17T00:00:00"
|
||||
|
||||
def test_none_defaults_for_optional_fields(self):
|
||||
"""None/缺失字段的默认值"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip(asset_id=None, status=None, template_clip_config_id=None)
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.asset_id == ""
|
||||
assert resp.status == "pending"
|
||||
assert resp.template_clip_config_id == ""
|
||||
|
||||
def test_zero_duration_fallback(self):
|
||||
"""duration=0 → playback_speed 默认 1.0"""
|
||||
from app.api.routes.templates_editor.clips import _clip_to_response
|
||||
|
||||
c = _clip(playback_speed=None)
|
||||
resp = _clip_to_response(c)
|
||||
assert resp.playback_speed == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 单元测试: _build_asset_url_map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildAssetUrlMap:
|
||||
"""_build_asset_url_map 测试 — 覆盖行 93-118"""
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空 asset_ids 直接返回空 dict"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
repo = MagicMock()
|
||||
result = _build_asset_url_map([], repo)
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_storage_service_failure(self, mock_get_storage):
|
||||
"""存储服务获取失败时返回全 None"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
mock_get_storage.side_effect = RuntimeError("storage unavailable")
|
||||
repo = MagicMock()
|
||||
result = _build_asset_url_map(["a1", "a2"], repo)
|
||||
assert result == {"a1": None, "a2": None}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_asset_not_found(self, mock_get_storage):
|
||||
"""asset_id 找不到对应素材 → None"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
storage = MagicMock()
|
||||
mock_get_storage.return_value = storage
|
||||
repo = MagicMock()
|
||||
repo.find_by_ids.return_value = []
|
||||
|
||||
result = _build_asset_url_map(["missing-id"], repo)
|
||||
assert result == {"missing-id": None}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_no_storage_key(self, mock_get_storage):
|
||||
"""素材没有 storage_key → None"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
storage = MagicMock()
|
||||
mock_get_storage.return_value = storage
|
||||
repo = MagicMock()
|
||||
asset = MagicMock()
|
||||
asset.id = "a1"
|
||||
asset.storage_key = ""
|
||||
repo.find_by_ids.return_value = [asset]
|
||||
|
||||
result = _build_asset_url_map(["a1"], repo)
|
||||
assert result == {"a1": None}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_successful_url_generation(self, mock_get_storage):
|
||||
"""正常路径:返回签名 URL"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://cdn.example.com/signed.mp4"
|
||||
mock_get_storage.return_value = storage
|
||||
repo = MagicMock()
|
||||
asset = MagicMock()
|
||||
asset.id = "a1"
|
||||
asset.storage_key = "videos/test.mp4"
|
||||
repo.find_by_ids.return_value = [asset]
|
||||
|
||||
result = _build_asset_url_map(["a1"], repo)
|
||||
assert result == {"a1": "https://cdn.example.com/signed.mp4"}
|
||||
storage.get_download_url.assert_called_once_with("videos/test.mp4", expires_seconds=3600)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_exception_during_url_generation(self, mock_get_storage):
|
||||
"""单个 asset 生成 URL 异常 → None,不影响其他"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.side_effect = [Exception("boom"), "https://ok.com/v2"]
|
||||
mock_get_storage.return_value = storage
|
||||
|
||||
repo = MagicMock()
|
||||
asset1 = MagicMock()
|
||||
asset1.id = "a1"
|
||||
asset1.storage_key = "v1.mp4"
|
||||
asset2 = MagicMock()
|
||||
asset2.id = "a2"
|
||||
asset2.storage_key = "v2.mp4"
|
||||
repo.find_by_ids.return_value = [asset1, asset2]
|
||||
|
||||
result = _build_asset_url_map(["a1", "a2"], repo)
|
||||
assert result["a1"] is None
|
||||
assert result["a2"] == "https://ok.com/v2"
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_skip_empty_asset_id(self, mock_get_storage):
|
||||
"""空字符串 asset_id 被跳过"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
storage = MagicMock()
|
||||
mock_get_storage.return_value = storage
|
||||
repo = MagicMock()
|
||||
|
||||
result = _build_asset_url_map(["", "a1"], repo)
|
||||
# "" not in result because it's skipped by `if not aid: continue`
|
||||
assert "" not in result
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_multiple_assets_mixed(self, mock_get_storage):
|
||||
"""混合场景:正常+异常+缺失"""
|
||||
from app.api.routes.templates_editor.clips import _build_asset_url_map
|
||||
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://cdn.com/ok.mp4"
|
||||
mock_get_storage.return_value = storage
|
||||
|
||||
repo = MagicMock()
|
||||
good_asset = MagicMock()
|
||||
good_asset.id = "a1"
|
||||
good_asset.storage_key = "good.mp4"
|
||||
# a1=good, a2=not found, a3=good
|
||||
good_asset2 = MagicMock()
|
||||
good_asset2.id = "a3"
|
||||
good_asset2.storage_key = "good.mp4"
|
||||
repo.find_by_ids.return_value = [good_asset, good_asset2]
|
||||
|
||||
result = _build_asset_url_map(["a1", "a2", "a3"], repo)
|
||||
assert result["a1"] == "https://cdn.com/ok.mp4"
|
||||
assert result["a2"] is None
|
||||
assert result["a3"] == "https://cdn.com/ok.mp4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 集成测试: 路由层 asset_repo 注入
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClipRoutesAssetIntegration:
|
||||
"""路由层测试 — 覆盖 asset_url 在 list/detail/split/merge 中的拼接逻辑"""
|
||||
|
||||
def _create_app(self, plan_svc_config=None):
|
||||
from app.api.routes import templates_editor as editor_module
|
||||
from app.dependencies import get_asset_repository
|
||||
|
||||
mock_clip_1 = _clip(id="c1", asset_id="asset-001")
|
||||
mock_clip_2 = _clip(id="c2", asset_id="")
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.list_clips.return_value = [mock_clip_1, mock_clip_2]
|
||||
mock_plan_svc.count_clips.return_value = 2
|
||||
mock_plan_svc.get_clip.return_value = mock_clip_1
|
||||
mock_plan_svc.create_clip.return_value = _clip(id="c-new", asset_id="")
|
||||
mock_plan_svc.update_clip.return_value = _clip(id="c1", duration=15.0)
|
||||
mock_plan_svc.delete_clip.return_value = True
|
||||
mock_plan_svc.split_clip.return_value = {
|
||||
"left_clip": _clip(id="c-left", asset_id="asset-L"),
|
||||
"right_clip": _clip(id="c-right", asset_id="asset-R"),
|
||||
}
|
||||
mock_plan_svc.merge_clips.return_value = _clip(id="c-merged", asset_id="asset-M")
|
||||
|
||||
if plan_svc_config:
|
||||
for k, v in plan_svc_config.items():
|
||||
setattr(mock_plan_svc, k, v)
|
||||
|
||||
def _deps():
|
||||
return mock_tpl_svc, mock_plan_svc
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
editor_module.router,
|
||||
prefix="/api/v1/templates/{template_id}/editor",
|
||||
)
|
||||
app.dependency_overrides[editor_module.get_current_user] = _auth_user
|
||||
app.dependency_overrides[editor_module.get_draft_plan_id] = lambda: TEST_PLAN_ID
|
||||
app.dependency_overrides[editor_module.get_editor_services] = _deps
|
||||
app.dependency_overrides[get_asset_repository] = lambda: mock_asset_repo
|
||||
|
||||
return TestClient(app), mock_plan_svc, mock_asset_repo
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_list_clips_includes_asset_urls(self, mock_get_storage):
|
||||
"""GET /clips 应为有 asset_id 的片段返回签名 URL"""
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://cdn.com/c1.mp4"
|
||||
mock_get_storage.return_value = storage
|
||||
|
||||
client, _, asset_repo = self._create_app()
|
||||
asset = MagicMock()
|
||||
asset.id = "asset-001"
|
||||
asset.storage_key = "videos/c1.mp4"
|
||||
asset_repo.find_by_ids.return_value = [asset]
|
||||
|
||||
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
items = data["items"]
|
||||
assert len(items) == 2
|
||||
# c1 has asset_id → should have url
|
||||
assert items[0]["asset_url"] == "https://cdn.com/c1.mp4"
|
||||
# c2 has empty asset_id → None
|
||||
assert items[1]["asset_url"] is None
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_get_clip_detail_with_asset_url(self, mock_get_storage):
|
||||
"""GET /clips/{clip_id} 应返回素材签名 URL"""
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://cdn.com/detail.mp4"
|
||||
mock_get_storage.return_value = storage
|
||||
|
||||
client, _, asset_repo = self._create_app()
|
||||
asset = MagicMock()
|
||||
asset.id = "asset-001"
|
||||
asset.storage_key = "videos/detail.mp4"
|
||||
asset_repo.find_by_ids.return_value = [asset]
|
||||
|
||||
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["asset_url"] == "https://cdn.com/detail.mp4"
|
||||
|
||||
def test_get_clip_detail_no_asset(self):
|
||||
"""片段没有 asset_id 时不应调用 URL 解析"""
|
||||
client, plan_svc, asset_repo = self._create_app()
|
||||
# 返回没有 asset_id 的片段
|
||||
plan_svc.get_clip.return_value = _clip(id="c-no-asset", asset_id="")
|
||||
|
||||
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/c-no-asset")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["asset_url"] is None
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_split_clip_returns_asset_urls(self, mock_get_storage):
|
||||
"""POST /clips/{clip_id}/split 返回的左右片段应带签名 URL"""
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.side_effect = ["https://cdn.com/L.mp4", "https://cdn.com/R.mp4"]
|
||||
mock_get_storage.return_value = storage
|
||||
|
||||
client, _, asset_repo = self._create_app()
|
||||
asset_l = MagicMock()
|
||||
asset_l.storage_key = "videos/L.mp4"
|
||||
asset_r = MagicMock()
|
||||
asset_r.storage_key = "videos/R.mp4"
|
||||
asset_l.id = "asset-L"
|
||||
asset_r.id = "asset-R"
|
||||
asset_repo.find_by_ids.return_value = [asset_l, asset_r]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001/split",
|
||||
json={"split_time": 5.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["left_clip"]["asset_url"] == "https://cdn.com/L.mp4"
|
||||
assert data["right_clip"]["asset_url"] == "https://cdn.com/R.mp4"
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_merge_clips_returns_asset_url(self, mock_get_storage):
|
||||
"""POST /clips/merge 返回的合并片段应带签名 URL"""
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://cdn.com/M.mp4"
|
||||
mock_get_storage.return_value = storage
|
||||
|
||||
client, _, asset_repo = self._create_app()
|
||||
asset = MagicMock()
|
||||
asset.id = "asset-M"
|
||||
asset.storage_key = "videos/M.mp4"
|
||||
asset_repo.find_by_ids.return_value = [asset]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
|
||||
json={"clip_ids": ["c1", "c2"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["merged_clip"]["asset_url"] == "https://cdn.com/M.mp4"
|
||||
assert data["deleted_clip_ids"] == ["c1", "c2"]
|
||||
|
||||
def test_merge_clips_not_found(self):
|
||||
"""merge 时某片段不存在应返回 404"""
|
||||
client, plan_svc, _ = self._create_app()
|
||||
plan_svc.get_clip.return_value = None
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
|
||||
json={"clip_ids": ["nonexistent-1", "nonexistent-2"]},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_clip_success(self):
|
||||
"""DELETE /clips/{clip_id} 成功返回 204"""
|
||||
client, _, _ = self._create_app()
|
||||
resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_clip_not_found(self):
|
||||
"""DELETE 片段不存在返回 404"""
|
||||
client, plan_svc, _ = self._create_app()
|
||||
plan_svc.delete_clip.return_value = False
|
||||
resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/bad-id")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Tests for cover_url backfill to GenerationTask.
|
||||
|
||||
Verifies _finalize_render_success correctly writes cover_url
|
||||
from cover_candidates to gen_task.cover_url.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add worker app to sys.path
|
||||
_WORKER_ROOT = Path(__file__).resolve().parents[2] / "apps" / "worker"
|
||||
if str(_WORKER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKER_ROOT))
|
||||
|
||||
|
||||
class FakeGenTask:
|
||||
"""Simple stand-in for GenerationTask that tracks attribute assignment."""
|
||||
|
||||
def __init__(self):
|
||||
object.__setattr__(self, "_assigned", {})
|
||||
self.id = "task-1"
|
||||
self.status = MagicMock()
|
||||
self.status.value = "running"
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if not name.startswith("_"):
|
||||
self._assigned[name] = value
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def append_log(self, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def _make_plan():
|
||||
plan = MagicMock()
|
||||
plan.project_id = "proj-1"
|
||||
plan.created_by_user_id = "user-1"
|
||||
plan.config = {"batch_id": "batch-1", "mode": "edit_plan", "title": {"text": "test"}}
|
||||
plan.mark_completed = MagicMock()
|
||||
return plan
|
||||
|
||||
|
||||
def _call_finalize(cover_candidates=None, gen_task=None, plan=None):
|
||||
from worker_app.tasks.edit_plan_generation import _finalize_render_success
|
||||
|
||||
plan = plan or _make_plan()
|
||||
gen_task = gen_task or FakeGenTask()
|
||||
|
||||
plan_repo = MagicMock()
|
||||
clip_repo = MagicMock()
|
||||
gen_task_repo = MagicMock()
|
||||
gen_task_repo.get.return_value = gen_task
|
||||
db = MagicMock()
|
||||
|
||||
with patch("worker_app.tasks.edit_plan_generation.create_video_record_and_dedup"):
|
||||
result = _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
plan_id="plan-1",
|
||||
output_url="https://oss.example.com/output.mp4",
|
||||
storage_key="rendered/plan-1/task-1.mp4",
|
||||
duration=10.0,
|
||||
file_size=1024,
|
||||
width=1280,
|
||||
height=720,
|
||||
rendered_clip_ids=["clip-1"],
|
||||
failed_clip_ids=[],
|
||||
generation_task_id="task-1",
|
||||
output_path=Path("/tmp/output.mp4"),
|
||||
engine="unified",
|
||||
thumbnail_url="",
|
||||
cover_candidates=cover_candidates,
|
||||
)
|
||||
|
||||
return result, gen_task, gen_task_repo
|
||||
|
||||
|
||||
class TestFinalizeCoverUrl:
|
||||
|
||||
def test_cover_url_set_from_image_url(self):
|
||||
"""cover_candidates with image_url should set gen_task.cover_url"""
|
||||
candidates = [
|
||||
{"image_url": "https://oss.example.com/cover1.jpg", "frame_time": 1.5},
|
||||
{"image_url": "https://oss.example.com/cover2.jpg", "frame_time": 3.0},
|
||||
]
|
||||
_, gen_task, gen_task_repo = _call_finalize(cover_candidates=candidates)
|
||||
assert gen_task.cover_url == "https://oss.example.com/cover1.jpg"
|
||||
gen_task_repo.update.assert_called()
|
||||
|
||||
def test_cover_url_fallback_to_url_key(self):
|
||||
"""Should fallback to 'url' key when 'image_url' is absent"""
|
||||
candidates = [{"url": "https://oss.example.com/cover_url_key.jpg"}]
|
||||
_, gen_task, _ = _call_finalize(cover_candidates=candidates)
|
||||
assert gen_task.cover_url == "https://oss.example.com/cover_url_key.jpg"
|
||||
|
||||
def test_cover_url_not_set_when_empty_list(self):
|
||||
"""Empty cover_candidates should not set cover_url"""
|
||||
_, gen_task, _ = _call_finalize(cover_candidates=[])
|
||||
assert "cover_url" not in gen_task._assigned
|
||||
|
||||
def test_cover_url_not_set_when_none(self):
|
||||
"""None cover_candidates should not set cover_url"""
|
||||
_, gen_task, _ = _call_finalize(cover_candidates=None)
|
||||
assert "cover_url" not in gen_task._assigned
|
||||
|
||||
def test_cover_url_not_set_when_url_empty(self):
|
||||
"""Empty URL strings in candidates should not set cover_url"""
|
||||
candidates = [{"image_url": "", "url": ""}]
|
||||
_, gen_task, _ = _call_finalize(cover_candidates=candidates)
|
||||
assert "cover_url" not in gen_task._assigned
|
||||
|
||||
def test_no_generation_task_no_crash(self):
|
||||
"""Should not crash when gen_task is None"""
|
||||
candidates = [{"image_url": "https://oss.example.com/cover.jpg"}]
|
||||
gen_task_repo = MagicMock()
|
||||
gen_task_repo.get.return_value = None
|
||||
result, _, _ = _call_finalize(cover_candidates=candidates)
|
||||
assert result["status"] == "completed"
|
||||
|
||||
def test_image_url_priority_over_url(self):
|
||||
"""image_url should take priority over url key"""
|
||||
candidates = [{"image_url": "https://a.jpg", "url": "https://b.jpg"}]
|
||||
_, gen_task, _ = _call_finalize(cover_candidates=candidates)
|
||||
assert gen_task.cover_url == "https://a.jpg"
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Tests for /generate endpoint — custom_title and cover_url passing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestGenerateEndpointTitleAndCover:
|
||||
"""测试 /generate 端点传递 custom_title 和 cover_url。"""
|
||||
|
||||
def test_generate_passes_cover_url_from_plan_config(self):
|
||||
"""从 plan.config.cover.image_url 读取封面 URL 传递给生成任务。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-123"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {
|
||||
"clips": [{"id": "c1"}],
|
||||
"asset_ids": ["a1"],
|
||||
"cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"},
|
||||
}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
mock_gen_task = MagicMock()
|
||||
mock_gen_task.id = "task-new"
|
||||
mock_gen_task.project_id = "project-1"
|
||||
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
body = EditPlanGenerateRequest() # No title_config
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-123"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_gen_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-123",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=mock_current_user,
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
# Verify cover_url was passed to CreateGenerationTaskCommand
|
||||
call_args = mock_usecase.execute.call_args
|
||||
command = call_args[0][0]
|
||||
assert command.cover_url == "https://oss.example.com/uploaded/cover.jpg"
|
||||
assert command.custom_title == ""
|
||||
|
||||
def test_generate_passes_custom_title_from_title_config(self):
|
||||
"""前端传 title_config 时,序列化为 JSON 存入 custom_title。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-456"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {
|
||||
"clips": [{"id": "c1"}],
|
||||
"asset_ids": ["a1"],
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/cover.jpg"},
|
||||
}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
mock_gen_task = MagicMock()
|
||||
mock_gen_task.id = "task-title"
|
||||
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
title_config = {
|
||||
"text": "测试标题",
|
||||
"font_size": 36,
|
||||
"font_color": "#ffffff",
|
||||
"position": "center",
|
||||
}
|
||||
body = EditPlanGenerateRequest(title_config=title_config)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_gen_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-456",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=mock_current_user,
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
# Verify custom_title was serialized to JSON
|
||||
call_args = mock_usecase.execute.call_args
|
||||
command = call_args[0][0]
|
||||
parsed_title = json.loads(command.custom_title)
|
||||
assert parsed_title["text"] == "测试标题"
|
||||
assert parsed_title["font_size"] == 36
|
||||
assert command.cover_url == "https://oss.example.com/cover.jpg"
|
||||
|
||||
def test_generate_empty_title_config_passes_empty_custom_title(self):
|
||||
"""title_config 为空时 custom_title 为空字符串。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-789"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
mock_gen_task = MagicMock()
|
||||
mock_gen_task.id = "task-no-title"
|
||||
|
||||
body = EditPlanGenerateRequest() # No title_config
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_gen_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-789",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
call_args = mock_usecase.execute.call_args
|
||||
command = call_args[0][0]
|
||||
assert command.custom_title == ""
|
||||
|
||||
|
||||
class TestGenerateEndpointRequestSchema:
|
||||
"""测试 EditPlanGenerateRequest schema。"""
|
||||
|
||||
def test_schema_default_empty_title_config(self):
|
||||
"""默认 title_config 为空 dict。"""
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
req = EditPlanGenerateRequest()
|
||||
assert req.title_config == {}
|
||||
|
||||
def test_schema_accepts_title_config(self):
|
||||
"""可以传入标题配置。"""
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
req = EditPlanGenerateRequest(title_config={"text": "我的标题", "font_size": 48})
|
||||
assert req.title_config["text"] == "我的标题"
|
||||
assert req.title_config["font_size"] == 48
|
||||
|
||||
|
||||
class TestGenerateTitleChangeSkipsReuse:
|
||||
"""测试标题变更时跳过预览产物复用。"""
|
||||
|
||||
def _make_mocks(self, custom_title=""):
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-reuse"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
reusable_task = MagicMock()
|
||||
reusable_task.id = "task-reusable"
|
||||
reusable_task.is_completed = True
|
||||
reusable_task.is_preview = True
|
||||
reusable_task.custom_title = custom_title
|
||||
reusable_task.project_id = "project-1"
|
||||
reusable_task.source_edit_plan_id = "plan-reuse"
|
||||
|
||||
mock_new_task = MagicMock()
|
||||
mock_new_task.id = "task-new"
|
||||
|
||||
return mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task
|
||||
|
||||
def test_title_removed_skips_reuse(self):
|
||||
"""原来有标题,现在移除了 → 跳过复用,创建新任务。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks(
|
||||
custom_title='{"text": "旧标题"}'
|
||||
)
|
||||
|
||||
body = EditPlanGenerateRequest() # No title_config → title removed
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_new_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-reuse",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
# 应该创建新任务而不是复用
|
||||
mock_usecase.execute.assert_called_once()
|
||||
# 不应该 mark_confirmed 在 reusable_task 上
|
||||
reusable_task.mark_confirmed.assert_not_called()
|
||||
|
||||
def test_title_changed_skips_reuse(self):
|
||||
"""标题变更 → 跳过复用。"""
|
||||
import json
|
||||
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks(
|
||||
custom_title=json.dumps({"text": "旧标题", "font_size": 36}, ensure_ascii=False)
|
||||
)
|
||||
|
||||
body = EditPlanGenerateRequest(title_config={"text": "新标题", "font_size": 48})
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_new_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-reuse",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
mock_usecase.execute.assert_called_once()
|
||||
reusable_task.mark_confirmed.assert_not_called()
|
||||
@@ -552,3 +552,138 @@ class TestStrayLoggerRemoved:
|
||||
assert (
|
||||
"logger.info(\n plan_id," not in source
|
||||
), "Stray logger.info(plan_id, generation_task_id) should be removed"
|
||||
|
||||
|
||||
class TestUploadCoverType:
|
||||
"""测试 cover_type=upload 封面上传功能。"""
|
||||
|
||||
def test_upload_cover_saves_url_directly(self):
|
||||
"""cover_type=upload 时直接保存 cover_url,不需要预览视频。"""
|
||||
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_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-upload"
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="upload",
|
||||
cover_url="https://oss.example.com/uploaded/cover.jpg",
|
||||
)
|
||||
|
||||
with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize:
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-upload",
|
||||
plan_id="plan-upload",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
|
||||
assert result.plan_id == "plan-upload"
|
||||
assert result.cover["type"] == "upload"
|
||||
assert result.cover["image_url"] == "https://oss.example.com/uploaded/cover.jpg"
|
||||
|
||||
# 验证 plan config 被更新
|
||||
mock_plan_svc.update_plan_config.assert_called_once()
|
||||
call_args = mock_plan_svc.update_plan_config.call_args
|
||||
assert call_args[0][0] == "plan-upload"
|
||||
assert call_args[0][1]["cover"]["type"] == "upload"
|
||||
|
||||
def test_upload_cover_without_url_returns_400(self):
|
||||
"""cover_type=upload 但未提供 cover_url 时返回 400。"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
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()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="upload")
|
||||
# cover_url is None by default
|
||||
|
||||
import pytest
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="template-upload",
|
||||
plan_id="plan-upload",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "cover_url" in exc_info.value.detail
|
||||
|
||||
def test_upload_cover_schema_has_cover_url_field(self):
|
||||
"""GenerateCoverRequest schema 包含 cover_url 字段。"""
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
req = GenerateCoverRequest(cover_type="upload", cover_url="https://example.com/img.jpg")
|
||||
assert req.cover_url == "https://example.com/img.jpg"
|
||||
assert req.cover_type == "upload"
|
||||
|
||||
# 默认值为 None
|
||||
req2 = GenerateCoverRequest()
|
||||
assert req2.cover_url is None
|
||||
|
||||
def test_upload_cover_does_not_require_preview_video(self):
|
||||
"""cover_type=upload 时不查找预览视频,即使 plan.config 为空也不报错。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {} # 没有 rendered_storage_key
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="upload",
|
||||
cover_url="https://oss.example.com/uploaded/my-cover.png",
|
||||
)
|
||||
|
||||
with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize:
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/my-cover.png"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
# 不应该抛出 "请先生成预览视频" 的异常
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-1",
|
||||
plan_id="plan-no-preview",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
assert result.cover["image_url"] == "https://oss.example.com/uploaded/my-cover.png"
|
||||
# 验证没有调用任何预览视频查找逻辑
|
||||
# (normalize_plan_config 是唯一被调用的外部函数)
|
||||
|
||||
@@ -61,6 +61,9 @@ def _make_mock_clip(clip_id="clip-001", order=0, duration=10.0, clip_type="video
|
||||
clip.config = {}
|
||||
clip.asset_id = "asset-001"
|
||||
clip.status = "ready"
|
||||
clip.template_clip_config_id = ""
|
||||
clip.created_at = None
|
||||
clip.updated_at = None
|
||||
return clip
|
||||
|
||||
|
||||
@@ -327,7 +330,8 @@ class TestClipSplitMerge:
|
||||
resp = c.post(BASE + "/clips/merge", json={"clip_ids": ["clip-001", "clip-002"]})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
assert "merged_clip" in data
|
||||
assert "id" in data["merged_clip"]
|
||||
mock_plan_svc.merge_clips.assert_called_once_with(["clip-001", "clip-002"])
|
||||
|
||||
def test_merge_clips_single_422(self, client):
|
||||
|
||||
Reference in New Issue
Block a user