Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 425a7cb623 | |||
| f9d243a5af | |||
| cf7e295f35 | |||
| 1eb9d8667a | |||
| bde37af2bb | |||
| d3fc15ddd9 | |||
| e5e18ef269 | |||
| 3b828ab184 | |||
| 2957ad724c | |||
| b6f211ebe6 | |||
| 3e71a00b12 | |||
| d512cd2ac1 | |||
| 1c9c9c79c2 |
@@ -1,15 +1,18 @@
|
||||
"""封面生成路由 — Generation 模块.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
- POST /generate-cover AI 生成封面(从最终成片视频中抽帧,兼容预览片段回退)
|
||||
|
||||
挂载路径: /api/v1/generation/generate-cover
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
@@ -24,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
|
||||
|
||||
@@ -51,6 +55,14 @@ class GenerateCoverRequest(BaseModel):
|
||||
default=None,
|
||||
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
|
||||
)
|
||||
generated_video_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,而非预览片段。",
|
||||
)
|
||||
video_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="最终视频 URL(兜底)。当 generated_video_id 不可用时,直接从此 URL 对应的视频抽帧。",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
@@ -121,8 +133,6 @@ def _persist_cover_frame(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
storage.upload_file(
|
||||
@@ -140,6 +150,106 @@ def _persist_cover_frame(
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task_id)
|
||||
if videos:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]:
|
||||
"""将 storage_key 或完整 URL 转换为可访问的裸 URL。"""
|
||||
if not storage_key:
|
||||
return None
|
||||
try:
|
||||
if storage_key.startswith("http"):
|
||||
url = storage_key
|
||||
else:
|
||||
storage_svc = get_shared_storage_service()
|
||||
url = storage_svc.get_url(storage_key)
|
||||
if url:
|
||||
url = re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning("[封面生成] storage_key 转 URL 失败: key=%s err=%s", storage_key, e)
|
||||
return None
|
||||
|
||||
|
||||
def _endpoint_host(value: str) -> str:
|
||||
"""从 endpoint / URL 字符串中安全提取主机名(兼容有无 scheme 两种配置)。"""
|
||||
v = (value or "").strip().lower()
|
||||
if not v:
|
||||
return ""
|
||||
if "://" in v:
|
||||
return (urlparse(v).hostname or "").lower()
|
||||
# 无 scheme:去掉可能的端口(host:port),urlparse 补 // 以正确解析
|
||||
return (urlparse("//" + v).hostname or "").lower()
|
||||
|
||||
|
||||
def _is_private_or_reserved_host(host: str) -> bool:
|
||||
"""判断主机名是否为内网/回环/链路本地/保留地址(IPv4 与 IPv6 统一处理)。
|
||||
|
||||
使用标准库 ipaddress 判定;非 IP 主机名(如 localhost)单独处理。
|
||||
"""
|
||||
h = host.strip().lower()
|
||||
if h in {"localhost", "0.0.0.0", "::", "::1"}:
|
||||
return True
|
||||
try:
|
||||
addr = ipaddress.ip_address(h)
|
||||
# is_private 覆盖 10/8、172.16/12、192.168/16、127/8、169.254/16、
|
||||
# ::1、fc00::/7、fe80::/10 等全部私有/保留段
|
||||
return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_trusted_media_url(url: str) -> bool:
|
||||
"""校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。
|
||||
|
||||
用户可通过 video_url 传入视频地址,但服务端(MediaKit)会主动请求该 URL,
|
||||
因此必须限制为自家存储域名,拒绝内网地址、元数据地址等任意主机。
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not host:
|
||||
return False
|
||||
# 拒绝一切内网/回环/链路本地/保留地址(IPv4 + IPv6,标准库判定)
|
||||
if _is_private_or_reserved_host(host):
|
||||
return False
|
||||
# 允许:自家 OSS bucket 域名(<bucket>.<endpoint>)或 endpoint 自身及其子域
|
||||
try:
|
||||
storage_svc = get_shared_storage_service()
|
||||
trusted_hosts = set()
|
||||
public_base = getattr(storage_svc, "public_url", "") or ""
|
||||
h1 = _endpoint_host(public_base)
|
||||
if h1:
|
||||
trusted_hosts.add(h1)
|
||||
h2 = _endpoint_host(getattr(storage_svc, "endpoint", "") or "")
|
||||
if h2:
|
||||
trusted_hosts.add(h2)
|
||||
for trusted in trusted_hosts:
|
||||
if host == trusted or host.endswith("." + trusted):
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 存储域名白名单初始化失败,URL 校验从严拒绝", exc_info=True)
|
||||
return False
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("[封面生成] video_url 白名单校验异常,从严拒绝: url=%s", url[:80], exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
@@ -149,12 +259,16 @@ def generate_cover(
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
"""AI 生成封面 — 优先从最终成片视频中抽帧,回退到预览片段.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
1. 优先使用前端传入的 generation_task_id 定位最终成片任务,
|
||||
或自动查找 plan 关联的已完成最终成片任务(is_preview=False)
|
||||
2. 回退:从预览片段获取视频 URL(兼容旧流程)
|
||||
3. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
4. 帧图下载后上传到 OSS covers/ 路径
|
||||
|
||||
MediaKit 的调用方式(strategy / max_frames / 轮询 / 重试 / 降级)不变。
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
@@ -182,27 +296,111 @@ def generate_cover(
|
||||
)
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
# ── 查找用于抽帧的视频 URL ────────────────────────────────────────
|
||||
# 优先级:
|
||||
# 0. 请求体显式传入的 generation_task_id(最终成片任务)
|
||||
# 1. plan.config.rendered_storage_key
|
||||
# 2. plan.config.generation_task_id 对应的任务
|
||||
# 3. source_edit_plan_id 关联的已完成「最终成片」任务(is_preview=False)
|
||||
# 4. source_edit_plan_id 关联的已完成预览任务(is_preview=True,兼容回退)
|
||||
# 5. user + template 最近的已完成预览任务(兜底)
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
# 步骤 0:请求体传入最终视频标识(generated_video_id 或 video_url)
|
||||
if not rendered_storage_key:
|
||||
# 0a:通过 generated_video_id 查找最终成片视频
|
||||
if body.generated_video_id:
|
||||
logger.info(
|
||||
"[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
)
|
||||
try:
|
||||
gv_repo = get_generated_video_repository(db)
|
||||
gv = gv_repo.get(body.generated_video_id)
|
||||
if gv:
|
||||
file_url = getattr(gv, "file_url", "") or ""
|
||||
if file_url:
|
||||
# 权限校验(双重,任何一层确认归属不符即拒绝):
|
||||
# 1) GeneratedVideo.user_id 直接归属(老数据可能为空,为空时不据此放行)
|
||||
gv_owner = (getattr(gv, "user_id", "") or "").strip()
|
||||
if gv_owner and gv_owner != current_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该视频")
|
||||
# 2) 关联 generation_task 归属校验;关联任务缺失时不可静默放行:
|
||||
# 若 video 自身无 owner 信息且关联任务也查不到,拒绝访问
|
||||
gv_task_id = getattr(gv, "generation_task_id", "") or ""
|
||||
task0 = None
|
||||
if gv_task_id:
|
||||
try:
|
||||
task0 = SQLAlchemyGenerationTaskRepository(db).get(gv_task_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0a关联任务查询异常: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
gv_task_id,
|
||||
exc_info=True,
|
||||
)
|
||||
if task0 is not None:
|
||||
task_owner = (getattr(task0, "created_by_user_id", "") or "").strip()
|
||||
if task_owner and task_owner != current_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该视频")
|
||||
elif not gv_owner:
|
||||
# video 无 owner 且关联任务不存在/无法确认归属 → 拒绝,防止越权
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0a视频归属无法确认,拒绝访问: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="无权访问该视频")
|
||||
rendered_storage_key = file_url
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
file_url[:80],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 0b:直接使用 video_url(兜底)— 必须通过存储域名白名单校验,防止 SSRF
|
||||
if not rendered_storage_key and body.video_url:
|
||||
if _is_trusted_media_url(body.video_url):
|
||||
logger.info(
|
||||
"[封面生成] 步骤0b: 使用请求体传入的 video_url(白名单通过): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
body.video_url[:80],
|
||||
)
|
||||
rendered_storage_key = body.video_url
|
||||
else:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤0b: video_url 不在受信任存储域名白名单内,已忽略: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
body.video_url[:80],
|
||||
)
|
||||
|
||||
# 步骤 2:通过 plan.config.generation_task_id 查找
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
logger.info(
|
||||
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
|
||||
)
|
||||
if generation_task_id:
|
||||
logger.info(
|
||||
"[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
)
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = _repo.get(generation_task_id)
|
||||
if task:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
rendered_storage_key = _get_task_video_url(db, task.id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
@@ -211,26 +409,23 @@ def generate_cover(
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
"[封面生成] 步骤2查找失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
|
||||
# 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(pt.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id)
|
||||
all_tasks = _repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in all_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and not getattr(pt, "is_preview", False):
|
||||
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
|
||||
"[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
@@ -238,66 +433,74 @@ def generate_cover(
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
|
||||
"[封面生成] 步骤3查找最终成片失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
# 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id)
|
||||
preview_tasks = _repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤4查找预览任务失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 5:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info(
|
||||
"[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
)
|
||||
preview_tasks = _repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
if preview_tasks:
|
||||
completed_preview = preview_tasks[0]
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(completed_preview.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
rendered_storage_key = _get_task_video_url(db, preview_tasks[0].id) or ""
|
||||
if rendered_storage_key:
|
||||
logger.info(
|
||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
||||
"[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
completed_preview.id,
|
||||
preview_tasks[0].id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
||||
"[封面生成] 步骤5 user+template 查找失败: plan_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读);找不到渲染视频时不立即报错,
|
||||
# 因为步骤 E 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
|
||||
# 将 storage_key 转换为可访问 URL;找不到视频时不立即报错,
|
||||
# 因为步骤 E2 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
|
||||
primary_video_url = None
|
||||
if rendered_storage_key:
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取预览视频URL失败: plan_id=%s err=%s", plan_id, e)
|
||||
primary_video_url = None
|
||||
primary_video_url = _resolve_storage_key_to_url(rendered_storage_key)
|
||||
logger.info(
|
||||
"[封面生成] 封面抽帧视频URL: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
@@ -326,20 +529,67 @@ def generate_cover(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
|
||||
# 步骤 A2:通过 generated_video_id 查找其关联任务的 cover_url
|
||||
if not cover_url_from_task and body.generated_video_id:
|
||||
try:
|
||||
gv_repo = get_generated_video_repository(db)
|
||||
gv = gv_repo.get(body.generated_video_id)
|
||||
if gv:
|
||||
gv_task_id = getattr(gv, "generation_task_id", "") or ""
|
||||
if gv_task_id:
|
||||
task_a2 = gen_task_repo.get(gv_task_id)
|
||||
if task_a2 and getattr(task_a2, "cover_url", ""):
|
||||
cover_url_from_task = task_a2.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤A2读取 cover_url 失败: plan_id=%s video_id=%s",
|
||||
plan_id,
|
||||
body.generated_video_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联任务的 cover_url
|
||||
# 优先最终成片任务(is_preview=False),其次预览任务
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
|
||||
all_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
# 先找最终成片
|
||||
for pt in all_tasks:
|
||||
if (
|
||||
getattr(pt, "status", "") == "completed"
|
||||
and not getattr(pt, "is_preview", False)
|
||||
and getattr(pt, "cover_url", "")
|
||||
):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
|
||||
"[封面生成] 封面(步骤B-final): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
# 再找预览
|
||||
if not cover_url_from_task:
|
||||
for pt in all_tasks:
|
||||
if (
|
||||
getattr(pt, "status", "") == "completed"
|
||||
and getattr(pt, "is_preview", False)
|
||||
and getattr(pt, "cover_url", "")
|
||||
):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 封面(步骤B-preview): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
|
||||
|
||||
@@ -12,7 +12,14 @@ export interface GenerateCoverTitleConfig {
|
||||
}
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
/**
|
||||
* 封面源视频标识(二选一):
|
||||
* - generated_video_id:确认生成任务产出的最终视频 ID
|
||||
* - video_url:最终视频 URL(兜底)
|
||||
* 后端根据此标识定位最终成片文件并抽帧,MediaKit 选帧逻辑不变
|
||||
*/
|
||||
generated_video_id?: string
|
||||
video_url?: string
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
/** 标题样式,用于在封面上叠加标题文字 */
|
||||
@@ -31,7 +38,7 @@ export interface GenerateCoverResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 生成封面 — 从预览视频中抽帧 */
|
||||
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* 智能剪辑页面 — 前端实时预览架构
|
||||
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 6 步向导:选择模板 → 素材 → 配音 → 标题(含预览) → 确认生成 → 选择封面
|
||||
*
|
||||
* 架构:
|
||||
* - 步骤 4-6 右侧显示 FrontendPreviewPlayer 实时预览
|
||||
* - 步骤 7 右侧内联播放生成的最终视频
|
||||
* - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
|
||||
* - 步骤 5 右侧内联播放生成中的/最终视频
|
||||
* - 步骤 6 封面从最终成片中智能选帧(MediaKit)
|
||||
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
|
||||
*/
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react"
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
@@ -163,7 +163,16 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
|
||||
previewAssetIds,
|
||||
previewAssetsEnabled,
|
||||
)
|
||||
|
||||
/* ── 预览就绪:素材已加载,且有模板 ── */
|
||||
const previewReady = useMemo(
|
||||
() => previewAssetsReady && !!currentTemplate,
|
||||
[previewAssetsReady, currentTemplate],
|
||||
)
|
||||
|
||||
/* ── 视频总时长计算 ── */
|
||||
const totalVideoDuration = useMemo(() => {
|
||||
@@ -172,17 +181,6 @@ const GeneratePage: React.FC = () => {
|
||||
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
||||
}, [previewAssets, currentTemplate])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -219,6 +217,38 @@ const GeneratePage: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览视频正在加载,请稍候")
|
||||
return
|
||||
}
|
||||
const ok = await handleGenerate()
|
||||
if (ok) {
|
||||
setCurrentStep(5)
|
||||
}
|
||||
}, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady,
|
||||
generated,
|
||||
})
|
||||
|
||||
/* ── 最终成片(步骤5/6 右侧播放) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
================================================================ */
|
||||
@@ -255,13 +285,10 @@ const GeneratePage: React.FC = () => {
|
||||
onApplyPreset={styleUpdaters.applyPreset}
|
||||
activePreset={styleUpdaters.activePreset}
|
||||
titlePresets={styleUpdaters.titlePresets}
|
||||
onPreviewTaskCreated={setPreviewTaskId}
|
||||
onSourceEditPlanIdExtracted={setStoredSourceEditPlanId}
|
||||
bgm={bgm}
|
||||
bgmConfig={bgmConfig}
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
duration={duration}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
@@ -289,16 +316,16 @@ const GeneratePage: React.FC = () => {
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onGenerate={handleGenerate}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:步骤 4-6 实时预览,步骤 7 最终视频 ════ */}
|
||||
{/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{currentStep >= 4 && currentStep <= 6 && !!currentTemplate && (
|
||||
{currentStep === 4 && !!currentTemplate && (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
@@ -319,14 +346,14 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 7 && generated && generatedVideos.length > 0 && (
|
||||
{currentStep >= 5 && generated && finalVideo && (
|
||||
<div className="xx-inline-video-player">
|
||||
<video
|
||||
src={generatedVideos[0].download_url || generatedVideos[0].file_url}
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
autoPlay={currentStep === 5}
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
|
||||
poster={generatedVideos[0].thumbnail_url || undefined}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* GeneratePage 步骤底部操作按钮
|
||||
*
|
||||
* 步骤 1~3:上一步 / 下一步
|
||||
* 步骤 4(标题+预览):上一步 / 确认生成视频(点击后直接创建最终渲染任务,成功后跳转步骤5)
|
||||
* 步骤 5(确认生成):上一步 / 下一步(渲染中禁用,渲染完成后可进入封面)
|
||||
* 步骤 6(选择封面):仅上一步
|
||||
*/
|
||||
import React from "react"
|
||||
import { ThunderboltOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface GenerateStepActionsProps {
|
||||
currentStep: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onGenerate: () => void
|
||||
/** 步骤4:确认生成视频(校验 + 创建渲染任务 + 成功后进入步骤5) */
|
||||
onConfirmGenerate: () => void | Promise<void>
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
@@ -18,36 +23,65 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
currentStep,
|
||||
onPrev,
|
||||
onNext,
|
||||
onGenerate,
|
||||
onConfirmGenerate,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
}) => {
|
||||
const renderPrimaryButton = () => {
|
||||
/* 步骤 4:确认生成视频(触发按钮在标题页) */
|
||||
if (currentStep === 4) {
|
||||
if (generating) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
⏳ 视频生成中…
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (generateError) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
🔄 重新生成视频
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
✨ 确认生成视频
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5:渲染中禁用,完成后下一步进入封面 */
|
||||
if (currentStep === 5) {
|
||||
return (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onNext}
|
||||
disabled={generating || !generated}
|
||||
>
|
||||
{generating ? "视频生成中…" : "下一步 →"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 6(最后一步):无主按钮 */
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-step-actions">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={onPrev} disabled={currentStep === 1}>
|
||||
← 上一步
|
||||
</button>
|
||||
{currentStep < 7 ? (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
{renderPrimaryButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* V24: 移除 Step5 预览生成相关 props,改为纯标题样式编辑
|
||||
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -15,10 +12,9 @@ import type { TitleSettings } from "../types"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step5GeneratePreview from "../components/Step5GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
export interface GenerateStepContentProps {
|
||||
@@ -37,7 +33,6 @@ export interface GenerateStepContentProps {
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/* 标题样式回调 — Step5 样式面板使用 */
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
@@ -51,7 +46,6 @@ export interface GenerateStepContentProps {
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
@@ -76,10 +70,6 @@ export interface GenerateStepContentProps {
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
/** 预览任务创建回调——传递给 Step6CoverSettings */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** BGM 开关 */
|
||||
bgm: boolean
|
||||
/** BGM 配置(来自模板) */
|
||||
@@ -112,7 +102,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
@@ -128,10 +117,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
} = props
|
||||
|
||||
/* 当前模板的 segments,传给 Step2 构建 clips */
|
||||
@@ -175,12 +160,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
titleSettings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
@@ -193,27 +172,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titlePresets={titlePresets}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
case 5:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={onCoverSettingsChange}
|
||||
duration={duration}
|
||||
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
onPreviewTaskCreated={onPreviewTaskCreated}
|
||||
onSourceEditPlanIdExtracted={onSourceEditPlanIdExtracted}
|
||||
voiceMode={voiceMode}
|
||||
selectedVoice={selectedVoice}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
bgm={bgm}
|
||||
bgmConfig={bgmConfig}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
return (
|
||||
<Step7ConfirmGenerate
|
||||
<Step5ConfirmGenerate
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
@@ -235,6 +196,16 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={onCoverSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
generatedVideos={generatedVideos}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
* 仅包含标题文字输入 + AI 标题生成
|
||||
* 标题样式面板已迁移到 Step5(生成预览页面)
|
||||
* Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板)
|
||||
*
|
||||
* 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设)
|
||||
* 右侧:FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染)
|
||||
*/
|
||||
import React from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { PlayCircleOutlined } from "@ant-design/icons"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
/* 标题样式回调 */
|
||||
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 Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
const {
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
@@ -112,6 +139,42 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 标题样式面板(原 Step5) */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 14px",
|
||||
background: "rgba(59, 130, 246, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
border: "1px solid rgba(59, 130, 246, 0.15)",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
|
||||
<span style={{ fontSize: 12, color: "var(--text-secondary, #666)" }}>
|
||||
右侧为实时预览,调整样式即时生效
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TitleStylePanel
|
||||
settings={t.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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Step 5 预览设置组件
|
||||
*
|
||||
* 前端实时预览架构:
|
||||
* - 右侧面板使用 FrontendPreviewPlayer 实时播放素材片段
|
||||
* - 标题样式可实时调整,CSS 层即时叠加预览
|
||||
* - 点"确认生成"时触发一次服务器渲染
|
||||
*/
|
||||
import React from "react"
|
||||
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 {
|
||||
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 Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
titleSettings,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 预览设置</h3>
|
||||
|
||||
<div
|
||||
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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step5GeneratePreview
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
@@ -9,27 +11,12 @@ import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: import("../types").TitleSettings
|
||||
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** 配音模式 */
|
||||
voiceMode?: "preset" | "custom" | "clone"
|
||||
/** 选中的配音素材 ID */
|
||||
selectedVoice?: string
|
||||
/** 选中的克隆音色 ID */
|
||||
selectedClonedVoice?: string
|
||||
/** BGM 开关 */
|
||||
bgm?: boolean
|
||||
/** BGM 配置 */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
/** Step4 标题设置,用于封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 确认生成步骤产出的最终视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
@@ -37,6 +24,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
finalVideo,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
@@ -53,17 +41,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
duration: props.duration,
|
||||
assetIds: props.assetIds,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
titleSettings: props.titleSettings,
|
||||
onPreviewTaskCreated: props.onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted: props.onSourceEditPlanIdExtracted,
|
||||
voiceMode: props.voiceMode,
|
||||
selectedVoice: props.selectedVoice,
|
||||
selectedClonedVoice: props.selectedClonedVoice,
|
||||
bgm: props.bgm,
|
||||
bgmConfig: props.bgmConfig,
|
||||
generatedVideos: props.generatedVideos,
|
||||
})
|
||||
|
||||
const handleAutoGenerate = () => {
|
||||
@@ -77,8 +57,25 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 最终成片信息 */}
|
||||
{finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
background: "rgba(16, 185, 129, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: "1px solid rgba(16, 185, 129, 0.15)",
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-cover-actions">
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate}>
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate} disabled={!finalVideo}>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
@@ -126,7 +123,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI 正在生成封面,请稍候...</p>
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
AI 正在从最终成片选帧,请稍候...
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,21 @@ const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||
onRetry,
|
||||
onDismissError,
|
||||
}) => {
|
||||
if (!generating && !generated && !generateError) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{!generating && !generated && !generateError && (
|
||||
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">🎬</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">尚未开始生成视频</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
请返回「选择标题」步骤,点击「确认生成视频」开始渲染最终视频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
|
||||
@@ -33,9 +33,8 @@ export const STEPS = [
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "生成预览" },
|
||||
{ key: 5, label: "确认生成" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
/* ── 标题位置选项 ── */
|
||||
|
||||
@@ -44,12 +44,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
onFailed: handleFailed,
|
||||
})
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
/* ── 生成视频 ──
|
||||
返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */
|
||||
const generate = useCallback(async (): Promise<boolean> => {
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
if (errorMsg) {
|
||||
message.warning(errorMsg)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
@@ -142,7 +143,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const finalMsg = translateError(backendMsg)
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
* 封面候选帧从确认生成的最终视频中获取(MediaKit 选帧)
|
||||
* 不再从预览片段创建预览视频
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import { updateEditPlan } from "@/api/template-editor"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
@@ -19,47 +19,22 @@ import {
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
/** Step4 标题设置,用于封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** 配音模式 */
|
||||
voiceMode?: "preset" | "custom" | "clone"
|
||||
/** 选中的配音素材 ID(配音素材库 asset ID) */
|
||||
selectedVoice?: string
|
||||
/** 选中的克隆音色 ID */
|
||||
selectedClonedVoice?: string
|
||||
/** BGM 开关 */
|
||||
bgm?: boolean
|
||||
/** BGM 配置(来自模板) */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
/** 确认生成步骤产出的最终视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
titleSettings,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
generatedVideos,
|
||||
}: UseStep6CoverProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性
|
||||
const previewParamsRef = useRef<string>("")
|
||||
|
||||
// ── 封面设置弹窗状态 ──
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
@@ -72,6 +47,9 @@ export function useStep6Cover({
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 最终成片:取第一个已完成视频 */
|
||||
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setTemplatesLoading(true)
|
||||
@@ -94,7 +72,7 @@ export function useStep6Cover({
|
||||
}
|
||||
}, [showCoverSettings, loadTemplates])
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
/** 调用后端智能封面 API,从最终成片中抽帧 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generating) {
|
||||
message.warning("封面正在生成中,请稍候...")
|
||||
@@ -106,19 +84,20 @@ export function useStep6Cover({
|
||||
return
|
||||
}
|
||||
|
||||
if (assetIds.length === 0) {
|
||||
message.error("请先选择素材")
|
||||
if (!finalVideo) {
|
||||
message.error("请先生成视频再选择封面")
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
|
||||
const timeoutId = setTimeout(() => {
|
||||
setGenerating(false)
|
||||
}, 300000)
|
||||
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
generated_video_id: finalVideo.id,
|
||||
video_url: finalVideo.file_url || finalVideo.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
@@ -151,151 +130,9 @@ export function useStep6Cover({
|
||||
clearTimeout(timeoutId)
|
||||
console.error("[Step6] 智能封面生成失败:", err)
|
||||
|
||||
// 提取详细错误信息
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyErr = err as any
|
||||
const statusCode = anyErr?.response?.status
|
||||
|
||||
// 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errCode = anyErr?.response?.data?.code as string | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errMsg = (anyErr?.response?.data?.message ||
|
||||
anyErr?.response?.data?.detail ||
|
||||
"") as string
|
||||
const isPreviewMissing =
|
||||
statusCode === 400 &&
|
||||
(errCode?.includes("PREVIEW") ||
|
||||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
|
||||
errMsg,
|
||||
))
|
||||
|
||||
if (isPreviewMissing) {
|
||||
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
|
||||
message.info("正在准备预览视频,请稍候...")
|
||||
try {
|
||||
// 记录当前参数指纹,用于任务完成时校验一致性(防竞态)
|
||||
previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
|
||||
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
|
||||
const previewVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
// 配音:始终传递 voice_library_id,确保后端能正确接收
|
||||
voice_library_id: previewVoiceLibraryId,
|
||||
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
|
||||
...(selectedVoice && !previewVoiceLibraryId ? { voice_ids: [selectedVoice] } : {}),
|
||||
// BGM 配置:受 bgm 开关控制
|
||||
bgm_config: {
|
||||
enabled: bgm !== false,
|
||||
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
|
||||
},
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
// 将预览任务 ID 暴露给父组件,供 Step7 确认生成时复用(confirmGeneration)
|
||||
const currentFingerprint = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
|
||||
if (previewResp.task_id && previewParamsRef.current === currentFingerprint) {
|
||||
onPreviewTaskCreated?.(previewResp.task_id)
|
||||
// 提取后端自动关联的 source_edit_plan_id,供 fallback 路径使用
|
||||
if (previewResp.source_edit_plan_id) {
|
||||
onSourceEditPlanIdExtracted?.(previewResp.source_edit_plan_id)
|
||||
}
|
||||
}
|
||||
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let finished = false
|
||||
const done = (fn: () => void) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timeoutId)
|
||||
fn()
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
done(() => reject(new Error("预览生成超时,请稍后重试")))
|
||||
}, 120_000)
|
||||
const poll = async () => {
|
||||
if (finished) return
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
// 保存预览视频地址到 plan.config.rendered_storage_key,
|
||||
// 供封面 API 的 E1 兜底路径定位渲染后的视频(含标题烧录)。
|
||||
// video_url 可能是完整 http(s) URL 或 OSS storage_key,两种格式后端都能处理。
|
||||
if (status.video_url) {
|
||||
try {
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: status.video_url },
|
||||
})
|
||||
} catch (saveErr) {
|
||||
console.warn(
|
||||
"[Step6] 保存 rendered_storage_key 失败(不阻塞封面重试):",
|
||||
saveErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
done(() => resolve())
|
||||
} else if (status.status === "failed") {
|
||||
done(() => reject(new Error(status.error_message || "预览渲染失败")))
|
||||
} else {
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
done(() => reject(e))
|
||||
}
|
||||
}
|
||||
poll()
|
||||
})
|
||||
message.success("预览视频就绪,重新生成封面...")
|
||||
// 重试封面生成
|
||||
const retryResp = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const retryUrl = retryResp.cover?.image_url || ""
|
||||
if (retryUrl) {
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
thumbnail_url: retryUrl,
|
||||
ai_suggested_time: retryResp.cover?.frame_time ?? null,
|
||||
})
|
||||
message.success("封面生成成功")
|
||||
} else {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (retryErr) {
|
||||
console.error("[Step6] 自动创建预览后重试失败:", retryErr)
|
||||
message.error("预览视频创建失败,请稍后重试")
|
||||
}
|
||||
} else if (anyErr?.__msgShown) {
|
||||
if (anyErr?.__msgShown) {
|
||||
// 拦截器已处理,不再重复弹出
|
||||
} else {
|
||||
let errorMsg = "封面生成失败"
|
||||
@@ -310,31 +147,21 @@ export function useStep6Cover({
|
||||
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)
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
assetIds,
|
||||
finalVideo,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
generating,
|
||||
duration,
|
||||
titleSettings,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
@@ -391,13 +218,11 @@ export function useStep6Cover({
|
||||
const selectedTemplateName =
|
||||
coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认"
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
finalVideo,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
|
||||
@@ -87,7 +87,9 @@ export function useStep7Generate({
|
||||
}
|
||||
|
||||
const handleScrollToPreview = () => {
|
||||
const el = document.querySelector(".xx-preview-section")
|
||||
const el =
|
||||
document.querySelector(".xx-inline-video-player") ||
|
||||
document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* 前端实时预览架构:Step5 无需等待服务器渲染
|
||||
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -16,6 +13,10 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** 预览是否已就绪(素材已加载,可播放) */
|
||||
previewReady: boolean
|
||||
/** 是否已完成视频生成(步骤5确认生成后才能进入封面) */
|
||||
generated: boolean
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
@@ -32,6 +33,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady,
|
||||
generated,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
@@ -47,11 +50,23 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
// Step4(标题+预览):标题必填 + 预览必须已加载
|
||||
if (currentStep === 4) {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览视频正在加载,请稍候")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Step5(确认生成):必须已完成生成才能进入封面
|
||||
if (currentStep === 5 && !generated) {
|
||||
message.warning("请先生成视频")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
if (currentStep < 6) {
|
||||
setCurrentStep((s) => s + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import "@/api/generation/types"
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Step5GeneratePreview smoke test
|
||||
* Step4+5 merged preview smoke test
|
||||
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
@@ -12,8 +11,8 @@ import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step5GeneratePreview module smoke test", () => {
|
||||
it("should load all step5 preview modules", () => {
|
||||
describe("Merged Step4+5 preview module smoke test", () => {
|
||||
it("should load all merged step4+5 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,7 +181,7 @@ class TestUnifiedCoverPipelineEndpoint:
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
@@ -449,7 +449,7 @@ class TestUnifiedCoverPipelineEndpoint:
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
@@ -590,7 +590,7 @@ class TestSourceEditPlanFallback:
|
||||
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
|
||||
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
@@ -657,7 +657,7 @@ class TestSourceEditPlanFallback:
|
||||
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
|
||||
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
@@ -1177,7 +1177,7 @@ class TestUploadCoverType:
|
||||
return_value=mock_asset_repo,
|
||||
),
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
@@ -1200,3 +1200,511 @@ class TestUploadCoverType:
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_mk.extract_frames.assert_not_called()
|
||||
|
||||
|
||||
class TestCoverFromFinalVideo:
|
||||
"""测试封面从最终成片任务(is_preview=False)获取视频源。"""
|
||||
|
||||
def test_generated_video_fields_in_schema(self):
|
||||
"""请求体支持 generated_video_id 和 video_url 字段。"""
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
req = GenerateCoverRequest(
|
||||
generated_video_id="gv-001",
|
||||
video_url="https://example.com/final.mp4",
|
||||
)
|
||||
assert req.generated_video_id == "gv-001"
|
||||
assert req.video_url == "https://example.com/final.mp4"
|
||||
|
||||
# 默认 None
|
||||
req_default = GenerateCoverRequest()
|
||||
assert req_default.generated_video_id is None
|
||||
assert req_default.video_url is None
|
||||
|
||||
def test_cover_uses_final_video_when_generated_video_id_provided(self):
|
||||
"""传 generated_video_id 时,从该最终成片视频抽帧。"""
|
||||
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()
|
||||
|
||||
# Generated video
|
||||
mock_gv = MagicMock()
|
||||
mock_gv.file_url = "rendered/final/video.mp4"
|
||||
mock_gv.generation_task_id = "task-final-001"
|
||||
mock_gv.user_id = "user-1"
|
||||
|
||||
# 最终成片任务
|
||||
mock_final_task = MagicMock()
|
||||
mock_final_task.id = "task-final-001"
|
||||
mock_final_task.created_by_user_id = "user-1"
|
||||
mock_final_task.cover_url = ""
|
||||
|
||||
mock_gv_repo = MagicMock()
|
||||
mock_gv_repo.get.return_value = mock_gv
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="ai_frame",
|
||||
generated_video_id="gv-final-001",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch(
|
||||
"app.api.routes.generation_cover.get_generated_video_repository",
|
||||
return_value=mock_gv_repo,
|
||||
),
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
|
||||
patch(
|
||||
"app.api.routes.generation_cover._persist_cover_frame",
|
||||
return_value="https://oss.example.com/covers/final-cover.jpg",
|
||||
) as mock_persist,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_final_task
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/final/video.mp4"
|
||||
mock_storage_svc.public_url = "https://oss.example.com"
|
||||
mock_storage_svc.endpoint = "oss.example.com"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]
|
||||
mock_mk_getter.return_value = mock_mk
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final-cover.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-1",
|
||||
plan_id="plan-final",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
|
||||
assert result.cover["image_url"] == "https://oss.example.com/covers/final-cover.jpg"
|
||||
call_kwargs = mock_mk.extract_frames.call_args.kwargs
|
||||
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
|
||||
assert call_kwargs["strategy"] == "SpecifiedFrames"
|
||||
assert call_kwargs["max_frames"] == 1
|
||||
assert call_kwargs["max_retries"] == 0
|
||||
mock_persist.assert_called_once()
|
||||
|
||||
def test_cover_uses_video_url_directly(self):
|
||||
"""传 video_url 时,直接从该 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_db = MagicMock()
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="ai_frame",
|
||||
video_url="https://oss.example.com/rendered/final/video.mp4",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
|
||||
patch(
|
||||
"app.api.routes.generation_cover._persist_cover_frame",
|
||||
return_value="https://oss.example.com/covers/c.jpg",
|
||||
),
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.public_url = "https://oss.example.com"
|
||||
mock_storage_svc.endpoint = "oss.example.com"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
|
||||
mock_mk_getter.return_value = mock_mk
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-1",
|
||||
plan_id="plan-url",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
|
||||
call_kwargs = mock_mk.extract_frames.call_args.kwargs
|
||||
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
|
||||
|
||||
def test_cover_prefers_final_task_over_preview_in_source_plan(self):
|
||||
"""步骤3:source_edit_plan 关联任务中,优先使用 is_preview=False 的最终成片。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {} # 无 rendered_storage_key / generation_task_id
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
# 一个预览任务 + 一个最终成片任务
|
||||
mock_preview = MagicMock()
|
||||
mock_preview.id = "task-preview"
|
||||
mock_preview.status = "completed"
|
||||
mock_preview.is_preview = True
|
||||
mock_preview.cover_url = ""
|
||||
|
||||
mock_final = MagicMock()
|
||||
mock_final.id = "task-final"
|
||||
mock_final.status = "completed"
|
||||
mock_final.is_preview = False
|
||||
mock_final.cover_url = ""
|
||||
|
||||
mock_video_preview = MagicMock()
|
||||
mock_video_preview.file_url = "rendered/preview/video.mp4"
|
||||
mock_video_final = MagicMock()
|
||||
mock_video_final.file_url = "rendered/final/video.mp4"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_list_videos,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
|
||||
patch(
|
||||
"app.api.routes.generation_cover._persist_cover_frame",
|
||||
return_value="https://oss.example.com/covers/c.jpg",
|
||||
),
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
# list_by_source_edit_plan 返回 [preview, final],最终成片排在后面
|
||||
mock_repo.list_by_source_edit_plan.return_value = [mock_preview, mock_final]
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
# 根据 task id 返回不同 video
|
||||
def get_videos(task_id):
|
||||
if task_id == "task-final":
|
||||
return [mock_video_final]
|
||||
return [mock_video_preview]
|
||||
|
||||
mock_use_case = MagicMock()
|
||||
mock_use_case.execute.side_effect = get_videos
|
||||
mock_list_videos.return_value = mock_use_case
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_url.side_effect = lambda key: f"https://oss.example.com/{key}"
|
||||
mock_storage_svc.public_url = "https://oss.example.com"
|
||||
mock_storage_svc.endpoint = "oss.example.com"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
|
||||
mock_mk_getter.return_value = mock_mk
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-1",
|
||||
plan_id="plan-priority",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
|
||||
# 应该使用 final video URL 抽帧,而非 preview
|
||||
call_kwargs = mock_mk.extract_frames.call_args.kwargs
|
||||
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
|
||||
assert "rendered/preview" not in call_kwargs["video_url"]
|
||||
|
||||
def test_cover_generated_video_permission_denied(self):
|
||||
"""generated_video_id 关联任务属于其他用户时,返回 403。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
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()
|
||||
|
||||
mock_gv = MagicMock()
|
||||
mock_gv.file_url = "rendered/other/video.mp4"
|
||||
mock_gv.generation_task_id = "task-other"
|
||||
mock_gv.user_id = "" # 老数据无 user_id,走关联任务归属校验
|
||||
|
||||
mock_other_task = MagicMock()
|
||||
mock_other_task.created_by_user_id = "other-user"
|
||||
|
||||
mock_gv_repo = MagicMock()
|
||||
mock_gv_repo.get.return_value = mock_gv
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="ai_frame",
|
||||
generated_video_id="gv-other",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch(
|
||||
"app.api.routes.generation_cover.get_generated_video_repository",
|
||||
return_value=mock_gv_repo,
|
||||
),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_other_task
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-1",
|
||||
plan_id="plan-perm",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_cover_video_url_ssrf_blocked(self):
|
||||
"""video_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_db = MagicMock()
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
# SSRF 攻击载荷:内网元数据地址
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="ai_frame",
|
||||
video_url="http://100.100.100.200/latest/meta-data/",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
|
||||
patch("app.api.routes.generation_cover._persist_cover_frame") as mock_persist,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
|
||||
mock_storage_svc.endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||
mock_storage_svc.get_url.side_effect = lambda k: f"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/{k}"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
|
||||
mock_mk_getter.return_value = mock_mk
|
||||
|
||||
mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": "https://mk/f.jpg"}}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
from fastapi import HTTPException
|
||||
|
||||
# 内网 URL 被白名单拦截后,无任何可用视频源 → 400(而不是向内网发请求)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-1",
|
||||
plan_id="plan-ssrf",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
# MediaKit 从未被要求抽帧该内网地址
|
||||
if mock_mk.extract_frames.called:
|
||||
called_url = mock_mk.extract_frames.call_args.kwargs.get("video_url", "")
|
||||
assert "100.100.100.200" not in called_url
|
||||
assert "meta-data" not in called_url
|
||||
|
||||
def test_cover_generated_video_ownership_unverifiable_denied(self):
|
||||
"""video 无 user_id 且关联任务不存在时,归属无法确认 → 403(防权限绕过)。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
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()
|
||||
|
||||
mock_gv = MagicMock()
|
||||
mock_gv.file_url = "rendered/mystery/video.mp4"
|
||||
mock_gv.generation_task_id = "task-gone" # 关联任务已删除
|
||||
mock_gv.user_id = "" # 老数据无 owner
|
||||
|
||||
mock_gv_repo = MagicMock()
|
||||
mock_gv_repo.get.return_value = mock_gv
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="ai_frame",
|
||||
generated_video_id="gv-mystery",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch(
|
||||
"app.api.routes.generation_cover.get_generated_video_repository",
|
||||
return_value=mock_gv_repo,
|
||||
),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None # 关联任务查不到
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-1",
|
||||
plan_id="plan-orphan",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=mock_current_user,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_is_trusted_media_url_blocks_internal_and_ipv6(self):
|
||||
"""白名单函数:内网 IPv4/IPv6/元数据地址一律拒绝,自家 OSS 域名放行。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import _is_trusted_media_url
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
|
||||
mock_storage.endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||
|
||||
with patch(
|
||||
"app.api.routes.generation_cover.get_shared_storage_service",
|
||||
return_value=mock_storage,
|
||||
):
|
||||
# 内网 / 元数据 / IPv6 本地地址全部拒绝
|
||||
for bad in [
|
||||
"http://127.0.0.1/admin",
|
||||
"http://10.0.0.5/video.mp4",
|
||||
"http://192.168.1.1/video.mp4",
|
||||
"http://172.16.0.1/video.mp4",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]:8080/video.mp4",
|
||||
"http://[fe80::1]/video.mp4",
|
||||
"http://[fc00::1]/video.mp4",
|
||||
"http://localhost/x",
|
||||
"ftp://oss-cn-hangzhou.aliyuncs.com/a.mp4",
|
||||
"",
|
||||
]:
|
||||
assert _is_trusted_media_url(bad) is False, f"应拒绝: {bad}"
|
||||
|
||||
# 自家 OSS 域名(含签名 URL 子路径、bucket 域名)放行
|
||||
for good in [
|
||||
"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/final/v.mp4",
|
||||
"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/v.mp4?Expires=123&Signature=abc",
|
||||
]:
|
||||
assert _is_trusted_media_url(good) is True, f"应放行: {good}"
|
||||
|
||||
def test_is_trusted_media_url_endpoint_with_scheme_parsed(self):
|
||||
"""endpoint 配置带 http:// 前缀时也能正确提取主机名,不出现 .http 后缀绕过。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import _is_trusted_media_url
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.public_url = "http://oss.internal.example.com:9000"
|
||||
mock_storage.endpoint = "http://oss.internal.example.com:9000"
|
||||
|
||||
with patch(
|
||||
"app.api.routes.generation_cover.get_shared_storage_service",
|
||||
return_value=mock_storage,
|
||||
):
|
||||
# 正确域名放行
|
||||
assert _is_trusted_media_url("http://oss.internal.example.com:9000/a/b.mp4") is True
|
||||
# 伪造后缀域名必须拒绝(修复前 split(':')[0] 会取到 'http' 导致绕过)
|
||||
assert _is_trusted_media_url("http://evil-http.com/x.mp4") is False
|
||||
assert _is_trusted_media_url("http://evil.http/x.mp4") is False
|
||||
|
||||
Reference in New Issue
Block a user